AWS Copilot でデプロイした Phoenix から Aurora Serverless に接続する

## はじめに

AWS Copilot は AWS 上にアプリケーションを超簡単にデプロイできるツールです

https://aws.amazon.com/jp/containers/copilot/

Docker　で定義したコンテナさえあれば、 VPC とか ALB とか Route53 とか IAM とか一々考えなくてもよしなに構築してくれます

そして、当然アプリケーションで必要になるデータベースも、いくつかの質問に答えるだけで作ってくれます

データベースの選択肢はサーバレスなものだけです

- [DynamoDB](https://aws.amazon.com/jp/pm/dynamodb/)
- [Aurora Serverless](https://aws.amazon.com/jp/rds/aurora/serverless/)

昔の記事で DynamoDB への接続は説明しましたが、今回は Aurora Serverless (PostgreSQL) の場合について説明します

https://qiita.com/RyoWakabayashi/items/660b87f9deaaa179545d

:::note warn
かなりややこしいことをしており、説明不足も多いです

　ほぼ自分用のメモなのでご容赦ください
:::

## Aurora Serverless の環境構築

Aurora Serverless　はその名の通り、サーバレスで動作する RDB です

https://aws.amazon.com/jp/rds/aurora/serverless/

どういうところがサーバレスかというと、使用状況に応じて勝手にスケーリングしてくれるところです

データベースアクセスがない間は最低限の性能、費用で動作し、アクセスが増えてくるとそれに応じて性能も費用も上がります

これによって、徐々にユーザー数が増えるような場合でも手動でのインスタンスタイプ変更やインスタンス追加が不要になり、コストも最適化されます

### AWS Copilot によるストレージ追加

AWS Copilot でアプリケーション設定作成済（`copilot init` 実行済）の状態から `copilot storage init` を実行すると、ストレージが作成されます

今回は Aurora Serverless を選択しましょう

```bash
$ copilot storage init
Only found one workload, defaulting to: sample

  What type of storage would you like to associate with sample?  [Use arrows to move, type to filter, ? for more help]
    DynamoDB            (NoSQL)
    S3                  (Objects)
  > Aurora Serverless   (SQL)
```

次にクラスタ名を決めます

```bash
...
Storage type: Aurora Serverless

  What would you like to name this Database Cluster? [? for help] (sample-cluster)
```

次にデータベースエンジンを選択します

```bash
...
Storage resource name: sample-cluster

  Which database engine would you like to use?  [Use arrows to move, type to filter]
    MySQL
  > PostgreSQL
```

次にデータベース名を決めます

```bash
...
Database engine: PostgreSQL

  What would you like to name the initial database in your cluster?
```

ここまでの質問に答えると、以下のように表示され、 `copilot/sample/addons/sample-cluster.yml` に CloudFormation の形式で Aurora Serverless V2 の設定情報が作成されています

:::note info
まだデータベース自体は作成されていない状態
:::

```bash
✔ Wrote CloudFormation template at copilot/sample/addons/sample-cluster.yml

Recommended follow-up actions:
  - Update sample's code to leverage the injected environment variable SAMPLECLUSTER_SECRET.
    For example, in JavaScript you can write:
    ```
    const {username, host, dbname, password, port} = JSON.parse(process.env.SAMPLECLUSTER_SECRET)
    ```
  - Run `copilot deploy --name sample` to deploy your storage resources.
```

### Aurora Serverless の設定

AWS Copilot によって作成されたファイルの内容は以下のようなものです

```yaml
Parameters:
  App:
    Type: String
    Description: Your application's name.
  Env:
    Type: String
    Description: The environment name your service, job, or workflow is being deployed to.
  Name:
    Type: String
    Description: The name of the service, job, or workflow being deployed.
  # Customize your Aurora Serverless cluster by setting the default value of the following parameters.
  sampleclusterDBName:
    Type: String
    Description: The name of the initial database to be created in the Aurora Serverless v2 cluster.
    Default: sample_db
    # Cannot have special characters
    # Naming constraints: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Limits.html#RDS_Limits.Constraints
Mappings:
  sampleclusterEnvScalingConfigurationMap:
    All:
      "DBMinCapacity": 0.5 # AllowedValues: from 0.5 through 128
      "DBMaxCapacity": 8   # AllowedValues: from 0.5 through 128

Resources:
  sampleclusterDBSubnetGroup:
    Type: 'AWS::RDS::DBSubnetGroup'
    Properties:
      DBSubnetGroupDescription: Group of Copilot private subnets for Aurora Serverless v2 cluster.
      SubnetIds:
        !Split [',', { 'Fn::ImportValue': !Sub '${App}-${Env}-PrivateSubnets' }]
  sampleclusterSecurityGroup:
    Metadata:
      'aws:copilot:description': 'A security group for your workload to access the Aurora Serverless v2 cluster samplecluster'
    Type: 'AWS::EC2::SecurityGroup'
    Properties:
      GroupDescription: !Sub 'The Security Group for ${Name} to access Aurora Serverless v2 cluster samplecluster.'
      VpcId:
        Fn::ImportValue:
          !Sub '${App}-${Env}-VpcId'
      Tags:
        - Key: Name
          Value: !Sub 'copilot-${App}-${Env}-${Name}-Aurora'
  sampleclusterDBClusterSecurityGroup:
    Metadata:
      'aws:copilot:description': 'A security group for your Aurora Serverless v2 cluster samplecluster'
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: The Security Group for the Aurora Serverless v2 cluster.
      SecurityGroupIngress:
        - ToPort: 5432
          FromPort: 5432
          IpProtocol: tcp
          Description: !Sub 'From the Aurora Security Group of the workload ${Name}.'
          SourceSecurityGroupId: !Ref sampleclusterSecurityGroup
      VpcId:
        Fn::ImportValue:
          !Sub '${App}-${Env}-VpcId'
      Tags:
        - Key: Name
          Value: !Sub 'copilot-${App}-${Env}-${Name}-Aurora'
  sampleclusterAuroraSecret:
    Metadata:
      'aws:copilot:description': 'A Secrets Manager secret to store your DB credentials'
    Type: AWS::SecretsManager::Secret
    Properties:
      Description: !Sub Aurora main user secret for ${AWS::StackName}
      GenerateSecretString:
        SecretStringTemplate: '{"username": "postgres"}'
        GenerateStringKey: "password"
        ExcludePunctuation: true
        IncludeSpace: false
        PasswordLength: 16
  sampleclusterDBClusterParameterGroup:
    Metadata:
      'aws:copilot:description': 'A DB parameter group for engine configuration values'
    Type: 'AWS::RDS::DBClusterParameterGroup'
    Properties:
      Description: !Ref 'AWS::StackName'
      Family: 'aurora-postgresql14'
      Parameters:
        client_encoding: 'UTF8'
  sampleclusterDBCluster:
    Metadata:
      'aws:copilot:description': 'The samplecluster Aurora Serverless v2 database cluster'
    Type: 'AWS::RDS::DBCluster'
    Properties:
      MasterUsername:
        !Join [ "",  [ '{{resolve:secretsmanager:', !Ref sampleclusterAuroraSecret, ":SecretString:username}}" ]]
      MasterUserPassword:
        !Join [ "",  [ '{{resolve:secretsmanager:', !Ref sampleclusterAuroraSecret, ":SecretString:password}}" ]]
      DatabaseName: !Ref sampleclusterDBName
      Engine: 'aurora-postgresql'
      EngineVersion: '14.4'
      DBClusterParameterGroupName: !Ref sampleclusterDBClusterParameterGroup
      DBSubnetGroupName: !Ref sampleclusterDBSubnetGroup
      Port: 5432
      VpcSecurityGroupIds:
        - !Ref sampleclusterDBClusterSecurityGroup
      ServerlessV2ScalingConfiguration:
        # Replace "All" below with "!Ref Env" to set different autoscaling limits per environment.
        MinCapacity: !FindInMap [sampleclusterEnvScalingConfigurationMap, All, DBMinCapacity]
        MaxCapacity: !FindInMap [sampleclusterEnvScalingConfigurationMap, All, DBMaxCapacity]
  sampleclusterDBWriterInstance:
    Metadata:
      'aws:copilot:description': 'The samplecluster Aurora Serverless v2 writer instance'
    Type: 'AWS::RDS::DBInstance'
    Properties:
      DBClusterIdentifier: !Ref sampleclusterDBCluster
      DBInstanceClass: db.serverless
      Engine: 'aurora-postgresql'
      PromotionTier: 1
      AvailabilityZone: !Select
        - 0
        - !GetAZs
          Ref: AWS::Region

  sampleclusterSecretAuroraClusterAttachment:
    Type: AWS::SecretsManager::SecretTargetAttachment
    Properties:
      SecretId: !Ref sampleclusterAuroraSecret
      TargetId: !Ref sampleclusterDBCluster
      TargetType: AWS::RDS::DBCluster
Outputs:
  sampleclusterSecret: # injected as SAMPLECLUSTER_SECRET environment variable by Copilot.
    Description: "The JSON secret that holds the database username and password. Fields are 'host', 'port', 'dbname', 'username', 'password', 'dbClusterIdentifier' and 'engine'"
    Value: !Ref sampleclusterAuroraSecret
  sampleclusterSecurityGroup:
    Description: "The security group to attach to the workload."
    Value: !Ref sampleclusterSecurityGroup
```

ファイル内のコメントにある通りですが、何点かポイントを説明しておきます

#### キャパシティー

Aurora Serverless ではキャパシティー（性能容量）の下限と上限を指定します

データベースの状況に応じて、暇なときは下限まで性能が落ち、忙しくなってくると徐々に性能が上がり、上限で止まります

値の単位は ACU = Aurora Capacity Unit で、 1 ACU あたり 2 GiB ([ギビバイト](https://ja.wikipedia.org/wiki/%E3%82%AE%E3%83%93%E3%83%90%E3%82%A4%E3%83%88))のメモリを使えます

CPU　もメモリの上昇に応じた性能に上がっていきます

```yaml
...
      "DBMinCapacity": 0.5 # AllowedValues: from 0.5 through 128
      "DBMaxCapacity": 8   # AllowedValues: from 0.5 through 128
...
```

V1 は下限を 0 （完全に停止した状態）にできましたが、 V2 では下限の最小値が 0.5 になっています

値はアプリケーションのアクセス量、データ量などに応じて変更しましょう

#### PostgreSQL のバージョン

`Family` と `EngineVersion` で PostgreSQL のバージョンを指定しています

2023/11/10 現在、デフォルトのバージョンは 14.4 です

必要に応じて `aurora-postgresql15` 、 `15.3` などに変更しましょう

```yaml
...
      Family: 'aurora-postgresql14'
...
      Engine: 'aurora-postgresql'
      EngineVersion: '14.4'
...
```

## DB作成とマイグレーションの準備

Phoenix 初回起動時、DB作成とマイグレーションが実行されるようにします

作成しているアプリケーションの名前を `app` とした場合、 `lib/app/releas.ex` を以下の内容で作成します

```elixir
defmodule App.Release do
  @moduledoc false

  @start_apps [:postgrex, :ecto, :ecto_sql]

  @app :app

  def create_and_migrate do
    create_db()
    migrate()
  end

  def create_db do
    # Start postgrex and ecto
    IO.puts("Starting dependencies...")

    # Start apps necessary for executing migrations
    Enum.each(@start_apps, &Application.ensure_all_started/1)

    :ok = ensure_repo_created(App.Repo)

    IO.puts("create_db task done!")
  end

  defp ensure_repo_created(repo) do
    IO.puts("create #{inspect(repo)} database if it doesn't exist")

    case repo.__adapter__.storage_up(repo.config) do
      :ok -> :ok
      {:error, :already_up} -> :ok
      {:error, term} -> {:error, term}
    end
  end

  def migrate do
    load_app()

    for repo <- repos() do
      {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end
  end

  def rollback(repo, version) do
    load_app()
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
  end

  defp repos do
    Application.fetch_env!(@app, :ecto_repos)
  end

  defp load_app do
    Application.load(@app)
  end
end
```

これで `App.Release.create_and_migrate` を実行すれば、以下の動作を実行するようになりました

- DBが存在しなければ作成する
- 必要なマイグレーションだけを実行する

以下のフォーラムの情報を参考に実装しました

https://elixirforum.com/t/how-to-create-database-on-release/28443/2

## コンテナからのデータベース接続

### コンテナに渡される Aurora Serverless の接続情報

この状態でデプロイしたコンテナに `copilot svc exec` で接続します

`printenv DB_SECRET` （環境変数 `DB_SECRET` の値表示）を実行すると、接続情報を確認できます

```json
{"dbClusterIdentifier":"<一意の値>","password":"<DBパスワード>","dbname":"<DB名>","engine":"postgres","port":5432,"host":"<ホスト名>","username":"<DBユーザー名>"}
```

ただし、この JSON 形式の環境変数そのままでは Phoenix の config が理解してくれません

JSON をパースして個別の値に分解した後、 config に渡す必要があります

### jq による環境変数のパース

JSON のパースなので jq の出番です

https://jqlang.github.io/jq/

jq はコマンドラインで JSON をパースするためのツールです

以下のようなコマンドでインストール可能です

- macOS の場合

  ```bash
  brew install jq
  ```

- Ubuntu の場合

  ```bash
  apt-get install jq
  ```

- Alpine Linux の場合

  ```bash
  apk add --no-cache jq
  ```

例えば先ほどの `DB_SECRET` を jq に渡すと、以下のように整形して表示してくれます

```bash
$ printenv DB_SECRET | jq
{
  "dbClusterIdentifier": "<一意の値>",
  "password": "<DBパスワード>",
  "dbname": "<DB名>",
  "engine": "postgres",
  "port": 5432,
  "host": "<ホスト名>",
  "username": "<DBユーザー名>"
}
```

更に jq に `'.<項目名>'` のように指定すると、 JSON 内の特定の項目の値だけを取り出せます

```bash
$ printenv DB_SECRET | jq '.host'
"xxxx.cluster-xxxx.ap-northeast-1.rds.amazonaws.com"
```

ダブルクォーテーションが邪魔な場合、 `-r` を指定します

```bash
$ printenv DB_SECRET | jq -r '.host'
xxxx.cluster-xxxx.ap-northeast-1.rds.amazonaws.com
```

### 接続情報作成用シェルスクリプト

Phoenix の `config/runtime.exs` でデータベース接続設定は以下のようになっています

```elixir
...
  database_url =
    System.get_env("DATABASE_URL") ||
      raise """
      environment variable DATABASE_URL is missing.
      For example: ecto://USER:PASS@HOST/DATABASE
      """
...
```

そのため、環境変数 `DATABASE_URL` に `ecto://USER:PASS@HOST/DATABASE` の形式で接続情報を入れればいいことになります

以下のようなシェルスクリプト `create-profile.sh` を作成します

```bash
#!/bin/sh
DB_HOST="$(printenv DB_SECRET | jq -r '.host')"

DB_PORT="$(printenv DB_SECRET | jq -r '.port')"

DB_NAME="$(printenv DB_SECRET | jq -r '.dbname')"

DB_USER="$(printenv DB_SECRET | jq -r '.username')"

DB_PASSWORD="$(printenv DB_SECRET | jq -r '.password')"

echo "export DATABASE_URL=\"ecto://${DB_USER}:${DB_PASSWORD}:${DB_PORT}@${DB_HOST}/${DB_NAME}\"" > ~/application.profile
```

これを実行すると、 `~/application.profile` に以下のように Ecto からデータベースに接続するための環境変数設定が生成されます

```bash
export DATABASE_URL="ecto://<DBユーザー名>:<DBパスワード>:5432@<ホスト名>/<DB名>"
```

### Phoenix の起動

`mix phx.release` で生成したリリース用バイナリを `/work/bin/app` とし、 ワーキングディレクトリーを `/work` とします

以下のようにコマンドを実行すると、環境変数の設定、マイグレーションを実行した上で Phoenix が起動できます

```bash
# 環境変数設定のための application.profile を作成する
/bin/sh ./create-profile.sh

# 環境変数を読み込む
. application.profile

# DB作成とマイグレーションを実行してから Phoenix を起動する
bin/app eval "Apb.Release.create_and_migrate" && bin/app start
```

このコマンドをシェルスクリプト `/work/runtime-entry.sh` として作ります

ディレクトリー構成は以下のようになります

```
/work
├── bin
|    └── app
├── create-profile.sh
└── runtime-entry.sh
```

以下のようにコンテナ起動時に実行すれば DB 接続できる状態で Phoenix が起動します

```Dockerfile
...
CMD ["/bin/sh", "/work/runtime-entry.sh"]
```

### psql 用の接続情報作成

`copilot svc exec` でコンテナに入ったとき、 psql でもデータベース接続したいと思います

そのための接続情報も `create-profile.sh` で作ってしまいましょう

 `create-profile.sh` に以下の内容を追記します

```bash
...
echo "[app]" > ~/.pg_service.conf \
    && echo "host=${DB_HOST}" >> ~/.pg_service.conf \
    && echo "port=${DB_PORT}" >> ~/.pg_service.conf \
    && echo "dbname=${DB_NAME}" >> ~/.pg_service.conf \
    && echo "user=${DB_USER}" >> ~/.pg_service.conf

echo "${DB_HOST}:${DB_PORT}:${DB_NAME}:${DB_USER}:${DB_PASSWORD}" > ~/.pgpass \
    && chmod 600 ~/.pgpass
```

実行すると以下の２つのファイルが生成されるようになります

- `~/.pg_service.conf`

  `[<サービス名>]` の形式でサービスを登録することで、接続情報をまとめて指定できるようになる

  ```
  [app]
  host=<ホスト名>
  port=５４３２
  dbname=<DB名>
  user=<DBユーザー名>
  ```

- `~/.pgpass`

  毎回のパスワード入力を省略できるようになる

  ```
  <ホスト名>:5432:<DB名>:<DBユーザー名>:<DBパスワード>
  ```

これらのファイルが存在する状態で `copilot svc exec` からコンテナに接続した場合、以下のコマンドだけでデータベースに接続可能になります

```bash
psql service=app
```

最終的なコンテナ起動後のディレクトリー構成は以下のようになります

:::note info
ホームディレクトリーもワーキングディレクトリーも両方 work とします
:::

```
/work
├── bin
|    └── app
├── .pg_service.conf
├── .pgpass
├── application.profile
├── create-profile.sh
└── runtime-entry.sh
```

## まとめ

コンテナ起動時に環境変数や設定ファイルを生成することで、 Phoenix 、 psql から Aurora Serverless に接続できました

基本的に Copilot に任せておけば諸々うまくやってくれるのですが（例えば DynamoDB ならほとんど考える必要なし）、 RDB を使うとなると工夫が必要ですね