CDK Patternsを参考にしてRDS Proxyを利用したAPIをデプロイしてみた

CDK PatternsのThe RDS Proxyを参考に API Gateway / Lambda / RDS Proxy / RDS 構成のAPIをデプロイしてみました。CDKのコードはTypeScriptで、Lambda関数のコードはGoで実装しています。
2020.10.26

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、CX事業本部のうらわです。

弊社ブログ記事でも紹介しているAWS CDKのパターン集「CDK Patterns」は、CDKの実装例として非常に参考になります。

本記事ではパターンの一つ「The RDS Proxy」に少し手を加えて(個人的に満足のいく)RDS Proxyの検証環境を構築してみたのでご紹介します。

前提

下記環境で実装しています。CDKはTypeScriptで実装しています。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.6
BuildVersion:   19G2021

$ node -v
v12.18.3

$ npm -v
6.14.6

$ cdk --version
1.70.0 (build c145314)

本記事記載のコードは以下のリポジトリに格納してあります。

CDK Patternsのサンプルコードと異なる点

本家のThe RDS Proxyのサンプルコードとは以下の点が異なります。

  1. CDKのバージョンを本記事時点の最新バージョンに更新
  2. 踏み台サーバを利用
  3. Lambda関数をGoで実装

1については、サンプルコードはCDKのバージョンが1.59.0です。本記事では記事執筆時点の最新バージョン1.70.0を使用しています。

2については、サンプルコードのLambda関数は1つの関数内でデータベース作成・テーブル作成・レコード作成を実施しています。 検証目的のコードなので仕方ありませんがあまり実用的ではないコードだと感じたため、本記事では踏み台サーバ経由でRDSに接続し事前にデータベースやテーブルを作成します。

3については、2の理由からサンプルのLambda関数コードは使わず一から書く必要があったため、個人的な勉強も兼ねてGoで書いてみました。

Lambda関数の実装

booksテーブルの全件を返却するGET /books APIを想定して実装しました。 処理は以下のような流れです。

  1. Secret ManagerからMySQLに接続するためSecret Managerから認証情報を取得
  2. TLSで接続するためCA証明書を登録
  3. MySQLに接続しSELECT文でbooksテーブルの全件取得

2については以下を参考にしました。

AmazonRootCA1.pemはThe RDS Proxyのサンプルコードと一緒に格納してあるファイルを流用するか、以下のページのリンクから取得できます。

https://www.amazontrust.com/repository/AmazonRootCA1.pem

lambda/main.go

package main

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"os"
	"path/filepath"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/secretsmanager"

	"database/sql"
	"github.com/go-sql-driver/mysql"
)

type UserInfo struct {
	UserName string `json:"username"`
	Password string `json:"password"`
}

type Book struct {
	Id    int
	Name  string
	Price int
}

func connect() (*sql.DB, error) {
	svc := secretsmanager.New(session.New(), aws.NewConfig().WithRegion("ap-northeast-1"))

	input := &secretsmanager.GetSecretValueInput{
		SecretId: aws.String(os.Getenv("RDS_SECRET_NAME")),
	}

	result, err := svc.GetSecretValue(input)
	if err != nil {
		return nil, err
	}

	// Secret Managerから認証情報を取得
	var userInfo UserInfo
	secrets := *result.SecretString
	json.Unmarshal([]byte(secrets), &userInfo)

	// CA証明書の設定
	rootCertPool := x509.NewCertPool()
	absPath, _ := filepath.Abs("./cert/AmazonRootCA1.pem")
	pem, err := ioutil.ReadFile(absPath)
	if err != nil {
		return nil, err
	}

	if ok := rootCertPool.AppendCertsFromPEM(pem); !ok {
		fmt.Println("[ERROR]", "Fialed to append PEM")
	}

	mysql.RegisterTLSConfig("custom", &tls.Config{
		ClientCAs: rootCertPool,
	})

	// MySQLに接続
	dsn := fmt.Sprintf(
		"%s:%s@tcp(%s:%s)/rds_proxy_go?charset=utf8mb4&parseTime=True&loc=Local&tls=custom",
		userInfo.UserName,
		userInfo.Password,
		os.Getenv("PROXY_ENDPOINT"),
		"3306",
	)

	db, err := sql.Open("mysql", dsn)
	if err != nil {
		return nil, err
	}

	return db, nil
}

func handleRequest(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	jsonReq, _ := json.Marshal(request)
	fmt.Println(string(jsonReq))

	db, err := connect()
	if err != nil {
		fmt.Println("[ERROR]", err)
		return events.APIGatewayProxyResponse{Body: "Error!", StatusCode: 500}, nil
	}
	defer db.Close()

	rows, err := db.Query("SELECT * FROM books")
	if err != nil {
		fmt.Println("[ERROR]", err)
		return events.APIGatewayProxyResponse{Body: "Error!", StatusCode: 500}, nil
	}
	defer rows.Close()

	var books []Book
	for rows.Next() {
		var book Book
		err := rows.Scan(&book.Id, &book.Name, &book.Price)
		if err != nil {
			fmt.Println("[ERROR]", err)
			return events.APIGatewayProxyResponse{Body: "Error!", StatusCode: 500}, nil
		}
		books = append(books, book)
	}

	jsonBooks, _ := json.Marshal(books)
	return events.APIGatewayProxyResponse{Body: string(jsonBooks), StatusCode: 200}, nil
}

func main() {
	lambda.Start(handleRequest)
}

ビルドしておきます。

GOARCH=amd64 GOOS=linux go build -o main main.go

CDKによる環境構築

CDKで EC2 / RDS / RDS Proxy / API Gateway / Lambda をデプロイします。 大部分をThe RDS Proxyのサンプルコードを参考にしています。 踏み台サーバ関連のコードは新規追加しています。

lib/rds-proxy-go-stack.ts

import * as cdk from '@aws-cdk/core';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as lambda from '@aws-cdk/aws-lambda';
import * as apigw from '@aws-cdk/aws-apigateway';
import * as rds from '@aws-cdk/aws-rds';
import * as secrets from '@aws-cdk/aws-secretsmanager';

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

    // パブリックサブネットとプライベートサブネットを用意
    // NAT Gatewayは利用しない
    const vpc = new ec2.Vpc(this, 'Vpc', {
      maxAzs: 2,
      natGateways: 0,
      cidr: '10.1.0.0/16',
      subnetConfiguration: [
        {
          name: 'public',
          subnetType: ec2.SubnetType.PUBLIC,
          cidrMask: 24,
        },
        {
          name: 'private',
          subnetType: ec2.SubnetType.ISOLATED,
          cidrMask: 24,
        },
      ],
    });

    const bastionGroup = new ec2.SecurityGroup(
      this,
      'Bastion to DB Connection',
      {
        vpc,
      }
    );

    const lambdaToRDSProxyGroup = new ec2.SecurityGroup(
      this,
      'Lambda to RDS Proxy Connection',
      {
        vpc,
      }
    );

    const dbConnectionGroup = new ec2.SecurityGroup(
      this,
      'Proxy to DB Connection',
      {
        vpc,
      }
    );

    dbConnectionGroup.addIngressRule(
      dbConnectionGroup,
      ec2.Port.tcp(3306),
      'allow db connection'
    );

    dbConnectionGroup.addIngressRule(
      lambdaToRDSProxyGroup,
      ec2.Port.tcp(3306),
      'allow lambda connection'
    );

    dbConnectionGroup.addIngressRule(
      bastionGroup,
      ec2.Port.tcp(3306),
      'allow bastion connection'
    );

    // パブリックサブネットに踏み台サーバを配置する
    const host = new ec2.BastionHostLinux(this, 'BastionHost', {
      vpc,
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T2,
        ec2.InstanceSize.MICRO
      ),
      securityGroup: bastionGroup,
      subnetSelection: {
        subnetType: ec2.SubnetType.PUBLIC,
      },
    });

    // 踏み台サーバにmysqlとjqをインストールしておく
    host.instance.addUserData('yum -y update', 'yum install -y mysql jq');

    // RDSの認証情報
    const databaseCredentialsSecret = new secrets.Secret(
      this,
      'DBCredentialsSecret',
      {
        secretName: id + '-rds-credentials',
        generateSecretString: {
          secretStringTemplate: JSON.stringify({
            username: 'syscdk',
          }),
          excludePunctuation: true,
          includeSpace: false,
          generateStringKey: 'password',
        },
      }
    );

    // Lambda関数からSecret ManagerにアクセスするためのVPCエンドポイント
    new ec2.InterfaceVpcEndpoint(this, 'SecretManagerVpcEndpoint', {
      vpc: vpc,
      service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
    });

    const rdsInstance = new rds.DatabaseInstance(this, 'DBInstance', {
      engine: rds.DatabaseInstanceEngine.mysql({
        version: rds.MysqlEngineVersion.VER_5_7_30,
      }),
      credentials: rds.Credentials.fromSecret(databaseCredentialsSecret),
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T2,
        ec2.InstanceSize.MICRO
      ),
      vpc,
      vpcSubnets: {
        subnetType: ec2.SubnetType.ISOLATED,
      },
      securityGroups: [dbConnectionGroup],
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      deletionProtection: false,
      parameterGroup: new rds.ParameterGroup(this, 'ParameterGroup', {
        engine: rds.DatabaseInstanceEngine.mysql({
          version: rds.MysqlEngineVersion.VER_5_7_30,
        }),
        parameters: {
          character_set_client: 'utf8mb4',
          character_set_server: 'utf8mb4',
        },
      }),
    });

    const proxy = rdsInstance.addProxy(id + '-proxy', {
      secrets: [databaseCredentialsSecret],
      debugLogging: true,
      vpc,
      securityGroups: [dbConnectionGroup],
    });

    const rdsLambda = new lambda.Function(this, 'RdsProxyHandler', {
      runtime: lambda.Runtime.GO_1_X,
      code: lambda.Code.asset('lambda'),
      handler: 'main',
      vpc: vpc,
      securityGroups: [lambdaToRDSProxyGroup],
      environment: {
        PROXY_ENDPOINT: proxy.endpoint,
        RDS_SECRET_NAME: id + '-rds-credentials',
      },
    });

    // 認証情報へのアクセス許可
    databaseCredentialsSecret.grantRead(rdsLambda);
    databaseCredentialsSecret.grantRead(host);

    const restApi = new apigw.RestApi(this, 'RestApi', {
      restApiName: 'rds-proxy-go',
      deployOptions: {
        stageName: 'dev',
      },
    });

    const rdsLambdaIntegration = new apigw.LambdaIntegration(rdsLambda);
    const booksResource = restApi.root.addResource('books');
    booksResource.addMethod('GET', rdsLambdaIntegration);
  }
}

デプロイは結構時間がかかります。

npm run cdk deploy

検証用データベースの準備

CDKによるデプロイが完了したら、検証用のデータベース等を作成します。

Session Managerで踏み台サーバにアクセス

今回はAWS CLIを使用します。ローカルマシンに以下のプラグインをインストールしておく必要があります。

準備ができたら、踏み台サーバのインスタンスIDを指定してセッションを開始します。

aws ssm start-session --target i-XXXXXXXXXXXXXXXX

※ Session Managerのアクセス制御について

CDKのBastionHostLinuxコンストラクトは以下のリンクに記載のあるようなSession Managerでのアクセスに必要な設定を自動でしてくれています。

https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/session-manager-getting-started-instance-profile.html

AWS CLIでSession Managerを使用するために必要なIAMポリシーは下記リンクを参照ください。本記事ではAdministratorAccessのIAMポリシーが付与されているIAMロールを利用しています。

https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/getting-started-restrict-access-quickstart.html#restrict-access-quickstart-end-user

MySQLに接続

AWS CLIを用いてSecret Managerからユーザー名やパスワードを取得します。 以下のコマンドを実行して変数にセットします。

secret=$(aws secretsmanager get-secret-value --region ap-northeast-1 --secret-id RdsProxyGoStack-rds-credentials | jq .SecretString | jq fromjson)
user=$(echo $secret | jq -r .username)
password=$(echo $secret | jq -r .password)
endpoint=$(echo $secret | jq -r .host)
port=$(echo $secret | jq -r .port)

※ これらのコマンドは以下のAWS Secret Managerのワークショップを参考にしています。

https://secrets-manager.awssecworkshops.com/RDSFargate/RDS/

データベース・テーブル・レコードの作成

必要な情報が準備できたら、以下のコマンドでMySQLに接続しSQLを発行します。

mysql -h $endpoint -P $port -u $user -p$password

# SQL
CREATE DATABASE rds_proxy_go;
USE rds_proxy_go;
CREATE TABLE books(id int primary key auto_increment, name text, price int);
INSERT INTO books (name, price) VALUES ('思い出の本', 100);
INSERT INTO books (name, price) VALUES ('AWS Database Book', 200000);
INSERT INTO books (name, price) VALUES ('日記帳12345', 3000000);
INSERT INTO books (name, price) VALUES ('あいうえお', 12345);

SELECT文で全件取得してみます。

MySQL [rds_proxy_go]> select * from books;
+----+-------------------+---------+
| id | name              | price   |
+----+-------------------+---------+
|  1 | 思い出の本          |     100 |
|  2 | AWS Database Book |  200000 |
|  3 | 日記帳12345        | 3000000 |
|  4 | あいうえお           |   12345 |
+----+-------------------+---------+
4 rows in set (0.01 sec)

以上で準備は完了です。

books APIをたたいてみる

API GatewayのエンドポイントはCDKによるデプロイ完了後に出力されるURLかAWSコンソールにて参照します。 先ほどのSQLの実行結果と同様、booksテーブルの全レコードが返ってきます。

$ curl -X GET https://XXXXXXXXXX.execute-api.ap-northeast-1.amazonaws.com/dev/books | jq
[
  {
    "Id": 1,
    "Name": "思い出の本",
    "Price": 100
  },
  {
    "Id": 2,
    "Name": "AWS Database Book",
    "Price": 200000
  },
  {
    "Id": 3,
    "Name": "日記帳12345",
    "Price": 3000000
  },
  {
    "Id": 4,
    "Name": "あいうえお",
    "Price": 12345
  }
]

おわりに

記事の大半がCDK関連ではなく踏み台サーバ関連になってしまいました。

CDK Patternsのコードは初めて触るリソースをCDKで構築する時に非常に参考になります。ぜひ色々なパターンを試してみてください。