
AWS Control TowerのメンバーアカウントをTerraformとYAMLでいい感じに管理する(AFTなし)
はじめに
皆様こんにちは、あかいけです。
AFT(Account Factory for Terraform)を使わずに、Control TowerのメンバーアカウントをTerraformで管理したいと思ったことはありますか?私はあります。
まずAFTを使わずにService Catalog経由でアカウントを発行する方法は、以下の記事が参考になります。
今回はこれをベースに、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
コードは以下です。
# 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 {
# 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
])
}
# 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,
)
}
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"
}
}
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などを設定ファイルとして扱う方法は、よろしければ以下をご参照ください。
OU IDを参照する
ManagedOrganizationalUnitに渡す値は、OU名 (OU ID)の形式です。
ただしOU IDをYAMLに直接書くと視認性が悪いので、OU名を元にOrganizationsのデータソースからOU IDを参照しています。
なおaws_organizations_organizational_unitsはparent_idの直下しか返さないため、Rootから5階層分のデータソースを連鎖させています。
OUのネストはRoot配下に5階層までという上限があるので、これで全階層をカバーできます。
有効な製品バージョンを動的に取得する
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つまでです。
Terraformの既定の並列数は10なので、6アカウント以上をまとめてapplyすると上限に引っかかる可能性があります。
そのためterraform apply -parallelism=5のように並列数を絞っておくのが安全です。
またaws_servicecatalog_provisioned_productの作成タイムアウトは既定で30分です。
並列数を絞ると後続のアカウントは待つことになるので、今回は念の為timeoutsで60分に延ばしています。
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や通知を設定するとプロビジョニングが失敗しうると記載があります。(コワイ…)
本当に失敗するかまでは検証していませんが、今回はnotification_arnsを指定せず、tagsだけを設定する形にしています。
さいごに
以上、AFTなしでAWS Control TowerのメンバーアカウントをTerraformとYAMLでいい感じに管理してみました。
AFTは便利ですが、色々リソースをデプロイしたりリポジトリを用意する必要があったり、アカウントが数個から数十個くらいの規模だとオーバースペックに感じる場面もあります。
そういった場合は今回の構成であれば、既存のTerraformリポジトリにファイルを数個足すだけで済みますし、アカウントの定義がYAMLファイルとしてそのまま残るのも運用上わかりやすくておすすめです。
一方で、アカウント作成後のベースライン適用やカスタマイズまでやりたくなったら、素直にAFTを検討したほうがよいと思います。
このあたりは規模と要件次第なので、まずは小さく始めてみて、手に負えなくなったらAFTに乗り換える、くらいの温度感でもいいかもしれません。










