RDS Proxyを使ってLambda + RDSの構成を作る

9 min read読了の目安(約8500字

いくつかのエンドポイントと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_idsvpc_subnet_idsについても先ほどのRDS Proxy用のセキュリティグループと、サブネットを2つ指定します。

authにはRDSへの接続情報が記述されたSecrets Managerパラメータのarnを指定します。IAM認証による接続可否も設定できるようです。

aws_db_proxy_default_target_groupaws_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の接続情報を使いたい & 環境別で使い分けたいので、それぞれ環境変数で定義しています。
securityGroupIdssubnetIdsは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();
  }
}

まとめ

サーバレスでパッと作れるといろいろ捗りますね。たまに良くわからないエラーでハマりますが...。