はじめに

マネージドサービスプロバイダー(以下、MSP)として顧客の AWS 環境を管理している場合、監視用の Lambda や EventBridge ルールなどのリソースを顧客の各アカウントに配布・維持管理したいというケースがあります。部門やサービス毎、本番・検証などの環境毎の複数アカウントについて、ランタイムのバージョンアップや設定更新が発生するたびにそれぞれ手動で対応するのは、アカウント数が増えるほど困難になります。

この課題を解決するアプローチの1つとして、CloudFormation を使ったテンプレート管理が考えられます。CloudFormation は AWS マネージドであり追加コストがかからず、また、顧客側からも MSP が作成したリソースをコンソール上で把握しやすく、サービス利用終了時の削除も容易です。

本記事では、GitHub でテンプレートを管理しつつ Git push だけで全アカウント(環境)への自動展開・自動更新を実現する 方法として、以下の2パターンを実際に動かして比較した結果をまとめます。

パターン 概要
1 StackSet in Stack (A) CloudFormation Git Sync を利用、(B) GitHub Actions 利用
2 GitHub Actions + OpenTofu + AssumeRole + CloudFormation

前提として、以下の条件を想定しています。

  • 展開先は単一の顧客が持つ複数アカウントとする。これらは MSP とは異なる Organizations(以下、Org)に属し、顧客側が複数 Org や別 OU にまたがる場合も含む
  • リポジトリに置く環境固有の情報は AWS アカウントID・リージョン・環境名などの非機密情報に限定し、認証情報や顧客の機密データは置かない
  • スモールスタートを想定し、全アカウントを同じタイミングで一斉更新する
    (段階的ロールアウトは対象外)
  • アカウントごとの設定差分は少なく、CloudFormation パラメータで吸収できる範囲とする
  • GitHub 以外の外部 SaaS は使用しない

前提:検証環境と顧客ライフサイクル

検証環境の構成

今回の検証では以下のアカウント構成を使用しました。

役割 備考
MSP アカウント MSP 側。スタック・StackSet の管理元
顧客のアカウント A 東京リージョン
顧客のアカウント B 大阪リージョン

なお、本記事の各アカウントは、顧客内の別サービス/プロダクト/部門など、用途の異なる AWS アカウントを指します(いわゆる dev/stg/prod の環境分けに限りません)。

テンプレートの実体は GitHub で管理し、Lambda(ZipFile 形式のインライン Python)や S3 バケットを含む構成で検証しました。Lambda の環境変数を設定することで、アカウント(環境)ごとに異なるパラメータを反映できることを確認しています。

Bootstrap Template

顧客アカウントへのアクセス手段として、Bootstrap Template を利用します。顧客がこの CloudFormation テンプレートを自分のアカウントに一度だけデプロイすることで、MSP アカウントを信頼する Execution Role が作成されます。以後の展開・更新・削除は MSP 側で制御します。

顧客ライフサイクル

検証は「契約(初回展開)→ リソース更新 → 解約(削除)」のライフサイクル全体をカバーしています。更新として Lambda のランタイムバージョンアップや設定変更を想定し、実際に変更を push して全顧客への伝播を確認しました。

事前知識

CloudFormation StackSet (self-managed)

StackSet は単一の CloudFormation テンプレートを複数の AWS アカウント・リージョンに展開・管理する機能です。StackSet には権限モデルが2種類あり、本記事では self-managed を使用します。

self-managed では、MSP アカウントに Administration Role、各展開先アカウントに Execution Role を事前に作成します。展開先アカウントが別の Org に属していても展開できることが特徴です。

主な特性として以下が挙げられます。

  • 展開先を Stack instance(アカウント × リージョンの組み合わせ)単位で管理する
  • アカウント(環境)ごとにパラメータを上書き(override)できる

Git Sync

Git Sync は CloudFormation のネイティブ機能で、GitHub リポジトリの指定ブランチへの push をトリガーに、対応する CloudFormation スタックを自動更新します。設定ファイル(Deployment file)にテンプレートのパスとパラメータを記述し、スタックに紐付けます。

利用には事前に GitHub App による接続設定が必要です。なお、1つの Git Sync 設定が同期できるのは 1 スタックのみです。

各パターンの検証結果

パターン1:StackSet in Stack

仕組み

通常の CloudFormation スタック(親スタック)の中に AWS::CloudFormation::StackSet リソースを含めるパターンです。本記事では StackSet in Stack と記載します。親スタックを更新すると、StackSet 経由で全 Stack instance(各展開先アカウント)にカスケードします。

StackSet 作成前に、以下の事前準備が必要です。

  1. MSP アカウントに Administration Role を作成する
  2. 各展開先アカウントに Execution Role(Bootstrap Template でデプロイ)を作成してもらう

アカウント(環境)の追加・削除はテンプレート内の StackInstancesGroup を変更して push するだけで完結します。Stack instance の作成・削除は StackSet が自動的に行います。

テンプレートの骨格は以下のようになります。

Resources:
CustomerStackSet:
Type: AWS::CloudFormation::StackSet
Properties:
StackSetName: my-stackset
AdministrationRoleARN: !Sub arn:aws:iam:::role/StackSetAdminRole
ExecutionRoleName: StackSetExecutionRole
TemplateBody: |
# 各展開先アカウントに展開するテンプレート(インライン)
AWSTemplateFormatVersion: '2010-09-09'
Description: 'Resources deployed in customer accounts'
Resources:
# ------------------------------------------------------------
# ここに各展開先アカウントへ展開したいリソースを定義
# ------------------------------------------------------------
StackInstancesGroup:
- DeploymentTargets:
Accounts:
- ""
Regions:
- ap-northeast-1
- DeploymentTargets:
Accounts:
- ""
Regions:
- ap-northeast-3

CFn Execution Role に必要な権限

CloudFormation 実行ロールには、StackSets 操作権限が必要です。検証時に実際に追加が必要だった権限は以下のとおりです。

[
{
"Effect": "Allow",
"Action": [
"cloudformation:CreateStackSet",
"cloudformation:UpdateStackSet",
"cloudformation:DeleteStackSet",
"cloudformation:DescribeStackSet",
"cloudformation:CreateStackInstances",
"cloudformation:UpdateStackInstances",
"cloudformation:DeleteStackInstances",
"cloudformation:ListStackInstances",
"cloudformation:TagResource",
"cloudformation:DescribeStackSetOperation"
],
"Resource": "arn:aws:cloudformation:*::stackset/my-stackset:*"
},
{
"Effect": "Allow",
"Action": "cloudformation:GetTemplateSummary",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam:::role/StackSetAdminRole"
}
]

デプロイ方式 A:Git Sync

Git Sync を親スタックに設定することで、以下のフローでスタック自動更新が行われます。

  1. テンプレートを変更して main ブランチに push する
  2. Git Sync が差分を検知し、親スタックの更新を開始する
  3. 親スタックの更新が StackSet リソースの変更を検知し、StackSet の更新オペレーションが実行される
  4. StackSet が全 Stack instance(各展開先アカウント)に変更を伝播する

PR コメントによる変更プレビュー

PR を作成すると、Git Sync が changeset を生成して PR にコメントします。

ただし、TemplateBody プロパティ全体が1つの変更として扱われるため、コメントにはテンプレート全文の before/after が表示されます。変更箇所を特定するには自分で差分を確認する必要があります。テンプレートが大きくなるほど確認が困難になります。


(中略)

更新伝播の確認

StackSet の更新状況(各 Stack instance が SUCCEEDED か FAILED か)は、AWS マネジメントコンソールの StackSet オペレーション画面で確認します。MSP アカウント側からは SUCCEEDED / FAILED のステータスのみが見えます。詳細な CloudFormation イベントは顧客アカウント側にしか表示されません。


デプロイ方式 B:Terraform + GitHub Actions

Terraform で aws_cloudformation_stack_setaws_cloudformation_stack_set_instance リソースを管理します。GitHub Actions と組み合わせることで、PR 時に terraform plan を実行してコメントに結果を表示し、main へのマージ時に terraform apply を実行します。

PR での変更プレビュー

GitHub Actions で、terraform plan の結果を PR にコメントするようにしました。方式 A と異なり、変更のある行が明示されるため、変更箇所の特定が容易です。


(中略)

更新伝播の確認

terraform apply のログには Terraform リソース単位の結果が表示されますが、StackSet の各 Stack instance への伝播状況は表示されません。方式 A と同様に、コンソールでの別途確認が必要です。


パターン2:OpenTofu + AssumeRole

仕組み

このパターンは StackSets を使用しません。OpenTofu から各展開先アカウントへ直接 AssumeRole し、aws_cloudformation_stack リソースをアカウント(環境)ごとに個別にデプロイします。コード管理と GitOps を IaC で一元化したい場合の選択肢です。

なぜ OpenTofu か

アカウント(環境)ごとに異なる AWS 認証情報(provider)を動的に扱うには、provider for_each が必要です。Terraform OSS では今のところ、この機能が未実装のままです(Issue #25244)。回避策として「アカウント数分だけ provider alias と module を手書きする」方法がありますが、アカウントを追加するたびにコード構造を変更する必要があります。 OpenTofu では 1.9.0 で provider for_each が実装され、この問題が解決されました。

provider for_each の実装

locals {
accounts = {
account_a = {
role_arn = "arn:aws:iam:::role/TerraformExecutionRole"
env = "p2-a"
region = "ap-northeast-1"
}
account_b = {
role_arn = "arn:aws:iam:::role/TerraformExecutionRole"
env = "p2-b"
region = "ap-northeast-3"
}
}

# 管理対象から外すアカウント(環境)のキーをここに列挙する(理由は後述「アカウント(環境)削除時の注意」)
offboarding = []

# 対象外を除く、アクティブなアカウント(環境)のキー
active_accounts = {
for key, account in local.accounts : key => account
if !contains(local.offboarding, key)
}
}

provider "aws" {
alias = "accounts"
for_each = local.accounts
region = each.value.region
assume_role {
role_arn = each.value.role_arn
}
}

resource "aws_cloudformation_stack" "this" {
for_each = local.active_accounts
provider = aws.accounts[each.key]
name = "my-stack"
parameters = { Env = each.value.env }
template_body = file("${path.module}/../../template.yaml")
}

アカウント(環境)を追加する場合は locals.accounts マップに1エントリ追加して push するだけで、コード構造の変更は不要です。

plan の出力について

StackSets を使用しないため、アカウント(環境)ごとのリソースが plan に独立して表示されます。変更の影響範囲がアカウント単位で把握しやすいという利点があります。一方、template_body がフル展開されるため、アカウント数が増えると plan の出力が長くなります。

アカウント(環境)削除時の注意

リソースの destroy にも provider インスタンスが必要となるため、provider を先に消すと「もう存在しない provider でリソースを削除できない」状態になり、tofu plan が次のエラーで止まります。

Provider instance not present ... its original provider instance ... is required, but it has been removed.

そのため、公式ドキュメントでは provider の for_each がリソースの for_each の superset(上位集合)になる構成が推奨されています。

上のコードで provideraccounts(全アカウント)、resourceactive_accounts(対象外を除いた部分集合)に分けているのはこのためです。アカウント account_b を管理対象から外す場合、offboarding = ["account_b"] を追加して apply し、スタックを destroy した後に、accountsoffboarding から account_b を完全に削除するというステップが必要になります。

補足・注意事項

​​リポジトリやブランチの保護が前提

本記事のパターンはいずれも GitHub リポジトリが展開の起点となるため、リポジトリは Private に設定することを大前提としています。また、ブランチへの直接 push を禁止する、PR 経由でのみ変更を受け付けるといったブランチ保護ルールを設けることを推奨します。

リポジトリに置く情報は非機密に限定する

本記事のパターンはいずれも、環境固有の情報(AWS アカウントID・リージョン・環境名など)をリポジトリ上のテンプレートや設定ファイルに記述します。これらは機密性こそ高くないものの、認証情報(アクセスキー等)や顧客の機密データはリポジトリに含めないことを前提とします。

監視・最小権限で多層に守る

リポジトリ自体が顧客アカウントへの高権限なデプロイ経路となるため、保護設定だけに頼らず、以下を併用して多層で守ることを前提とします。

  • GitHub Audit Log を監視し、ブランチ保護・ワークフロー・アクセス権の変更を検知する
  • AWS 側でも CloudTrail / GuardDuty などにより、StackSet オペレーションや AssumeRole の想定外の利用・異常を検知する
  • パイプラインから AWS への認証は長期キーを使わず OIDC で一時認証し、デプロイ用ロールは最小権限に絞る
  • ロールの信頼ポリシーは特定の呼び出し元に限定し、認証情報などの機密はリポジトリやテンプレートに置かない

顧客側の CloudFormation イベントやリソース詳細は見えない

どのパターンでも、CloudFormation イベントの詳細は顧客アカウント側にしか表示されません。更新が FAILED になった場合の原因調査には顧客アカウントへのアクセス手段が必要です。対応としては、MSP の調査用の Role を顧客アカウントに用意しておくなどが考えられます。

初回展開・解約時は手動

Bootstrap Template(Execution Role)のデプロイと削除は、どのパターンでも顧客自身が実施する必要があります。契約時の初回デプロイと、解約時の Execution Role 削除が手動オペレーションとして残ります。

段階的リリースには別途制御が必要

本記事で扱った3パターンはいずれも全アカウント(環境)への一斉更新が前提です。先行1アカウント(環境)に適用して確認してから残りへ展開したい場合は、IaC ツール側ではなくパイプライン側(GitHub Actions のジョブ分割など)での制御が別途必要になります。

反映タイミングの制御は別途検討が必要

本記事のパターンはいずれも MSP 側が push したタイミングで全アカウント(環境)へ即時反映されます。顧客が特定環境(本番など)の反映タイミングを自分で制御したい場合は、AWS Service Catalog などの別の仕組みを組み合わせる必要があります。

Terraform/OpenTofu で回す場合はタイムアウトに注意(パターン1-B・2)

CloudFormation 自体には、明示的に指定しない限りデプロイのタイムアウトはありません (※1) 。一方、Terraform/OpenTofu で CloudFormation を管理するパターン(1-B・2)では、リソース単位のタイムアウト(デフォルト 30 分)があります。AWS 側のオペレーション自体は成功していても、この時間を超えると apply がタイムアウトで失敗します。
特にパターン1-B では、StackSet オペレーションを逐次処理(max_concurrent_count = 1 / SEQUENTIAL)で動かしているため、Stack instance(アカウント × リージョン)が増えるほど処理時間が延び、30 分の壁を超えやすくなります。対策として、timeouts ブロックでタイムアウトを延長するか、max_concurrent_count を引き上げて並列度を上げるなどが考えられます。

※1 TimeoutInMinutes はスタック作成時の任意指定。カスタムリソースを使う場合は例外でタイムアウトあり。

まとめ・選択指針

3パターンの特徴と選び方の目安を整理します。

  • パターン1-A(StackSet in Stack + Git Sync):GitHub Actions ワークフローの構築・管理が不要で、AWS ネイティブな仕組みとして完結します。リポジトリごとに GitHub Actions 用の IAM Role を用意する必要もありません。
  • パターン1-B(StackSet in Stack + Terraform + GitHub Actions)terraform plan による差分確認が可能で、変更内容のレビューがしやすくなります。デプロイパイプラインを自分でコントロールしたい場合に適しています。
  • パターン2(OpenTofu + AssumeRole):StackSets に依存せず、IaC でアカウント(環境)フリートを一元管理できます。OpenTofu 1.9.0 の provider for_each によりアカウント追加をコード1行で管理できますが、OpenTofu 限定の機能です。
優先したいこと 選択
デプロイの仕組みを AWS に任せたい・ワークフロー管理を減らしたい パターン1-A
PR で変更差分をきちんと確認したい・デプロイフローを自分でコントロールしたい パターン1-B
StackSets に依存せず IaC で Stack を一元管理したい パターン2

おわりに

本記事では、別 Org にある複数 AWS アカウントへ差分の少ない CloudFormation スタックを Git push で自動展開・更新する3パターンを比較しました。いずれのパターンも GitHub 上でのコードレビューやコードスキャンが可能で、その後の更新・削除オペレーションを自動化でき、選択肢になりえます。

もちろんデプロイパイプラインを構築すればより制御された柔軟なデプロイも可能なわけですが、今回はなるべくツールを使わず、管理対象少なく実現することを試みました。同じような構成を検討している方の参考になれば幸いです。