Terraform で AWS Control Tower ランディングゾーンを作成・有効化する

2024.05.28

どうも、ちゃだいん(@chazuke4649)です。

昨年末に AWS Control Tower Landing Zone API が公開され、API経由で AWS Control Tower の有効化が可能になりました。

TerraformのAWS Providerでも以下バージョンで新しいリソースとしてサポートされています。

Release v5.36.0 · hashicorp/terraform-provider-aws

aws_controltower_landing_zone | Resources | hashicorp/aws | Terraform | Terraform Registry

早速やってみましょう。

ただし、執筆時点では既知のバグが存在しますのでご注意ください。(ブログ後半参照)

前提

執筆時点で最新バージョンのTerraformとAWS Providerを使用します。

% terraform --version
Terraform v1.8.4
on darwin_arm64
+ provider registry.terraform.io/hashicorp/aws v5.51.0

1.事前準備

API経由でランディングゾーンを作成する場合、マネコンで実施するのとは異なり、事前準備が2つ必要となります。

  1. 事前に、共有アカウントであるAuditアカウントとLogArchiveアカウントを発行する
  2. 共有アカウントに必要なIAMロールを作成する

また、現時点ではAPI経由だと「リージョン拒否設定」は未対応ですのでご注意ください。

この辺りは先述のリリースブログ、あるいは、公式ドキュメントを参照ください。

Step 1: Configure your landing zone - AWS Control Tower

1-1. 共有アカウントであるAuditアカウントとLogArchiveアカウントを事前に発行する

例えば以下のように、既存の組織とOUに対し、新しく2つのメンバーアカウントを作成します。

organizations.tf

data "aws_organizations_organization" "org" {
}

data "aws_organizations_organizational_unit" "existing_ou" {
  parent_id = data.aws_organizations_organization.org.roots[0].id
  name      = "ExistingOU"
}


resource "aws_organizations_account" "audit" {
  name              = "Audit"
  email             = "controltower+audit@example.jp"
  parent_id         = data.aws_organizations_organizational_unit.existing_ou.id
  close_on_deletion = true
}

resource "aws_organizations_account" "log_archive" {
  name              = "Log-Archive"
  email             = "controltower+log-archive@example.jp"
  parent_id         = data.aws_organizations_organizational_unit.existing_ou.id
  close_on_deletion = true
}

これを terraform apply することによって、2つのAWSアカウントが発行されました。

1-2. 共有アカウントに必要なIAMロールを作成する

続いて、Audit/LogArchiveアカウントそれぞれに対し、以下4つのサービスロールを作成します。

  • AWSControlTowerAdmin
  • AWSControlTowerCloudTrailRole
  • AWSControlTowerConfigAggregatorRoleForOrganizations
  • AWSControlTowerStackSetRole

公式ドキュメントにはそれらを作成するCloudFormationテンプレートのサンプルがあります。

Prerequisites for launching a landing zone using AWS CloudFormation - AWS Control Tower

これをTerraformに書き直します。

iam_role.tf

resource "aws_iam_role" "controltower_admin" {
  name = "AWSControlTowerAdmin"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Service = "controltower.amazonaws.com"
      }
      Action = "sts:AssumeRole"
    }]
  })

  path = "/service-role/"
}

resource "aws_iam_role_policy_attachment" "controltower_admin_managed" {
  role       = aws_iam_role.controltower_admin.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSControlTowerServiceRolePolicy"
}

resource "aws_iam_policy" "controltower_admin" {
  name = "AWSControlTowerAdminPolicy"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "ec2:DescribeAvailabilityZones"
      Resource = "*"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "controltower_admin_custom" {
  role       = aws_iam_role.controltower_admin.name
  policy_arn = aws_iam_policy.controltower_admin.arn
}

resource "aws_iam_role" "controltower_cloudtrail" {
  name = "AWSControlTowerCloudTrailRole"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Service = "cloudtrail.amazonaws.com"
      }
      Action = "sts:AssumeRole"
    }]
  })

  path = "/service-role/"
}

resource "aws_iam_policy" "controltower_cloudtrail" {
  name = "AWSControlTowerCloudTrailRolePolicy"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ]
      Resource = "arn:aws:logs:*:*:log-group:aws-controltower/CloudTrailLogs:*"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "controltower_cloudtrail" {
  role       = aws_iam_role.controltower_cloudtrail.name
  policy_arn = aws_iam_policy.controltower_cloudtrail.arn
}

resource "aws_iam_role" "controltower_config" {
  name = "AWSControlTowerConfigAggregatorRoleForOrganizations"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Service = "config.amazonaws.com"
      }
      Action = "sts:AssumeRole"
    }]
  })

  path = "/service-role/"
}

resource "aws_iam_role_policy_attachment" "controltower_config" {
  role       = aws_iam_role.controltower_config.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSConfigRoleForOrganizations"
}

resource "aws_iam_role" "controltower_stackset" {
  name = "AWSControlTowerStackSetRole"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Service = "cloudformation.amazonaws.com"
      }
      Action = "sts:AssumeRole"
    }]
  })

  path = "/service-role/"
}

resource "aws_iam_policy" "controltower_stackset" {
  name = "AWSControlTowerStackSetRolePolicy"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "sts:AssumeRole"
      Resource = "arn:aws:iam::*:role/AWSControlTowerExecution"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "controltower_stackset" {
  role       = aws_iam_role.controltower_stackset.name
  policy_arn = aws_iam_policy.controltower_stackset.arn
}

各アカウントにてこれらが適用できたら準備はOKです。

2.ランディングゾーンを作成する

aws_controltower_landing_zone の主たる引数である manifest_json では、JSONファイルを渡します。 こちらを jsonencode関数を用い、外出しされたJSONファイルではなく、resourceブロック内に記述してみます。

controltower.tf

resource "aws_controltower_landing_zone" "main" {
  manifest_json = jsonencode(
    {
      "governedRegions" : [
        "us-east-1",
        "ap-northeast-1",
      ],
      "organizationStructure" : {
        "security" : {
          "name" : "SecurityOU"
        },
        "sandbox" : {
          "name" : "SandboxOU"
        }
      },
      "centralizedLogging" : {
        "accountId" : "999999999999", ## LogArchive Account ID が入ります
        "configurations" : {
          "loggingBucket" : {
            "retentionDays" : 3650
          },
          "accessLoggingBucket" : {
            "retentionDays" : 365
          }
        },
        "enabled" : true
      },
      "securityRoles" : {
        "accountId" : "111111111111" ## Audit Account ID が入ります
      },
      "accessManagement" : {
        "enabled" : false
      }
    }
  )
  version = "3.3"
}

上記でplanを実行してみます。

% terraform plan

## 中略

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following
symbols:
  + create

Terraform will perform the following actions:

  # aws_controltower_landing_zone.main will be created
  + resource "aws_controltower_landing_zone" "main" {
      + arn                      = (known after apply)
      + drift_status             = (known after apply)
      + id                       = (known after apply)
      + latest_available_version = (known after apply)
      + manifest_json            = jsonencode(
            {
              + accessManagement      = {
                  + enabled = false
                }
              + centralizedLogging    = {
                  + accountId      = "999999999999"
                  + configurations = {
                      + accessLoggingBucket = {
                          + retentionDays = 365
                        }
                      + loggingBucket       = {
                          + retentionDays = 3650
                        }
                    }
                  + enabled        = true
                }
              + governedRegions       = [
                  + "ap-northeast-1",
                  + "us-east-1",
                ]
              + organizationStructure = {
                  + sandbox  = {
                      + name = "SandboxOU"
                    }
                  + security = {
                      + name = "SecurityOU"
                    }
                }
              + securityRoles         = {
                  + accountId = "111111111111"
                }
            }
        )
      + version                  = "3.3"
    }

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

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

問題なければapplyします。

% terraform apply  -auto-approve

## 中略

Plan: 1 to add, 0 to change, 0 to destroy.
aws_controltower_landing_zone.main: Creating...
aws_controltower_landing_zone.main: Still creating... [10s elapsed]

## 中略

aws_controltower_landing_zone.main: Still creating... [20m10s elapsed]
aws_controltower_landing_zone.main: Still creating... [20m20s elapsed]
aws_controltower_landing_zone.main: Still creating... [20m30s elapsed]
aws_controltower_landing_zone.main: Creation complete after 20m34s [id=8NIP5NU70GMBF8JF]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

20分後にapplyが完了しました。

ちなみにControl Towerのマネコン側では、セットアップの途中経過や成功したことが確認できます。

ランディングゾーンの作成については以上です。

重要)バグに注意

執筆時点(2024.5.28)では、aws_controltower_landing_zone には、既知のバグが存在しています。

これにより、結論から申しますと、 aws_controltower_landing_zone リソースを本番環境で使用することは、現時点ではオススメできません。

「早くランディングゾーンをTerraformで作成・管理したい!」という方は、上記Issueをサブスクライブして、ウォッチしておくと良さそうです。

詳細はIssueの会話を参照となりますが、上記では主にバグは2箇所あります。

  1. リージョンの順番を入れ替えるようchangesが検出される
  2. retensionDaysが文字列->数字に変えるようにchangesが検出される

1.の方はまだ軽微で、暫定対応としてplan結果に合わせて入れ替えてあげれば、差分は検出されなくなります。

2.の方はちょっと致命的なので少し説明します。

tfファイル(あるいはJSONファイル)上の ログバケットとアクセスログバケットの保持期間を 365 などの number型で記述しますが、Terraform AWS ProviderおよびStateファイルではこれを"365" string型として扱っています。

        "configurations" : {
          "loggingBucket" : {
            "retentionDays" : 3650
          },
          "accessLoggingBucket" : {
            "retentionDays" : 365
          }
        },

よって、何も変更していなくてもplanを実行するとAWS側のレスポンスとして、

                  ~ configurations = {
                      ~ accessLoggingBucket = {
                          ~ retentionDays = "365" -> 365
                        }
                      ~ loggingBucket       = {
                          ~ retentionDays = "3650" -> 3650
                        }
                    }

文字列を数値に直そうとするplan結果が返ってきます。

ちなみにこれをapplyすると、10分程度経過後に完了しますが、実体は何も変わっておらず、またplanすれば同様の差分が永遠に検知される状態となります。(この挙動が本番利用をオススメできない理由です)

おそらくAWS Providerの問題で、JSONを基本的にstringとして解釈してる部分を、AWS側が定義するマニフェストファイルの型に合わせる必要がありそうです。

つまずきポイント

もう1点、ランディングゾーンの作成はTerraformで最初からやるより、マネコンからセットアップしたランディングゾーンを terraform import する方が現時点では簡単と思われます。

つまずきポイントとして、Terraformでランディングゾーンを作成する場合、

1.どの方法でも当てはまる注意事項(前提条件: 管理アカウントの起動前自動チェック - AWS Control Tower

2.API経由のみ注意事項(API を使用した AWS Control Tower の開始方法 - AWS Control Tower

両方を確認することになります。

実は、自分はこのうち「共有アカウントとして使用する既存アカウントのAWS Configレコーダーとチャンネルが作成されていた」ため、1回目の実行はエラーになりました。

マネコン画面でも以下のようにエラーが表示されました。

そして、途中で失敗するとTerraformでは「途中から再開」することができず「一度、ランディングゾーンを廃止して、再度作成」したくなります。 一度廃止してから再度作成する場合、以下注意事項を踏まえる必要が出てきます。

ランディングゾーン廃止後のセットアップ - AWS Control Tower

というように...これらのエラーによりControl Towerの内部的に作成されるリソースについて学べるのでおもしろいですが、万人にオススメできるものではないので、現時点では作成後にimportして管理するのが無難かもしれません。

終わりに

現時点では少し課題はあれど、とはいえ、ランディングゾーン自体をTerraformも管理できるようになったことは、ユーザーが待ち望んだものでした。

ぜひ、どんどん触ってフィードバックしていきましょう〜

それでは今日はこの辺で。ちゃだいん(@chazuke4649)でした。