前回(第3回)は、CDK アプリを環境ごとに分けてデプロイする話を書きました。dev と prod で設定を切り替えて、あとは cdk deploy を叩けば出来上がり……なんですが、ここで一つ問題がありまして。
その cdk deploy、全部自分の MacBook から打ってるんですよね。深夜にコーヒー飲みながら prod に deploy して、翌朝「あれ、あの変更どのブランチだっけ」となったことがあります。というわけでシリーズ最終回は、CI/CD パイプラインで自動化する話と、ここまでの3回で踏み抜いてきた落とし穴の供養をまとめておきます。
そもそも手元デプロイの何がまずいのか
自分が実際に困ったのは、だいたいこの3つでした。
- ローカルの
aws-cdk-libのバージョンが微妙に古くて、synth 結果が他の環境と一致しない - 「テストしてからデプロイ」を守る気があるのは最初の3日だけ
- 誰が何をいつ deploy したのかがどこにも残らない(自分ひとりでも困る)
特に1つ目は地味に厄介で、CDK CLI(aws-cdk)と Construct Library(aws-cdk-lib)のリリースサイクルが分離されていて、バージョン番号も別々になっています。「CLI は新しいけどライブラリは去年のまま」みたいな状態が普通に起こるので、ビルド環境を固定できるのは思ったよりありがたいです。
CDK Pipelines で自動デプロイを構成する
CDK には aws_cdk.pipelines という、CodePipeline を CDK から組み立てるためのモジュールがあります。普通に aws_cdk.aws_codepipeline を使って書くこともできるんですが、CDK アプリをデプロイするならこっちのほうが圧倒的に短く済みます。
ソースは GitHub にしました。CodeConnections 経由でつなぐことで、マネジメントコンソールでの初期設定を最小限に抑えます。接続自体は一回作って、ARN をメモしておく方式です(ここだけ手作業になるのがちょっと残念)。
from aws_cdk import Stack, Stage, Environment
from aws_cdk import pipelines
from constructs import Construct
from myapp.app_stack import AppStack
class AppStage(Stage):
def __init__(self, scope: Construct, id: str, **kwargs) -> None:
super().__init__(scope, id, **kwargs)
AppStack(self, "App")
class PipelineStack(Stack):
def __init__(self, scope: Construct, id: str, **kwargs) -> None:
super().__init__(scope, id, **kwargs)
source = pipelines.CodePipelineSource.connection(
"nyanchu/cdk-sample",
"main",
connection_arn="arn:aws:codeconnections:ap-northeast-1:123456789012:connection/xxxxxxxx",
)
pipeline = pipelines.CodePipeline(
self, "Pipeline",
synth=pipelines.ShellStep(
"Synth",
input=source,
install_commands=[
"npm i -g aws-cdk",
"pip install -r requirements.txt",
],
commands=["pytest -q", "cdk synth"],
),
)
pipeline.add_stage(AppStage(
self, "Dev",
env=Environment(account="111111111111", region="ap-northeast-1"),
))
prod = pipeline.add_stage(AppStage(
self, "Prod",
env=Environment(account="222222222222", region="ap-northeast-1"),
))
prod.add_pre(pipelines.ManualApprovalStep("ApproveProd"))
これで cdk deploy PipelineStack を一回だけ手元から実行すれば、あとは main に push するたびに synth → dev デプロイ → 承認待ち → prod デプロイ、が回ります。
self-mutation という気持ち悪いくらい便利な仕組み
CDK Pipelines のいちばんの特徴は、パイプライン自身が自分を更新する(self-mutating)ところです。add_stage() を1行足して push すると、パイプラインがまず自分を作り直してから、新しいステージのデプロイに進みます。パイプラインを直したいときにパイプラインを手で直さなくていい、という再帰っぽい話で、最初に動いたときは「え、いま何が起きた?」となりました。
逆に言うと、この自己更新の分だけ実行時間は増えます。イテレーションを速く回したい検証段階だけ self_mutation=False にしておく、という手もあるようです(本番でこれをやると意味がなくなるので注意)。
3回書いて踏み抜いた落とし穴
bootstrap の信頼関係を忘れる
いちばん時間を溶かしたのがこれです。パイプラインのアカウントとデプロイ先アカウントが別(あるいはリージョンが別)の場合、デプロイ先側で「パイプライン側アカウントを信頼する」形で bootstrap し直す必要があります。
cdk bootstrap aws://222222222222/ap-northeast-1 \
--trust 111111111111 \
--cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess
これを忘れると、パイプラインが CodeBuild のところで見慣れない AssumeRole エラーを吐いて止まります。エラーメッセージだけ見ても原因がわかりにくいので、パイプラインが赤くなったらまず bootstrap を疑うのが早いです。ちなみに --cloudformation-execution-policies に AdministratorAccess を渡すのは楽ですが、本番では権限を絞ったほうがいいとよく言われます。自分もまだ完全には絞りきれていないのが実情です。
Docker イメージを使うと突然コケる
Lambda を DockerImageFunction やバンドリング付きで作っていると、synth 時に Docker が必要になります。ケースによっては CodeBuild 側で Docker を扱えるようにする必要があるので、ShellStep ではなく CodeBuildStep に差し替えて privileged を有効にします。
from aws_cdk import aws_codebuild as codebuild
synth = pipelines.CodeBuildStep(
"Synth",
input=source,
install_commands=["npm i -g aws-cdk", "pip install -r requirements.txt"],
commands=["pytest -q", "cdk synth"],
build_environment=codebuild.BuildEnvironment(
build_image=codebuild.LinuxBuildImage.STANDARD_7_0,
privileged=True, # ここ注意
),
)
IAM 権限が知らないうちに広がる
grant_read_write() みたいな便利メソッドは楽ですが、コード上の1行が裏で何をしているかは synth するまで見えません。prod ステージの前に ConfirmPermissionsBroadening を挟んでおくと、権限やセキュリティグループが広がる変更のときだけ承認待ちで止まってくれます。
prod_stage = AppStage(self, "Prod", env=prod_env)
pipeline.add_stage(prod_stage, pre=[
pipelines.ConfirmPermissionsBroadening("SecurityCheck", stage=prod_stage),
])
アセットの S3 バケットが太る
デプロイのたびに bootstrap 用バケットへアセットが積まれていくので、放っておくと地味に課金されます。cdk gc という未使用アセットを掃除するコマンドがあって、個人アカウントでは助かる存在です。自分は月末にまとめて叩いています。
テストは「全部書く」より「壊れたら困るところだけ」
CDK のテストは aws_cdk.assertions でやります。スタックを synth して、出来上がった CloudFormation テンプレートに対してアサーションを書く、という形です。
import aws_cdk as cdk
from aws_cdk.assertions import Template, Match
from myapp.app_stack import AppStack
def test_table_is_retained():
template = Template.from_stack(AppStack(cdk.App(), "Test"))
template.has_resource("AWS::DynamoDB::Table", {"DeletionPolicy": "Retain"})
def test_bucket_is_not_public():
template = Template.from_stack(AppStack(cdk.App(), "Test"))
template.has_resource_properties("AWS::S3::Bucket", {
"PublicAccessBlockConfiguration": Match.object_like({
"BlockPublicAcls": True,
}),
})
最初は全リソースにテストを書こうとして、CDK のバージョンを上げた瞬間に半分落ちて心が折れました。今は「消えたら復旧できないもの(DynamoDB の DeletionPolicy)」「公開されたら事故るもの(S3 のパブリックアクセス)」あたりに絞っています。テンプレート全体のスナップショットテストも入れていますが、これは差分検知用の保険くらいの気持ちです。
あと cdk-nag という、ベストプラクティス違反を synth 時に警告してくれるツールがあります。試しに入れたら既存スタックで20個くらい怒られて、半分くらいは自分でも「まあそうだよな」という内容でした。抑制(suppression)を書きまくると本末転倒になるので、ここは付き合い方を模索中です。
そもそも CDK Pipelines を使うべきか問題
ここまで書いておいて何なんですが、個人開発で CDK Pipelines が最適解かというと、正直ちょっと悩みます。GitHub Actions から OIDC で AWS のロールを引き受けて cdk deploy するだけ、というやり方でも同じことはできますし、そっちのほうが YAML 数十行で済みます。
ざっくり自分の整理はこんな感じです。
- CDK Pipelines:マルチアカウント/マルチリージョンにデプロイする、承認フローを AWS 側で完結させたい、パイプライン定義も Python で書きたい
- GitHub Actions + OIDC:単一アカウント、ビルドが軽い、無料枠の範囲で回したい
CodePipeline は V2 タイプだと実行時間ベースの課金が絡んでくるので、動かす頻度によっては料金の見積もりも変わってきます。このへんは実運用の経験がまだ浅いので、まだ確実なことは言えません。
4回書いてみて
第1回で cdk init を叩いたときは「YAML 書かなくていいの最高だな」くらいの感想だったんですが、4回目にしてようやく、CDK の本体は Construct のツリーであって CloudFormation は出力形式にすぎない、という感覚が少しつかめてきた気がします。逆に言うと、CloudFormation の知識がゼロだとエラーの読み方で詰まるので、そこは避けて通れないっぽいです。
余談ですが、このシリーズを書いている間に気になり続けていたのが Terraform との比較で、「同じことを Terraform でやるとどうなるのか」は手を動かしてみたいところです。CDK for Terraform という選択肢もあるらしく、そうなるとまた沼が深くなりそうな予感がしています。
ところで、みなさんは prod へのデプロイ、承認ステップって挟んでいますか。自分は最初「ひとりだし要らないでしょ」と思っていたんですが、深夜の自分を信用しないという意味では、あって良かった機能でした。
※この記事にはプロモーションが含まれます
ちなみに、お名前.com レンタルサーバー(WordPressに特化した高速レンタルサーバー。月額990円〜、独自ドメイン実質0円)も気になっています。お名前.com レンタルサーバー![]()

