CircleCIとecspressoによるECSへのデプロイメントパイプライン

こんにちは。SREチームの山根(@fumikony)です。
このブログでも東口(@hgsgtk)が何度か紹介している即時資金調達できる金融サービス「YELL BANK(エールバンク)」のインフラまわりに関わっています。

今回は、YELL BANKのデプロイメントパイプラインを構築したときの工夫などを紹介します。

インフラ構成

インフラ構成図
インフラ構成図

まず大まかなインフラ構成について説明します。上の図は構成図です。
YELL BANKではGo言語でAPIサーバを開発しており、ビルドしたコンテナをECSのFargateモードを使って動かしています。
コンテナレジストリにはAWSのECRを使っています。
また複数AWSアカウント構成をとっていて、本番・ステージング・開発の各環境ごとに個別のAWSアカウントを用意しています。
今回のデプロイ機構ではこれらの環境のうち本番環境(prd)およびステージング環境(stg)を対象としました。
GitリポジトリにはGitHub、CIにはCircleCIを使っています。

デプロイ

ECSデプロイツールにはkayac/ecspressoを採用しました。
GitHub上のmasterブランチへのマージをトリガーとして、CircleCI内からecspressoを実行することでECSへのデプロイを実現しています。

以下、このデプロイ機構について説明していきます。

ecspressoについて

ecspressoはGo言語製のECSデプロイツールです。
今回ecspressoを採用した理由は主に2つあります。

1つは、aws ecs describe-task-definitionで出力されるjsonを、そのままecspressoで使うことができるという点です。これによって、まずECSのマネジメントコンソール上で試行錯誤してタスク定義を作り、それをそのままecspressoで使うという事ができます。これはECSの経験が浅い身としてはとても便利でした。

もう1つはテンプレート機能の存在です。これは、タスク定義のjson内に

{{ env `FOO` `bar` }}

という記述があると、その部分を環境変数FOOで置き換えるというものです。barFOOがなかった場合のデフォルト値です。また、

{{ must_env `FOO` }}

という記述の場合はFOOが無いとエラーになります。
この機能を使って、タスク定義内部で以下のように記述しました。

taskdef.json

{
    "taskDefinition": {
        "containerDefinitions": [
            {
                "name": "example",
                "image": "{{must_env `AWS_ACCOUNT_ID`}}.dkr.ecr.ap-northeast-1.amazonaws.com/basebank/example:{{env `D_TAG` `latest`}}",

これによって2つのことが実現できました。

  1. 環境変数 D_TAG によって、dockerイメージのタグをデプロイ実行時に指定します(省略時は latest)。
  2. 環境変数 AWS_ACCOUNT_ID によって環境ごとの差を吸収します。こうすることで、prdとstgのタスク定義を一括で管理できます。

なお、これらの機能はecspressoのREADMEで説明されているので、あわせて参照いただければと思います。

CircleCI設定

いきなりですが、今回作成した.circleci/config.ymlです。縦に長いですがご容赦ください。

.circleci/config.yml

# https://circleci.com/orbs/registry/orb/circleci/slack
# https://circleci.com/docs/2.0/env-vars/#using-bash_env-to-set-environment-variables

version: 2.1

orbs:
  slack: circleci/slack@2.0.0

commands:
  install_awscli:
    steps:
      - run: |
          sudo apt-get install python-pip
          sudo pip install awscli

  setenv_docker_tag:
    steps:
      - run: echo "export DOCKER_TAG=${CIRCLE_SHA1:0:8}" >> $BASH_ENV

  setenv_stg:
    steps:
      - run: echo "export AWS_ACCESS_KEY_ID=XXXXXXXXXXXXXXXXXXXX" >> $BASH_ENV
      - run: echo "export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY_STG" >> $BASH_ENV
      - run: echo "export ECR_ENDPOINT='yyyyyyyyyyyy.dkr.ecr.ap-northeast-1.amazonaws.com'" >> $BASH_ENV

  setenv_prd:
    steps:
      - run: echo "export AWS_ACCESS_KEY_ID=YYYYYYYYYYYYYYYYYYYY" >> $BASH_ENV
      - run: echo "export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY_PRD" >> $BASH_ENV
      - run: echo "export ECR_ENDPOINT='zzzzzzzzzzzz.dkr.ecr.ap-northeast-1.amazonaws.com'" >> $BASH_ENV

jobs:
  test:
    docker:
      - image: circleci/golang
    steps:
      - checkout
      # (略)

  build: ## (B)
    docker:
      - image: circleci/golang
    steps:
      - slack/notify:
          message: "begin build"
      - checkout
      - setup_remote_docker
      - install_awscli
      - run:
          name: docker build
          command: |
            make build

      - setenv_docker_tag

      - setenv_stg
      - run:
          name: stg docker tag & docker push
          command: |
            $(aws ecr get-login --no-include-email --region ap-northeast-1)
            docker tag "basebank/example" "${ECR_ENDPOINT}/basebank/example:latest"
            docker push "${ECR_ENDPOINT}/basebank/example:latest"
            docker tag "basebank/example" "${ECR_ENDPOINT}/basebank/example:${DOCKER_TAG}"
            docker push "${ECR_ENDPOINT}/basebank/example:${DOCKER_TAG}"

      - setenv_prd
      - run:
          name: prd docker tag & docker push
          command: |
            $(aws ecr get-login --no-include-email --region ap-northeast-1)
            docker tag "basebank/example" "${ECR_ENDPOINT}/basebank/example:latest"
            docker push "${ECR_ENDPOINT}/basebank/example:latest"
            docker tag "basebank/example" "${ECR_ENDPOINT}/basebank/example:${DOCKER_TAG}"
            docker push "${ECR_ENDPOINT}/basebank/example:${DOCKER_TAG}"

  deploy-stg: ## (C)
    docker:
      - image: circleci/golang
    steps:
      - slack/notify:
          message: "begin deploy-stg"
      - checkout
      - setup_remote_docker
      - install_awscli
      - setenv_docker_tag
      - setenv_stg
      - deploy:
          name: deploy-stg
          command: |
            make deploy-stg D_TAG=$DOCKER_TAG
      - slack/status

  deploy-prd: ## (C)
    docker:
      - image: circleci/golang
    steps:
      - slack/notify:
          message: "begin deploy-prd"
      - checkout
      - setup_remote_docker
      - install_awscli
      - setenv_docker_tag
      - setenv_prd
      - deploy:
          name: deploy-prd
          command: |
            make deploy-prd D_TAG=$DOCKER_TAG
      - slack/status

workflows:  ## (A)
  version: 2

  test-build-deploy:
    jobs:
      - test

      - build:
          requires:
            - test
          filters:
            branches:
              only: master

      - deploy-stg:
          requires:
            - build
          filters:
            branches:
              only: master

      - slack/approval-notification:
          message: 'prdにデプロイするにはApproveが必要です'
          mentions: '<!here>'
          requires:
            - deploy-stg
          filters:
            branches:
              only: master

      - approve-deploy-prd:
          type: approval
          requires:
            - deploy-stg
          filters:
            branches:
              only: master

      - deploy-prd:
          requires:
            - approve-deploy-prd
          filters:
            branches:
              only: master

以下、工夫したポイントを解説していきます。

(A): workflows

デプロイの流れがtest-build-deployというワークフローに定義されています。
GitHubのmasterブランチにfeatureブランチがマージされると、以下のような流れでデプロイが進んでいきます。

  1. テスト
  2. ビルド
  3. stgへのデプロイ
  4. Manual Approval
  5. prdへのデプロイ

Manual ApprovalというのはCircleCIの機能で、ワークフローの次のjobに行く前に手動による承認を要求するというものです。
これをワークフローに挟み込んでおくことで、stgでの確認を終えるまでprdへのデプロイを待たせることができます。
また、ワークフロー内にslack/approval-notification というものがありますが、これはCircleCIのSlack Orbを利用したもので、以下のようなSlack通知を簡単に出すことができます。(@が重なっているのは、Orbが@hereに対応していないところに強引に入れたため)

Slack通知
Slack Orbによる通知

(B): build

このjobでは、docker build、docker tag、docker pushを行っています。
この際、stgで動作確認したイメージそのものをprdにもデプロイしたいので、同一job内でstgとprd両方のECRに対してdocker pushしています。
また、dockerのtagとしては、gitのコミットハッシュの先頭8桁を使うようにしました。こうしておくことで、どのコミットからビルドされたイメージなのかわかります。

(C): deploy-stg, deploy-prd

各環境へのデプロイを実行するjobです。
具体的なデプロイコマンドはMakefileにラップして、例えばstgであれば make deploy-stg D_TAG=$DOCKER_TAG のようにしています。この D_TAG というのはecspressoに渡すための環境変数です。
Makefileのターゲットとしては以下のようになっています。

deploy-stg: bin/ecspresso
  AWS_ACCOUNT_ID=yyyyyyyyyyyy \
  ./bin/ecspresso deploy --config=config/stg.yml

deploy-prd: bin/ecspresso
  AWS_ACCOUNT_ID=zzzzzzzzzzzz \
  ./bin/ecspresso deploy --config=config/prd.yml

Makefileにラップしておくことで、いざというときには手元からもデプロイしやすくなっています。

おわりに

CircleCIとecspressoを用いて、ECSへのデプロイメントパイプラインを構築しました。
しばらく使ってみるうちにいくつか気になるところも出てきたので、引き続き改良していこうと思っています。

また、今回ひととおりのECSデプロイを組んでみて、ECS自体にも慣れることができました。
次の機会があればCodePipelineによるBlueGreenデプロイメントを使うパターンなども試してみて、今回作ったものとの比較できればと考えています。そのときにはまた本ブログで紹介しますので、お楽しみに。