[AWS SAM] Amazon RDS Proxy をつかって Amazon Aurora MySQL に接続するサーバーレスアプリケーションを構築する | SEEDS Creators' Blog | 株式会社シーズ

[AWS SAM] Amazon RDS Proxy をつかって Amazon Aurora MySQL に接続するサーバーレスアプリケーションを構築する

クラウド事業部エンジニアの川勝です。
今回は Amazon RDS Proxy をつかって Amazon Aurora MySQL に接続するサーバーレスアプリケーションを構築するサンプルを作成したのでその方法を解説したいと思います。

概要

  1. 構成図
  2. Amazon CloudFormation スタック
    1. VPC Stack
    2. Secrets Stack
    3. RDS Stack
    4. SAM Stack
  3. AWS Lambda 実装(Go)
  4. まとめ(感想)

構成図

Amazon API Gateway -> AWS Lambda -> Amazon RDS Proxy -> Amazon Aurora MySQL
という経路になっています。
VPC には Private Subnet オンリーで頑張っていますが、実用的には Public Subnet に DB 接続用の踏み台インスタンスが必要になるかなと思います。
(まとめで書いていますがそもそも Public Subnet に配置でいい気もします)

Amazon CloudFormation スタック

AWS SAM-CLI で構築していますので、Amazon CloudFormation のスタックごとに解説します。
スタックのネストについてはここでは詳しく取り上げませんので、ドキュメントを参照してください。
※ネストされたスタックの操作
template.yaml を親に cloudformation 配下にテンプレートを分割して配置しています。

.
├── template.yaml
└── cloudformation
    ├── rds.yaml
    ├── sam.yaml
    ├── secrets.yaml
    └── vpc.yaml

各テンプレートは Description などは省いて最低限 sam deply でデプロイが完了する状態を目指しています。
まずは親スタックのテンプレート。

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  blog-rds-proxy
  Parent Template

Parameters:
  VPCCidr:
    Type: String
    Default: 10.1.0.0/16
  PrivateSubnet1Cidr:
    Type: String
    Default: 10.1.0.0/24
  PrivateSubnet2Cidr:
    Type: String
    Default: 10.1.1.0/24
  RDSMasterUsername:
    Type: String
    Default: root
  RDSDatabaseName:
    Type: String
    Default: blog_rds_proxy

Resources:
  VPCStack:
    Type: AWS::Serverless::Application
    Properties:
      Location: cloudformation/vpc.yaml
      Parameters:
        VPCCidr: !Ref VPCCidr
        PrivateSubnet1Cidr: !Ref PrivateSubnet1Cidr
        PrivateSubnet2Cidr: !Ref PrivateSubnet2Cidr
  SecretsStack:
    Type: AWS::Serverless::Application
    Properties:
      Location: cloudformation/secrets.yaml
      Parameters:
        RDSMasterUsername: !Ref RDSMasterUsername
  RDSStack:
    Type: AWS::Serverless::Application
    Properties:
      Location: cloudformation/rds.yaml
      Parameters:
        RDSDatabaseName: !Ref RDSDatabaseName
        RDSMasterUserSecretArn: !GetAtt SecretsStack.Outputs.RDSMasterUserSecretArn
        PrivateSubnet1Id: !GetAtt VPCStack.Outputs.PrivateSubnet1Id
        PrivateSubnet2Id: !GetAtt VPCStack.Outputs.PrivateSubnet2Id
        DBClusterSecurityGroupId: !GetAtt VPCStack.Outputs.DBClusterSecurityGroupId
        RDSProxySecurityGroupId: !GetAtt VPCStack.Outputs.RDSProxySecurityGroupId
  SAMStack:
    Type: AWS::Serverless::Application
    Properties:
      Location: cloudformation/sam.yaml
      Parameters:
        PrivateSubnet1Id: !GetAtt VPCStack.Outputs.PrivateSubnet1Id
        PrivateSubnet2Id: !GetAtt VPCStack.Outputs.PrivateSubnet2Id
        FunctionSecurityGroupId: !GetAtt VPCStack.Outputs.FunctionSecurityGroupId
        RDSMasterUsername: !Ref RDSMasterUsername
        RDSDatabaseName: !Ref RDSDatabaseName
        RDSProxyEndpoint: !GetAtt RDSStack.Outputs.RDSProxyEndpoint
        RDSProxyArn: !GetAtt RDSStack.Outputs.RDSProxyArn

Outputs:
  APIEndpoint:
    Value: !GetAtt SAMStack.Outputs.APIEndpoint
  RDSProxyEndpoint:
    Value: !GetAtt RDSStack.Outputs.RDSProxyEndpoint
  GetSecretValueByCLI:
    Value: !GetAtt SecretsStack.Outputs.GetSecretValueByCLI

SAM-CLI を使うので、子スタックは Type: AWS::Serverless::Application で定義しています。詳細についてはドキュメントを参照してください。
※ネストされたアプリケーションを使用する

Parameters について。
VPC の Cidr は実行するAWSアカウントに合わせます。
RDS の DatabaseName は デプロイ後に Create Database するのが面倒だったのでインスタンス作成時に作成されるように設定しています。
MasterUser のパスワードについては後述の AWS Secrets Manager で生成するため Parameters で指定していません。
各スタックに渡す Parameters の中にはスタックの Outputs で定義した出力値を渡しているものがあります。詳細については以下を参照してください。
※テンプレートにおけるスタックのネスト

VPC Stack

Amazon Aurora MySQL は VPC 内に構築が必要ですので VPC から作っていきます。
多くの場合 データベース は Private Subnet 内に構築することが多いと思います。今回は記述量の簡略化のために Private Subnet のみで以下リソースを配置します。

  • Amazon Aurora MySQL
  • Amazon RDS Proxy
  • AWS Lambda

vpc.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: >
  blog-rds-proxy
  for VPC template


Parameters:
  VPCCidr:
    Type: String
  PrivateSubnet1Cidr:
    Type: String
  PrivateSubnet2Cidr:
    Type: String

Resources:
  # VPC
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VPCCidr
      EnableDnsHostnames: 'true'
      EnableDnsSupport: 'true'
      InstanceTenancy: default
  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties: 
      AvailabilityZone: ap-northeast-1a
      CidrBlock: !Ref PrivateSubnet1Cidr
      VpcId: !Ref VPC
  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties: 
      AvailabilityZone: ap-northeast-1c
      CidrBlock: !Ref PrivateSubnet2Cidr
      VpcId: !Ref VPC
  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
  PrivateSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: 
      SubnetId: !Ref PrivateSubnet1
      RouteTableId: !Ref PrivateRouteTable
  PrivateSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: 
      SubnetId: !Ref PrivateSubnet2
      RouteTableId: !Ref PrivateRouteTable

  # SecurityGroup
  FunctionSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: for Lambda Function
      VpcId: !Ref VPC
  DBClusterSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: for DB Cluster
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 3306
          ToPort: 3306
          SourceSecurityGroupId: !Ref RDSProxySecurityGroup
  RDSProxySecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: for RDS Proxy
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 3306
          ToPort: 3306
          SourceSecurityGroupId: !Ref FunctionSecurityGroup

Outputs:
  PrivateSubnet1Id:
    Value: !Ref PrivateSubnet1
  PrivateSubnet2Id:
    Value: !Ref PrivateSubnet2
  FunctionSecurityGroupId:
    Value: !Ref FunctionSecurityGroup
  DBClusterSecurityGroupId:
    Value: !Ref DBClusterSecurityGroup
  RDSProxySecurityGroupId:
    Value: !Ref RDSProxySecurityGroup

Amazon Aurora クラスターは複数の AvailabilityZone に属した Subnet が必要なため PrivateSubnet は2つ作成しています。
SecurityGroup もこのテンプレートにまとめています。SecurityGroup では各リソース間のアクセス制限をしています。
DBClusterSecurityGroup を Amazon Aurora MySQL に割り当てて RDSProxySecurityGroup からのアクセスを許可。
RDSProxySecurityGroup を Amazon RDS Proxy に割り当てて FunctionSecurityGroup からのアクセスを許可。
FunctionSecurityGroup は AWS Lambda に割り当てます。

Secrets Stack

Amazon RDS Proxy から Amazon Aurora MySQL への認証情報(username, password)は AWS Secrets Manager に保存する必要があります。保存する形式は以下のような JSON 形式で保存します。
※AWS Secrets Manager でのデータベース認証情報の設定

{"username":"your_username","password":"your_password"}

username のみ Parameters から受け取り password は AWS Secrets Manager で作成するようにしています。

secrets.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: >
  blog-rds-proxy
  for Secrets template

Parameters:
  RDSMasterUsername:
    Type: String

Resources:
  # SecretsManager
  RDSMasterUserSecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      GenerateSecretString:
        SecretStringTemplate: !Sub '{"username": "${RDSMasterUsername}"}'
        GenerateStringKey: password
        PasswordLength: 16
        ExcludeCharacters: '"@/\'

Outputs:
  RDSMasterUserSecretArn:
    Value: !Ref RDSMasterUserSecret
  GetSecretValueByCLI:
    Value: !Sub >
        aws secretsmanager get-secret-value
          --secret-id ${RDSMasterUserSecret}
          --region ${AWS::Region}
          --query SecretString

Outputs の GetSecretValueByCLI は作成した Password の確認用の AWS-CLI のコマンドです。

RDS Stack

Amazon Aurora MySQL と Amazon RDS Proxy を作成します。

rds.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: >
  blog-rds-proxy
  for RDS template

Parameters:
  RDSMasterUserSecretArn:
    Type: String
  RDSDatabaseName:
    Type: String
  PrivateSubnet1Id:
    Type: String
  PrivateSubnet2Id:
    Type: String
  DBClusterSecurityGroupId:
    Type: String
  RDSProxySecurityGroupId:
    Type: String

Resources:
  # RDS
  DBCluster:
    Type: AWS::RDS::DBCluster
    Properties:
      MasterUsername: !Sub '{{resolve:secretsmanager:${RDSMasterUserSecretArn}:SecretString:username}}'
      MasterUserPassword: !Sub '{{resolve:secretsmanager:${RDSMasterUserSecretArn}:SecretString:password}}'
      Engine: aurora-mysql
      EngineVersion: 5.7.mysql_aurora.2.10.0
      DatabaseName: !Ref RDSDatabaseName
      DBSubnetGroupName: !Ref DBClusterSubnetGroup
      VpcSecurityGroupIds:
        - !Ref DBClusterSecurityGroupId
  DBInstance1:
    Type: AWS::RDS::DBInstance
    Properties:
      DBClusterIdentifier: !Ref DBCluster
      DBSubnetGroupName: !Ref DBClusterSubnetGroup
      Engine: aurora-mysql
      EngineVersion: 5.7.mysql_aurora.2.10.0
      DBInstanceClass: db.t3.small
    DependsOn: DBCluster
  DBClusterAttachment:
    Type: AWS::SecretsManager::SecretTargetAttachment
    DependsOn: DBCluster
    Properties:
      SecretId: !Ref RDSMasterUserSecretArn
      TargetId: !Ref DBCluster
      TargetType: AWS::RDS::DBCluster
  DBClusterSubnetGroup:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: for DB Cluster
      SubnetIds:
        - !Ref PrivateSubnet1Id
        - !Ref PrivateSubnet2Id
  # RDS Proxy
  RDSProxy:
    Type: AWS::RDS::DBProxy
    Properties:
      DBProxyName: blog-rds-proxy-for-db-cluster
      EngineFamily: MYSQL
      RequireTLS: True
      RoleArn: !GetAtt RDSProxyRole.Arn
      Auth:
        - AuthScheme: SECRETS
          SecretArn: !Ref RDSMasterUserSecretArn
          IAMAuth: REQUIRED
      VpcSecurityGroupIds:
        - !Ref RDSProxySecurityGroupId
      VpcSubnetIds:
        - !Ref PrivateSubnet1Id
        - !Ref PrivateSubnet2Id
  RDSProxyTargetGroup:
    Type: AWS::RDS::DBProxyTargetGroup
    DependsOn:
      - DBCluster
      - DBInstance1
    Properties:
      DBProxyName: !Ref RDSProxy
      DBClusterIdentifiers:
        - !Ref DBCluster
      TargetGroupName: default
  RDSProxyRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: rds.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: AllowGetSecretValue
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - secretsmanager:GetSecretValue
                  - secretsmanager:DescribeSecret
                Resource:
                  - !Ref DBClusterAttachment
                  - !Ref RDSMasterUserSecretArn

Outputs:
  RDSProxyEndpoint:
    Value: !GetAtt RDSProxy.Endpoint
  RDSProxyArn:
    Value: !GetAtt RDSProxy.DBProxyArn

DBCluster の MasterUsername, MasterUserPassword は SecretsStack で作成した シークレットのArnから取得するようになっています。
※動的な参照を使用してテンプレート値を指定する

Engine についてはサンプルなので Amazon Aurora Serverless を使いたいところでしたが、 2021/08 現在 Amazon RDS Proxy は Amazon Aurora Serverless に対応していませんでした。残念。

AWS::RDS::DBCluster のパラメータに明示的に設定していませんが、Amazon RDS Proxy から Amazon Aurora MySQL へはパスワード認証になるためIAM認証(EnableIAMDatabaseAuthentication)は無効にしておく必要があります。

Amazon RDS Proxy は AWS Lambda から IAM認証で接続する想定のため AWS::RDS::DBProxy の Auth で IAMAUth: REQUIRED に、また IAM認証には TLS が必須ですので RequireTLS: True にもしておく必要があります。

RDSProxyRole では AWS Secrets Manager から認証情報を取得するため、必要な Policy (secretsmanager:GetSecretValue, secretsmanager:DescribeSecret) を付与しています。

SAM Stack

最後に SAMStack では AWS::Serverless::Function で AWS Lambda と Amazon API Gateway を作成します。Amazon API Gateway は HTTP API を使用します。

sam.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  blog-rds-proxy
  for SAM Template

Parameters:
  PrivateSubnet1Id:
    Type: String
  PrivateSubnet2Id:
    Type: String
  RDSMasterUsername:
    Type: String
  FunctionSecurityGroupId:
    Type: String
  RDSDatabaseName:
    Type: String
  RDSProxyEndpoint:
    Type: String
  RDSProxyArn:
    Type: String

Resources:
  # LambdaFunction
  BlogRDSProxyFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: ../src/
      Handler: main
      Runtime: go1.x
      Timeout: 29
      VpcConfig:
        SecurityGroupIds:
          - !Ref FunctionSecurityGroupId
        SubnetIds:
          - !Ref PrivateSubnet1Id
          - !Ref PrivateSubnet2Id
      Environment:
        Variables:
          RDS_PROXY_ENDPOINT: !Ref RDSProxyEndpoint
          RDS_USER: !Ref RDSMasterUsername
          RDS_DATABASE_NAME: !Ref RDSDatabaseName
      Events:
        CatchAllApi:
          Type: HttpApi
          Properties:
            Path: '{proxy+}'
            Method: ANY
      Policies:
        - Version: 2012-10-17
          Statement:
            - Effect: Allow
              Action: rds-db:connect
              Resource: !Sub arn:aws:rds-db:${AWS::Region}:${AWS::AccountId}:dbuser:${RDSProxyArn}/${RDSMasterUsername}
        - AWSLambdaVPCAccessExecutionRole

Outputs:
  APIEndpoint:
    Value: !Sub https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/

Amazon RDS Proxy にアクセスするため AWS Lambda は VPC 上に配置します。Private Subent でも Amazon API Gateway から接続はできるようです。

AWS Lambda の IAM Role には Amazon RDS Proxy へ IAM認証で接続するため Policy に rds-db:connect を付与する必要があります 。
余談ですが rds-db:connect で指定しているリソースは ドキュメント では以下のように記載されています。

arn:aws:rds-db:region:account-id:dbuser:DbiResourceId/db-user-name

Amazon RDS のコンソールでは DbiResourceId は明示的に表示されているのですが、Amazon RDS Proxy のコンソールでは表示されていません。この ID は Arn の末尾の値になるので、最初はテンプレート上で Arn から文字列を分割して取り出したりしていたのですが 実は Arn そのままでいいようです。末尾の ID だけ指定すると同一アカウントの同一リージョンのリソースと判定されるようです。(別のアカウントのリソースだったりする場合はフルの Arn を指定する必要がある)

以上でリソースの作成は完了です。

AWS Lambda 実装(Go)

AWS Lambda の実装面では Amazon RDS Proxy への IAM認証について解説します。

import (
	"crypto/tls"
	"crypto/x509"
	"database/sql"
	_ "embed"
	"errors"
	"fmt"
	"net/http"
	"os"

	"github.com/aws/aws-sdk-go/aws/credentials"
	"github.com/aws/aws-sdk-go/service/rds/rdsutils"

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

const (
	routeInitialize = "/initialize"
	routeLoad       = "/load"
)

const (
	driverName = "mysql"
	mysqlPort  = 3306
)

//go:embed AmazonRootCA1.pem
var amazonRootCA1 []byte

var (
	awsRegion        = os.Getenv("AWS_REGION")
	rdsProxyEndpoint = os.Getenv("RDS_PROXY_ENDPOINT")
	rdsUser          = os.Getenv("RDS_USER")
	databaseName     = os.Getenv("RDS_DATABASE_NAME")
)

func dbConnect() (*sql.DB, error) {
	host := fmt.Sprintf("%s:%d", rdsProxyEndpoint, mysqlPort)
	cfg := &mysql.Config{
		User: rdsUser,
		Addr: host,
		Net:  "tcp",
		Params: map[string]string{
			"tls": "true",
		},
		DBName:                  databaseName,
		AllowCleartextPasswords: true,
		AllowNativePasswords:    true,
	}

	paswd, err := rdsutils.BuildAuthToken(
		cfg.Addr,
		awsRegion,
		cfg.User,
		credentials.NewEnvCredentials(),
	)
	if err != nil {
		return nil, fmt.Errorf("rds build auth token error occurred: %w", err)
	}
	cfg.Passwd = paswd

	err = registerRDSMysqlCerts(http.DefaultClient)
	if err != nil {
		return nil, fmt.Errorf("register rds mysql certs error occurred: %w", err)
	}

	fmt.Printf("dsn: %s\n", cfg.FormatDSN())
	db, err := sql.Open(driverName, cfg.FormatDSN())
	if err != nil {
		return nil, fmt.Errorf("sql open error occurred: %w", err)
	}

	if err := db.Ping(); err != nil {
		return nil, fmt.Errorf("db ping error occurred: %w", err)
	}
	return db, nil
}

// refs: https://github.com/aws/aws-sdk-go/issues/1248#issuecomment-374837105
func registerRDSMysqlCerts(c *http.Client) error {
	rootCertPool := x509.NewCertPool()
	if ok := rootCertPool.AppendCertsFromPEM(amazonRootCA1); !ok {
		return errors.New("couldn't append certs from pem")
	}
	if err := mysql.RegisterTLSConfig("rds", &tls.Config{RootCAs: rootCertPool, InsecureSkipVerify: true}); err != nil {
		return err
	}
	return nil
}

上記はデータベース接続箇所を抜き出しています。

Amazon RDS Proxy のIAM認証では TLS が必要なため証明書を登録する必要があります。 (registerRDSMysqlCerts)
通常の Amazon RDS への IAM認証とは使用する証明書が異なるため注意が必要です。
上記実装では AmazonRootCA1.pem というファイル名で配置して go:embed で変数に埋め込んでいます。(便利!)
※RDS Proxy での TLS/SSL の使用

Go の場合のIAM認証をつかった接続のドキュメント では、dsn は以下で作成されています。

dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?tls=true&allowCleartextPasswords=true",
    dbUser, authToken, dbEndpoint, dbName,
)

github.com/go-sql-driver/mysql@1.4.0 から allowCleartextPasswords=true に加えて allowNativePasswords=true が必要になったようなので以下のように config を作成しました。

cfg := &mysql.Config{
  User: rdsUser,
  Addr: host,
  Net:  "tcp",
  Params: map[string]string{
    "tls": "true",
  },
  DBName:                  databaseName,
  AllowCleartextPasswords: true,
  AllowNativePasswords:    true,
}

cfg.FomartDSN() というメソッドで config に応じた dsn の文字列を返してくれます。
(これを書いたあとに実装を読んでると mysql.NewConfig() で作成すると allowNativePasswords はデフォルト true がセットされて返ってくるようでした。)

パスワードは IAM 認証では BuildAuthToken で一時パスワードを発行してから config にセットしています。

Go でのデータベース接続は以上です。

まとめ (感想)

さて、ここまでやってなのですが、サーバーレスで API というアプリケーションの場合で Amazon RDS 使うなら Public Subnet に配置するほうが使いやすいかもしれません。
というのも運用面ではデータベースへ接続するクライアントアプリケーションが必要になると思いますが、 Private Subnet にデータベースがあるとクライアントからの接続用に踏み台インスタンス立てる必要がでてきます。そうなってくるとサーバレスアプリケーションなのに踏み台インスタンスを管理する必要があり本末転倒?になりそうな気がしました。
既存の Private Subnet に配置された Amazon RDS をつかったアプリケーションがあって、一部 AWS Lambda で処理する、、というときが今回のようなケースになるかなと思いました。

ちなみに意地でも踏み台インスタンス立てない、ってやったのでテーブル作成のクエリとかデータ投入・取得は専用のエンドポイントをつくって AWS Lambda 越しに実行して確認しました。(素直に踏み台つくったほうが検証がスムーズだったと思います…)

以上、AWS Lambda から Amazon RDS Proxy を使う場合の参考になれば幸いです。