【SAM】Lambda（Python）でRDS Proxyを使ってみる【user/pass, IAM認証】

[2020年の7月に発表され大きな話題を読んだRDS Proxy](https://aws.amazon.com/jp/blogs/aws/amazon-rds-proxy-now-generally-available/)ですが、意外とIAM認証やCloudFormationを使った設定方法などの情報が多くなかったので記事にしました。

今回はPython（pymysql）を使用してuser/passでの認証とIAM認証の両方をAWS SAMを用いたCloudFormationで構築しました。

全てを説明すると長くなってしまうので、記事内で省略している部分は以下のリポジトリを参照してください。
https://github.com/yumemi-dendo/sample-aws-rds-proxy

# 環境
SAM CLI：1.15.0
Python：3.8

# user/passでの認証

user/passでの認証は主にIAM認証での要件に合わない場合に使用される接続方法です。
秒間20~200以上の新規コネクション生成が想定される場合はこちらにするべきでしょう。

また通常のRDSへの接続方法と同じなので探せば割と情報がある方法でもあります。
シンプルにSecrets ManagerからDBの認証情報を取得し、それを用いてRDSへ接続します。

## RDS Proxyのセットアップ

ネットワークやRDSのセットアップは[サンプルのリポジトリ](https://github.com/yumemi-dendo/sample-aws-rds-proxy/blob/main/sample-aws-rds-proxy/template.yaml#L91)を参照してください。

RDS ProxyがDBの認証情報を保存しているSecrets Managerにアクセスできるようにする必要があります。
なので`AWS::RDS::DBProxy`の他にIAMロールを作成し、RDS Proxyにアクセスを許可するMySQLユーザーの認証情報が保存されているSecretsManagerの読み取り権限を付与します。
今回はrootユーザーとlambdaユーザーの二種類を許可しています。

最後に定義したRDS Proxyを`AWS::RDS::DBProxyTargetGroup`でRDSに紐付けたら完了です。

この記事では割愛していますがRDS Proxy->RDS間のSecurityGroupのパスを通すのを忘れないようにしましょう。

```yaml:template.yaml
  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 RDSSecretAttachment
                  - !Ref RDSLambdaUserPassword

  RDSProxy:
    Type: AWS::RDS::DBProxy
    Properties:
      DBProxyName: sample-rds-proxy-for-mysql
      EngineFamily: MYSQL
      RoleArn: !GetAtt RDSProxyRole.Arn
      Auth:
        - AuthScheme: SECRETS
          SecretArn: !Ref RDSSecretAttachment
          IAMAuth: DISABLED
        - AuthScheme: SECRETS
          SecretArn: !Ref RDSLambdaUserPassword
          IAMAuth: DISABLED
      VpcSecurityGroupIds:
        - !Ref RDSProxySecurityGroup
      VpcSubnetIds:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2

  RDSProxyTargetGroup:
    Type: AWS::RDS::DBProxyTargetGroup
    DependsOn:
      - RDSInstance
    Properties:
      DBProxyName: !Ref RDSProxy
      DBInstanceIdentifiers:
        - !Ref RDSInstance
      TargetGroupName: default
      ConnectionPoolConfigurationInfo:
        ConnectionBorrowTimeout: 120
        MaxConnectionsPercent: 90
        MaxIdleConnectionsPercent: 10
```

## Lambdaのセットアップ

Secrets ManagerからDBの認証情報を取得し、それを使用してRDS ProxyにアクセスできるLambdaの設定をしていきます。

まずはLambdaをVPC上で実行するように`VpcConfig`と`AWSLambdaVPCAccessExecutionRole`を設定し、`Policies`にSecrets Managerの読み取り権限を付与します。

また今回はVPC上でLambdaを実行するため、VPCからSecrets ManagerへアクセスするためにVPCエンドポイントを用意してSecrityGroupなどのネットワークの設定を行う必要があります。
（こちらも割愛しているので[リポジトリ](https://github.com/yumemi-dendo/sample-aws-rds-proxy/blob/main/sample-aws-rds-proxy/template.yaml#L383-L408)を参照してください。）

あとはRDS ProxyのSecurityGroupにLambdaのSecurityGroupを追加すれば完了です。

```yaml:template.yaml
  RDSProxySecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: !Ref VPC
      GroupDescription: "Security Group for RDS Proxy"
      SecurityGroupIngress:
        - IpProtocol: "tcp"
          FromPort: !Ref RDSDBPort
          ToPort: !Ref RDSDBPort
          SourceSecurityGroupId: !Ref EC2SecurityGroup
        - IpProtocol: "tcp"
          FromPort: !Ref RDSDBPort
          ToPort: !Ref RDSDBPort
          SourceSecurityGroupId: !Ref FunctionSecurityGroup

  #
  # Lambda
  #
  FunctionSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "Lambda Function Security Group"
      VpcId: !Ref VPC

  GetUserWithDBPassFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/get_user_with_db_pass/
      VpcConfig:
        SecurityGroupIds:
          - !Ref FunctionSecurityGroup
        SubnetIds:
          - !Ref PrivateSubnet1
          - !Ref PrivateSubnet2
      Environment:
        Variables:
          RDS_PROXY_ENDPOINT: !GetAtt RDSProxy.Endpoint
          RDS_SECRET_NAME: "sample-rds-lambda-user"
          DB_NAME: "sample_rds_proxy"
      Policies:
        - Version: 2012-10-17
          Statement:
            - Effect: Allow
              Action: secretsmanager:GetSecretValue
              Resource: !Ref RDSLambdaUserPassword
        - AWSLambdaVPCAccessExecutionRole
```

## Lambdaのコード実装

Secrets ManagerからDBの認証情報を取得し、それを使用してRDS Proxy経由でRDSにアクセスするLambdaを実装します。

user/passを使用する場合は普通にRDSにアクセスする場合と同じようにusernameとpasswordを設定するだけです。
hostの向き先をRDS Proxyのエンドポイントにすることを忘れないようにしましょう。
(今回はpymysqlを使用していますが、mysql.connectorでも同様です。)

```python:app.py
import sys
import os
import boto3
from botocore.client import Config
import json
import pymysql
import logging

logger = logging.getLogger(__name__)

RDS_SECRET_NAME = os.environ['RDS_SECRET_NAME']
RDS_PROXY_ENDPOINT = os.environ['RDS_PROXY_ENDPOINT']
DB_NAME = os.environ['DB_NAME']


def lambda_handler(event, context):
    """ SecretsManagerに保存されたDBの認証情報を使用してRDSからデータを取得する。
    """
    try:
        config = Config(connect_timeout=5, retries={'max_attempts': 0})
        client = boto3.client(service_name='secretsmanager',
                              config=config)

        get_secret_value_response = client.get_secret_value(SecretId=RDS_SECRET_NAME)
    except Exception:
        logger.error("不明なエラーが発生しました。")
        raise

    rds_secret = json.loads(get_secret_value_response['SecretString'])


    try:
        conn = pymysql.connect(
            host=RDS_PROXY_ENDPOINT,
            user=rds_secret['username'],
            passwd=rds_secret['password'],
            db=DB_NAME,
            connect_timeout=5,
            cursorclass=pymysql.cursors.DictCursor
        )
    except Exception as e:
        logger.error("不明なエラーが発生しました。")
        logger.error(e)
        sys.exit()

    with conn.cursor() as cur:
        cur.execute('SELECT id, name FROM users;')
        results = cur.fetchall()

    return results
```


# IAM認証

IAM認証ではLambda側でSecrets Managerにアクセスする必要がなくなるので、権限的にもコスト的にも優れています。
しかしIAM認証では秒間あたりの新規コネクション生成数に制限があるので注意する必要があります。
（具体的な制限数に関してはドキュメントに記載がありませんでした。）



## RDS Proxyのセットアップ

RDS ProxyでIAM認証を行う場合、TLS/SSLを有効にして紐付けられているSecrets ManagerのIAM認証を有効にする必要があります。
なので`RequireTLS`をTrueにし、`Auth`の`IAMAuth`を`REQUIRED`にします。

それ以外の設定はuser/passの方と同じです。

```yaml:template.yaml
  RDSProxyWithIam:
    Type: AWS::RDS::DBProxy
    Properties:
      DBProxyName: sample-rds-proxy-for-mysql-with-iam
      EngineFamily: MYSQL
      RequireTLS: True
      RoleArn: !GetAtt RDSProxyRole.Arn
      Auth:
        - AuthScheme: SECRETS
          SecretArn: !Ref RDSLambdaUserPassword
          IAMAuth: REQUIRED
      VpcSecurityGroupIds:
        - !Ref RDSProxySecurityGroup
      VpcSubnetIds:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2
```

## Lambdaのセットアップ

基本的にはuser/passと同じですが、Secrets Managerの代わりにデータベース用のIAMポリシーを付与します。
https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.IAMPolicy.html

Actionは`rds-db:connect`を許可し、リソースにはデータベースのユーザーを指定します。

命名規則は以下のようになっています。
`arn:aws:rds-db:{region}:{account-id}:dbuser:{DbiResourceId}/{db-user-name}`

* `account-id`はDBインスタンスのAWSアカウント番号です。コンソールやRDS, RDS ProxyのArnから確認することができます。
* `DbiResourceId`はDBインスタンスの識別子です。今回はRDS Proxyの値を挿入します。
* `db-user-name`はアクセスを許可するMySQLユーザー名です。複数指定したり`*`で指定することも可能です。

実際に値を入れると以下のようになります。
`arn:aws:rds-db:eu-west-1:414867676510:dbuser:prx-07af81c332474cf27/lambda`



```yaml:template.yaml
  GetUserWithIamFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/get_user_with_iam/
      VpcConfig:
        SecurityGroupIds:
          - !Ref FunctionSecurityGroup
        SubnetIds:
          - !Ref PrivateSubnet1
          - !Ref PrivateSubnet2
      Environment:
        Variables:
          RDS_PROXY_ENDPOINT: !GetAtt RDSProxyWithIam.Endpoint
          RDS_PROXY_PORT: !Ref RDSDBPort
          RDS_USER: "lambda"
          DB_NAME: "sample_rds_proxy"
      Policies:
        - Version: 2012-10-17
          Statement:
            - Effect: Allow
              Action: rds-db:connect
              Resource: "arn:aws:rds-db:eu-west-1:414867676510:dbuser:prx-07af81c332474cf27/lambda"
        - AWSLambdaVPCAccessExecutionRole
```

## Lambdaのコード実装

Secrets Managerから認証情報を取得する代わりに`generate_db_auth_token`を使用します。
`generate_db_auth_token`IAM認証情報を使用してデータベースに接続するために使用する認証トークンを生成します。

またIAM認証をする場合はTLS/SSL接続が必要なので証明書を`app.py`から参照できる場所に保存します。
https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/rds-proxy.html#rds-proxy-security.tls

RDS Proxyを使用する場合は`Amazon root CA 1 trust store`が必要となるので以下のURLから取得します。
https://www.amazontrust.com/repository/AmazonRootCA1.pem

最後にpymysqlでの接続時に認証トークンと証明書を読み込ませれば完了です。

```python:app.py
import sys
import os
import boto3
import pymysql
import logging

logger = logging.getLogger(__name__)

RDS_PROXY_ENDPOINT = os.environ['RDS_PROXY_ENDPOINT']
RDS_PROXY_PORT = os.environ['RDS_PROXY_PORT']
RDS_USER = os.environ['RDS_USER']
REGION = os.environ['AWS_REGION']
DB_NAME = os.environ['DB_NAME']

rds = boto3.client('rds')


def lambda_handler(event, context):
    """ IAM認証でRDS Proxy経由でRDSからデータを取得する。
    https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.Connecting.Python.html

    TLS/SSLに関しては以下のURLを参照
    https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/rds-proxy.html#rds-proxy-security.tls
    """
    password = rds.generate_db_auth_token(
        DBHostname=RDS_PROXY_ENDPOINT,
        Port=RDS_PROXY_PORT,
        DBUsername=RDS_USER,
        Region=REGION
    )

    try:
        conn = pymysql.connect(
            host=RDS_PROXY_ENDPOINT,
            user=RDS_USER,
            passwd=password,
            db=DB_NAME,
            connect_timeout=5,
            cursorclass=pymysql.cursors.DictCursor,
            ssl={'ca': 'AmazonRootCA1.pem'}
        )
    except Exception as e:
        logger.error("不明なエラーが発生しました。")
        logger.error(e)
        sys.exit()

    with conn.cursor() as cur:
        cur.execute('SELECT id, name FROM users;')
        results = cur.fetchall()

    return results
```

ちなみに`mysql.connector`を使用する場合は証明書は不要でそのまま接続することができます。
（なぜかまでは調べきれてないのですが、たぶんライブラリ内に証明書も含まれているのかな…？）
https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.Connecting.Python.html

# 最後に

IAM認証やCloudFormation周りの情報が全然なくて探すのがめちゃくちゃ大変でした。
最終的に公式ドキュメントを隅々まで読んでなんとか動かすことができました。

RDSに直接繋ぐのと比べ多少性能は落ちるものの今まで問題だったLambdaでのコネクションの問題を解決し、料金的にも超大規模なRDSインスタンスタイプを使わない限りはお手軽なので、API Gatewayなど以外にもイベント実行や5~10分間隔といった短いバッチ処理などでも十分使えるレベルにあるのではないかと思います。

ただ実務で使うにはRDS Proxy周りのクォータに関する情報がほとんどないのでAWSのサポートにあれこれ問い合わせる必要がありそうです。

# 参考資料
[サンプルリポジトリ](https://github.com/yumemi-dendo/sample-aws-rds-proxy)

[AWS LambdaでAmazon RDS Proxyを使用する](https://aws.amazon.com/jp/blogs/news/using-amazon-rds-proxy-with-aws-lambda/)
[Amazon RDS Proxy による接続の管理](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/rds-proxy.html#rds-proxy-security.tls)
[AM 認証および AWS SDK for Python (Boto3) を使用した DB インスタンスへの接続](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.Connecting.Python.html)
[IAM データベースアクセス用の IAM ポリシーの作成と使用](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.IAMPolicy.html)
[サーバーレスアプリケーションから Amazon Aurora への IAM ロールベース認証](https://aws.amazon.com/jp/blogs/news/iam-role-based-authentication-to-amazon-aurora-from-serverless-applications/)
[IAM 認証情報を使用して Amazon RDS MySQL DB インスタンスに対する認証をユーザーに許可する方法を教えてください。](https://aws.amazon.com/jp/premiumsupport/knowledge-center/users-connect-rds-iam/)
[【全世界待望】Public AccessのRDSへIAM認証(+ SSL)で安全にLambda Pythonから接続する](https://blog.serverworks.co.jp/tech/2017/04/27/rds-iam-auth-lambda-python/)