ブログ一覧に戻る
Cloudflare WorkerNestJSfail-safeアーキテクチャDR

メンテナンスモードのエッジ層 + アプリ層ハイブリッド設計 — Cloudflare Worker + NestJS Guard + fail-safe = pass-through

はじめに

「メンテナンスモード」と聞くと、多くのプロジェクトは アプリケーション層に flag を置く 設計を選びます。NestJS Middleware が DB / 環境変数を read して 503 を返す、というパターンです。

これで済めば話は簡単ですが、DB が落ちている状況 でメンテを ON にしたい場合、その flag を read する経路自体が機能しなくなります。本記事では、Cloudflare Worker (エッジ層) と NestJS Guard (アプリ層) の 2 段防御 で対応した実装パターンと、「判定ミスで本番が止まらない」fail-safe 設計の原則を共有します。

問題提起 — 一段防御の限界

NestJS Middleware だけでメンテ判定する場合:

async use(req, res, next) {
  const flag = await db.query.featureFlag.findFirst({
    where: eq(featureFlag.key, "maintenance.mode"),
  });
  if (flag?.enabled === false) {
    return res.status(503).json({ error: "MAINTENANCE_MODE" });
  }
  next();
}

この設計には致命的な欠陥があります。以下のシナリオで対応できなくなります:

シナリオ問題
DB 全停止flag を read できない → メンテ画面を返せず、Cloud Run も 5xx を返す
API 異常デプロイCloud Run が起動失敗 → そもそも Middleware まで到達しない
GCP リージョン障害全インフラ不可用
セキュリティインシデント即時遮断したいが、アプリ層では「クライアントは到達できる」状態

これらのケースで必要なのは、アプリより上流で 503 を返せる経路。それが edge layer です。

ハイブリッド構成 — エッジ層 + アプリ層の 2 段防御

私たちの最終設計は 2 段で組みました:

┌──────────────────────────────────────────────────────────────┐
│  User (browser / mobile / desktop cloud sync)               │
└────────────┬─────────────────────────────────────────────────┘
             │
             ▼
┌──────────────────────────────────────────────────────────────┐
│  Cloudflare Worker (エッジ層)                               │
│  - KV から maintenance state を read (30s cache)             │
│  - inMaintenance=true → 503 メンテ HTML を返す               │
│  - allowedPaths (status, health) は通す                      │
│  - Cloudflare Access JWT 持ち (ADMIN) は bypass              │
└────────────┬─────────────────────────────────────────────────┘
             │ pass-through
             ▼
┌──────────────────────────────────────────────────────────────┐
│  GCLB → Cloud Run                                            │
└────────────┬─────────────────────────────────────────────────┘
             │
             ▼
┌──────────────────────────────────────────────────────────────┐
│  NestJS Global Guard (アプリ層)                             │
│  - feature_flag テーブルから state を read (30s cache)       │
│  - READ-ONLY: GET 系 + @ReadOnlySafe のみ通す               │
│  - kill-switch: 該当 endpoint のみ 503                      │
│  - FULL_STOP: 全 endpoint 503 (status / health 除く)         │
└──────────────────────────────────────────────────────────────┘

エッジ層 (Cloudflare Worker + KV)

Worker は実質的に 「DNS の後段にフックする小さな TypeScript 関数」 で、世界 300+ PoP で動きます。Cloud Run / DB が落ちていても稼働します。

// apps/maintenance-worker/src/worker.ts
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // STEP 1: KV から state を取得 (Cloudflare KV は edge に分散された JSON ストア)
    let state: MaintenanceKVState | null = null;
    try {
      state = await env.MAINTENANCE_KV.get<MaintenanceKVState>("state", "json");
    } catch {
      // fail-safe: KV エラー → pass-through (= 通常稼働とみなす)
      return fetch(request);
    }

    // STEP 2: state なし or inMaintenance=false → pass-through
    if (!state || state.inMaintenance !== true) {
      return fetch(request);
    }

    // STEP 3: allowedPaths にマッチ → pass-through (例: /health, /system-status)
    const url = new URL(request.url);
    if (state.allowedPaths?.some((p) => url.pathname.startsWith(p))) {
      return fetch(request);
    }

    // STEP 4: Cloudflare Access JWT 持ち (管理者 bypass) → pass-through
    if (await isValidMaintenanceBypass(request, env)) {
      return fetch(request);
    }

    // STEP 5: メンテナンス HTML を返す
    return new Response(renderMaintenancePage(state), {
      status: 503,
      headers: {
        "content-type": "text/html; charset=utf-8",
        "retry-after": "300",
      },
    });
  },
};

ポイント:

  • KV は eventually consistent — 書き込み後 30-60 秒で全 PoP に伝播。即時反映は期待しない設計
  • 30s キャッシュ — Worker は KV を都度 read するが、Cloudflare 側が自動でキャッシュ
  • JWT 検証 + email allowlist — bypass を許可する email は env で設定、JWKS は Cache API で 1 時間キャッシュ

アプリ層 (NestJS Global Guard + feature flag テーブル)

@Injectable()
export class MaintenanceGuard implements CanActivate {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const skip = this.reflector.getAllAndOverride<boolean>(
      SKIP_MAINTENANCE_KEY,
      [context.getHandler(), context.getClass()],
    );
    if (skip) return true; // /system-status / /health 等は素通り

    const state = await this.state.get(); // 30s キャッシュ

    // kill-switch チェック
    const killKey = this.reflector.getAllAndOverride<string | undefined>(
      KILLABLE_KEY,
      [context.getHandler(), context.getClass()],
    );
    if (killKey) {
      const killed = state.killSwitches.get(killKey);
      if (killed) throw new HttpException({ error: "FEATURE_KILLED" }, 503);
    }

    if (!state.inMaintenance) return true;

    // FULL_STOP: 全 deny
    if (state.level === "FULL_STOP") {
      throw this.maintenanceException(state);
    }

    // READ_ONLY: GET 系 or @ReadOnlySafe のみ通過
    if (state.level === "READ_ONLY") {
      const request = context.switchToHttp().getRequest<Request>();
      const method = (request.method ?? "GET").toUpperCase();
      if (["GET", "HEAD", "OPTIONS"].includes(method)) return true;

      const readOnlySafe = this.reflector.getAllAndOverride<boolean>(
        READ_ONLY_SAFE_KEY,
        [context.getHandler(), context.getClass()],
      );
      if (readOnlySafe) return true;

      throw this.maintenanceException(state);
    }

    return true;
  }
}

@ReadOnlySafe() decorator は read-only モード中も通過可能であることを明示する装飾です。deny by default で、明示的に許可された endpoint だけが書き込みを継続できます。

@Controller('users')
export class UsersController {
  @Get('me')
  @ReadOnlySafe()
  me() { ... }  // READ_ONLY モード中も通る

  @Post()  // decorator なし → READ_ONLY モード中は 503
  create() { ... }
}

fail-safe = pass-through という原則

実装で最も繊細だったのが fail-safe 設計 です。「判定に必要な情報が読めない時、どちらに倒すか」を間違えると、判定システム自身のバグで本番が止まる という最悪の事故が起きます。

「判定ミスは pass-through」が正解

状況「block 側に倒す」場合「pass-through 側に倒す」場合 (採用)
CF Worker の bug全顧客が 503 (本番事故)普通に通る (= 通常稼働)
CF KV が読めない全顧客が 503普通に通る
NestJS が DB エラー全 503 (本番事故)普通に通る
feature_flag が読めない全 503普通に通る

block 側に倒すと、メンテモード判定システム自体のバグが本番停止イベントを引き起こす という最悪の構造になります。pass-through 側に倒せば、判定システムが壊れていても通常稼働は維持される ため、リカバリの余裕が生まれます。

直感的には「もしものために止める」が安全に見えますが、判定システム自身のバグ確率は決して低くないため、判定システム障害で本番停止する方が、本物のメンテ判定漏れより損害が大きい ことがほとんどです。

設計原則として明文化

私たちはこの考え方を、次のように設計原則として明文化しました。判定ミス時は通す方向 (pass-through) に倒す。本番サイトが間違って止まる方が、メンテモードが正しく動かない事故より影響が大きいからです。

これは feature flag / kill-switch / 認可判定 / rate limit 等、「判定して通すか止めるか決める」全システム に適用できる原則です。

どちらの層をいつ使うか

エッジ層とアプリ層は独立して動かせます。シナリオごとに使い分けます:

シナリオ推奨
計画 migration (write のみ停止)アプリ層 READ_ONLY
API 異常 (rollback で復旧可能)アプリ層 FULL_STOP
個別 endpoint バグ (課金 / 決済)アプリ層 kill-switch
DB 全停止エッジ層 (アプリ層は機能しない)
GCP リージョン障害エッジ層
セキュリティインシデントエッジ層 (即停止)

「両方同時 ON」も可能です (DR drill 等)。

管理者 bypass は別経路

メンテ中に管理者だけは管理コンソールに入りたい場合、bypass 機構が必要です。私たちは Cloudflare Zero Trust (Access) で実装しました:

User → CF DNS → CF Access (email + Passkey 認証) → JWT 発行
              → CF Worker (JWT verify + email allowlist) → bypass
              → Cloud Run → 管理コンソール

CF Access は Identity-aware proxy で、JWT cookie + email allowlist で bypass を許可します。IP allowlist より柔軟で、出先からでも Passkey さえあれば入れます。

ただしエッジ層自体 / CF Access 自体に障害が起きた場合のフォールバックも要ります。私たちの場合は 物理金庫に CF API token を保管 して、Wrangler CLI から KV を直接書き換える経路を用意しています (3 層フォールバック)。これは長くなるので別記事に分けます。

まとめ

観点一段防御 (アプリ層のみ)2 段防御ハイブリッド (本記事)
DB 全停止時対応できない (flag read 不可)エッジ層で停止可能
Cloud Run 障害対応できないエッジ層が稼働
即時遮断 (セキュリティ)アプリ起動依存エッジ層で即遮断
計画 migrationOK (アプリ層 READ-ONLY)OK (アプリ層 READ-ONLY)
実装コスト軽い (1 日)中 (1-2 週間)
運用コスト中 (2 系統管理)
適用先小規模 / 短期プロジェクト可用性要件の高いサービス

メンテモード設計は地味な領域ですが、「いざという時の最後の砦」 です。判定システム自体のバグで本番を止めない fail-safe = pass-through の発想と、エッジ層 + アプリ層の 2 段防御を、ぜひ自分のプロジェクトで検討してみてください。