すでに本番稼働しているシステムの移行は、新規デプロイとは別の作業です。
データを失わないこと、ユーザーが使っている時間帯に落とさないこと、うまくいかなかったときに戻せること。この3つを満たしながら、少しずつ移していく必要があります。
移行前の構成
対象は、フロントエンドがNext.js、バックエンドがFlask、データベースがPostgreSQLという構成の業務システムです。AWS上では次のように動いていました。
- ECS Fargate — フロントエンドとバックエンドを別サービスとして実行。ALB経由で公開。
- Aurora PostgreSQL 14 — プライベートサブネットに配置。インターネットからは到達不可。
- Route 53 — apexドメインをフロントエンド、apiサブドメインをバックエンドに割り当て。いずれもALBへのALIASレコード。
- ACM — ワイルドカードを含む証明書をALBに設定。DNS検証方式。
この記事では、実際のドメイン名を example.com / api.example.com に置き換えて説明します。
3つのフェーズに分ける
移行作業をひとまとめにすると、失敗したときの切り分けができません。今回は、ユーザーへの影響が発生する箇所を1点に絞る形で分割しました。
Phase 1 データベースの移行 影響なし(コピーするだけ)
Phase 2 DNSホスティングの移管 影響なし(向き先は変えない)
Phase 3 ドメインの切り替え ここだけ影響があるPhase 1と2は、既存のサービスに一切触れずに完了できます。準備をすべて終わらせてから、Phase 3で一気に切り替える。この形にすると、切り戻しもPhase 3を巻き戻すだけで済みます。
「メンテナンス時間なし」が成立する条件
ここまでの構成には、はっきりさせておくべき前提があります。pg_dump はある時点のスナップショットであり、ダンプ後に旧環境へ書き込まれたデータは新環境に入りません。
サービスは落ちないので「無停止」ではありますが、それだけでは「データ損失なし」にはなりません。両方を満たすには、ダンプ取得から切り替え完了までの間、旧環境への書き込みが発生しないことが必要です。
満たし方は3つあります。
- A. 書き込みのない時間帯に収める — ダンプから切り替えまでを、実測で書き込みが発生しない時間帯に収めます。書き込み停止の操作は不要ですが、常時書き込みがあるシステムでは使えません。
- B. 直前に書き込みを止めて最終同期する — 切り替え直前にアプリを読み取り専用にするか停止し、その状態で再ダンプして流し込みます。数分の書き込み停止は発生しますが、データの一致は保証されます。
- C. 論理レプリケーションで継続同期する — 切り替えまで差分を追従させ続けます。書き込み停止をほぼゼロにできますが、構成と検証の手間は最も大きくなります。
今回はAを選びました。前提として、対象が業務時間内にしか使われない社内向けシステムであることを確認しています。後述するとおり4週間分のアクセス実績を取得し、書き込みが発生しない時間帯を特定したうえで、その中にダンプから切り替えまでを収めました。
Aを採用する場合、切り替え後に旧環境へ書き込みが届いていないことを確認してください。ロードバランサーのリクエスト数や、アプリケーションログの更新系リクエストを見れば分かります。差分が出ていた場合は、その分を手で移すか、Bでやり直すことになります。
24時間書き込みが続くサービスであれば、AではなくBかCを前提に計画してください。この記事の以降の手順は、A・B・Cのいずれでも共通して使えます。違うのは「ダンプをいつ取るか」だけです。
プライベートなDBからダンプを取る
Aurora はプライベートサブネットにあり、PubliclyAccessible は false でした。セキュリティグループも、特定のセキュリティグループからの5432番ポートのみを許可しています。つまり手元から直接 pg_dump は実行できません。
ここで有効なのが、Session Manager のリモートホストポートフォワーディングです。VPC内にSSMエージェントが動く踏み台があれば、踏み台自体にPostgreSQLクライアントを入れる必要はありません。トンネルだけを借りて、ダンプは手元のマシンに書き出せます。
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 で弾かれます。
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_dump と pg_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 refusedlibpqはIPv4にフォールバックするため最終的には繋がりますが、本当の原因が2行目以降に隠れて読みにくくなります。-h 127.0.0.1 と明示しておくほうが、トラブルシューティングが楽になります。
リストアしたあとに確認すること
KamuiDashのデータベースには外部接続用のユーザーとアプリケーション用のユーザーがあります。外部接続でリストアすると、作成されたテーブルの所有者は外部接続用ユーザーになります。この状態だと、アプリケーションがマイグレーションを実行するときに所有者エラーになります。
REASSIGN OWNED BY app_external TO app_user;テーブルもシーケンスもまとめて移るので、取りこぼしがありません。実行できる条件は、実行ユーザーが移譲先ロールのメンバーであることです。
そしてもうひとつ、移行後に必ず見ておきたいのがシーケンスの現在値です。ここがずれていると、最初のINSERTが主キー重複で落ちます。
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_value が max_id を上回っているはずです。データを1件も書き込まずに、読み取りだけで判定できます。
権限そのものも has_table_privilege や has_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のカスタムドメインへ切り替えたあとも、プロキシは無効のままにしてください。プロキシ同士が二重になる構成は想定されていません。
切り替え前に確認できること
ネームサーバを変更する前に、移管先のネームサーバへ直接問い合わせれば、レコードが正しいかを確認できます。
dig @新しいネームサーバ example.com +short
dig @新しいネームサーバ api.example.com +short現行と同じIPが返れば、切り替えても結果は変わりません。ここを確認しておけば、ネームサーバ変更は「答える人が変わるだけ」の作業になります。
DNSSEC が有効なゾーンでは、ネームサーバを変更する前にレジストラ側でDNSSECを無効化する必要があります。有効なまま切り替えると、ドメインが到達不能になります。
なぜバックエンドにもカスタムドメインが必要か
KamuiDashは、アプリを作成した時点で アプリ名.kamui-platform.com のURLを発行します。ワイルドカード証明書の対象なので、作成した瞬間からHTTPSで使えます。
そのため「フロントエンドだけカスタムドメインにして、バックエンドは発行されたURLのままでよいのでは」と考えたくなります。今回もそう検討しましたが、この構成は使えませんでした。
理由はセッションCookieです。対象のシステムはCookieベースの認証を使っており、フロントエンドはすべてのAPI呼び出しで credentials: 'include' を指定しています。
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送信は別のレイヤーです。
① 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の問題が発生します。
実際に何を呼んでいるかは、公開されているバンドルを取得して確認できます。設定画面の表示ではなく、配信されている成果物を見るのが確実です。
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 を呼ぶようにビルドされています。つまりこの組み合わせでは、
- ページは旧環境が配信する
- APIは新環境が応答する
- 同じ登録可能ドメインなのでCookieも正常に通る
という状態になり、画面上は完全に正常に動きます。 ログインもでき、Safariでも問題なく、書き込みは新環境のデータベースに入ります。移行が成功したようにしか見えません。
問題は、これが移行完了の証拠にならないことです。新環境のフロントエンドは一度も検証されていません。 伝播が完了して初めて新環境のフロントが表に出て、そこで不備が露見します。
実際、今回は新環境のフロントエンドがAPIの向き先を古いまま持っていました。旧環境のフロントが正しいバンドルを配り続けていたため、その事実が丸2日間隠れていました。発覚したのは、伝播が完全に終わってログインできなくなったときです。
URLではなく、実体を確認する
「URLを開いて動いた」は検証になりません。どちらの実体が応答したかまで確認する必要があります。
# 解決先の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という不確定要素を経路から外せます。
あわせて、ビルドが実際に行われたかもレジストリで確認してください。設定画面に表示される環境変数は「設定されている値」であって、「配信されている成果物に入っている値」ではありません。今回は、環境変数を変更したあと一度もイメージが作られていませんでした。
aws ecr describe-images --repository-name myapp \
--query "sort_by(imageDetails,&imagePushedAt)[-1].[imagePushedAt,to_string(imageTags)]" \
--output text環境変数を変更した時刻より新しいイメージが存在しなければ、その変更は成果物に反映されていません。再デプロイのボタンが「再起動」なのか「再ビルド」なのかは、プラットフォームによって挙動が異なります。イメージが増えたかどうかで判断するのが確実です。
移行後の確認
切り替えたあとは、期待どおりに移ったかを外側から確認します。いずれも読み取りだけの操作です。
- 複数のリゾルバで解決結果を見る — 手元のリゾルバはネームサーバをキャッシュしています。パブリックリゾルバをいくつか指定して問い合わせると、伝播の状況が分かります。
- バンドルの中身を見る — フロントエンドが実際にどのAPIを呼んでいるかを、配信物から確認します。
- 旧環境への流入が止まったか見る — ALBのリクエスト数がゼロ近辺に落ちていれば、トラフィックは移っています。
- CORSヘッダを確認する — 新しいオリジンに対して許可が返っているかを見ます。認証が必要なエンドポイントでも、401のレスポンスヘッダで確認できます。
切り戻しのために残すもの
移行が完了しても、すぐに旧環境を消さないでください。DNSのTTLを短くしてあれば、レコードを戻すだけで数分で切り戻せます。この選択肢を維持できる期間が、移行の安全性そのものです。
とくに見落としやすいのが、ACMのDNS検証レコードです。
_xxxxxxxx.example.com CNAME _yyyyyyyy.acm-validations.awsACMのマネージド更新は、有効期限の手前でこのレコードを再度参照します。移行後に不要だと判断して削除すると、更新に失敗して旧環境の証明書が失効します。切り戻し先として残すなら、このレコードも残す必要があります。
削除の判断基準はシンプルです。旧環境のロードバランサーを削除するときに、まとめて片付ける。それまでは触りません。
まとめ
今回の移行で効いたのは、次の3点でした。
- 影響のある操作を1点に絞る — データベースのコピーとDNSの移管を先に終わらせ、ユーザーに影響するのはCNAMEの切り替えだけにしました。
- 書き込みのない時間帯を実測で押さえる — ダンプはスナップショットなので、その後の書き込みは移りません。無風の時間帯に収めるか、直前に書き込みを止めるかを最初に決めておきます。
- TXT検証を活かす — 証明書の準備をトラフィックの切り替えと分離できるため、メンテナンス時間が不要になります。
- 切り戻せる状態を維持する — 旧環境と、その証明書更新に必要なレコードを残しておきます。
移行作業の大半は、実際には調査です。どこにデータがあり、何がどこを向いていて、どの設定がいつ固定されるのか。それが分かってしまえば、実行そのものは短時間で終わります。
KamuiDashの設計思想や、GitHubからデプロイする基本的な流れについてはKamuiDashを使う理由で説明しています。