1
1

More than 1 year has passed since last update.

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

Posted at

概要

前回はRDS Proxyは1つで、どちらのユーザも同じ読書ができるエンドポイントを使用していた。
今回は、読取専用のユーザは読取専用のエンドポイントを介してアクセスを行うようにする。
読取専用のエンドポイントを作るため、RDSクラスタには読書用のインスタンスと読取専用のインスタンスを用意する。

ソースコード

アーキテクチャ

image.png

CDK

VPCでサブネットIDをSystemMangerのパラメータストアに追加

読取用のエンドポイントの作成に必要。

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',としたエンドポイントを追加。

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

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
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 にする。

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 インスタンスに接続するようにアプリケーションを更新する
Amazon RDS Proxy が Amazon Aurora Replicas の読み取り専用エンドポイントを追加
Working with Amazon RDS Proxy endpoints
cdk v2 - CfnDBProxyEndpoint
ssm parameter store
postgres - ssl

野生

RDS Proxy でリーダーエンドポイントが利用可能になりました
そもそもコネクションプーリングとは?
Amazon RDS が使用する IAM ロールをうっかり絵を描いて整理してみた
Use Amazon RDS Proxy with read-only endpoints
RDS Proxy のリーダーエンドポイントに IAM 認証を使う方法
RDS Proxy 経由で Aurora の Reader エンドポイントに同時多重でクエリを発行して均等分散することを確認した
github RDS Proxyの追加
実践!AWS CDK #23 RDS インスタンス
aws-cdk-lib.aws_rds module
RDSとAuroraで変更を検討するパラメータ(PostgreSQL)
github - aws-samples/amazon-rds-init-cdk
github - aws-cdk-rds-proxy
CDK の Vpc.fromLookup では StringParameter.valueFromLookup を使う
CDKで作成したVPCネットワークのElastic IPを取得する
AWS-CDKで「これ、どうかくの?」
CDK Tips&Trics
AWS CDKでAWS Systems Manager パラメータストア及びAWS Secrets Managerからパラメータを取り込む方法
Node.js アプリから Heroku Postgres に接続できなくなったので SSL 通信設定を直す
SSL設定を変更(rejectUnauthorized: false )

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1