無停止のPostgresアップグレード
(knock.app)- Knockは通知ワークフローエンジンの中核ストレージであるPostgresを AWS RDS Aurora 11.9から15.3へ 引き上げ、顧客影響なしで切り替える手順を整備した
- Amazon RDSの Postgres 11.9の廃止日である2024年2月29日 までに対応しなければ、強制アップグレードとダウンタイムを受け入れなければならない状況だった
- インプレースアップグレードと
pg_dump/pg_restoreは長い停止時間が必要なため除外し、新DBにPUBLICATION/SUBSCRIPTIONベースの 論理レプリケーション を構成する方式を採用した - テーブルサイズと書き込みパターンに応じてレプリケーション戦略を分け、小さいテーブルは直接レプリケーションし、大きな append-only テーブルは
copy_data = falseとスナップショットのバックフィルを組み合わせた - 最終切り替えは2つのDB接続を維持したままフラグを切り替え、実行中クエリに 500ms を与えた後、1秒間新DBへのリクエストを止めて stale read のリスクを減らす方式で、数秒で完了した
アップグレードの目標と制約
- Knockは通知ワークフローエンジンをPostgresに依存しており、ワークフロー設定、メッセージテンプレート、数百万件のログ収集、バックグラウンドジョブのキューイングにPostgresを利用している
- Postgresはリレーショナルデータベースの特性上、アップグレード時に最低限の再起動が必要であり、メジャーバージョンアップグレードではディスク上のデータ・インデックス保存方式の変更により、数分以上の完全停止が必要になる場合がある
- 会社創業以来使ってきた Postgres 11.9 はAmazon RDSで廃止予定であり、別途対応しなければ強制アップグレードと強制ダウンタイムの可能性があった
- アップグレード条件は運用リスクを下げることに重点を置いた
- 可能な限り最新のAurora向け Postgres 15.3 まで一気に進める
- 60秒を超えるダウンタイムは許容せず、理想的には システムダウンタイム0
- Amazonの2024年2月の期限前に完了する
- 顧客影響を最小化する。たとえばAPIエラーレスポンス0
- 次回のアップグレードでも再利用できるよう、手順を ランブック化 する
- 11.9から15.3までは 4つのメジャーバージョン のアップグレードに相当するため、インプレースアップグレードを4回繰り返す方式は選択肢から外れた
事前準備: リスク低減と可観測性
- Postgresアップグレードはまずリスク一覧を作り、影響が大きく、かつ事前に取り除きやすいリスクから減らす方針で進めた
- 長いダウンタイム
- データ損失
- アプリケーションワークロードに対するDB性能の変化
VACUUMの頻度や挙動の変化- レプリケーションスロット移行の必要性
- Postgresリリースノート でバージョン間の変更点を確認し、
VACUUMの挙動変化や特定アップグレードでの再インデックス化の必要性などのリスクを特定した - アップグレード中は システムとデータベースのメトリクス を継続的に確認する必要がある
- トランザクション wraparound 防止のための Max TXN ID
- DB CPU使用率
- writer インスタンスの待機セッション
- クエリレイテンシ
- アプリケーションAPI応答レイテンシ
- Knockは、APIリクエストが通知へ変換されるまでの時間のようなアプリケーション固有の指標もあわせて監視した
- すぐ確認できる指標がないと、アップグレード中は 目隠しされた状態 になる
除外した方式: インプレースアップグレードと dump/restore
- AWS RDSの インプレースアップグレード はAWSコンソールから実行され、AWSがDBを停止し、アップグレードスクリプトを実行してから再度オンラインに戻す
- このプロセスはデータ量とバージョン差分に応じて、数分から数時間以上かかることがある
- DBが再びオンラインになった後でも、
VACUUMやREINDEXのようなメンテナンス作業が必要で、すぐに完全に使える状態とは限らない pg_dumpとpg_restoreの方式は、信頼できるバックアップを得るためにすべてのアプリケーションを既存DBから切り離す必要があり、大規模DBでは dump と restore 自体にも長時間かかる- Knockの許容ダウンタイムを大幅に超える可能性が高く、両方式とも除外された
採用した方式: 論理レプリケーションベースのアップグレード
- 最終的に選んだのは、Postgresの
PUBLICATIONとSUBSCRIPTIONを使う 論理レプリケーション 方式だった - 基本的な流れは次の通り
- 目標Postgresバージョンの新しいDBを起動する
- 設定、拡張、テーブル構成、ユーザーなどを移す
- 既存DBに publication を作り、新DBに subscription を構成する
- テーブルを publication に追加する
- レプリケーション完了後、残るリスクを確認するテストを行う
- 新DB構成の確認が十分できたら、アプリケーションを新DBへ切り替える
- 既存DBを削除する
- 一度に大規模アップグレードを実行するのではなく 段階的なステップ で進められ、実データと実ワークロードで新DBをテストできた
- 新DBの準備後は切り替え自体が数秒で済むため、切り替え時点と方法をよりよく制御できた
レプリケーション構成の要点
- Postgresの論理レプリケーションは、レプリケーションスロット設定に必要なパラメータを使い、単純なアプリケーションでは
wal_levelをlogicalに設定することが主な変更点となる場合がある - すでに読み取りレプリカ、DBフェイルオーバー、データウェアハウス同期などでレプリケーションスロットを使っているなら、
max_replication_slotsなど関連パラメータをドキュメントに従って調整する必要がある - 新DBのテーブル構造は既存DBと 同一である必要があるが、空でなければならない
- スキーマスナップショットは
pg_dumpallに--schema-only,--no-role-passwordsオプションを付けて生成し、新DB向けSQLと比較して差分を修正できる - 既存DBで publication を作り、新DBで subscription を作る際には主要オプションを設定する
enabled = false: 最初から同期を始めないようにするcreate_slot = true: Postgresにレプリケーションスロット管理を任せるcopy_data = true: デフォルトでテーブル内容をコピーするdisable_on_error = true: 想定外のエラー時に subscription を停止し、問題修正後に再開できるようにする
FOR ALL TABLESで全テーブルを一度に publication に入れると大規模DBで性能問題が出る可能性があるため、KnockはALTER PUBLICATION ... ADD TABLEでテーブルを 1つずつ追加 した
テーブル分類とレプリケーション戦略
- Knockはテーブルをディスクサイズとタプル数で分類した
- 数分以内に同期できる小さいテーブル
- 大きいが append-only に近いテーブル
- 大きく、ほとんどの row が頻繁に更新されるテーブル
- Knock基準での「小さい」テーブルは 50GB未満 かつ 1000万タプル未満 のテーブルだった
- Postgresにおけるタプルは insert や update が保存される単位であり、row 数が少なくても未整理のタプルが多いとレプリケーション時間が長くなることがある
- レプリケーション前に
VACUUMを実行すると、ソースDBからターゲットDBへコピーすべきタプル数を減らす助けになる場合がある - テーブル同期時間はディスクサイズとタプル数に直接関係し、長時間の同期は primary DB の
VACUUMを妨げ、性能低下やトランザクション wraparound リスクにつながる可能性がある
小さいテーブルのレプリケーション
- 小さいテーブルは既存DBで publication にテーブルを追加し、新DBで subscription を refresh する形で処理した
- テーブルコピー、同期、その後の変更反映はPostgresが担う
- 非常に小さいテーブルであれば、1秒未満で同期できることもある
大きな append-only テーブルのレプリケーション
- 更新がない、または最近の row にしか更新が発生しない大きなテーブルは、
copy_data = falseで別の publication/subscription を作成できる - Knockは通常のレプリケーションと区別するため、名前に
_nocopy接尾辞を付けた - まず新規変更分だけをレプリケートし、過去データはバックアップやスナップショットから別途 バックフィル した
- AWS RDS Auroraで使った手順は次の通り
- 本番DBスナップショットを作成
- スナップショットを新しいDBインスタンスとして復元
- レプリケート対象のスナップショットDBテーブル名に
_snapshotのような接尾辞を追加 - ターゲットDBにも同じスキーマのスナップショット用テーブルを作成
- スナップショットDBからターゲットDBへ publication/subscription を構成
- レプリケーション進捗を監視
- レプリケーションが追いついたら
INSERT ... ON CONFLICT DO NOTHINGで実際のターゲットテーブルへマージ
- 非常に大きなテーブルではこのプロセスに数日かかることもあるが、バックグラウンドで進むため本番環境への影響は避けるべきだ
- マージ後は row 数を比較して整合性を確認し、ターゲットDBのスナップショットテーブル、スナップショット subscription、スナップショットDBインスタンスを削除した
大きく頻繁に更新されるテーブル
- 大きく、ほとんどの row が頻繁に更新されるテーブルは最も難しく、長時間のレプリケーションが
AUTOVACUUMの実行を妨げる可能性がある - 検討できる対策は次の通り
- ハウスキーピングでテーブルサイズを削減できるか確認
- 最近
VACUUMが実行されたか確認 - テーブルをより小さな単位にパーティショニングできるか検討
- 一定時間経過後に row 更新が止まるか確認し、append-only のように扱えるか判断
- ソースDBがPG 15未満なら選択肢は限られ、小さいテーブル方式でレプリケートし、監視によってサービス劣化の有無を確認する必要がある
- 必要なら publication からテーブルを外し、subscription を refresh してロールバックできる
- 非常に大きなテーブルは、トラフィックの少ない時間帯にレプリケーションを開始し、負荷と書き込み活動の影響を抑えられる
PG 15以上で可能な大規模テーブルの分割レプリケーション
- ソースDBがPG 15以上なら、複数の publication に分けてレプリケーションし、大きなテーブルを小さな断片に移せる
- この方式はパーティショニングやシャーディングに似た動作をし、その代償としてより多くの レプリケーションスロット を使う
- Knockは11.9から15.3へ移行したため、この方式は使えず、直接テストもしていない
- 例としては、primary key のハッシュと
WHERE句を使って row を複数の publication に分配する方法がある - Knockが管理可能と考えた断片サイズは、インデックスを除くデータ基準で約 100GB だった
レプリケーション状態の確認と中断
- subscription にテーブルが追加されると、ターゲットDBの
pg_subscription_rel.srsubstateで状態を確認できるi: 初期化d: テーブル内容コピーf: コピー完了、最終同期待ちs: 初期同期の仕上げr: 通常レプリケーション実行中
d段階では古いPostgresトランザクションIDを保持する必要があるため、VACUUMを実質的に阻害し、性能問題や transaction ID wraparound につながる可能性がある- wraparound に近づいたら、マイグレーションを中断して、より小さな断片に分けるほうがよい
- 特定テーブルのレプリケーションを止めるには、既存DBの publication からそのテーブルを削除し、新DBの subscription を refresh する
- subscription を単に disable するだけでは、ソースDBが古い transaction ID を保持し続け、性能問題が解決しないことがある
- 緊急時には publication と subscription 全体を削除して最初からやり直せる。その際、Postgresが関連するレプリケーションスロットを整理する
レプリケーションスロット移行の制約
- Postgresのレプリケーションスロットは、別のDBやアプリケーションが消費できるDBアクティビティログを保持する
- スロット進捗は Log Sequence Number、つまり LSN で追跡され、LSN は primary Postgres DB に固有である
- 既存DBのレプリケーションスロットLSNを新DBへそのままコピーすることはできない
- データウェアハウスツールのようにレプリケーションスロットを消費するアプリケーションは、各ツールのドキュメントに従って移行戦略を決める必要がある
- 自前アプリケーションでレプリケーションスロットを使っているなら、既存DBと新DBの重複トランザクションを除去できる 冪等性メカニズム が役立つ
最終検証
- すべてのテーブルを publication に追加し、subscription が追いついたら、テーブル同士が一致しているか検証する必要がある
- 論理レプリケーションの遅延により、既存DBと新DBが同じ瞬間に完全一致することは難しいが、row 数比較で十分近いかどうかは確認できる
- Knockは各テーブルについて既存DBと新DBの row 数を数えるスクリプトを作成した
inserted_atカラムがあるテーブルは、10秒より古い row だけを比較し、直近10秒分はまもなくレプリケートされる前提で検証した- 一部テーブルではランダムな row サンプルを比較し、テーブル内容が一致しているかも追加確認した
アプリケーション切り替え方式
- 最終 cutover のため、アプリケーションが2つのDBに接続するよう変更できる
- トラフィックの少ないDBなら、設定を新DBに変えてアプリケーションを再起動する単純な方式で移行できる
- 同時活動が多いアプリケーションでは、既存DBと新DBの間で 競合する書き込み を避ける必要があった
- Knockの cutover スクリプトは次の順序で動作した
- すべてのアプリケーションインスタンスに、新しいクエリを新DBへ送るよう指示
- 実行中のDBクエリに 500ms の完了時間を与え、その後は強制キャンセル
- フラグ切り替え後の最初の1秒間は、新DBリクエストを意図的に一時停止し、保留中トランザクションが新DBへレプリケートされる時間を確保
- その後、DBアクティビティを通常化しつつ、新DBを参照させる
- 一部の特殊なDBワークロードは停止後、新DBへ再接続するよう再起動
- Knockは500msがほとんどのDBクエリより十分長く、強制的な接続解除によるエラーはなかったことを確認した
シーケンス処理
- Postgresの論理レプリケーションは sequence を同期しない
- 既存DBで sequence 値が使われても、新DBの sequence 値は増加しない
- Knockは feature flag 切り替え直前に、両DBへ接続するスクリプトを実行した
- 既存DBのすべての sequence について
SELECT nextval('sequence_name')で次の値を取得 - 新DBで
SELECT setval('sequence_name', value::int4 + 100000)により sequence を先送りする
- 既存DBのすべての sequence について
- この方式は sequence にギャップを作るが、Knockの sequence は bigint なので、10万件分の値を飛ばしても利用可能な sequence 空間に占める割合は実質0%に近かった
- 実際の cutover 中に使用される sequence 値の規模に応じて、ギャップサイズを調整すべきである
cutover 前に確認すべきこと
- 最終切り替え前の確認項目は、運用準備状況を広くカバーする
- すべてのテーブルの row 数が期待通り一致しているか
- すべての subscription が enable 状態で、エラーなく実行中か
- スキーマが一致しているか、マイグレーションリリースを凍結できるか
- 新DBがワークロードに見合うサイズになっているか
- 既存DBと新DBのクラスタトポロジーをそろえるために read replica が必要か
- 新DBで
REINDEXと標準のVACUUMメンテナンスを実施したか - Postgresリリースノートでアプリケーション回帰の可能性を再確認したか
- 新バージョンの staging DB で自動・手動テストを実施したか
- 最も負荷の高いクエリを
pg_benchで負荷試験したか - まだ減らせるリスクが残っていないか
- staging または test 環境で cutover 手順を何度も練習したか
- cutover 直前にDBバックアップを作成したか
実際の切り替え結果
- Knockは数週間かけてテーブルを1つずつレプリケートし、主に業務時間後とトラフィックが最も少ない時間帯に進めた
- staging 環境で cutover を何度も練習し、運用者の介入が多くなくても動作するよう手順を磨いた
- PG 15 replica とアプリケーション切り替えコードの準備後、最終点検を行ってフラグを切り替えた
- 実際の cutover は数秒以内に終わり、レプリケーション待ちのための意図的な短い latency blip を除けば、アプリケーションは動き続けた
- その後、一時的なアプリケーション変更を元に戻し、すべての接続を新DBへ恒久的に切り替え、新DBの subscription と既存DBを削除した
- KnockはPostgres 11.9から15.3へ 無停止移行を完了した
結論
- Postgresのメジャーバージョンを4つ一度に飛ばす作業は大変だが可能である
- 論理レプリケーション方式は、実際の cutover 前に何度も練習、テスト、やり直しができるため、予定ダウンタイム方式より安全な場合がある
- 途中で問題が起きても、既存DBの publication を削除してやり直せるため、サービス劣化なしに手順を巻き戻せた
- 完璧な100%可用性は技術的に不可能でも、無停止マイグレーションは大きなサービス中断なしにシステムを継続運用する助けになる
1件のコメント
Hacker News の意見
テーブルの内容を1つずつすべてコピーする方式は入出力負荷が大きすぎ、非常に大きなテーブルでは通用しない
より良い方法は、レプリケーションスロットを作成し、スナップショットを取得してから新しいインスタンスにリストアし、LSNを進めたうえで、そこからレプリケーションすること。そうすれば全データを持つ論理レプリカができ、そのレプリカをアップグレードすればよい
Instacartの記事に方法が載っている: https://archive.ph/K5ZuJ
記憶が正しければ記事には小さな誤りがいくつかあったが、全体的な手順は機能し、TB級のインスタンスをこの方法で何度もアップグレードしたことがある
先に論理レプリケーションを開始してからpg_upgradeを実行すると、破損のリスクがある。関連する議論はpgsql-hackersにある: https://www.postgresql.org/message-id/flat/20230217075433.u5...
解決するには、まず論理スロットを作成し、新しいクラスタをスロットのLSN位置まで進める。ただし論理レプリケーションはまだ開始しない。その後pg_upgradeを実行し、新しいPostgreSQLバージョンでクラスタが起動してから論理レプリケーションを開始する必要がある
Postgres.aiは最近、GitLabの複数のmulti-TiBクラスタを高負荷状態で無停止アップグレードする際、まさにこの方式を使い、PgBouncerのPAUSE/RESUMEも併用した。今週後半にAlexander Sosnaの発表が予定されている: https://www.postgresql.eu/events/pgconfeu2023/schedule/sessi...
テーブルごとの進行はずっと面倒だったが、より信頼できそうに見えた
この方式で、非常に大規模で活発なデータベースを多数成功裏にアップグレードしてきた
https://www.instacart.com/company/how-its-made/zero-downtime...
アプローチは興味深く、文書化もよくできているが、「現代の顧客は100%の可用性を期待する」という文には引っかかる
顧客としての好みでもないし、提供者としての経験とも違う。多くのワークロードでは、可用性より一貫性のほうがはるかに重要
提供側がダウンタイム枠を告知すると、むしろ自分のデータを慎重に扱っているサインのように見えて安心することが多い
製品の信頼性とワークロードの一貫性の両方に対する信頼を作りたかった。もちろん、一貫性があるふりをしながら不安定であるよりは、顧客の期待値を管理し、長期的により良い稼働時間のために意図的にダウンタイムを取るほうがはるかに良い
定期メンテナンス枠をあらかじめ想定してもらうことが、全体としてより堅牢なアーキテクチャにつながる可能性もある。顧客がダウンタイムに耐えられる安全策を作ればレジリエンスが高まり、チームも顧客をそのように信頼できるとき、より良い製品への投資に時間を得られる
次のメジャーバージョンアップグレードの後には、「ダウンタイムへの期待値設定こそが非常に高い稼働時間への道」という記事を書くかもしれない
AWSの顧客としては100%の可用性を期待する。自分の顧客は世界中にいて、ダウンタイムを設けられる時間がないから
AWSが今ではブルー/グリーンデプロイをサポートしている: https://aws.amazon.com/about-aws/whats-new/2023/10/amazon-rd...
AWSと何度かやり取りした後、実験は数時間止まり、後になってようやくAWS UIが切り替えが適用されていなかったことを認めた。幸い安全に失敗したが、GB以上のデータセットで実際の切り替えタイミングを合わせられるという信頼はない
次回は可能かもしれない
これは素晴らしい
経験したことの大半を自動化するツールを作ったので、役に立つなら、あるいはフィードバックやアイデアで拡張したいなら、いつでも歓迎する: https://github.com/shayonj/pg_easy_replicate
大きなテーブルで得られた発見は、こうしたツールにとって興味深いかもしれない。テーブルごとに適した戦略をもっと簡単に適用できるようにしてくれれば、今後この種のマイグレーションを行うチームにとって必須ツールになり得る
「Knockのようなサービスでは、予定されているかどうかにかかわらず、いかなるダウンタイムも許されない」というのは疑わしい
複雑なシステムなら障害もあるし、ダウンタイムもある。事前に告知された15分のダウンタイムは、ほぼすべてのSaaSビジネスで許容される。病院でも発電所でもない
サービスを実際以上に重要だと思い込むことで、見せかけの仕事が大量に生まれる。ここに費やしたエンジニアリング時間を、プロダクトや開発チームの生産性改善に使っていれば、ユーザーはもっと幸せだった可能性が高い。特に通知をキューに入れておき、ダウンタイム後に追いつけるならなおさら
15分のダウンタイムに対する補償条件があるエンタープライズSLAがあるなら正当化できるが、たいていはそうではない。実際には、すでに同程度かそれ以上の障害が何度か起きていた可能性も高い
データベース移行では、「短いダウンタイム」と「無停止」の間の作業量の差がたいていかなり大きいので、より重要になる。今回のように一回限りで、RDSの最新PostgreSQLバージョンが標準でサポートしている場合なら、特に正当化しにくいと思う
メンテナンスウィンドウを設けることも議論したが、ずっと悩んでいたのは、本番データでアップグレードをどうリハーサルできるかだった。本番データと同期したPG 15のレプリカは、ワークロードが想定どおり動作するかを検証するうえで非常に重要だった
リアルタイムのレプリカを使えば、本番環境への影響を最小限にしながらリハーサルできる
今回の移行で大きく学んだのは、こうしたプロジェクトで考え得るすべてのリスクを追跡し、緩和することがどれほど有用かという点だった。最終的には、インプレースアップグレードのリスクが、選んだ経路のリスクより大きく見えた。それはメンテナンスウィンドウの有無とは別の判断だった
おまけに、今後このアプローチが必要になったとき、このブログ記事が出発点となって数週間を節約してくれるはず。同じような状況の他チームにも役立つことを願う
米国最大級の電子カルテ提供企業であるEpicでさえ、アップグレードのために少なくとも月1回、毎回30〜60分程度の予定ダウンタイムがある
再起動のタイミングを制御できない。プロセスを開始すると、切り替えが1時間後、2時間後、3時間後に始まるかもしれず、いつ再起動されるのかも分からず、制御もできない
レプリカがあると並列にアップグレードされ、任意のタイミングで再起動されるため、さらに厄介
したがって、データベースのサイズによっては数時間に及ぶ時間帯の中で、任意のタイミングの利用不能を許容できないなら、RDSのアップグレードには論理レプリケーション方式が事実上唯一の選択肢になる
インスタンスが大きいほど問題は難しくなる
Jiraが1日15分落ちても、普通は大きな影響はない。作業キューには他の仕事があり、最悪の場合に複数の障害が重なっても、誰かに約束した文書作業がある
しかしAtlassian製品群全体が同時に死ぬと、仕事を続けるための緩衝作業を維持するのははるかに難しくなる。企業のすべてのアプリに同じストレージアレイを使わせると、生産性の損失が5%から95%に跳ね上がることもある
そういう競合は、私の要求を自分たちの都合より優先しているということ
あなたの障害は、同時に私の障害でもある
hava.ioで今このプロセスを進めているところ
AWS RDS PostgreSQL 11.13から15.5へ上げている
最終的にはpglogicalを使った一方向レプリケーションという、比較的単純なアプローチを選んだ。Google Cloud SQLからAWS RDSへ無停止移行を同じ方式で行った経験があったので、顧客に見える影響なしに動作するという確信があった
pglogicalはこの種の移行をかなり単純にしてくれる。常に速いわけではないが、データベース全体が新しいインスタンスへ段階的にレプリケートされる数日を待てるなら問題ない
この方式は、ストレージの種類とサイズを変更する自由度も高めてくれた。IOPSを得るためにストレージを過剰に確保していたため、ストレージ種別を変えてサイズも減らしたかった。そのため単純なスナップショット復元ではだめだった
「セールスエンジニアリング」の段階でAWSが約束していたあの機能のことを言っているのかなと思う
実際には、メジャーバージョンアップグレードを強制的にやらなければならなかったとき、それを提供できなかった
バックアップからレプリカを初期化できないというのは驚き
それが可能なら、安定している既存データベースの内容を新サーバーへストリーミングする苦労を減らせたはず
そしてこれは「無停止」ではなく、新サーバーへサービスを切り替える数秒のダウンタイムがある
記事は一貫性をどう保ったのかを省いている。たとえば、アプリケーションを一定期間そのまま両方のサーバーにつなぐことはできない。読み取りは両方から提供できるかもしれないが、それも完全ではなく、書き込みは必ず一方のサーバーだけに行く必要がある
最後に、ロールバックの選択肢もない。こうした大規模データを一度に持ち上げて移す作業は、深夜に問題が起きることがある。だから、前の段階に戻せて、朝になってもサービスが生きていると確信して眠れる計画が常に必要
特に、新サーバーに書き込みトランザクションをすでに送った後で、何らかの理由で旧サーバーへ戻す必要がある場合は難しく、データはすでに不整合になっている
何らかのレプリケーション手段がない、またはアプリケーション層に持ち上げない限り、復元されたシステムには書き込みの欠落が生じる
たとえばアプリを修正して二重書き込みを適用できる。RDBMSからApache Cassandraのようなまったく別のデータベースへアプリケーション全体をリプラットフォームしたチームも、そうしていたと理解している
私たちの状況では、二重書き込みはPostgreSQLの標準機能でストリーミングレプリケーションを設定するよりもリスクが高く見えた。ただし、チームによってはより良い選択かもしれない
「無停止ではない」「一貫性維持の詳細が抜けている」という点については、記事では一貫性を保ち、APIのダウンタイムを避けた方法を詳しく扱っている。要点は、アプリが両方のデータベースに接続していたが、新しいデータベースをデフォルトでは使っていなかったということ
その後、LaunchDarklyですべてのアプリインスタンスに切り替えシグナルを送り、LaunchDarklyは全インスタンスと低遅延の接続を維持していた
シグナル後の最初の1秒間、サーバーはレプリケーションが追いつけるようにデータベースリクエストをキューに入れた。このため短いレイテンシスパイクはあったが、意図的に計算した許容範囲内だった。その一時停止の後は、リクエストは通常どおり流れたが、対象は新しいデータベースとなり、切り替えは完了した
旧データベースに残っているトラフィックについては、500msのタイムアウトで強制切断する処理も入れた。この値はp99のクエリ時間よりずっと大きかったため、実行中のクエリが強制終了されることはなかった。これにより旧データベースへのトラフィックが止まり、レプリケーションが追いつくのに十分な時間ができた
ロールバックの選択肢はブログ記事には入れなかったが、PG 11.9の代替データベースを作り、15.3データベースをその第三のデータベースへレプリケートする案も検討した。中断する必要があれば、同じバージョンのこのデータベースへロールフォワードできた
ステージングでアップグレード手順を何度も練習して成功可能性を確認した後、この案は使わないことにした。何度もリハーサルしていたので、本番の切り替え時には自信があった。本番でもカナリアリリースで一部の読み取り専用ワークロードを15.3インスタンスに対して検証し、それを読み取りレプリカのように扱った
深夜の問題を避けるため、意図的に週末の早い夕方に実施した。切り替えは綿密にスクリプト化し、リハーサルして、人為ミスのリスクを減らした
致命的な失敗が起きた場合、システムは旧データベースへ戻せる準備もできていた。この場合、新データベースに入った一部データの損失が発生したはずで、重要部分は調整できるよう準備していた。データ損失リスクを下げるため、切り替え中は一部のバックグラウンドジョブを一時停止して書き込み数を減らした
こうした詳細は、Knock固有の考慮事項よりもPostgreSQL関連の詳細に集中しようとしたため、ブログには入れなかった。このプレイブックを適用しようとするチームは、常に自分たちの文脈でリスク一覧を作り、緩和すべき
シーケンス関連の部分は確かに興味深い
しばらくシーケンスはほとんど使わず、主にシーケンシャルUUIDやUUID v7、またはHiLoのような方式を使っている
https://en.wikipedia.org/wiki/Hi/Lo_algorithm
IETFドラフト仕様を基準に12ビットのシーケンスを作り、現在のUNIX epochミリ秒と62ビットの乱数を組み合わせてUUIDを構成する方式
核心は、
uuidv7_seqを用意し、generate_uuidv7()関数でclock_timestamp()、NEXTVAL、RANDOM()を使ってUUID v7形式の値を返すようにすること複数の場所でKSUIDとUUID v4を使っている。この「落とし穴」はすべてのシーケンスに当てはまるので、こうしたマイグレーションを行う際の一般的な助言として触れる価値がある
[1]: https://segment.com/blog/a-brief-history-of-the-uuid/
成功させた大変な作業を貶めるつもりはないが、新しいバージョンが出るたびに小さくアップグレードしなかった理由が気になる
読み物としては素晴らしいが、大きな嵐を迂回せず、悲劇で終わる可能性があると知りながら正面突破することを選んだ船乗りたちの話のように感じる
この場合、小さなアップグレードは選択肢になかったのだろうか?「小さなアップグレード1回でも大きなアップグレードと同じだけダウンタイムコストがかかるので、できるだけ先延ばしにした」という感じなのか気になる。導入部にそういうヒントは見えるが、読みすぎかもしれない
「先延ばしにして追い詰められた」というより、いつかジャンプしなければならないことは分かっていたが、「壊れていなければ直さない」に近かった
実際の答えが60秒未満だとしても、15まで到達する過程でそのダウンタイムを何度も経験したはず