はじめに
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 のバージョン
Family
と EngineVersion
で PostgreSQL のバージョンを指定しています
2023/11/10 現在、デフォルトのバージョンは 14.4 です
必要に応じて aurora-postgresql15
、 15.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_URL
に ecto://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 を使うとなると工夫が必要ですね