EC2インスタンスをAWS CDKとGitLab CIでCI/CDする

AWS FargateやAWS Lambdaなど、サーバレスな基盤活用が積極的に行われている昨今、EC2インスタンスがステートレスな場面で使われる機会は減ってきているのかなと思います。しかし、要件によってはEC2インスタンスを使わざるを得ない場面がまだあります。

例えばサーバレスあるいはクラウドネイティブな基盤では、IPアドレスは一時的なリソースであり、変わってしまうものであると考えるのが普通です。ところがアプリが接続する先のセキュリティ要件によって、ソースIPが固定であることを要求される場合もあるでしょう。そのようなときにはまだまだEC2インスタンスにEIPをアタッチして使う、昔ながらの構成が必要になります。

今回はそんなステートレスなワークロードであるにも関わらずEC2インスタンスを使わなくてはならないときに、dockerコンテナのようにImmutableにCI/CDする例をご紹介いたします。なお、CI/CDにはGitLab CI、プロビジョニングには皆様大好物であろうAWS CDKを使ってみたいと思います。

# 今回実現することの具体例
今回は対向システムがセキュリティによって特定の固定IPのみを接続許可していることを要件とします。このためECSタスクのような動的IPをもつリソースではなく、EC2インスタンス + EIPで固定IPを付与することを考えます。

![無題のプレゼンテーション (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151015/a5114735-d310-6937-26c3-720b8fa850ac.png)

上記の要件を満たしかつ、GitLab CIによってBlue/Greenデプロイメントを実現します。この際にAWS CDKを用いてEC2インスタンスのプロビジョニングを行います。

![無題のプレゼンテーション (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151015/597631db-be5c-22db-3c30-cef171f48715.png)

# 今回作ったコード
GitHubに公開しておりますので、細かいところはソースコードをご覧ください :bow:
[https://github.com/bbrfkr/ec2-nginx-cdk-template](https://github.com/bbrfkr/ec2-nginx-cdk-template)

# 簡単に解説...

## CI/CD-buildステージ
まずはコードの静的解析を以下のように行います。ここではblack、flake8、mypyを実行していますね。

```yml:.gitlab-ci.yml
qualities_test:
  stage: build
  image: python:3.7
  before_script:
  - pip3 install pipenv
  - cd cdk
  - pipenv install --system --dev
  script:
  - black .
  - flake8 .
  - mypy --config ./setup.cfg .
  except:
  - tags
```

静的解析を通過したらdockerコンテナイメージをビルドします。EC2インスタンスをプロビジョニングしますが、nginxの起動はEC2インスタンス内部でdockerコンテナを起動することにより行いました。
トピックブランチが切られたときはコンテナイメージのビルドをし、実際にコンテナを起動してnginxのconfigが妥当なものかを確認しています。masterブランチにトピックブランチがマージされたときにはトピックブランチと同様の作業後、実際にコンテナレジストリに検証デプロイ用のイメージをpushします。タグが切られたときには既にテストを通過したコミットを利用するため、コンテナイメージのテストは省略して本番デプロイ用のイメージをビルド、レジストリにpushします。

```yml:.gitlab-ci.yml
.build_template: &build_template
  stage: build
  image: docker:latest
  variables:
    <<: *aws_environments
    TARGET_HOST: google.com
    RESOLVER_IP: 8.8.8.8
    DOCKER_HOST: tcp://docker:2375/
    DOCKER_DRIVER: overlay2
    DOCKER_TLS_CERTDIR: ""
  services:
  - docker:dind

dev_build:
  <<: *build_template
  before_script:
  - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
  - cd docker
  - docker pull $CI_REGISTRY_IMAGE:latest || true
  - >
    docker build
    --cache-from $CI_REGISTRY_IMAGE:latest
    --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
  - >
    docker run
    -e TARGET_HOST=$TARGET_HOST
    -e RESOLVER_IP=$RESOLVER_IP
    $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    nginx -T
  except:
  - master
  - tags

master_build:
  <<: *build_template
  before_script:
  - apk update && apk add python3
  - pip3 install awscli
  - eval $(aws ecr get-login --no-include-email);
  - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY  
  script:
  - cd docker
  - docker pull $CI_REGISTRY_IMAGE:latest || true
  - >
    docker build
    --cache-from $CI_REGISTRY_IMAGE:latest
    --tag $CI_REGISTRY_IMAGE:latest
    --tag $ECR_REGISTRY_IMAGE:latest .
  - >
    docker run
    -e TARGET_HOST=$TARGET_HOST
    -e RESOLVER_IP=$RESOLVER_IP
    $CI_REGISTRY_IMAGE:latest
    nginx -T  
  - docker push $CI_REGISTRY_IMAGE:latest
  - docker push $ECR_REGISTRY_IMAGE:latest
  only:
  - master

release_build:
  <<: *build_template
  before_script:
  - apk update && apk add python3
  - pip3 install awscli
  - eval $(aws ecr get-login --no-include-email);
  - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
  - cd docker
  - docker pull $CI_REGISTRY_IMAGE:latest || true
  - >
    docker build
    --cache-from $CI_REGISTRY_IMAGE:latest
    --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
    --tag $ECR_REGISTRY_IMAGE:$CI_COMMIT_TAG .
  - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
  - docker push $ECR_REGISTRY_IMAGE:$CI_COMMIT_TAG
  only:
  - tags
```

## CI/CD-provisionステージ
EC2インスタンスのプロビジョンはAWS CDKにおまかせです。デプロイ環境を環境変数`ENVIRONMENT`で渡して、`cdk deploy`コマンドを叩いて終わり！ です。

```yml:.gitlab-ci.yml
.aws_environments: &aws_environments
  AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
  AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY
  AWS_DEFAULT_REGION: $AWS_DEFAULT_REGION
  ECR_REGISTRY_IMAGE: $ECR_REGISTRY_IMAGE
  NGINX_STACK_PREFIX: ec2-nginx

.provision_template: &provision_template
  stage: provision
  image: python:3.7
  before_script:
  - apt update
  - apt install -y nodejs npm
  - pip3 install pipenv
  - cd cdk
  - npm install -g aws-cdk
  - pipenv install --system
  script:
  - cdk deploy

master_provision:
  <<: *provision_template
  variables:
    <<: *aws_environments
    ENVIRONMENT: dev
  only:
  - master

release_provision:
  <<: *provision_template
  variables:
    <<: *aws_environments
    ENVIRONMENT: prod
  only:
  - tags
```

## CI/CD-deployステージ
デプロイはプロビジョニングされたEC2インスタンス上で正常にnginxが稼働していることを確認し、人間の手で承認をして行います。一度承認してしまえば新しいTarget GroupにALBの向き先とEIPのアタッチインスタンスが切り替わるようになっています。

```yml:.gitlab-ci.yml
.deploy_template: &deploy_template
  stage: deploy
  image: python:3.7
  before_script:
  - apt update
  - pip3 install awscli
  script:
  - >
    ALLOCATION_ID=$(
    aws ec2 describe-addresses
    --public-ips ${NGINX_ELASTIC_IP}
    --query Addresses[].AllocationId 
    --output text
    );
    NEW_NGINX_INSTANCE_ID=$(
    aws cloudformation describe-stacks
    --stack-name ${NEW_STACK_NAME}
    --query "Stacks[0].Outputs[?OutputKey=='NginxInstanceId'].OutputValue"
    --output text
    );
    NEW_NGINX_TARGETGROUP_ARN=$(
    aws cloudformation describe-stacks
    --stack-name ${NEW_STACK_NAME}
    --query "Stacks[0].Outputs[?OutputKey=='NginxTargetGroupArn'].OutputValue"
    --output text
    );
  - >
    aws ec2 associate-address
    --instance-id ${NEW_NGINX_INSTANCE_ID}
    --allocation-id ${ALLOCATION_ID}
    --allow-reassociation &&
    aws elbv2 modify-listener
    --listener-arn ${NGINX_ALB_LISTENER_ARN}
    --default-actions Type=forward,TargetGroupArn=${NEW_NGINX_TARGETGROUP_ARN}

master_deploy:
  <<: *deploy_template
  variables:
    <<: *aws_environments
    NGINX_ELASTIC_IP: $NGINX_ELASTIC_IP_DEV
    NGINX_ALB_LISTENER_ARN: $NGINX_ALB_LISTENER_ARN_DEV
    NEW_STACK_NAME: ${NGINX_STACK_PREFIX}-${CI_COMMIT_SHA}
  when: manual
  allow_failure: false
  only:
  - master

release_deploy:
  <<: *deploy_template
  variables:
    <<: *aws_environments
    NGINX_ELASTIC_IP: $NGINX_ELASTIC_IP_PROD
    NGINX_ALB_LISTENER_ARN: $NGINX_ALB_LISTENER_ARN_PROD
    NEW_STACK_NAME: ${NGINX_STACK_PREFIX}-${CI_COMMIT_TAG}
  when: manual
  allow_failure: false
  only:
  - tags
```

## CI/CD-cleanupステージ
最後が泥沼です… プロビジョニングやデプロイが途中で失敗したときを考慮して、クリーンアップ処理を自動化します。
正常にデプロイが完了した場合には、CDKが作成した古いCloudFormationスタックを消し、EIPのタグに新しいスタックの名前を付与します。このタグは次回のデプロイ時に、どのスタックが現在アクティブなのかを示す重要な役割を担います。先の「古いCloudFormationスタックを消し」といった作業ができるのも、このタグが古いスタック名を保持しているからです。
デプロイが失敗した場合は、EIPのタグから古いスタック名を参照し、古いスタック内のTarget GroupおよびEC2インスタンスにALBをEIPがそれぞれ向くように切り戻します。その後、新しく作られたスタックを削除します。

```yml:.gitlab-ci.yml
.cleanup_template: &cleanup_template
  stage: cleanup
  image: python:3.7
  before_script:
  - apt update
  - pip3 install awscli

# if deploying is succeeded, delete old stack and modify eip tag.
.cleanup_old_script: &cleanup_old_script
# get old stack name
- >
  ALLOCATION_ID=$(
  aws ec2 describe-addresses
  --public-ips ${NGINX_ELASTIC_IP}
  --query Addresses[].AllocationId 
  --output text
  );
  OLD_STACK_NAME=$(
  aws ec2 describe-tags
  --filter "Name=key,Values=AssociateStackName"
  --query "Tags[?ResourceId=='$ALLOCATION_ID'].Value"
  --output text
  )
# delete old stack
- |
  if [ -n "$OLD_STACK_NAME" ] ; then
    aws cloudformation delete-stack --stack-name ${OLD_STACK_NAME}
  fi
# add new stack name tag to eip
- >
  aws ec2 delete-tags
  --resources ${ALLOCATION_ID}
  --tags Key=AssociateStackName ;
  aws ec2 create-tags
  --resources ${ALLOCATION_ID} 
  --tags Key=AssociateStackName,Value=${NEW_STACK_NAME}

# if deploying is failed, recover eip direction and
# listener default target group, then delete new stack.
.cleanup_new_script: &cleanup_new_script
# get old stack name
- >
  ALLOCATION_ID=$(
  aws ec2 describe-addresses
  --public-ips ${NGINX_ELASTIC_IP}
  --query Addresses[].AllocationId 
  --output text
  );
  OLD_STACK_NAME=$(
  aws ec2 describe-tags
  --filter "Name=key,Values=AssociateStackName"
  --query "Tags[?ResourceId=='$ALLOCATION_ID'].Value"
  --output text
  );
- >
  OLD_NGINX_INSTANCE_ID=$(
  aws cloudformation describe-stacks
  --stack-name ${OLD_STACK_NAME}
  --query "Stacks[0].Outputs[?OutputKey=='NginxInstanceId'].OutputValue"
  --output text
  );
  OLD_NGINX_TARGETGROUP_ARN=$(
  aws cloudformation describe-stacks
  --stack-name ${OLD_STACK_NAME}
  --query "Stacks[0].Outputs[?OutputKey=='NginxTargetGroupArn'].OutputValue"
  --output text
  );
- >
  aws ec2 associate-address
  --instance-id ${OLD_NGINX_INSTANCE_ID}
  --allocation-id ${ALLOCATION_ID}
  --allow-reassociation ;
  aws elbv2 modify-listener
  --listener-arn ${NGINX_ALB_LISTENER_ARN}
  --default-actions Type=forward,TargetGroupArn=${OLD_NGINX_TARGETGROUP_ARN}
- |
  aws cloudformation describe-stacks --stack-name ${NEW_STACK_NAME} 2>&1 | grep "does not exist"
  if [ $? -eq 0 ]; then
    echo "cleanup target does not exist."
  else
    aws cloudformation delete-stack --stack-name ${NEW_STACK_NAME}
  fi

master_cleanup_old:
  <<: *cleanup_template
  variables:
    <<: *aws_environments
    NGINX_ELASTIC_IP: $NGINX_ELASTIC_IP_DEV
    NEW_STACK_NAME: ${NGINX_STACK_PREFIX}-${CI_COMMIT_SHA}
  script: *cleanup_old_script
  when: on_success
  only:
  - master

master_cleanup_new:
  <<: *cleanup_template
  variables:
    <<: *aws_environments
    NGINX_ELASTIC_IP: $NGINX_ELASTIC_IP_DEV
    NGINX_ALB_LISTENER_ARN: $NGINX_ALB_LISTENER_ARN_DEV
    NEW_STACK_NAME: ${NGINX_STACK_PREFIX}-${CI_COMMIT_SHA}
  script: *cleanup_new_script
  when: on_failure
  only:
  - master

release_cleanup_old:
  <<: *cleanup_template
  variables:
    <<: *aws_environments
    NGINX_ELASTIC_IP: $NGINX_ELASTIC_IP_PROD
    NEW_STACK_NAME: ${NGINX_STACK_PREFIX}-${CI_COMMIT_TAG}
  script: *cleanup_old_script
  when: on_success
  only:
  - tags

release_cleanup_new:
  <<: *cleanup_template
  variables:
    <<: *aws_environments
    NGINX_ELASTIC_IP: $NGINX_ELASTIC_IP_PROD
    NGINX_ALB_LISTENER_ARN: $NGINX_ALB_LISTENER_ARN_PROD
    NEW_STACK_NAME: ${NGINX_STACK_PREFIX}-${CI_COMMIT_TAG}
  script: *cleanup_new_script
  when: on_failure
  only:
  - tags
```

## AWS CDK-app.py
次にAWS CDKのコードを見ていきましょう。pythonで書いていきます。
環境変数に`ENVIRONMENT`を`export`して`cdk deploy`すると、各環境にあわせたconfigを読み取ってプロビジョニングを行うようにしています。

```py:app.py
...
from nginx_ec2.nginx_ec2_stack import NginxEc2Stack

...

with open(f"config/{os.environ.get('ENVIRONMENT')}/nginx-ec2.yaml") as f:
    config = yaml.safe_load(f)

if os.environ.get("ENVIRONMENT") == "dev":
    stack_suffix = os.environ.get("CI_COMMIT_SHA", default="dev")
elif os.environ.get("ENVIRONMENT") == "prod":
    stack_suffix = os.environ.get("CI_COMMIT_TAG", default="dev")
else:
    stack_suffix = "dev"

NginxEc2Stack(app, f"{os.environ.get('NGINX_STACK_PREFIX')}-{stack_suffix}", config)
...
```

## AWS CDK-nginx-ec2-stack.py
実際にCloudFormationスタックを作成するコードがこちらです。
UserDataのJinja2テンプレート`user_data.txt.j2`を予め`config`ディレクトリ内に入れておきます。Jinja2テンプレートにしているのは、configで受け取ったパラメータに応じてnginxのデプロイ方法(主にnginxコンテナに渡す環境変数)を変えたいからです。configで受け取ったその他のパラメータはスタックリソースのパラメータにそれぞれ代入していきます。

```py:nginx-ec2-stack.py
...
class NginxEc2Stack(core.Stack):
    def __init__(self, scope: core.Construct, id: str, config: dict, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        with open(f"config/{os.environ.get('ENVIRONMENT')}/user_data.txt.j2", "r") as f:
            template = Template(f.read())
            user_data = base64.encodestring(
                template.render(env=os.environ, config=config, stack=id).encode("utf8")
            ).decode("ascii")

        nginx_ec2 = CfnInstance(
            self,
            "NginxEc2",
            block_device_mappings=config.get("block_device_mappings"),
            iam_instance_profile=config.get("iam_instance_profile"),
            image_id=config.get("image_id"),
            instance_type=config.get("instance_type"),
            key_name=config.get("key_name"),
            network_interfaces=config.get("network_interfaces"),
            user_data=user_data,
            tags=config.get("tags"),
        )

        nginx_ec2.cfn_options.creation_policy = CfnCreationPolicy(
            resource_signal=CfnResourceSignal(count=1, timeout="PT5M")
        )

        nginx_targetgroup = CfnTargetGroup(
            self,
            "NginxTargetGroup",
            vpc_id=config.get("tg_vpc_id"),
            protocol=config.get("tg_protocol"),
            port=config.get("tg_port"),
            health_check_enabled=True,
            health_check_protocol=config.get("tg_healthcheck_protocol"),
            health_check_port=config.get("tg_health_check_port"),
            health_check_path=config.get("tg_healthcheck_path"),
            health_check_timeout_seconds=config.get("tg_health_check_timeout_seconds"),
            health_check_interval_seconds=config.get(
                "tg_health_check_interval_seconds"
            ),
            healthy_threshold_count=config.get("tg_healthy_threshold_count"),
            unhealthy_threshold_count=config.get("tg_unhealthy_threshold_count"),
            matcher=config.get("tg_matcher"),
            tags=config.get("tg_tags"),
            target_group_attributes=config.get("tg_target_group_attributes"),
            targets=[
                CfnTargetGroup.TargetDescriptionProperty(
                    id=nginx_ec2.ref, port=config.get("tg_target_port")
                )
            ],
        )
...
```

# まとめ
このようにEC2インスタンス、即ち仮想マシンでもImmutableにCI/CDすることができます。これはつまり仮想マシンでもクラウドネイティブっぽい使い方ができる良い例ではないかと思います。真にクラウドネイティブになるためには固定IPからの呪縛に打ち勝つ必要がありますが…
CDK、便利で楽しいので是非活用してみてくださいね :smile: