Aurora(Postgres)に読取専用のRDS Proxy Endpointを使ってアクセスするLambdaをcdk v2で作成したメモ

## 概要
[前回](https://qiita.com/hibohiboo/items/178f39898b0f88cf9387)はRDS Proxyは１つで、どちらのユーザも同じ読書ができるエンドポイントを使用していた。
今回は、読取専用のユーザは読取専用のエンドポイントを介してアクセスを行うようにする。
読取専用のエンドポイントを作るため、RDSクラスタには読書用のインスタンスと読取専用のインスタンスを用意する。

[ソースコード](https://github.com/hibohiboo/aws-cdk-v2/tree/id/4)

## アーキテクチャ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/941d7bad-ce6f-8c9f-4bb2-337849ad6f28.png)

## CDK
### VPCでサブネットIDをSystemMangerのパラメータストアに追加
読取用のエンドポイントの作成に必要。

```diff:cdk/lib/vpc-stack.ts
import { Aspects, Stack, StackProps, Tag, Tags } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Peer, Port, PrivateSubnet, PrivateSubnetProps, SecurityGroup, SubnetType, Vpc } from 'aws-cdk-lib/aws-ec2';
import { SubnetGroup } from 'aws-cdk-lib/aws-rds';
import { StringListParameter } from 'aws-cdk-lib/aws-ssm';

const DB_PORT = 5432;

interface VpcStackProps extends StackProps {
  subnetGroupName: string
  ssmParamKeySubnetIds: string
}
export class VpcStack extends Stack {
  constructor(scope: Construct, id: string, props: VpcStackProps) {
    super(scope, id, { ...props, subnetGroupName: undefined } as StackProps);
    const cidr = '10.0.0.0/16';
    const vpc = new Vpc(this, 'VPC', {
      cidr,
      natGateways: 0,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: "public-subnet",
          subnetType: SubnetType.PUBLIC
        },
      ],
      maxAzs: 3
    })
    // Tags.of(vpc).add('Stack', id);
    Tags.of(vpc).add('Name', 'vpc');

    // プライベートSubnet（RDS用）
    const privateSubnetProps: PrivateSubnetProps[] = [
      { availabilityZone: 'ap-northeast-1a', vpcId: vpc.vpcId, cidrBlock: '10.0.3.0/24' },
      { availabilityZone: 'ap-northeast-1c', vpcId: vpc.vpcId, cidrBlock: '10.0.4.0/24' },
      { availabilityZone: 'ap-northeast-1d', vpcId: vpc.vpcId, cidrBlock: '10.0.5.0/24' },
    ]

    const subnets = privateSubnetProps.map((prop, i) => {
      const subnet = new PrivateSubnet(this, `MyPrivateSubnet${i}`, prop);
      Tags.of(subnet).add('Name', `private-subnet-${i}`);
      Tags.of(subnet).add('aws-cdk:subnet-type', SubnetType.PRIVATE_ISOLATED);
      return subnet
    });

    //------------------ Aurora用の設定 ----------------------------------
    const dbConnectionGroup = new SecurityGroup(this, 'SecurityGroupForPrivateSubnets', {
      vpc,
      description: 'seburity group for Aurora'
    })
    Tags.of(dbConnectionGroup).add('Name', 'SecurityGroupForPrivateSubnets');
    // securityGroupPrivate.addIngressRule(Peer.ipv4(cidr), Port.allTcp());

    const subnetGroupForAurora = new SubnetGroup(this, 'SubnetGroupForAurora', {
      vpc,
      vpcSubnets: { subnets },
      description: 'subnet group for Aurora db',
      subnetGroupName: props.subnetGroupName
    });
    Tags.of(subnetGroupForAurora).add('Name', 'SubnetGroupForAurora');


+    // 読取専用 RDSProxyのエンドポイントを作るためにSubnetIdのリストが必要
+    const subnetIds = subnets.map(subnet => subnet.subnetId);
+    const subnetIdsParameter = new StringListParameter(this, "ssm-subnet-ids", {
+      parameterName: props.ssmParamKeySubnetIds,
+      stringListValue: subnetIds
+    });
+    Tags.of(subnetIdsParameter).add('Name', 'ssm-subnet-ids');

    //------------------ 踏み台用の設定 ----------------------------------
    const securityGroupPublic = new SecurityGroup(this, 'SecurityGroupForPublicSubnets', {
      vpc,
      description: 'seburity group for bastion'
    })
    Tags.of(securityGroupPublic).add('Name', 'SecurityGroupForPublicSubnets');
    dbConnectionGroup.addIngressRule(securityGroupPublic, Port.tcp(DB_PORT));

    //------------------ Lambda用の設定 ----------------------------------
    // We need this security group to add an ingress rule and allow our lambda to query the proxy
    const lambdaToRDSProxyGroup = new SecurityGroup(this, 'Lambda to RDS Proxy Connection', {
      vpc,
      description: 'seburity group for lambda'
    });
    Tags.of(lambdaToRDSProxyGroup).add('Name', 'SecurityGroupForPrivateSubnetsForLambda');
    dbConnectionGroup.addIngressRule(lambdaToRDSProxyGroup, Port.tcp(DB_PORT), 'allow lambda connection');
    //------------------ 共通設定 ----------------------------------
    // 作成したリソース全てにタグをつける
    Aspects.of(this).add(new Tag('Stack', id));

  }
}

```


### RDS Proxyに読取エンドポイントの追加

読取専用のRDSインスタンスが必要なので、作成するインスタンス数を2としている。
`targetRole: 'READ_ONLY',`としたエンドポイントを追加。


```diff:cdk/lib/aurora-stack.ts
import { Aspects, RemovalPolicy, Stack, StackProps, Tag, Tags } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { InstanceClass, InstanceSize, InstanceType, SecurityGroup, SubnetType, Vpc } from 'aws-cdk-lib/aws-ec2';
import { AuroraPostgresEngineVersion, CfnDBProxyEndpoint, Credentials, DatabaseCluster, DatabaseClusterEngine, DatabaseProxy, DatabaseSecret, ParameterGroup, ProxyTarget, SubnetGroup } from 'aws-cdk-lib/aws-rds';
import { Secret } from 'aws-cdk-lib/aws-secretsmanager';
import { AccountPrincipal, Role } from 'aws-cdk-lib/aws-iam';
import { StringListParameter, StringParameter } from 'aws-cdk-lib/aws-ssm';

interface AuroraStackProps extends StackProps {
  vpcId: string
  sgId: string
  subnetGroupName: string
  dbAdminName: string
  dbAdminSecretName: string
  dbReadOnlyUserName: string
  dbReadOnlyUserSecretName: string
+  ssmParamKeySubnetIds: string
}

export class AuroraStack extends Stack {
  constructor(scope: Construct, id: string, props: AuroraStackProps) {
    // デフォルトのpropsとの意図しない競合を防ぐため、自前で設定したプロパティを削除して親に渡す
    const superProps = {
      ...props, vpcId: undefined, sgId: undefined, subnetName: undefined
      , dbSecretName: undefined, dbAdminName: undefined, dbUserPassword: undefined

    } as StackProps
    super(scope, id, superProps);

    const vpc = Vpc.fromLookup(this, 'Vpc', { vpcId: props.vpcId })
    const securityGroup = SecurityGroup.fromLookupById(this, 'SecurityGroup', props.sgId);

    // subnetGroupNameはlowecaseで作成されている
    const subnetGroup = SubnetGroup.fromSubnetGroupName(this, 'SubnetGroup', props.subnetGroupName.toLowerCase());

    const secret = this.createSecret({ secretName: props.dbAdminSecretName, rdsName: props.dbAdminName });

    const cluster = new DatabaseCluster(this, 'clusterForAurora', {
      // LTSのバージョンを選択.RDSProxyは10と11のみのサポート 2021.12.10
      engine: DatabaseClusterEngine.auroraPostgres({ version: AuroraPostgresEngineVersion.VER_11_13 }),
      removalPolicy: RemovalPolicy.DESTROY, // 本番運用だと消しちゃだめだと思う
      defaultDatabaseName: 'postgres',
      instanceProps: {
        vpc,
        securityGroups: [securityGroup],
        // postgresを使える最安値 (2021.12.10)
        instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MEDIUM),
      },
+      instances: 2,
-      instances: 1,
      subnetGroup,
      credentials: Credentials.fromSecret(secret)
    });

    // RDSでの作成ユーザをシークレットに登録
    const secretForDBUser = this.createSecret({ secretName: props.dbReadOnlyUserSecretName, rdsName: props.dbReadOnlyUserName });

    const proxy = cluster.addProxy('Proxy', {
      secrets: [cluster.secret!, secretForDBUser],
      vpc,
      securityGroups: [securityGroup],
      requireTLS: true,
      iamAuth: true
    });
    Tags.of(proxy).add('Name', 'AuroraRDSProxy');

    const role = new Role(this, 'DBProxyRole', { assumedBy: new AccountPrincipal(this.account) });
    Tags.of(role).add('Name', 'AuroraProxyRole');
    proxy.grantConnect(role, props.dbAdminName);
    proxy.grantConnect(role, props.dbReadOnlyUserName);


+    // 読取専用エンドポイント
+    const readOnlyEndpoint = new CfnDBProxyEndpoint(this, 'readOnlyProxyEndpoint', {
+      dbProxyEndpointName: 'readOnlyProxyEndpoint',
+      dbProxyName: proxy.dbProxyName,
+      vpcSubnetIds: StringParameter.valueFromLookup(this, props.ssmParamKeySubnetIds).split(','), 
+      targetRole: 'READ_ONLY',
+      vpcSecurityGroupIds: [props.sgId]
+    })
+    Tags.of(readOnlyEndpoint).add('Name', 'readOnlyProxyEndpoint');

    // 作成したリソース全てにタグをつける
    Aspects.of(this).add(new Tag('Stack', id));
  }

  private createSecret(props: { secretName: string, rdsName: string }) {
    const secret = new DatabaseSecret(this, props.secretName, {
      secretName: props.secretName,
      username: props.rdsName
    });
    Tags.of(secret).add('Name', props.secretName);
    return secret;
  }
}
```

### 読取専用のLambda

```diff:cdk/.env
VPC_ID=vpc-hoge
PRIVATE_SG_ID=sg-fuga
PUBLIC_SG_ID=sg-hoge
SUBNET_GROUP_NAME=SubnetGroupForAurora
PUBLIC_SUBNET_ID=subnet-hoge
DB_SECRET_NAME=db-secrets
DB_ADMIN_NAME=rdsadmin
DB_PROXY_RESOURCE_ID=prx-xxxxxxx (RDSプロキシのリソースID(ARNの最後))
DB_PROXY_ENDPOINT=proxy.proxy-hoge.ap-northeast-1.rds.amazonaws.com
+ DB_PROXY_READ_ONLY_ENDPOINT=readOnlyProxyEndpoint.endpoint.proxy-hoge.ap-northeast-1.rds.amazonaws.com
DB_USER_SECRET_NAME=db-rdsuser-secrets
DB_USER_NAME=rdsuser
+ SSM_PARAM_KEY_SUBNET_IDS=/subnet_ids
```


```diff:cdk/lib/private-lambda-stack.ts
import { Aspects, Duration, Stack, StackProps, Tag, Tags } from 'aws-cdk-lib';
import { NodejsFunction, BundlingOptions } from 'aws-cdk-lib/aws-lambda-nodejs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
import { NODE_LAMBDA_LAYER_DIR } from './process/setup';
import { ISecurityGroup, IVpc, SecurityGroup, SelectedSubnets, SubnetType, Vpc } from 'aws-cdk-lib/aws-ec2';
import { LambdaIntegration, RestApi } from 'aws-cdk-lib/aws-apigateway';
import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';

interface PrivateLambdaStackProps extends StackProps {
  vpcId: string
  sgId: string
  rdsProxyResourceId: string
  dbAdminName: string
  dbProxyEndpoint: string
  dbReadOnlyUserName: string
  dbProxyReadOnlyEndpoint: string
}

export class PrivateLambdaStack extends Stack {
  constructor(scope: Construct, id: string, props: PrivateLambdaStackProps) {
    super(scope, id, props);
    const vpc = Vpc.fromLookup(this, 'Vpc', { vpcId: props.vpcId })
    const securityGroup = SecurityGroup.fromLookupById(this, 'SecurityGroup', props.sgId);
    const vpcSubnets = vpc.selectSubnets({ subnetType: SubnetType.PRIVATE_ISOLATED })

    const nodeModulesLayer = new lambda.LayerVersion(this, 'NodeModulesLayer',
      {
        code: lambda.AssetCode.fromAsset(NODE_LAMBDA_LAYER_DIR),
        compatibleRuntimes: [lambda.Runtime.NODEJS_14_X]
      }
    );
    const bundling = {
      externalModules: [
        'aws-sdk', // Use the 'aws-sdk' available in the Lambda runtime
        'date-fns', // Layrerに入れておきたいモジュール
        'pg'
      ],
    }
    const lambdaParamsDefault = {
      layers: [nodeModulesLayer],
      vpc,
      vpcSubnets,
      securityGroups: [securityGroup],
      bundling
    }

    const helloLambda = this.createLambda({
      ...lambdaParamsDefault,
      entry: `../src/handler/api/hello.ts`,
      name: 'hellorLambda',
      descritption: 'サンプル用メッセージ表示'
    })

    const rdsEnv = {
      DB_PORT: '5432',
      DB_HOST: props.dbProxyEndpoint,
      DB_USER: props.dbAdminName,
      DB_DBNAME: 'postgres',
    }
    const rdsEnvReadOnly = {
      ...rdsEnv,
      DB_USER: props.dbReadOnlyUserName,
+      DB_HOST: props.dbProxyReadOnlyEndpoint,
+      IS_READ_ONLY: 'true'
    }
    const dbConnectArn = `arn:aws:rds-db:${this.region}:${this.account}:dbuser:${props.rdsProxyResourceId}`
    const rdsAdminResource = `${dbConnectArn}/${props.dbAdminName}`;
    const rdsReadOnlyUserResource = `${dbConnectArn}/${props.dbReadOnlyUserName}`;
    const adminPolicy = new PolicyStatement({
      effect: Effect.ALLOW,
      actions: ['rds-db:connect'],
      resources: [rdsAdminResource],
    })
    const readOnlyUserPolicy = new PolicyStatement({
      effect: Effect.ALLOW,
      actions: ['rds-db:connect'],
      resources: [rdsReadOnlyUserResource],
    })


    const electricLambda = this.createLambda({
      ...lambdaParamsDefault,
      entry: `../src/handler/api/getElectric.ts`,
      name: 'electricLambda',
      descritption: 'RDSAdminでテーブル参照',
      environment: { ...rdsEnv },
      initialPolicy: [adminPolicy],
      timeoutSec: 10, // DBへの再接続を1秒置き、3回まで行うため、デフォルトの3秒より延ばしておく
    })

    const api = new RestApi(this, 'ServerlessRestApi', { cloudWatchRole: false });
    api.root.addResource('hello').addMethod('GET', new LambdaIntegration(helloLambda));
    const electricResource = api.root.addResource('electric')
    electricResource.addMethod('GET', new LambdaIntegration(electricLambda));

    const electricLambda2 = this.createLambda({
      ...lambdaParamsDefault,
      entry: `../src/handler/api/getElectric.ts`,
      name: 'electricLambda2',
      descritption: 'RDS 読取専用ユーザでテーブル参照',
      environment: { ...rdsEnvReadOnly },
      initialPolicy: [readOnlyUserPolicy],
      timeoutSec: 10,
    })

    const electricReadonlyResource = api.root.addResource('electric-readonly');
    electricReadonlyResource.addMethod('GET', new LambdaIntegration(electricLambda2));

    const electricLambda3 = this.createLambda({
      ...lambdaParamsDefault,
      entry: `../src/handler/api/postElectric.ts`,
      name: 'electricLambda3',
      descritption: 'RDS 読取専用ユーザでテーブル挿入',
      environment: { ...rdsEnvReadOnly },
      initialPolicy: [readOnlyUserPolicy],
      timeoutSec: 10,
    })

    electricReadonlyResource.addMethod('POST', new LambdaIntegration(electricLambda3));

    const electricLambda4 = this.createLambda({
      ...lambdaParamsDefault,
      entry: `../src/handler/api/postElectric.ts`,
      name: 'electricLambda4',
      descritption: 'RDSAdminでテーブル挿入',
      environment: { ...rdsEnv },
      initialPolicy: [adminPolicy],
      timeoutSec: 10,
    })
    electricResource.addMethod('POST', new LambdaIntegration(electricLambda4));

    // // 認証情報へのアクセス許可
    // const secret = Secret.fromSecretNameV2(this, 'RDSSecret', props.dbSecretName);
    // secret.grantRead(electricLambda);

    Aspects.of(this).add(new Tag('Stack', id));
  }

  private createLambda(props: {
    layers: lambda.LayerVersion[]
    vpc: IVpc
    vpcSubnets: SelectedSubnets
    securityGroups: ISecurityGroup[]
    bundling: BundlingOptions
    name: string
    descritption: string
    entry: string
    environment?: Record<string, string>
    initialPolicy?: PolicyStatement[]
    timeoutSec?: number
  }) {
    const func = new NodejsFunction(this, props.name, {
      runtime: lambda.Runtime.NODEJS_14_X,
      entry: props.entry,
      functionName: props.name,
      description: props.descritption,
      layers: props.layers,
      vpc: props.vpc,
      vpcSubnets: props.vpcSubnets,
      securityGroups: props.securityGroups,
      bundling: props.bundling,
      environment: props.environment,
      initialPolicy: props.initialPolicy,
      timeout: props.timeoutSec ? Duration.seconds(props.timeoutSec) : undefined,
    });
    Tags.of(func).add('Name', props.name);
    return func;
  }
}
```

## Postgres接続のソース修正

リーダーエンドポイントのホスト名に .endpoint. があるため、証明書のコモンネームと不一致とされてしまう。
コモンネームの一致まで検証しないように、 sslmode を verify-full ではなく verify-ca にする。

```diff:src/common/parsistants/postgres.ts
const getPool = () => {
  const { DB_PORT, DB_HOST, DB_USER, DB_DBNAME, AWS_REGION } = process.env;

  const signerOptions = {
    region: AWS_REGION,
    hostname: DB_HOST,
    port: Number(DB_PORT), 
    username: DB_USER,
  }


+  // 読取専用エンドポイントの場合は verify-ca で接続(ホストのドメインに.endpointが含まれるため証明書のコモンネームと不一致となるため検証スキップ)
+  const ssl = process.env.IS_READ_ONLY ? {
+    ca: fs.readFileSync('/opt/nodejs/data/AmazonRootCA1.pem'),
+    requestCert: true,
+    rejectUnauthorized: false
+  } : { ca: fs.readFileSync('/opt/nodejs/data/AmazonRootCA1.pem') };

  return new Pool({
    host: signerOptions.hostname,
    port: signerOptions.port,
    user: signerOptions.username,
    database: DB_DBNAME,
    // IAM認証のため、パスワードの代わりにトークンを使用する。
    password: () => signer.getAuthToken(signerOptions),
+    ssl
-    ssl: { ca: fs.readFileSync('/opt/nodejs/data/AmazonRootCA1.pem') }
  });
}
```

## 動作確認

読取専用のエンドポイント経由で接続されたことをログから確認する。


## 参考

### 公式

[新しい SSL/TLS 証明書を使用して PostgreSQL DB インスタンスに接続するようにアプリケーションを更新する](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/ssl-certificate-rotation-postgresql.html)
[Amazon RDS Proxy が Amazon Aurora Replicas の読み取り専用エンドポイントを追加](https://aws.amazon.com/jp/about-aws/whats-new/2021/03/amazon-rds-proxy-adds-read-only-endpoints-for-amazon-aurora-replicas/)
[Working with Amazon RDS Proxy endpoints](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/rds-proxy-endpoints.html#rds-proxy-endpoints-overview)
[cdk v2 - CfnDBProxyEndpoint ](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_rds.CfnDBProxyEndpoint.html)
[ssm parameter store](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/systems-manager-parameter-store.html)
[postgres - ssl](https://www.postgresql.org/docs/11/libpq-ssl.html)

### 野生
[RDS Proxy でリーダーエンドポイントが利用可能になりました](https://dev.classmethod.jp/articles/rds-proxy-adds-read-only-endpoints-for-amazon-aurora-replicas/)
[そもそもコネクションプーリングとは？](https://dev.classmethod.jp/articles/20200626-abou-rds-proxy/)
[Amazon RDS が使用する IAM ロールをうっかり絵を描いて整理してみた](https://dev.classmethod.jp/articles/rds-aws-service-role-e/)
[Use Amazon RDS Proxy with read-only endpoints](https://dataintegration.info/use-amazon-rds-proxy-with-read-only-endpoints)
[RDS Proxy のリーダーエンドポイントに IAM 認証を使う方法](https://qiita.com/sukobuto/items/d772ef37d17eda257b06)
[RDS Proxy 経由で Aurora の Reader エンドポイントに同時多重でクエリを発行して均等分散することを確認した](https://yohei-a.hatenablog.jp/entry/20210930/1632966146?utm_source=feed)
[github RDS Proxyの追加](https://github.com/keitakn/my-terraform/pull/52)
[実践！AWS CDK #23 RDS インスタンス](https://dev.classmethod.jp/articles/cdk-practice-23-rds-instance/)
[aws-cdk-lib.aws_rds module](https://docs.aws.amazon.com/cdk/api/v2//docs/aws-cdk-lib.aws_rds-readme.html)
[RDSとAuroraで変更を検討するパラメータ(PostgreSQL)](https://zatoima.github.io/aws-aurora-rds-postgresql-parameter-change.html)
[github - aws-samples/amazon-rds-init-cdk](https://github.com/aws-samples/amazon-rds-init-cdk)
[github - aws-cdk-rds-proxy](https://github.com/bodokaiser/aws-cdk-rds-proxy)
[CDK の Vpc.fromLookup では StringParameter.valueFromLookup を使う](https://fits.hatenablog.com/entry/2021/09/26/212139)
[CDKで作成したVPCネットワークのElastic IPを取得する](https://zenn.dev/sai2_dev/articles/20210330_cdk_eip)
[AWS-CDKで「これ、どうかくの？」](https://zenn.dev/bohebohe/scraps/bcf3bac0d2cc13)
[CDK Tips&Trics](https://d1.awsstatic.com/webinars/jp/pdf/services/20200303_BlackBelt_CDK.pdf)
[AWS CDKでAWS Systems Manager パラメータストア及びAWS Secrets Managerからパラメータを取り込む方法](https://dev.classmethod.jp/articles/aws-cdk-ssm-secrets-manager/)
[Node.js アプリから Heroku Postgres に接続できなくなったので SSL 通信設定を直す](https://neos21.net/blog/2021/03/26-01.html)
[SSL設定を変更（rejectUnauthorized: false ）](https://www.i-ryo.com/entry/2020/10/27/183001#SSL%E8%A8%AD%E5%AE%9A%E3%82%92%E5%A4%89%E6%9B%B4rejectUnauthorized-false-)


メモ
読取専用エンドポイントに変更すると、タイムアウトとなる。 -> 読取専用エンドポイントのセキュリティグループの設定をしていなかった。