AWS標準機能だけでLambdaのCI/CDを作る(全体像編)
🐈🐈🐈 作成した内容は👆こちら 🐈🐈🐈
1. はじめに
よく仕事で小さなプログラムをAWS上で定期実行したい、という案件に出会うことがあります。
- 毎週1回、外部からファイルを取得してS3へ保存する
- 定期的に外部APIを呼び出してデータを取得する
- 一定間隔で簡単な集計を行う
Lambdaの中で動かす処理は案件ごとに違いますが、その周りに必要なものはほとんど同じです。実行Role、定期実行、ログ、監視、通知、テスト、CI/CD、Version管理、環境分離。これを案件ごとに設計し直していると、本来書きたい処理へ着手するまでに時間がかかります。
そこで、この共通部分だけを最小構成でテンプレート化しました。3回に分けて、その中身と設計判断を書いていきます。
やりたいこと
- 新しい案件では、コードを書いてpushすればLambdaが更新される状態から始めたい
- そのために自分で作り込むコードは、できるだけ少なくしたい
- 何が起きているか追えない仕組みは入れたくない
この構成のメリット
- CI/CDの処理をAWS標準機能へ寄せているため、動きを公式ドキュメントで確認できる
- 自作のスクリプトが少ないので、壊れる箇所が少ない
- Lambdaを1つ追加する手間が、ディレクトリを1つ増やすだけになる
今回は全体像と設計の方針までを扱います。AWSへの構築は第2回、リリースと監視の仕組みは第3回で扱います。
2. 今回作るもの
全体の構成
GitHubへpushすると、テスト、ビルド、デプロイが順に動いてLambdaが更新されます。
開発者
│ push
▼
GitHub ──> CodePipeline
├ Source GitHubからコードを取得
├ Test pytestを実行
├ Build SAMでLambdaのパッケージを作る
└ Deploy CloudFormationでStackを更新
│
▼
Lambda(定期実行、ログ、監視つき)
Pipelineの実体はCodePipelineとCodeBuildです。デプロイはSAM(AWS Serverless Application Model)のコマンドをCodeBuildから実行します。自作のデプロイスクリプトは、設定値を組み立ててSAMへ渡す部分だけです。
3つのStack
CloudFormationのStackを、変更の頻度で3つに分けています。
| Stack | 中身 | 誰が更新するか | 更新の頻度 |
|---|---|---|---|
| Bootstrap | CloudFormationが使うIAM Role、SAMの資材置き場のS3 Bucket | 管理者 | ほぼ変更しない |
| CI | CodePipeline、CodeBuild、GitHub接続、通知用のSNS Topic | 管理者 | CI構成を変えるときだけ |
| Runtime | Lambda本体、実行Role、Log Group、Alarm、定期実行のScheduler | Pipeline | コード変更のたび |
Pipelineが触るのはRuntime Stackだけです。Pipeline自身の定義(CI Stack)や権限の定義(Bootstrap Stack)は、Pipelineから更新させません。Pipelineが壊れたときに、Pipelineを使って直す状況を避けるためです。
使用技術
| 分類 | 使うもの |
|---|---|
| IaC | CloudFormation、AWS SAM |
| CI/CD | CodePipeline、CodeBuild、CodeConnections(GitHub接続)、CodeDeploy |
| 実行基盤 | Lambda(Python 3.12)、EventBridge Scheduler |
| 監視・通知 | CloudWatch Alarm、CloudWatch Logs、SNS、EventBridge Rule |
| 開発 | Python 3.12、pytest、PyYAML |
新しいサービスは使っていません。組み合わせ方だけを決めたものが、このテンプレートです。
3. 設計の方針
標準機能に寄せる
CI/CDを自分で作り始めると、だんだん「自作の仕組み」が増えていきます。デプロイ対象を記録するファイル、前回成功したコミットとの差分を見る処理、失敗したときに戻すスクリプト。どれも最初は便利ですが、増えるほどその仕組み自身の面倒を見る必要が出てきます。
そこで、判断の基準を1つ決めました。
標準機能で説明できない仕組みは作らない。
持たない仕組み
意図的に作らなかったものを、最初に書いておきます。
| 持たないもの | 代わりにどうするか |
|---|---|
| Git差分による対象Lambdaの選別 | 毎回すべてのStackを処理し、変更の有無はCloudFormationに判断させる |
| デプロイ対象の管理ファイル | ディレクトリの配置がそのまま対象一覧になる |
| ZIP作成、Version発行、Alias更新のスクリプト | SAMのビルドとAutoPublishAliasに任せる |
| Rollbackスクリプト | CloudFormationとCodeDeployの標準動作に任せる |
| Lambda Layer | 共有コードを各Lambdaのパッケージへ同梱する |
| Nested Stack | 使わない。必要性が確認できた時点で判断する |
「持たないもの」を先に決めると、設計が安定します。迷ったときに「それは持たない側だ」と判断できるためです。
毎回すべて配備する
このテンプレートは、Lambdaが10個あればデプロイのたびに10個すべてのStackを処理します。差分を見て絞り込みません。
「無駄では」と思われるところですが、実際に無駄なリリースは起きません。CloudFormationが変更の有無を判断するためです。
| 期待する挙動 | どう実現されるか |
|---|---|
| テストだけを直したときにLambdaを更新しない | コードが変わらないので、Change Setが空になる |
| 関係ない設定だけを変えたときにVersionを増やさない | 変更されたResourceだけが更新される |
| 1つのLambdaを直したときに他へ影響しない | Stackが分かれているため、他StackのChange Setは空になる |
Change Setは、CloudFormationが「今の状態と比べて何を変えるか」をまとめた変更計画です。中身が空なら何も起きません。
代わりに失うものもあります。Lambdaの数が増えると、デプロイ時間が全体の数に比例して伸びます。この構成はLambdaが10〜15個程度までを想定しています。
4. 1つのStackに何を入れるか
1 Runtime Stack = 1つのデプロイ単位
Runtime Stackは「まとめて配備したい範囲」を1つにしたものです。単独のLambdaであれば、こうなります。
Runtime Stack: example-project-dev-daily-report
│
├ Scheduler 定期実行(EventBridge Scheduler)
│ └ Scheduler Role このLambdaを呼ぶ権限だけ
│ │ 呼び出し
│ ▼
├ Lambda
│ ├ live Alias 呼び出し先はここに固定
│ ├ Version デプロイのたびに発行
│ └ 実行Role 自分のLog Groupへ書く権限だけ
│ │ 出力
│ ▼
├ Log Group 保持期間を指定。Stack削除後も残す
│
└ Alarm × 2 エラー数と実行時間
Lambdaだけを別Stackにして、Schedulerやログを共通Stackへ置く構成も考えられます。そうしなかった理由は2つあります。
| 理由 | 内容 |
|---|---|
| 追いやすさ | 1つのTemplateを読めば、その処理に関わるResourceが全部分かる |
| 参照の制約 | Stackをまたいで値を渡すと、参照されている間はそのStackを作り直せなくなる |
名前の付け方
Stack名は<Project名>-<環境>-<Runtime名>に固定します。この例ならexample-project-dev-daily-reportです。
その中のResource名も、Stack名を土台に付けます。
| Resource | 名前 |
|---|---|
| Lambda関数 | example-project-dev-daily-report |
| 実行Role | example-project-dev-daily-report-execution |
| Alarm | example-project-dev-daily-report-errors |
名前を固定しているのは、IAMで操作範囲を絞るためです。「example-project-dev-*という名前のLambdaだけ作れる」という許可の書き方ができます。ここは第2回で詳しく書きます。
5. ディレクトリ全体
repository/
├ application/ # Lambdaの実行コードとテスト
│ ├ common/ # 全Lambdaへ同梱する共有コード
│ │ ├ logger.py
│ │ └ utils.py
│ ├ functions/
│ │ └ daily_report/
│ │ ├ handler.py # Lambdaの入口
│ │ └ requirements.txt # この関数が使う外部ライブラリ
│ └ tests/ # pytestのテスト
├ infrastructure/ # Template(設計)と設定値
│ ├ bootstrap/template.yaml
│ ├ ci/template.yaml
│ ├ runtimes/
│ │ └ daily-report/
│ │ ├ template.yaml # Runtime 1つ分
│ │ └ parameters.json # このRuntimeの設定値
│ ├ env/
│ │ ├ dev/
│ │ ├ stg/
│ │ └ prod/ # 環境ごとの設定値
│ └ tests/ # Templateの構造テスト
└ deployment/ # Test / Build / Deployの補助処理
├ buildspecs/
└ scripts/
infrastructure/runtimes/のディレクトリ名が、そのままRuntime名になります。別途一覧ファイルを持ちません。 ディレクトリを置くことが「デプロイ対象である」という意味になります。
日常的に触るのはこの2か所です。
| やりたいこと | 触る場所 |
|---|---|
| 処理を書く・直す | application/functions/<関数名>/handler.py |
| 実行間隔を変える | infrastructure/runtimes/<Runtime名>/parameters.json |
6. 手元で動かす
このテンプレートは、AWSのアカウントがなくてもテストまで動きます。必要なのはPython 3.12だけです。
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-dev.txt
python -m pytest
SAM CLIもDockerもAWSの認証情報も要りません。CI/CDを組むとき、手元で確認できる範囲を広く取っておくと開発が楽になります。
設定値が正しく組み立てられるかも、AWSへ接続せずに確認できます。
python -m deployment.scripts.parameters --environment dev
daily-report: parameters valid (7)
この確認処理は、Templateが宣言しているParameterと、JSONに書いた設定値を突き合わせます。Templateに無いキーを書いていれば、ここでエラーになります。デプロイ中に気付くより、手元で気付いたほうが早いためです。
なお、値そのものは表示しません。件数だけを出します。CIのログに設定値が残ることを避けるためです。
7. つまずいたところ
S3 Bucketの名前が途中で切れる
構築して最初に気付いた問題です。作られたBucketの名前がこうなっていました。
example-project-dev-bootst-deploymentbucket-f2je2bt672l3
bootstrapがbootstで切れています。
CloudFormationは、名前を指定しなかったResourceに<Stack名>-<論理ID>-<ランダム>という名前を付けます。S3 Bucketの名前は63文字までなので、超えた分は途中が切り詰められます。
| 要素 | 文字数 |
|---|---|
Stack名(example-project-dev-bootstrap) |
29 |
論理ID(DeploymentBucket) |
16 |
| ランダム部分とハイフン | 15 |
| 合計 | 60 |
Project名が長くなると、すぐに63文字を超えます。名前が切れると、名前でResourceを判別できなくなり、名前で許可範囲を絞っているIAMからも外れる恐れがあります。
対処は2つです。
- 論理IDを短くする(
DeploymentBucket→DeployBucket) - Project名の上限を20文字に決める
そして、二度と起きないようにテストを書きました。Project名とRuntime名を上限いっぱいにした最長のStack名で、すべてのResource名が上限に収まるかを確認します。
# Project名を上限の20文字にした、最も長くなるStack名で確認する
PROJECT = "p" * 20
ENVIRONMENT = "prod"
def test_resource_names_fit_their_limits():
stack = f"{PROJECT}-{ENVIRONMENT}-{runtime_name}"
for logical_id, resource in resources.items():
# 名前を指定しないResourceは <Stack名>-<論理ID>-<ランダム13文字>
assert generated_length(stack, logical_id) <= limit_of(resource)
こうした「名前の長さ」は設計の地味な部分ですが、あとから直すとResourceの作り直しになります。最初にテストで縛っておくと安心です。
8. おわりに
今回は全体像と、設計の方針を書きました。
改めて並べると、決めたことは次の3つだけです。
- 標準機能で説明できない仕組みは作らない
- 1つのStackに、まとめて配備したい範囲を入れる
- 名前はStack名から決める
次回は、実際にAWSへBootstrap StackとCI Stackを作り、pushでLambdaが配備される状態まで進めます。CI/CDで最初につまずきやすいIAMの権限設計を、2つのRoleの役割分担という形で整理します。