このページに書いてあること
3つのWebアプリを刷新する例で、DNSを一度に切り替える方式と、アプリごとに切り替える方式を比べます。接続先の切り替えと、旧系を停止できる条件は分けて考えます。
前提
注文・在庫・請求の3アプリがあり、新旧アプリは同じDBを使います。既存のURLを保ち、DNSの参照先を変更する構成です。

切替前に、DNSレコードのTTL、クライアントやリゾルバーのキャッシュ、新旧アプリが共通DBを読み書きできる期間を確認します。先にTTLを短くしても、既に旧TTLでキャッシュされた回答はその場では変わりません。旧系と新系の到達数・失敗率・書き込み結果を別々に見られるようにします。
ビッグバン方式
新しい3アプリを同時に利用可能な状態にし、DNSレコードをまとめて切り替えます。利用者のURLを保てますが、切り替え直後はキャッシュを持つ利用者が旧IPへ到達することがあります。

移行時は、旧IPと新IPの両方で代表操作の成功、書き込み後の再取得、エラー率を確認します。新系だけで書き込みが失敗するなど、事前に決めた中止条件を満たしたら、DNSを旧系へ戻して調査します。戻した後も新系へ到達するアクセスを監視します。

旧系を止めるのは、想定したキャッシュ期間を経過し、旧系への到達が監視上収束し、新系で同じ操作を継続できると確認してからです。
拡張方式
準備ができたアプリから切り替えます。例えば先に注文を新系へ切り替え、在庫と請求は旧系に残します。変更の影響を1アプリに絞れますが、新旧の組み合わせで動く期間が長くなります。

共通DBに新しい項目を追加するときは、旧アプリの読み書きも維持してから新アプリを出します。旧アプリが使う列を削除するのは、移行と切り戻しの期間が終わってからです。

以下は実DNSやDBを操作しないシミュレーションです。レコード、クライアントのキャッシュ、DBの入出力契約だけをモデル化し、切り替えと切り戻しの直後に新旧の到達先が混在することを確認します。
from dataclasses import dataclass
@dataclass(frozen=True)
class Record:
target: str
ttl_seconds: int
class Resolver:
def __init__(self):
self.cache = {}
def resolve(self, records, app, now):
target, expires = self.cache.get(app, (None, 0))
if now >= expires:
record = records[app]
target = record.target
self.cache[app] = (target, now + record.ttl_seconds)
return target
def can_use_shared_database(target, available_contracts):
required_contract = "v1" if target == "old" else "v2"
return required_contract in available_contracts
def check(condition, label):
if not condition:
raise AssertionError(label)
print(label)
def main():
apps = ("orders", "inventory", "billing")
records = {app: Record("old", 300) for app in apps}
early = Resolver()
check(early.resolve(records, "orders", 0) == "old",
"t=0: first resolver cached the old route for 300s")
records = {app: Record("old", 30) for app in apps}
records["orders"] = Record("new", 30)
fresh = Resolver()
old = early.resolve(records, "orders", 50)
new = fresh.resolve(records, "orders", 50)
check((old, new) == ("old", "new"),
"t=50: early resolver uses old; fresh resolver uses new")
check(fresh.resolve(records, "inventory", 50) == "old",
"t=50: inventory remains on old during the staged cutover")
expanded = {"v1", "v2"}
check(all(can_use_shared_database(route, expanded) for route in (old, new)),
"shared DB with v1 and v2 accepts both generations")
check(not can_use_shared_database(old, {"v2"}),
"shared DB with v2 only rejects the old app")
records["orders"] = Record("old", 30)
check((fresh.resolve(records, "orders", 60),
Resolver().resolve(records, "orders", 60)) == ("new", "old"),
"t=60: rollback changes fresh lookups; cached new routes remain")
check(fresh.resolve(records, "orders", 80) == "old",
"t=80: the cached new route expired in this model")
if __name__ == "__main__":
main()python3 cutover_model.py
# t=50: early resolver uses old; fresh resolver uses new
# shared DB with v1 and v2 accepts both generations
# shared DB with v2 only rejects the old app
# t=60: rollback changes fresh lookups; cached new routes remain出力はモデルの時刻です。本番では、レコードのTTLだけで停止時刻を決めず、接続先ごとの実際の到達数・失敗率・保存結果も確認します。

