【第3回】AWS CDK × Python インフラ自動化入門 — スタックの分割・環境ごとのデプロイ管理を実践する

テックツール

前回(第2回)は Lambda と DynamoDB を CDK で作って、共通部分を Construct に切り出すところまでやりました。1つのスタックに全部詰め込んでいたので、動くには動くけど「これ規模が大きくなったらどうするんだ?」というモヤモヤが残っていたんですよね。

で、実際に自分の個人プロジェクトで dev と prod を分けようとした瞬間に詰まりました。というわけで今回はスタック分割とスタック間の依存関係、そして環境ごとのデプロイ管理です。ここが CDK で一番「設計」っぽくなる部分かなと思っています。

この記事でわかること

  • スタックを分ける判断基準(なんとなくで分けると後悔する)
  • Python でのスタック間の値の渡し方と、CDK が裏でやっていること
  • クロススタック参照の落とし穴「deadly embrace」と回避策
  • dev / prod を1つの CDK アプリで管理する構成

そもそもスタックを分ける必要ある?

正直、小規模なら分けなくていいと思っています。1スタックのほうが参照も楽だし、cdk deploy 一発で済むので。ただ、以下に当てはまると分割を考えたほうがよさそうです。

  • CloudFormation のリソース上限:1スタックあたり500リソースが上限です。CDK は L2 Construct が裏でけっこうな数のリソースを生成するので、体感より早く近づくとのこと
  • 変更頻度が違う:VPC や RDS みたいな「めったに変えない」ものと、Lambda みたいな「毎日変える」ものが同じスタックにいると、デプロイのたびに全部の diff を見る羽目になる
  • ライフサイクルが違う:アプリを消しても DB は残したい、みたいなケース
  • デプロイ単位を分けたい:Construct への切り出しはあくまでコードの分割で、デプロイ単位は変わりません。ここは最初勘違いしてました

自分の場合は3つ目の理由が大きかったです。DynamoDB のテーブルを間違って作り直したときの絶望感、二度と味わいたくない。

Python でスタックを分割してみる

典型的な3分割(ネットワーク/データ/アプリ)でいきます。ディレクトリはこんな感じ。

.
├── app.py
├── cdk.json
└── stacks/
    ├── __init__.py
    ├── network_stack.py
    ├── data_stack.py
    └── app_stack.py

まずデータ層。他のスタックから使いたいリソースは、素直にインスタンス属性として公開しておきます。

from aws_cdk import Stack, RemovalPolicy, aws_dynamodb as dynamodb
from constructs import Construct


class DataStack(Stack):
    def __init__(self, scope: Construct, construct_id: str, *, stage: str, **kwargs):
        super().__init__(scope, construct_id, **kwargs)

        self.table = dynamodb.Table(
            self, "MemoTable",
            partition_key=dynamodb.Attribute(
                name="pk", type=dynamodb.AttributeType.STRING
            ),
            billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
            removal_policy=(
                RemovalPolicy.DESTROY if stage == "dev" else RemovalPolicy.RETAIN
            ),
        )

このコード例では、DataStackself.table として DynamoDB テーブルを公開しています。stage によって削除ポリシーを分けることで、dev では手軽に作り直せ、prod では意図しない削除から守れます。

次にアプリ層。コンストラクタで ITable を受け取ります。ここで dynamodb.Table ではなく dynamodb.ITable 型で受けておくと、あとで Table.from_table_name() で作った参照も渡せるようになるので便利でした。

from aws_cdk import Stack, Duration, aws_lambda as lambda_, aws_dynamodb as dynamodb
from constructs import Construct


class AppStack(Stack):
    def __init__(self, scope, construct_id, *, table: dynamodb.ITable, stage: str, **kwargs):
        super().__init__(scope, construct_id, **kwargs)

        fn = lambda_.Function(
            self, "ApiHandler",
            runtime=lambda_.Runtime.PYTHON_3_13,
            handler="index.handler",
            code=lambda_.Code.from_asset("lambda"),
            timeout=Duration.seconds(10),
            environment={"TABLE_NAME": table.table_name, "STAGE": stage},
        )
        table.grant_read_write_data(fn)

AppStack では受け取ったテーブルに対して grant_read_write_data() を呼ぶことで、Lambda に自動的に必要な IAM 権限が与えられます。

そして app.py でつなぎます。

import aws_cdk as cdk
from stacks.data_stack import DataStack
from stacks.app_stack import AppStack

app = cdk.App()
env = cdk.Environment(account="123456789012", region="ap-northeast-1")

data = DataStack(app, "Memo-Data-dev", stage="dev", env=env)
AppStack(app, "Memo-App-dev", table=data.table, stage="dev", env=env)

Python 的には「オブジェクトを渡してるだけ」に見えますが、CDK は合成時にこれを検知して、DataStack 側に Outputs + Export、AppStack 側に Fn::ImportValue を自動生成してくれます。さらに、両スタックの間に依存関係も自動で追加されるので、cdk deploy --all したときのデプロイ順序も勝手に正しくなります。この「勝手にやってくれる」感じが CDK の気持ちいいところですね。

明示的に依存を張りたいとき

値の受け渡しがないのに順序だけ保証したい、というケースもあります。その場合は add_dependency() です。

app_stack.add_dependency(data_stack)

TypeScript の記事では addDependency と書かれていることが多いですが、Python は当然スネークケースになります。最初 addDependency と書いて AttributeError を出しました。

クロススタック参照の落とし穴:deadly embrace

ここが今回いちばん「調べておいてよかった」ポイントです。

CloudFormation の Export は、他のスタックが ImportValue で参照している間は削除できません。一方で、参照している側を更新するには先に生産側を変えたい……という状況になると、どちらのスタックも動かせなくなります。これが英語圏で deadly embrace(死の抱擁)と呼ばれているやつです。名前が物騒すぎる。

具体的には、AppStack で table.table_name を参照するのをやめたときに起きます。CDK は「もう誰も参照してないから Export を消そう」として、CloudFormation は「まだ ImportValue が残ってるから消せない」と拒否する。デッドロックです。

回避策として、CDK には export_value() というメソッドが用意されています。参照をやめる前に、生産側スタックで Export を明示的に維持しておく方法です。

# 参照を外す「前」にこれを入れて一度デプロイする
data_stack.export_value(data_stack.table.table_arn)

手順としては、①生産側に export_value() を足してデプロイ → ②消費側から参照を消してデプロイ → ③生産側の export_value() を消してデプロイ、という3ステップになります。面倒ですが、スタックを作り直すよりは圧倒的にマシです。

そもそも踏まないための対策としては、クロススタック参照を最小限にするのが王道っぽいです。疎結合にしたいなら SSM Parameter Store を経由する手もあります。

from aws_cdk import aws_ssm as ssm

# 生産側
ssm.StringParameter(self, "TableNameParam",
    parameter_name=f"/memo/{stage}/table-name",
    string_value=self.table.table_name)

# 消費側
table_name = ssm.StringParameter.value_from_lookup(self, f"/memo/{stage}/table-name")

ただしこれは CloudFormation 的な依存関係が切れるので、デプロイ順は自分で管理する必要があります。あと value_from_lookup は synth 時に実際の AWS アカウントを見に行くので、初回は dummy-value-for-... のようなダミー値が返ることがあって「あれ?」となります。トレードオフですね。個人の小規模構成なら、素直に参照を渡して export_value() の存在だけ覚えておく、で十分な気がしています。

余談ですが、リージョンをまたぐ参照は CDK で cross_region_references=True を指定することで対応できます。最近になって CloudFormation 側に Fn::GetStackOutput という新しい組み込み関数が入って、この辺が根本から改善に向かっているらしいです。ただ自分ではまだ試せていないので、ここは伝聞です。

環境ごとのデプロイをどう管理するか

dev / prod の切り替え、最初は環境変数でやろうとして「デプロイのたびに export し忘れて事故る未来」が見えたのでやめました。今は cdk.json の context に設定を書いて、スタックを静的に全部定義する方式にしています。

{
  "app": "python3 app.py",
  "context": {
    "stages": {
      "dev": { "account": "111111111111", "region": "ap-northeast-1", "memory": 256 },
      "prod": { "account": "222222222222", "region": "ap-northeast-1", "memory": 1024 }
    }
  }
}

このように cdk.json に環境ごとの設定を記述しておくと、コード内でループ処理するだけで全環境が定義できます。

import aws_cdk as cdk
from stacks.data_stack import DataStack
from stacks.app_stack import AppStack

app = cdk.App()
stages = app.node.try_get_context("stages")

for stage, conf in stages.items():
    env = cdk.Environment(account=conf["account"], region=conf["region"])
    data = DataStack(app, f"Memo-Data-{stage}", stage=stage, env=env)
    AppStack(app, f"Memo-App-{stage}", table=data.table, stage=stage,
             memory=conf["memory"], env=env)

ポイントは、全環境のスタックを常に定義しておくことです。こうすると cdk synth の時点で prod 側のコードミスにも気づけます。dev だけ定義する動的な作り方だと、prod をデプロイする直前まで壊れていることに気づけないんですよね。逆に「dev のエラーで prod の synth まで落ちるのは嫌」という考え方もあるらしく、ここはチームの好み次第みたいです。

デプロイはスタック名で絞ります。

cdk ls
cdk diff "Memo-*-dev"
cdk deploy "Memo-*-dev"

# 依存スタックを巻き込まず単体でデプロイしたいとき
cdk deploy Memo-App-dev --exclusively

--exclusively は覚えておくと便利でした。Lambda のコードだけ直したのに DataStack まで diff チェックが走る、みたいなときに時短になります。

Stage を使うとどうなるか

もう一段きれいにやるなら cdk.Stage を使う手もあります。スタックをグループ化できて、スタック名に自動でプレフィックスが付き、cdk deploy "Dev/*" のように指定できます。CDK Pipelines を使うなら相性が良いと言われています。

class MemoStage(cdk.Stage):
    def __init__(self, scope, construct_id, *, stage: str, **kwargs):
        super().__init__(scope, construct_id, **kwargs)
        data = DataStack(self, "Data", stage=stage)
        AppStack(self, "App", table=data.table, stage=stage)


MemoStage(app, "Dev", stage="dev", env=dev_env)
MemoStage(app, "Prod", stage="prod", env=prod_env)

ただ、Stage を挟むとスタック名が Dev-Data-XXXX みたいに変わるので、既存環境に後から導入するのはけっこう勇気がいります。自分はまだ Stage なしの構成のままです。最初から入れておけばよかったやつ。

※この記事にはプロモーションが含まれます

ちなみに、お名前.com レンタルサーバー(WordPressに特化した高速レンタルサーバー。月額990円〜、独自ドメイン実質0円)も気になっています。お名前.com レンタルサーバー

まとめ

スタック分割は「分ければ分けるほど偉い」わけではなくて、分けた瞬間にスタック間の依存という新しい管理コストが発生するというのが実感です。特に deadly embrace は、知らずに踏むと手が止まるタイプの罠なので、分割する前に一度頭に入れておくといいと思います。自分は検証用アカウントで意図的に再現させてみて、ようやく仕組みが腹落ちしました。

GitHub Actions で CDK を回す話もよく見かけます。OIDC で AssumeRole して、PR で cdk diff、main マージで cdk deploy、みたいな定番の構成ですね。ただ、prod への自動デプロイをどこまで許すかは正直まだ迷っていて、手動承認を挟むべきか考え中です。

📚 シリーズ「AWS CDK × Python インフラ自動化入門」(第3回 / 全4回)

← 前回の記事: 前回の記事はこちら

→ 次回の記事: 【第4回】AWS CDK × Python インフラ自動化入門 — よくある落とし穴・テスト・本番運用のベストプラクティス

参考になったらクリックしてもらえると嬉しいです!

Blogmura NetDev Ranking
タイトルとURLをコピーしました