RDS Proxyを使ってLambda + RDSの構成を作る
いくつかのエンドポイントとDBがあれば完結するアプリケーションの場合、わざわざWebサーバーやらなんやらをを用意するのは面倒なので、サーバーレス構成で作れると楽ちんです。
最近まではLambdaからRDSに直接接続するような構成はある程度スケールした場合に同時接続数の問題が起こりえるためよろしくない、とのことでしたが、RDS Proxyによってその問題は解決したとのことです。
参考:なぜAWS LambdaとRDBMSの相性が悪いかを簡単に説明する
仕事でちょうどLambda + RDSの構成で開発する機会があったので、その際に得た知識なんかをまとめておきます。
構成図
VPC、RDSあたりはアプリケーションのバックエンドとみなし、Terraformでリソースを定義していきます。
API Gateway、Lambdaはフロントエンドと捉えて、Serverless Frameworkで作ります。
もともとはTerraformで全部作るつもりだったのですが、TerraformでLambda周辺を作るのは面倒なことと、変更が入りやすいAPI周辺とインフラにあたるDB周辺は切り離してリソースを作ると運用が楽というアドバイスをもらい、TerraformとServerlss Frameworkでそれぞれリソース定義を行うことにしました。
あと、現時点(2021/01/28)ではRDS ProxyはMysql8.0に対応していないようです。そうとは知らず、Terraformに「そんなリソースないんだけど?」と数十回怒られました。
VPC、EC2、RDSを作る
まずはTerraformでバックエンド部分を作っていきます。VPC、EC2、RDSについてはとくに特別なことはない & 微妙にコード量も多いので、コードは省略します。
次にRDS Proxyを定義します。まずはロールを定義します。
data "aws_iam_policy_document" "rds_proxy_assume_role" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["rds.amazonaws.com"]
}
}
}
resource "aws_iam_role" "rds_proxy_role" {
name = "rds-proxy-role"
assume_role_policy = data.aws_iam_policy_document.rds_proxy_assume_role.json
}
resource "aws_iam_role_policy" "rds_proxy_policy" {
name = "rds-proxy-policy"
role = aws_iam_role.rds_proxy_role.id
policy = <<POLICY
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetResourcePolicy",
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret",
"secretsmanager:ListSecretVersionIds"
],
"Resource": "arn:aws:secretsmanager:*:*:*"
}
]
}
POLICY
}
RDS ProxyはRDSに接続する際、その接続情報をSecrets Managerから取得します。なので、ロールに該当する権限を付与しておく必要があります。
また、Secrets Managerに置く接続情報もTerraformで定義しちゃいます。
resource "aws_secretsmanager_secret" "db_info" {
name = "DB_CONNECTION_INFO"
}
resource "aws_secretsmanager_secret_version" "db_info" {
secret_id = aws_secretsmanager_secret.db_auth.id
secret_string = jsonencode({
username: aws_db_instance.example.username
password: aws_db_instance.example.password
engine: aws_db_instance.example.engine
host: aws_db_instance.example.address
port: aws_db_instance.example.port
dbname: aws_db_instance.example.name
dbInstanceIdentifier: aws_db_instance.example.identifier
})
}
接続情報は一緒に生成されるaws_db_instance
リソースのものを参照するようにすると、齟齬が起きないので便利です。
次にセキュリティグループです。
resource "aws_security_group" "lambda" {
name = "lambda-security-group"
description = "lambda security group"
vpc_id = aws_vpc.example.id
ingress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "rds_proxy" {
name = "rds-proxy-security-grout"
description = "rds proxy security group"
vpc_id = aws_vpc.example.id
ingress {
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = ["${aws_security_group.lambda.id}"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "rds" {
name = "rds-security-group"
description = "rds security group"
vpc_id = aws_vpc.example.id
ingress {
from_port = 3306
to_port = 3306
protocol = "tcp"
description = ""
security_groups = ["${aws_security_group.rds_proxy.id}", "${aws_security_group.ec2.id}"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
RDS ProxyはLambdaからしか接続できないように、また、RDSはRDS Proxyと踏み台のEC2からのみ接続できるようにしています。
最後にRDS Proxyです。
resource "aws_db_proxy" "example" {
name = "example"
engine_family = "MYSQL"
role_arn = aws_iam_role.rds_proxy_role.arn
vpc_security_group_ids = [aws_security_group.rds_proxy.id]
vpc_subnet_ids = [aws_subnet.private_a.id, aws_subnet.private_c.id]
auth {
secret_arn = aws_secretsmanager_secret.db_auth.arn
}
}
resource "aws_db_proxy_default_target_group" "example" {
db_proxy_name = aws_db_proxy.example.name
}
resource "aws_db_proxy_target" "example" {
db_instance_identifier = aws_db_instance.example.id
db_proxy_name = aws_db_proxy.example.name
target_group_name = "default"
}
role_arn
には先ほど作ったIAMロールを指定します。vpc_security_group_ids
とvpc_subnet_ids
についても先ほどのRDS Proxy用のセキュリティグループと、サブネットを2つ指定します。
auth
にはRDSへの接続情報が記述されたSecrets Managerパラメータのarnを指定します。IAM認証による接続可否も設定できるようです。
aws_db_proxy_default_target_group
、aws_db_proxy_target
はRDS proxyとRDSを紐づけるための記述です。
これでRDS Proxyの設定は完了です。RDS Proxyは自身のエンドポイントを持っているので、それに対してリクエストすることでProxyを介してRDSに接続できます。エンドポイント情報はServerless Framework側で利用するので、Systems Managerパラメータストアに配置されるようにしておくと良いです。
resource "aws_ssm_parameter" "db_hostname" {
name = "DB_HOSTNAME"
type = "SecureString"
value = aws_db_proxy.example.endpoint
}
同じように、usernameやdbnameなどのパラメータも設定しておくと便利です。
API Gateway、Lambdaを作る
次はServerless FrameworkでAPI GatewayとLambdaを作っていきます。といっても、特別なことはなくて、ほぼ普通に作るのと変わりません。
まず、serverless.ymlを書きます。
service:
name: example
custom:
stage: ${opt:stage, self:provider.stage}
environment:
stg: ${file(./config/stg/environment.yml)}
prod: ${file(./config/prod/environment.yml)}
webpack:
webpackConfig: ./webpack.config.js
includeModules:
forceInclude:
- mysql
plugins:
- serverless-webpack
provider:
name: aws
runtime: nodejs12.x
stage: stg
region: ap-northeast-1
endpointType: REGIONAL
profile: example
apiGateway:
minimumCompressionSize: 1024KB
environment:
AWS_NODEJS_CONNECTION_REUSE_ENABLED: 1
functions:
example:
handler: handler.connectDb
name: ${self:service}-${self:custom.stage}-connect-db
environment:
DB_NAME: ${self:custom.environment.${self:custom.stage}.DB_NAME}
DB_PASSWORD: ${self:custom.environment.${self:custom.stage}.DB_PASSWORD}
DB_USERNAME: ${self:custom.environment.${self:custom.stage}.DB_USERNAME}
DB_HOSTNAME: ${self:custom.environment.${self:custom.stage}.DB_HOSTNAME}
DB_PORT: ${self:custom.environment.${self:custom.stage}.DB_PORT}
vpc:
securityGroupIds:
- ${self:custom.environment.${self:custom.stage}.VPC_SECURITY_GROUP}
subnetIds:
- ${self:custom.environment.${self:custom.stage}.PRIVATE_SUBNET_A_ID}
- ${self:custom.environment.${self:custom.stage}.PRIVATE_SUBNET_C_ID}
events:
- http:
method: get
path: /
cors: true
ハンドラ内でDBの接続情報を使いたい & 環境別で使い分けたいので、それぞれ環境変数で定義しています。
securityGroupIds
やsubnetIds
はTerraform側で定義したセキュリティグループをパラメータストア経由で取得しています。RDS Proxyと同じサブネット内にLambdaを配置する必要があることに注意です。
次に環境変数についてのファイルを作成します。
DB_NAME: ${ssm:DB_NAME_STAGING~true}
DB_PASSWORD: ${ssm:DB_PASSWORD_STAGING~true}
DB_USERNAME: ${ssm:DB_USER_NAME_STAGING~true}
DB_HOSTNAME: ${ssm:DB_HOST_NAME_STAGING~true}
DB_PORT: ${ssm:DB_PORT_STAGING~true}
VPC_SECURITY_GROUP: ${ssm:SECURITY_GROUP_ID_LAMBDA_STAGING~true}
PRIVATE_SUBNET_A_ID: ${ssm:PRIVATE_SUBNET_A_ID_STAGING~true}
PRIVATE_SUBNET_C_ID: ${ssm:PRIVATE_SUBNET_C_ID_STAGING~true}
本番環境用のものも用意しますが、ここでは省略します。
パラメータストアにSecureStringとして保存されている値を利用する場合は、~true
をつけないと暗号化されたままの値を取得することになるので注意です。
最後にLambdaにデプロイするハンドラを記述します。DBと接続チェックするだけの関数です。
import { APIGatewayProxyHandler } from 'aws-lambda';
import { createConnection, Connection } from "mysql2/promise";
export const connectDb: APIGatewayProxyHandler = async (_event, _context) => {
let con: Connection
try {
con = await createConnection({
host: process.env.DB_HOSTNAME,
user: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({
message: "db connection failed",
error
}, null, 2),
}
}
const sql = 'select * from `test_table`';
try {
const [rows] = await con.execute(sql);
return {
statusCode: 200,
body: JSON.stringify({
message: "db connection success",
data: rows
}, null, 2),
}
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({
message: "failed execute sql",
error
}, null, 2),
}
} finally {
con.end();
}
}
まとめ
サーバレスでパッと作れるといろいろ捗りますね。たまに良くわからないエラーでハマりますが...。
Discussion
いろいろ参考になって、ありがとうございます。
細かいところですが、
aws_secretsmanager_secret.db_auth
になっているので、上の secretsmanager を定義している箇所は、 db_info が db_auth にするほうが正しいですかね。