アイキャッチ

設計・テスト・レビュー

システム移行のDNS切り替え|新旧の併存と切り戻しを3アプリで考える

このページに書いてあること

3つのWebアプリを刷新する例で、DNSを一度に切り替える方式と、アプリごとに切り替える方式を比べます。接続先の切り替えと、旧系を停止できる条件は分けて考えます。

前提

注文・在庫・請求の3アプリがあり、新旧アプリは同じDBを使います。既存のURLを保ち、DNSの参照先を変更する構成です。

既存の3つのWebアプリと共通DBの構成

切替前に、DNSレコードのTTL、クライアントやリゾルバーのキャッシュ、新旧アプリが共通DBを読み書きできる期間を確認します。先にTTLを短くしても、既に旧TTLでキャッシュされた回答はその場では変わりません。旧系と新系の到達数・失敗率・書き込み結果を別々に見られるようにします。

ビッグバン方式

新しい3アプリを同時に利用可能な状態にし、DNSレコードをまとめて切り替えます。利用者のURLを保てますが、切り替え直後はキャッシュを持つ利用者が旧IPへ到達することがあります。

新旧の3アプリを並行稼働させた切替前

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

3アプリのDNS参照先をまとめて新系へ切り替える構成

旧系を止めるのは、想定したキャッシュ期間を経過し、旧系への到達が監視上収束し、新系で同じ操作を継続できると確認してからです。

拡張方式

準備ができたアプリから切り替えます。例えば先に注文を新系へ切り替え、在庫と請求は旧系に残します。変更の影響を1アプリに絞れますが、新旧の組み合わせで動く期間が長くなります。

1つのWebアプリを先に新系へ切り替える段階

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

残りのWebアプリを順に新系へ切り替える段階

以下は実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だけで停止時刻を決めず、接続先ごとの実際の到達数・失敗率・保存結果も確認します。

関連記事リリース手順書の書き方|作業・確認・切り戻しをセットにした記入例 切り替え時に誰が何を確認し、どの結果で切り戻すかをリリース手順書に落とすときに使えます。 記事を読む

出典 RFC 1034のDNSキャッシュとTTL、RFC 8767の期限切れDNS応答

-設計・テスト・レビュー
-