ECS FargateとAurora PostgreSQLから
KamuiDashへ引っ越し。

ECS FargateとAurora PostgreSQLで運用していた業務システムを、KamuiDashへ移行しました。書き込みが発生しない時間帯を実測で特定することで、メンテナンス時間を取らずに切り替えています。データベースのダンプ、DNSの移管、ドメインの切り替えという3つのフェーズと、その途中で踏んだ落とし穴を記録します。

すでに本番稼働しているシステムの移行は、新規デプロイとは別の作業です。
データを失わないこと、ユーザーが使っている時間帯に落とさないこと、うまくいかなかったときに戻せること。この3つを満たしながら、少しずつ移していく必要があります。

移行前の構成

対象は、フロントエンドがNext.js、バックエンドがFlask、データベースがPostgreSQLという構成の業務システムです。AWS上では次のように動いていました。

この記事では、実際のドメイン名を example.com / api.example.com に置き換えて説明します。

移行前と移行後の構成
移行前 / AWS Route 53(ALIAS) Application Load Balancer ECS Fargate フロントエンド ECS Fargate バックエンド プライベートサブネット Aurora PostgreSQL 14 インターネットから到達不可 移行 移行後 / KamuiDash Cloudflare DNS(CNAME) custom.kamui-platform.com Webアプリ example.com Webアプリ api.example.com 東京リージョン Postgres 同一プロジェクト内で管理 apexとapiを1つのALBで受ける 2つのアプリに別々のドメインを割り当てる
移行前はALBがapexとapiの両方を受け、ECS Fargateへ振り分けていました。移行後は2つのアプリそれぞれにカスタムドメインを割り当て、KamuiDashのエッジがホスト名で振り分けます。データベースは同じプロジェクト内のPostgresへ移しています。

3つのフェーズに分ける

移行作業をひとまとめにすると、失敗したときの切り分けができません。今回は、ユーザーへの影響が発生する箇所を1点に絞る形で分割しました。

移行フェーズ
Phase 1  データベースの移行           影響なし(コピーするだけ)
Phase 2  DNSホスティングの移管        影響なし(向き先は変えない)
Phase 3  ドメインの切り替え           ここだけ影響がある

Phase 1と2は、既存のサービスに一切触れずに完了できます。準備をすべて終わらせてから、Phase 3で一気に切り替える。この形にすると、切り戻しもPhase 3を巻き戻すだけで済みます。

作業の順序と、影響が出る地点
旧環境(AWS)で稼働中 — ユーザーへの影響なし 新環境で稼働 PHASE 1 DBのダンプとリストア 所有権・シーケンスを確認 PHASE 2 DNSホスティング移管 レコードの中身は変えない PHASE 3-a TXTを追加して検証 CNAMEはまだ触らない PHASE 3-b CNAMEを切り替え ここだけ影響がある 切り戻しはCNAMEを戻すだけ 旧環境を残しておくことが前提 準備はすべて事前に完了させる
Phase 1から3-aまでは既存のサービスに触れないため、時間をかけて進められます。ユーザーに影響するのはPhase 3-bのCNAME切り替えのみで、失敗した場合もレコードを戻すだけで復旧できます。ただしデータの鮮度については次節の条件が付きます。

「メンテナンス時間なし」が成立する条件

ここまでの構成には、はっきりさせておくべき前提があります。pg_dump はある時点のスナップショットであり、ダンプ後に旧環境へ書き込まれたデータは新環境に入りません。

サービスは落ちないので「無停止」ではありますが、それだけでは「データ損失なし」にはなりません。両方を満たすには、ダンプ取得から切り替え完了までの間、旧環境への書き込みが発生しないことが必要です。

満たし方は3つあります。

今回はAを選びました。前提として、対象が業務時間内にしか使われない社内向けシステムであることを確認しています。後述するとおり4週間分のアクセス実績を取得し、書き込みが発生しない時間帯を特定したうえで、その中にダンプから切り替えまでを収めました。

Aを採用する場合、切り替え後に旧環境へ書き込みが届いていないことを確認してください。ロードバランサーのリクエスト数や、アプリケーションログの更新系リクエストを見れば分かります。差分が出ていた場合は、その分を手で移すか、Bでやり直すことになります。

24時間書き込みが続くサービスであれば、AではなくBかCを前提に計画してください。この記事の以降の手順は、A・B・Cのいずれでも共通して使えます。違うのは「ダンプをいつ取るか」だけです。

プライベートなDBからダンプを取る

Aurora はプライベートサブネットにあり、PubliclyAccessible は false でした。セキュリティグループも、特定のセキュリティグループからの5432番ポートのみを許可しています。つまり手元から直接 pg_dump は実行できません。

ここで有効なのが、Session Manager のリモートホストポートフォワーディングです。VPC内にSSMエージェントが動く踏み台があれば、踏み台自体にPostgreSQLクライアントを入れる必要はありません。トンネルだけを借りて、ダンプは手元のマシンに書き出せます。

port-forward.sh
aws ssm start-session \
  --target i-0123456789abcdef0 \
  --document-name AWS-StartPortForwardingSessionToRemoteHost \
  --parameters '{"host":["mycluster.cluster-ro-xxxxxxxx.ap-northeast-1.rds.amazonaws.com"],
                 "portNumber":["5432"],
                 "localPortNumber":["15432"]}'

ホストにはリーダーエンドポイント(cluster-ro-)を指定しています。ダンプは読み取りしかしませんが、経路の側で書き込みができない状態にしておくと、事故の余地がひとつ減ります。

踏み台に付けるセキュリティグループは、既存のアプリケーション用セキュリティグループを流用できる場合があります。すでにDB側で許可されているものを使えば、本番のセキュリティグループを一切変更せずに済みます。

ダンプで踏んだ落とし穴

トンネルが通っても、そこから先で3つ引っかかりました。いずれも移行作業では起こりやすいものです。

1. マスターパスワードがローテーションしていた

RDSのマネージドマスターパスワードを使っている場合、パスワードはSecrets Managerに保存され、既定で自動ローテーションします。今回は7日周期でした。

一方、アプリケーションはParameter Storeにコピーされた値を参照していました。このコピーは自動では更新されません。結果として、数日前まで通っていたパスワードが password authentication failed で弾かれます。

get-password.sh
SECRET_ARN=$(aws rds describe-db-clusters \
  --db-cluster-identifier my-cluster \
  --query "DBClusters[0].MasterUserSecret.SecretArn" --output text)

export PGPASSWORD=$(aws secretsmanager get-secret-value \
  --secret-id "$SECRET_ARN" --query SecretString --output text \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["password"])')

ARNを変数経由で渡しているのには理由があります。RDSが管理するシークレットの名前には rds!cluster- のように ! が含まれ、zshではダブルクォート内でも履歴展開が働いて event not found になります。シングルクォートで囲むか、変数に入れてから使うのが確実です。

2. pg_dumpとpg_restoreのバージョンが違った

カスタム形式のアーカイブには、作成したpg_dumpのバージョンに対応する形式番号が入ります。新しいpg_dumpで作ったダンプを古いpg_restoreで読むと、次のように落ちます。

エラー
pg_restore: error: unsupported version (1.16) in file header

複数のPostgreSQLクライアントが入っている環境では、pg_dumppg_restore が別のインストール先から呼ばれていることがあります。作業前に両方の --version を確認しておくと早いです。

3. localhostがIPv6に解決された

Session Managerのプラグインは、127.0.0.1にのみバインドします。macOSで -h localhost と書くと、まず ::1 への接続が試行されて失敗し、エラー出力が増えます。

エラー
connection to server at "localhost" (::1), port 15432 failed: Connection refused

libpqはIPv4にフォールバックするため最終的には繋がりますが、本当の原因が2行目以降に隠れて読みにくくなります。-h 127.0.0.1 と明示しておくほうが、トラブルシューティングが楽になります。

リストアしたあとに確認すること

KamuiDashのデータベースには外部接続用のユーザーとアプリケーション用のユーザーがあります。外部接続でリストアすると、作成されたテーブルの所有者は外部接続用ユーザーになります。この状態だと、アプリケーションがマイグレーションを実行するときに所有者エラーになります。

reassign.sql
REASSIGN OWNED BY app_external TO app_user;

テーブルもシーケンスもまとめて移るので、取りこぼしがありません。実行できる条件は、実行ユーザーが移譲先ロールのメンバーであることです。

そしてもうひとつ、移行後に必ず見ておきたいのがシーケンスの現在値です。ここがずれていると、最初のINSERTが主キー重複で落ちます。

check-sequences.sql
SELECT t.tablename,
       s.last_value,
       (xpath('/row/max/text()',
              query_to_xml(format('SELECT max(id) AS max FROM public.%I', t.tablename),
                           false, true, '')))[1]::text::bigint AS max_id
FROM pg_tables t
JOIN pg_sequences s ON s.sequencename = t.tablename || '_id_seq'
WHERE t.schemaname = 'public'
ORDER BY 1;

全体ダンプにはsetvalが含まれるため、通常は last_valuemax_id を上回っているはずです。データを1件も書き込まずに、読み取りだけで判定できます。

権限そのものも has_table_privilegehas_sequence_privilege で照会できます。テストデータを入れて確かめる必要はありません。

DNSを移す。ただしトラフィックは動かさない

Route 53のALIASレコードは、AWSリソースにしか向けられません。他社のDNSサービスへ移すときは、ALIASをそのまま再現できないという制約が出てきます。とくにapexドメインは、DNSの仕様上CNAMEを置けません。

CNAMEフラットニングに対応したDNSサービスであれば、apexにCNAMEを書けます。ここでは移管先としてCloudflareを使いました。

重要なのは、ゾーンの移管とトラフィックの切り替えは別物だという点です。レコードの中身を変えなければ、DNSを誰が答えるかが変わるだけで、行き先は変わりません。

移管時のレコード
example.com          CNAME  dualstack.my-alb-000000.ap-northeast-1.elb.amazonaws.com
api.example.com      CNAME  dualstack.my-alb-000000.ap-northeast-1.elb.amazonaws.com
_xxxxxxxx.example.com  CNAME  _yyyyyyyy.acm-validations.aws

ネームサーバを切り替えても、引き続きALBに届きます。この状態で解決結果を確認してから、次に進めます。

自動スキャンはALIASを再現できない

移管先のDNSサービスには、既存レコードを自動で読み取る機能があることがあります。ただしALIASは外部から見るとAレコードなので、スキャン結果はALBのIPアドレスが直接書かれたAレコードになります

ALBのIPアドレスは固定ではありません。今回の作業中にも、1日のあいだに3つのうち1つが入れ替わっていました。IPを直書きすると、いずれ静かに壊れます。ALIASの代わりにはCNAMEを使ってください。

プロキシは切っておく

CDN機能を持つDNSサービスでは、レコード作成時にプロキシが既定で有効になることがあります。移行期間中は無効にしておくのが安全です。有効にすると、SSLの終端位置が変わり、オリジン側の証明書設定との組み合わせによってはエラーやリダイレクトループが起きます。

また、KamuiDashのカスタムドメインへ切り替えたあとも、プロキシは無効のままにしてください。プロキシ同士が二重になる構成は想定されていません。

切り替え前に確認できること

ネームサーバを変更する前に、移管先のネームサーバへ直接問い合わせれば、レコードが正しいかを確認できます。

verify.sh
dig @新しいネームサーバ example.com +short
dig @新しいネームサーバ api.example.com +short

現行と同じIPが返れば、切り替えても結果は変わりません。ここを確認しておけば、ネームサーバ変更は「答える人が変わるだけ」の作業になります。

DNSSEC が有効なゾーンでは、ネームサーバを変更する前にレジストラ側でDNSSECを無効化する必要があります。有効なまま切り替えると、ドメインが到達不能になります。

なぜバックエンドにもカスタムドメインが必要か

KamuiDashは、アプリを作成した時点で アプリ名.kamui-platform.com のURLを発行します。ワイルドカード証明書の対象なので、作成した瞬間からHTTPSで使えます。

そのため「フロントエンドだけカスタムドメインにして、バックエンドは発行されたURLのままでよいのでは」と考えたくなります。今回もそう検討しましたが、この構成は使えませんでした。

理由はセッションCookieです。対象のシステムはCookieベースの認証を使っており、フロントエンドはすべてのAPI呼び出しで credentials: 'include' を指定しています。

Cookieが届くかどうか
example.com  →  api.example.com
  登録可能ドメインが同じ  →  first-party Cookie  ○

example.com  →  myapp.kamui-platform.com
  登録可能ドメインが違う  →  third-party Cookie  ×

Cookieが送られるかどうかは、SameSite属性とブラウザのthird-party Cookieポリシーで決まります。SameSite属性を設定していない場合、Chromeは Lax として扱うため、クロスサイトのfetchではCookieを送りません。SafariはITPにより、既定でthird-party Cookieをブロックします。

CORSを許可しても解決しない

ここは混同しやすいところです。CORSとCookie送信は別のレイヤーです。

2つの関門
① CORS            サーバが「このオリジンにレスポンスを読ませてよい」と許可する
                   Access-Control-Allow-Origin / Allow-Credentials

② Cookie送信可否   ブラウザが「このCookieを付けてよいか」を判断する
                   SameSite属性 / third-party Cookieポリシー

Access-Control-Allow-Credentials: true は①を通すだけで、ブラウザにCookieの送信を強制するものではありません。②で落ちると、Cookieが付かないリクエストがサーバに届き、CORSヘッダは正常なのに認証だけ通らない、という状態になります。

curlやHTTPクライアントツールはSameSiteもITPも実装していないため、この問題を再現しません。CORSさえ通っていれば成功して見えます。検証は実ブラウザで、Safariを含めて行ってください。

結論として、フロントエンドとバックエンドは同じ登録可能ドメインの配下に置く必要があります。フロントエンドに example.com を使うなら、バックエンドは api.example.com になります。

TXT検証だから、無停止で切り替えられる

KamuiDashでカスタムドメインを登録すると、設定すべきCNAMEとTXTが表示されます。TXTはドメイン所有の確認とSSL証明書の発行に使われます。

設定するレコード
CNAME  api.example.com                  custom.kamui-platform.com
TXT    _acme-challenge.api.example.com  表示された検証値

ここが移行の設計上、重要なポイントです。検証がTXTで行われるため、CNAMEの向き先とは無関係に証明書を発行できます。

切り替えの順序
1. TXTだけ追加する            CNAMEは既存のまま → 影響なし
2. 検証が完了するまで待つ       この間もサービスは元の環境で動き続ける
3. CNAMEを切り替える           準備が整っているので、切り替えた瞬間から応答する

もしHTTP検証(指定パスにファイルを置く方式)だったら、先にCNAMEを切り替える必要があり、検証が終わるまでのダウンタイムを避けられません。TXT検証であることが、メンテナンス時間を取らずに移行できる前提になっています。

切り替えを急ぐ必要もありません。TXTを入れてから翌日にCNAMEを変える、といった進め方ができます。

ビルド時に焼き込まれる設定に注意する

Next.jsの NEXT_PUBLIC_ 接頭辞が付いた環境変数は、ビルド時にJavaScriptバンドルへ埋め込まれます。実行時に環境変数を変更しても反映されません。

APIの向き先をこの方式で渡している場合、ドメインを切り替えるタイミングで再ビルドが必要です。今回は次の順序にしました。

切り替え手順
1. api.example.com を KamuiDash のバックエンドへ切り替え
2. フロントエンドの API URL を https://api.example.com に変更して再デプロイ
3. example.com を KamuiDash のフロントエンドへ切り替え

2を1より先に行うと、まだ切り替わっていないAPIを指すことになります。逆に2を飛ばして3まで進めると、フロントエンドがプラットフォーム発行のURLを呼び続け、先ほどのthird-party Cookieの問題が発生します。

実際に何を呼んでいるかは、公開されているバンドルを取得して確認できます。設定画面の表示ではなく、配信されている成果物を見るのが確実です。

check-bundle.sh
curl -s https://example.com/ \
  | grep -oE '/_next/static/[^"]+\.js' | sort -u \
  | while read -r p; do curl -s "https://example.com$p"; done \
  | grep -oE 'https://[a-zA-Z0-9._-]+\.example\.com' | sort -u

切り替える時間帯を決める

「土日は使われていないはず」という前提は、確認してから採用したほうがよいものです。今回はALBのリクエスト数を4週間分取得して判断しました。

結果として、日曜は4週連続で低い一方、ある土曜だけは平日と同水準のアクセスがありました。時間帯を見ると、明らかに業務で使われている形です。曜日でまとめず、日別・時間帯別まで落として見ることをおすすめします。

アクセスログを持っているなら、それが最も確実な材料です。「使われていないと思う」ではなく「この時間帯は過去4週間ゼロだった」と言える状態にしてから切り替えます。

移行期間中は、同じURLでも別の実体に当たる

今回いちばん時間を取られたのがこれでした。

DNSの切り替えは瞬時ではありません。ネームサーバのTTLは既定で172800秒(2日)あり、どのリゾルバを使っているかで切り替わる時刻がばらつきます。その間、同じURLが新旧どちらの環境にも解決され得ます。

さらに厄介なのは、レコードごとに伝播のタイミングが違うことです。今回は次の状態が発生していました。

伝播の途中で起きていたこと
example.com      → 旧環境(まだ伝播していない)
api.example.com  → 新環境(先に伝播した)

旧環境のフロントエンドは、もともと api.example.com を呼ぶようにビルドされています。つまりこの組み合わせでは、

という状態になり、画面上は完全に正常に動きます。 ログインもでき、Safariでも問題なく、書き込みは新環境のデータベースに入ります。移行が成功したようにしか見えません。

問題は、これが移行完了の証拠にならないことです。新環境のフロントエンドは一度も検証されていません。 伝播が完了して初めて新環境のフロントが表に出て、そこで不備が露見します。

実際、今回は新環境のフロントエンドがAPIの向き先を古いまま持っていました。旧環境のフロントが正しいバンドルを配り続けていたため、その事実が丸2日間隠れていました。発覚したのは、伝播が完全に終わってログインできなくなったときです。

URLではなく、実体を確認する

「URLを開いて動いた」は検証になりません。どちらの実体が応答したかまで確認する必要があります。

verify-origin.sh
# 解決先のIPが旧環境か新環境か
dig +short example.com

# レスポンスヘッダで配信元を判別する
curl -sI https://example.com/ | grep -iE "server|via|x-powered-by"

# 新環境を名指しで叩く(プラットフォーム発行のURL経由)
curl -s https://myapp.example-platform.com/ | head

最も確実なのは、DNSを介さずに新環境へ直接接続することです。プラットフォームがデフォルトURLを発行しているならそれを使い、無ければコンテナへポートフォワードして取得します。DNSという不確定要素を経路から外せます。

あわせて、ビルドが実際に行われたかもレジストリで確認してください。設定画面に表示される環境変数は「設定されている値」であって、「配信されている成果物に入っている値」ではありません。今回は、環境変数を変更したあと一度もイメージが作られていませんでした。

check-image.sh
aws ecr describe-images --repository-name myapp \
  --query "sort_by(imageDetails,&imagePushedAt)[-1].[imagePushedAt,to_string(imageTags)]" \
  --output text

環境変数を変更した時刻より新しいイメージが存在しなければ、その変更は成果物に反映されていません。再デプロイのボタンが「再起動」なのか「再ビルド」なのかは、プラットフォームによって挙動が異なります。イメージが増えたかどうかで判断するのが確実です。

移行後の確認

切り替えたあとは、期待どおりに移ったかを外側から確認します。いずれも読み取りだけの操作です。

切り戻しのために残すもの

移行が完了しても、すぐに旧環境を消さないでください。DNSのTTLを短くしてあれば、レコードを戻すだけで数分で切り戻せます。この選択肢を維持できる期間が、移行の安全性そのものです。

とくに見落としやすいのが、ACMのDNS検証レコードです。

消さないレコード
_xxxxxxxx.example.com  CNAME  _yyyyyyyy.acm-validations.aws

ACMのマネージド更新は、有効期限の手前でこのレコードを再度参照します。移行後に不要だと判断して削除すると、更新に失敗して旧環境の証明書が失効します。切り戻し先として残すなら、このレコードも残す必要があります。

削除の判断基準はシンプルです。旧環境のロードバランサーを削除するときに、まとめて片付ける。それまでは触りません。

まとめ

今回の移行で効いたのは、次の3点でした。

移行作業の大半は、実際には調査です。どこにデータがあり、何がどこを向いていて、どの設定がいつ固定されるのか。それが分かってしまえば、実行そのものは短時間で終わります。

KamuiDashの設計思想や、GitHubからデプロイする基本的な流れについてはKamuiDashを使う理由で説明しています。