AWS Lambda 待望の RDS Proxy を CloudFormation で作ってみる

2020/06/30、RDS Proxy がプレビューを終了し、ついに GA (正式リリース) となったことが[発表](https://aws.amazon.com/jp/blogs/aws/amazon-rds-proxy-now-generally-available/)されました。

これまでアンチパターンと言われていた、AWS Lambda と RDS との接続について解決策のひとつとなることから、サーバーレス界隈では待望の機能であったと思います。

この記事では、CloudFormation でこの RDS Proxy を作ってみたいと思います。が、その前に、RDS Proxy について少し振り返ってみたいと思います。

# RDS Proxy は AWS Lambda にとって何が嬉しいのか

AWS Lambda はいわゆるサーバーレスのサービスです。そのため、常在的に起動しているリソースは基本的にありません。なんらかのイベントが発生したことを契機に、Lambda 上で実装したアプリケーションを動作させるためのリソースがスピンアップするので、例えば HTTP 要求が着信した、特定の時刻になった、というようなイベントごとに、計算リソースが立ち上がります。

そういったサービスの性質上、同時に発生するイベントの数が多くなればなるほど、起動する AWS Lambda の数が増えることになります。500 件同時に HTTP 要求が着信すれば、500 個の AWS Lambda リソースが立ち上がっている可能性もあります。

この性質と、リレーショナルなデータベース製品との相性が、これまではアンチパターンと言われていました。Lambda のリソースは互いに疎なかたちで起動されることから、誤解を恐れずに言えば、立ち上がった Lambda の数だけデータベースとのコネクションが生成されることになり、これは一般的なフレームワークに搭載されている、いわゆる「コネクションプール」の仕組みとは大きく異なっています。

コネクションが多く生成されるほど、データベースサーバー側の消費メモリも増えることになりますが、上記のような Lambda というサービスの性質上、実装のなかでコネクションプールのような仕組みをもたせることはできず、サービスの外にコネクションプールの役割を果たす何らかのレイヤーを置く必要があるということが、容易に想像できるのではないかと思います。

このコネクションプールとしてのレイヤーを提供する役割を果たすのが、今回 GA された RDS Proxy となります。

# RDS Proxy の制約

このように、AWS Lambda としては待望のリリースである RDS Proxy ですが、もちろん銀の弾丸というわけではなく、制約を理解して使っていく必要があります。いくつか、注意すべき制約をピックアップしてみます。

1. Aurora を含む、RDS for MySQL 5.6 and 5.7、もしくは RDS for PostgreSQL 10.11 and 11.5 に対応しており、RDS MySQL 8.0 や RDS Oracle などの DB エンジンでは利用できない
2. 自身で EC2 インスタンス上に構築した RDB と RDS Proxy を接続することはできない
3. 書き込み用のインスタンスのみに対応しており、 読み込み専用のインスタンス (リードレプリカ) に対する RDS Proxy は作れない
4. Aurora Serverless や Aurora Multi-Master と Proxy を接続することはできない

特に 3. のリードレプリカと Proxy が接続できない制約は、データベースへの読み込みワークロードをスケールできないので、現時点では大きいと思います。そのため、読み込みに関しては、Proxy を通さないエンドポイントを使う必要があります。ここは今後の対応に期待ですね。

# CloudFormation で作ってみる

今回は、MySQL 5.7 互換の Aurora クラスターを構築し、RDS Proxy を CloudFormation から作成してみました。Aurora を配置する VPC やサブネットの ID などは、適宜変更したうえでそのまま使えるかと思います。

なお、CDK でも、2020/07/02 にリリースされた [v1.49.0](https://github.com/aws/aws-cdk/releases/tag/v1.49.0) から L2 Construct が追加され、RDS Proxy がかなり簡潔に書けるようになっています。[こちら](https://github.com/civilizeddev/aws-cdk/blob/2c07dfff4c77d60f3563c5883631523a72b1f629/packages/%40aws-cdk/aws-rds/README.md#creating-a-database-proxy)の README に例があるので、こちらも試してみるとよいかもしれません。


```yaml
AWSTemplateFormatVersion: 2010-09-09

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "Database Configuration"
        Parameters:
          - AuroraUserName
          - AuroraDatabaseName
    ParameterLabels:
      AuroraUserName:
        default: "Master User Name"
      AuroraDatabaseName:
        default: "Database Name"

Parameters:
  AuroraUserName:
    Type: String
    AllowedPattern: "[a-zA-Z0-9-]+"
    ConstraintDescription: "must be between 1 to 16 alphanumeric characters."
    Description: "The database admin account user name, between 1 to 16 alphanumeric characters."
    MaxLength: 16
    MinLength: 1
    Default: "root"

  AuroraDatabaseName:
    Type: String
    AllowedPattern: "[a-zA-Z0-9_]+"
    ConstraintDescription: "must be between 1 to 64 alphanumeric characters."
    Description: "The database name, between 1 to 64 alphanumeric characters."
    MaxLength: 64
    MinLength: 1
    Default: "test"

Resources:
  ### Aurora MySQL のパスワードを生成 ###
  AuroraMasterPassword:
    Type: AWS::SecretsManager::Secret
    Properties:
      GenerateSecretString:
        GenerateStringKey: password
        PasswordLength: 16
        SecretStringTemplate: !Sub "{\"username\":\"${AuroraUserName}\"}"
        ExcludeCharacters: "\"@/\\"
 
  AuroraSecretAttachment:
    Type: AWS::SecretsManager::SecretTargetAttachment
    Properties:
      SecretId: !Ref AuroraMasterPassword
      TargetId: !Ref AuroraCluster
      TargetType: AWS::RDS::DBCluster

  ### Aurora MySQL の生成 ###
  AuroraSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: vpc-0123456789abcedfg
      GroupDescription: "Security Group for Aurora"
      SecurityGroupIngress:
        - IpProtocol: tcp
          SourceSecurityGroupId: !Ref RdsProxySecurityGroup
          FromPort: 3306
          ToPort: 3306

  AuroraSubnetGroup:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: "Aurora Subnet Group"
      SubnetIds:
        - subnet-0123456789abcedfg
        - subnet-abcdefg0123456789

  AuroraClusterParameterGroup:
    Type: AWS::RDS::DBClusterParameterGroup
    Properties:
      Description: "Aurora Cluster Parameter Group"
      Family: aurora-mysql5.7
      Parameters:
        time_zone: UTC

  AuroraCluster:
    Type: AWS::RDS::DBCluster
    Properties:
      DatabaseName: !Ref AuroraDatabaseName
      DBClusterParameterGroupName: !Ref AuroraClusterParameterGroup
      Engine: aurora-mysql
      MasterUsername: !Ref AuroraUserName
      MasterUserPassword: !Sub "{{resolve:secretsmanager:${AuroraMasterPassword}:SecretString:password::}}"
      DBSubnetGroupName: !Ref AuroraSubnetGroup
      VpcSecurityGroupIds:
        - !Ref AuroraSecurityGroup

  AuroraParameterGroup:
    Type: AWS::RDS::DBParameterGroup
    Properties:
      Description: "Aurora Parameter Group"
      Family: aurora-mysql5.7

  AuroraInstance1:
    Type: AWS::RDS::DBInstance
    Properties:
      DBClusterIdentifier: !Ref AuroraCluster
      DBInstanceClass: db.t3.small
      DBParameterGroupName: !Ref AuroraParameterGroup
      Engine: aurora-mysql

  ### RDS Proxy の生成 ###
  ##### RDS Proxy が Aurora へ接続するためのセキュリティグループ #####
  RdsProxySecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: vpc-01234567890abcdefg
      GroupDescription: "Security Group for RDS Proxy"

  ##### RDS Proxy が Aurora へ接続するためのパスワードを取得する IAM ロール #####
  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 AuroraSecretAttachment

  RdsProxy:
    Type: AWS::RDS::DBProxy
    Properties:
      DBProxyName: rds-proxy-for-aurora
      EngineFamily: MYSQL
      RoleArn: !GetAtt RdsProxyRole.Arn
      Auth:
        - AuthScheme: SECRETS
          SecretArn: !Ref AuroraSecretAttachment
          IAMAuth: DISABLED
      VpcSecurityGroupIds:
        - !Ref RdsProxySecurityGroup
      VpcSubnetIds:
        - subnet-0123456789abcedfg
        - subnet-abcdefg0123456789
  
  RdsProxyTargetGroup:
    Type: AWS::RDS::DBProxyTargetGroup
    DependsOn:
      - AuroraCluster
      - AuroraInstance1
    Properties:
      DBProxyName: !Ref RdsProxy
      DBClusterIdentifiers:
        - !Ref AuroraCluster
      TargetGroupName: default

Outputs:
  RDSProxyEndpoint:
    Value: !GetAtt RdsProxy.Endpoint
    Export:
      Name: rds-proxy-endpoint

  AuroraMasterPassword:
    Value: !Ref AuroraMasterPassword
    Export:
      Name: aurora-master-password
```

RDS Proxy が Aurora に接続するための接続情報を、認証情報を管理するサービスである Secrets Manager に格納する必要がある点や、RDS Proxy の Target Group を Aurora クラスターおよびインスタンスが立ち上がったあとに作成するよう、DependsOn 句で明示的に指定してあげる必要がある点などが、注意が必要です。

Lambda から利用するときは、最後に Outputs で出力した RDS Proxy の接続エンドポイントを、Lambda の環境変数などにインポートし、認証情報は Secrets Manager から取得するように実装すると良いと思います。

# まとめ

CloudFormation で RDS Proxy を作る実例はあまり見かけなかったので、本記事にて取り上げてみました。次回は、実際に RDS Proxy を通した環境と通さない環境で、Lambda から接続してみてパフォーマンスがどう変わるのか、試してみたいと思います。