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

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

2021/08/13

クラウド事業部エンジニアの川勝です。

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

構成図

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 配下にテンプレートを分割して配置しています。

1.
2├── template.yaml
3└── cloudformation
4    ├── rds.yaml
5    ├── sam.yaml
6    ├── secrets.yaml
7    └── vpc.yaml

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

まずは親スタックのテンプレート。

template.yaml

1AWSTemplateFormatVersion: '2010-09-09'
2Transform: AWS::Serverless-2016-10-31
3Description: >
4  blog-rds-proxy
5  Parent Template
6
7Parameters:
8  VPCCidr:
9    Type: String
10    Default: 10.1.0.0/16
11  PrivateSubnet1Cidr:
12    Type: String
13    Default: 10.1.0.0/24
14  PrivateSubnet2Cidr:
15    Type: String
16    Default: 10.1.1.0/24
17  RDSMasterUsername:
18    Type: String
19    Default: root
20  RDSDatabaseName:
21    Type: String
22    Default: blog_rds_proxy
23
24Resources:
25  VPCStack:
26    Type: AWS::Serverless::Application
27    Properties:
28      Location: cloudformation/vpc.yaml
29      Parameters:
30        VPCCidr: !Ref VPCCidr
31        PrivateSubnet1Cidr: !Ref PrivateSubnet1Cidr
32        PrivateSubnet2Cidr: !Ref PrivateSubnet2Cidr
33  SecretsStack:
34    Type: AWS::Serverless::Application
35    Properties:
36      Location: cloudformation/secrets.yaml
37      Parameters:
38        RDSMasterUsername: !Ref RDSMasterUsername
39  RDSStack:
40    Type: AWS::Serverless::Application
41    Properties:
42      Location: cloudformation/rds.yaml
43      Parameters:
44        RDSDatabaseName: !Ref RDSDatabaseName
45        RDSMasterUserSecretArn: !GetAtt SecretsStack.Outputs.RDSMasterUserSecretArn
46        PrivateSubnet1Id: !GetAtt VPCStack.Outputs.PrivateSubnet1Id
47        PrivateSubnet2Id: !GetAtt VPCStack.Outputs.PrivateSubnet2Id
48        DBClusterSecurityGroupId: !GetAtt VPCStack.Outputs.DBClusterSecurityGroupId
49        RDSProxySecurityGroupId: !GetAtt VPCStack.Outputs.RDSProxySecurityGroupId
50  SAMStack:
51    Type: AWS::Serverless::Application
52    Properties:
53      Location: cloudformation/sam.yaml
54      Parameters:
55        PrivateSubnet1Id: !GetAtt VPCStack.Outputs.PrivateSubnet1Id
56        PrivateSubnet2Id: !GetAtt VPCStack.Outputs.PrivateSubnet2Id
57        FunctionSecurityGroupId: !GetAtt VPCStack.Outputs.FunctionSecurityGroupId
58        RDSMasterUsername: !Ref RDSMasterUsername
59        RDSDatabaseName: !Ref RDSDatabaseName
60        RDSProxyEndpoint: !GetAtt RDSStack.Outputs.RDSProxyEndpoint
61        RDSProxyArn: !GetAtt RDSStack.Outputs.RDSProxyArn
62
63Outputs:
64  APIEndpoint:
65    Value: !GetAtt SAMStack.Outputs.APIEndpoint
66  RDSProxyEndpoint:
67    Value: !GetAtt RDSStack.Outputs.RDSProxyEndpoint
68  GetSecretValueByCLI:
69    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

1AWSTemplateFormatVersion: '2010-09-09'
2Description: >
3  blog-rds-proxy
4  for VPC template
5
6
7Parameters:
8  VPCCidr:
9    Type: String
10  PrivateSubnet1Cidr:
11    Type: String
12  PrivateSubnet2Cidr:
13    Type: String
14
15Resources:
16  # VPC
17  VPC:
18    Type: AWS::EC2::VPC
19    Properties:
20      CidrBlock: !Ref VPCCidr
21      EnableDnsHostnames: 'true'
22      EnableDnsSupport: 'true'
23      InstanceTenancy: default
24  PrivateSubnet1:
25    Type: AWS::EC2::Subnet
26    Properties:
27      AvailabilityZone: ap-northeast-1a
28      CidrBlock: !Ref PrivateSubnet1Cidr
29      VpcId: !Ref VPC
30  PrivateSubnet2:
31    Type: AWS::EC2::Subnet
32    Properties:
33      AvailabilityZone: ap-northeast-1c
34      CidrBlock: !Ref PrivateSubnet2Cidr
35      VpcId: !Ref VPC
36  PrivateRouteTable:
37    Type: AWS::EC2::RouteTable
38    Properties:
39      VpcId: !Ref VPC
40  PrivateSubnet1RouteTableAssociation:
41    Type: AWS::EC2::SubnetRouteTableAssociation
42    Properties:
43      SubnetId: !Ref PrivateSubnet1
44      RouteTableId: !Ref PrivateRouteTable
45  PrivateSubnet2RouteTableAssociation:
46    Type: AWS::EC2::SubnetRouteTableAssociation
47    Properties:
48      SubnetId: !Ref PrivateSubnet2
49      RouteTableId: !Ref PrivateRouteTable
50
51  # SecurityGroup
52  FunctionSecurityGroup:
53    Type: AWS::EC2::SecurityGroup
54    Properties:
55      GroupDescription: for Lambda Function
56      VpcId: !Ref VPC
57  DBClusterSecurityGroup:
58    Type: AWS::EC2::SecurityGroup
59    Properties:
60      GroupDescription: for DB Cluster
61      VpcId: !Ref VPC
62      SecurityGroupIngress:
63        - IpProtocol: tcp
64          FromPort: 3306
65          ToPort: 3306
66          SourceSecurityGroupId: !Ref RDSProxySecurityGroup
67  RDSProxySecurityGroup:
68    Type: AWS::EC2::SecurityGroup
69    Properties:
70      GroupDescription: for RDS Proxy
71      VpcId: !Ref VPC
72      SecurityGroupIngress:
73        - IpProtocol: tcp
74          FromPort: 3306
75          ToPort: 3306
76          SourceSecurityGroupId: !Ref FunctionSecurityGroup
77
78Outputs:
79  PrivateSubnet1Id:
80    Value: !Ref PrivateSubnet1
81  PrivateSubnet2Id:
82    Value: !Ref PrivateSubnet2
83  FunctionSecurityGroupId:
84    Value: !Ref FunctionSecurityGroup
85  DBClusterSecurityGroupId:
86    Value: !Ref DBClusterSecurityGroup
87  RDSProxySecurityGroupId:
88    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 でのデータベース認証情報の設定

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

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

secrets.yaml

1AWSTemplateFormatVersion: '2010-09-09'
2Description: >
3  blog-rds-proxy
4  for Secrets template
5
6Parameters:
7  RDSMasterUsername:
8    Type: String
9
10Resources:
11  # SecretsManager
12  RDSMasterUserSecret:
13    Type: AWS::SecretsManager::Secret
14    Properties:
15      GenerateSecretString:
16        SecretStringTemplate: !Sub '{"username": "${RDSMasterUsername}"}'
17        GenerateStringKey: password
18        PasswordLength: 16
19        ExcludeCharacters: '"@/\'
20
21Outputs:
22  RDSMasterUserSecretArn:
23    Value: !Ref RDSMasterUserSecret
24  GetSecretValueByCLI:
25    Value: !Sub >
26        aws secretsmanager get-secret-value
27          --secret-id ${RDSMasterUserSecret}
28          --region ${AWS::Region}
29          --query SecretString

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

RDS Stack

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

rds.yaml

1AWSTemplateFormatVersion: '2010-09-09'
2Description: >
3  blog-rds-proxy
4  for RDS template
5
6Parameters:
7  RDSMasterUserSecretArn:
8    Type: String
9  RDSDatabaseName:
10    Type: String
11  PrivateSubnet1Id:
12    Type: String
13  PrivateSubnet2Id:
14    Type: String
15  DBClusterSecurityGroupId:
16    Type: String
17  RDSProxySecurityGroupId:
18    Type: String
19
20Resources:
21  # RDS
22  DBCluster:
23    Type: AWS::RDS::DBCluster
24    Properties:
25      MasterUsername: !Sub '{{resolve:secretsmanager:${RDSMasterUserSecretArn}:SecretString:username}}'
26      MasterUserPassword: !Sub '{{resolve:secretsmanager:${RDSMasterUserSecretArn}:SecretString:password}}'
27      Engine: aurora-mysql
28      EngineVersion: 5.7.mysql_aurora.2.10.0
29      DatabaseName: !Ref RDSDatabaseName
30      DBSubnetGroupName: !Ref DBClusterSubnetGroup
31      VpcSecurityGroupIds:
32        - !Ref DBClusterSecurityGroupId
33  DBInstance1:
34    Type: AWS::RDS::DBInstance
35    Properties:
36      DBClusterIdentifier: !Ref DBCluster
37      DBSubnetGroupName: !Ref DBClusterSubnetGroup
38      Engine: aurora-mysql
39      EngineVersion: 5.7.mysql_aurora.2.10.0
40      DBInstanceClass: db.t3.small
41    DependsOn: DBCluster
42  DBClusterAttachment:
43    Type: AWS::SecretsManager::SecretTargetAttachment
44    DependsOn: DBCluster
45    Properties:
46      SecretId: !Ref RDSMasterUserSecretArn
47      TargetId: !Ref DBCluster
48      TargetType: AWS::RDS::DBCluster
49  DBClusterSubnetGroup:
50    Type: AWS::RDS::DBSubnetGroup
51    Properties:
52      DBSubnetGroupDescription: for DB Cluster
53      SubnetIds:
54        - !Ref PrivateSubnet1Id
55        - !Ref PrivateSubnet2Id
56  # RDS Proxy
57  RDSProxy:
58    Type: AWS::RDS::DBProxy
59    Properties:
60      DBProxyName: blog-rds-proxy-for-db-cluster
61      EngineFamily: MYSQL
62      RequireTLS: True
63      RoleArn: !GetAtt RDSProxyRole.Arn
64      Auth:
65        - AuthScheme: SECRETS
66          SecretArn: !Ref RDSMasterUserSecretArn
67          IAMAuth: REQUIRED
68      VpcSecurityGroupIds:
69        - !Ref RDSProxySecurityGroupId
70      VpcSubnetIds:
71        - !Ref PrivateSubnet1Id
72        - !Ref PrivateSubnet2Id
73  RDSProxyTargetGroup:
74    Type: AWS::RDS::DBProxyTargetGroup
75    DependsOn:
76      - DBCluster
77      - DBInstance1
78    Properties:
79      DBProxyName: !Ref RDSProxy
80      DBClusterIdentifiers:
81        - !Ref DBCluster
82      TargetGroupName: default
83  RDSProxyRole:
84    Type: AWS::IAM::Role
85    Properties:
86      AssumeRolePolicyDocument:
87        Version: 2012-10-17
88        Statement:
89          - Effect: Allow
90            Principal:
91              Service: rds.amazonaws.com
92            Action: sts:AssumeRole
93      Policies:
94        - PolicyName: AllowGetSecretValue
95          PolicyDocument:
96            Version: 2012-10-17
97            Statement:
98              - Effect: Allow
99                Action:
100                  - secretsmanager:GetSecretValue
101                  - secretsmanager:DescribeSecret
102                Resource:
103                  - !Ref DBClusterAttachment
104                  - !Ref RDSMasterUserSecretArn
105
106Outputs:
107  RDSProxyEndpoint:
108    Value: !GetAtt RDSProxy.Endpoint
109  RDSProxyArn:
110    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

1AWSTemplateFormatVersion: '2010-09-09'
2Transform: AWS::Serverless-2016-10-31
3Description: >
4  blog-rds-proxy
5  for SAM Template
6
7Parameters:
8  PrivateSubnet1Id:
9    Type: String
10  PrivateSubnet2Id:
11    Type: String
12  RDSMasterUsername:
13    Type: String
14  FunctionSecurityGroupId:
15    Type: String
16  RDSDatabaseName:
17    Type: String
18  RDSProxyEndpoint:
19    Type: String
20  RDSProxyArn:
21    Type: String
22
23Resources:
24  # LambdaFunction
25  BlogRDSProxyFunction:
26    Type: AWS::Serverless::Function
27    Properties:
28      CodeUri: ../src/
29      Handler: main
30      Runtime: go1.x
31      Timeout: 29
32      VpcConfig:
33        SecurityGroupIds:
34          - !Ref FunctionSecurityGroupId
35        SubnetIds:
36          - !Ref PrivateSubnet1Id
37          - !Ref PrivateSubnet2Id
38      Environment:
39        Variables:
40          RDS_PROXY_ENDPOINT: !Ref RDSProxyEndpoint
41          RDS_USER: !Ref RDSMasterUsername
42          RDS_DATABASE_NAME: !Ref RDSDatabaseName
43      Events:
44        CatchAllApi:
45          Type: HttpApi
46          Properties:
47            Path: '{proxy+}'
48            Method: ANY
49      Policies:
50        - Version: 2012-10-17
51          Statement:
52            - Effect: Allow
53              Action: rds-db:connect
54              Resource: !Sub arn:aws:rds-db:${AWS::Region}:${AWS::AccountId}:dbuser:${RDSProxyArn}/${RDSMasterUsername}
55        - AWSLambdaVPCAccessExecutionRole
56
57Outputs:
58  APIEndpoint:
59    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 で指定しているリソースは ドキュメント では以下のように記載されています。

1arn: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認証について解説します。

1import (
2	"crypto/tls"
3	"crypto/x509"
4	"database/sql"
5	_ "embed"
6	"errors"
7	"fmt"
8	"net/http"
9	"os"
10
11	"github.com/aws/aws-sdk-go/aws/credentials"
12	"github.com/aws/aws-sdk-go/service/rds/rdsutils"
13
14	"github.com/go-sql-driver/mysql"
15)
16
17const (
18	routeInitialize = "/initialize"
19	routeLoad       = "/load"
20)
21
22const (
23	driverName = "mysql"
24	mysqlPort  = 3306
25)
26
27//go:embed AmazonRootCA1.pem
28var amazonRootCA1 []byte
29
30var (
31	awsRegion        = os.Getenv("AWS_REGION")
32	rdsProxyEndpoint = os.Getenv("RDS_PROXY_ENDPOINT")
33	rdsUser          = os.Getenv("RDS_USER")
34	databaseName     = os.Getenv("RDS_DATABASE_NAME")
35)
36
37func dbConnect() (*sql.DB, error) {
38	host := fmt.Sprintf("%s:%d", rdsProxyEndpoint, mysqlPort)
39	cfg := &mysql.Config{
40		User: rdsUser,
41		Addr: host,
42		Net:  "tcp",
43		Params: map[string]string{
44			"tls": "true",
45		},
46		DBName:                  databaseName,
47		AllowCleartextPasswords: true,
48		AllowNativePasswords:    true,
49	}
50
51	paswd, err := rdsutils.BuildAuthToken(
52		cfg.Addr,
53		awsRegion,
54		cfg.User,
55		credentials.NewEnvCredentials(),
56	)
57	if err != nil {
58		return nil, fmt.Errorf("rds build auth token error occurred: %w", err)
59	}
60	cfg.Passwd = paswd
61
62	err = registerRDSMysqlCerts(http.DefaultClient)
63	if err != nil {
64		return nil, fmt.Errorf("register rds mysql certs error occurred: %w", err)
65	}
66
67	fmt.Printf("dsn: %s\n", cfg.FormatDSN())
68	db, err := sql.Open(driverName, cfg.FormatDSN())
69	if err != nil {
70		return nil, fmt.Errorf("sql open error occurred: %w", err)
71	}
72
73	if err := db.Ping(); err != nil {
74		return nil, fmt.Errorf("db ping error occurred: %w", err)
75	}
76	return db, nil
77}
78
79// refs: https://github.com/aws/aws-sdk-go/issues/1248#issuecomment-374837105
80func registerRDSMysqlCerts(c *http.Client) error {
81	rootCertPool := x509.NewCertPool()
82	if ok := rootCertPool.AppendCertsFromPEM(amazonRootCA1); !ok {
83		return errors.New("couldn't append certs from pem")
84	}
85	if err := mysql.RegisterTLSConfig("rds", &tls.Config{RootCAs: rootCertPool, InsecureSkipVerify: true}); err != nil {
86		return err
87	}
88	return nil
89}

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

Amazon RDS Proxy のIAM認証では TLS が必要なため証明書を登録する必要があります。 (registerRDSMysqlCerts)

通常の Amazon RDS への IAM認証とは使用する証明書が異なるため注意が必要です。

上記実装では AmazonRootCA1.pem というファイル名で配置して go:embed で変数に埋め込んでいます。(便利!)

※RDS Proxy での TLS/SSL の使用

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

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

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

1cfg := &mysql.Config{
2  User: rdsUser,
3  Addr: host,
4  Net:  "tcp",
5  Params: map[string]string{
6    "tls": "true",
7  },
8  DBName:                  databaseName,
9  AllowCleartextPasswords: true,
10  AllowNativePasswords:    true,
11}

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