0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【AWS】GitHub ActionsでCI/CDを実現する方法について

0
Posted at

はじめに

こんばんは、mirukyです。

GitHubへマージしたら、AWSでもそのコードが動いていてほしいですよね。今回はGitHub Actionsで、テストからdev環境への反映までを自動化します。

反映先はEC2、ECS Fargate、Lambdaの3つです。実行先のコードが今回のコミットと一致するところまで、一緒に確かめていきましょう。

目次

  1. 今回作るCI/CD
  2. アクセスキーなしでAWSを使いましょう
  3. ロールは全部で6つ作ります
  4. アプリとワークフローを用意しましょう
  5. AWSの環境は、コンソールから作ります
  6. マージしたら、CDが動き出します
  7. EC2には、Run Commandで配置します
  8. ECS Fargateでは、ロールバックも起きました
  9. Lambdaへのデプロイ
  10. 本番で使う前に足したいこと

1. 今回作るCI/CD

1-1. ブランチからdev環境までの流れ

CI(継続的インテグレーション)は変更のたびにテストを自動で走らせ、CD(継続的デプロイメント)はテストに通ったコードを実行環境へ自動で反映します。今回のCIはfeature/**へのプッシュと、develop宛てのプルリクエスト(PR)で動きます。

featureへのプッシュ、develop宛てPR、developへのプッシュで起動するワークフローとジョブ

featureへプッシュしたときに検査するのは、そのブランチの先頭のコミットです。PRのときは少し違って、developの先頭にfeatureの変更を重ねた一時的なマージ結果を検査します。featureが呼んでいる関数をdevelop側で先に改名していた、という場合に失敗するのはマージ結果のほうだけです。

featureの先頭Fとdevelopの先頭Bから用意される一時的なマージ結果Rと、2つのCIの検査対象

CDはdevelopへのプッシュで始まります。まずverifyでテストをもう一度実行し、成功して、かつDEPLOY_ENABLEDがtrueのときだけ3つのAWSのジョブへ進みます。PRのマージで起きたプッシュでも、直接のプッシュでも同じです。

注意したいのは、PRのCIが検査したマージ結果は、あくまで仮の組み合わせだという点です。マージの方法や、その間のdevelopの更新によっては、実際のマージコミットと中身が変わることがあります。そこでCDはイベントのgithub.shaをDEPLOY_REVISIONに固定し、どのジョブもこのコミットを取得します。実行中にdevelopの先頭が進んでも、取得するのは最初に固定したコミットのままです。

実行開始後にdevelopがMからNへ進んでも、github.shaとDEPLOY_REVISIONと取得する版はMのまま

1-2. 用意するものと、作る環境

EC2とFargateは、東京リージョンに作る専用VPCのパブリックサブネットに置き、AWSのサービスへはパブリックIPv4アドレスで接続します。セキュリティグループの受信ルールは空のままにし、ロードバランサーとNAT Gatewayを使わない構成にします。

EC2とFargateのパブリックIPv4から、IGWを経由してAWSサービスの公開HTTPSエンドポイントへ向かう送信経路

外から接続する手段がないので、デプロイしたあとの確認にはAWSのAPIを使います。ECSのジョブが取得するのは稼働中のイメージと正常性で、HTTPの応答はコンテナ自身のヘルスチェックが検査します。

EC2のコマンド出力、ECSのAPIとコンテナ内ヘルスチェック、Lambdaの同期呼び出しによる確認

dev環境かどうかは専用のAWSリソースとDEPLOY_ENVIRONMENT=devという環境変数で表します。値の渡し方は実行先ごとに異なり、EC2はsystemdの設定から、ECSとLambdaはCloudFormationテンプレートのDeploymentEnvironmentから受け取ります。

ec2_command.pyとenvironment.template.yamlから、3つのアプリに設定されるdevの環境変数

手を動かすには、リポジトリへの書き込み権限と、IAMロールを作成できるAWSの権限が必要です。料金は参考リンクにまとめました。

2. アクセスキーなしでAWSを使いましょう

2-1. 信頼ポリシーは何を照合しているの?

OIDC(OpenID Connect)を使うと、configure-aws-credentialsがGitHubの署名付きトークンをSTSへ渡し、期限付きの認証情報を受け取ります。GitHubに長期のアクセスキーを置かずに済みます。友人に家の合鍵を渡しておく代わりに、来る日だけ使えるスマートロックの暗証番号を送る、みたいな感じです。

同じecsジョブで、OIDCトークン要求からAWS認証情報受領、ECS API要求までを①〜⑤で示す

ここから少しややこしいですかね。id-token: writeの権限で許されるのは、GitHubにOIDCトークンを要求するところまでです。そのトークンでロールを引き受けられるかは信頼ポリシーが、引き受けたあとにどのAWSのAPIを呼べるかは権限ポリシーが決めます。

トークン要求の権限、ロールを引き受ける信頼条件、AWS APIの権限を、それぞれの設定と照合する

信頼ポリシーではトークンのaudとsubを照合します。今回のsubには所有者IDとリポジトリIDが含まれていたので、4-3で取得した値をそのまま条件に使います。

{
  "sub": "repo:miruky@155365058/aws-github-actions-deploy-lab@1413857726:ref:refs/heads/develop",
  "aud": "sts.amazonaws.com",
  "repository_id": "1413857726",
  "repository_owner_id": "155365058"
}

<OBSERVED_SUB>と<AWS_ACCOUNT_ID>は自分の値に置き換えてください。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::<AWS_ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "<OBSERVED_SUB>"
        }
      }
    }
  ]
}

PRのイベントでは、subの末尾がpull_requestになります。マージしたときに起きるclosedイベントでも同じです。StringEqualsはsubを全文で照合するので、リポジトリIDまで同じでも、develop用の条件には一致しません。

StringEqualsはsub全文を照合し、共通のリポジトリID以降のdevelopとpull_requestの相違を確認する

そこでCDはマージによって起きるdevelopへのプッシュをきっかけに始めます。

3. ロールは全部で6つ作ります

3-1. GitHubが使うロール、AWSが使うロール

GitHubからAWSへ変更を送るジョブは3つあり、操作先ごとにデプロイ用のロールを用意します。これに、EC2のインスタンス、ECSのタスクの起動、Lambdaの関数がAWS側で使うロールを加えて、作るロールは全部で6つです。

GitHubの3ジョブが指定するデプロイロールと共通の信頼条件、AWS側の3主体が使うロールとサービスプリンシパルの対応

GitHub用の3つのロールは同じOIDCの信頼条件を持ち、どのロールを使うかはジョブのrole-to-assumeで指定します。ECSでAWS側が使うのはEcsExecutionRoleで、このロールを使うのはFargateのエージェントです。

ワークフローのrole-to-assume、タスク定義のexecutionRoleArn、省略するtaskRoleArnの指定場所と利用者

今回のアプリケーションはAWSのAPIを呼ばないので、タスク定義のtaskRoleArnは空のままにしています。起動時のイメージの取得とログの送信には、EcsExecutionRoleの権限を使います。

ECS設定のタスク実行ロールと、タスクロールが未指定の表示

画面でもタスクロールは-で、タスク実行ロールにだけロールが設定されています。ジョブがタスク定義を登録し直すときも、記録されているこのロールのARNをそのまま引き継ぎます。

3-2. PassRoleが要るのはECSのジョブだけです

iam:PassRoleは、AWSのサービスにロールを渡すための許可です。タスク定義に実行ロールのARNを書いて登録するECSのジョブにはこの許可が要るので、渡せる対象をEcsExecutionRoleのARNに、渡し先をecs-tasks.amazonaws.comに絞りました。

EcsDeployRoleによる1回のRegisterTaskDefinition要求の中で、登録の許可と実行ロールを渡すPassRoleの許可を評価する

ecsジョブは取得したタスク定義のうちimageだけを差し替えて登録します。3-1で触れたとおり、executionRoleArnは取得したときの値のまま登録の要求に入ります。

appのimageは変更し、executionRoleArnとlogConfigurationは保持して、新しいタスク定義のリビジョンを登録する

PassRoleが要るのはECSのジョブだけで、ロールを指定し直さないEC2とLambdaのジョブには付けていません。また、Resourceに指定できる対象は操作によって違うため、この例では表のように設定しています。

操作 対象の指定
ecr:GetAuthorizationToken Resource: "*"。イメージの保存・取得は別の操作でリポジトリのARNに限定
ssm:GetCommandInvocation Resource: "*"。コマンドの送信はSendCommandでインスタンスとドキュメントのARNに限定
ecs:RegisterTaskDefinition タスク定義のファミリーのARN
ecs:ListTasks Resource: "*"と、ecs:clusterの条件を併用

Resourceが"*"の操作でも、許可されるのは列挙したActionだけです。ListTasksはecs:clusterの条件で対象クラスターのARNを照合するので、別のクラスターへの要求にはこのAllow文が当てはまりません。

Resourceが"*"のAllow文で、ArnEqualsのecs:cluster条件を対象C1と別のC2の要求に照合する

4. アプリとワークフローを用意しましょう

4-1. 3つの実行先で同じJSONを返すアプリ

手元にはGit、Python 3.13、GitHub CLIを用意し、gh auth loginでリポジトリへ書き込める状態にしておきます。app.pyは3つの実行先で共通の応答を作り、コミットIDはrevision.txtから、実行先と環境名は環境変数から取得します。

最初の作業はREADME付きの非公開リポジトリの作成とクローンです。your-id/your-repositoryは自分の値に置き換えてください。

DEPLOY_LAB_REPOSITORY="your-id/your-repository"
gh repo create "$DEPLOY_LAB_REPOSITORY" --private --add-readme --clone
cd "${DEPLOY_LAB_REPOSITORY##*/}"
# 既定ブランチがmain以外の場合も、この例ではmainを使用します。
if [ "$(git branch --show-current)" != main ]; then
  git branch --move main
  git push --set-upstream origin main
  gh repo edit "$DEPLOY_LAB_REPOSITORY" --default-branch main
fi
mkdir -p .github/workflows scripts tests infra

4〜9章のコードはそれぞれ直前に書いたファイル名で保存します。全部保存したら、4-3に戻ってプッシュします。

.gitignore

# 生成した配置物や環境ごとの設定をGitの管理対象から除外します。
__pycache__/
*.pyc
build/
prepared/
revision.txt
*.zip
*.tar.gz

app.py

"""EC2、ECS、Lambdaで同じ応答を生成する処理。"""

import json
import os
from pathlib import Path


def make_response(name="miruky", target=None):
    # デプロイしたコミットと入力値を実際の応答で確認します。
    if not isinstance(name, str) or not name.strip() or len(name) > 80:
        raise ValueError("name must be a non-empty string of at most 80 characters")
    revision_file = Path(__file__).with_name("revision.txt")
    revision = revision_file.read_text().strip() if revision_file.exists() else "local"
    return {
        "message": f"Hello, {name}!",
        "revision": revision,
        "target": target or os.environ.get("DEPLOY_TARGET", "local"),
        "environment": os.environ.get("DEPLOY_ENVIRONMENT", "local"),
    }


def encode_response(name="miruky", target=None):
    return json.dumps(make_response(name, target), ensure_ascii=False).encode("utf-8")

make_response('miruky')で、revision.txtと環境変数の設定値または既定のlocalから決まる応答

挨拶のHello, miruky!は今回のコミットでも、前にデプロイしたコードでも同じ文字列です。そのためジョブではrevision、target、environmentも照合し、今回のコミットが目的の環境で動いていることを確認します。

期待するM・ec2・devと、前の版、実行先の値の相違、環境名未設定の応答を比較する

EC2とFargateではserver.pyが8080番ポートでJSONを返し、Lambdaではlambda_function.pyをindex.pyとして配置します。テストでは起動したHTTPサーバーの/healthも検査します。

HTTPはHandler.do_GETとencode_response、Lambdaはlambda_handlerを経由してmake_responseを呼ぶ

server.py、lambda_function.py、Dockerfile、テストの全文

server.py

"""EC2とFargateで待ち受けるHTTPサーバー。"""

import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

from app import encode_response, make_response


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        # 正常性の確認とアプリの応答を同じ処理で返します。
        parsed = urlparse(self.path)
        if parsed.path not in ("/", "/health"):
            self.send_error(404)
            return
        name = parse_qs(parsed.query).get("name", ["miruky"])[0]
        try:
            body = encode_response(name)
        except ValueError as error:
            body = json.dumps({"error": str(error)}).encode()
            self.send_response(400)
        else:
            self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


if __name__ == "__main__":
    print(json.dumps({"ready": make_response()}), flush=True)
    ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()

lambda_function.py

"""Lambdaの呼び出し入口。"""

import json

from app import make_response


def lambda_handler(event, context):
    # EC2とECSと同じ応答に、Lambdaでの実行を記録します。
    response = make_response(event.get("name", "miruky"), "lambda")
    print(json.dumps(response, ensure_ascii=False))
    return response

Dockerfile

# Fargateでも同じアプリを実行し、コミットIDを応答へ含めます。
FROM python:3.13-slim
WORKDIR /app
COPY app.py server.py ./
ARG REVISION
RUN printf '%s\n' "$REVISION" > revision.txt
ENV DEPLOY_TARGET=ecs PYTHONUNBUFFERED=1
EXPOSE 8080
USER 65534:65534
CMD ["python", "server.py"]

tests/test_app.py

"""公開する応答の内容と入力の拒否を確認する。"""

import json
import os
import threading
import unittest
import urllib.request
from http.server import ThreadingHTTPServer
from unittest.mock import patch

from app import encode_response, make_response
from lambda_function import lambda_handler
from server import Handler


class AppTests(unittest.TestCase):
    def test_message_and_target(self):
        # 実行先を応答から判断できることを確認します。
        with patch.dict(os.environ, {"DEPLOY_TARGET": "ec2", "DEPLOY_ENVIRONMENT": "dev"}):
            result = make_response("miruky")
        self.assertEqual(result["message"], "Hello, miruky!")
        self.assertEqual(result["target"], "ec2")
        self.assertEqual(result["environment"], "dev")
        self.assertTrue(result["revision"])

    def test_lambda_entry(self):
        self.assertEqual(lambda_handler({"name": "miruky"}, None)["target"], "lambda")

    def test_utf8_json(self):
        self.assertEqual(json.loads(encode_response("読者"))["message"], "Hello, 読者!")

    def test_invalid_names_are_rejected(self):
        for value in (None, 42, "", " ", "a" * 81):
            with self.subTest(value=value), self.assertRaises(ValueError):
                make_response(value)

    def test_health_response_has_runtime_identity(self):
        # 実際のHTTP応答へ、実行先とdev設定が届くことを確認します。
        with patch.dict(os.environ, {"DEPLOY_TARGET": "ecs", "DEPLOY_ENVIRONMENT": "dev"}):
            server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
            thread = threading.Thread(target=server.serve_forever, daemon=True)
            thread.start()
            try:
                address = "http://127.0.0.1:%d/health" % server.server_port
                with urllib.request.urlopen(address, timeout=3) as response:
                    result = json.load(response)
                    self.assertEqual(response.status, 200)
                self.assertEqual(result["message"], "Hello, miruky!")
                self.assertEqual(result["target"], "ecs")
                self.assertEqual(result["environment"], "dev")
                self.assertTrue(result["revision"])
            finally:
                server.shutdown()
                thread.join(timeout=3)
                server.server_close()

4-2. CIのワークフロー

.github/workflows/ci.yml

name: CI

# feature単体と、developへ取り込む結果をそれぞれ検査します。
on:
  push:
    branches: ['feature/**']
  pull_request:
    branches: [develop]
permissions:
  contents: read
concurrency:
  group: ci-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true
jobs:
  test:
    name: ${{ github.event_name == 'pull_request' && 'ci-pr-merge-result' || 'ci-feature-head' }}
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version: '3.13'
      - name: Record the tested revision
        run: |
          # PRでは既定のマージ結果、pushではfeatureの先頭を検査します。
          printf 'event=%s\nref=%s\nsha=%s\n' "$GITHUB_EVENT_NAME" "$GITHUB_REF" "$GITHUB_SHA"
          test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
      - run: python -m unittest discover -s tests -v
      - run: python -m compileall -q app.py server.py lambda_function.py

CIはコミットIDを記録したあと、unittestでアプリの応答を、compileallでPythonの構文を検査します。各ステップは直前のステップの成功を条件に動くので、途中で失敗するとそこで止まります。

unittestとcompileallを順に実行し、失敗した後のステップへ進まないCIの検査

ci.ymlに与える権限はcontents: readだけです。Record the tested revisionのステップでは検査する参照とコミットIDをログに出しておきます。

プッシュのfeature参照とPRのマージ結果参照で、検査するSHAが異なる実行ログ

実際のログでは、参照先がプッシュではrefs/heads/feature/develop-dev-flow、PRではrefs/pull/1/mergeで、SHAも別の値でした。PRのほうは、1-1で触れた一時的なマージ結果を検査しています。

concurrencyを設定しているので、同じPRやブランチに新しいコミットが来ると、実行中の古いCIは取り消されます。CDは別のグループなので、CIの取り消しとは関係なく進む設定です。

同じリポジトリで、featureのpushとPRは異なるconcurrencyグループになり、同じグループ内の古い実行だけを取り消す

4-3. 信頼条件に使うsubを取り出してみましょう

信頼条件に使うクレームは、手動で実行するprepare.ymlで取得します。workflow_dispatchで起動するには既定のブランチにもファイルが必要なので、mainとdevelopの両方に置き、--ref developを付けて実行します。

既定mainのワークフローファイルで手動実行を受け付け、--ref developでdevelopの版を実行して、そのrefに対応するsubを取得する

まずコードをmainへプッシュし、同じコミットからdevelopを作ります。

# 必要な全ファイルをmainへ保存し、同じコミットからdevelopを作ります。
git add .gitignore app.py server.py lambda_function.py Dockerfile tests .github scripts infra
git commit -m "Add sample app and deployment workflows"
git push origin main
git switch -c develop
git push -u origin develop

developを作ったときにもCDは動き出しますが、DEPLOY_ENABLEDがまだtrueではないので、実行されるのはverifyだけです。

.github/workflows/prepare.yml

name: Prepare template and inspect OIDC

# AWSへアクセスする前に、コードと信頼条件の材料を確認します。
on:
  workflow_dispatch:
    inputs:
      verify_aws_role:
        description: Verify the EC2 deployment role without deploying
        type: boolean
        default: false
permissions:
  contents: read
jobs:
  inspect:
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version: '3.13'
      - name: Check application behavior
        run: python -m unittest discover -s tests -v
      - name: Inspect selected OIDC claims
        run: python scripts/oidc_claims.py
      - name: Verify AWS role trust
        if: inputs.verify_aws_role
        uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: ${{ vars.EC2_DEPLOY_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}
          mask-aws-account-id: true
          retry-max-attempts: 2
      - name: Verify the assumed identity
        if: inputs.verify_aws_role
        run: |
          # デプロイを行わず、ロールの取得結果だけを確認します。
          aws sts get-caller-identity --query 'Arn' --output text

scripts/oidc_claims.py

"""GitHub Actions内で必要なOIDCクレームのみ表示する。"""

import base64
import json
import os
import urllib.request

# トークン全体、認証ヘッダー、有効期限をログへ出力しません。
url = os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"] + "&audience=sts.amazonaws.com"
request = urllib.request.Request(
    url, headers={"Authorization": "Bearer " + os.environ["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]}
)
with urllib.request.urlopen(request, timeout=30) as response:
    jwt = json.load(response)["value"]
payload = jwt.split(".")[1]
claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
print(json.dumps({key: claims[key] for key in ("sub", "aud", "repository_id", "repository_owner_id")}, indent=2))

このスクリプトはトークンをデコードするだけで、署名の検証はしません。ロールの作成と変数の登録が済んだあとにverify_aws_role=trueで実行すると、STSでロールを引き受けられるかの確認も加わります。

falseでは4つのクレームを表示し、trueでは同じ表示に加えて、ロールを引き受けたArnを確認する

# developから手動実行し、信頼条件に使うsubを取得します。
REPO='<所有者>/<リポジトリ>'
gh workflow run prepare.yml --repo "$REPO" --ref develop -f verify_aws_role=false
gh run list --repo "$REPO" --workflow prepare.yml --limit 5

ログに出すのは、subとaud、それに今回のsubに含まれる2つのIDだけで、トークン本体は伏せています。developの参照で実行して、次の出力を取得しました。

developの手動ワークフローで取得したsub、aud、リポジトリID、所有者ID

この時点ではまだロールがないのでfalseで実行し、出力されたsubを5章のGitHubSubjectに渡します。

5. AWSの環境は、コンソールから作ります

5-1. テンプレートとリソース名は先に用意しておきます

アプリの実行環境はCloudFormationのテンプレートで作り、IAMポリシーの対象はリソースの参照で絞ります。S3バケットだけは先にコンソールで作るので、スタックから適用するのはそのバケットポリシーです。

CloudFormationスタックが管理するリソースと、先に作る管理外S3バケットへArtifactBucketPolicyだけを適用する関係

パラメーターのうち、GitHubSubjectはdevelopのsubだけを、DeploymentEnvironmentはdevだけを受け付けます。

環境テンプレートの全文

infra/environment.template.yaml

# 初期環境をコンソールから作成し、アプリの更新権限を各デプロイ先へ限定します。
AWSTemplateFormatVersion: '2010-09-09'
Description: GitHub OIDC deployment to EC2, ECS Fargate and Lambda with independent roles and no inbound network access.
Metadata:
  AWSToolsMetrics:
    AWSAgentToolkit: aws-cloudformation@3
Parameters:
  ArtifactBucketName:
    Type: String
    Default: __AWSNAME_ArtifactBucket__
    AllowedPattern: 'miruky-[a-z]{16}'
    Description: Newly created private S3 bucket for the template and application artifacts.
  GitHubSubject:
    Type: String
    AllowedPattern: '^repo:.+:ref:refs/heads/develop$'
    Description: Exact sub claim observed in the develop branch of the GitHub workflow.
  DeploymentEnvironment:
    Type: String
    Default: dev
    AllowedValues: [dev]
    Description: Dedicated development environment for this example.
  LatestAmi:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64
Resources:
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.77.0.0/24
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags: [{Key: Name, Value: __AWSNAME_Vpc__}]
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags: [{Key: Name, Value: __AWSNAME_InternetGateway__}]
  GatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref Vpc
      InternetGatewayId: !Ref InternetGateway
  PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      CidrBlock: 10.77.0.0/26
      MapPublicIpOnLaunch: true
      Tags: [{Key: Name, Value: __AWSNAME_Subnet__}]
  RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags: [{Key: Name, Value: __AWSNAME_RouteTable__}]
  InternetRoute:
    Type: AWS::EC2::Route
    DependsOn: GatewayAttachment
    Properties:
      RouteTableId: !Ref RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
  SubnetRouteAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet
      RouteTableId: !Ref RouteTable
  ComputeSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    # 受信通信を許可せず、AWSサービスへ向かう送信通信だけを許可します。
    Properties:
      GroupName: __AWSNAME_SecurityGroup__
      GroupDescription: Outbound service access only
      VpcId: !Ref Vpc
      SecurityGroupIngress: []
      SecurityGroupEgress: [{IpProtocol: '-1', CidrIp: 0.0.0.0/0}]
      Tags: [{Key: Name, Value: __AWSNAME_SecurityGroup__}]
  ArtifactBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref ArtifactBucketName
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: DenyNonTLS
            Effect: Deny
            Principal: '*'
            Action: s3:*
            Resource:
              - !Sub arn:${AWS::Partition}:s3:::${ArtifactBucketName}
              - !Sub arn:${AWS::Partition}:s3:::${ArtifactBucketName}/*
            Condition:
              Bool: {aws:SecureTransport: 'false'}
  GitHubProvider:
    Type: AWS::IAM::OIDCProvider
    Properties:
      Url: https://token.actions.githubusercontent.com
      ClientIdList: [sts.amazonaws.com]
  Ec2RuntimeRole:
    Type: AWS::IAM::Role
    # Systems Managerの通信と、配置物の取得をEC2自身へ許可します。
    Properties:
      RoleName: __AWSNAME_Ec2RuntimeRole__
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal: {Service: ec2.amazonaws.com}
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !Sub arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore
      Policies:
        - PolicyName: __AWSNAME_Ec2RuntimePolicy__
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: s3:GetObject
                Resource: !Sub arn:${AWS::Partition}:s3:::${ArtifactBucketName}/releases/*
  Ec2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      InstanceProfileName: __AWSNAME_Ec2InstanceProfile__
      Roles: [!Ref Ec2RuntimeRole]
  Ec2Instance:
    Type: AWS::EC2::Instance
    DependsOn: InternetRoute
    Properties:
      ImageId: !Ref LatestAmi
      InstanceType: t3.micro
      CreditSpecification: {CPUCredits: standard}
      IamInstanceProfile: !Ref Ec2InstanceProfile
      SubnetId: !Ref PublicSubnet
      SecurityGroupIds: [!Ref ComputeSecurityGroup]
      MetadataOptions: {HttpTokens: required, HttpPutResponseHopLimit: 1}
      BlockDeviceMappings:
        - DeviceName: /dev/xvda
          Ebs: {VolumeSize: 8, VolumeType: gp3, Encrypted: true, DeleteOnTermination: true}
      Tags: [{Key: Name, Value: __AWSNAME_Ec2Instance__}]
      UserData:
        Fn::Base64: |
          #!/bin/bash
          # コードの更新前にPythonとSystems Managerの実行環境を準備します。
          set -eu
          dnf install -y python3
          systemctl enable --now amazon-ssm-agent
  Ec2DeployRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: __AWSNAME_Ec2DeployRole__
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal: {Federated: !Ref GitHubProvider}
            Action: sts:AssumeRoleWithWebIdentity
            Condition:
              StringEquals:
                token.actions.githubusercontent.com:aud: sts.amazonaws.com
                token.actions.githubusercontent.com:sub: !Ref GitHubSubject
      Policies:
        - PolicyName: __AWSNAME_Ec2DeployPolicy__
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: s3:PutObject
                Resource: !Sub arn:${AWS::Partition}:s3:::${ArtifactBucketName}/releases/*
              - Effect: Allow
                Action: ssm:SendCommand
                Resource:
                  - !Sub arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/${Ec2Instance}
                  - !Sub arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-RunShellScript
              - Effect: Allow
                Action: [ssm:GetCommandInvocation, ssm:DescribeInstanceInformation]
                Resource: '*'
  ContainerRepository:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: __AWSNAME_ContainerRepository__
      ImageTagMutability: IMMUTABLE
      EncryptionConfiguration: {EncryptionType: AES256}
      EmptyOnDelete: true
  EcsLogGroup:
    Type: AWS::Logs::LogGroup
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      LogGroupName: __AWSNAME_EcsLogGroup__
      RetentionInDays: 1
  EcsExecutionRole:
    Type: AWS::IAM::Role
    # イメージ取得とログ送信はFargateのエージェントが担当します。
    Properties:
      RoleName: __AWSNAME_EcsExecutionRole__
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal: {Service: ecs-tasks.amazonaws.com}
            Action: sts:AssumeRole
            Condition:
              StringEquals: {aws:SourceAccount: !Ref 'AWS::AccountId'}
              ArnLike: {aws:SourceArn: !Sub 'arn:${AWS::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:*'}
      Policies:
        - PolicyName: __AWSNAME_EcsExecutionPolicy__
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: ecr:GetAuthorizationToken
                Resource: '*'
              - Effect: Allow
                Action: [ecr:BatchCheckLayerAvailability, ecr:GetDownloadUrlForLayer, ecr:BatchGetImage]
                Resource: !GetAtt ContainerRepository.Arn
              - Effect: Allow
                Action: [logs:CreateLogStream, logs:PutLogEvents]
                Resource: !GetAtt EcsLogGroup.Arn
  EcsCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: __AWSNAME_EcsCluster__
  EcsTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    # アプリはAWS APIを呼ばないため、アプリ用のタスクロールを指定しません。
    Properties:
      Family: __AWSNAME_EcsTaskFamily__
      Cpu: '256'
      Memory: '512'
      NetworkMode: awsvpc
      RequiresCompatibilities: [FARGATE]
      ExecutionRoleArn: !GetAtt EcsExecutionRole.Arn
      RuntimePlatform: {CpuArchitecture: X86_64, OperatingSystemFamily: LINUX}
      ContainerDefinitions:
        - Name: app
          Image: !Sub ${ContainerRepository.RepositoryUri}:bootstrap
          Essential: true
          Environment:
            - Name: DEPLOY_ENVIRONMENT
              Value: !Ref DeploymentEnvironment
            - Name: DEPLOY_TARGET
              Value: ecs
          PortMappings: [{ContainerPort: 8080, Protocol: tcp}]
          HealthCheck:
            Command:
              - CMD
              - python
              - '-c'
              - >-
                import json, pathlib, urllib.request;
                response = json.load(urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3));
                assert response['environment'] == 'dev';
                assert response['target'] == 'ecs';
                assert response['revision'] == pathlib.Path('/app/revision.txt').read_text().strip()
            Interval: 10
            Timeout: 5
            Retries: 3
            StartPeriod: 30
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref EcsLogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: app
  EcsService:
    Type: AWS::ECS::Service
    DependsOn: InternetRoute
    # イメージの登録前は0タスクとし、最初のワークフローで1タスクにします。
    Properties:
      ServiceName: __AWSNAME_EcsService__
      Cluster: !Ref EcsCluster
      TaskDefinition: !Ref EcsTaskDefinition
      DesiredCount: 0
      LaunchType: FARGATE
      PlatformVersion: '1.4.0'
      DeploymentConfiguration:
        MinimumHealthyPercent: 100
        MaximumPercent: 200
        DeploymentCircuitBreaker: {Enable: true, Rollback: true}
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: ENABLED
          Subnets: [!Ref PublicSubnet]
          SecurityGroups: [!Ref ComputeSecurityGroup]
  EcsDeployRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: __AWSNAME_EcsDeployRole__
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal: {Federated: !Ref GitHubProvider}
            Action: sts:AssumeRoleWithWebIdentity
            Condition:
              StringEquals:
                token.actions.githubusercontent.com:aud: sts.amazonaws.com
                token.actions.githubusercontent.com:sub: !Ref GitHubSubject
      Policies:
        - PolicyName: __AWSNAME_EcsDeployPolicy__
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: ecr:GetAuthorizationToken
                Resource: '*'
              - Effect: Allow
                Action: [ecr:BatchCheckLayerAvailability, ecr:InitiateLayerUpload, ecr:UploadLayerPart, ecr:CompleteLayerUpload, ecr:PutImage, ecr:DescribeImages]
                Resource: !GetAtt ContainerRepository.Arn
              - Effect: Allow
                Action: ecs:RegisterTaskDefinition
                Resource: !Sub arn:${AWS::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:task-definition/__AWSNAME_EcsTaskFamily__:*
              - Effect: Allow
                Action: ecs:DescribeTaskDefinition
                Resource: '*'
              - Effect: Allow
                Action: [ecs:UpdateService, ecs:DescribeServices]
                Resource: !Ref EcsService
              - Effect: Allow
                Action: ecs:ListTasks
                Resource: '*'
                Condition:
                  ArnEquals: {ecs:cluster: !GetAtt EcsCluster.Arn}
              - Effect: Allow
                Action: ecs:DescribeTasks
                Resource: !Sub arn:${AWS::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:task/${EcsCluster}/*
              - Effect: Allow
                Action: iam:PassRole
                Resource: !GetAtt EcsExecutionRole.Arn
                Condition:
                  StringEquals: {iam:PassedToService: ecs-tasks.amazonaws.com}
  LambdaLogGroup:
    Type: AWS::Logs::LogGroup
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      LogGroupName: __AWSNAME_LambdaLogGroup__
      RetentionInDays: 1
  LambdaRuntimeRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: __AWSNAME_LambdaRuntimeRole__
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal: {Service: lambda.amazonaws.com}
            Action: sts:AssumeRole
      Policies:
        - PolicyName: __AWSNAME_LambdaRuntimePolicy__
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: [logs:CreateLogStream, logs:PutLogEvents]
                Resource: !GetAtt LambdaLogGroup.Arn
  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: __AWSNAME_LambdaFunction__
      Runtime: python3.13
      Handler: index.lambda_handler
      Role: !GetAtt LambdaRuntimeRole.Arn
      Timeout: 10
      MemorySize: 128
      Architectures: [x86_64]
      Environment:
        Variables:
          DEPLOY_ENVIRONMENT: !Ref DeploymentEnvironment
      LoggingConfig: {LogGroup: !Ref LambdaLogGroup, LogFormat: Text}
      Code:
        ZipFile: |
          # 最初のデプロイでアプリを反映するまでの初期コードです。
          def lambda_handler(event, context):
              return {"message": "Initial function", "revision": "bootstrap", "target": "lambda"}
  LambdaDeployRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: __AWSNAME_LambdaDeployRole__
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal: {Federated: !Ref GitHubProvider}
            Action: sts:AssumeRoleWithWebIdentity
            Condition:
              StringEquals:
                token.actions.githubusercontent.com:aud: sts.amazonaws.com
                token.actions.githubusercontent.com:sub: !Ref GitHubSubject
      Policies:
        - PolicyName: __AWSNAME_LambdaDeployPolicy__
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: [lambda:UpdateFunctionCode, lambda:GetFunctionConfiguration, lambda:InvokeFunction]
                Resource: !GetAtt LambdaFunction.Arn
Outputs:
  DeploymentEnvironment:
    Description: Deployment environment represented by these dedicated resources.
    Value: !Ref DeploymentEnvironment
  Ec2InstanceId: {Description: EC2 deployment target., Value: !Ref Ec2Instance}
  ArtifactBucket: {Description: Private artifact bucket., Value: !Ref ArtifactBucketName}
  Ec2DeployRoleArn: {Description: OIDC role for EC2 deployment., Value: !GetAtt Ec2DeployRole.Arn}
  EcsClusterName: {Description: ECS cluster., Value: !Ref EcsCluster}
  EcsServiceName: {Description: ECS service., Value: !GetAtt EcsService.Name}
  EcsTaskFamily: {Description: ECS task definition family., Value: __AWSNAME_EcsTaskFamily__}
  EcrRepository: {Description: ECR repository., Value: !Ref ContainerRepository}
  EcsDeployRoleArn: {Description: OIDC role for ECS deployment., Value: !GetAtt EcsDeployRole.Arn}
  LambdaFunctionName: {Description: Lambda deployment target., Value: !Ref LambdaFunction}
  LambdaDeployRoleArn: {Description: OIDC role for Lambda deployment., Value: !GetAtt LambdaDeployRole.Arn}

作成した直後のECRは空なので、タスクを起動してもbootstrapのイメージの取得で失敗します。そこでECSサービスは希望タスク数0で作り、デプロイを有効にしたあとの初回の反映で1に変えます。

ECRが空でbootstrap未登録の初期状態から、イメージ保存とタスク定義登録を経て、UpdateServiceで希望数を1へ変える正常完了例

補助スクリプトはプレースホルダーの種類ごとにランダムな名前を作り、同じ種類が出てくる箇所をすべて同じ値で置き換えます。完成したテンプレートと名前の対応は、preparedの2つのファイルに保存されます。

同じEcsTaskFamilyのプレースホルダーを、Family、登録許可のResource、Outputsの3か所で同じ生成値に置き換える

再実行すると新しい名前で2つのファイルが上書きされ、作成済みのAWSリソースの名前と合わなくなります。最初に生成したpreparedを、最後まで使ってください。

名前とテンプレートを準備する補助スクリプトの全文

scripts/prepare_template.py

"""新しいAWS名を生成し、コンソールへ渡すテンプレートを作成する。"""

import json
import re
import subprocess
import sys
from pathlib import Path

# 名前は同梱した生成器の出力を変更せずに使用します。
root = Path(__file__).resolve().parents[1]
template = (root / "infra/environment.template.yaml").read_text()
labels = ["Stack"] + sorted(set(re.findall(r"__AWSNAME_(\w+)__", template)))
generator = root / "scripts/aws_resource_names.py"
generated = subprocess.check_output(
    [sys.executable, str(generator), "generate", "--count", str(len(labels))], text=True
).splitlines()
if len(generated) != len(labels):
    raise RuntimeError("Unexpected resource-name count")
names = dict(zip(labels, generated))
subprocess.run([sys.executable, str(generator), "audit", *generated], check=True)
for label, value in names.items():
    template = template.replace(f"__AWSNAME_{label}__", value)
prepared = root / "prepared"
prepared.mkdir(exist_ok=True)
(prepared / "environment.yaml").write_text(template)
(prepared / "names.json").write_text(json.dumps(names, indent=2))
print(json.dumps({"stack": names["Stack"], "bucket": names["ArtifactBucket"]}, indent=2))

scripts/aws_resource_names.py

#!/usr/bin/env python3
# 乱数によるAWSリソース名の生成と、記録した名前の検査を行います。
"""Generate and audit clock-independent AWS resource-name cores."""

from __future__ import annotations

import argparse
import json
import re
import secrets
import string
import sys
from dataclasses import asdict, dataclass
from pathlib import Path

DEFAULT_RANDOM_LENGTH = 16
MIN_RANDOM_LENGTH = 12
MAX_RANDOM_LENGTH = 56
GENERATOR_ID = "aws-resource-name-v1"
FORBIDDEN_SUBSTRINGS = ("qiita", "zenn")
DATE_OR_TIME_PATTERNS = (
    re.compile(r"(?:19|20)\d{2}[-_.]?(?:0[1-9]|1[0-2])[-_.]?(?:0[1-9]|[12]\d|3[01])"),
    re.compile(r"(?<!\d)\d{8}(?!\d)"),
    re.compile(r"(?<!\d)\d{10,13}(?!\d)"),
    re.compile(r"(?:timestamp|datetime|epoch)", re.IGNORECASE),
)


@dataclass(frozen=True)
class NameRecord:
    name: str
    prefix: str
    separator: str
    random_length: int
    alphabet: str = "lowercase-ascii"
    generated_by: str = GENERATOR_ID


def _random_letters(length: int) -> str:
    while True:
        value = "".join(secrets.choice(string.ascii_lowercase) for _ in range(length))
        if not any(term in value for term in FORBIDDEN_SUBSTRINGS):
            return value


def generate_name(*, prefix: str, compact: bool, random_length: int) -> NameRecord:
    if not MIN_RANDOM_LENGTH <= random_length <= MAX_RANDOM_LENGTH:
        raise ValueError(
            f"random length must be between {MIN_RANDOM_LENGTH} and {MAX_RANDOM_LENGTH}"
        )
    if prefix not in {"miruky", "none"}:
        raise ValueError("prefix must be 'miruky' or 'none'")

    random_part = _random_letters(random_length)
    if prefix == "none":
        return NameRecord(
            name=random_part,
            prefix="",
            separator="",
            random_length=random_length,
        )

    separator = "" if compact else "-"
    return NameRecord(
        name=f"miruky{separator}{random_part}",
        prefix="miruky",
        separator=separator,
        random_length=random_length,
    )


def validate_name(name: str) -> list[str]:
    errors: list[str] = []
    lowered = name.lower()

    for term in FORBIDDEN_SUBSTRINGS:
        if term in lowered:
            errors.append(f"contains prohibited platform term: {term}")

    if any(pattern.search(name) for pattern in DATE_OR_TIME_PATTERNS):
        errors.append("contains a date-, time-, epoch-, or timestamp-shaped fragment")

    random_part = ""
    if re.fullmatch(r"miruky-[a-z]+", name):
        random_part = name.removeprefix("miruky-")
    elif re.fullmatch(r"miruky[a-z]+", name):
        random_part = name.removeprefix("miruky")
    elif re.fullmatch(r"[a-z]+", name):
        random_part = name
    else:
        errors.append(
            "must be lowercase random letters with only the optional prefix 'miruky'"
        )

    if random_part and not MIN_RANDOM_LENGTH <= len(random_part) <= MAX_RANDOM_LENGTH:
        errors.append(
            f"random part must contain {MIN_RANDOM_LENGTH}-{MAX_RANDOM_LENGTH} lowercase letters"
        )

    if random_part and any(term in random_part for term in FORBIDDEN_SUBSTRINGS):
        errors.append("random part contains a prohibited platform term")

    return list(dict.fromkeys(errors))


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Generate or audit random AWS resource-name cores without dates or platform names."
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    generate_parser = subparsers.add_parser("generate", help="generate compliant names")
    generate_parser.add_argument("--count", type=int, default=1)
    generate_parser.add_argument(
        "--prefix", choices=("miruky", "none"), default="miruky"
    )
    generate_parser.add_argument("--compact", action="store_true")
    generate_parser.add_argument(
        "--random-length", type=int, default=DEFAULT_RANDOM_LENGTH
    )
    generate_parser.add_argument("--json", action="store_true")

    audit_parser = subparsers.add_parser("audit", help="audit one or more name cores")
    audit_parser.add_argument("names", nargs="+")

    ledger_parser = subparsers.add_parser(
        "audit-ledger", help="audit every resource-name record in a JSON ledger"
    )
    ledger_parser.add_argument("ledger", type=Path)
    ledger_parser.add_argument(
        "--final",
        action="store_true",
        help="also require a verified absent final state for every attempted name",
    )

    return parser


def _run_generate(args: argparse.Namespace) -> int:
    if not 1 <= args.count <= 1000:
        print("ERROR: count must be between 1 and 1000", file=sys.stderr)
        return 2

    try:
        records = [
            generate_name(
                prefix=args.prefix,
                compact=args.compact,
                random_length=args.random_length,
            )
            for _ in range(args.count)
        ]
    except ValueError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 2

    names = [record.name for record in records]
    if len(names) != len(set(names)):
        print("ERROR: generator produced a duplicate name", file=sys.stderr)
        return 1

    if args.json:
        print(
            json.dumps(
                {
                    "schema_version": 1,
                    "naming_policy": GENERATOR_ID,
                    "names": [asdict(record) for record in records],
                },
                ensure_ascii=False,
                indent=2,
            )
        )
    else:
        print("\n".join(names))
    return 0


def _run_audit(args: argparse.Namespace) -> int:
    failed = False
    for name in args.names:
        errors = validate_name(name)
        if errors:
            failed = True
            print(f"FAIL {name}")
            for error in errors:
                print(f"  - {error}")
        else:
            print(f"PASS {name}")
    return 1 if failed else 0


def _run_audit_ledger(args: argparse.Namespace) -> int:
    findings: list[str] = []
    try:
        payload = json.loads(args.ledger.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        print(f"FAIL {args.ledger}")
        print(f"  - cannot read a valid JSON ledger: {exc}")
        return 1

    if not isinstance(payload, dict):
        findings.append("ledger root must be a JSON object")
        resources: list[object] = []
        aws_generated_identifiers: list[object] = []
    else:
        if payload.get("schema_version") != 1:
            findings.append("schema_version must be 1")
        if payload.get("inventory_complete") != "PASS":
            findings.append("inventory_complete must be PASS")
        if payload.get("name_field_inventory_complete") != "PASS":
            findings.append("name_field_inventory_complete must be PASS")
        resources_value = payload.get("resources")
        if not isinstance(resources_value, list):
            findings.append("resources must be an array")
            resources = []
        else:
            resources = resources_value
        generated_value = payload.get("aws_generated_identifiers")
        if not isinstance(generated_value, list):
            findings.append("aws_generated_identifiers must be an array")
            aws_generated_identifiers = []
        else:
            aws_generated_identifiers = generated_value
        if not resources and not aws_generated_identifiers:
            findings.append(
                "resources and aws_generated_identifiers cannot both be empty"
            )

    seen_names: set[str] = set()
    seen_cores: set[str] = set()
    for index, resource in enumerate(resources, start=1):
        location = f"resources[{index}]"
        if not isinstance(resource, dict):
            findings.append(f"{location} must be an object")
            continue

        required_strings = (
            "logical_label",
            "service",
            "region",
            "resource_name",
            "name_core",
            "generated_by",
            "create_target",
            "cleanup_target",
            "absence_check",
        )
        for key in required_strings:
            if not isinstance(resource.get(key), str) or not resource[key].strip():
                findings.append(f"{location}.{key} must be a nonempty string")

        core = resource.get("name_core")
        full_name = resource.get("resource_name")
        if isinstance(core, str) and core:
            for error in validate_name(core):
                findings.append(f"{location}.name_core {error}")
            if core in seen_cores:
                findings.append(f"{location}.name_core duplicates another ledger entry")
            seen_cores.add(core)
        if isinstance(full_name, str) and isinstance(core, str) and full_name and core:
            lowered_full_name = full_name.lower()
            for term in FORBIDDEN_SUBSTRINGS:
                if term in lowered_full_name:
                    findings.append(
                        f"{location}.resource_name contains prohibited platform term: {term}"
                    )
            if any(pattern.search(full_name) for pattern in DATE_OR_TIME_PATTERNS):
                findings.append(
                    f"{location}.resource_name contains a date-, time-, epoch-, or timestamp-shaped fragment"
                )
            required_prefix = resource.get("required_prefix", "")
            required_suffix = resource.get("required_suffix", "")
            wrapper_justification = resource.get("wrapper_justification", "")
            wrapper_fields_valid = True
            for key, value in (
                ("required_prefix", required_prefix),
                ("required_suffix", required_suffix),
                ("wrapper_justification", wrapper_justification),
            ):
                if not isinstance(value, str):
                    findings.append(f"{location}.{key} must be a string when present")
                    wrapper_fields_valid = False

            if wrapper_fields_valid:
                has_wrapper = bool(required_prefix or required_suffix)
                if full_name == core:
                    if has_wrapper or wrapper_justification:
                        findings.append(
                            f"{location} must leave wrapper fields empty when resource_name equals name_core"
                        )
                else:
                    if not has_wrapper:
                        findings.append(
                            f"{location} must record every service-required prefix or suffix"
                        )
                    if not wrapper_justification.strip():
                        findings.append(
                            f"{location}.wrapper_justification must cite the service requirement"
                        )
                    if full_name != f"{required_prefix}{core}{required_suffix}":
                        findings.append(
                            f"{location}.resource_name must equal required_prefix + name_core + required_suffix"
                        )
            if full_name in seen_names:
                findings.append(f"{location}.resource_name duplicates another ledger entry")
            seen_names.add(full_name)

        if resource.get("generated_by") != GENERATOR_ID:
            findings.append(f"{location}.generated_by must be {GENERATOR_ID}")

        if args.final and resource.get("final_state") not in {
            "deleted-and-absent",
            "not-created-and-absent",
        }:
            findings.append(
                f"{location}.final_state must prove deleted-and-absent or not-created-and-absent"
            )

    for index, item in enumerate(aws_generated_identifiers, start=1):
        location = f"aws_generated_identifiers[{index}]"
        if not isinstance(item, dict):
            findings.append(f"{location} must be an object")
            continue
        for key in ("logical_label", "service", "region", "identifier_kind"):
            if not isinstance(item.get(key), str) or not item[key].strip():
                findings.append(f"{location}.{key} must be a nonempty string")
        if item.get("exemption_reason") != "no-supported-user-name-field":
            findings.append(
                f"{location}.exemption_reason must be no-supported-user-name-field"
            )

    if findings:
        print(f"FAIL {args.ledger}")
        for finding in findings:
            print(f"  - {finding}")
        return 1

    print(
        f"PASS {args.ledger} ({len(resources)} names, "
        f"{len(aws_generated_identifiers)} AWS-generated identifiers)"
    )
    return 0


def main() -> int:
    args = _build_parser().parse_args()
    if args.command == "generate":
        return _run_generate(args)
    if args.command == "audit":
        return _run_audit(args)
    return _run_audit_ledger(args)


if __name__ == "__main__":
    raise SystemExit(main())
# 名前を生成し、コンソールへ渡す完成版のテンプレートを手元に保存します。
python3 scripts/prepare_template.py

既存のGitHubのOIDCプロバイダーを使うときは、テンプレートからGitHubProviderを外し、そのARNをPrincipal.Federatedに指定します。

5-2. S3にテンプレートを置いて、スタックを作りましょう

最初に、テンプレートと配置物を置くS3バケットを作ります。名前にはprepared/names.jsonのArtifactBucketを使います。

東京の汎用バケットへ、生成器の名前を指定する作成前の画面

バケット名には生成した名前を入力し、AWS リージョンはアジアパシフィック (東京) ap-northeast-1にします。あわせて汎用とグローバル名前空間を選びます。

ACLを無効にし、パブリックアクセスをすべてブロックする選択状態

ACL 無効 (推奨)を選び、パブリックアクセスをすべてブロックにチェックを入れます。配置物の保存と取得には、IAMロールの権限を使います。

バケットのバージョニングを有効にした選択状態

バケットのバージョニングは有効にするにします。同じキーのファイルを上書きしても、以前のバージョンを指定して配置物を取り出せます。

SSE-S3を選択したデフォルト暗号化の設定

暗号化はAmazon S3 マネージドキーを使用したサーバー側の暗号化 (SSE-S3)にします。SSE-S3なら、配置物を取得する側に要るのは、S3からの取得の許可だけです。

この設定でバケットを作り、prepared/environment.yamlをアップロードします。

S3のenvironment.yamlのプロパティに表示されたオブジェクトURLとキー

アップロードしたファイルのオブジェクト URLを、CloudFormationのテンプレートの取得元に指定します。スタック名にはprepared/names.jsonのStackを使います。

CloudFormationのパラメータにdev環境、成果物バケット、developのOIDCサブジェクトを入力した状態

パラメータには次の値を入力します。

入力欄 値
ArtifactBucketName 作成したS3バケットの名前
GitHubSubject 4-3で取得した値
DeploymentEnvironment dev
LatestAmi 既定のAmazon Linux 2023のパラメーター

GitHubSubjectに入れるのは、4-3でdevelopから取得したsubです。ほかのブランチの値はテンプレートのパラメーター条件に合わないので、スタックの作成時に拒否されます。

CloudFormationでカスタム名のIAMリソース作成を承認するチェックを付けた状態

テンプレートがIAMロールを作るので、AWS CloudFormation によって IAM リソースがカスタム名で作成される場合があることを承認します。にチェックを入れてから、次へで確認画面に進みます。

CloudFormationの最終確認でenvironment.yamlのテンプレートURLを確認した状態

確認して作成の画面では、テンプレート URLがアップロードしたenvironment.yamlになっているかを確かめてから、この完成版のテンプレートでスタックを作成します。

専用のCloudFormationスタックでCREATE_COMPLETEを確認した画面

ステータスがCREATE_COMPLETEになれば作成は完了です。5-1で書いたとおりECSサービスの希望タスク数は0なので、コンテナが起動するのは、6-2でデプロイを有効にしたあとのCDです。

CloudFormationの出力でdevとEC2インスタンスID、ECRリポジトリ、ECSクラスターを確認した画面

出力 (11)の中身はDeploymentEnvironmentのdevや、今回のインスタンスIDを示すEc2InstanceIdなどです。DeploymentEnvironment以外の値は6-2でリポジトリ変数に登録します。

6. マージしたら、CDが動き出します

6-1. わざと失敗させてから、developへマージしましょう

ここからは、PRをマージしてCDを動かす段階です。developからfeature/develop-dev-flowを作り、READMEへの追記をPRの差分にしてプッシュしたら、develop宛てのPRを作ります。

git switch develop
git pull --ff-only origin develop
git switch -c feature/develop-dev-flow
# PRで確認する変更として、dev環境の説明をREADMEへ追記します。
printf '\nDeploy target: dev\n' >> README.md
git add README.md
git commit -m "Describe the dev deployment target"
git push -u origin feature/develop-dev-flow
gh pr create --base develop --title "Describe the dev deployment target" --body "READMEへdev環境の説明を追記します。"

両方のCIが成功したら、今度はわざと失敗させてみましょう。tests/test_app.pyのtest_message_and_targetで、メッセージの期待値だけをExpected failing greetingに書き換えてプッシュすると、PRにも同じ変更が反映されます。

# test_message_and_targetのmessageの期待値をExpected failing greetingへ書き換えてから実行します。
git add tests/test_app.py
git commit -m "Expect a wrong greeting to check CI"
git push

期待値を変更した結果、featureとPRの両方で同じアサーションが失敗したログ

featureの先頭でも、PRのマージ結果でも、同じアサーションで失敗しました。

# 直前の失敗用コミットだけを打ち消してから、再びプッシュします。
git revert --no-edit HEAD
git push

期待値を戻したコミットをプッシュすると、両方のCIが成功に戻りました。

期待値を戻したコミットでpushとpull_requestのCIが成功した実行記録

CIが成功したのを確認してから、PR #1をdevelopへマージします。

# 修正後のCIが成功したことを確認してから、PRをマージします。
gh pr checks --watch && gh pr merge --merge

feature/develop-dev-flowからdevelopへマージされたPRの状態とマージコミット

今回はブランチ保護を設定していないので、CIが失敗したPRでもマージはできてしまいます。その場合でもCDのverifyが同じテストで失敗し、AWSのジョブの手前で止まります。マージそのものを止める方法は、10章の話題です。

6-2. CDのワークフローとリポジトリ変数

.github/workflows/cd-dev.yml

CDのワークフロー全文
name: CD to dev

# PRのマージでdevelopへ反映された版を、devへデプロイします。
on:
  push:
    branches: [develop]
permissions:
  contents: read
concurrency:
  group: deploy-dev-${{ github.repository }}
  cancel-in-progress: false
env:
  DEPLOY_REVISION: ${{ github.sha }}
  DEPLOY_ENVIRONMENT: dev
jobs:
  verify:
    if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ env.DEPLOY_REVISION }}
      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version: '3.13'
      - name: Verify the merged revision
        run: |
          # 実際にマージされた版を、配置する版として固定します。
          printf 'ref=%s\ndeploy_revision=%s\nenvironment=%s\n' "$GITHUB_REF" "$DEPLOY_REVISION" "$DEPLOY_ENVIRONMENT"
          test "$(git rev-parse HEAD)" = "$DEPLOY_REVISION"
          python -m unittest discover -s tests -v
          python -m compileall -q app.py server.py lambda_function.py

  ec2:
    needs: verify
    if: vars.DEPLOY_ENABLED == 'true'
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    permissions:
      contents: read
      id-token: write
    env:
      ARTIFACT_BUCKET: ${{ vars.ARTIFACT_BUCKET }}
      INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ env.DEPLOY_REVISION }}
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: ${{ vars.EC2_DEPLOY_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}
          mask-aws-account-id: true
      - name: Package and deploy to EC2 dev
        run: bash scripts/deploy_ec2.sh

  ecs:
    needs: verify
    if: vars.DEPLOY_ENABLED == 'true'
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    permissions:
      contents: read
      id-token: write
    env:
      ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
      ECS_CLUSTER: ${{ vars.ECS_CLUSTER }}
      ECS_SERVICE: ${{ vars.ECS_SERVICE }}
      ECS_TASK_FAMILY: ${{ vars.ECS_TASK_FAMILY }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ env.DEPLOY_REVISION }}
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: ${{ vars.ECS_DEPLOY_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}
          mask-aws-account-id: true
      - name: Build and push the container image
        id: image
        run: |
          # マージした版と再実行番号から、今回のイメージを特定します。
          ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
          REGISTRY="$ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com"
          IMAGE="$REGISTRY/$ECR_REPOSITORY:$DEPLOY_REVISION-$GITHUB_RUN_ATTEMPT"
          aws ecr get-login-password | docker login --username AWS --password-stdin "$REGISTRY"
          docker build --build-arg REVISION="$DEPLOY_REVISION" --tag "$IMAGE" .
          docker push "$IMAGE"
          printf 'image=%s\n' "$IMAGE" >> "$GITHUB_OUTPUT"
      - name: Obtain the current task definition
        run: |
          # devの実行ロールとログ設定を保持して更新します。
          aws ecs describe-task-definition --task-definition "$ECS_TASK_FAMILY" --query taskDefinition > task-definition.json
      - name: Render the new task definition
        id: task
        uses: aws-actions/amazon-ecs-render-task-definition@8d79160660ad39c402a16e5fc7cd2a32e8f7d6b7 # v1.9.1
        with:
          task-definition: task-definition.json
          container-name: app
          image: ${{ steps.image.outputs.image }}
      - name: Deploy the new task definition to dev
        uses: aws-actions/amazon-ecs-deploy-task-definition@a00d4d57ac0de1a99b39fe469f3d898d38717c2b # v2.6.4
        with:
          task-definition: ${{ steps.task.outputs.task-definition }}
          cluster: ${{ vars.ECS_CLUSTER }}
          service: ${{ vars.ECS_SERVICE }}
          desired-count: 1
          wait-for-service-stability: true
          wait-for-minutes: 15
      - name: Verify the actual running image and health
        env:
          EXPECTED_IMAGE: ${{ steps.image.outputs.image }}
        run: python3 scripts/verify_ecs.py

  lambda:
    needs: verify
    if: vars.DEPLOY_ENABLED == 'true'
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    permissions:
      contents: read
      id-token: write
    env:
      FUNCTION_NAME: ${{ vars.LAMBDA_FUNCTION_NAME }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ env.DEPLOY_REVISION }}
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: ${{ vars.LAMBDA_DEPLOY_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}
          mask-aws-account-id: true
      - name: Update and invoke the dev function
        run: bash scripts/deploy_lambda.sh

各ジョブはDEPLOY_REVISION(github.sha)をチェックアウトします。CDのverifyに与える権限はcontents: readだけで、id-token: writeはAWSへ反映する3つのジョブにだけ設定します。

4-3のprepare.ymlのinspectジョブにもこの権限を付けているのは、クレームを取得するためです。

1-1の条件どおり、vars.DEPLOY_ENABLEDがまだtrueではないマージ直後の実行では、verifyがsuccess、3つのジョブはskippedになりました。

developのマージコミットでverifyだけ成功し、3つのAWSジョブがskippedになったCDの記録

この実行のheadShaはマージコミットと一致しています。続いて、5章の出力から実行先の名前とロールのARNをリポジトリ変数に登録します。

# 山かっこの値を、自分のスタックの出力で置き換えます。
REPO='<所有者>/<リポジトリ>'
gh variable set AWS_REGION --repo "$REPO" --body 'ap-northeast-1'
gh variable set ARTIFACT_BUCKET --repo "$REPO" --body '<ArtifactBucketの値>'
gh variable set EC2_INSTANCE_ID --repo "$REPO" --body '<Ec2InstanceIdの値>'
gh variable set EC2_DEPLOY_ROLE_ARN --repo "$REPO" --body '<Ec2DeployRoleArnの値>'
gh variable set ECR_REPOSITORY --repo "$REPO" --body '<EcrRepositoryの値>'
gh variable set ECS_CLUSTER --repo "$REPO" --body '<EcsClusterNameの値>'
gh variable set ECS_SERVICE --repo "$REPO" --body '<EcsServiceNameの値>'
gh variable set ECS_TASK_FAMILY --repo "$REPO" --body '<EcsTaskFamilyの値>'
gh variable set ECS_DEPLOY_ROLE_ARN --repo "$REPO" --body '<EcsDeployRoleArnの値>'
gh variable set LAMBDA_FUNCTION_NAME --repo "$REPO" --body '<LambdaFunctionNameの値>'
gh variable set LAMBDA_DEPLOY_ROLE_ARN --repo "$REPO" --body '<LambdaDeployRoleArnの値>'
# 設定値をすべて登録した後に、AWSへの反映を有効にします。
gh variable set DEPLOY_ENABLED --repo "$REPO" --body true

CDのconcurrencyは実行中の反映を取り消さない設定です。ただし、1つ目の反映中に2つ目と3つ目をマージすると、待っていた2つ目は取り消されます。

同じグループのCDで、実行中Aを継続し、待機中Bを新しい待機Cで置き換える状態例

マージしたすべてのコミットが反映されるとは限らないので、実際に反映されたコミットは応答や稼働中のイメージで確認します。

これで、PRをdevelopへマージすると、3つのジョブがそれぞれのロールで反映を始めるようになりました。

試しに、dev環境で確認する項目をREADMEへ追記するPRを作り、CIの成功を待ってマージします。

git switch develop
git pull --ff-only origin develop
git switch -c feature/describe-dev-checks
# 反映後に確認する項目をREADMEへ追記します。
cat >> README.md <<'EOF'

## dev環境での確認

- EC2はRun Commandの出力、ECS Fargateはタスクのヘルスステータス、Lambdaは同期呼び出しの応答で確認します。
- 各応答のrevisionが、developへマージしたコミットIDと一致することを確認します。
EOF
git add README.md
git commit -m "Describe dev deployment checks in README"
git push -u origin feature/describe-dev-checks
gh pr create --base develop --title "Describe dev deployment checks" --body "dev環境での確認項目をREADMEへ追記します。"
# PRのCIが成功した場合にマージします。
gh pr checks --watch && gh pr merge --merge

初回のdev反映でverify、ec2、ecs、lambdaがすべて成功したCDの実行記録

verifyと3つのAWSのジョブはすべて成功しました。なお、7〜9章で確認するのは、8章のヘルスチェックの失敗から復旧したあとのコミットです。

7. EC2には、Run Commandで配置します

7-1. EC2の中で応答を確かめてみましょう

EC2のジョブは配置物を圧縮してS3に保存し、Systems ManagerのRun Commandで対象のインスタンスへ配置を指示します。指示を受け取るのはインスタンスのSSM Agentで、Agentのほうから Systems Managerへ接続するため、受信のルールは空のままで動きます。

app.tar.gzをS3、SendCommandをSSMへ送る経路と、Agentの送信接続・EC2内の処理

インスタンスでは配置物のSHA-256を確認してから展開し、アプリケーションのサービスを再起動します。

scripts/deploy_ec2.sh

#!/usr/bin/env bash
# 配置物のハッシュを確認してから、専用EC2上のアプリを更新します。
set -euo pipefail
: "${ARTIFACT_BUCKET:?}" "${INSTANCE_ID:?}" "${DEPLOY_REVISION:?}" "${GITHUB_RUN_ATTEMPT:?}"
mkdir -p build
printf '%s\n' "$DEPLOY_REVISION" > revision.txt
tar -czf build/app.tar.gz app.py server.py revision.txt
KEY="releases/$DEPLOY_REVISION/$GITHUB_RUN_ATTEMPT/app.tar.gz"
aws s3api put-object --bucket "$ARTIFACT_BUCKET" --key "$KEY" \
  --body build/app.tar.gz --server-side-encryption AES256 --query ETag --output text
export ARTIFACT_KEY="$KEY"
export ARTIFACT_SHA256
ARTIFACT_SHA256=$(sha256sum build/app.tar.gz | cut -d ' ' -f 1)
python3 scripts/ec2_command.py > build/parameters.json
for attempt in $(seq 1 30); do
  ONLINE=$(aws ssm describe-instance-information \
    --filters "Key=InstanceIds,Values=$INSTANCE_ID" \
    --query 'InstanceInformationList[0].PingStatus' --output text)
  if [ "$ONLINE" = Online ]; then break; fi
  if [ "$attempt" = 30 ]; then printf 'EC2 is not online in Systems Manager\n' >&2; exit 1; fi
  sleep 10
done
COMMAND_ID=$(aws ssm send-command --document-name AWS-RunShellScript \
  --instance-ids "$INSTANCE_ID" --parameters file://build/parameters.json \
  --timeout-seconds 300 --query 'Command.CommandId' --output text)
printf 'CommandId=%s\n' "$COMMAND_ID"
aws ssm wait command-executed --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" || {
  aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" \
    --query '{Status:Status,Error:StandardErrorContent,Output:StandardOutputContent}'
  exit 1
}
aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" \
  --query StandardOutputContent --output text > build/ec2-output.txt
cat build/ec2-output.txt
python3 - <<'PY'
# 応答が今回のコミットを返した場合だけ、デプロイ成功として扱います。
import json, os
from pathlib import Path
response = json.loads(Path('build/ec2-output.txt').read_text().strip().splitlines()[-1])
assert response['revision'] == os.environ['DEPLOY_REVISION'], response
assert response['target'] == 'ec2', response
assert response['environment'] == 'dev', response
print('EC2 revision verified')
PY

ジョブはコマンドが終わるのを待ち、出力された3項目が今回の値と一致したときだけ成功にします。

EC2上で実行するコマンドを作る scripts/ec2_command.py の全文

scripts/ec2_command.py

"""既知の配置先へ実行するコマンドをJSON化する。"""

import json
import os
import re

# 外部入力をシェルへ渡す前に、使える文字と配置先を制限します。
bucket, key, digest = (os.environ[x] for x in ("ARTIFACT_BUCKET", "ARTIFACT_KEY", "ARTIFACT_SHA256"))
if not re.fullmatch(r"miruky-[a-z]{16}", bucket):
    raise ValueError("Invalid artifact bucket")
if not re.fullmatch(r"releases/[0-9a-f]{40}/[0-9]+/app\.tar\.gz", key):
    raise ValueError("Invalid artifact key")
if not re.fullmatch(r"[0-9a-f]{64}", digest):
    raise ValueError("Invalid artifact digest")
commands = [
    "set -eu",
    f"aws s3 cp s3://{bucket}/{key} /tmp/deploy-app.tar.gz --only-show-errors",
    f"printf '%s  %s\\n' '{digest}' '/tmp/deploy-app.tar.gz' | sha256sum --check --status",
    "install -d -m 755 /opt/deploy-app",
    "tar -xzf /tmp/deploy-app.tar.gz -C /opt/deploy-app",
    "cat > /etc/systemd/system/deploy-app.service <<'UNIT'\n[Unit]\nDescription=Deployment example application\nAfter=network.target\n[Service]\nWorkingDirectory=/opt/deploy-app\nEnvironment=DEPLOY_TARGET=ec2\nEnvironment=DEPLOY_ENVIRONMENT=dev\nExecStart=/usr/bin/python3 /opt/deploy-app/server.py\nUser=nobody\nRestart=on-failure\n[Install]\nWantedBy=multi-user.target\nUNIT",
    "systemctl daemon-reload",
    "systemctl enable deploy-app.service >/dev/null 2>&1",
    "systemctl restart deploy-app.service",
    "for attempt in $(seq 1 20); do if curl --silent --fail http://127.0.0.1:8080/health; then echo; break; fi; if [ \"$attempt\" = 20 ]; then exit 1; fi; sleep 1; done",
]
print(json.dumps({"commands": commands}))

EC2のインスタンス概要に表示された実行中のt3.microとIAMロール

インスタンスの状態は実行中、インスタンスタイプはt3.microです。このインスタンスはIAM ロールに設定したEC2用のロールで、Systems ManagerとS3へ接続します。

Run Commandの成功、レスポンスコード0、devのEC2から返ったJSONを確認した出力

OutputのJSONは、targetがec2、environmentがdevで、revisionも今回のコミットIDでした。レスポンスコードも0なので、インスタンスの中で応答を取得するところまで成功しています。

8. ECS Fargateでは、ロールバックも起きました

8-1. ヘルスチェックに落ちたら、どうなるの?

ECSのジョブはコミットIDと再実行番号を付けたタグでイメージをECRに保存し、appコンテナのイメージだけを差し替えたタスク定義でサービスを更新します。

ヘルスチェックで検査するのは/healthの3項目です。ジョブのほうは、今回のイメージで動くタスクが実行中で、しかも正常かを確認します。

scripts/verify_ecs.py

"""サービスの稼働数だけでなく、今回のイメージとコンテナの正常性を確認する。"""

import json
import os
import subprocess
import time


def aws(*arguments):
    return json.loads(subprocess.check_output(["aws", "ecs", *arguments], text=True))


cluster, service, expected = (os.environ[x] for x in ("ECS_CLUSTER", "ECS_SERVICE", "EXPECTED_IMAGE"))
# 更新前のタスクを成功と誤認しないよう、今回のイメージを照合します。
for attempt in range(60):
    task_arns = aws("list-tasks", "--cluster", cluster, "--service-name", service)["taskArns"]
    if task_arns:
        tasks = aws("describe-tasks", "--cluster", cluster, "--tasks", *task_arns)["tasks"]
        matching = [task for task in tasks if any(container.get("image") == expected for container in task["containers"])]
        if matching and all(task.get("lastStatus") == "RUNNING" and task.get("healthStatus") == "HEALTHY" for task in matching):
            definition = aws("describe-task-definition", "--task-definition", matching[0]["taskDefinitionArn"])["taskDefinition"]
            app = next(container for container in definition["containerDefinitions"] if container["name"] == "app")
            environment = {item["name"]: item["value"] for item in app.get("environment", [])}
            if environment.get("DEPLOY_ENVIRONMENT") != "dev":
                raise RuntimeError("The running task definition is not configured for dev")
            print(json.dumps({"status": "RUNNING", "health": "HEALTHY", "revision": os.environ["DEPLOY_REVISION"]}))
            print("ECS image and health verified")
            break
    if attempt == 59:
        raise RuntimeError("The current image did not become healthy")
    time.sleep(5)

わざわざイメージまで照合するのには理由があります。サーキットブレーカーで復旧したあとは前のリビジョンのタスクでサービスが安定することがあり、稼働数だけを見ると、今回の更新が成功したように見えてしまうからです。

実際に、HTTPサーバーを開始せずに600秒待つだけのCMDへ変えて反映してみました。ユニットテストは通っても、このコンテナはヘルスチェックに応答できません(ユニットテストだけでは気づけないのが、なかなか厄介です)。

ECSのコンテナヘルスチェック失敗と、リビジョン3から2へのロールバック

Task failed container health checksとロールバックされましたが表示され、ターゲットはリビジョン3、ソースはリビジョン2です。前のリビジョンのタスクが1つ動いている状態に戻りました。

GitHub Actionsのほうも、更新を待つところで失敗しました。

{"state":"TIMEOUT","observedResponses":{"200: OK":7},"reason":"Waiter has timed out"}

ECSは前のリビジョンへ戻りましたが、EC2とLambdaには、ヘルスチェックに失敗したのと同じコミットが反映されたままです。そこでCMDを戻したPRをマージし、3つの実行先が同じコミットで動くことを確認しました。

同じ失敗を試すなら、DockerfileのCMDをCMD ["python", "-c", "import time; time.sleep(600)"]に変えます。失敗用の変更も復旧用の変更もfeatureブランチからのPRでマージしてください。

git switch develop
git pull --ff-only origin develop
git switch -c feature/ecs-startup-check
# DockerfileのCMDを待機する処理へ変更してからコミットします。
git add Dockerfile
git commit -m "Check an unhealthy container startup"
git push -u origin feature/ecs-startup-check
gh pr create --base develop --title "Check an unhealthy container startup" --body "ECSのヘルスチェックによる旧版への復帰を確認します。"
gh pr checks --watch && gh pr merge --merge

ECSのジョブが失敗したのを確認したら、CMDをCMD ["python", "server.py"]に戻し、同じ手順でPRをマージします。

git switch develop
git pull --ff-only origin develop
git switch -c feature/restore-server-cmd
# DockerfileのCMDをserver.pyの起動へ戻してから続けます。
git add Dockerfile
git commit -m "Restore the HTTP server command"
git push -u origin feature/restore-server-cmd
gh pr create --base develop --title "Restore the HTTP server command" --body "CMDをserver.pyの起動へ戻します。"
gh pr checks --watch && gh pr merge --merge

復旧後のECSでタスク定義リビジョン4が実行中かつ正常になった画面

復旧後は、前回のステータスが実行中、ヘルスステータスが正常になりました。タスク定義リビジョン4のタスクが1つ動いていて、ヘルスチェックにも通っています。

稼働中のappコンテナのイメージURI末尾と正常な状態

イメージ URIの末尾は復旧用PRのマージコミットIDと再実行番号の1です。念のため、起動ログでもコミットIDと環境の値を確認します。

ECSの起動時readyログに記録されたコミットID、target ecs、environment dev

readyのログにはrevisionに復旧用PRのコミットID、targetにecs、environmentにdevが出ていました。ただ、これはプロセスが起動したときの記録なので、HTTPの応答が正常かどうかはヘルスチェックで判断します。

9. Lambdaへのデプロイ

9-1. 更新が終わるのを待ってから呼び出しましょう

Lambdaのジョブがコードの更新に使うのは、直前に取得したRevisionIdです。取得してから更新するまでの間にコンソールなどで関数が更新されていれば、上書きせずに拒否されます。

LambdaのRevisionIdによる更新、CLIでの待機、同期呼び出し、ジョブ内の5項目検査

ZIPのルートにはlambda_function.pyをindex.pyとしてコピーしたものと、app.py、revision.txtを入れます。更新の完了を待ってから同期呼び出しをして、InvokeのStatusCodeが200でもFunctionErrorがあれば失敗です。応答の3項目も今回の値と照合します。

scripts/deploy_lambda.sh

#!/usr/bin/env bash
# 関数の実行ロールを保持したままコードを更新し、応答のコミットIDを確認します。
set -euo pipefail
: "${FUNCTION_NAME:?}" "${DEPLOY_REVISION:?}"
mkdir -p build/lambda
cp app.py build/lambda/app.py
cp lambda_function.py build/lambda/index.py
printf '%s\n' "$DEPLOY_REVISION" > build/lambda/revision.txt
(cd build/lambda && zip -q ../lambda.zip app.py index.py revision.txt)
REVISION_ID=$(aws lambda get-function-configuration --function-name "$FUNCTION_NAME" \
  --query RevisionId --output text)
aws lambda update-function-code --function-name "$FUNCTION_NAME" \
  --revision-id "$REVISION_ID" --zip-file fileb://build/lambda.zip \
  --query LastUpdateStatus --output text
# 許可したGetFunctionConfigurationで更新完了を確認します。
aws lambda wait function-updated --function-name "$FUNCTION_NAME"
aws lambda invoke --function-name "$FUNCTION_NAME" --cli-binary-format raw-in-base64-out \
  --payload '{"name":"miruky"}' build/lambda-response.json > build/lambda-invocation.json
python3 - <<'PY'
# APIの受付成功に加え、関数の例外と返却値の内容を確認します。
import json, os
from pathlib import Path
invocation = json.loads(Path('build/lambda-invocation.json').read_text())
response = json.loads(Path('build/lambda-response.json').read_text())
assert invocation.get('StatusCode') == 200, invocation
assert not invocation.get('FunctionError'), response
assert response['revision'] == os.environ['DEPLOY_REVISION'], response
assert response['target'] == 'lambda', response
assert response['environment'] == 'dev', response
print(json.dumps(response, ensure_ascii=False))
print('Lambda revision verified')
PY

Lambdaのコード編集画面にあるapp.py、index.py、revision.txtと配置したコミットID

ファイル一覧にはapp.py、index.py、revision.txtがあり、revision.txtの中身は復旧用PRのマージコミットでした。配置した内容がそのまま入っています。

Lambdaの実行ログへ出力されたdevの応答JSONとコミットID

関数が出力したJSONも、targetがlambda、environmentがdevで、revisionは今回のコミットIDと一致しました。これで、3つの実行先すべてで同じコミットが動いていることを確認できました。

10. 本番で使う前に足したいこと

今回自動化したのは、CIから3つの実行先への反映と、その照合までです。GitHub用のロールに与えたのも、既存の実行先にコードを反映する権限だけです。

CIが失敗したPRのマージを止めたり、直接のプッシュを制限したりするには、developにブランチの保護規則を追加します。使える規則はプランやリポジトリの公開範囲によって異なります。

GitHubのデプロイ環境で承認ルールを使うなら、ジョブにenvironmentの指定が必要です。そうするとsubが環境名を含む形式に変わるので、信頼ポリシーとAllowedPatternも変更します。

EC2上のアプリはサービスを再起動している間、応答が止まります。本番ではこの停止への対策が要り、ECSの前段にはロードバランサーなどが候補です。Lambdaをバージョンとエイリアスで反映、復旧したい場合は、その権限と確認の処理の追加が必要です。

おわりに

ここまでお付き合いいただきありがとうございます。

CIはfeatureの先頭とPRのマージ結果を検査し、CDはdevelopで受け取ったコミットをdev環境へ反映します。8章のようにECSだけが前のリビジョンへ戻ることもあるので、反映したあとは応答や稼働中のイメージで、どのコミットが動いているかを確認してみてください(3つの実行先のうちECSだけが自分で前のリビジョンに戻るのを見ると、サーキットブレーカーってよくできているな、と思います)。

ではまた、お会いしましょう。

参考リンク

アクセスキーなしでAWSを使いましょう

マージしたら、CDが動き出します

本番で使う前に足したいこと

GitHub Actions

OIDCと認証

IAMと実行先

配置と確認

料金

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?