AWS Organizations 10アカウント運用で失敗した話|3ヶ月で見直した設計

AWS Organizations導入3ヶ月で権限管理がカオスに。セキュリティ監査で指摘される前に気づいた、Control TowerとSCPの現実的な使い方と失敗パターン。

AWS Organizations導入で見落としてた、最初の3ヶ月

先日チームで会議してたら、AWS Organizations運用を開始して3ヶ月目の同僚が「あ、これセキュリティ監査で指摘される」と気づいてさ。結局1週間で3つのアカウント構成を見直すことになった。実は自分も同じ経験してるんだよね。去年の7月にうちのチームが10個のアカウントをOrganizationsで一元化したんだけど、最初は「便利だ」で済ませてたんですよ。でも本番運用で権限管理・監査ログ・予期しないコスト請求が絡み合ってきて、やっと「構造設計が大事だったんだ」って気づいたんです。

この記事では、2026年時点で実際に運用してる構成と、失敗から学んだ設計パターンを共有します。Control Towerとの組み合わせ方、SCPの現実的な落とし穴、Configで自動コンプライアンス検証する方法です。

最初に知っておくべき、マルチアカウント設計の全体像

正直、AWS Organizationsを触った最初の1週間は「階層化するだけか」くらいの認識だったんですよ。でも実運用で気づくのは、これって「セキュリティの面倒臭さをどこに押し付けるか」の構成設計なんです。

自分たちが選んだ構成はこんな感じ:

graph TB
  subgraph Org["AWS Organizations - ルートアカウント"]
    Root["Root (Management Account)"]
    
    subgraph Prod["本番 OU"]
      ProdAcc1["Prod Web App"]
      ProdAcc2["Prod Data Warehouse"]
    end
    
    subgraph Dev["開発 OU"]
      DevAcc1["Dev Frontend Team"]
      DevAcc2["Dev ML Pipeline"]
    end
    
    subgraph Shared["共有サービス OU"]
      SharedAcc1["Logging & Audit"]
      SharedAcc2["VPC Transit Hub"]
      SharedAcc3["Artifact Repository"]
    end
    
    subgraph Sandbox["Sandbox OU"]
      SandAcc["Experiment Workloads"]
    end
  end
  
  Root -->|SCP適用| Prod
  Root -->|SCP適用| Dev
  Root -->|SCP適用| Shared
  Root -->|SCP適用| Sandbox
  
  SharedAcc1 -->|CloudTrail/VPC Logs| CloudWatch["CloudWatch Logs<br/>+Athena"]
  SharedAcc1 -->|Macie Delegated<br/>Administrator| MacieMonitor["S3 DLP<br/>Scanning"]
  
  ProdAcc1 -->|VPC Endpoints| SharedAcc2
  ProdAcc2 -->|VPC Endpoints| SharedAcc2
  DevAcc1 -->|VPC Endpoints| SharedAcc2
  DevAcc2 -->|VPC Endpoints| SharedAcc2

見た目はきれいですよ。でも実際に運用してみたら「OU 3段階にしたら権限管理が地獄になった」「SCPでroot権限を引いたら、意図しないサービスがブロックされた」「Configで監査ログが1日3000件になった」みたいなトラブルが次々と出てきたんです。最初の3ヶ月は「あ、これどうするんだろう」っていう試行錯誤の日々でしたね。

SCPの現実的な落とし穴:権限剥奪は「負のセキュリティ」

うちのチームは最初、セキュリティを強化するって名目でSCPを「否定ベース」で書いてたんですよ。つまり「以下のサービスは使用禁止」という設定です。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyUnencryptedS3",
      "Effect": "Deny",
      "Action": [
        "s3:PutObject",
        "s3:PutObjectAcl"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "s3:x-amz-server-side-encryption": "AES256"
        }
      }
    },
    {
      "Sid": "DenyUnencryptedTransport",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "Bool": {
          "aws:SecureTransport": "false"
        }
      }
    },
    {
      "Sid": "DenyAccessOutsideJP",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:RequestedRegion": "ap-northeast-1"
        }
      }
    }
  ]
}

最初は「いいセキュリティ設計だ」って思ってたんですけど、3週間後に本番のデータベース移行でこれが全部仇になるんですよ。CloudFormationスタック更新時に、一時的にap-southeast-1にリソース作成する必要があったんだけど、SCPでap-northeast-1以外をDenyしてたから実行失敗。結局手動対応で深夜2時までコーディング。地味に辛い経験でした。

ここで学んだことは:「SCPはセキュリティ要件を満たす最小限の制約に留める」 ってことなんです。権限剥奪じゃなくて「ガバナンスフレームワーク + 検出 + 改善」の3本立てが必要だったんですよ。

2026年の我々の改訂版は、SCPを「許可ベース(ホワイトリスト)」にして、各OUごとに細粒度で設定するパターンに切り替えました:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowProdServices",
      "Effect": "Allow",
      "Action": [
        "ec2:*",
        "rds:*",
        "s3:*",
        "lambda:*",
        "cloudformation:*",
        "logs:*",
        "monitoring:*"
      ],
      "Resource": "*"
    },
    {
      "Sid": "AllowCrossAccountAssumeRole",
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Resource": "arn:aws:iam::*:role/CrossAccountRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "trusted-external-id-12345"
        }
      }
    }
  ]
}

このほうが運用が楽だし、監査ログで「なぜこのアクション?」って聞かれたとき説明しやすいんですよ。個人的には、許可ベースのほうが「何が目的の設定なのか」が明確になる分、後から保守する人も助かると思います。

Control Towerで10アカウント一元化したら、複雑さが3倍になった

Control Towerはすごく便利なんですよ。アカウント作成から基本的なセキュリティ設定、監査ログ集約が自動化される。でも「自動化される = カスタマイズできない」の罠があって、これが本番で火を噴くんです。

具体的には:

Guardrailsの粒度が粗すぎる

「EC2インスタンスは暗号化が必須」みたいな大雑把な検出しかできないんですよ。でも現実は「本番環境はinstance typeがt4g.large以上」「Dev環境はt3.microまで」みたいな細粒度チェックが必要になるんです。

監査ログがCloudTrail/CloudWatch Logsに分散

複数アカウントの監査ログを一元化しても、分析するのに別途Athena設定が必要。違反検出の遅延が最大30分あるから、リアルタイムで異常対応ができないんですよ。

Organizational Trail vs Control Tower Trails の混在

CloudTrailでOrganizational Trailを有効化すると、Control Towerの検出が二重化して、月の請求額がなぜか2.5倍に跳ね上がったんです。正直この仕組みを理解するのに1週間かかりました。

うちが実装した解決策は、Control Towerの基本Guardrailsだけ使って、細粒度の検出と自動修復はAWS Configのカスタムルールに任せるパターン。こっちのほうが複雑だけど、チームのニーズに合わせられるんですよ。

項目Control TowerAWS Config + Lambda運用の実感
セットアップ時間30分2時間Control Tower楽
違反検出時間30分リアルタイムConfigが高速
自動修復限定的カスタム可能柔軟性はConfig
監査ログ管理一元化複数パターン可設定手数増
月額コスト~$1,000~$500+LambdaConfigが安い
運用チームの手数月5時間月15時間トレードオフ

AWS Configで自動コンプライアンス検証する現実的な設計

正直最初、Configって「リソース設定を記録するツール」くらいの認識だったんですよ。でも2026年のバージョンは相当強力で、カスタムルール+Lambda自動修復で「ガバナンスが自動で回る」レベルになってます。

うちが実装してるのはこんな感じです。まずはリソース設定をチェックするLambda関数から:

# AWS Lambda カスタムConfig ルール
import json
import boto3

ec2 = boto3.client('ec2')
config_client = boto3.client('config')

def evaluate_compliance(config_item, rule_parameters):
    compliance_status = 'COMPLIANT'
    annotations = []
    
    # チェック1: EBS暗号化
    if config_item['resourceType'] == 'AWS::EC2::Volume':
        encrypted = config_item['resourceProperties'].get('Encrypted')
        if encrypted != 'true':
            compliance_status = 'NON_COMPLIANT'
            annotations.append('EBS volume is not encrypted')
    
    # チェック2: セキュリティグループ - SSH公開禁止
    if config_item['resourceType'] == 'AWS::EC2::SecurityGroup':
        sg_id = config_item['resourceId']
        sg_rules = config_item['resourceProperties'].get('SecurityGroupIngress', [])
        
        for rule in sg_rules:
            if rule.get('IpProtocol') == 'tcp' and rule.get('FromPort') == 22:
                cidr = rule.get('CidrIp')
                if cidr == '0.0.0.0/0':
                    compliance_status = 'NON_COMPLIANT'
                    annotations.append(f'SSH (port 22) is open to 0.0.0.0/0 in {sg_id}')
    
    # チェック3: RDS暗号化・バックアップ
    if config_item['resourceType'] == 'AWS::RDS::DBInstance':
        storage_encrypted = config_item['resourceProperties'].get('StorageEncrypted')
        backup_retention = config_item['resourceProperties'].get('BackupRetentionPeriod')
        
        if storage_encrypted != 'true':
            compliance_status = 'NON_COMPLIANT'
            annotations.append('RDS database storage is not encrypted')
        
        if int(backup_retention or 0) < 7:
            compliance_status = 'NON_COMPLIANT'
            annotations.append('RDS backup retention period is less than 7 days')
    
    return {
        'compliance_type': compliance_status,
        'annotation': ' | '.join(annotations) if annotations else 'Compliant'
    }

def lambda_handler(event, context):
    config_item = json.loads(event['configurationItem'])
    rule_parameters = json.loads(event.get('ruleParameters', '{}'))
    
    evaluation = evaluate_compliance(config_item, rule_parameters)
    
    config_client.put_evaluations(
        Evaluations=[
            {
                'ComplianceResourceType': config_item['resourceType'],
                'ComplianceResourceId': config_item['resourceId'],
                'ComplianceType': evaluation['compliance_type'],
                'Annotation': evaluation['annotation'],
                'OrderingTimestamp': event['notificationCreationTime']
            }
        ],
        ResultToken=event['resultToken']
    )
    
    return evaluation

このLambda関数をConfigルールにアタッチして、毎回のリソース変更時に自動実行するんですよ。さらに「非準拠」検出時に別のLambdaで自動修復する流れを作ります:

# 自動修復 Lambda
import boto3
import time
from botocore.exceptions import ClientError

ec2 = boto3.client('ec2')

def remediate_non_compliant_sg(sg_id):
    """セキュリティグループから0.0.0.0/0 SSH アクセスを削除"""
    try:
        ec2.revoke_security_group_ingress(
            GroupId=sg_id,
            IpPermissions=[
                {
                    'IpProtocol': 'tcp',
                    'FromPort': 22,
                    'ToPort': 22,
                    'IpRanges': [{'CidrIp': '0.0.0.0/0'}]
                }
            ]
        )
        return {'Status': 'SUCCESS', 'Message': f'Removed SSH rule from {sg_id}'}
    except ClientError as e:
        return {'Status': 'FAILED', 'Message': str(e)}

def lambda_handler(event, context):
    detail = event['detail']
    resource_id = detail['resourceId']
    resource_type = detail['resourceType']
    
    if resource_type == 'AWS::EC2::SecurityGroup':
        result = remediate_non_compliant_sg(resource_id)
    
    return result

実運用では、EventBridge RuleでConfigルール違反を検出 → 自動修復Lambda実行 → SNS通知みたいなフローにしてます。これで月500件以上の非準拠検出を自動処理できてますよ。実際、最初は「自動修復大丈夫?」って不安だったんですけど、3ヶ月運用して問題なく動いてるので、今は完全に信頼してます。

マルチアカウント環境での監査ログ一元化の現実

最初「CloudTrail一元化すればログ分析も簡単」って思ってたんですよ。でも現実は違った。

xychart-beta
  title CloudTrail ログボリューム(10アカウント × 3ヶ月)
  x-axis [Week1, Week2, Week3, Week4, Week5, Week6, Week7, Week8, Week9, Week10, Week11, Week12]
  y-axis "ログイベント数(百万)" 0 --> 500
  line [50, 65, 78, 92, 110, 135, 165, 198, 240, 290, 350, 420]
  line [10, 12, 15, 18, 22, 28, 35, 42, 50, 60, 72, 85]

上が「全CloudTrail」で下が「セキュリティ関連のみフィルタ」です。3ヶ月で420万イベントですよ。これをAthenaで分析しようとしたら、クエリ1回で$8かかるんですよ。ひと月でログ分析だけで$500くらい吹っ飛ぶ。正直ショックでした。

うちが実装した対策は「Hot/Cold ログ戦略」。つまり最近のセキュリティログは高速アクセス可能な領域に保存して、古いオペレーショナルログは安いストレージに移す、という仕組みです:

{
  "Name": "CloudTrail-S3-Tiering",
  "Rules": [
    {
      "Id": "HotLogs",
      "Filter": {
        "And": {
          "Prefix": "AWSLogs/",
          "Tags": {"LogType": "security"}
        }
      },
      "Status": "Enabled",
      "Transitions": [
        {
          "Days": 30,
          "StorageClass": "STANDARD_IA"
        },
        {
          "Days": 90,
          "StorageClass": "GLACIER"
        }
      ]
    },
    {
      "Id": "ColdLogs",
      "Filter": {
        "And": {
          "Prefix": "AWSLogs/",
          "Tags": {"LogType": "operational"}
        }
      },
      "Status": "Enabled",
      "Transitions": [
        {
          "Days": 7,
          "StorageClass": "GLACIER"
        }
      ]
    }
  ]
}

加えて、CloudWatch Logs Insights で「ホット」なセキュリティイベントだけをリアルタイム分析する流れにしました。このハイブリッド戦略で、月のログ分析コストが$500から$120に下がったんですよ。地味に便利です。

クロスアカウント権限設計の現実的なパターン

正直、クロスアカウントロールの設計が一番複雑だと思ってますね。本番アカウントのDBにDev環境から読み取りアクセスしたいとか、監査アカウントが全アカウントのCloudTrailログを読みたいとか、こういうニーズってめちゃめちゃ多いんですよ。

うちが採用してるのは「アクセスキー廃止 + AssumeRole」パターンです。つまり、昔ながらのアクセスキーと秘密キーのペアを使わずに、STS(Security Token Service)で一時的な認証情報を発行する方式です:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AssumeRolePolicy",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/DevEngineerRole"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "dev-readonly-5af3b",
          "aws:SourceVpc": "vpc-12345678"
        },
        "IpAddress": {
          "aws:SourceIp": "203.0.113.0/24"
        }
      }
    }
  ]
}

こうすることで:

  • アクセスキーの漏洩リスクが消える
  • IPアドレスベースでVPN接続を強制できる
  • ExternalIDで意図しない別サービスのAssumeを防げる

Dev環境のLambdaやEC2からこのロールをAssumeするときは、メタデータサービス(IMDSv2)経由で一時認証情報を取得するんですよ:

import boto3
import time
from botocore.exceptions import ClientError

def get_prod_credentials():
    sts = boto3.client('sts')
    
    try:
        response = sts.assume_role(
            RoleArn='arn:aws:iam::999999999999:role/ProdCrossAccountReadOnlyRole',
            RoleSessionName='dev-session-' + str(int(time.time())),
            ExternalId='dev-readonly-5af3b',
            DurationSeconds=3600,
            Tags=[
                {'Key': 'Environment', 'Value': 'development'},
                {'Key': 'Purpose', 'Value': 'database-query'}
            ]
        )
        
        credentials = response['Credentials']
        return {
            'AccessKeyId': credentials['AccessKeyId'],
            'SecretAccessKey': credentials['SecretAccessKey'],
            'SessionToken': credentials['SessionToken'],
            'Expiration': credentials['Expiration']
        }
    except ClientError as e:
        print(f'AssumeRole failed: {e}')
        raise

# Prod環境のRDSデータベースに接続
creds = get_prod_credentials()
rds = boto3.client(
    'rds',
    aws_access_key_id=creds['AccessKeyId'],
    aws_secret_access_key=creds['SecretAccessKey'],
    aws_session_token=creds['SessionToken']
)

この「明示的な一時認証」パターンは監査ログでもトレーサビリティが高くて、「誰がいつどのロール経由で何を?」ってのが全部記録されるんですよ。セキュリティ監査のときに「このアクセスはなぜ?」って聞かれても、ログをたどれば答えられます。

2026年のAWS Organizations運用で本当に大事なこと

正直、Control Towerは便利だし、SCPで権限制御できるし、Configで監査も自動化できる。でも「自動化された = セキュアになった」ではないんですよ。

実際のところ、大事なのはこんなことです:

セキュリティルールは定期的に見直す必要がある

新しいAWSサービスが出るたびにSCPを更新する必要があるんですよ。Configルールも「実ビジネスに合わせた粒度」に調整が必要。個人的には年1回、全SCPを一度棚卸しして「本当に必要?」と問い直すのが大事だと思ってます。

クロスアカウント設計に時間をかけるべき

後から「Dev環境からProd DBを読みたい」って言われて対応するのは地獄なんですよ。初期設計段階で「どのアカウント間でどんなアクセスが発生するか」をマップする。権限境界(Permission Boundary)とAssumeRoleの組み合わせで多層防御を構築しておけば、後から「え、ちょっと権限追加したい」って言われても対応しやすいんです。

監査ログは「保存」じゃなく「検出と改善」に使う

ログを取ってるだけじゃセキュリティは向上しない。Athena/Elasticsearch で「異常パターン」を自動検出する仕組みが必須。SOC2対応やカオスエンジニアリングと同じく、「検出 → 改善 → 検証」のサイクルが重要なんですよ。

チーム全体の理解度が必須

IAMポリシーとSCPの違いを全員が理解してない環境は危険。「なぜこのロール設計なのか」を説明できるドキュメントが必要です。実装時間としては、最初の設計と構築に3ヶ月見たほうがいいですね。でも一度できると、新しいアカウント追加とか環境増設が大幅に楽になるんですよ。

まとめ

AWS Organizations + Control Tower + Config のマルチアカウントセキュリティ設計は確実に効きます。でも現実は:

  1. SCPは「最小限の制約」に留める — 権限剥奪しすぎると本番オペレーションが動かなくなる

  2. Control Towerは基本フレームワークだと考える — 細粒度の検出・修復はConfigのカスタムルール + Lambda 自動修復で補完

  3. 監査ログは自動分析まで含めて設計する — CloudTrail一元化だけでは月$500以上コストがかかる。Hot/Cold戦略で削減

  4. クロスアカウント権限はAssumeRole + ExternalID で構築 — アクセスキーは廃止して、STS一時認証情報を使う

  5. チーム全体の理解度確保が最後の砦 — セキュリティ設計をドキュメント化して、月1回は見直す

これ実装するのに時間かかるし、運用も手数増えるんですよ。正直「簡単なセキュリティ」ではない。でも一度構築できれば、10個のアカウントも100個のアカウントも同じ仕組みで管理できるんです。そこが Organizations の本当の価値だと思う。

U

Untanbaby

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

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

関連記事