[AWS SAM] Amazon RDS Proxy をつかって Amazon Aurora MySQL に接続するサーバーレスアプリケーションを構築する
クラウド事業部エンジニアの川勝です。
今回は Amazon RDS Proxy をつかって Amazon Aurora MySQL に接続するサーバーレスアプリケーションを構築するサンプルを作成したのでその方法を解説したいと思います。
概要
- 構成図
- Amazon CloudFormation スタック
- VPC Stack
- Secrets Stack
- RDS Stack
- SAM Stack
- AWS Lambda 実装(Go)
- まとめ(感想)
構成図

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 を使う場合の参考になれば幸いです。