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 フェーズを開いて、代わりに絞め殺す設計から始めてみてください。
この記事は役に立ちましたか?