17
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ElixirAdvent Calendar 2023

Day 7

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

Last updated at Posted at 2023-11-10

はじめに

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

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

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

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

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

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

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

Aurora Serverless の環境構築

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

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

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

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

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

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

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

$ 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)

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

...
Storage type: Aurora Serverless

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

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

...
Storage resource name: sample-cluster

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

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

...
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 の設定情報が作成されています

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

✔ 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 によって作成されたファイルの内容は以下のようなものです

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 (ギビバイト)のメモリを使えます

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

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

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

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

PostgreSQL のバージョン

FamilyEngineVersion で PostgreSQL のバージョンを指定しています

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

必要に応じて aurora-postgresql1515.3 などに変更しましょう

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

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

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

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

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が存在しなければ作成する
  • 必要なマイグレーションだけを実行する

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

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

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

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

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

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

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

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

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

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

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

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

  • macOS の場合

    brew install jq
    
  • Ubuntu の場合

    apt-get install jq
    
  • Alpine Linux の場合

    apk add --no-cache jq
    

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

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

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

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

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

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

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

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

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

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

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

#!/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 からデータベースに接続するための環境変数設定が生成されます

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

Phoenix の起動

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

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

# 環境変数設定のための 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 が起動します

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

psql 用の接続情報作成

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

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

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

...
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

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

  • ~/.pg_service.conf

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

    [app]
    host=<ホスト名>
    port=5432
    dbname=<DB名>
    user=<DBユーザー名>
    
  • ~/.pgpass

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

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

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

psql service=app

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

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

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

まとめ

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

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

17
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?