ECSタスク関連のデプロイとロールバックをGitHub Actionsだけで完結する
2022-05-15
- プログラミング
バッチのデプロイフローを整備しているときに思いついた方法があり、それでうまくいくか検証してみたメモ。場合によってはアンチパターンかもしれない。
以下、「タグ」は「Gitタグ」と「dockerのイメージタグ」の2種類ある。わかりにくいのでできるだけ明示して書く。
やりたいこと
GitHub ActionsだけでECSタスクのデプロイ&ロールバックができるようにしたい。
コマンドを打ちたくない。AWSコンソールにログインするのも面倒。
また、ロールバックのときはコードを変更したくない(git revertしたくない)。前にデプロイした内容をそのまま再デプロイしたい。
状況・前提
- 定期的に実行するバッチをECS on Fargateで動かしている(ECSタスク)
常時稼働ではない。 - デプロイでは ECRリポジトリへのイメージpush と CloudFormationのデプロイ を行う
アプリケーション部分はdockerイメージに入っている。CloudFormationではdockerイメージのバージョンを変更したりする。 - dockerイメージをlatestタグで運用していない
latest運用はアンチパターンらしい。(参考: latestタグ絶対禁止!?ECRでコンテナイメージタグの変更禁止設定がサポートされました! | DevelopersIO)
ECRリポジトリでイメージタグの変更不可設定ができるので、そうしている。 - デプロイに数分程度かかっても許容できる
「1秒でもサービス停止するのは許容できない」というような場合は、今回の方法は使えない。 - 「GitHubでGitタグをつけたときにデプロイする」というフローが可能
もしかしたら権限的にNGな場合があるかも。
流れ
- GitHubで
v1.0
みたいなGitタグをつけるとGitHub Actionsが発火 - 環境変数
IMAGE_TAG
にGitタグv1.0
が入る
dockerイメージタグとしてGitタグをそのまま使う。 - AWS認証
- ECRへのデプロイ
- ECRにログイン
これでECRリポジトリにpushできるようになる。 - ECRリポジトリに
IMAGE_TAG
がついたイメージが既に存在するか調べる - 上記イメージが存在しないならdockerイメージをbuildしてpush
存在する場合は何もしない。
- ECRにログイン
- 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。
比較的新しい機能だけどここらへんは本題ではないので省略。以下を参照。
- aws-actions/configure-aws-credentials
- Configuring OpenID Connect in Amazon Web Services - GitHub Docs
- GitHub ActionsにAWSクレデンシャルを直接設定したくないのでIAMロールを利用したい | DevelopersIO
- 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
を取得する部分は以下を参考にした。
- list-images — AWS CLI 1.23.10 Command Reference
- Check if Docker image exists with tag in AWS ECR · GitHub
- 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_ARN
と MY_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をデプロイ
Dockerfileで ENV_IMAGE
を v1.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 追記] 勘違いしていた。ワークフローは特定のコミットに紐付く。なのでブランチへのマージを発火条件としても同じようにできるはず。)
結局この方法でうまくいきそうなので一安心。