Baseline Environment on AWS(BLEA) を参考にAWS CDKのプラクティスを確認する

2024/03/31に公開

TL;DR

  • Baseline Environment on AWS(BLEA) v3.0.0を元にAWS CDKのリファレンス構成を確認します

はじめに

AWS Japanの方が開発し提供してくださっている、Baseline Environment on AWS(BLEA) というAWS CDK のテンプレート群があります。

https://github.com/aws-samples/baseline-environment-on-aws/tree/main

AWS Japan作なので日本語でのドキュメントも用意されています。

BLEAとは、日本語Readmeの最初の内容のみ引用しますと以下のとおりです。

Baseline Environment on AWS (BLEA) は 単独の AWS アカウントまたは AWS Control Tower で管理されたマルチアカウント環境で、セキュアなベースラインを確立するための リファレンス AWS CDK テンプレート群です。このテンプレート群は AWS のセキュリティサービスを活用して基本的かつ拡張可能なガードレールを提供します。また典型的なシステムアーキテクチャを実現するエンドツーエンドの AWS CDK サンプルコードを提供します。この AWS CDK テンプレートは用途に合わせてユーザが拡張して使うことを前提としており、拡張の参考となるコードやコメントを多く含んでいます。これによって AWS のアーキテクチャベストプラクティスや AWS CDK コードのカスタマイズを習得しやすくすることを目的としています。

AWS CDK初心者にとっては、CDKでどのような構成でコードを書くのがよいのかが最初は掴みにくいため、このようなリファレンス実装があると非常に参考になります。

本記事では、BLEAをベースに、AWS CDKでのIaCのとっかかりを掴んでみたいと思います。

なお言語はBLEAと合わせてTypeScriptとします。

BLEAは2023年4月20日にv3.0.0がリリースされており、それまでのv2系から構成をリファクタリングされています。

v3.0.0を確認することで、最新のAWS CDKにおけるベストプラクティスについても確認してみたいと思います。

v2.1.1 -> v3.0.0の内容

リリースノートを翻訳したものを以下に示します。

https://github.com/aws-samples/baseline-environment-on-aws/releases/tag/v3.0.0

BLEAが2021年にリリースされた後、AWSはセキュリティサービスとCDKの使用が広がり、様々なアップデートを行いました。次回のBLEAリリースでは、最近のAWSセキュリティおよびCDKのベストプラクティスに追いつくためのいくつかのアップデートを提案します。これにはいくつかの破壊的変更が含まれるため、バージョン番号をBLEA v3.0とする必要があります。

主なポイントは以下の通りです:

  • 単一または少数のスタック

    • 現在、BLEAは多くのスタックを作成しています。これは、アップデート時の影響範囲を減らし、クラスアーキテクチャを簡素化するためです。しかし、これによりスタックの依存関係にいくつかの困難が生じているため、最近のCDKベストプラクティスでは少数のスタックを使用することを推奨しています。
    • ControlTowerはAccount Factory Customization(AFC)をリリースしました。AFCはアカウント作成時にベースラインをデプロイする機能を提供し、適用には単一のスタック(CFnテンプレート)が必要です。単一のスタックを使用することで、BLEAのベースラインをAFCでデプロイできます。
  • bin/およびlib/ディレクトリ内のファイル配置の簡素化

    • ゲストシステムサンプル(特にguest-webapp-sample)は、bin/ディレクトリにいくつかのCDKアプリを含み、これらのアプリはそれぞれ必要なlib/ディレクトリで定義されたコンストラクトを参照します。これを簡素化するために、単一のユースケースに対して1つのアプリのみを使用します。他のデプロイメントパターン(例えば、CDKPipelinesの使用)が必要な場合にのみ、bin/ディレクトリにCDKアプリを追加します。
  • cdk.jsonではなくCDKコード内でのパラメーターの受け渡し

    • フィードバックとCDKベストプラクティスによると、cdk.jsonをパラメーターストアとして使用することは推奨されません。例えば、パラメーターの型を検証できない、または他の環境のためにsynthしたときにクラウドアセンブリが上書きされます。したがって、パラメーターを定義しCDKアプリで使用するために、CDKコード内(例:parameters.ts)にパラメーターを追加します。CDKアプリ内では、各環境(例:DevStack、ProdStack)のスタックを定義します。特定の環境スタックを作成する必要がある場合は、cdk deployコマンドでスタックの名前を指定します。
  • ガバナンスモデルは変わりません

    • CloudTrail、Config、SecurityHubなどのベースラインは変わりません。しかし、すでにBLEA v2を使用している場合は、スタックアーキテクチャとリソース名が変更されるため、ベースラインのリソースを再作成する必要があります。

ファイル構成/スタック・コンストラクト構成

BLEAはユースケース別に以下の種類のコードを提供してくれています。

ユースケース 一覧

ユースケース フォルダ
スタンドアローン版ガバナンスベース usecases/blea-gov-base-standalone
Control Tower 版ガバナンスベース(ゲストアカウント用) usecases/blea-gov-base-ct
ECS による Web アプリケーションサンプル usecases/blea-guest-ecs-app-sample
EC2 による Web アプリケーションサンプル usecases/blea-guest-ec2-app-sample
サーバーレス API アプリケーションサンプル usecases/blea-guest-serverless-api-sample

本記事では、AWS CDKの構成の仕方を確認したいことと、個人的にECSの利用頻度が高いため、 usecases/blea-guest-ecs-app-sample を見ていきたいと思います。


出典: https://github.com/aws-samples/baseline-environment-on-aws/blob/main/usecases/blea-guest-ecs-app-sample/README_ja.md

ファイル内容をトレースし構成を確認

$ git clone https://github.com/aws-samples/baseline-environment-on-aws.git
$ cd usecases/
$ tree blea-guest-ecs-app-sample
blea-guest-ecs-app-sample
├── README.md
├── README_ja.md
├── bin
│   ├── blea-guest-ecs-app-sample-via-cdk-pipelines.ts
│   └── blea-guest-ecs-app-sample.ts
├── cdk.json
├── jest.config.js
├── lambda
│   └── canary-app
│       └── nodejs
│           └── node_modules
│               └── index.js
├── lib
│   ├── construct
│   │   ├── canary.ts
│   │   ├── dashboard.ts
│   │   ├── datastore.ts
│   │   ├── ecsapp.ts
│   │   ├── frontend.ts
│   │   ├── monitoring.ts
│   │   └── networking.ts
│   ├── stack
│   │   ├── blea-guest-ecs-app-frontend-stack.ts
│   │   ├── blea-guest-ecs-app-monitoring-stack.ts
│   │   ├── blea-guest-ecs-app-sample-stack.ts
│   │   └── blea-guest-ecs-app-sample-via-cdk-pipelines-stack.ts
│   └── stage
│       └── blea-guest-ecs-app-sample-stage.ts
├── package.json
├── parameter.ts
├── test
│   ├── __snapshots__
│   │   ├── blea-guest-ecs-app-sample-pipeline.test.ts.snap
│   │   └── blea-guest-ecs-app-sample.test.ts.snap
│   ├── blea-guest-ecs-app-sample-pipeline.test.ts
│   └── blea-guest-ecs-app-sample.test.ts
└── tsconfig.json

v2.1.1の時と比べてとてもシンプルな構成になっています。
v2.1.1以前はEC2とECSが同じ構成内に存在したことも少し影響していますが、それを考慮してもスタックが多く、スタック間依存が多くなる構成となっていました。

v2.1.1
guest-webapp-sample
├── README.md
├── README_ja.md
├── bin
│   ├── blea-guest-asgapp-sample.ts
│   ├── blea-guest-ec2app-sample.ts
│   ├── blea-guest-ecsapp-sample-pipeline.ts
│   ├── blea-guest-ecsapp-sample.ts
│   └── blea-guest-ecsapp-ssl-sample.ts
├── cdk.json
├── container
│   └── sample-ecs-app
│       ├── Dockerfile
│       └── buildspec.yml
├── jest.config.js
├── lambda
│   └── canary-app
│       └── nodejs
│           └── node_modules
│               └── index.js
├── lib
│   ├── blea-asgapp-stack.ts
│   ├── blea-build-container-stack.ts
│   ├── blea-canary-stack.ts
│   ├── blea-chatbot-stack.ts
│   ├── blea-dashboard-stack.ts
│   ├── blea-db-aurora-pg-sl-stack.ts
│   ├── blea-db-aurora-pg-stack.ts
│   ├── blea-ec2app-stack.ts
│   ├── blea-ecr-stack.ts
│   ├── blea-ecsapp-stack.ts
│   ├── blea-frontend-interface.ts
│   ├── blea-frontend-simple-stack.ts
│   ├── blea-frontend-ssl-stack.ts
│   ├── blea-investigation-instance-stack.ts
│   ├── blea-key-app-stack.ts
│   ├── blea-monitor-alarm-stack.ts
│   ├── blea-vpc-stack.ts
│   └── blea-waf-stack.ts
├── package.json
├── pipeline
│   └── blea-ecsapp-sample-pipeline-stack.ts
├── test
│   ├── __snapshots__
│   │   ├── blea-guest-asgapp-sample.test.ts.snap
│   │   ├── blea-guest-ec2app-sample.test.ts.snap
│   │   ├── blea-guest-ecsapp-sample-pipeline.test.ts.snap
│   │   ├── blea-guest-ecsapp-sample.test.ts.snap
│   │   └── blea-guest-ecsapp-ssl-sample.test.ts.snap
│   ├── blea-guest-asgapp-sample.test.ts
│   ├── blea-guest-ec2app-sample.test.ts
│   ├── blea-guest-ecsapp-sample-pipeline.test.ts
│   ├── blea-guest-ecsapp-sample.test.ts
│   └── blea-guest-ecsapp-ssl-sample.test.ts
└── tsconfig.json

実行時の処理の流れを元にしたトレース

BLEAを実行していく流れを元にコードを追っていきます。

設定確認

まず実行環境の設定であるparameter.tsを確認しておきます。

usecases/blea-guest-ecs-app-sample/parameter.ts
import { Environment } from 'aws-cdk-lib';

// Parameters for Application
export interface AppParameter {
  env?: Environment;
  envName: string;
  monitoringNotifyEmail: string;
  monitoringSlackWorkspaceId: string;
  monitoringSlackChannelId: string;
  vpcCidr: string;
  dashboardName: string;

  // -- Sample to use custom domain on CloudFront
  // hostedZoneId: string;
  // domainName: string;
  // cloudFrontHostName: string;
}

// Parameters for Pipelines
// You can use the same account or a different account than the application account.
// If you use independent account for pipeline, you have to bootstrap guest accounts with `--trust`.
// See: https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping.html
export interface PipelineParameter {
  env: Environment; // required
  envName: string;
  sourceRepository: string;
  sourceBranch: string;
  sourceConnectionArn: string;
}

// Parameters for Dev Account
export const devParameter: AppParameter = {
  env: {
    // account: '111111111111',
    region: 'ap-northeast-1',
  },
  envName: 'Development',
  monitoringNotifyEmail: 'notify-security@example.com',
  monitoringSlackWorkspaceId: 'TXXXXXXXXXX',
  monitoringSlackChannelId: 'CYYYYYYYYYY',
  vpcCidr: '10.100.0.0/16',
  dashboardName: 'BLEA-ECS-App-Sample',

  // -- Sample to use custom domain on CloudFront
  // hostedZoneId: 'Z00000000000000000000',
  // domainName: 'example.com',
  // cloudFrontHostName: 'www',
};

// Parameters for Pipeline Account
export const devPipelineParameter: PipelineParameter = {
  env: {
    account: '123456789012',
    region: 'ap-northeast-1',
  },
  envName: 'DevPipeline',
  sourceRepository: 'aws-samples/baseline-environment-on-aws',
  sourceBranch: 'main',
  sourceConnectionArn:
    'arn:aws:codestar-connections:us-west-2:222222222222:connection/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
};

今回はCDK Pipelineによる実行は見ないので、devParameterだけ確認しておきます。

// Parameters for Dev Account
export const devParameter: AppParameter = {
  env: {
    // account: '111111111111',
    region: 'ap-northeast-1',
  },
  envName: 'Development',
  monitoringNotifyEmail: 'notify-security@example.com',
  monitoringSlackWorkspaceId: 'TXXXXXXXXXX',
  monitoringSlackChannelId: 'CYYYYYYYYYY',
  vpcCidr: '10.100.0.0/16',
  dashboardName: 'BLEA-ECS-App-Sample',

  // -- Sample to use custom domain on CloudFront
  // hostedZoneId: 'Z00000000000000000000',
  // domainName: 'example.com',
  // cloudFrontHostName: 'www',
};

monitoringNotifyEmailなどの通知関連の設定だけ、必要に応じて変更しておけばよさそうです。
env.accountはコメントアウトされていますが、これは指定がない場合はaws cliのprofileに従うためそのままで問題ありません。

deployの実行

cd usecases/blea-guest-ecs-app-sample
npx aws-cdk deploy --all --profile prof_dev

実行したときの処理の流れを元に、AWS CDKでのファイル構成方法を見て行ってみます。

npx aws-cdk deploy --all によって何が起こるかというと、ざっくり以下の流れで実行されていきます。(nodejsの動作含めた詳細な流れではないので注意)

  1. --appが指定されていないのでcdk.jsonを参照します。以下のように"app"が定義されているため、npx ts-node --prefer-ts-exts bin/blea-guest-ecs-app-sample.ts--appとして指定されたものとして実行されます。
cdk.json
{
  "app": "npx ts-node --prefer-ts-exts bin/blea-guest-ecs-app-sample.ts"
}
  1. blea-guest-ecs-app-sample.tsでは以下のようにparmeterや、../lib/stack/からスタックがimportされます。
bin/blea-guest-ecs-app-sample.ts
import 'source-map-support/register';
import { App } from 'aws-cdk-lib';
import { devParameter } from '../parameter';
import { BLEAEcsAppStack } from '../lib/stack/blea-guest-ecs-app-sample-stack';
import { BLEAEcsAppFrontendStack } from '../lib/stack/blea-guest-ecs-app-frontend-stack';
import { BLEAEcsAppMonitoringStack } from '../lib/stack/blea-guest-ecs-app-monitoring-stack';
......
  1. そして、const ecsapp = new BLEAEcsAppStack(app, 'Dev-BLEAEcsApp', {Dev-BLEAEcsAppというスタックを定義しています。ここでparameter.tsで定義しているdevParameterを参照しています。他にもfrontendとmonitorのスタックが定義されていますが、npx aws-cdk deploy --all--allとしてすべてのスタックが対象となっているため、定義されているすべてのスタックが構築されます。
bin/blea-guest-ecs-app-sample.ts
......
const app = new App();

const ecsapp = new BLEAEcsAppStack(app, 'Dev-BLEAEcsApp', {
  description: 'BLEA ECS App sample for guest accounts (uksb-1tupboc58) (tag:blea-guest-ecs-app-sample-backend)',
  env: {
    account: devParameter.env?.account || process.env.CDK_DEFAULT_ACCOUNT,
    region: devParameter.env?.region || process.env.CDK_DEFAULT_REGION,
  },
  crossRegionReferences: true,
  tags: {
    Repository: 'aws-samples/baseline-environment-on-aws',
    Environment: devParameter.envName,
  },

  // from parameter.ts
  monitoringNotifyEmail: devParameter.monitoringNotifyEmail,
  monitoringSlackWorkspaceId: devParameter.monitoringSlackWorkspaceId,
  monitoringSlackChannelId: devParameter.monitoringSlackChannelId,
  vpcCidr: devParameter.vpcCidr,
});
......
  1. BLEAEcsAppStackを追っていきます。new BLEAEcsAppStackとしているため、import { BLEAEcsAppStack } from '../lib/stack/blea-guest-ecs-app-sample-stack';でimportされているblea-guest-ecs-app-sample-stack.tsが実行されます。このファイルでは以下のようにlib/construct/配下のファイルがimportされています。
lib/stack/blea-guest-ecs-app-sample-stack.ts
import { Names, Stack, StackProps } from 'aws-cdk-lib';
import { IAlarm } from 'aws-cdk-lib/aws-cloudwatch';
import { Key } from 'aws-cdk-lib/aws-kms';
import { ITopic } from 'aws-cdk-lib/aws-sns';
import { Construct } from 'constructs';
import { Datastore } from '../construct/datastore';
import { EcsApp } from '../construct/ecsapp';
import { Monitoring } from '../construct/monitoring';
import { Networking } from '../construct/networking';
import { ILoadBalancerV2 } from 'aws-cdk-lib/aws-elasticloadbalancingv2';
.....
v2.1.1以前

v2.1.1以前では以下のようにecsapp, db, vpc, wafといった要素ごとにスタックが定義されており、現状ではアンチパターンになってしまっていたと考えられます。

lib
├── blea-asgapp-stack.ts
├── blea-build-container-stack.ts
├── blea-canary-stack.ts
├── blea-chatbot-stack.ts
├── blea-dashboard-stack.ts
├── blea-db-aurora-pg-sl-stack.ts
├── blea-db-aurora-pg-stack.ts
├── blea-ec2app-stack.ts
├── blea-ecr-stack.ts
├── blea-ecsapp-stack.ts
├── blea-frontend-interface.ts
├── blea-frontend-simple-stack.ts
├── blea-frontend-ssl-stack.ts
├── blea-investigation-instance-stack.ts
├── blea-key-app-stack.ts
├── blea-monitor-alarm-stack.ts
├── blea-vpc-stack.ts
└── blea-waf-stack.ts
  1. blea-guest-ecs-app-sample-stack.tsBLEAEcsAppStackを定義しており、monitoring``cmk``networking``datastore``ecsappがそれぞれlib/construct/からimportした独自のConstructを利用して定義されています。これによりスタック間参照をすることなく、一連のリソースを定義しています。
lib/stack/blea-guest-ecs-app-sample-stack.ts
.....
export class BLEAEcsAppStack extends Stack {
  public readonly alarmTopic: ITopic;
  public readonly alb: ILoadBalancerV2;
  public readonly albFullName: string;
  public readonly albTargetGroupName: string;
  public readonly albTargetGroupUnhealthyHostCountAlarm: IAlarm;
  public readonly ecsClusterName: string;
  public readonly ecsServiceName: string;
  public readonly ecsTargetUtilizationPercent: number;
  public readonly ecsScaleOnRequestCount: number;
  public readonly dbClusterName: string;

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

    const monitoring = new Monitoring(this, 'Monitoring', {
      monitoringNotifyEmail: props.monitoringNotifyEmail,
      monitoringSlackWorkspaceId: props.monitoringSlackWorkspaceId,
      monitoringSlackChannelId: props.monitoringSlackChannelId,
    });
    this.alarmTopic = monitoring.alarmTopic;

    const cmk = new Key(this, 'CMK', {
      enableKeyRotation: true,
      description: 'BLEA Guest Sample: CMK for EcsApp',
      alias: Names.uniqueResourceName(this, {}),
    });

    const networking = new Networking(this, 'Networking', {
      vpcCidr: props.vpcCidr,
    });

    const datastore = new Datastore(this, 'Datastore', {
      vpc: networking.vpc,
      cmk: cmk,
      alarmTopic: monitoring.alarmTopic,
    });
    this.dbClusterName = datastore.dbCluster.clusterIdentifier;

    const ecsapp = new EcsApp(this, 'EcsApp', {
      alarmTopic: monitoring.alarmTopic,
      cmk: cmk,
      vpc: networking.vpc,
      dbCluster: datastore.dbCluster,
    });
    this.exportValue(ecsapp.alb.loadBalancerDnsName);
    this.alb = ecsapp.alb;
    this.albFullName = ecsapp.albFullName;
    this.albTargetGroupName = ecsapp.albTargetGroupName;
    this.albTargetGroupUnhealthyHostCountAlarm = ecsapp.albTargetGroupUnhealthyHostCountAlarm;
    this.ecsClusterName = ecsapp.ecsClusterName;
    this.ecsServiceName = ecsapp.ecsServiceName;
    this.ecsTargetUtilizationPercent = ecsapp.ecsTargetUtilizationPercent;
    this.ecsScaleOnRequestCount = ecsapp.ecsScaleOnRequestCount;
  }
}
  1. 最後にnetworkだけ覗いてみます。長いため全ては記載しませんが、以下のようにexport class Networking extends Construct {で独自のConstructを定義し、VPCやVPC FlowLogs, NACL, 各種エンドポイントなどネットワーク関連のリソースを作成しています。
lib/construct/networking.ts
import * as cdk from 'aws-cdk-lib';
import { aws_ec2 as ec2, aws_iam as iam, aws_kms as kms, aws_s3 as s3 } from 'aws-cdk-lib';
import { IpAddresses } from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';

export interface NetworkingProps {
  vpcCidr: string;
}

export class Networking extends Construct {
  public readonly vpc: ec2.Vpc;

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

    const vpc = new ec2.Vpc(this, 'Vpc', {
      ipAddresses: IpAddresses.cidr(props.vpcCidr),
      maxAzs: 2,
      natGateways: 1,
      flowLogs: {},
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'Public',
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 22,
          name: 'Private',
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
        {
          cidrMask: 22,
          name: 'Protected',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
    });
.....

ここまで、ざっくりとcdk deploy時の処理をトレースすることで概ねファイル構成やスタックの分け方がなんとなくわかったかと思います。

以下にAWS CDKにおける構成要素のイメージ図を引用します。


出典:https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/home.html

自組織への適用

ユースケースとして提供していただいている内容はあくまでサンプルであるため、自組織で利用する際には、BLEAをまずまるごとコピーした上で、不要なリソースの削除、必要なリソースの追加を行っていくことになります。

このようにベストプラクティスに従ったサンプルをAWS公式から提供いただけるとゼロからコーディングするのと比較して圧倒的に効率がよく、さらに安全な環境の構築、管理が可能になると思います。

本当にありがとうございます。

Discussion