AWS Organizations設計で10アカウント管理が崩壊した話|実装を丸ごと作り直した3ヶ月

5アカウントから10アカウントに増えた瞬間、SCP設計が完全に壊れました。削除権限の制御失敗、OU階層の複雑化、ポリシー組み合わせの地獄…実務で学んだ失敗と改善を包み隠さず公開します。

先日、10アカウント管理で設計を丸ごと壊しました

うちのチームがAWS Organizations導入して1年半。最初は5アカウントで細かく管理できてたんですけど、プロダクト増えて10アカウントになった瞬間に統制が崩壊しました。SCP(Service Control Policy)の設計が甘かった。削除権限の制御で本来消していい開発環境のリソースまで引っかかって、チーム全体で毎回「なぜロック掛かってるんだ?」って騒ぐ羽目に。

そこから3ヶ月間、実装を見直して気づいたことが結構あります。教科書通りのOrganizationsって、本番環境では思ったより複雑なんですよね。今日はそのあたりの実務的な話をシェアします。

階層設計を「運用できる粒度」で決めるべき

最初の失敗は、アカウント階層をビジネス単位で分けようとしたことです。うちは「本番」「ステージング」「開発」「セキュリティ」って分けてたんですけど、実際に運用してみたら、プロダクトごとにアカウントを分けたい要件が増えていった。

いま落ち着いたのがこんな構成:

Root (Organization管理アカウント)
├── Security OU
│   ├── Audit Account (CloudTrail、Config、GuardDuty一元化)
│   └── Log Archive (ログ集約)
├── Workload Production OU
│   ├── Product-A Account
│   └── Product-B Account
├── Workload Development OU
│   ├── Dev Account (デプロイ検証用)
│   └── Sandbox Account (個人実験環境)
└── Security Baseline OU
    └── Network Account (Transit Gateway、VPC集約)

ここのポイントは「OU(Organization Unit)の数を絞ること」。以前は8個OUがあったんですけど、いまは5個。なぜかというと、各OUごとにSCPを作成・維持する手間が半端ないんです。OUが増えるとポリシー組み合わせがマトリックス爆発する。

実装当初、3個のOUに対して5個のSCPを組み合わせてたんですけど、本来の意図と違う挙動が出始めました。例えば「本番で削除禁止」と「開発で削除許可」を両立させようとしたら、どのポリシーが優先されるのかわからなくなる。そうなると運用が地獄です。

本番環境で「最小限の制御」をするなら、OU数は片手で数えられるくらいが現実的だと痛感しました。

SCPの「明示的な許可」と「暗黙的な拒否」のジレンマ

これが本当に悩ましい。SCPはデフォルト許可で、制限したい項目を拒否するスタイル(Deny-based)が一般的です。でも実務では逆行します。本番アカウントでセキュリティキーの削除を防ぎつつ、開発アカウントではキー削除を許可したい。こういう要件が増えるんですよね。

僕たちの現在の実装がこれ:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyDeleteKMSKey",
      "Effect": "Deny",
      "Action": [
        "kms:ScheduleKeyDeletion",
        "kms:DisableKey"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:RequestedRegion": "ap-northeast-1"
        }
      }
    },
    {
      "Sid": "DenyTerminateProductionEC2",
      "Effect": "Deny",
      "Action": "ec2:TerminateInstances",
      "Resource": "arn:aws:ec2:*:*:instance/*",
      "Condition": {
        "StringEquals": {
          "ec2:ResourceTag/Environment": "production"
        }
      }
    }
  ]
}

これを本番OUに適用して、開発OUには別のポリシーを適用する。ただここで落とし穴があるんです。SCPはIAMポリシーとは別なんです。SCPで許可しても、IAMで拒否されたら実行できない。逆も然り。つまり、両方確認する必要があるんです。

実装6ヶ月で気づいたんですけど、「なぜか削除できない」って障害報告は、SCPが原因じゃなくてIAMロール設定が間違ってるケースが60%ありました。正直、その時は「SCPチェック漏れだー」って思ってたから、いい勉強になりました。

Control Towerで「自動コンプライアンス」はウソ

正直に言うと、Control Towerは便利です。新しいアカウント作るとき、VPC・CloudTrail・Config・GuardDutyが自動で仕込まれます。ほんと地味に便利。でも「自動コンプライアンス」という謳い文句は、かなり誇大広告です。

実際のところ、Control Towerは「基本的なセキュリティ構成を自動化する」だけなんですよ。本番環境のコンプライアンス要件(例:SOC2、HIPAA、PCI-DSS)に対応しようと思ったら、相当カスタマイズが必要です。

うちが3ヶ月で組んだのがこの構成:

flowchart TB
    subgraph ControlTower["Control Tower"]
        direction TB
        AccountFactory["Account Factory<br/>自動プロビジョニング"]
        Guardrails["Guardrails<br/>SCP + IAM"]
    end
    
    subgraph CustomCompliance["カスタムコンプライアンス層"]
        direction TB
        Config["AWS Config<br/>リアルタイムコンプライアンス評価"]
        Lambda["Lambda Function<br/>自動修復"]
        EventBridge["EventBridge<br/>イベント駆動トリガー"]
    end
    
    subgraph Monitoring["監視・ロギング"]
        direction TB
        CloudTrail["CloudTrail<br/>監査ログ"]
        SecurityHub["Security Hub<br/>脅威検知集約"]
        Macie["Macie<br/>データ保護"]
    end
    
    AccountFactory --> Config
    Guardrails --> EventBridge
    Config --> Lambda
    Lambda --> EventBridge
    CloudTrail --> SecurityHub
    Macie --> SecurityHub
    EventBridge --> Monitoring
    
    style ControlTower fill:#ff9999
    style CustomCompliance fill:#99ccff
    style Monitoring fill:#99ff99

Control TowerのGuardrailsは基本的な制御です。例えば「S3バケットはデフォルト暗号化」みたいなやつ。でも実務では「S3には特定のタグ付けが必須」とか「CloudFrontの前段は必須」みたいな、より細かい制御が必要になるんです。

そこで活躍するのがAWS Configですね。ConfigルールをEventBridgeと組み合わせると、非準拠のリソースが見つかったときに自動で修復できる。これで初めて「本当のコンプライアンス管理」になります。

import boto3
import json
from datetime import datetime

lambda_client = boto3.client('lambda')
s3_client = boto3.client('s3')
config_client = boto3.client('config')

def lambda_handler(event, context):
    # EventBridgeから非準拠リソース情報を受け取る
    detail = event.get('detail', {})
    resource_id = detail.get('resourceId')
    resource_type = detail.get('resourceType')
    
    if resource_type == 'AWS::S3::Bucket':
        try:
            # S3バケットの暗号化を有効化
            s3_client.put_bucket_encryption(
                Bucket=resource_id,
                ServerSideEncryptionConfiguration={
                    'Rules': [{
                        'ApplyServerSideEncryptionByDefault': {
                            'SSEAlgorithm': 'AES256'
                        },
                        'BucketKeyEnabled': True
                    }]
                }
            )
            
            # Config評価を再トリガー
            config_client.put_evaluations(
                Evaluations=[{
                    'ComplianceResourceType': resource_type,
                    'ComplianceResourceId': resource_id,
                    'ComplianceType': 'COMPLIANT',
                    'Annotation': 'Auto-remediated by Lambda',
                    'OrderingTimestamp': datetime.utcnow().isoformat() + 'Z'
                }],
                ResultToken='remediation-token'
            )
            
            return {'statusCode': 200, 'body': 'Remediation successful'}
        except Exception as e:
            print(f'Remediation failed: {str(e)}')
            return {'statusCode': 500, 'body': str(e)}

本番運用で気づいたのは、この自動修復もやりすぎるとダメなんですよ。ログ保持期間を自動で変更されたら、意図した監査ログが消えちゃう。だから修復対象は「明らかにセキュリティ的にアウト」な項目(暗号化なし、公開設定)に限定して、それ以外は通知だけにしています。慎重になるべきです。

マルチアカウント統制の現実的な運用

正直、AWS Organizations + Control Tower だけでは足りません。6ヶ月運用で見えた、本当に必要な層はこんな感じです:

1. アカウント作成時の自動セットアップ

Control Towerの Account Factory で新規アカウント作るんですけど、その後の細かいセットアップ(VPC作成、サブネット設計、ルートテーブル設定)は自動化されていません。手作業になってしまう。

そこで CDK + StackSets を使って自動化します:

import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';

export class BaselineStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 本番VPC作成(所有アカウントで自動適用)
    const vpc = new ec2.Vpc(this, 'ProductionVPC', {
      cidr: '10.0.0.0/16',
      maxAzs: 3,
      natGateways: 1,
      subnetConfiguration: [
        {
          subnetType: ec2.SubnetType.PUBLIC,
          name: 'Public',
          cidrMask: 24,
        },
        {
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
          name: 'Private',
          cidrMask: 24,
        },
      ],
    });

    // CloudTrail用のS3バケット(ログ集約アカウントで共有)
    const auditBucket = new s3.Bucket(this, 'AuditLogBucket', {
      encryption: s3.BucketEncryption.KMS,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      versioned: true,
      lifecycleRules: [
        {
          noncurrentVersionExpirationInDays: 90,
        },
      ],
    });

    // ログ集約アカウントへのアクセス許可
    const auditAccountId = '123456789012'; // ログ集約アカウントID
    auditBucket.addToResourcePolicy(
      new iam.PolicyStatement({
        sid: 'AllowAuditAccountAccess',
        principals: [new iam.AccountPrincipal(auditAccountId)],
        actions: ['s3:GetObject', 's3:ListBucket'],
        resources: [auditBucket.bucketArn, auditBucket.arnForObjects('*')],
      })
    );

    // タグの強制(Guardrail補完)
    cdk.Tags.of(this).add('Compliance', 'required');
    cdk.Tags.of(this).add('Environment', props?.description || 'production');
  }
}

これをStackSetsで配布すると、新アカウント自動作成の1時間後には本番用VPCが出来上がっています。いい話ですよね。

2. ログ集約と一元監視

セキュリティ系OUの専用アカウント(Audit Account)で、全アカウントのCloudTrail・Config・GuardDutyログを集約します。これが本当に重要。マルチアカウント環境でセキュリティインシデント起きたときに「あのアカウントのログ見てください」なんて言ってたら地獄です。

実装は Lake Formation + Athena で、こんな感じに:

-- マルチアカウント CloudTrail ログクエリ
SELECT 
    eventtime,
    useridentity.accountid,
    eventname,
    eventsource,
    sourceipaddress,
    useragent
FROM cloudtrail_logs
WHERE 
    from_iso8601_timestamp(eventtime) > current_timestamp - interval '7' day
    AND eventname IN ('DeleteBucket', 'PutBucketPolicy', 'DisableLogging')
GROUP BY useridentity.accountid, eventname
ORDER BY eventtime DESC;

気づいたのは、ログ集約だけしてもダメなんですよ。アラートも一元化する必要があります。Security Hubを使って、各アカウントのGuardDuty検知を1箇所で見れるようにしました。こうすることで、インシデント検知から対応までの時間が劇的に縮まります。

3. Terraform/CDKのマルチアカウント対応

最初の失敗:各アカウントで別々のTerraformを管理してました。結果、環境間で設定がズレまくりました。もう大変なんですよ。あっちで削除されたリソースがこっちにはまだ残ってるとか、そんなカオスが発生する。

今は assume role で、管理アカウントから各アカウントリソースにアクセスする形にしました:

# 管理アカウント側
provider "aws" {
  alias = "production"
  assume_role {
    role_arn = "arn:aws:iam::PROD_ACCOUNT_ID:role/TerraformRole"
  }
}

resource "aws_s3_bucket" "production" {
  provider = aws.production
  bucket   = "prod-bucket-${var.environment}"
}

これによって、管理アカウント側の単一の git リポジトリから全アカウントを統制できます。個人的には、この手法が一番運用しやすいと思ってます。

セキュリティ監査と定期的な見直し

本当に大切なのはここです。オンボーディング完了しても、3ヶ月ごとに設計を見直す必要があります。なぜなら、AWSの新機能・新ベストプラクティスが四半期ごとに出てくるから。放っておくと気づかないうちに陳腐化します。

うちのチームで実装した「設計レビュープロセス」がこちら:

頻度内容担当者
月1回脅威検知確認(Security Hubの検知傾向分析)セキュリティチーム
月1回コンプライアンス監査(Configルール評価の失敗件数推移)インフラチーム
四半期ごと設計レビュー(新機能対応・ベストプラクティス更新)リードエンジニア
半年ごとペネテストシミュレーション(SCPの実効性確認)セキュリティエンジニア

また、セキュリティ事象の検知から対応までのフローも整備する必要があります。Organizations だけで完結しません。インシデント対応の体制、連絡先、エスカレーションパスなど、そういったものを同時に用意しておく必要があるんです。

まとめ

AWS Organizations のセキュリティ設計、本当のところはこんな感じです:

  • OU は「運用できる粒度」で。5個前後が現実的 — 多すぎるとSCP管理が指数爆発する
  • Control Tower は基礎。その上に自動修復層を重ねる — ConfigルールとLambdaで本当のコンプライアンス実現
  • マルチアカウント IaC 統制が必須 — Terraform/CDK の assume role パターンで統一管理
  • ログ集約と一元監視がないと、セキュリティインシデント対応が地獄 — Lake Formation + Athena + Security Hub で対応
  • 設計は固定ではなく定期的に見直す — 四半期ごとに新機能・新ベストプラクティス対応を

10アカウント運用でボロボロになった経験があるから、次のマルチアカウント環境では(多分5年以内にきますw)、最初からこの設計で行こうと思ってます。教科書通りじゃなく、本当に運用できる粒度を最優先に。

皆さんのチームではどんなMulti-Account戦略とってますか?何か気づきがあれば、コメントで教えてもらえると嬉しいです。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事