CloudFormationでVPCを作成してLambdaをデプロイしAurora Serverlessを使う

awsdatabasemysqlgolang

Aurora ServerlessはオートスケールするAuroraで、 使ったAurora Capacity Unit (ACU)によって料金が発生するため、 使用頻度が少なかったり変動するアプリケーションにおいて安くRDBを使うことができる。 インスタンスを立てると最低でも月3000円くらいかかるが、Serverlessだとほとんどストレージ分から運用することができて趣味でも使いやすい。 ただしLambdaと同様に常に同等のリソースを使っている状態だとインスタンスと比べて割高になる。

今回はLambdaで使う。 Serverlessと名前には付いているが用途としてはLambdaに限らず、 むしろコンテナの数が容易に増え得るLambdaは同時接続数が問題になるRDBと一般に相性が良くない。 現在Betaの、コネクションを張らずにHTTPSでクエリを投げられるData APIはこの問題を解消すると思われるが、トランザクションが張れなかったり、レスポンスサイズに制限があるようだ。今回はコンソール上から初期クエリを流すためにData APIを有効にしている。

他の選択肢として、DynamoDBは現状最有力で最近トランザクションもサポートされたがSQLのように複雑なクエリは投げられない。 Athenaはクエリは投げられるがそこそこ時間がかかるし、INSERT/UPDATEはできずクエリごとに料金が発生する。

Serverless Frameworkを使ってリソースを作成しデプロイする。リポジトリはここ

Serverless FrameworkでLambdaをデプロイする - sambaiz-net

VPCの作成

Aurora Serverlessの制限の一つとしてVPC内からしか接続しかできないというものがある。ということでVPCから作成していく。以前Terraformで作ったのと同じリソースをCloudFormationで作る。

TerraformでVPCを管理するmoduleを作る - sambaiz-net

LambdaをVPC内で動かすとコンテナ起動時にENIも作成するため立ち上がりの際時間がかかる。必要なら定期的に呼び出して削除されないようにする。 また、今回はテストのため/24でVPCを切っているが、小さいとENIのIPアドレスが枯渇する可能性がある。

TestVPC:
  Type: AWS::EC2::VPC
  Properties:
    CidrBlock: 172.32.0.0/24
    Tags:
      - Key: Name
        Value: test-vpc

Aurora Serverlessのために少なくとも2つのサブネットが必要。

TestPublicSubnet:
  Type: AWS::EC2::Subnet
  Properties:
    VpcId: !Ref TestVPC
    CidrBlock: 172.32.0.0/25
    AvailabilityZone: us-east-1d
    Tags:
      - Key: Name
        Value: test-public-subnet1
TestPrivateSubnet1:
  Type: AWS::EC2::Subnet
  Properties:
    VpcId: !Ref TestVPC
    CidrBlock: 172.32.0.128/26
    AvailabilityZone: us-east-1a
    Tags:
      - Key: Name
        Value: test-private-subnet1
TestPrivateSubnet2:
  Type: AWS::EC2::Subnet
  Properties:
    VpcId: !Ref TestVPC
    CidrBlock: 172.32.0.192/26
    AvailabilityZone: us-east-1b
    Tags:
      - Key: Name
        Value: test-private-subnet2
TestInternetGateway:
  Type: AWS::EC2::InternetGateway
  Properties:
    Tags:
      - Key: Name
        Value: test-igw
VPCGatewayAttachment:
  Type: AWS::EC2::VPCGatewayAttachment
  Properties:
    VpcId: !Ref TestVPC
    InternetGatewayId: !Ref TestInternetGateway
TestPublicRouteTable:
  Type: AWS::EC2::RouteTable
  Properties:
    VpcId: !Ref TestVPC
    Tags:
      - Key: Name
        Value: test-public-route-table
TestPublicRoute:
  Type: AWS::EC2::Route
  Properties:
    RouteTableId: !Ref TestPublicRouteTable
    DestinationCidrBlock: 0.0.0.0/0
    GatewayId: !Ref TestInternetGateway
TestPublicSubnetRouteTableAssociation:
  Type: AWS::EC2::SubnetRouteTableAssociation
  Properties:
    SubnetId: !Ref TestPublicSubnet
    RouteTableId: !Ref TestPublicRouteTable
TestEIP:
  Type: AWS::EC2::EIP
  Properties:
    Domain: vpc
TestNatGateway:
  Type: AWS::EC2::NatGateway
  Properties:
    AllocationId: !GetAtt TestEIP.AllocationId
    SubnetId: !Ref TestPublicSubnet
    Tags:
      - Key: Name
        Value: TestNatGateway
TestPrivateRouteTable:
  Type: AWS::EC2::RouteTable
  Properties:
    VpcId: !Ref TestVPC
    Tags:
      - Key: Name
        Value: test-private-route-table
TestPrivateRoute:
  Type: AWS::EC2::Route
  Properties:
    RouteTableId: !Ref TestPrivateRouteTable
    DestinationCidrBlock: 0.0.0.0/0
    NatGatewayId: !Ref TestNatGateway
TestPrivateSubnet1RouteTableAssociation:
  Type: AWS::EC2::SubnetRouteTableAssociation
  Properties:
    SubnetId: !Ref TestPrivateSubnet1
    RouteTableId: !Ref TestPrivateRouteTable
TestPrivateSubnet2RouteTableAssociation:
  Type: AWS::EC2::SubnetRouteTableAssociation
  Properties:
    SubnetId: !Ref TestPrivateSubnet2
    RouteTableId: !Ref TestPrivateRouteTable
TestSecurityGroup:
  Type: AWS::EC2::SecurityGroup
  Properties:
    VpcId: !Ref TestVPC
    GroupName: test-sg
    GroupDescription: test security group
    Tags:
      - Key: Name
        Value: test-sg
TestSecurityGroupIngress:
  Type: AWS::EC2::SecurityGroupIngress
  Properties:
    GroupId: !Ref TestSecurityGroup
    SourceSecurityGroupId: !Ref TestSecurityGroup
    IpProtocol: -1
    FromPort: -1
    ToPort: -1

Aurora Serverlessの作成

認証情報はSecretsManagerに置いてパスワードも自動生成させる。

AWS Systems Manager (SSM)のParameter Storeに認証情報を置き参照する - sambaiz-net

ACUが0の状態で確認しやすくするためSecondsUntilAutoPauseを最短の300にしている。

AuroraSecret:
  Type: AWS::SecretsManager::Secret
  Properties:
    GenerateSecretString:
      SecretStringTemplate: '{"username": "test"}'
      GenerateStringKey: 'password'
      PasswordLength: 16
      ExcludeCharacters: '"@/\'
 SecretRDSInstanceAttachment:
  Type: AWS::SecretsManager::SecretTargetAttachment
  Properties:
    SecretId: !Ref AuroraSecret
    TargetId: !Ref AuroraServerless
    TargetType: AWS::RDS::DBCluster
TestSubnetGroup:
  Type: "AWS::RDS::DBSubnetGroup"
  Properties: 
    DBSubnetGroupDescription: test subnet group
    SubnetIds:
      - !Ref TestPrivateSubnet1
      - !Ref TestPrivateSubnet2
    Tags:
      - Key: Name
        Value: test-subnet-group
AuroraServerless:
  Type: AWS::RDS::DBCluster
  Properties:
    Engine: aurora
    EngineMode: serverless
    DeletionProtection: true
    Port: 3306
    DatabaseName: test
    MasterUsername: !Join ['', ['{{resolve:secretsmanager:', !Ref AuroraSecret, ':SecretString:username}}' ]]
    MasterUserPassword: !Join ['', ['{{resolve:secretsmanager:', !Ref AuroraSecret, ':SecretString:password}}' ]]
    DBSubnetGroupName: !Ref TestSubnetGroup
    VpcSecurityGroupIds:
      - !Ref TestSecurityGroup
    ScalingConfiguration:
      AutoPause: true
      MaxCapacity: 2
      SecondsUntilAutoPause: 300

環境変数とVPCまわりの設定はこんな感じ。 ServerlessFrameworkではデプロイ時にSecretを解決することもできるが、ローテーションすることも考えて実行時に取りに行っている。

environment:
  TZ: Asia/Tokyo
  DB_SECRET: !Ref AuroraSecret
vpc:
  securityGroupIds:
    - !Ref TestSecurityGroup
  subnetIds:
    - !Ref TestPrivateSubnet1
    - !Ref TestPrivateSubnet2
iamRoleStatements:
  - Effect: "Allow"
    Action:
      - "ec2:CreateNetworkInterface"
      - "ec2:DescribeNetworkInterfaces"
      - "ec2:DeleteNetworkInterface"
    Resource:
      - "*"

アプリケーション

Secretsにはusernameやpasswordだけではなくhostやport、dbNameまで含まれているのでこれだけで接続情報が作れる。

type dbConfig struct {
  Password string `json:"password"`
  DbName   string `json:"dbname"`
  Engine   string `json:"engine"`
  Port     int    `json:"port"`
  Host     string `json:"host"`
  UserName string `json:"username"`
}

func dbSrc() (string, error) {
  secret, err := secret.GetSecretString(os.Getenv("DB_SECRET"))
  if err != nil {
    return "", err
  }
  var config dbConfig
  if err := json.Unmarshal([]byte(secret), &config); err != nil {
    return "", err
  }
  return fmt.Sprintf(
    "%s:%[email protected](%s:%d)/%s?parseTime=true",
    config.UserName,
    config.Password,
    config.Host,
    config.Port,
    config.DbName,
  ), nil
}

トランザクションを張ってINSERTしSELECTする。

...
tx, err := db.Begin()
if err != nil {
  return Response{StatusCode: http.StatusInternalServerError}, err
}

_, err = tx.Exec("INSERT INTO b (a_id) VALUES (1)")
if err != nil {
  tx.Rollback()
  return Response{StatusCode: http.StatusInternalServerError}, err
}

if err := tx.Commit(); err != nil {
  return Response{StatusCode: http.StatusInternalServerError}, err
}

rows, err := db.Query("SELECT b.id FROM a JOIN b ON (a.id = b.a_id)")
if err != nil {
  return Response{StatusCode: http.StatusInternalServerError}, err
}
...

初期クエリの実行

デプロイ後、DBの設定からData APIを有効にしてコンソールのQuery Editorから初期クエリを流す。

Data APIを有効にする

CREATE TABLE a (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(128) NOT NULL
);

CREATE TABLE b (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    a_id BIGINT UNSIGNED NOT NULL,
    FOREIGN KEY (a_id) REFERENCES a (id)
);

INSERT INTO a (id, name) VALUES (1, '1');

レスポンスタイム

ENIなし、ACUが0の状態で呼んだところAPI Gatewayの上限30秒でタイムアウトしてしまった。 ENIがある状態でも25秒前後ほどかかって、いずれも立ち上がっている状態では800ms前後。 API Gatewayの後ろでAutoPauseなAurora Serverlessを使う場合は、時々レスポンスに時間がかかることを許容した上で、 タイムアウトしたときにクライアントでリトライさせるなりする必要がある。 Auroraへのアクセスはバッチで行い、API呼び出し時は別のデータソースを参照するといった使い方が良いかもしれない。