サンプル DB 入りの Amazon Aurora PostgreSQL をサクッと立てる CDK Construct 

## 概要

- Aurora PostgreSQL の何らかの検証を実施する際、サンプルデータが入っている DB と、 DB に繋がるための踏み台をサクッと用意したいケースがある
- それらをサクっと構築できる CDK Construct (TypeScript) を作った

## はじめに

Aurora PostgreSQL (あるいはもっと普遍的な DB) の検証環境がサクっと欲しいこと、ありますよね？
その際に、サンプル DB も入っていてほしいし、ローカルの DB クライアントからサクっと繋げるようにもしたいですよね？

これを実現する CDK のコンストラクトを作りました。1 ファイルで収めているので、簡単に取り込んで使うことができます。中身には以下が含まれています。

- Aurora PostgreSQL Serverless v2
- SSM Session Manager で繋ぐ踏み台 EC2 インスタンス
- PostgreSQL Tutorial に含まれているサンプルデータベース（dvdrental）の自動セットアップ
- 必要なネットワークリソース一式

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3790935/e936e088-1983-4f2d-a6e3-5eab98c89ea8.png)


## 使い方

### 1. コンストラクトの準備

プロジェクトの `lib/constructs` ディレクトリに `sample-db-env.ts` を作成し、以下のコードをコピーします (長いので折りたたんでいます)：

<details><summary>lib/constructs/sample-db-en.tsv (クリックして全文表示)</summary>

```typescript
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as rds from 'aws-cdk-lib/aws-rds';
import { Construct } from 'constructs';

export interface SampleDbEnvProps {
  /**
   * VPCで使用するアベイラビリティゾーンの最大数
   * @default 2
   */
  maxAzs?: number;

  /**
   * VPCで使用するNAT Gatewayの数
   * @default 1
   */
  natGateways?: number;

  /**
   * 踏み台サーバーのインスタンスタイプ
   * @default t3.small
   */
  instanceType?: ec2.InstanceType;

  /**
   * リソースの削除ポリシー
   * @default RemovalPolicy.DESTROY
   */
  removalPolicy?: cdk.RemovalPolicy;

  /**
   * Serverless v2の最小キャパシティユニット
   * @default 0.5
   */
  minCapacity?: number;

  /**
   * Serverless v2の最大キャパシティユニット
   * @default 1
   */
  maxCapacity?: number;

  /**
   * Postgresのバージョン
   * @default VER_16_6
   */
  engineVersion?: rds.AuroraPostgresEngineVersion;
}

export class SampleDbEnv extends Construct {
  public readonly vpc: ec2.Vpc;
  public readonly cluster: rds.DatabaseCluster;
  public readonly bastionInstance: ec2.Instance;
  public readonly bastionSecurityGroup: ec2.SecurityGroup;
  public readonly databaseSecurityGroup: ec2.SecurityGroup;

  constructor(scope: Construct, id: string, props: SampleDbEnvProps = {}) {
    super(scope, id);

    // デフォルト値の設定
    const {
      maxAzs = 2,
      natGateways = 1,
      instanceType = ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.SMALL),
      removalPolicy = cdk.RemovalPolicy.DESTROY,
      minCapacity = 0.5,
      maxCapacity = 1,
      engineVersion = rds.AuroraPostgresEngineVersion.VER_16_6,
    } = props;

    // VPCの作成
    this.vpc = new ec2.Vpc(this, 'VPC', {
      maxAzs,
      natGateways,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'Public',
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: 'Private',
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
      ],
    });

    // EC2のセキュリティグループ
    this.bastionSecurityGroup = new ec2.SecurityGroup(this, 'BastionSecurityGroup', {
      vpc: this.vpc,
      description: 'Security group for bastion EC2 instance',
      allowAllOutbound: true,
    });

    // Auroraのセキュリティグループ
    this.databaseSecurityGroup = new ec2.SecurityGroup(this, 'DatabaseSecurityGroup', {
      vpc: this.vpc,
      description: 'Security group for Aurora PostgreSQL',
      allowAllOutbound: true,
    });

    // EC2からAuroraへの通信を許可
    this.databaseSecurityGroup.addIngressRule(
      this.bastionSecurityGroup,
      ec2.Port.tcp(5432),
      'Allow PostgreSQL access from EC2'
    );

    // EC2のIAMロール
    const ec2Role = new iam.Role(this, 'EC2Role', {
      assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
    });

    // SSM管理ポリシーを追加
    ec2Role.addManagedPolicy(
      iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')
    );

    // Aurora PostgreSQL Serverless v2 クラスターの作成
    this.cluster = new rds.DatabaseCluster(this, 'AuroraCluster', {
      engine: rds.DatabaseClusterEngine.auroraPostgres({
        version: engineVersion,
      }),
      serverlessV2MinCapacity: minCapacity,
      serverlessV2MaxCapacity: maxCapacity,
      vpc: this.vpc,
      writer: rds.ClusterInstance.serverlessV2("Writer"),
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
      },
      securityGroups: [this.databaseSecurityGroup],
      removalPolicy,
    });

    this.cluster.secret?.grantRead(ec2Role);
  
    const userData = ec2.UserData.forLinux();

    // 第1段階：必要なツールのインストールと初期設定
    userData.addCommands(
      'dnf install postgresql16 jq -y',
      'echo "Setting up environment variables..."',
      `echo ${this.cluster.secret?.secretName}`,
      `aws secretsmanager get-secret-value --secret-id ${this.cluster.secret?.secretName} | jq .SecretString > secrets.json`,
      'PGHOST=`cat secrets.json | jq "fromjson | .host" -r`',
      'PGUSER=`cat secrets.json | jq "fromjson | .username" -r`',
      'PGPASSWORD=`cat secrets.json | jq "fromjson | .password" -r`',
      'echo "export PGHOST=$PGHOST" >> ~/.bash_profile',
      'echo "export PGUSER=$PGUSER" >> ~/.bash_profile',
      'echo "export PGPASSWORD=$PGPASSWORD" >> ~/.bash_profile',
      'source ~/.bash_profile',
    );

    // 第2段階：タイムアウト付きでRDSの準備完了を確認してからサンプルDBを読み込む
    userData.addCommands(
      'echo "Waiting for RDS to be available (timeout: 20 minutes)..."',
      'start_time=$(date +%s)',
      'timeout=1200  # 20 minutes in seconds',
      'while true; do',
      '  if PGPASSWORD=$PGPASSWORD psql -h $PGHOST -U $PGUSER -d postgres -c "SELECT 1" > /dev/null 2>&1; then',
      '    echo "Database connection successful!"',
      '    break',
      '  fi',
      '  current_time=$(date +%s)',
      '  elapsed=$((current_time - start_time))',
      '  if [ $elapsed -ge $timeout ]; then',
      '    echo "Timeout waiting for database to be ready after 20 minutes"',
      '    exit 1',
      '  fi',
      '  echo "Waiting for database to be ready... ($(($timeout - $elapsed)) seconds remaining)"',
      '  sleep 10',
      'done',
      'echo "Proceeding with sample data import..."',
      'curl -O https://www.postgresqltutorial.com/wp-content/uploads/2019/05/dvdrental.zip',
      'unzip ./dvdrental.zip',
      'createdb dvdrental',
      'pg_restore -d dvdrental ./dvdrental.tar',
    );

    // EC2インスタンスの作成
    this.bastionInstance = new ec2.Instance(this, 'BastionInstance', {
      vpc: this.vpc,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
      },
      instanceType,
      machineImage: new ec2.AmazonLinuxImage({
        generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2023,
      }),
      securityGroup: this.bastionSecurityGroup,
      role: ec2Role,
      userData,
    });

    // 出力の追加
    new cdk.CfnOutput(this, 'EC2InstanceId', {
      value: this.bastionInstance.instanceId,
    });

    new cdk.CfnOutput(this, 'AuroraEndpoint', {
      value: this.cluster.clusterEndpoint.hostname,
    });

    const command = `aws ssm start-session --target ${this.bastionInstance.instanceId} \
    --document-name AWS-StartPortForwardingSessionToRemoteHost \
    --region ${cdk.Stack.of(this).region} \
    --parameters '{ \
      "host": ["${this.cluster.clusterEndpoint.hostname}"], \
      "portNumber": ["5432"], \
      "localPortNumber": ["5432"] \
    }'`;

    new cdk.CfnOutput(this, 'PortForwardingCommand', {
      value: command,
    });

    new cdk.CfnOutput(this, 'GetPasswordCommand', {
      value: `aws secretsmanager get-secret-value --secret-id ${this.cluster.secret?.secretName} | jq ".SecretString | fromjson | .password" -r`,
    });

    new cdk.CfnOutput(this, 'AuroraSecretName', {
      value: this.cluster.secret?.secretName || '',
    });

    // SSM Session Managerを使用したリモートログインコマンド
    new cdk.CfnOutput(this, 'SSMLoginCommand', {
      value: `aws ssm start-session --target ${this.bastionInstance.instanceId} --region ${cdk.Stack.of(this).region}`
    });
  }
}
```

</details>

### 2. CDK スタックでの使用

メインのスタックファイルで以下のように使用します：

```typescript
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { SampleDbEnv } from './constructs/sample-db-env';

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

    // デフォルト設定で作成
    const dbEnv = new SampleDbEnv(this, 'SampleDbEnv');

    // または、カスタム設定で作成
    const customDbEnv = new SampleDbEnv(this, 'CustomDbEnv', {
      maxAzs: 3,
      natGateways: 2,
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MEDIUM),
      minCapacity: 1,
      maxCapacity: 4,
      engineVersion: rds.AuroraPostgresEngineVersion.VER_15_4,
    });
  }
}
```

### 3. デプロイ

`cdk deploy` 実行後、以下の出力が表示されます：

```
Outputs:
  MyStack.SampleDbEnvEC2InstanceId = i-0123456789abcdef0
  MyStack.SampleDbEnvAuroraEndpoint = sample-cluster.cluster-xxxxx.region.rds.amazonaws.com
  MyStack.SampleDbEnvPortForwardingCommand = aws ssm start-session --target i-0123456789abcdef0 --document-name AWS-StartPortForwardingSessionToRemoteHost --region ap-northeast-1 --parameters '{"host": "............" // 省略
  MyStack.SampleDbEnvGetPasswordCommand = aws secretsmanager get-secret-value ...
  MyStack.SampleDbEnvAuroraSecretName = MyStack-SampleDbEnvAuroraClusterSecret...
  MyStack.SampleDbEnvSSMLoginCommand = aws ssm start-session --target i-0123456789abcdef0 ...
```

### 4. データベースへの接続

#### 踏み台にログインして使う場合

まず、EC2 インスタンスにログインしましょう。
```bash
# 出力された SSMLoginCommand を実行
aws ssm start-session --target i-0123456789abcdef0 --region ap-northeast-1
```

ログイン先の EC2 で root ユーザに切り替えると、 psql コマンドを叩くだけで接続できます。
(環境変数に接続情報が設定されています)
```bash
# root ユーザに切替
sudo su - 
# 環境変数が既に設定されているため、そのまま psql コマンドが使えます
psql -d dvdrental
```

#### ローカル環境から接続する場合

まず、パスワードを取得します。
```bash
# 出力された GetPasswordCommand を実行
aws secretsmanager get-secret-value ...
```

次に、SSM を使ってポートフォワーディングの設定をします。
```bash
# 出力された PortForwardingCommand を実行
aws ssm start-session --target i-0123456789abcdef0 --document-name AWS-StartPortForwardingSessionToRemoteHost --region ap-northeast-1 --parameters '{"host": "............" // 省略
```

あとは、適当な DB クライアントで localhost:5432 に向かって接続するだけです。
DB 名は dvdrental、ユーザーは postgres、パスワードは先ほど取得したものを使います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3790935/6631bdb5-69c3-4e22-87d9-2a50a22b5e9b.png)



## カスタマイズ可能なパラメータ

| パラメータ | 説明 | デフォルト値 |
|------------|------|--------------|
| maxAzs | VPCで使用するAZの数 | 2 |
| natGateways | NAT Gatewayの数 | 1 |
| instanceType | 踏み台サーバーのインスタンスタイプ | t3.small |
| minCapacity | Aurora Serverless v2の最小容量 | 0.5 |
| maxCapacity | Aurora Serverless v2の最大容量 | 1 |
| engineVersion | PostgreSQLのバージョン | VER_16_6 |
| removalPolicy | リソースの削除ポリシー | DESTROY |

## 注意点

全体的に検証用の構成なので、用法・容量を守ってお使いください。(セキュリティ等)