【GitHub Actions】AWS 公式のアクションを使わないで Fargate の B/G Deploy 機構を用意してみる

こんにちは。お久しぶりです。
今年の春に受験した IPA のネットワークスペシャリスト試験に合格しているのを確認し、ホっと胸をなで下ろしている今日この頃です。

さて、ちょうど AWS が Fargate ネイティブの Blue/Green デプロイを公式サポートしたところではありますが、普段 CodeDeploy と AWS 公式の GitHub Actions を使って組んでいるデプロイ機構に関して、あえて公式のアクション ( amazon-ecs-deploy-task-definition 等 ) を使わずに実装してみようと思います。
※ シェルと CLI で頑張る?という謎な縛りで実装する備忘録になります。いったい何の役に立つのやら。

構成

以下の図のように GitHub Actions でコンテナイメージのビルド、及び ECR への PUSH と、タスク定義登録、CodeDeploy のキックを行います。
今回は主に GitHub Actions の WorkFlow の処理に着目してみます。

詳細な処理のシーケンスは以下の通りです。
① ビルドしたコンテナイメージを ECR に PUSH
② タスク定義を編集し、新規登録
③ AppSpec の内容を更新し、CodeDeploy を起動
④ Green のターゲットグループ内で新イメージを元に ECS タスクが起動
⑤ ALB からの同線を Green に切り替え

Actions ( steps )

⓪ 事前処理

リポジトリのコードをチェックアウトし、IAM Role から一時的な認証情報を取得します。
ECR へのログインも済ませておきます。
※ ココだけ AWS 公式のアクションを使います。( 早速のレギュ違反 )

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:

      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4.0.2
        with:
          role-to-assume: ${{ inputs.role-arn }}
          aws-region: ${{ inputs.region }}

      - name: Login to Amazon ECR
        uses: aws-actions/amazon-ecr-login@v2.0.1

① ビルドしたコンテナイメージを ECR に PUSH

Docker のコンテナイメージをビルドし、ビルドしたイメージへタグを付与、そして ECR へ PUSH します。

      - name: Build and push the image
        env:
          IMAGE_URI: ${{ inputs.image-uri }}
          IMAGE_TAG: ${{ inputs.github-sha }}
        run: |
          docker build -f ./Dockerfile -t src_image .
          docker tag src_image ${IMAGE_URI}:${IMAGE_TAG}
          docker push ${IMAGE_URI}:${IMAGE_TAG}

※ タグ ( IMAGE_TAG ) には コミット SHA を使用する想定です。

on:
  workflow_call:
    inputs:
      github-sha:
        required: false
        default: ${{ github.sha }}
        type: string

② タスク定義を編集し、新規登録

① で PUSH したイメージでタスクが起動するように、タスク定義の内容を更新し、AWS へ登録します。
ここで登録したタスク定義の ARN は後続の処理で使う想定です。

      - name: Edit taskdefinition
        env:
          IMAGE_URI: ${{ inputs.image-uri }}
          IMAGE_TAG: ${{ inputs.github-sha }}
          TASK_DEF_FILE: ${{ inputs.task-def-file }}
        run: |
          sed -i "s#<IMAGE_TAG>#${IMAGE_TAG}#g" ${TASK_DEF_FILE}
          sed -i "s#<IMAGE_URI>#${IMAGE_URI}#g" ${TASK_DEF_FILE}
          cat ${TASK_DEF_FILE}

      - name: Registe taskdefinition
        id: register-task
        env:
          IMAGE_URI: ${{ inputs.image-uri }}
          IMAGE_TAG: ${{ inputs.github-sha }}
          TASK_DEF_FILE: ${{ inputs.task-def-file }}
        run: |
          TASK_DEF_ARN=$(aws ecs register-task-definition \
            --cli-input-json file://${TASK_DEF_FILE} \
            --query 'taskDefinition.taskDefinitionArn' --output text)
          echo "task_definition_arn=${TASK_DEF_ARN}" >> $GITHUB_OUTPUT

③ AppSpec の内容を更新し、CodeDeploy を起動

AppSpec テンプレートファイル ( ※① ) にある プレースホルダを、登録したタスク定義の ARN に置き換えます。
置き換えた結果を appspec-tmp.yml という一時的なファイルとして保存します。

CodeDeploy に渡すリビジョン JSON 構築のため、先のステップで作った appspec-tmp.yml を jq コマンドで JSON 文字列へ変換しつつ appSpecContent の値として埋め込みます。

最後に aws deploy create-deployment を実行し、 CodeDeploy による ECS デプロイを開始します。

      - name: Replace task definition ARN in AppSpec
        env:
          APPSPEC_FILE: ${{ inputs.appspec-file }}
        run: |
          sed "s|<TASK_DEFINITION>|${{ steps.register-task.outputs.task_definition_arn }}|g" ${APPSPEC_FILE} > ./appspec-tmp.yml

      - name: Create CodeDeploy revision JSON
        id: revision-json
        run: |
          APPSPEC=$(cat ./appspec-tmp.yml | jq -Rs .)
          REVISION='{"revisionType":"AppSpecContent","appSpecContent":{"content":'${APPSPEC}'}}'
          echo "revision_json=${REVISION}" >> $GITHUB_OUTPUT

      - name: Create deployment
        id: create-deployment
        env:
          CODEDEPLOY_APP_NAME: ${{ inputs.codedeploy-application }}
          CODEDEPLOY_DEPLOY_GROUP: ${{ inputs.codedeploy-deployment-group }}
          GITHUB_SHA: ${{ inputs.github-sha }}
        run: |
          DEPLOYMENT_ID=$(aws deploy create-deployment \
            --application-name ${CODEDEPLOY_APP_NAME} \
            --deployment-group-name ${CODEDEPLOY_DEPLOY_GROUP} \
            --revision '${{ steps.revision-json.outputs.revision_json }}' \
            --description "Deploy from commit ${GITHUB_SHA}" \
            --output text)
          echo "deployment_id=${DEPLOYMENT_ID}" >> $GITHUB_OUTPUT
          echo "Started deployment: ${DEPLOYMENT_ID}"

※① AppSpec テンプレートファイルのサンプルは以下の通りです。

AppSpec のサンプル
version: 0.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: <TASK_DEFINITION>
        LoadBalancerInfo:
          ContainerName: "snkk1210-sandbox-hoge-hoge01"
          ContainerPort: 80
        PlatformVersion: "1.4.0"

④ / ⑤

後続の処理は CodeDeploy が代行してくれるので、WorkFlow 上では wait を使って、処理完了まで待機します。
必要な処理はココまでです。

      - name: Wait for deployment to finish and show URL
        env:
          REGION: ${{ inputs.region }}
        run: |
          DEPLOYMENT_ID="${{ steps.create-deployment.outputs.deployment_id }}"
          echo "View deployment in AWS Console:"
          echo "https://${REGION}.console.aws.amazon.com/codesuite/codedeploy/deployments/${DEPLOYMENT_ID}?region=${REGION}"
          echo "Waiting for deployment to complete..."
          aws deploy wait deployment-successful --deployment-id "$DEPLOYMENT_ID"

終わりに

冒頭でも触れたとおり、Fargate ネイティブの Blue/Green デプロイが公式サポートされたようで、これからは CodeDeploy を使ったデプロイ機構が非推奨になる?ようです。
将来的に公式アクションもサポートされなくなる可能性があるかも?と思い、今回、中身の代替手段を調べた次第でした。

【CI/CD】tfnotify と GitHub App で Terraform の CI 機構を作ってみる【CodeBuild】

こんにちは。お久しぶりです。
新しい会社に移ってから、約半年が経過しました。
周りの皆様はとてもレベルが高く、大変素晴らしい環境でお仕事をさせていただいております。
本当に心の底から転職して良かったな、と感じる今日この頃です。

さて、本日は、とあるシステムの構築に携わらせていただき、Terraform の CI 機構を CodeBuild / GitHub App / tfnotify で作る機会があったので、その際の備忘録をツラツラと残しておこうと思います。

作るもの

構成

Terraform のコード自体は GitHub のリポジトリでホストされていることを前提とします。
Pull Request の作成、更新時に CodeBuild を Hook して Terraform を実行し、plan 結果を Pull Request のコメントとして通知します。
詳細なシーケンスは以下の通りです。

① PR の作成、更新をトリガーに CodeBuild を起動。
② CodeBuild が SSM Parameter Store に格納された GitHub App の秘密鍵を取得
③ GitHub App の秘密鍵から JWT を生成し、 API を叩いて Installation ID を生成
④ Installation ID を指定して API を叩き、Access Token を生成
⑤ terraform plan を実行
⑥ plan 結果を tfnotify に渡して Pull Request に通知
⑦ Access Token を無効化

ディレクトリ構成

.
├── deploy_scripts
│   └── ci
│       ├── .tfnotify.yml
│       ├── bin
│       │   ├── create_github_token.sh
│       │   ├── install_terraform.sh
│       │   ├── install_tfnotify.sh
│       │   ├── plan_tfnotify.sh
│       │   └── revoke_github_token.sh
│       └── buildspec.yml
└── env
    └── dev
        ├── resource1
        │   ├── 00_backend.tf
        │   ├── 00_provider.tf
        │   └── vpc.tf
        ├── resource2
        │   ├── 00_backend.tf
        │   ├── 00_provider.tf
        │   └── vpc.tf
        └── resource3
            ├── 00_backend.tf
            ├── 00_provider.tf
            └── vpc.tf

Terraform のコードは /env/dev/resource[1 – 3] 配下の 3 つをエントリーポイントとするディレクトリ構成を前提とします。

また、/deploy_scripts/ci/bin 配下に GitHub App より Token を発行するスクリプトや、Terraform の実行結果を通知するためのユーティリティスクリプトを配置します。

buildspec.yml

CodeBuild の処理を定義する buildspec.yml は以下の通りです。

---
version: 0.2
  
phases:
  install:
    runtime-versions:
      golang: latest
    commands:
      - /bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/install_terraform.sh
      - /bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/install_tfnotify.sh
  pre_build:
    commands:
      - export APP_ID=$(aws ssm get-parameter --name ${APP_ID_SSM_ARN} --with-decryption --query Parameter.Value --output text)
      - export APP_SECRET=$(aws ssm get-parameter --name ${APP_SECRET_SSM_ARN} --with-decryption --query Parameter.Value --output text)
      - export GITHUB_TOKEN=$(/bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/create_github_token.sh)
      - export REPO_OWNER=$(echo $REPO_FULL_NAME | cut -d '/' -f 1)
      - export REPO_NAME=$(echo $REPO_FULL_NAME | cut -d '/' -f 2)
      - sed -i "s/<OWNER>/$REPO_OWNER/g; s/<NAME>/$REPO_NAME/g" ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/.tfnotify.yml
      - cd ${CODEBUILD_SRC_DIR}
      - terraform fmt -no-color -check -diff -recursive
  build:
    commands:
      - |
        if git --no-pager diff origin/main..HEAD --name-only | grep -E '^env/dev/resource1/'; then
          /bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/plan_tfnotify.sh /env/dev/resource1/
        fi
  
      - |
        if git --no-pager diff origin/main..HEAD --name-only | grep -E '^env/dev/resource2/'; then
          /bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/plan_tfnotify.sh /env/dev/resource2/
        fi
        
      - |
        if git --no-pager diff origin/main..HEAD --name-only | grep -E '^env/dev/resource3/'; then
          /bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/plan_tfnotify.sh /env/dev/resource3/
        fi
  post_build:
    commands:
      - /bin/bash ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/bin/revoke_github_token.sh

CodeBuild の処理自体は、先のシーケンスと同等です。
差分のあったディレクトリの plan 結果のみを通知するように、build ステージで main ブランチとの diff を取ってから、検知したディレクトリのみ Terraform の実行と通知処理を行います。

.tfnotify.yml

GitHub への通知を行うため tfnotify を用います。
通知内容のカスタマイズも可能で、以下のように yaml ファイルへ通知内容を記述し、 CLI 実行時の引数として渡してあげます。
※ <OWNER> と <NAME> は、先の buildspec.yaml の処理 ( 16 – 18 行 ) にて適切なリポジトリ情報で置換されます。

---
ci: codebuild

notifier:
  github:
    token: $GITHUB_TOKEN
    repository:
      owner: "<OWNER>"
      name: "<NAME>"

terraform:
  plan:
    template: |
      {{ .Title }} for {{ .Message }} <sup>[CI link]( {{ .Link }} )</sup>
      {{if .Result}}
      <pre><code> {{ .Result }}
      </pre></code>
      {{end}}
      <details><summary>Details (Click me)</summary>

      <pre><code>
      {{ .Body }}
      </pre></code>
      </details>
    when_add_or_update_only:
      label: "add-or-update"
    when_destroy:
      label: "destroy"
      template: |
        ## :warning: WARNING: Resource Deletion will happen :warning:

        This plan contains **resource deletion**. Please check the plan result very carefully!
    when_plan_error:
      label: "error"

Utility scripts

CLI の導入、Token の発行、通知等々のスクリプトを以下のように用意します。
※ クリックで展開します。

install_terraform.sh
#!/bin/bash

####################################
# This script is used by CodeBuild #
####################################

set -euxo pipefail

TFVERSION="1.7.3"

git clone https://github.com/tfutils/tfenv.git $HOME/.tfenv
ln -s $HOME/.tfenv/bin/* /usr/local/bin
tfenv install ${TFVERSION} && tfenv use ${TFVERSION}
terraform version
install_tfnotify.sh
#!/bin/bash

####################################
# This script is used by CodeBuild #
####################################

set -euxo pipefail

TNVERSION="v0.8.0"

wget https://github.com/mercari/tfnotify/releases/download/${TNVERSION}/tfnotify_linux_amd64.tar.gz
tar xzf tfnotify_linux_amd64.tar.gz
cp tfnotify /usr/local/bin/
tfnotify
plan_tfnotify.sh
#!/bin/bash

####################################
# This script is used by CodeBuild #
####################################

set -euxo pipefail

echo "Run Terraform plan in ${CODEBUILD_SRC_DIR}/${1}/."

cd ${CODEBUILD_SRC_DIR}/${1}/ && \
terraform init && \
terraform plan -no-color | tfnotify --config ${CODEBUILD_SRC_DIR}/deploy_scripts/ci/.tfnotify.yml plan --message "${1}"

echo "Terraform plan executed in ${CODEBUILD_SRC_DIR}/${1}/."
create_github_token.sh
#!/bin/bash

####################################
# This script is used by CodeBuild #
####################################

base64url() {
  openssl enc -base64 -A | tr '+/' '-_' | tr -d '='
}

sign() {
  openssl dgst -binary -sha256 -sign <(printf '%s' "${APP_SECRET}")
}

header="$(printf '{"alg":"RS256","typ":"JWT"}' | base64url)"

now="$(date '+%s')"
iat="$((now - 60))"
exp="$((now + (3 * 60)))"
template='{"iss":"%s","iat":%s,"exp":%s}'
payload="$(printf "${template}" "${APP_ID}" "${iat}" "${exp}" | base64url)"

signature="$(printf '%s' "${header}.${payload}" | sign | base64url)"

jwt="${header}.${payload}.${signature}"

installation_id="$(curl --location --silent --request GET \
  --url "https://api.github.com/repos/${REPO_FULL_NAME}/installation" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${jwt}" \
  | jq -r '.id'
)"

token="$(curl --location --silent --request POST \
  --url "https://api.github.com/app/installations/${installation_id}/access_tokens" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${jwt}" \
  | jq -r '.token'
)"

echo "${token}"
revoke_github_token.sh
#!/bin/bash

####################################
# This script is used by CodeBuild #
####################################

curl --location --silent --request DELETE \
  --url "https://api.github.com/installation/token" \
  --header "Accept: application/vnd.github+json" \
  --header "X-GitHub-Api-Version: 2022-11-28" \
  --header "Authorization: Bearer ${GITHUB_TOKEN}"

AWS Resources

構成図にある CodeBuild 関連のリソースですが、こちら に Terraform の module を用意しました。
以下のように呼び出すことで必要なリソースがプロビジョニングされます。
※ GitHub App の ID と 秘密鍵 は SSM Parameter Store に SecureString として手動で登録しておき、その ARN を CodeBuild の環境変数 ( APP_ID_SSM_ARN, APP_SECRET_SSM_ARN ) として設定しておきます。
※ クリックで展開します。

呼び出しサンプル
module "cicd_terraform_ci_github" {
  source = "git::https://github.com/snkk1210/tf-m-templates.git//modules/aws/cicd/terraform/ci/github"

  common = {
    "project"      = "sample"
    "environment"  = "sandbox"
    "service_name" = "hcl"
    "type"         = "ci"
  }

  source_info = {
    location        = "https://github.com/<ORGANIZATION_NAME>/<REPOSITORY_NAME>.git"
    git_clone_depth = 1
    buildspec       = "./deploy_scripts/ci/buildspec.yml"
  }

  environment_variable = {
    variables = [
      {
        name  = "APP_ID_SSM_ARN"
        value = "arn:aws:ssm:ap-northeast-1:xxxxxxxxxxxx:parameter/path/to/id"
        type  = "PLAINTEXT"
      },
      {
        name  = "APP_SECRET_SSM_ARN"
        value = "arn:aws:ssm:ap-northeast-1:xxxxxxxxxxxx:parameter/path/to/secret"
        type  = "PLAINTEXT"
      },
      {
        name  = "REPO_FULL_NAME"
        value = "<ORGANIZATION_NAME>/<REPOSITORY_NAME>"
        type  = "PLAINTEXT"
      }
    ]
  }

  filter_groups = [
    {
      event_pattern = "PULL_REQUEST_CREATED"
      file_pattern  = "^env/dev/*"
    },
    {
      event_pattern = "PULL_REQUEST_UPDATED"
      file_pattern  = "^env/dev/*"
    },
    {
      event_pattern = "PULL_REQUEST_REOPENED"
      file_pattern  = "^env/dev/*"
    }
  ]
}

やってみる!!

以下のように /env/dev/resource1 配下のリソースを変更 ( Name タグの更新 ) し、Pull Request を作成します。

diff --git a/env/dev/resource1/vpc.tf b/env/dev/resource1/vpc.tf
index 5360514..415e5ae 100644
--- a/env/dev/resource1/vpc.tf
+++ b/env/dev/resource1/vpc.tf
@@ -2,6 +2,6 @@ resource "aws_vpc" "this" {
   cidr_block = "10.10.0.0/16"

   tags = {
-    Name = "dev-resource1-vpc.0.0.11"
+    Name = "dev-resource1-vpc.0.0.12"
   }
 }

CodeBuild が起動しました!

コメントが通知されました!

Terraform の実行結果も確認できました!

と、いった形で Terraform の CI 機構が完成しました!

終わりに

当初は GitHub の PAT ( Personal Access Token ) をそのまま使って機構を作ろうとしておりました。
しかし、セキュリティリスクの懸念があるのではないか?との指摘をいただいて、GitHub App から一時トークンを生成して利用する構成に変更した経緯があります。
これも新しい職場に転職したおかげで気づけた事柄ですね!!

これからも新天地で頑張っていきます!!
※ 本当に転職して良かった (歓喜)。