バッチのデプロイフローを整備しているときに思いついた方法があり、それでうまくいくか検証してみたメモ。場合によってはアンチパターンかもしれない。

以下、「タグ」は「Gitタグ」と「dockerのイメージタグ」の2種類ある。わかりにくいのでできるだけ明示して書く。

やりたいこと

GitHub ActionsだけでECSタスクのデプロイ&ロールバックができるようにしたい。
コマンドを打ちたくない。AWSコンソールにログインするのも面倒。

また、ロールバックのときはコードを変更したくない(git revertしたくない)。前にデプロイした内容をそのまま再デプロイしたい。

状況・前提

  • 定期的に実行するバッチをECS on Fargateで動かしている(ECSタスク)
    常時稼働ではない。
  • デプロイでは ECRリポジトリへのイメージpushCloudFormationのデプロイ を行う
    アプリケーション部分はdockerイメージに入っている。CloudFormationではdockerイメージのバージョンを変更したりする。
  • dockerイメージをlatestタグで運用していない
    latest運用はアンチパターンらしい。(参考: latestタグ絶対禁止!?ECRでコンテナイメージタグの変更禁止設定がサポートされました! | DevelopersIO)
    ECRリポジトリでイメージタグの変更不可設定ができるので、そうしている。
  • デプロイに数分程度かかっても許容できる
    「1秒でもサービス停止するのは許容できない」というような場合は、今回の方法は使えない。
  • 「GitHubでGitタグをつけたときにデプロイする」というフローが可能
    もしかしたら権限的にNGな場合があるかも。

流れ

  1. GitHubで v1.0 みたいなGitタグをつけるとGitHub Actionsが発火
  2. 環境変数 IMAGE_TAG にGitタグ v1.0 が入る
    dockerイメージタグとしてGitタグをそのまま使う。
  3. AWS認証
  4. ECRへのデプロイ
    1. ECRにログイン
      これでECRリポジトリにpushできるようになる。
    2. ECRリポジトリに IMAGE_TAG がついたイメージが既に存在するか調べる
    3. 上記イメージが存在しないならdockerイメージをbuildしてpush
      存在する場合は何もしない。
  5. CloudFormationスタックのデプロイ
    このとき IMAGE_TAG を渡し、ECSタスクではそのイメージタグのdockerイメージを使用させる。

上のようにすれば、ロールバックのときは過去のバージョンのGitHub Actionsワークフローを実行し直すだけでいい。そのときは既にECRリポジトリにdockerイメージがあるためpushは省略され、CloudFormationのデプロイだけが実行される。

以降は長々と書いているが、この方法でうまくいくかの検証。結論としてはうまくいった。

書いたGitHub Actionsのyaml

リポジトリはsample_github_actions_ecs_rollback。(ロール関連はREADMEにあまり書かなかったので、実際に試すのは難しいかも。)

全体はここにあるので、分割しながら説明してみる。

発火条件、環境変数

v* のパターンのGitタグがついたら発火する。全体で使用する環境変数を定義しておく。

name: Deployment workflow

on:
  push:
    tags:
      - 'v*'

env:
  AWS_REGION: ap-northeast-1
  ECR_REPOSITORY: sample_ga_ecs_rollback/app_image

各種設定、チェックアウト

いちおうタイムアウトを設定しておく。 permissionsはAWS認証用(後述)。

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      id-token: write
      contents: read

    steps:
      - name: Checkout
        uses: actions/checkout@v2

Gitタグを環境変数に保存

Gitタグをそのままdockerイメージへのタグにするので保存しておく。

Gitタグ名は $GITHUB_REF_NAME で取れる。(ここ参照)
コマンド実行結果を環境変数に入れたいときは $GITHUB_ENV に追加すればいい。(ここ参照)


      - name: Set ENV (image tag)
        run: |
          echo "IMAGE_TAG=$GITHUB_REF_NAME" >> $GITHUB_ENV

AWS認証(OpenID Connect)

ECRリポジトリにpushしたり、AWS CLIを使ったりするので、デプロイ用のロールを認証しておく。
いちいちアクセスキーIDなどを作ったりGitHubに預けたりしたくないので、OpenID Connectによる認証をする。 MY_DEPLOYMENT_ROLE_ARN はGitHub Actionsのsecretsに保存している、デプロイ用ロールのARN。

比較的新しい機能だけどここらへんは本題ではないので省略。以下を参照。

      - name: Configure AWS credentials by OpenID Connect
        uses: aws-actions/configure-aws-credentials@v1
        with:
          role-to-assume: ${{ secrets.MY_DEPLOYMENT_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

ECRリポジトリへのpush

ここは工夫した。

aws-actions/amazon-ecr-loginを使ってECR認証(docker loginする)。
次に EXISTS_IN_ECR という環境変数を作る。これはこれからpushしたいイメージタグが既にECRリポジトリに存在するかどうかの値。これを取得するのがやや複雑。存在する場合は TAG_IN_ECR にそのイメージタグ名が入り、存在しない場合は空文字。これを判定して EXISTS_IN_ECR に値を入れている。falseのときだけで十分。
最後にECRリポジトリにdockerイメージをpush。 if を使って、ECRリポジトリに該当イメージタグが存在しないときだけpushする。

このように EXISTS_IN_ECR を作るステップと、ECRへpushするステップを別にする必要はないかもしれない。ただifを見るとpushのステップの意図が理解しやすいと思ったので分けた。

EXISTS_IN_ECR を取得する部分は以下を参考にした。

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - name: Set ENV (check if the image tag already exists in ECR)
        run: |
          TAG_IN_ECR=$(aws ecr list-images --repository-name $ECR_REPOSITORY --query "imageIds[?imageTag=='${IMAGE_TAG}'].imageTag" --output text)
          if [ "$TAG_IN_ECR" = '' ]; then
            echo "EXISTS_IN_ECR=false" >> $GITHUB_ENV
          fi
      - name: Build, tag, and push image to Amazon ECR
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        if: ${{ env.EXISTS_IN_ECR == 'false' }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG code
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

CloudFormationデプロイ

AWS CLIを実行して、ECSタスクなどを作成する。
このとき IMAGE_TAG を渡すことで、使用するdockerイメージのタグを伝える。
MY_EXECUTION_ROLE_ARNMY_TASK_ROLE_ARN はECSタスクを作る際に必要なロールARNで、これらもGitHub Actionsのsecretsに保存している。

      - name: Deploy ECS task & Log Group by CloudFormation
        env:
          MY_EXECUTION_ROLE_ARN: ${{ secrets.MY_EXECUTION_ROLE_ARN }}
          MY_TASK_ROLE_ARN: ${{ secrets.MY_TASK_ROLE_ARN }}
        run: |
          ./infra/sh/02-task.sh $IMAGE_TAG

./infra/sh/02-task.sh というのはこれ。 aws cloudformation deploy している。

#!/bin/sh

cd `dirname $0` || exit 1

PROJECT_NAME=SampleGAEcsRollback
IMAGE_VERSION=$1

# Environment variables below will be given by GitHub Actions.
#
# MY_EXECUTION_ROLE_ARN
# MY_TASK_ROLE_ARN

aws cloudformation deploy --template-file "../cfn/task.yaml" \
                          --stack-name "$PROJECT_NAME-Task" \
                          --parameter-overrides \
                            ProjectName="$PROJECT_NAME" \
                            MyExecutionRoleArn="$MY_EXECUTION_ROLE_ARN" \
                            MyTaskRoleArn="$MY_TASK_ROLE_ARN" \
                            ImageVersion="$IMAGE_VERSION"

本当にこれでうまくいくのか検証

デプロイは問題ないと思うが、本当にGitHub Actionsでロールバックされるのかを知りたい。

確認するため、ECSタスクでは2つの環境変数を出力するようにした(app.sh参照)。
ENV_IMAGE はDockerfileで設定する環境変数、 ENV_CFN はGitタグを読んでCloudFormationで設定する環境変数。これらをデプロイのたびに変えて、意図した値になれば正常に動作していると判断できる。

まず v1.1 をデプロイし、次に v1.2 をデプロイする。そのあとで v1.1 にロールバックする。

v1.1をデプロイ

DockerfileENV_IMAGEv1.1 にしてcommitし、push。
そのあとGitHubでReleaseを作成する。このときRelease作成画面でGitタグを作れるので、 v1.1 で作成。
できたReleaseがこれ

Gitタグ v1.1 がついたため、GitHub Actionsワークフローが走ってデプロイされる。

デプロイ完了後、AWSコンソールからECSタスクを実行して、CloudWatch Logsに出力される環境変数を見る。
ちょっと出力がややこしいが、以下は v1.1 が入っているということ。

ENV_IMAGE = This is ENV_IMAGE:v1.1
ENV_CFN = This is ENV_CFN:v1.1

v1.2をデプロイ

上と同じようにDockerfileを修正してcommit/pushし、同じ手順でRelease v1.2 を作成。(これ)
GitHub Actionsワークフローが走り、デプロイされる。

再びECSタスクを実行。環境変数には v1.2 が入ってる。

ENV_IMAGE = This is ENV_IMAGE:v1.2
ENV_CFN = This is ENV_CFN:v1.2

v1.1にロールバック

GitHub Actionsの画面に行き、 v1.1 のワークフローをRe-run。既に v1.1 のイメージタグは存在するので、ECRリポジトリへのpushはスキップされた。

ECSタスクを実行。

ENV_IMAGE = This is ENV_IMAGE:v1.1
ENV_CFN = This is ENV_CFN:v1.1

意図した通り、 v1.1 のバージョンが再びデプロイされている。

おわりに

なんかきれいにできてしまったせいで、何を悩んでいたのかがよくわからなくなってしまった。

元々何で悩んでいたのかを書いておく。どうやってロールバックを簡単にするかで悩んでいた。
GitHub Actionsでデプロイは簡単にできるようになった。でも問題が起きてロールバックしたいときにはCloudShellとかに入ってコマンドを打たないといけない。またはgit revertしてデプロイしないといけない。
あるときGitHub Actionsで実行済みワークフローをRe-runできることに気付き、これを使えばロールバックも簡単にできそうだと思った。ただECRリポジトリに既存のイメージタグのついたdockerイメージをpushするのはそのままやるとエラーになるので、既に存在する場合は何もしないという条件分岐を入れた。それと、GitHub Actionsが本当にそのGitタグの状態でデプロイするのか確認したかった。 v1.1 で発火したワークフローは、その後コミットが積み上がっても v1.1 に紐付いたままなのか確信が持てなかった。
(検証し忘れていたが、もしタグで発火するのではなくmainブランチへのマージで発火するようになっていたらこの方法はできないのではという気がする。コミットが積み上がればmainブランチの内容は変わり、過去のワークフローだとしても現在のmainブランチに対して行われるはず。それとも特定のコミットに紐づいたまま?
→ [2022/06/02 追記] 勘違いしていた。ワークフローは特定のコミットに紐付く。なのでブランチへのマージを発火条件としても同じようにできるはず。)

結局この方法でうまくいきそうなので一安心。