実践Terraform:マルチAWSアカウント環境の構成管理とモジュール分割

これは ドワンゴ Advent Calendar 2025 の20日目の記事です。

はじめに

こんにちは!技術本部クラウド構築運用部クラウドプラットフォームセクションの堀内です。

弊社では昨年6月、オンプレの仮想化基盤で大規模障害が発生し、急遽WebサービスをAWSへお引越しすることになりました。 もともと1台のVMにキャッシュ・DB・Webサーバ・CMSが全部乗っているモノリス構成だったのですが、AWS移行にあたってCloudFront + ALB + EC2 + Auroraというクラウドネイティブな構成にリニューアルしました。 この移行プロジェクトでは、すべてのWebサービスを1つのアカウントで管理するのではなく、複数のAWSアカウントに分散して構築する要件がありました。 同じ構成のWebサービスが複数あったことからインフラ構成を統一して再利用性を高めつつ、複数AWSアカウントにそれぞれWebサービスをデプロイできる仕組みが求められました。 この記事では、そんなWebサービス移行プロジェクトで得られたTerraformによるモジュール分割とマルチアカウント管理のノウハウを、実体験ベースでご紹介します。

前回の記事ではAnsibleによるAWSマルチアカウント管理について紹介しましたが、今回はTerraformを使ったマルチアカウント管理について紹介します。

全体像

今回のマルチアカウント構成の概要は以下の通りです。

マルチアカウント構成の概要図

アカウントはざっくり分けると、管理用リソースが作成される管理用アカウントと、実際のWebサービスのAWSリソースが乗る複数のサービスアカウントがあります。 管理用アカウントでは、tfstateの管理やメトリクス・アラートの集約、OIDC(OpenID Connect)でTerraformのplan/applyを行うロールの管理などを担当します。 実際にWebサービスのAWSリソースを管理する各サービスアカウントでは、WebサービスごとにCloudFrontやVPC、ALBなどのリソースを作成します。


第1章: マルチAWSアカウント管理のアーキテクチャ設計

まずは、マルチAWSアカウント管理のアーキテクチャ設計から始めます。

Terraform権限設計

Terraform用IAMロールは、plan用のロールとapply用のロールに分割しました。 ロールを分割し付与する権限を分けることで、plan時にインフラ環境が変更されないことを保証できるようにしました。

ロールの引き受け方は、管理用アカウントから各サービスアカウントへAssumeRoleする構成にしました。 これは、第3章で説明するGitHub Actionsからのアクセスを一元管理しやすくするためです。 GitHub Actionsでplan/applyを実行する時は、まず管理用AWSアカウントの管理用ロールにAssumeRoleし、その後各環境のAWSアカウントにある実行用ロールを使う流れです。

管理用ロールからのAssumeRoleを許可する信頼ポリシーの設定例は以下の通りです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::<ManagementAccountId>:root"
            },
            "Action": "sts:AssumeRole",
            "Condition": {
                "ArnEquals": {
                    "aws:PrincipalArn": "arn:aws:iam::<ManagementAccountId>:role/<plan or apply>-role"
                }
            }
        }
    ]
}

管理用ロールには、各アカウントの実行用ロールへのAssumeRole権限とtfstate管理を管理するためのS3の権限を付与しました。Terraform v1.10以降では、S3バックエンドのロックにDynamoDBが不要になったため、DynamoDBの権限は付与せず、ロックファイル用の権限を以下の例のように付与しました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AssumeExecutionRole",
            "Action": "sts:AssumeRole",
            "Effect": "Allow",
            "Resource": [
                "arn:aws:iam::<ServiceAccountId>:role/<plan or apply>-role",
                "arn:aws:iam::<OtherServiceAccountId>:role/<plan or apply>-role",
                ...
            ]
        },
        {
            "Sid": "ListTfStateBucket",
            "Action": "s3:ListBucket",
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::<TfstateBucket>"
        },
        {
            "Sid": "ReadWriteTfStateBucket",
            "Action": [
                "s3:GetObject",
                "s3:PutObject"
            ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::<TfstateBucket>/*"
        },
        {
            "Sid": "DeleteLockFile",
            "Action": [
                "s3:DeleteObject"
            ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::<TfstateBucket>/*.tflock"
        }
    ],
}

AssumeRole対象のアカウントが複数ある場合、アカウントIDはjsonファイルで管理し、Terraformのfor_eachでjsonファイルを参照して動的に生成すると便利です。

[
  { "id": "<ServiceAccountId>", "name": "ServiceAccount1" },
  { "id": "<OtherServiceAccountId>", "name": "ServiceAccount2" }
]
data "aws_iam_policy_document" "management_role" {
  statement {
    sid    = "AssumeExecutionRole"
    effect = "Allow"
    actions = [
      "sts:AssumeRole",
    ]
    resources = [
      for account in jsondecode(file(local.account_list.json)) : "arn:aws:iam::${account.id}:role/<plan or apply>-role"
    ]
  }
}

ディレクトリ構成

Terraformのディレクトリ構成はGoogle Cloudのベストプラクティスをベースにしました。 Google Cloudのベストプラクティスでは、環境ごとのルートモジュール、機能単位モジュールを分割して管理します。 環境ごとのルートモジュールはenvironmentsディレクトリに配置し、ルートモジュールから呼び出される機能単位モジュールはmodulesディレクトリに配置します。

今回のプロジェクトでは、複数のAWSアカウントに複数の環境があったため、environmentsディレクトリ内でアカウントごと・サービスごとにさらにディレクトリを分けました。 さらに、アカウントをまたぐリソースや第2章で説明するサービス単位モジュール用のusecasesディレクトリも用意しました。

以上から全体のディレクトリ構成は以下のようになりました。

.
├── environments
│   ├── ServiceAccount1
│   │   ├── ServiceA
│   │   │   ├── dev
│   │   │   ├── stg
│   │   │   └── prd
│   │   └── ServiceB
│   │       └── 略
│   └── ServiceAccount2
│       ├── ServiceA
│       │   └── 略
│       └── ServiceB
│           └── 略
├── cross-account
│   ├── TerraformManagement
│   ├── Notification
│   └── Alarm
├── modules
│   ├── ServiceA
│   │   ├── VPC
│   │   ├── EC2
│   │   ├── ALB
│   │   ├── CloudFront
│   │   └── RDS
│   └── ServiceB
│       └── 略
└── usecases
    ├── ServiceA
    └── ServiceB

アカウントをまたいだリソース

CloudWatchアラームやSNSトピックなど、アカウントをまたぐ監視リソースはcross-accountディレクトリにまとめました。 今回のプロジェクトのアカウントをまたぐ監視設定には、CloudWatch Observability Access Manager(OAM)やEventBridgeクロスアカウント配信を利用しました。 CloudWatch Observability Access Manager(OAM)やEventBridgeクロスアカウント配信の詳細はAWS公式ドキュメントをどうぞ。


第2章: モジュール分割による再利用性とメンテナンス性の向上

モジュールはVPCやS3、IAMロールなどの機能単位モジュールと、Webサービスを構成するために必要なリソースをまとめたサービス単位モジュールに分割しました。 サービス単位モジュールは機能単位モジュールをまとめて呼び出し、依存関係を内部で完結させます。 AWS CDK経験者向けに説明すると、機能単位モジュールはL2コンストラクト、サービス単位モジュールはL3コンストラクトまたはスタックに近いイメージです。 機能単位モジュールはmodulesディレクトリ、サービス単位モジュールはusecasesディレクトリに配置しています。

サービス単位モジュールを利用した場合の依存関係のイメージ図

このプロジェクトの最初の段階ではルートモジュールから直接機能単位モジュールを呼び出しており、main.tfが非常に長くなってしまい、各ルートモジュールの記述の統一や管理が難しくなっていました。 サービス単位モジュールを導入したことで、ルートモジュールの可読性と保守性を大幅に上げることができました。

以下は機能単位モジュールの例です。

VPC機能単位モジュール例:

resource "aws_vpc" "main" {
  cidr_block = var.cidr_block
  ...
}

resource "aws_subnet" "public_1" {
  vpc_id = aws_vpc.main.id
  cidr_block = cidrsubnet(var.cidr_block, 8, 1)
  ...
}

resource "aws_subnet" "public_2" {
  vpc_id = aws_vpc.main.id
  cidr_block = cidrsubnet(var.cidr_block, 8, 2)
  ...
}
variable "cidr_block" {
  type = string
  description = "The CIDR block for the VPC"
}
output "vpc_id" {
  value = aws_vpc.main.id
  description = "The ID of the VPC"
}

output "public_subnet_ids" {
  value = aws_subnet.public[*].id
  description = "List of public subnet IDs"
}

ALB機能単位モジュール例:

resource "aws_lb" "main" {
  name                       = var.name
  load_balancer_type         = "application"
  subnets                    = var.subnet_ids
  enable_deletion_protection = var.enable_deletion_protection
  ...
}
variable "name" {
  type = string
  description = "The name of the ALB"
}

variable "subnet_ids" {
  type = list(string)
  description = "List of subnet IDs for the ALB"
}

variable "enable_deletion_protection" {
  type    = bool
  description = "Whether to enable deletion protection"
}

サービス単位モジュールは、これらの機能単位モジュールを組み合わせてWebサービスを構成するリソースを作るためのモジュールです。 入力変数として機能単位モジュールのパラメータをobject型で受け取り、サービス単位モジュール内で機能単位モジュールを呼び出すようにしました。 複数の設定値(例: VPCやALBのパラメータなど)をobject型の変数にまとめてサービス単位モジュールに渡すことで、ルートモジュール側では個別の変数を一つずつ指定する必要がなくなり、記述がシンプルになります。 このようにすることで、ルートモジュールからはobject型にまとめた設定値を渡すだけでサービス単位モジュールを利用でき、全体の見通しが良くなります。

module "vpc" {
  source = "../../modules/ServiceA/VPC"
  cidr_block = var.vpc_config.vpc_cidr
}
module "alb" {
  source = "../../modules/ServiceA/ALB"
  name = var.alb_config.alb_name
  subnet_ids = module.vpc.public_subnet_ids
}
variable "vpc_config" {
  type = object({
    vpc_cidr = string
  })
}
variable "alb_config" {
  type = object({
    alb_name                   = string
    enable_deletion_protection = optional(bool, true)
  })
}

ルートモジュールからサービス単位モジュールを呼び出す際には、各環境の設定をsettings.tf内のlocalsで定義し、サービス単位モジュールの入力変数に渡すようにしました。

locals {
    vpc_config = {
        vpc_cidr = "<VPC_CIDR>"
    }
    alb_config = {
        alb_name = "<ALB_NAME>"
    }
}
module "service_a" {
    source = "../../usecases/ServiceA"
    
    vpc_config = local.vpc_config
    alb_config = local.alb_config 
}

このように、機能単位モジュールは渡された変数でリソースを作成するだけとし、サービス単位モジュールで依存関係を完結させることで、ルートモジュールからはサービス単位モジュールを呼び出すだけで済む構成にしています。 サービス単位モジュールを使うことで、同じWebサービス構成を複数のAWSアカウントにデプロイする際にも、ルートモジュールの記述量を減らせて管理が楽になりました。

この章で説明したモジュールをまとめます。

  • 機能単位モジュール:VPC、ALB、EC2、RDSなどの個別リソースを管理し、変数で受け取ったパラメータでリソースを作成
  • サービス単位モジュール:機能単位モジュールを組み合わせて依存を解決し、Webサービス全体のリソースを管理
  • ルートモジュール:各環境ごとにサービス単位モジュールを呼び出し、環境固有の設定を適用

ルートモジュールから直接機能単位モジュールを呼び出している場合は、下図左のように依存数がルートモジュール数×機能単位モジュール数になりますが、サービス単位モジュールを使うことでルートモジュール数+サービス単位モジュール数に減らせて管理が楽になりました。

サービス単位モジュールを利用した場合と利用しない場合の依存関係の違い

第3章: GitHub ActionsによるCI/CDパイプライン構築

Terraformのplan/applyを自動化するため、CI/CDパイプラインも作りました。Pull Request作成時にterraform planを自動実行してレビューを支援し、mainブランチへのマージ時にterraform applyを自動実行する流れです。

GitHub ActionsからAWSへの認証にはOIDC(OpenID Connect)がおすすめです。アクセスキーをGitHub Secretsに保存する必要がなく、セキュアに運用できます。OIDCの設定手順は公式ドキュメントをどうぞ。

各ルートモジュールにplanまたはapply用のvariableを追加し、GitHub Actionsのワークフローでplanを実行するかapplyを実行するかを切り替えられるようにしました。

variable "execution_mode" {
  type    = string
  default = "plan" # or "apply"
  description = "Execution mode: plan or apply"
  validation {
    condition     = contains(["plan", "apply"], var.execution_mode)
    error_message = "execution_mode must be either 'plan' or 'apply'."
  }
}
provider "aws" {
  region = "us-east-1"
  assume_role {
    role_arn = var.execution_mode == "plan" ? "arn:aws:iam::<ServiceAccountId>:role/plan-role" : "arn:aws:iam::<ServiceAccountId>:role/apply-role"
  }
}

以下はワークフローの例です。Pull Request作成時にplanを実行し、mainブランチへのマージ時にapplyを実行します。 AWSの管理用ロールのARNはリポジトリのSecretsに保存します。

name: Terraform CI/CD
on:
  pull_request:
  push:
    branches:
      - main

inputs:
  targets:
    description: 'List of root modules to plan or apply, e.g. ["environments/ServiceAccount1/ServiceA/dev", "environments/ServiceAccount1/ServiceA/stg"]'
    required: true

jobs:
  terraform:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      max-parallel: 10
      matrix:
        root-module: ${{ fromJson(inputs.targets) }}
    env:
        AWS_REGION: ap-northeast-1
    steps:
        - name: Checkout code
          uses: actions/checkout@v5
        - name: Configure Apply AWS credentials
          if: github.event_name == 'push' && github.ref == 'refs/heads/main'
          uses: aws-actions/configure-aws-credentials@v5
          with:
            role-to-assume: ${{ secrets.AWS_APPLY_ROLE_ARN }}
            aws-region: ${{ env.AWS_REGION }}
            role-session-name: GitHubActionsSession
        - name: Configure Plan AWS credentials
          if: github.event_name == 'pull_request'
          uses: aws-actions/configure-aws-credentials@v5
          with:
            role-to-assume: ${{ secrets.AWS_PLAN_ROLE_ARN }}
            aws-region: ${{ env.AWS_REGION }}
            role-session-name: GitHubActionsSession
        - name: Set up Terraform
          uses: hashicorp/setup-terraform@v2
          with:
            terraform_version: 1.10.0
        - name: Terraform Init
          run: terraform init
          working-directory: ${{ matrix.root-module }}
        - name: Terraform Plan
          if: github.event_name == 'pull_request'
          run: terraform plan -var="execution_mode=plan"
          working-directory: ${{ matrix.root-module }}
        - name: Terraform Apply
          if: github.event_name == 'push' && github.ref == 'refs/heads/main'
          run: terraform apply -auto-approve -var="execution_mode=apply"
          working-directory: ${{ matrix.root-module }}

このワークフローは、引数でルートモジュールのパスを受け取り、特定のルートモジュールだけ実行するようにしています。 plan/apply対象のルートモジュールリストは、プログラムで差分を検出して生成します(第4章参照)。


第4章: 補助プログラム

差分検出プログラム

Terraformプロジェクトの規模が大きくなると、CIのplan時間やAPIレートリミットが課題になります1。 そこで、変更のあったルートモジュールだけを出力するプログラムをGoで書きました。さらに、変更のあったルートモジュールが依存するモジュールも再帰的に探索して、差分検出対象に含めるようにしています。 CI/CDではこのプログラムを実行し、変更のあったルートモジュールリストを第3章のGitHub Actionsの入力引数として渡すことで、効率的にplan/applyを実行できます。

このプログラムはtf-mod-watcherという名前で公開していますのでぜひ見ていただけると嬉しいです。

import用プログラム

プロジェクト開始直後は開発速度を優先し、手動でAWSコンソールからリソースを作ったり、CloudFormationで作ったりしていました。 その後、これまで説明したTerraform構成に移行するため、既存リソースをTerraform管理下に置く必要が出てきました。 既存リソースをTerraform管理下に置くにはimportコマンドではなく、importブロックを使いました。 これは、importコマンドは手動実行が必要で自動化が難しいのに対し、importブロックはHCLコードとして管理できるためCI/CDパイプラインで自動実行できるからです。 importブロックを使うためには、import対象リソースのID(ARNなど)を調べてHCLコードとして書き起こす必要があるので、ただのHCLコード生成ではなくAWS SDKでリソース情報を取得してimport用のHCLコードを自動生成するプログラムをGoで作りました。

テンプレートファイルtemplates/import_alb.tf.tmplの例:

import {
  to = module.service_a.module.alb.aws_lb.main
  id = "{{ .AlbArn }}"
}

# セキュリティグループは省略

import {
  to = module.alb.aws_lb_target_group.main
  id = "{{ .TargetGroupArn }}"
}

import {
  to = module.alb.aws_lb_listener.main
  id = "{{ .ListenerArn }}"
}

Goプログラム例:

import (
  "context"
  "errors"
  "io"
  "os"
  "path/filepath"
  "text/template"

  "github.com/aws/aws-sdk-go-v2/aws"
  "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2"
)

type GenerateImportAlbArgs struct {
  OutputDir    string
  Service      string
  Environment  string
  awsConfig    aws.Config
}

type AlbTemplateParams struct {
  AlbArn                                 string
  TargetGroupArn                         string
  ListenerArn                            string
}

func generateImportAlb(args *GenerateImportAlbArgs) error {
  // security groupは省略
  lbClient := elasticloadbalancingv2.NewFromConfig(args.awsConfig)

  tgRes, err := lbClient.DescribeTargetGroups(context.TODO(), &elasticloadbalancingv2.DescribeTargetGroupsInput{
    Names: []string{args.Service + "-" + args.Environment + "-alb-tg"},
  })
  if err != nil {
    return err
  }
  if len(tgRes.TargetGroups) != 1 {
    return errors.New("target group not found or more than one target group found")
  }

  lbRes, err := lbClient.DescribeLoadBalancers(context.TODO(), &elasticloadbalancingv2.DescribeLoadBalancersInput{
    Names: []string{args.Service + "-" + args.Environment + "-alb"},
  })
  if err != nil {
    return err
  }
  if len(lbRes.LoadBalancers) != 1 {
    return errors.New("load balancer not found or more than one load balancer found")
  }

  listenerRes, err := lbClient.DescribeListeners(context.TODO(), &elasticloadbalancingv2.DescribeListenersInput{
    LoadBalancerArn: lbRes.LoadBalancers[0].LoadBalancerArn,
  })
  if err != nil {
    return err
  }

  var httpListenerArn string
  for _, listener := range listenerRes.Listeners {
    if *listener.Port == 80 {
      httpListenerArn = *listener.ListenerArn
      break
    }
  }

  if httpListenerArn == "" {
    return errors.New("alb listener not found")
  }

  file, err := os.Create(filepath.Join(args.OutputDir, "import_alb.tf"))
  if err != nil {
    return err
  }
  defer file.Close()

  return executeImportAlbTemplate(file, &AlbTemplateParams{
    AlbArn:                                 *lbRes.LoadBalancers[0].LoadBalancerArn,
    TargetGroupArn:                         *tgRes.TargetGroups[0].TargetGroupArn,
    ListenerArn:                            httpListenerArn,
  })
}

func executeImportAlbTemplate(wr io.Writer, params *AlbTemplateParams) error {
  tmpl, err := template.New("import_alb.tf.tmpl").ParseFiles("templates/import_alb.tf.tmpl")
  if err != nil {
    return err
  }

  return tmpl.Execute(wr, params)
}

AWS SDKでリソース情報を取得し、テンプレートでimport用HCLを生成することで、import作業もサクッと効率化できます。


おわりに

この記事では、実際のプロジェクトを元にTerraformによるマルチAWSアカウント環境の構成管理、モジュール分割、CI/CD自動化、補助プログラムによる運用効率化の実践手法をまとめてみました。

この記事が、みなさんのマルチアカウント環境の構成管理や運用自動化のヒントになれば嬉しいです。

ドワンゴでは、クラウド上に、可用性・保守性・セキュリティなどに優れたシステムを一緒に作る仲間を募集中です。ご興味のある方は、ぜひ採用情報もチェックしてみてください!

参考文献