AWS Control TowerのメンバーアカウントをTerraformとYAMLでいい感じに管理する(AFTなし)

AWS Control TowerのメンバーアカウントをTerraformとYAMLでいい感じに管理する(AFTなし)

AFTを使わずにControl TowerのメンバーアカウントをYAMLでアカウントを定義し、Terraformで管理する方法を試してみました。 メンバーアカウントをTerraformで管理したいけどAFTはちょっと…という方におすすめです。
2026.10.12

はじめに

皆様こんにちは、あかいけです。
AFT(Account Factory for Terraform)を使わずに、Control TowerのメンバーアカウントをTerraformで管理したいと思ったことはありますか?私はあります。

まずAFTを使わずにService Catalog経由でアカウントを発行する方法は、以下の記事が参考になります。

https://dev.classmethod.jp/articles/create-ct-member-account-by-terraform-not-aft/

今回はこれをベースに、YAMLでアカウントを定義して、Terraform側で読み込んで作成してみました。

前提条件

  • AWS Control Towerのランディングゾーンがセットアップ済みであること
  • 配置先のOUがControl Towerに登録済みであること
  • Terraformを実行するプリンシパルが、ポートフォリオへのアクセス権が付与されていること

構成イメージ

アカウントの定義を1アカウント1ファイルで作成して、Terraformがそれをまとめて読んでアカウントの数だけアカウントファクトリーを起動する、という構成にしてみます。

ソースコード

ディレクトリ構成は次のとおりです。

.
├── accounts/              # アカウント定義
│   ├── example-dev.yaml
│   └── example-prd.yaml
├── accounts.tf            # アカウントファクトリーの起動
├── organizations.tf       # OUパスからOU IDを参照
├── locals.tf              # YAMLの読み込みとパラメータの組み立て
└── providers.tf

コードは以下です。

accounts/example-dev.yaml
# Service Catalogの製品「AWS Control Tower Account Factory」に渡すパラメータ
# キー名はService Catalogコンソールの入力項目と同じ
provisioning_parameters:
  AccountName: Example-Dev
  AccountEmail: aws+example-dev@example.com
  ManagedOrganizationalUnit: Workloads/Dev
  SSOUserEmail: aws+example-dev@example.com
  SSOUserFirstName: AWS
  SSOUserLastName: Admin

# プロビジョニングされた製品に付けるタグ
tags:
  created-by: terraform
  system: example
  environment: development
locals.tf
locals {
  # Control Towerホームリージョンのアカウントファクトリーの製品ID
  #   aws servicecatalog describe-product --name "AWS Control Tower Account Factory" \
  #     --region ap-northeast-1 --query 'ProductViewSummary.ProductId' --output text
  account_factory_product_id = "prod-xxxxxxxxxxxxx"

  # accounts/配下のYAMLを読み込み、拡張子を除いたファイル名をキーにする
  accounts = {
    for filename in fileset("${path.module}/accounts", "*.yaml") :
    trimsuffix(filename, ".yaml") => yamldecode(file("${path.module}/accounts/${filename}"))
  }

  # ManagedOrganizationalUnitは "OU名 (OU ID)" の形式で渡す必要がある
  # OU名はパスではなく配置先のOU単体の名前なので、パスの末尾を使う
  # それ以外のパラメータはYAMLの内容をそのまま製品に渡す
  provisioning_parameters = {
    for key, account in local.accounts : key => merge(
      account.provisioning_parameters,
      {
        ManagedOrganizationalUnit = format(
          "%s (%s)",
          reverse(split("/", account.provisioning_parameters.ManagedOrganizationalUnit))[0],
          local.ou_ids[account.provisioning_parameters.ManagedOrganizationalUnit],
        )
      }
    )
  }

  # ランディングゾーンの更新でIDが変わるため、有効なバージョンを動的に参照する
  active_provisioning_artifact_id = one([
    for artifact in data.aws_servicecatalog_provisioning_artifacts.account_factory.provisioning_artifact_details :
    artifact.id if artifact.active
  ])
}
organizations.tf
# OUはRoot配下に最大5階層までネストできる
# https://docs.aws.amazon.com/organizations/latest/userguide/orgs_reference_limits.html
#
# aws_organizations_organizational_unitsは parent_id の直下しか返さないため、
# Rootから5階層分のデータソースを連鎖させて、全OUを引けるようにする

data "aws_organizations_organization" "this" {}

data "aws_organizations_organizational_units" "level1" {
  parent_id = data.aws_organizations_organization.this.roots[0].id
}

data "aws_organizations_organizational_units" "level2" {
  for_each = local.ou_level1

  parent_id = each.value
}

## 中略(level3からlevel5も同じ形で、1つ上の階層のマップをfor_eachに渡す)

locals {
  # 各階層の "OUパス => OU ID" のマップ
  # 1階層下のfor_eachのキーがそのまま親のパスになるので、連結していけばフルパスになる
  ou_level1 = {
    for ou in data.aws_organizations_organizational_units.level1.children : ou.name => ou.id
  }

  ou_level2 = merge([
    for parent, ous in data.aws_organizations_organizational_units.level2 : {
      for ou in ous.children : "${parent}/${ou.name}" => ou.id
    }
  ]...)

  ## 中略(ou_level3からou_level5も同じ形)

  # OUの指定からOU IDを参照するためのマップ
  # ネストしたOUは名前が重複しうるため、Rootからのパスをキーにする
  ou_ids = merge(
    local.ou_level1,
    local.ou_level2,
    local.ou_level3,
    local.ou_level4,
    local.ou_level5,
  )
}
accounts.tf
data "aws_servicecatalog_provisioning_artifacts" "account_factory" {
  product_id = local.account_factory_product_id
}

resource "aws_servicecatalog_provisioned_product" "account" {
  for_each = local.accounts

  name                     = each.value.provisioning_parameters.AccountName
  product_id               = local.account_factory_product_id
  provisioning_artifact_id = local.active_provisioning_artifact_id

  dynamic "provisioning_parameters" {
    for_each = local.provisioning_parameters[each.key]

    content {
      key   = provisioning_parameters.key
      value = provisioning_parameters.value
    }
  }

  tags = each.value.tags

  # 既定の30分では足りない場合があるため延長する
  timeouts {
    create = "60m"
    update = "60m"
    delete = "60m"
  }
}
providers.tf
terraform {
  required_version = "~> 1.16"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.68"
    }
  }
}

provider "aws" {
  region = "ap-northeast-1"
}

実装の説明

アカウント単位でYAMLファイルを分割する

アカウントの定義はaccounts/配下に1ファイルずつ置き、fileset()で列挙して読み込みます。
拡張子を除いたファイル名がfor_eachのキーになります。

1つのYAMLにまとめないのは、アカウントを追加するときに既存のファイルを編集しないでいいためです。これによりPull Requestの差分が新規ファイル1つに収まるので、発行依頼が同時に走ってもコンフリクトしないので良さなげな気がします。

なおTerraformでYAMLなどを設定ファイルとして扱う方法は、よろしければ以下をご参照ください。

https://dev.classmethod.jp/articles/terraform-yaml-toml-config/

OU IDを参照する

ManagedOrganizationalUnitに渡す値は、OU名 (OU ID)の形式です。
ただしOU IDをYAMLに直接書くと視認性が悪いので、OU名を元にOrganizationsのデータソースからOU IDを参照しています。

https://docs.aws.amazon.com/controltower/latest/userguide/automated-provisioning-walkthrough.html

なおaws_organizations_organizational_unitsはparent_idの直下しか返さないため、Rootから5階層分のデータソースを連鎖させています。
OUのネストはRoot配下に5階層までという上限があるので、これで全階層をカバーできます。

https://docs.aws.amazon.com/ja_jp/organizations/latest/userguide/orgs_reference_limits.html

有効な製品バージョンを動的に取得する

provisioning_artifact_idはランディングゾーンを更新すると変わるため、データソースからactiveなものを選んでいます。

動作確認

実行する前に、locals.tfのaccount_factory_product_idに設定する製品IDを調べます。
アカウントファクトリーの実体はService CatalogのAWS Control Tower Account Factoryという製品なので、Control Towerのホームリージョンでその製品IDを取得します。

aws servicecatalog describe-product \
  --name "AWS Control Tower Account Factory" \
  --region ap-northeast-1 \
  --query 'ProductViewSummary.ProductId' \
  --output text
prod-xxxxxxxxxxxxx

値をlocals.tfに設定したら、terraform planを実行します。

terraform plan

Terraform will perform the following actions:

  # aws_servicecatalog_provisioned_product.account["example-dev"] will be created
  + resource "aws_servicecatalog_provisioned_product" "account" {
      + accept_language                        = "en"
      + arn                                    = (known after apply)
      + id                                     = (known after apply)
      + ignore_errors                          = false
      + name                                   = "Example-Dev"
      + outputs                                = (known after apply)
      + path_id                                = (known after apply)
      + product_id                             = "prod-xxxxxxxxxxxxx"
      + provisioning_artifact_id               = "pa-xxxxxxxxxxxxx"
      + region                                 = "ap-northeast-1"
      + retain_physical_resources              = false
      + status                                 = (known after apply)
      + tags                                   = {
          + "created-by"  = "terraform"
          + "environment" = "development"
          + "system"      = "example"
        }

      + provisioning_parameters {
          + key   = "AccountEmail"
          + value = "aws+example-dev@example.com"
        }
      + provisioning_parameters {
          + key   = "AccountName"
          + value = "Example-Dev"
        }
      + provisioning_parameters {
          + key   = "ManagedOrganizationalUnit"
          + value = "Dev (ou-xxxx-xxxxxxxx)"
        }
      + provisioning_parameters {
          + key   = "SSOUserEmail"
          + value = "aws+example-dev@example.com"
        }
      + provisioning_parameters {
          + key   = "SSOUserFirstName"
          + value = "AWS"
        }
      + provisioning_parameters {
          + key   = "SSOUserLastName"
          + value = "Admin"
        }

      + timeouts {
          + create = "60m"
          + delete = "60m"
          + update = "60m"
        }
    }

## 省略(example-prdも同様)

Plan: 2 to add, 0 to change, 0 to destroy.

問題なければterraform applyを実行します。

terraform apply

デプロイ完了までに、10分から15分ほどかかります。
完了すると、プロビジョニングされた製品のステータスがAVAILABLEになり、作成されたアカウントIDがoutputsから取得できます。

terraform state show 'aws_servicecatalog_provisioned_product.account["example-dev"]'

    status = "AVAILABLE"
    type   = "CONTROL_TOWER_ACCOUNT"
    outputs = [
      {
        key   = "AccountId"
        value = "XXXXXXXXXXXX"
      },
      ## 中略
    ]

以降、アカウントを発行する際はYAMLを1つ置いてapplyすればOKです。

既存のファイルにもアカウントにも差分が出ないので、アカウント発行の依頼をそのままPull Requestの形にできるのがGoodです。

考慮事項

一度に作成できるのは5アカウントまで

Control Towerが同時にプロビジョニングできるアカウントは5つまでです。

https://docs.aws.amazon.com/controltower/latest/userguide/provision-as-end-user.html

Terraformの既定の並列数は10なので、6アカウント以上をまとめてapplyすると上限に引っかかる可能性があります。
そのためterraform apply -parallelism=5のように並列数を絞っておくのが安全です。

またaws_servicecatalog_provisioned_productの作成タイムアウトは既定で30分です。
並列数を絞ると後続のアカウントは待つことになるので、今回は念の為timeoutsで60分に延ばしています。

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/servicecatalog_provisioned_product

AccountNameは後から変更できない

aws_servicecatalog_provisioned_productのnameは、変更すると再作成になるForceNewの属性であり、今回の構成の場合YAMLのAccountNameを書き換えると、Terraformはプロビジョニングされた製品を削除して作り直そうとします。

そのためAccountNameは後から変えないという運用にしておくのが無難でしょう。

リソースを削除するとControl Towerの管理から外れる

aws_servicecatalog_provisioned_productを削除しても、AWSアカウント自体は削除されません。
実際に試したところ、アカウントはControl Towerの管理から外れ、配置先のOUからRoot直下に移動しました。

そのためアカウントを閉鎖したい場合は、別途Organizationsから閉鎖する操作が必要です。

TagOptionsと通知は設定していない

Control Towerのドキュメントでは、アカウントファクトリーの起動時にTagOptionsや通知を設定するとプロビジョニングが失敗しうると記載があります。(コワイ…)

https://docs.aws.amazon.com/controltower/latest/userguide/provision-as-end-user.html

本当に失敗するかまでは検証していませんが、今回はnotification_arnsを指定せず、tagsだけを設定する形にしています。

さいごに

以上、AFTなしでAWS Control TowerのメンバーアカウントをTerraformとYAMLでいい感じに管理してみました。

AFTは便利ですが、色々リソースをデプロイしたりリポジトリを用意する必要があったり、アカウントが数個から数十個くらいの規模だとオーバースペックに感じる場面もあります。
そういった場合は今回の構成であれば、既存のTerraformリポジトリにファイルを数個足すだけで済みますし、アカウントの定義がYAMLファイルとしてそのまま残るのも運用上わかりやすくておすすめです。

一方で、アカウント作成後のベースライン適用やカスタマイズまでやりたくなったら、素直にAFTを検討したほうがよいと思います。

このあたりは規模と要件次第なので、まずは小さく始めてみて、手に負えなくなったらAFTに乗り換える、くらいの温度感でもいいかもしれません。


そのマルチアカウント運用、気合いで支えていませんか

Organizations や Control Tower で土台は作れても、アカウントもポリシーも増えるほど、運用は「詳しい一人」に寄りかかっていく。属人化が限界を迎える前に、組織として回す仕組み=CCoEへ。5,600社の支援から得た立ち上げの型を、無料資料にまとめました。

CCoE総合支援

組織で回す仕組みの資料をもらう

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事