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を付与することを考えます。
上記の要件を満たしかつ、GitLab CIによってBlue/Greenデプロイメントを実現します。この際にAWS CDKを用いてEC2インスタンスのプロビジョニングを行います。
今回作ったコード
GitHubに公開しておりますので、細かいところはソースコードをご覧ください
https://github.com/bbrfkr/ec2-nginx-cdk-template
簡単に解説...
CI/CD-buildステージ
まずはコードの静的解析を以下のように行います。ここではblack、flake8、mypyを実行していますね。
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します。
.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
コマンドを叩いて終わり! です。
.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のアタッチインスタンスが切り替わるようになっています。
.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がそれぞれ向くように切り戻します。その後、新しく作られたスタックを削除します。
.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を読み取ってプロビジョニングを行うようにしています。
...
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で受け取ったその他のパラメータはスタックリソースのパラメータにそれぞれ代入していきます。
...
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、便利で楽しいので是非活用してみてくださいね