AWS
deploy
GitLab
GitLab-CI
ECS
iRidgeDay 7

ECS + GitLab CIでカナリアリリースみたいなことを実現する

この記事はiRidge Advent Calendar 2018 の7日目の記事です。

tl;dr;

私の運用しているシステムではGitLab CI + ansibleを利用して、リリースの度にEC2を半自動+手順書で作り変える運用をしているんですが、どうしてもリリースに時間がかかってしまうため、アプリケーションのコンテナ化 + コンテナ向けのデプロイの仕組みづくりを推進することになりました。
その中でECSとGitLab CIを用いたカナリアリリースの仕組み等を考えて検証を行ったのでこの記事ではそれについて紹介していきます。

はじめに

デプロイの全体の流れ的なところを中心に書いていきます。ECSよりGitLab CIの要素が強めです。
AWSのECSクラスタの構築の仕方等いろいろ省略してたりするので読みにくいかもしれませんが、ご容赦ください。

カナリアリリースとは

本番環境に部分的に新しいバージョンのアプリケーションをデプロイし、カナリア環境のアプリケーションに問題ないことを確認してから全体の更新を行なうリリース手法のことです。
下記にカナリアリリースのコンセプト等が書かれています。
https://cloudplatform-jp.googleblog.com/2017/04/how-release-canaries-can-save-your-bacon-CRE-life-lessons.html

作りたい仕組みの全体像

カナリアデプロイ計画.jpg

AWS側の構成

検証として、ECSクラスタ(EC2)を作っておきます。検証としてnginxのTaskを利用するのでALBとそれに紐づくTargetGroupも用意しました。
ECSへTaskをデプロイするためのツールとしてはecs-cliを利用することにしました。採用の経緯はdocker-composeのような形でECSのServiceの更新ができ、個人的に使いやすいと思ったためです。

GitLab CIで準備するJob

GitLab CIに次のbuild,canary-deploy,deployの3つのjobを定義したパイプラインを作ります。それぞれの役割は下記のような内容になっています。(商用に適用する際はアプリケーションのユニットテストやら別のjobも入ってくると思います。)
パイプラインはリポジトリでタグを作成したタイミングで生成され、build jobまでは自動実行、deploy系のjobはマニュアル実行という形を想定しています。

  • build
    • docker buildによるimageの生成、Registryへのpushを行なう。
    • 自動実行
  • canary-deploy
    • ECSのカナリア用のServiceにデプロイを行なう
    • まず、こっちのjobを実行して、カナリア用のServiceに新しいバージョンのタスクを配置
    • 実行はGitLab CI の when: manualを利用した手動実行
  • deploy
    • ECSの本番用のServiceにデプロイを行なう
    • カナリアリリースの内容が問題なかったら実行
    • 実行はGitLab CI の when: manualを利用した手動実行

ディレクトリの構成

.
├── .gitignore
├── .gitlab-ci.yml
├── Dockerfile # テスト用のnginxファイル
├── conf
│   └── test.conf # テスト用のnginx.conf
├── deploy
│   ├── docker-compose.yml
│   └── ecs-params.yml
└── html
    └── index.html # nginxで表示させるindex.html

ecs-cliに渡すファイルの設定

docker-compose.yml
version: '3'
services:
  canary-test:
    container_name: canary-test
    image: <Registryのpath>:${CI_COMMIT_TAG}
    environment:
      - TZ=Asia/Tokyo
    ports:
      - "80"
    restart: always
    logging:
      driver: awslogs
      options:
        awslogs-region: ap-northeast-1
        awslogs-group: /ecs/canary-test
ecs-params.yml
version: 1
task_definition:
  task_execution_role: TaskExecRole
  ecs_network_mode: bridge
  task_size:
    cpu_limit: 256
    mem_limit: 512
  services:
    canary-test:
      essential: True
      repository_credentials:
        credentials_parameter: "arn:aws:secretsmanager:ap-northeast-1:xxxxxxxxxxxxx:secret:ecs-canary-poc2-1ax2py"

今回はGitLabのDocker Registryを使ったので、Registryにアクセスするための情報をSecretsManagerに登録しました。

GitLab CIの設定

.gitlab-ci.ymlに下記のようにjobの定義をしました。

.gitlab-ci.yml
image: docker:latest
services:
   - docker:dind

stages:
  - build
  - deploy

tag-build:
  stage: build
  only:
    - tags
    - triggers
  before_script:
    - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
  tags:
    - docker

canary-deploy:
  stage: deploy
  variables:
    AWS_REGION: ap-northeast-1 # AWS region
    AWS_ACCOUNT_ID: xxxxxxxxxxxx # AWSのアカウントID
    AWS_ACCESS_KEY_ID: $POC_AWS_ACCESS_KEY_ID # GitLab CIのSecret variableから取得
    AWS_SECRET_ACCESS_KEY: $POC_AWS_SECRET_ACCESS_KEY # GitLab CIのSecret variableから取得
    AWS_TARGET_GROUP_NAME: ecs-canary-test-poc # target groupの名前
    AWS_TARGET_GROUP_ID: yyyyyyyyyyyyyyyy # target groupのID
    AWS_ECS_CLUSTER_NAME: ecs-canary-test-poc-svc-cluster # ECSのクラスタ名
    AWS_TASK_ROLE_NAME: TaskExecRole # Taskに割り当てるIAMRole名
    ENVIRONMENT: poc
    CONTAINER_NAME: canary-test
    CONTAINER_PORT: 80
    DEPLOYMENT_MAX_PARCENT: 100
    DEPLOYMENT_MIN_PARCENT: 0
    SERVICE_NAME: canary-release # ECSのService名
  before_script: &deploy_before_script
    - apk -v --update add python py-pip groff less mailcap curl
    - pip install --upgrade awscli s3cmd python-magic
    - aws sts get-caller-identity # 正しいIAMを読み込めているかを確認
    - curl -o /usr/local/bin/ecs-cli https://s3.amazonaws.com/amazon-ecs-cli/ecs-cli-linux-amd64-latest
    - chmod 755 /usr/local/bin/ecs-cli
    - ecs-cli -v
  script: &deploy_script
    - echo ecs-cli compose -p ${SERVICE_NAME} -f deploy/docker-compose.yml --ecs-params deploy/ecs-params.yml --task-role-arn arn:aws:iam::${AWS_ACCOUNT_ID}:role/${AWS_TASK_ROLE_NAME} service up --target-group-arn arn:aws:elasticloadbalancing:ap-northeast-1:${AWS_ACCOUNT_ID}:targetgroup/${AWS_TARGET_GROUP_NAME}/${AWS_TARGET_GROUP_ID} --container-name ${CONTAINER_NAME} --container-port ${CONTAINER_PORT} --cluster ${AWS_ECS_CLUSTER_NAME} --deployment-max-percent ${DEPLOYMENT_MAX_PARCENT} --deployment-min-healthy-percent ${DEPLOYMENT_MIN_PARCENT}
    - ecs-cli compose -p ${SERVICE_NAME} -f deploy/docker-compose.yml --ecs-params deploy/ecs-params.yml --task-role-arn arn:aws:iam::${AWS_ACCOUNT_ID}:role/${AWS_TASK_ROLE_NAME} service up --target-group-arn arn:aws:elasticloadbalancing:ap-northeast-1:${AWS_ACCOUNT_ID}:targetgroup/${AWS_TARGET_GROUP_NAME}/${AWS_TARGET_GROUP_ID} --container-name ${CONTAINER_NAME} --container-port ${CONTAINER_PORT} --cluster ${AWS_ECS_CLUSTER_NAME} --deployment-max-percent ${DEPLOYMENT_MAX_PARCENT} --deployment-min-healthy-percent ${DEPLOYMENT_MIN_PARCENT}
  tags:
    - docker
  environment:
    name: canary-poc
  only:
    - tags
  when: manual

deploy:
  stage: deploy
  variables:
    AWS_REGION: ap-northeast-1 # AWS region
    AWS_ACCOUNT_ID: xxxxxxxxxxxx # AWSのアカウントID
    AWS_ACCESS_KEY_ID: $POC_AWS_ACCESS_KEY_ID # GitLab CIのSecret variableから取得
    AWS_SECRET_ACCESS_KEY: $POC_AWS_SECRET_ACCESS_KEY # GitLab CIのSecret variableから取得
    AWS_TARGET_GROUP_NAME: ecs-canary-test-poc # target groupの名前
    AWS_TARGET_GROUP_ID: yyyyyyyyyyyyyyyy # target groupのID
    AWS_ECS_CLUSTER_NAME: ecs-canary-test-poc-svc-cluster # ECSのクラスタ名
    AWS_TASK_ROLE_NAME: TaskExecRole # Taskに割り当てるIAMRole名
    ENVIRONMENT: poc
    CONTAINER_NAME: canary-test
    CONTAINER_PORT: 80
    DEPLOYMENT_MAX_PARCENT: 200
    DEPLOYMENT_MIN_PARCENT: 100
    SERVICE_NAME: release # ECSのService名
  before_script: *deploy_before_script
  script: *deploy_script
  tags:
    - docker
  environment:
    name: poc
  only:
    - tags
  when: manual

個人的なポイントとしては、GitLab CIのEnvironmentsを利用している点です。deploy系のjobにそれぞれenvironmentを定義しておくことで、カナリア用にデプロイしたアプリケーションに問題があった場合、1つ前のバージョンに戻すことが容易になります。
https://docs.gitlab.com/ce/ci/environments.html

上記を設定して、リポジトリにタグを作成すると下記のようなパイプラインが作成されます。
デプロイするときは、canary-deployのjobを実行し様子を見て問題ないことが確認できたらdeployのjobを実行していく想定です。
※ 初回デプロイ時はTaskが1つしか作成されないので手動でスケールさせる必要があります。2回目以降はその時定義されているTask数を維持したまま更新する挙動になっています。

スクリーンショット 2018-12-06 19.44.38.png

更新をしてみる

nginxのv1のコンテナがデプロイされた状況から、カナリアデプロイ -> 通常デプロイ の順で更新をしてみようと思います。ALBにアクセスすると下記のようなページが表示される状況です。

スクリーンショット 2018-12-06 19.58.43.png

ECSは下記のような状態で、canary用のServiceは1 Task,通常用のServiceは3 Taskという状況で更新してみます。
スクリーンショット 2018-12-06 20.00.58.png

  1. nginxで表示させるindex.html内のアプリケーションバージョンをv2に変更し、pushします。
  2. バージョン2.0.0のタグを作成します。するとタグの内容をデプロイするパイプラインが構築されます。
    スクリーンショット 2018-12-06 20.07.57.png

  3. canary-deployをマニュアル実行して、カナリア用のサービスを2.0.0に更新します。

    • 完了後にALBにアクセスすると、4回に1回(タスクが全部で4つあるので)は下記のような新しいバージョンが表示されるようになります。

    スクリーンショット 2018-12-06 20.12.31.png

  4. deployをマニュアル実行して、全体のバージョンを2.0.0に更新します。

    • 完了後にALBにアクセスすると、新しいバージョンのみが表示される様になります。

ロールバックをしてみる

ロールバックには先程書いたように、GitLab CIのEnvironmentsを使います。CI/CD > Environmentsに移動すると下記のようなページが表示されます。

スクリーンショット 2018-12-06 20.20.11.png

Environmentの詳細に移動すると下記のようにデプロイの履歴が表示されます。
この一覧の戻したいバージョンの右側にあるRollbackボタンを押すと、そのバージョンをデプロイするためのjobが実行され、特定のバージョンに戻すことができます。

スクリーンショット 2018-12-06 20.22.34.png

まとめ

ECSのServiceを分離して、それぞれにデプロイするjobをGitLab CIで準備することで、カナリアリリースっぽいこと実現することができました。GitLab CIの仕組みをうまく活用することで、ロールバック等も比較的容易に行えそうかなと思っています。今回は同一のALBのTargetGroupを利用してしまいましたが、分離しておくことでカナリア部分のみの応答状況をCloudWatchで見れそうなので、そういった細かい点の見直しをしていきたいです。
今後はカナリアリリース後、エラーレートとかを見て問題なければ自動でリリースを続ける仕組みとか、新しいバージョンの割合を徐々に増やしていくような仕組みがあったらいいなーとか考えています。以上です。