👨‍💻

AWS CDKで作るおひとりさまランディングゾーン

2024/12/09に公開

はじめに

会社で AWS を利用する際、AWS アカウントは AWS Control Tower を導入して管理することが一般的です。
これを個人環境にも導入したいです。
個人環境でもマルチアカウントの検証やアカウント作成自体も簡単にしたいのです。

更に最近 AWS CDK も気になっています。
二つとも一緒にやってしまおうと考え、AWS CDK を利用して AWS Control Tower のランディングゾーンを作成していきます。

AWS Control Tower とは? や AWS CDK とは?という方は、下記の AWS Black Belt Online Seminar で詳しく解説されているので、そちらをご覧ください。

  • AWS Control Tower

  • AWS CDK

    • AWS CDK 概要 (Basic #1) PDF Youtube
    • AWS CDK の基本的なコンポーネントと機能 (Basic #2) PDF Youtube

方針

下記の AWS ドキュメントを元に AWS CloudFormation の部分を AWS CDK に置き換える形で作成していきます。

https://docs.aws.amazon.com/ja_jp/controltower/latest/userguide/lz-apis-cfn.html

事前準備

AWS のベストプラクティスでは、管理アカウントは管理アカウントでしかできないタスクのみに使用することが推奨されています。
既存の AWS アカウントを使っても良いですが、管理アカウントをよりクリーンにしておくために新たに AWS アカウントを開設しておきます。

新規アカウント作成およびアカウントの初期設定については、下記のクラスメソッドさんの記事を参考に実施しました。

https://dev.classmethod.jp/articles/aws-account-setup-guide-2024-05/

実装

つくるもの

AWS CDK アプリケーションとして下記のリソースを作成していきます。

環境設定

下記の環境で実施しました。

  • Node.js v22.11.0
  • AWS CDK v2.167.2

AWS CDK アプリケーションの作成

プロジェクトの初期化

AWS CDK 用のプロジェクトを作成します。

mkdir ct-landing-zone
cd ct-landing-zone && cdk init app --language=typescript

ログアカウントや監査アカウントのメールアドレスを環境変数で扱いたいので、そのためのパッケージを追加していきます。

npm i -D dotenv

cdk.jsonを編集して追加したパッケージを利用するようにします

cdk.json
{
-  "app": "npx ts-node --prefer-ts-exts bin/ct-landing-zone.ts",
+  "app": "npx ts-node -r dotenv/config --prefer-ts-exts bin/ct-landing-zone.ts",
  .
  .
}

コンストラクトの作成

下記の 3 つのコンストラクトを作成していきます。

  • Iam
  • Organizations
  • LandingZone

ランディングゾーンを起動の前提条件となる IAM 関連のリソースから作成していきます。

ct-landing-zone/lib/construct/iam.ts
import { aws_iam as iam, Stack } from "aws-cdk-lib";
import { Construct } from "constructs";

export class Iam extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // AWSControlTowerAdmin
    const ctAdminPolicyJSON = {
      Version: "2012-10-17",
      Statement: [
        {
          Effect: "Allow",
          Action: "ec2:DescribeAvailabilityZones",
          Resource: "*",
        },
      ],
    };

    const ctAdminManagedPolicy = new iam.ManagedPolicy(this, "CtAdminPolicy", {
      managedPolicyName: "AWSControlTowerAdminPolicy",
      document: iam.PolicyDocument.fromJson(ctAdminPolicyJSON),
    });

    new iam.Role(this, "CtAdminRole", {
      roleName: "AWSControlTowerAdmin",
      assumedBy: new iam.ServicePrincipal("controltower.amazonaws.com"),
      path: "/service-role/",
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AWSControlTowerServiceRolePolicy"
        ),
        ctAdminManagedPolicy,
      ],
    });

    // AWSControlTowerCloudTrail
    const ctCloudTrailPolicyJSON = {
      Version: "2012-10-17",
      Statement: [
        {
          Effect: "Allow",
          Action: ["logs:CreateLogStream", "logs:PutLogEvents"],
          Resource: `arn:${
            Stack.of(this).partition
          }:logs:*:*:log-group:aws-controltower/CloudTrailLogs:*`,
        },
      ],
    };

    const ctCloudTrailManagedPolicy = new iam.ManagedPolicy(
      this,
      "CtCloudTrailPolicy",
      {
        managedPolicyName: "AWSControlTowerCloudTrailPolicy",
        document: iam.PolicyDocument.fromJson(ctCloudTrailPolicyJSON),
      }
    );

    new iam.Role(this, "CtCloudTrailRole", {
      roleName: "AWSControlTowerCloudTrailRole",
      assumedBy: new iam.ServicePrincipal("cloudtrail.amazonaws.com"),
      path: "/service-role/",
      managedPolicies: [ctCloudTrailManagedPolicy],
    });

    // AWSControlTowerConfigAggregatorRoleForOrganizations
    new iam.Role(this, "CtConfigAggregatorRoleForOrg", {
      roleName: "AWSControlTowerConfigAggregatorRoleForOrganizations",
      assumedBy: new iam.ServicePrincipal("config.amazonaws.com"),
      path: "/service-role/",
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AWSConfigRoleForOrganizations"
        ),
      ],
    });

    // AWSControlTowerStackSet
    const ctStackSetJSON = {
      Version: "2012-10-17",
      Statement: [
        {
          Effect: "Allow",
          Action: "sts:AssumeRole",
          Resource: `arn:${
            Stack.of(this).partition
          }:iam::*:role/AWSControlTowerExecution`,
        },
      ],
    };

    const ctStackSetManagedPolicy = new iam.ManagedPolicy(
      this,
      "CtStackSetPolicy",
      {
        managedPolicyName: "AWSControlTowerStackSetRolePolicy",
        document: iam.PolicyDocument.fromJson(ctStackSetJSON),
      }
    );

    new iam.Role(this, "CtStackSetRole", {
      roleName: "AWSControlTowerStackSetRole",
      assumedBy: new iam.ServicePrincipal("cloudformation.amazonaws.com"),
      path: "/service-role/",
      managedPolicies: [ctStackSetManagedPolicy],
    });
  }
}

次に Organizations のコンストラクトを作成します。

ct-landing-zone/lib/construct/organizations.ts
import { aws_organizations as organizations, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";

interface OrganizationsConstructProps extends StackProps {
  loggingAccountEmail: string;
  securityAccountEmail: string;
}

export class Organizations extends Construct {
  public readonly loggingAccountId: string;
  public readonly securityAccountId: string;

  constructor(
    scope: Construct,
    id: string,
    props: OrganizationsConstructProps
  ) {
    super(scope, id);

    // Organization
    const org = new organizations.CfnOrganization(this, "Organization", {
      featureSet: "ALL",
    });

    // Logging Account
    const loggingAccount = new organizations.CfnAccount(
      this,
      "LoggingAccount",
      {
        accountName: "log-archive",
        email: props.loggingAccountEmail,
      }
    );
    this.loggingAccountId = loggingAccount.attrAccountId;

    // Security Account
    const securityAccount = new organizations.CfnAccount(
      this,
      "SecurityAccount",
      {
        accountName: "audit",
        email: props.securityAccountEmail,
      }
    );
    this.securityAccountId = securityAccount.attrAccountId;
  }
}

L2 Construct がないので、L1 Construct で記述していきます。
もともと記述量が少ないので、L1 Construct でも特段困りません。

最後に LandingZone のコンストラクトを作成します。

ct-landing-zone/lib/construct/landingzone.ts
import { aws_controltower as controltower } from "aws-cdk-lib";
import { Construct, IDependable } from "constructs";

export interface LandingZoneConstructProps {
  version: string;
  governedRegions: string[];
  centralizedLoggingAccountId: string;
  enableCentlizedLogging: boolean;
  securityAccountId: string;
}

export class LandingZone extends Construct {
  public readonly landingZone: controltower.CfnLandingZone;

  constructor(scope: Construct, id: string, props: LandingZoneConstructProps) {
    super(scope, id);

    const manifest = {
      governedRegions: props.governedRegions,
      organizationStructure: {
        security: {
          name: "Security",
        },
        sandbox: {
          name: "Sandbox",
        },
      },
      centralizedLogging: {
        accountId: props.centralizedLoggingAccountId,
        configurations: {
          loggingBucket: {
            retentionDays: 365,
          },
          accessLoggingBucket: {
            retentionDays: 3650,
          },
        },
        enabled: props.enableCentlizedLogging,
      },
      securityRoles: {
        accountId: props.securityAccountId,
      },
      accessManagement: {
        enabled: false,
      },
    };

    this.landingZone = new controltower.CfnLandingZone(this, "LandingZone", {
      version: props.version,
      manifest: manifest,
    });
  }

  public addDependencies(dependencies: IDependable[]): this {
    dependencies.forEach((dependency) => {
      this.landingZone.node.addDependency(dependency);
    });
    return this;
  }
}

manifestの記述については、AWS CloudFormation のドキュメントにも詳細な設定について確認できないので、下記ドキュメントのサンプルを元に記述していきます。

このコンストラクト作成で重要な点は、依存関係を追加できるようにすることです。
ランディングゾーンの作成は必要な IAM ロールや AWS Organizations を作成した後でないとできません。
(依存関係の追加に気づかず、どうしてデプロイに失敗するのか格闘していました。。。)

スタックの作成

作成するスタックは下記の 1 つです

  • CtLandingZoneStack

コンストラクトの作成で作ったものを取り込んでいきます。
ここでは、ログアカウントや監査アカウントのメールアドレス等は App から注入できるようにパラメーター化を行なっています。

ct-landing-zone/lib/stack/ct-landing-zone-stack.ts
import { Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { Iam } from "../construct/iam";
import { Organizations } from "../construct/organizations";
import { LandingZone } from "../construct/landingzone";

export interface CtLandingZoneStackProps extends StackProps {
  ctSecurityAccountEmail: string;
  ctLoggingAccountEmail: string;
  ctVersion: string;
  ctGovernedRegions: string[];
  ctEnableCentlizedLogging: boolean;
}

export class CtLandingZoneStack extends Stack {
  constructor(scope: Construct, id: string, props: CtLandingZoneStackProps) {
    super(scope, id, props);

    /**
     * Preparation
     * ref: https://docs.aws.amazon.com/ja_jp/controltower/latest/userguide/lz-apis-cfn-setup.html
     */
    const iam = new Iam(this, "Iam");
    const organization = new Organizations(this, "Organizations", {
      loggingAccountEmail: props.ctLoggingAccountEmail,
      securityAccountEmail: props.ctSecurityAccountEmail,
    });

    /**
     * Create AWS Control Tower Landing Zone
     * ref: https://docs.aws.amazon.com/ja_jp/controltower/latest/userguide/lz-apis-cfn-launch.html
     */
    new LandingZone(this, "LandingZone", {
      version: props.ctVersion,
      governedRegions: props.ctGovernedRegions,
      centralizedLoggingAccountId: organization.loggingAccountId,
      enableCentlizedLogging: props.ctEnableCentlizedLogging,
      securityAccountId: organization.securityAccountId,
    }).addDependencies([iam, organization]);
  }
}

エントリーポイントの作成

CDK アプリケーションのエントリーポイントを作成していきます。

ct-landing-zone/bin/ct-landing-zone.ts
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { CtLandingZoneStack } from "../lib/stack/ct-landing-zone-stack";
import { prodParameter } from "../parameter";

if (!process.env.CT_SECURITY_ACCOUNT_EMAIL) {
  throw new Error("CT_SECURITY_ACCOUNT_EMAIL is not set");
}
if (!process.env.CT_LOGGING_ACCOUNT_EMAIL) {
  throw new Error("CT_LOGGING_ACCOUNT_EMAIL is not set");
}

const app = new cdk.App();

// Create stack for "Prod" environment.
new CtLandingZoneStack(app, "Prod-CtLandingZone", {
  description: "AWS Control Tower Landing Zone",
  env: {
    account: prodParameter.env?.account || process.env.CDK_DEFAULT_ACCOUNT,
    region: prodParameter.env?.region || process.env.CDK_DEFAULT_REGION,
  },
  tags: {
    Environment: "Prod",
  },
  ctSecurityAccountEmail: process.env.CT_SECURITY_ACCOUNT_EMAIL,
  ctLoggingAccountEmail: process.env.CT_LOGGING_ACCOUNT_EMAIL,
  ctVersion: prodParameter.ctVersion,
  ctGovernedRegions: prodParameter.ctGovernedRegions,
  ctEnableCentlizedLogging: prodParameter.ctEnableCentlizedLogging,
});

App に注入するパラメータは環境変数の他に別途パラメータファイルを作成しています。
このあたりは Baseline Environtmnet on AWS の記述を参考にしています。

ct-landing-zone/parameter.ts
import { Environment } from "aws-cdk-lib";

export interface AppParameter {
  env?: Environment;
  envName: string;
  /**
   * AWS Control Tower Landing Zone Parameter
   *
   * The following email addresses are loaded from environment variables, so they are not listed in the parameters:
   *  - Security Account Email
   *  - Logging Account Email
   */
  ctVersion: string;
  ctGovernedRegions: string[];
  ctEnableCentlizedLogging: boolean;
}

export const prodParameter: AppParameter = {
  envName: "Prod",
  ctVersion: "3.3",
  ctGovernedRegions: ["ap-northeast-1", "us-east-1"],
  ctEnableCentlizedLogging: true,
};

デプロイ

まず、CDK プロジェクト配下にデプロイのための環境変数ファイルを作成し、CDK のデプロイ環境と監査アカウントおよびロギングアカウントのメールアドレスを設定します。

.env
CDK_DEFAULT_ACCOUNT=012345678901
CDK_DEFAULT_REGION=ap-northeast-1
CT_SECURITY_ACCOUNT_EMAIL="username+audit@example.com"
CT_LOGGING_ACCOUNT_EMAIL="username+log-archive@example.com"

CDK アプリケーションを初めてデプロイする際には、ブートストラップスタックのデプロイが必要なので実施していきます。

cdk bootstrap

下記のように表示されればブートストラップ完了です。
※ AWS CLI の認証情報を設定していないとエラーとなるので、ブートストラップに失敗する場合はそちらを確認します。

CDKToolkit: creating CloudFormation changeset...
[█████████▋················································] (2/12)

10:54:09 PM | CREATE_IN_PROGRESS   | AWS::CloudFormation::Stack | CDKToolkit
10:54:12 PM | CREATE_IN_PROGRESS   | AWS::IAM::Role          | ImagePublishingRol
 ✅  Environment aws://999999999999/ap-northeast-1 bootstrapped.

準備が整ったところで、CDK アプリケーションをデプロイします。

cdk deploy

このとき、機密性が高いデプロイである旨の警告メッセージが表示されます。
ランディングゾーンを作成しようとしているので、そうですよねということでyを入力して続行です。

This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
┌───┬─────────────────────────────────────────┬────────┬────────────────┬──────────────────────────────────────┬───────────┐
│   │ Resource                                │ Effect │ Action         │ Principal                            │ Condition │
├───┼─────────────────────────────────────────┼────────┼────────────────┼──────────────────────────────────────┼───────────┤
│ + │ ${Iam/CtAdminRole.Arn}                  │ Allow  │ sts:AssumeRole │ Service:controltower.amazonaws.com   │           │
├───┼─────────────────────────────────────────┼────────┼────────────────┼──────────────────────────────────────┼───────────┤
│ + │ ${Iam/CtCloudTrailRole.Arn}             │ Allow  │ sts:AssumeRole │ Service:cloudtrail.amazonaws.com     │           │
├───┼─────────────────────────────────────────┼────────┼────────────────┼──────────────────────────────────────┼───────────┤
│ + │ ${Iam/CtConfigAggregatorRoleForOrg.Arn} │ Allow  │ sts:AssumeRole │ Service:config.amazonaws.com         │           │
├───┼─────────────────────────────────────────┼────────┼────────────────┼──────────────────────────────────────┼───────────┤
│ + │ ${Iam/CtStackSetRole.Arn}               │ Allow  │ sts:AssumeRole │ Service:cloudformation.amazonaws.com │           │
└───┴─────────────────────────────────────────┴────────┴────────────────┴──────────────────────────────────────┴───────────┘
IAM Policy Changes
┌───┬─────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────────┐
│   │ Resource                            │ Managed Policy ARN                                                               │
├───┼─────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${Iam/CtAdminRole}                  │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSControlTowerServiceRolePol │
│   │                                     │ icy                                                                              │
│ + │ ${Iam/CtAdminRole}${Iam/CtAdminPolicy}                                                             │
├───┼─────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${Iam/CtCloudTrailRole}${Iam/CtCloudTrailPolicy}                                                        │
├───┼─────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${Iam/CtConfigAggregatorRoleForOrg} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSConfigRoleForOrganizations │
├───┼─────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${Iam/CtStackSetRole}${Iam/CtStackSetPolicy}                                                          │
└───┴─────────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)?

デプロイが終わるのを待ちます。(30 分くらい待ちました。)

つくったものの確認

まず、管理アカウントの AWS CloudFormation コンソールを開いて、スタックがデプロイされていることを確認します。

ブートストラップ用の CDKToolkit スタックと ランディングゾーンである Prod-CtLandingZone のスタックがデプロイされていることが確認できます。
また、ランディングゾーン作成の中で下記の Control Tower 関連のスタックも一緒にデプロイされていることが分かります。

  • AWSControlTowerBP-BASELINE-CONFIG-MASTER
  • AWSControlTowerBP-BASELINE-CLOUDTRAIL-MASTER

cloudformation_stack

管理アカウントの AWS Control Tower コンソールを開いて、ランディングゾーン設定も確認しておきます。

組織単位 (OU) が 2 つ作成されて、Security OU の配下に監査アカウントとロギングアカウントが存在することが分かります。

landingzone

さいごに

無事に AWS CDK を利用してランディングゾーンを作成できました。
これで個人でもマルチアカウントや Organizations の検証がしやすくなるはずです。

BABY JOB  テックブログ

Discussion