開発チームがお届けするブログリレーです!既に公開されている記事もありますので、こちらから他のメンバーの投稿もぜひチェックしてみてください!

はじめに

Amplify Gen 2が一般提供されて1年が経ちました。
一般提供したての時に、プロジェクトにAmplify Gen 2を導入しまして、
最初はバグや、ここ使えないのか…というところでかなり苦闘しましたが、
今はだいぶ解消されて普及しているところを目にし、我が子のような気持ちで、どんどん成長しているな…と熱い目線を送ってます!

Amplifyは、AppSync+DynamoDBを簡単に作成できる雛形があり、簡単にバックエンドの構築を実現、
かつ、フロントエンドからの接続も容易に実現できる、アプリケーション構築のテンプレートツールになります。

ただ、
お客様の要件によっては、DynamoDBではなくてRDBMSを使いたい。
RDBMSをプライベートに置いて、外部ネットワークから直接アクセスできないようにしてほしい。
ということがあるのではないでしょうか?

\ Amplify Gen 2、割となんでもできます! /
カスタマイズ次第でバックエンドのアーキテクチャ、柔軟に変更ができます!

ということで、まだAmplify Gen 2が公開されてから1ヶ月ほどしか経っていない中、
Amplify Gen 2を使いはじめ、
お客様の要件的に、データの集計表示等複雑な検索が必要になるからRDBMSが適していそうな要件だということで、
チームメンバーでなんとかプライベートDBに接続、運用できないか挑戦した苦闘のエピソードをご紹介します!

今回の構成図

構成はこちらです。

この中で簡単に役割分担を説明すると、、

Amplify Gen 2が構築する部分

  • Lambda:DB Migrationを除く部分(2ルートあるのは、Data Resource と Functions を適宜使い分けているため)
  •  AppSync
  •  Cognito

Amplifyが構築しないようにしている部分

Amplifyでの旨みとしてバックエンドのSandboxが簡単に構築できるというところがありますが、
Sandboxで構築をしてしまうとコスト面的にあまりよくないかなと思い、分ける方向としました。

  • VPC
  •  Aurora Serverless v2
  •  RDS proxyを立てています(Data APIを使用した場合はproxy不要ですが、使用せずにDB接続する場合はproxyを経由します)
  •  踏み台サーバー用EC2:ここからしかDBへ外部接続できないように構成(当時EC2のほうが非常に馴染みがあってEC2を立てました。)
  •  Secrets Manager:DB情報格納用
  •  Lambda:DB Migrationロジック管理

プライベートDB苦闘エピソードはじまり

エピ1. Amplify Gen 22公式ドキュメントがパブリックサブネットに置いているRDBを前提とした説明のみだった

If your RDS database exists within a VPC, it must be configured to be Publicly accessible.
This does not mean the instance needs to be accessible from all IP addresses on the Internet,
but this flag is required to allow your local machine to connect via an Inbound Rule rather than being inside your VPC or connected to the VPC via VPN.

お客様のDBをパブリックになんか置けないよね…と頭抱えました。
ですが、チーム内での探究心・好奇心が勝り
なんとかプライベートDBに接続できないか挑戦しました!!

エピ2. とりあえずCDKでAmplify Gen 2で構築しない部分を構築

ここは問題なくすんなりと構築できました!

エピ3. Amplify Gen 2の旨みである Generate Schema ができない

◾️ 問題発生!!
Generate Schemaを実施すると、AppSync構築で使用するschema modelを既存のDBを読み取って自動生成できる!ということでしたので、
公式ドキュメント通りに試してみました。
※Generate Schemaとは:公式ドュメント
最初はとりあえずDB接続情報をDBのclusterホストで指定してみました。

npx ampx sandbox secret set SQL_CONNECTION_STRING // このコマンド叩いて
> mysql://user:password@hostname(clusterのホスト入れてみた):port/db-name

そしてgenerate Schamaを実行します。

packages/backend❯ npx ampx generate schema-from-database --connection-uri-secret SQL_CONNECTION_STRING --out amplify/data/schema.sql.ts
✖ Failed to fetch the database schema.

そうですよね。プライベートのDBに接続できるわけがないです。。。
さてどうしたものか。
これがないと、Amplifyでバックエンド構築を実行できません。

◾️ 解決
踏み台サーバーの中にAmplifyをインストールしてその中でschema model作成する?
やっぱりパブリックにDB置いてセキュリティグループでアクセス制御するしか…?
と悩んだ時に思いついたのが「ポートフォワード」
公式

こんな感じを夢見て
多段ポートフォワードを実施して、Generate Schemaしてみました。

npx ampx sandbox secret set SQL_CONNECTION_STRING
> mysql://user:password@hostname(ローカルだから127.0.0.1):13306/db-name

npx ampx generate schema-from-database --connection-uri-secret SQL_CONNECTION_STRING --out amplify/data/schema.sql.ts
✖ Failed to fetch the database schema.

思ってたのと違う…

ここから私はインフラ知識不足を発揮して調べてもわからずでしたが、
私よりも先にポートフォワードしてプライベートDBをGenerate Schemaをしている方法を探っている方々が社内にいて、助けていただきました…!(ありがとうございます!)
正解は、「host名の解決」 が必要でした。

Generate Schemaをする際に、Amplifyに設定されたSQL_CONNECTION_STRINGを見て、一致するEC2インスタンスやプロキシを検索かけ、
そこから紐づくVPCの情報を自動的に取得して、アクセス・schema modelの生成を実施しているみたいです。
後ほど構築するAPIも、AmplifyがそのDB情報からどこにLambdaを構築するのがベストか判断しているみたいです。便利ですね!

開発環境にCodespacesを使用、
OSが Debian GNU/Linux 12 ということで、下記のように記載。

/etc/hosts

127.0.0.1 {RDS_HOST}

再度Generate Schema試してみました。

npx ampx sandbox secret set SQL_CONNECTION_STRING
> mysql://user:password@hostname(ローカルだから127.0.0.1):13306/db-name

npx ampx generate schema-from-database --connection-uri-secret SQL_CONNECTION_STRING --out amplify/data/schema.sql.ts
✔ Successfully fetched the database schema.

success…!!

ちゃんと欲しかった、schema.sql.tsが作成されました!
今はこのスクリプトを元に、SQL_CONNECTION_STRINGの設定から、Generate Schemaまで一気通貫させています。

作成したスクリプト

#!/bin/sh

set -eu

ENV_NAME={適当な環境名}
PORTFOWARD_PORT=13306
SECRETSMANAGER_ID_FOR_RDS={ DBのシークレット
port
password
dbname
username
engine
}
SECRETSMANAGER_ID_FOR_BASTIONSERVER={ 踏み台EC2のシークレット
publicIp:踏み台EC2のパブリックIP
sshkey:踏み台EC2のsshkey名
instanceId:踏み台EC2インスタンスID
securityGroupId:踏み台EC2のセキュリティグループID
DBProxyHost:DB proxyホスト
}

# get secret value from secrets manager
BASTION_INSTANCE_DATA=$(aws secretsmanager get-secret-value --secret-id ${SECRETSMANAGER_ID_FOR_BASTIONSERVER} --query "SecretString" | jq -r .)
BASTION_IP=$(echo $BASTION_INSTANCE_DATA | jq -r .publicIp)
BASTION_SSHKEY_PATH=$(echo "/workspace/$(echo $BASTION_INSTANCE_DATA | jq -r .sshkey).pem")
BASTION_INSTANCE_ID=$(echo $BASTION_INSTANCE_DATA | jq -r .instanceId)
BASTION_SG=$(echo $BASTION_INSTANCE_DATA | jq -r .securityGroupId)

RDS_PROXY_HOST=$(echo $BASTION_INSTANCE_DATA | jq -r .DBProxyHost)

DB_DATA=$(aws secretsmanager get-secret-value --secret-id ${SECRETSMANAGER_ID_FOR_RDS} --query "SecretString" | jq -r .)
RDS_PORT=$(echo $DB_DATA | jq -r .port)
RDS_PASSWORD=$(echo $DB_DATA | jq -r .password)
RDS_DB_NAME=$(echo $DB_DATA | jq -r .dbname)
RDS_USER_NAME=$(echo $DB_DATA | jq -r .username)
RDS_DB_TYPE=$(echo $DB_DATA | jq -r .engine)

# constant
RDS_CONNECTION_STRING=${RDS_DB_TYPE}://${RDS_USER_NAME}:${RDS_PASSWORD}@${RDS_PROXY_HOST}:${PORTFOWARD_PORT}/${RDS_DB_NAME}
echo $RDS_CONNECTION_STRING
AWS_PROFILE=amplify # aws profile name

# checkparameter
if [ ! -e ${BASTION_SSHKEY_PATH} ]; then
echo "【ERROR】 踏み台サーバーのSSHKeyを設置してください"
exit 1
elif [ ! $(stat ${BASTION_SSHKEY_PATH} -c '%a') -eq 600 ]; then
echo "【ERROR】 踏み台サーバーのSSHKeyの権限を600にしてください"
exit 1
fi

if [ ! -e ./session-manager-plugin.deb ]; then
echo "【START】 SessionManager Plugin Install"
curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" -o "session-manager-plugin.deb" && sudo dpkg -i session-manager-plugin.deb
fi

# set try catch
function error {
echo "!!!!ERROR OCCURED!!!!"
finish
exit 1
}

function finish {
echo "【START】 POST PROCESSING"
set +eu

kill -9 $(lsof -i:${PORTFOWARD_PORT} -t)
echo ">>> STOP PORT FORWARDING"

ssh -o "StrictHostKeyChecking no" -i ${BASTION_SSHKEY_PATH} ubuntu@${BASTION_IP} "lsof -i:${PORTFOWARD_PORT} -t | xargs kill -9"
echo ">>> STOP BASTION SOCATE RELAY"

export CIDR=$(curl https://checkip.amazonaws.com) && aws ec2 revoke-security-group-ingress --group-id ${BASTION_SG} --protocol tcp --port 22 --cidr ${CIDR}/32
echo ">>> CLEAR SECURITY GROUP INGRESS"

echo "【END】 ALL PROCESS"
exit 0
}

trap error ERR

# start process
echo "【START】 PORT FORWARDING FROM RDS TO BASTION"

export CIDR=$(curl https://checkip.amazonaws.com) && aws ec2 authorize-security-group-ingress --group-id ${BASTION_SG} --ip-permissions IpProtocol=tcp,FromPort=22,ToPort=22,IpRanges='[{CidrIp='${CIDR}'/32,Description='${GITHUB_USER}'}]'

ssh -o "StrictHostKeyChecking no" -i ${BASTION_SSHKEY_PATH} ubuntu@${BASTION_IP} nohup socat TCP-LISTEN:${PORTFOWARD_PORT},fork TCP:${RDS_PROXY_HOST}:${RDS_PORT} >> ~/socate-log.txt &

nohup aws ssm start-session --target ${BASTION_INSTANCE_ID} --document-name AWS-StartPortForwardingSession --parameters '{"portNumber":["'${PORTFOWARD_PORT}'"],"localPortNumber":["'${PORTFOWARD_PORT}'"]}' >> /workspace/port-forwading.log &

echo "127.0.0.1 "${RDS_PROXY_HOST} | sudo tee -a /etc/hosts > /dev/null

cd ./packages/backend

echo ${RDS_CONNECTION_STRING} | npx ampx sandbox secret set SQL_CONNECTION_STRING --profile ${AWS_PROFILE}

npx ampx sandbox secret get SQL_CONNECTION_STRING --profile ${AWS_PROFILE}

echo "【START】 CREATE SCHEMA"
if [ ${ENV_NAME} = 'STG' ]; then
echo "create schema for STG"
export EXP_FILE_NAME='/workspace/packages/backend/amplify/data/schema.sql.stg.ts'
else
echo "create schema for DEV"
export EXP_FILE_NAME='/workspace/packages/backend/amplify/data/schema.sql.ts'
fi

echo $EXP_FILE_NAME
npx ampx generate schema-from-database --connection-uri-secret SQL_CONNECTION_STRING --profile ${AWS_PROFILE} --out ${EXP_FILE_NAME}

finish

exit 0

エピ4. 立てたAppSyncからデータが返ってこない

◾️ 問題発生!
schema.sql.tsも作成できたし、Amplify バックエンドのソースも準備できて、
ついにSandboxを立てるところまできました、ということで下記のコマンドを実行。

npx ampx sandbox --stream-function-logs true # !!stream-function-logsをtrueにするのおすすめです。バックエンドのログがコマンド実行したところの続きに表示されるようになります!!
~省略~
[Sandbox] Watching for file changes...
Fire written: amplify_outputs.json

ここは問題なく、Sandboxが立ち上げられました。

ところが、いざAPIを叩いてみると
応答が返ってこない…

◾️ 解決方法
Amplify Gen 2の Data Resource APIはAppSync + Lambdaで基本で構成されます。

先ほどエピ3の中でも説明をした通り、
npx ampx sandbox secret set SQL_CONNECTION_STRING で設定したDB情報から
どのVPCにどのような構成で構築するかをAmplifyが判断して自動構築しています。

今回の場合は、プライベートサブネット内にいるDB情報だったため、
AppSyncが呼ぶLambdaはプライベートサブネットに入っていました。
しかも、proxyのホストを指定していたこともあり、
proxyのセキュリティグループにLambdaが同居している形に…。

proxyのセキュリティグループ内からLambdaが、proxyセキュリティ内のproxyにアクセスをしようとしていて、
アクセスできない状態になっていました。

そこで、
proxyのセキュリティグループにproxyのセキュリティグループから443ポートとMySQLポートを解放してあげる
ことが解決の糸口となりました。

▼ CDKの追記した部分

import * as ec2 from 'aws-cdk-lib/aws-ec2';
~省略~
proxySecrurityGroup.addIngressRule(proxySecrurityGroup, ec2.Port.HTTPS, 'allow 443 from bastion');
proxySecrurityGroup.addIngressRule(proxySecrurityGroup, ec2.Port.MYSQL_AURORA, 'allow 3306 from proxy sg');

エピ5. schema.sql.tsで定義されているmodelからデータが操作できない

◾️ 問題発生
AppSyncから応答も返ってきたし、
データfetchしよう。deleteもmodelに関数用意されているみたい。便利だと思い試したところ

▼Amplifyで提供されているlist関数を実行

▼返却されたResponseをコンソールに表示している

あれ、なんかエラーが…

あれ、でもdelete・create・update・getはできる…おかしいぞ。

あれこれ調べていると、issueが上がっていました。
#13519 list() Command Returns Empty Array on PostgreSQL Auto-Generated Types
「list関数は、識別子(=主キーのはず)が a.string().required() のタイプであるモデルに対してリストコマンドを実行すると、常に空の配列が返されるとのこと。」
と書いてありました。

さらに調査して分かったことが、下記の内容でした。
「識別子(=主キーのはず)が idという命名以外であると、常に空の配列が返される。」

その当時はPostgresql かつ 主キーがすべてidという命名以外だったので、大当たりでした。

◾️ 解決方法
一旦schema.sql.tsに定義したmodelを使わずに、custom query・mutation を使って、SQL・modelを記載して実装を行いました。

その後、
パフォーマンス面・複雑なデータ処理が登場するにあたりFunctionsの中で、Data APIを使う方向になり、Postgresql→MySQLへ移行して、この問題は解消されました。
※当時、PostgresqlはData APIが未対応でした。

Postgresqlに対してこの課題が解決されたか試していません。

補足ですが、並び替えができないので、基本的に custom query・mutation を今も使ってしまっています。

さいごに

約1年間、Amplify Gen 2と共にプロジェクトを立ち上げて、進めてきて
プライベートDBを使うことで苦闘したエピをまとめてみました!
まとめましたが、なんだかんだAmplify Gen 2で開発が進められています

まだまだAmplify Gen 2はどんどん成長していくと思うので、変化に頑張って着いていきたいと思いました。
久しぶりに公式ドキュメント開いたら、かなり機能増えてる…読み応えありそうです!

これからAmplify Gen 2を使う方々に、この記事が少しでもお力になればと思います!
アプリ開発チームなのに、かなりインフラ寄りになってしまいましたが…
ご覧いただきありがとうございました!