← ブログに戻る

Strangler Fig で React/FastAPI 段階移行

「もう限界です、一から作り直しましょう」を、私は 5 年で 3 回聞きました。3 回とも承認しませんでした。そのたびに Strangler Fig で絞め殺す方に振ったからです。今日はその実装型を書きます。

全面書き直しが失敗する構造

書き直し提案は、いつも同じ姿で来ます。「レガシーが読めない」「新しい設計なら 3 ヶ月で書き直せる」「今のうちに刷新した方が長期的に得」。魅力的です。魅力的なので、5 年で 3 回聞きました。

問題は、書き直し期間中も旧システムを保守し続ける必要があることです。人員は倍にならないので、実質は片手落ちの二重運用になります。しかも新システムが完成するまで、ユーザーには 1 円の価値も届きません。半年後にリリース、と言った時点で、その半年間の売上は据え置きです。

そして完成日は必ず遅れます。遅れる理由は毎回「旧システムの隠れた仕様が思ったより多かった」です。これは書き直し前に予測できません。書き直しに着手して初めて、隠れた仕様の量が見えるからです。10 個あったと思っていた仕様が、実装を始めると 47 個あります。この 47 という数字は経験則ですが、大きく外れたことは一度もありません。

もう 1 つの構造的な問題は、旧仕様の中に「なぜこう書かれているか誰も覚えていない」処理が必ず紛れていることです。誰も覚えていないので、書き直しチームはその処理を削除するか、意味を推測して再実装します。3 ヶ月後、コールセンターに「請求書の合計が微妙に違う」電話が入り、その日のうちに旧コードを grep して該当処理を掘り出す羽目になります。

Martin Fowler が絞め殺しイチジク (Strangler Fig) を持ち出したのは、この構造を回避するためでした。パターンの狙いはシンプルで、旧新を並走させ、動作を照合しながら少しずつ旧を細らせていきます。書き直しではなく、置き換えです。この差が、6 ヶ月の炎上と 14 ヶ月の平和を分けます。

Strangler Fig の 4 フェーズ

絞め殺しイチジクは熱帯雨林の植物です。宿主の木に巻きつき、少しずつ覆い尽くし、最後に宿主が枯れても自分の幹だけで立っている、というスタイルの生き方をします。ソフトウェアの置き換えも、同じ 4 段階で回します。

Phase状態新コードのトラフィック割合削除可能な旧コード
1並走0%なし
2カナリア1-10%なし
3主流50-100%一部
4単独100%全て

Phase 1 は新コードを配置するだけで、まだ誰も呼びません。Phase 2 はフィーチャーフラグで一部のユーザーに開けて、旧新の挙動差を観測します。Phase 3 は差分がないと確認できたら比率を上げていきます。Phase 4 は旧コードを消す作業です。

重要なのは、Phase 1 から Phase 3 まで一度もユーザー影響がゼロで進むことです。Phase 4 の削除だけが不可逆で、それも旧コードが 1 週間触られなかった実績があれば安全に踏み切れます。1 週間というのは私の感覚値で、書き込み頻度が低いドメインなら 2 週間見た方が安心です。逆にトラフィックが多い API なら 3 日でも十分な統計量が取れます。

各 Phase の判定は数字で持ちます。「新コードのカバレッジ 80% 以上」「差分ログ 0 件が 168 時間連続」「レイテンシ p95 が旧比 +20% 以内」のような閾値をチケットに明記して、閾値を超えたら Phase を上げる、下回ったら戻す、というルールで機械的に運用します。感覚で進めると、必ずどこかで議論に時間を取られます。

Phase 2: FeatureFlag ミドルウェア

React 側のフラグ切り替えは、Provider + hook で薄く書きます。GrowthBook でも LaunchDarkly でも Unleash でも、抽象化の型は同じです。

// src/lib/feature-flags.tsx
import { createContext, useContext, ReactNode } from "react";

type FlagMap = Record<string, boolean>;
const FlagContext = createContext<FlagMap>({});

export function FeatureFlagProvider({
  flags,
  children,
}: {
  flags: FlagMap;
  children: ReactNode;
}) {
  return <FlagContext.Provider value={flags}>{children}</FlagContext.Provider>;
}

export function useFeatureFlag(key: string): boolean {
  return useContext(FlagContext)[key] ?? false;
}

利用側は 3 行です。

// src/pages/UserListPage.tsx
import { useFeatureFlag } from "@/lib/feature-flags";
import { LegacyUserList } from "@/legacy/UserList";
import { UserList } from "@/components/UserList";

export function UserListPage() {
  const isNew = useFeatureFlag("user-list-v2");
  return isNew ? <UserList /> : <LegacyUserList />;
}

フラグ判定を UI コンポーネント側に置くのではなく、ページの入口で 1 回だけ判定するのがコツです。UI の深いところに useFeatureFlag を撒くと、フラグを消すときに散らかった参照を全部拾う必要が出てきます。入口 1 箇所なら Phase 4 の削除は 3 行の diff で終わります。

フラグ配信は環境変数ではなく、ユーザー単位で ON/OFF できるサービスを使うと Phase 2 のカナリアが楽になります。私は無料枠がある GrowthBook を使うことが多いですが、社内で LaunchDarkly が動いていればそちらに乗ります。「特定のユーザー ID のみ ON」「10% ロールアウト」「レイテンシが閾値を超えたら自動 OFF」のような制御が管理画面から即座に効くのが利点で、これがない状態で Phase 2 に入ると事故対応が遅くなります。

React 側でもう 1 つ気を付けたいのは、Provider の位置です。App のトップに 1 つだけ置いて、SSR / CSR で同じフラグ値が入るように統一します。Next.js の App Router なら Server Component からフラグを読み込んで Client Component にプロップで渡すのが素直です。ここを揃えないと、初回レンダリングで旧、hydration 後に新、というちらつきが起きます。

Phase 2: 並走ルーター (FastAPI 側)

バックエンドは Nginx などのプロキシ層で新旧を分ける方法もありますが、私は FastAPI の内側にルーターを 2 本用意する方が制御が効きます。フラグ判定の粒度をユーザー ID で刻めるからです。

# app/routers/users.py
from fastapi import APIRouter, Depends, Request
from app.legacy.users import list_users_legacy
from app.new.users import list_users_v2
from app.flags import get_flags

router = APIRouter(prefix="/api/users")


@router.get("")
async def list_users(request: Request, flags = Depends(get_flags)):
    user_id = request.headers.get("X-User-Id", "")
    if flags.is_enabled("user-list-v2", user_id):
        return await list_users_v2()
    return await list_users_legacy()

Depends(get_flags) にすると、テスト時にフラグ実装を差し替えられます。私は本番では GrowthBook の Python SDK を、テストでは辞書を返すダミー実装を注入して切り替えています。フラグ実装をハードコードで os.getenv に置くと、テスト毎に環境変数を書き換える必要が出て、並列テストが崩れます。

X-User-Id ヘッダで刻んでいる点も意図があります。Phase 2 のカナリアは、ランダム抽選より「社内ユーザー全員」「特定顧客のみ」の指定 ON にした方が事故時のリカバリが早いです。ランダム抽選だと「今新コードで動いている顧客」の一覧が取れず、問題が起きたときにどの顧客に連絡すべきかが不明になります。ユーザー ID ベースなら管理画面から即座にリストが引けます。

ここで書き直したくなる衝動が来ます。「もう list_users_legacy を消したい」「新旧の重複が気持ち悪い」。踏みとどまってください。Phase 3 に入るまで、旧関数の 1 行も触らない、が Strangler Fig の唯一のルールです。旧を触ると、Phase 3 で観測している「旧新の差分」が「旧の差分と旧の差分」になり、比較の意味が消えます。

Phase 3: 旧新差分の観測ログ

比率を上げる前に、旧新が本当に同じ結果を返しているかを確認する必要があります。私は Phase 2 の途中で、旧新を両方叩いて差分を JSONL に吐くシャドウ実行を挟みます。

# app/routers/users_shadow.py
import json
import time
from pathlib import Path
from deepdiff import DeepDiff
from app.legacy.users import list_users_legacy
from app.new.users import list_users_v2

DIFF_LOG = Path("/var/log/strangler/user-list-diff.jsonl")


async def list_users_with_shadow(user_id: str):
    t0 = time.perf_counter()
    result_legacy = await list_users_legacy()
    t1 = time.perf_counter()
    result_new = await list_users_v2()
    t2 = time.perf_counter()

    diff = DeepDiff(result_legacy, result_new, ignore_order=True)
    if diff:
        DIFF_LOG.parent.mkdir(parents=True, exist_ok=True)
        with DIFF_LOG.open("a") as f:
            f.write(json.dumps({
                "user_id": user_id,
                "diff": diff.to_dict(),
                "latency_legacy_ms": (t1 - t0) * 1000,
                "latency_new_ms": (t2 - t1) * 1000,
            }, default=str) + "\n")

    return result_legacy  # 返却はまだ旧

差分ログをレビューして、意味のある差分がゼロになるまで Phase 3 には進みません。私は「1 週間差分ゼロ」を実運用の基準にしています。1 週間走らせて 1 件も差分が出なければ、旧新が同値だと納得できます。

DeepDiff(ignore_order=True) は、リストの順序が仕様上意味を持たない場合に便利です。順序が意味を持つ場合は ignore_order=False に戻して、順序差も差分として拾います。ここは対象 API ごとに考える必要があって、機械的に決まりません。私は最初 ignore_order=True で走らせて、差分ゼロが出たあとに順序込みでもう 1 週間走らせる、という 2 段構えを取ることが多いです。

差分が出た場合、9 割は新コード側のバグです。1 割は「実は旧の挙動が仕様ではなくバグだった」というやつで、これが出るとステークホルダーに相談する会議が 1 回追加されます。「旧では 100 円を返していたが、新では 105 円を返している。仕様書には 105 円と書いてあるが、顧客はずっと 100 円請求されてきた」という話で、旧のバグに合わせるか仕様に合わせるかの判断が要ります。

レイテンシの差分も、この段階で見ておくと Phase 3 の途中で慌てずに済みます。私は latency_new_ms / latency_legacy_ms の p95 を毎日 Slack にポストするようにしています。新が旧より遅い場合、Phase 3 のトラフィック引き上げ前に最適化しないと、切り替えた瞬間に負荷アラートが鳴ります。

Phase 4: レガシー削除

差分ゼロが 1 週間続き、Phase 3 で新コード 100% に振り切れたら、Phase 4 の削除です。ここは機械的に進めます。

# 1. フラグを完全に true 固定
# GrowthBook / LaunchDarkly の management API で kill-switch を外す

# 2. コード側からフラグ参照を消す
git grep -l "user-list-v2" src/ | xargs sed -i '/user-list-v2/d'

# 3. 旧関数を削除
rm src/legacy/UserList.tsx
rm app/legacy/users.py

# 4. ルーターから旧分岐を消す
# 前掲の list_users から if 文を落として list_users_v2 直呼び

# 5. shadow ロガーも消す
rm app/routers/users_shadow.py

# 6. tests を回して緑を確認、PR

削除 PR は、Phase 1 から Phase 3 までの積み重ねが正しく機能した記念碑です。私はいつも「削除の PR は削除だけ」を守っています。ここで何か新機能を混ぜると、削除の可否がぼやけます。

削除 PR がマージされた瞬間、旧コードが Git 履歴の中だけの存在になります。私はこの瞬間に必ず、削除された旧関数の一覧と Phase 開始日のスクリーンショットを Notion に貼るようにしています。半年後に「あの処理どこ行った?」となったとき、コミット SHA まで数秒で辿れます。

ハマりどころ 3 つ

フラグ長寿命化。Phase 2 のまま 6 ヶ月放置されたフラグを、私は数え切れないほど見てきました。Phase 3 に進む閾値と、Phase 4 で削除する期限を、フラグ作成時にチケットに書いておく方が良いです。フラグには賞味期限があります。

シャドウの副作用。Phase 3 の観測ログで、旧新を両方叩くと、書き込みを伴う API では 2 回書きになります。私は書き込み API では旧だけを実行し、新はドライラン (トランザクション開始→ロールバック) で結果だけ取る方式に切り替えました。ここで気付かないと本番データが二重登録されます。

フラグ経路が別のバグを隠す。フラグの ON/OFF で挙動が変わる分岐が増えるほど、テストマトリクスが 2 倍になります。私は Phase 2 のフラグは 1 プロジェクトにつき常時 3 個まで、を自主ルールにしています。それ以上になったら片付ける優先度を上げます。

Phase 3 のロールバック手順が事前に共有されていない。カナリア 30% で「新の p99 レイテンシが 800ms に跳ねた」という事態は起きます。このとき、フラグを 0% に戻す判断を誰が下すかがチームで合意されていないと、Slack で議論しながら数字が悪化していきます。私は Phase 2 に入る時点で、「オンコール担当者は独断でフラグを 0% に戻して良い」というルールを事前に文書化しています。事後に議論する形にしないと、初動が必ず遅れます。

4 フェーズを回した実例

直近の案件で、Django 1.11 の管理画面を Next.js + FastAPI に置き換えました。全部で 78 の画面がありました。全面書き直しの見積もりは 8 ヶ月でしたが、実際は Strangler Fig で 14 ヶ月かけて 1 画面ずつ移しました。長いと感じるかもしれませんが、14 ヶ月の間ずっとユーザーは新旧の混在に気付かず、フリーズ期間ゼロで移行が終わりました。

面白かったのは、Phase 3 まで進めた画面のうち 4 つが、Phase 4 の削除タイミングを待たずに「実はこの画面、誰も使ってなかった」ことが判明したケースです。GA4 で新旧両方のトラフィックがゼロだったので、置き換え前に消せば良かったのですが、そこまでは調査していませんでした。Strangler Fig の副産物として、こういう「棚卸し」の機会にもなります。

書き直しなら 8 ヶ月のスケジュールを 2 度延ばして 18 ヶ月になっていたと思います。実案件で書き直しを見積もり通り終わらせた例を、私はまだ見たことがありません。書き直しの見積もりは「新機能の見積もり」と同じロジックで作られるので、旧仕様の隠れた重量を取り込めていません。Strangler Fig は 1 画面ずつ順に隠れ仕様を発見するので、見積もりのブレが 1 画面分に収まります。

パターン適用のチェックリスト

新規案件で Strangler Fig を導入する前に、私は以下を確認しています。

  • 旧新が同じ DB を読める状態にあるか (別 DB なら Phase 3 の差分観測が組めない)
  • フィーチャーフラグ配信の仕組みが用意されているか
  • 旧コードのカバレッジがゼロではないか (ゼロだと Phase 2 の差分基準を作れない)
  • 削除の PR を通す権限を持つ人が、Phase 4 まで残るか (人事異動で消えると Phase 4 が塩漬けになる)
  • 各 Phase の判定基準がチケットに数字で書かれているか

このチェックリストは、書き直しの見積もりを頼まれたときに真っ先に見ます。1 つでもチェックが付かない項目があれば、Strangler Fig より前にその項目を埋める作業を先に入れます。

「一から作り直そう」は、大抵は正しく感じられるが、正しくない選択です。Strangler Fig で 4 フェーズに刻めば、書き直しを承認しなくて済むどころか、承認した想像上の未来より早く終わります。次に「限界です、書き直しましょう」を言われたら、この記事の 4 フェーズを開いて、代わりに絞め殺す設計から始めてみてください。