はじめに
Goのモノレポに複数のLambda(user / order / post)を同居させて運用していると、あるところで必ずこう思います。
「cmd/user を1行直しただけなのに、なんで order と post までビルドし直してデプロイしてるんだ?」
関数が3つのうちは我慢できますが、10個、20個と増えればビルド時間もデプロイのリスクも線形に増えていきます。変更と無関係な関数に新しいVersionが発行され、無関係なデプロイ履歴が積み上がるのも気持ちが悪い。
そこで、Git差分とGoの依存グラフから「本当に影響を受ける関数」だけを判定して、その関数だけをビルド・デプロイする CodePipeline 基盤を作りました。この記事はその実装記録です。
リポジトリはこちらです。
最初にお断り
本リポジトリは学習目的で作っており、シンプルさを最優先しています。実際にドキュメントにもその旨を明記しています。
[!NOTE]
本リポジトリは学習目的のためリッチなステップわけはせず、簡素な作りにしています。
(docs/pipeline-design.md)
そのためこの記事で扱う範囲は、あくまで 「差分を検出して、デプロイ範囲を安全側に過大近似する」仕組み です。
- ECR vs ZIP、Lambda vs Fargate/ALB といったインフラ技術選定の是非には踏み込みません
- 本番運用で必要になる Canary デプロイや CloudWatch Alarm の整備は「今後の課題」として触れる程度に留めます
逆に言えば、変更検出ロジックとその限界については踏み込んで書きます。ここが一番おもしろく、そして一番「割り切り」が必要だった部分だからです。
対象読者
- Goでモノレポ構成のLambdaを運用したい人
- モノレポにおけるCI/CDの「変更検出 → 対象だけデプロイ」の実装例を知りたい人
- CodePipeline / CodeDeploy / CDK(Go) を使ったサーバーレスデプロイの具体例を探している人
前提技術・前提知識
- Go、AWS Lambda、API Gateway、AWS CDK の基本的な知識があるとスムーズに読めます
- CodePipeline 自体の基本については、以前書いた記事を参照してください
- CodeDeploy による Lambda の Blue/Green デプロイ(Version / Alias)の概念を軽く知っていると理解しやすいです
リポジトリ全体像
何を作ったか
Goモノレポ内の3関数を独立したLambdaとして管理し、Git差分とGo依存グラフから影響を受ける関数だけを CodePipeline でデプロイする仕組みです。
ディレクトリ構成と役割分担は次のとおりです。
codepipeline_monorepo_practice/
├── cmd/ # Lambdaのエントリポイント(1関数=1ディレクトリ)
│ ├── user/main.go
│ ├── order/main.go
│ └── post/main.go
├── internal/ # 共通・アプリケーションロジック
│ ├── httpapi/ # API Gateway HTTP API 用の共通ハンドラ
│ ├── user/
│ ├── order/
│ └── post/
├── infra/ # CDK for Go(Network / Storage / Application)
├── build/
│ └── lambda.Dockerfile # arm64 Lambdaイメージのビルド定義
├── scripts/
│ ├── pipeline-deploy.sh # ★本題。変更判定 → ビルド → デプロイ
│ ├── prepare-application-deploy.sh
│ └── push-initial-images.sh
├── docs/ # 設計ドキュメント
├── buildspec.yml
└── Taskfile.yml
ポイントは、cmd/ のディレクトリ名がすべての基準になることです。
- 変更影響判定の対象名
- Lambda関数名の末尾(
dev-codepipeline-monorepo-practice-user) - ECR Repository名
- CodeDeploy Deployment Group名
これらがすべて user / order / post というディレクトリ名から機械的に導出されます。命名規則を1本化しておくと、判定スクリプト側で分岐を持たずに済みます。
管理対象の3関数と API Gateway ルート
cmd |
Lambda関数名 | HTTP API Route |
|---|---|---|
cmd/user |
<env>-codepipeline-monorepo-practice-user |
/users、/users/{proxy+}
|
cmd/order |
<env>-codepipeline-monorepo-practice-order |
/orders、/orders/{proxy+}
|
cmd/post |
<env>-codepipeline-monorepo-practice-post |
/posts、/posts/{proxy+}
|
ハンドラ自体は極小です。3関数とも internal/httpapi の共通ハンドラを、サービス名だけ変えて呼んでいるだけです。
// cmd/user/main.go
package main
import (
"github.com/aws/aws-lambda-go/lambda"
"backend/internal/user"
)
func main() {
lambda.Start(user.NewHandler())
}
// internal/user/handler.go
package user
import (
"context"
"github.com/aws/aws-lambda-go/events"
"backend/internal/httpapi"
)
func NewHandler() func(context.Context, events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) {
return httpapi.NewHandler("user")
}
リクエストの内容をJSONで返すだけの実装です。この記事の主題はアプリケーションではなくパイプライン側の仕組みなので、ここは意図的に薄くしています。
なお、この internal/httpapi は3関数すべてが依存している共通パッケージです。後述する「過剰デプロイ」の話で再登場します。
インフラ構成(さらっと紹介)
infra/main.go を唯一のCDKエントリポイントとして、3つのStackを1つのAppで管理しています。
func main() {
defer _jsii_.Close()
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
app := awscdk.NewApp(nil)
props := stackProps(cfg)
network := stacks.NewNetworkStack(app, cfg, props)
storage := stacks.NewStorageStack(app, cfg, props)
stacks.NewApplicationStack(app, cfg, network, storage, props)
app.Synth(nil)
}
| Stack | 中身 |
|---|---|
| Network Stack | API Gateway HTTP API、$default Stage |
| Storage Stack | パイプラインアーティファクト用 S3 Bucket、関数別ECR Repository ×3、SSM Parameter |
| Application Stack | Lambda(ECRイメージ)、Version、live Alias、CodeDeploy、CodeBuild、CodePipeline |
Lambdaは arm64 のコンテナイメージ形式、ランタイムは provided.al2023 です。
冒頭で書いたとおり、ECR vs ZIP や Lambda vs Fargate/ALB といった技術選定の理由はこの記事の本題ではないので深掘りしません。ここでは「3層に分かれていて、Application Stack にデプロイ関連リソースが集まっている」ことだけ押さえていただければ十分です。
1点だけ:CDKとパイプラインの「イメージタグ争奪戦」を避ける
これは差分デプロイと直結する設計なので触れておきます。
Lambdaのコンテナイメージ形式では、CDKが imageUri(=タグ)を管理します。素直に書くと、パイプラインが latest や Git SHA でイメージを更新した後に cdk deploy すると、CDKが管理しているタグに巻き戻ってしまう問題が起きます。
そこで本リポジトリでは、現在のイメージタグをSSM Parameterに置き、CDKはそれを参照するだけにしました。
fn := awslambda.NewDockerImageFunction(stack, _jsii_.String(title(name)+"Function"), &awslambda.DockerImageFunctionProps{
FunctionName: _jsii_.String(functionName),
Architecture: awslambda.Architecture_ARM_64(),
Code: awslambda.DockerImageCode_FromEcr(repository, &awslambda.EcrImageCodeProps{
TagOrDigest: awsssm.StringParameter_ValueForStringParameter(
stack,
_jsii_.String(common.ImageTagParameterName(cfg.Environment, name)), // /dev/.../functions/user/image-tag
nil,
),
}),
// ...
})
パイプラインはデプロイ成功時にこのParameterを更新するので、後から cdk deploy してもイメージが巻き戻りません。「コードはパイプラインが、構成はCDKが管理する」という境界を、SSM Parameterで受け渡しているイメージです。
デプロイ戦略:CodeDeployDefault.LambdaAllAtOnce を選んだ理由
各関数の旧VersionをBlue、新VersionをGreenとして扱い、live Aliasの参照先を切り替えます。
変更前: API Gateway -> live Alias -> Version 1 (100%)
変更後: API Gateway -> live Alias -> Version 2 (100%)
CodeDeployDefault.LambdaAllAtOnce は、この切り替えを一度に100%行う設定です。ECS Cluster、Task Set、ALB、Target Group は一切使いません。
CDK側の記述もシンプルです。
awscodedeploy.NewLambdaDeploymentGroup(stack, _jsii_.String(title(name)+"DeploymentGroup"), &awscodedeploy.LambdaDeploymentGroupProps{
Application: application,
Alias: alias,
DeploymentConfig: awscodedeploy.LambdaDeploymentConfig_ALL_AT_ONCE(),
DeploymentGroupName: _jsii_.String(common.ResourceName(cfg.Environment, name+"-deployment-group")),
AutoRollback: &awscodedeploy.AutoRollbackConfig{
FailedDeployment: _jsii_.Bool(true),
StoppedDeployment: _jsii_.Bool(true),
},
})
なぜ Canary / Linear ではないのか
Canary(例: Canary10Percent5Minutes)やLinearは魅力的ですが、それらが真価を発揮するのは「異常を検知して自動ロールバックする」仕組みとセットのときです。つまり、
- PreTraffic / PostTraffic Hook(Lambdaで疎通・スモークテスト)
- CloudWatch Alarm(エラー率、レイテンシ)
が揃って初めて「10%流して様子を見る」に意味が出ます。これらを用意しないままCanaryにしても、単にデプロイが遅くなるだけです。
今回は仕組みをシンプルに保つことを優先し、All-at-once + AutoRollback(デプロイ失敗・停止時のみ) という割り切りにしました。現状のAutoRollbackは「デプロイそのものが失敗したとき」しか効かないので、正常終了した後の論理バグまでは自動検知できません。ここは自覚したうえでの割り切りです。
それでもCodeDeployを噛ませたのは、次の3点を先に確保しておきたかったからです。
- デプロイ履歴が残る
- Version / Alias による切り替えという形が固定される
- あとからCanary / Linearへ移行する経路が確保される
DeploymentConfig を1行差し替えればCanaryに移行できる状態を作っておく、というのが狙いです。
本題:変更影響判定ロジック(差分デプロイの仕組み)
ここからが本題です。実装はすべて scripts/pipeline-deploy.sh にあります。
パイプラインの流れ
GitHub (push)
|
| CodeConnections
v
CodePipeline
|
+-- Source: 対象コミットを取得(Clone形式)
| └ Commit ID を HEAD_SHA として後続へ渡す
|
+-- BuildDeploy: CodeBuild で scripts/pipeline-deploy.sh を実行
|-- 1. SSMから BASE_SHA を取得
|-- 2. BASE_SHA..HEAD_SHA の変更ファイルを列挙
|-- 3. Go依存グラフで影響を受ける関数を判定
|-- 4. テスト実行
|-- 5. 対象だけ arm64 イメージをビルドし ECR へ push
|-- 6. update-function-code --publish で新Version発行
|-- 7. AppSpecを生成して CodeDeploy Deployment を作成
|-- 8. デプロイ成功と live Alias の参照先を確認
`-- 9. SSM Parameter を HEAD_SHA へ更新
基準コミットを SSM Parameter で管理する
「差分」を取るには基準が要ります。GitHub Actions なら github.event.before などが使えますが、CodePipeline のSource Actionが渡してくれるのはそのとき取得したコミットID(HEAD_SHA)だけです。「前回どこまでデプロイしたか」は自分で持つ必要があります。
そこで、SSM Parameter Store に最後に成功したSHAを保持します。
/<env>/codepipeline-monorepo-practice/last-successful-commit
base_sha="$(aws ssm get-parameter --name "${PARAMETER_NAME}" --query 'Parameter.Value' --output text)"
if [[ -z "${base_sha}" || "${base_sha}" == "None" || "${base_sha}" == "UNSET" ]]; then
echo "Deployment baseline is not initialized: ${PARAMETER_NAME}" >&2
exit 1
fi
HEAD_SHA 側は、CDKでSource ActionのCommit IDをCodeBuildの環境変数として明示的に渡しています。
awscodepipelineactions.NewCodeBuildAction(&awscodepipelineactions.CodeBuildActionProps{
ActionName: _jsii_.String("SelectiveLambdaDeploy"),
Project: project,
Input: sourceOutput,
EnvironmentVariables: &map[string]*awscodebuild.BuildEnvironmentVariable{
"HEAD_SHA": {Value: sourceAction.Variables().CommitId},
},
})
こうしておくと、パイプライン実行中にブランチが進んでも、判定対象とデプロイ対象のコミットが必ず一致します。
この設計には、いくつか押さえておくべき前提があります。
-
Parameterは初回Applicationデプロイ前にGit SHAで初期化済みであること。 未初期化(
UNSET)ならパイプラインを失敗させます。「基準がないからとりあえず全部デプロイ」ではなく、明示的に落とす方針にしました - Parameterの更新は全対象の更新が成功した後だけ。 途中で失敗したら基準SHAは据え置きなので、次回実行時に同じ差分が再評価され、必要な関数が再度デプロイされます
- パイプライン実行は直列化する。 並列実行するとParameter更新が競合し、古いコミットが新しいコミットを上書きしかねません
3点目はCDK側で対応しています。
pipeline := awscodepipeline.NewPipeline(stack, _jsii_.String("Pipeline"), &awscodepipeline.PipelineProps{
PipelineName: _jsii_.String(common.ResourceName(cfg.Environment, "pipeline")),
PipelineType: awscodepipeline.PipelineType_V2,
ExecutionMode: awscodepipeline.ExecutionMode_QUEUED, // 直列化
// ...
})
【ハマりどころ】Source Action は Clone形式にする
これは地味ですが必須です。CodePipeline のSource Actionは、デフォルトではソースコードをZIPに固めてS3に置くため、.git ディレクトリが失われます。当然 git diff は打てません。
CodeBuildCloneOutput: true を指定して、Gitメタデータ付きのClone形式で受け渡します。
sourceAction := awscodepipelineactions.NewCodeStarConnectionsSourceAction(&awscodepipelineactions.CodeStarConnectionsSourceActionProps{
ActionName: _jsii_.String("GitHubSource"),
ConnectionArn: _jsii_.String(cfg.ConnectionArn),
Owner: _jsii_.String(cfg.RepositoryOwner),
Repo: _jsii_.String(cfg.RepositoryName),
Branch: _jsii_.String(cfg.RepositoryBranch),
Output: sourceOutput,
CodeBuildCloneOutput: _jsii_.Bool(true), // ★ Gitメタデータを保持する
})
これに伴い、CodeBuild Roleには codeconnections:UseConnection の権限が必要になります。
project.AddToRolePolicy(awsiam.NewPolicyStatement(&awsiam.PolicyStatementProps{
Actions: _jsii_.Strings("codeconnections:UseConnection"),
Resources: _jsii_.Strings(cfg.ConnectionArn),
}))
さらに、Clone形式でも履歴が浅くて BASE_SHA に到達できない場合があります。その場合はfetchを試み、それでもダメなら安全側(全関数デプロイ)に倒します。
if ! git cat-file -e "${base_sha}^{commit}" 2>/dev/null; then
git fetch --no-tags origin "${base_sha}" || true
fi
declare -a targets=()
if ! git cat-file -e "${base_sha}^{commit}" 2>/dev/null; then
echo "Baseline ${base_sha} is unavailable; deploying all functions."
targets=("${FUNCTIONS[@]}")
else
# ... 差分判定へ
fi
判定ルールの一覧
実装している判定ルールは次のとおりです。
| 変更 | 対象 |
|---|---|
docs/**、*_test.go
|
デプロイなし(テスト自体は実行する) |
cmd/<name>/** |
<name> のみ |
internal/** の .go
|
Go依存グラフで判定(そのパッケージに依存するすべての cmd) |
go.mod、go.sum
|
全関数 |
buildspec.yml、scripts/**
|
全関数 |
infra/**(CDKコード) |
全関数(+CDK反映は別途手動) |
削除(D)・リネーム(R)で cmd/・internal/ 配下 |
全関数 |
| 上記以外のすべて | 全関数 |
最後の行が重要です。知らないパスは全部「全関数デプロイ」に倒します。 新しいディレクトリが増えたときに、判定ロジックの更新漏れで「デプロイされるべき関数がスキップされる」事故を防ぐためです。
対応するコードがこちらです。
mapfile -t changes < <(git diff --name-status "${base_sha}" "${HEAD_SHA}")
declare -A selected=()
deploy_all=false
for change in "${changes[@]}"; do
status="${change%%$'\t'*}"
paths="${change#*$'\t'}"
# 削除・リネームは依存グラフで追えないので安全側に倒す
if [[ "${status}" == D* || "${status}" == R* ]]; then
if [[ "${paths}" == cmd/* || "${paths}" == internal/* ]]; then
deploy_all=true
fi
continue
fi
path="${paths##*$'\t'}"
case "${path}" in
docs/*|*_test.go)
;;
cmd/user/*) selected[user]=1 ;;
cmd/order/*) selected[order]=1 ;;
cmd/post/*) selected[post]=1 ;;
internal/*.go|internal/*/*.go|internal/*/*/*.go)
# ↓ 次節で解説
;;
go.mod|go.sum|buildspec.yml|scripts/*|infra/*) deploy_all=true ;;
*) deploy_all=true ;;
esac
done
Go依存グラフでの絞り込み実装
さて、いちばん面白いところです。internal/** が変更されたとき、どの cmd がその変更の影響を受けるかをどう判定するか。
答えはシンプルで、Goのツールチェーンに聞けばいいです。
go list -deps -f '{{.ImportPath}}' ./cmd/user
これで cmd/user が直接・間接に依存する全パッケージのImport Pathが列挙されます。
...
backend/internal/httpapi
backend/internal/user
backend/cmd/user
あとは「変更されたファイルのディレクトリ」をImport Pathに変換して、この集合に含まれるかを見るだけです。
変更されたpackage ∩ cmdの依存package ≠ 空集合
-> その cmd をデプロイ対象にする
実装がこちらです。
internal/*.go|internal/*/*.go|internal/*/*/*.go)
package_dir="${path%/*}"
# ファイルパス -> Import Path へ変換(例: internal/httpapi -> backend/internal/httpapi)
import_path="$(go list -f '{{.ImportPath}}' "./${package_dir}" 2>/dev/null || true)"
# 変換に失敗した = 判定不能なので安全側へ
if [[ -z "${import_path}" ]]; then
deploy_all=true
continue
fi
# 各関数の依存グラフに含まれるかチェック
for function_name in "${FUNCTIONS[@]}"; do
if go list -deps -f '{{.ImportPath}}' "./cmd/${function_name}" | grep -Fxq "${import_path}"; then
selected["${function_name}"]=1
fi
done
;;
grep -Fxq は「固定文字列」「行全体一致」「マッチしたら即終了」の組み合わせです。backend/internal/user が backend/internal/users に部分一致してしまう事故を防ぐために、行全体一致(-x)は必須です。
この判定によって、たとえば次のように動きます。
| 変更したファイル | デプロイ対象 |
|---|---|
internal/user/handler.go |
user のみ |
internal/order/handler.go |
order のみ |
internal/httpapi/handler.go |
user、order、post(3関数とも依存しているため) |
最後に、集めた結果からデプロイ対象を確定します。
if [[ "${deploy_all}" == true ]]; then
targets=("${FUNCTIONS[@]}")
else
for function_name in "${FUNCTIONS[@]}"; do
[[ -z "${selected[${function_name}]:-}" ]] || targets+=("${function_name}")
done
fi
echo "BASE_SHA=${base_sha}"
echo "HEAD_SHA=${HEAD_SHA}"
echo "Deploy targets: ${targets[*]:-(none)}"
go test ./...
判定結果はビルドログに出力しておきます。「なぜこの関数がデプロイされた/されなかったのか」を後から追えることは、この手の仕組みでは想像以上に重要です。
なお、テストは対象を絞らずモノレポ全体で実行しています。この規模では絞る旨みが薄く、むしろ「絞り込みのバグでテストがスキップされる」リスクのほうが大きいと判断しました。
ビルドとデプロイ
対象が決まったら、あとは対象だけをループします。
for function_name in "${targets[@]}"; do
repository_name="${ENV_NAME}-codepipeline-monorepo-practice-${function_name}"
image_uri="${registry}/${repository_name}:${HEAD_SHA}"
# 同じSHAのイメージが既にあればビルドをスキップ(タグはIMMUTABLE設定)
if ! aws ecr describe-images --repository-name "${repository_name}" --image-ids "imageTag=${HEAD_SHA}" >/dev/null 2>&1; then
docker build --platform linux/arm64 \
--build-arg "FUNCTION_NAME=${function_name}" \
--tag "${image_uri}" \
--file build/lambda.Dockerfile .
docker push "${image_uri}"
fi
lambda_name="${ENV_NAME}-codepipeline-monorepo-practice-${function_name}"
# 新Versionを発行
target_version="$(aws lambda update-function-code \
--function-name "${lambda_name}" --image-uri "${image_uri}" --publish \
--query Version --output text)"
aws lambda wait function-updated --function-name "${lambda_name}"
# 現在 live が指しているVersion(= Blue)
current_version="$(aws lambda get-alias \
--function-name "${lambda_name}" --name live \
--query FunctionVersion --output text)"
# ...
done
イメージタグにはGit SHAを使い、ECR Repository は ImageTagMutability: IMMUTABLE にしています。「このSHAのコードはこのイメージ」という対応が一意に固定されるので、あとから「本番で動いているのはどのコミットか」を追うのが楽になります。
続いて、Blue/Green の組み合わせから AppSpec を生成して CodeDeploy を叩きます。ここは静的なファイルではなく、関数ごとに動的生成しているのがポイントです。
appspec="${function_dir}/appspec.yml"
{
echo "version: 0.0"
echo "Resources:"
echo " - LambdaFunction:"
echo " Type: AWS::Lambda::Function"
echo " Properties:"
echo " Name: ${lambda_name}"
echo " Alias: live"
echo " CurrentVersion: ${current_version}"
echo " TargetVersion: ${target_version}"
} > "${appspec}"
revision="${function_dir}/revision.json"
jq -n --rawfile content "${appspec}" \
'{revisionType:"AppSpecContent",appSpecContent:{content:$content}}' > "${revision}"
deployment_id="$(aws deploy create-deployment \
--application-name "${APPLICATION_NAME}" \
--deployment-group-name "${ENV_NAME}-codepipeline-monorepo-practice-${function_name}-deployment-group" \
--revision "file://${revision}" \
--query deploymentId --output text)"
aws deploy wait deployment-successful --deployment-id "${deployment_id}"
# live が本当に新Versionを指したかを確認する
deployed_version="$(aws lambda get-alias \
--function-name "${lambda_name}" --name live \
--query FunctionVersion --output text)"
[[ "${deployed_version}" == "${target_version}" ]]
対象関数の分しかDeploymentを作らないので、非対象の関数にはデプロイ履歴すら残りません。これが差分デプロイの一番わかりやすい効果です。
最後の [[ "${deployed_version}" == "${target_version}" ]] は地味ですが重要です。set -euo pipefail のもとでは、この比較が偽ならスクリプトがそこで終了します。「CodeDeployは成功と言っているが、実際にAliasは切り替わっているのか」を必ず確認してから次に進む、という趣旨です。
すべて成功したら、初めて基準SHAを更新します。
aws ssm put-parameter --name "${PARAMETER_NAME}" --type String --value "${HEAD_SHA}" --overwrite
精度の限界と向き合い方
ここまで「うまくいく話」を書いてきましたが、この判定には明確な限界があります。むしろこの節が、この記事で一番書きたかったところです。
限界1: パッケージ単位であって、関数単位ではない
go list -deps が教えてくれるのは、「cmd/user が internal/common パッケージに依存している」ことまでです。パッケージ内のどの関数を使っているかまでは分かりません。
cmd/user -> internal/common.FunctionA を使用
変更 -> internal/common.FunctionB(userは使っていない)
判定結果 -> user もデプロイ対象になる
user にとっては完全に無関係な変更でも、同じパッケージ内である以上デプロイされます。共通パッケージが大きくなるほど、この過剰デプロイは起きやすくなります。
限界2: DIコンテナで依存が過剰に広がる
これは実運用で効いてきそうな懸念です。共通パッケージのDIコンテナに全サービスのProviderをまとめて登録すると、実際に解決・使用する型は一部でも、Goのimport依存としては全サービスに依存しているように見えます。
結果として、どの関数を触っても全関数がデプロイ対象になり、差分デプロイの意味が消えます。対策としては、
- 共通パッケージを責務ごとに小さく分割する
- 各LambdaのComposition Rootでは、そのLambdaが必要とするProviderだけを登録する
つまり、「差分デプロイが効くコードベースかどうか」は、パッケージ設計そのものに依存するということです。CI/CD側の工夫だけでは限界があります。
限界3: import グラフに現れない依存は見えない
実行時の設定値、Reflection、外部API、Database Schema など、Goのimportグラフに現れない依存関係は go list -deps では判定できません。この種の変更は、そもそも判定ルール上「知らないパス」なので全関数デプロイに倒れます。
同様に、Build Tags や GOOS / GOARCH / CGO_ENABLED が判定時と実ビルド時で異なると、異なる依存グラフが生成されうる点にも注意が必要です。判定時の環境は、実際のLambdaビルドと揃えておくべきです。
限界4: 削除・移動されたパッケージは追跡できない
go list が見ているのは現在のworktreeです。削除されたパッケージはもう存在しないので、「変更前は誰がそれに依存していたか」を現在側からは調べられません。
そのため、次の変更は問答無用で全関数デプロイにしています。
-
internal配下のGoファイル削除 - Goパッケージのディレクトリ移動
-
cmdディレクトリの削除・改名
将来最適化するなら、Git worktree を使って BASE_SHA と HEAD_SHA の両方で依存グラフを生成し、その和集合で判定するのが筋の良い方向だと考えています。「変更前に依存していた関数」と「変更後に依存している関数」の両方をカバーできます。
設計思想:見逃しより過剰デプロイを許容する
限界を並べましたが、これらはすべて「同じ方向に倒れる」ように設計しています。
判定に自信がある -> 対象を絞る
判定に自信がない -> 全関数デプロイ
過剰デプロイのコストは「ビルド時間」と「無関係なVersionが増えること」です。一方、見逃しのコストは「デプロイしたつもりのコードが本番に出ていない」という、気づきにくく、デバッグが極めて困難な障害です。
このコストは明らかに非対称です。だから、迷ったら全部デプロイする。
case 文の最後が *) deploy_all=true ;; になっているのも、import_path の取得に失敗したら deploy_all=true にしているのも、削除・移動を全関数扱いにしているのも、すべて同じ思想の表れです。差分デプロイの仕組みで一番大事なのは、絞り込みの賢さではなくフェイルセーフの一貫性だと思います。
今後の課題・発展させるなら
CloudWatch Alarm と Hook を追加して Canary へ
現状のAutoRollbackは「デプロイ自体の失敗」しか拾えません。ここに、
- CloudWatch Alarm(Lambda の
Errors、Duration、API Gateway の5xx) - PreTraffic Hook(切り替え前のスモークテスト)
- PostTraffic Hook(切り替え後の検証)
を足したうえで、DeploymentConfig を CANARY_10PERCENT_5MINUTES などに差し替えれば、「異常を検知して自動で戻る」本来のBlue/Greenになります。前述のとおり、CDK側は1行の差し替えで済むように作ってあります。
インフラ変更の自動 cdk deploy 化と承認フロー
現状、infra/** の変更は「全関数をビルド・デプロイする」だけで、CDKの反映自体は手動です。IAMや公開Routeの変更が無レビューで自動適用されるのを避けたかったためです。
自動化するなら、専用Stage、手動承認Action、環境別Roleをセットで用意することになります。cdk diff の結果を承認画面に出す仕組みまで作れると理想的です。
共通パッケージの責務分割
「限界2」で書いたとおり、過剰デプロイを減らす一番効果的な手段は、CI/CDではなくコード設計側にあります。共通パッケージを責務ごとに小さく保つことが、そのまま差分デプロイの精度になります。
Git worktree による削除・移動対応
「限界4」の対応です。BASE_SHA 側のworktreeを作って依存グラフを取り、和集合で判定します。実装コストはそれなりですが、リファクタリングのたびに全関数デプロイになる状況が辛くなったら着手する価値があります。
まとめ
Goモノレポ × 複数Lambdaという構成で、
- SSM Parameter による基準コミット管理(前回成功したSHAを持つ)
-
go list -depsによる依存グラフ判定(変更パッケージ ∩ cmdの依存パッケージ) - CodeDeploy による Version / Alias 切り替え(対象関数だけにDeploymentを作る)
を組み合わせることで、「変更された関数だけを安全にデプロイする」仕組みを作れました。中核となる判定ロジックは、シェルスクリプト100行ちょっとに収まっています。
学習目的での割り切りポイントを改めて整理すると、
| 項目 | 現状 | 本番導入時に足すもの |
|---|---|---|
| デプロイ方式 | All-at-once | Canary / Linear + Alarm + Hook |
| ロールバック | デプロイ失敗時のみ自動 | メトリクス異常での自動ロールバック |
| インフラ変更 | 手動 cdk deploy
|
承認Action付きの専用Stage |
| 判定精度 | パッケージ単位・過大近似 | worktree差分、責務分割による精度向上 |
| テスト | モノレポ全体を実行 | (規模次第で)対象パッケージのみ |
となります。
そして最後にもう一度書いておきたいのは、「見逃しより過剰デプロイを許容する」という一貫した方針です。差分デプロイは「賢く絞る」仕組みに見えますが、実際に作ってみると、価値の大半は判定できないケースをすべて安全側に倒しきれているかにありました。
同じような構成を検討している方の参考になれば幸いです。