はじめに
「メンテナンスモード」と聞くと、多くのプロジェクトは アプリケーション層に 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 障害 | 対応できない | エッジ層が稼働 |
| 即時遮断 (セキュリティ) | アプリ起動依存 | エッジ層で即遮断 |
| 計画 migration | OK (アプリ層 READ-ONLY) | OK (アプリ層 READ-ONLY) |
| 実装コスト | 軽い (1 日) | 中 (1-2 週間) |
| 運用コスト | 低 | 中 (2 系統管理) |
| 適用先 | 小規模 / 短期プロジェクト | 可用性要件の高いサービス |
メンテモード設計は地味な領域ですが、「いざという時の最後の砦」 です。判定システム自体のバグで本番を止めない fail-safe = pass-through の発想と、エッジ層 + アプリ層の 2 段防御を、ぜひ自分のプロジェクトで検討してみてください。