CloudFormation を触り始めると、誰もが一度はこの問いにぶつかります。
すべてのリソースをスタック化すべき?もしスタックするなら、1つのスタックに全部書くべきか、それとも分けるべきか。分けるなら、どこで線を引くのか。
私が現時点で考えるスタック作成の基本ポリシーは、以下の通りです。
- 単一責任の原則 ── 1スタック1責務にする
- 更新頻度に応じてスタックを分離する
- アカウント間で再利用するものはスタック化する
1. 単一責任の原則 ── 「責務」を何で定義するか
単一責任の原則(SRP)は、もともとソフトウェア設計の言葉です。「モジュールが変更される理由は1つであるべき」というものです。これを CloudFormation に持ち込むと、こうなります。
1つのスタックが変更される理由は、1つであるべき。
ここで重要なのは、「責務」を機能のまとまりではなく『変更される理由』で定義するという点です。これを取り違えると、SRP は途端に役に立たなくなります。
よくある失敗:「ネットワーク」を1つの責務とみなす
「VPC も Subnet も RouteTable も、ぜんぶネットワークだから1スタックでいい」── これは機能のまとまりで責務を切ってしまった例です。一見きれいに見えますが、変更される理由が混在しています。
VPC / Subnet / IGW を触りたくなる場面と、RouteTable / Route を触りたくなる場面を並べてみます。
- VPC / Subnet / IGW を触る理由: CIDR 設計の見直し、AZ 構成の変更、新環境の立ち上げ。いずれも初期構築時の判断にひもづくもので、運用フェーズに入ったらほとんど発生しない
- RouteTable / Route を触る理由: NAT Gateway の追加、VPC Peering 接続、Transit Gateway 接続、新しい宛先ルートの追加。サービスや構成が拡張するたびに継続的に発生する
両者はまったく別のトリガーで動きます。それでも同居させると、ルート1本足すだけのデプロイで、VPC を含むスタック全体が ChangeSet の評価対象になります。「変更される理由」が2つあるのですから、SRP 的にはこれは2スタックです。
責務で分けるとこうなる
「責務 = 変更される理由」で線を引くと、コメントは「何が起きたらこのスタックを触るのか」を書くことになります。
01-network-base.yaml # 触る理由:CIDR 設計の見直し / AZ 構成の変更 /
# 新環境の立ち上げ
# VPC / Subnet / IGW
02-network-routing.yaml # 触る理由:NAT Gateway 追加 / VPC Peering 接続 /
# Transit Gateway 接続 / 新規ルート追加
# RouteTable / Route / Association
「低頻度・高頻度」ではなく何のトリガーで触るのかを書くと、責務の輪郭がはっきりします。頻度の違い(土台はほぼ触らない、ルーティングは継続的に触る)は、トリガーの種類と発生頻度から自然に導かれる帰結にすぎません。
実際のテンプレートで見てみます。土台側(01)は、環境差分を Mappings で吸収しつつ、出力を Export します。
# 01-network-base.yaml (抜粋)
Mappings:
NetworkConfig:
dev:
VpcCidr: 172.29.4.0/22
stg:
VpcCidr: 172.29.8.0/22
Resources:
VPC:
Type: AWS::EC2::VPC
DeletionPolicy: Retain # 土台なので削除から守る
Properties:
CidrBlock: !FindInMap [NetworkConfig, !Ref Env, VpcCidr]
Outputs:
VPCId:
Value: !Ref VPC
Export:
Name: !Sub "${Env}-VPCId" # 02 がこれを参照する
ルーティング側(02)は、01 の出力を ImportValue で受け取って構築します。
# 02-network-routing.yaml (抜粋)
Resources:
RouteTablePublic01:
Type: AWS::EC2::RouteTable
# auxiliary resource なので DeletionPolicy は付けない
Properties:
VpcId: !ImportValue
Fn::Sub: "${Env}-VPCId" # 01 の Export を受け取る
こうすると、ルーティングをいじっても土台スタックには一切触れません。これが「変更される理由で責務を切る」ということです。
ポイント: 単一責任の「責務」は、機能カテゴリ(=ネットワーク)ではなく、変更トリガー(=何が起きたらこのスタックを触るか) で定義します。
2. 更新頻度に応じて分離する ── ただし正確には「ライフサイクルの一致」
2つ目の軸「更新頻度で分離」は、実は1つ目の SRP とほぼ同じことを別角度から言っています。変更される理由が違う = 更新頻度が違う、というケースが多いからです。
ただ、現場でより正確に効くのは「更新頻度」そのものより、ライフサイクルが一致しているかという観点です。
更新頻度が同じでも分けるべき例
NAT Gateway を考えてみます。NAT Gateway の設定変更頻度は、ルートテーブルとそう変わらないかもしれません。ですが、
- NAT Gateway は時間課金される有料リソース
- 開発環境では「そもそも要らない」「検証時だけ立てて、終わったら壊す」という運用がありうる
- 本番環境では常時必要
つまり生成・破棄のタイミング(ライフサイクル)がルートテーブルと根本的に違うわけです。これを 02-network-routing.yaml に同居させると、NAT の付け外しのたびにルートテーブル定義を含むスタックを触ることになり、せっかくの分離が崩れます。
だから NAT Gateway は、更新頻度の近さに関わらず、別スタックに切り出すのが正しい設計です。
ポイント: 「更新頻度で分離」は実務では 「ライフサイクル(生成・破棄のリズム)が一致するか」 と捉え直すと精度が上がります。頻度が同じでも、消えるタイミングが違うものは分けます。
3. アカウント間で再利用するものはスタック化する ── 設定ミスによる品質低下を防ぐ
3つ目の軸は、品質の話です。dev / stg / prd といったアカウント分離運用、あるいは複数プロジェクトで同じネットワーク構成を使い回す運用では、同じ構成を人手で何度も組み直すことが品質低下の最大の原因になります。スタック化(= CloudFormation テンプレートで定義しておく)は、この品質低下を防ぐための手段です。
手作業で再構築するときに何が起きるか
dev 環境で動かしていた構成を prd 環境にも作る、というシンプルな話を想像してください。コンソール画面でポチポチ作り直す場合、次のような事故が起きます。
- サブネットの CIDR を1つ書き間違える → ルーティングが通らず、原因特定に半日
- セキュリティグループのインバウンドルールを1行だけ漏らす → 接続できる時間とできない時間が混在し、再現性のないバグに見える
- AZ の指定を
1a / 1cのつもりが片方1dにしてしまう → 動くが、可用性設計が崩れている(しかも気づきにくい) - DNS ホスト名の有効化、IGW のアタッチ、ルートテーブルのアソシエーションのどれかを忘れる → 後から「なぜか繋がらない」状態の原因になる
どれも単体では小さなミスです。ただ、こうしたミスは動くけれど設計と違うという形で残るのが厄介です。テストでは通るけれど、後で別の人が見たときに「なぜここだけこうなっているのか」と混乱する。最悪、本番環境だけ設計と違う、という状態が長期間放置されます。
スタック化が品質を担保するメカニズム
CloudFormation テンプレートに落としておくと、同じ構成を機械的に・全自動で・毎回まったく同じように再現できます。
# dev アカウントで構築したのと同じテンプレートを prd アカウントへ
aws cloudformation deploy \
--template-file 01-network-base.yaml \
--stack-name network-base \
--parameter-overrides Env=prd
これが品質に効く理由は3つあります。
ひとつ目は、作成手順そのものがコードとしてレビュー可能になることです。手作業の手順書だと「サブネットを作る」と書かれていても、書いた人の頭の中の前提が抜け落ちます。テンプレートなら CIDR の値も、IGW のアタッチも、ルートテーブルのアソシエーションも、すべて記述として現れます。レビューで抜け漏れが見つけられます。
ふたつ目は、環境差分を Parameters と Mappings に閉じ込められることです。テンプレート本体は環境ごとに変わらず、差分は Env=dev / stg / prd というパラメータと、それに対応する Mappings の値だけ。「dev と prd で何が違うか」が一箇所で見える状態は、設定ミスを目視で検出しやすくします。
# 環境差分はここに集約される
Mappings:
NetworkConfig:
dev:
VpcCidr: 172.29.4.0/22
SubnetPublic01Cidr: 172.29.4.0/24
stg:
VpcCidr: 172.29.8.0/22
SubnetPublic01Cidr: 172.29.8.0/24
prd:
VpcCidr: 172.29.12.0/22
SubnetPublic01Cidr: 172.29.12.0/24
3つ目は、デプロイ前に cfn-lint や ChangeSet で検証できることです。手作業では「やってみないと分からない」ことが、テンプレートなら事前に検出されます。CIDR の書式不正、必須プロパティの抜け、リソース間の参照ミスは、デプロイ前に止められます。
4. まとめ
3つの軸を振り返ると、それぞれが扱っている問いは違うものでした。
| 軸 | 問いかけ | 答えで決まること |
|---|---|---|
| 単一責任 | このリソース群が変更される理由は1つか? | スタックをどこで切り分けるか |
| ライフサイクル | 生成・破棄のリズムは一致しているか? | 頻度が同じでも分けるべきかどうか |
| アカウント間再利用 | 同じ構成を別の場所でも組むか? | テンプレート化して人手を排除すべきかどうか |
つまり3軸はバラバラの話ではなく、「スタックの境界をどこに引くか(1・2)」と「スタックそのものをどう運用するか(3)」を順番にカバーしています。この順番で問いを立てると、スタック設計の判断はかなりブレなくなります。
実際の設計の手順に落とすなら、こうなります。
- 対象のリソース群を眺めて、それぞれが変更される理由(トリガー)を書き出す。理由が複数あるなら、その時点で分割の候補です
- トリガーが同じでも、生成・破棄のタイミングが違うものがないかを確認する。NAT Gateway のように「環境によっては存在しない」「常時起動ではない」ものは、ここで分離します
- 完成したテンプレートは、再利用前提で書く。
ParametersとMappingsで環境差分を吸収し、別アカウント・別プロジェクトでも同じテンプレートをそのままdeployできるようにします
最後に、3つの軸に共通する根本的な姿勢を書いておきます。それは 「人手の判断と作業をなるべく挟まない構造にする」 ということです。責務を切り分けるのは、変更時に人が「どこを触ればよいか」を迷わないため。ライフサイクルで分けるのは、関係ないスタックを人が触らなくて済むようにするため。再利用前提でテンプレート化するのは、人が手で組み直すことによるミスをなくすため。
CloudFormation の設計は、結局のところ 「人が間違える余地を減らす設計」 に尽きます。スタックをどう切るかという技術的な議論の裏には、常にこの視点があります。