私たちがこれまで対処してきた障害の多くは、サーバーが足りないから起きたのではありません。たいていはシステムがサーバーを 追加したあるいは削減したまさにその瞬間に起きています。起動したばかりの instance に、connection pool がまだ開いていないうちから load balancer が request を流し込む。古い instance が、まだ数十件の request を処理中で、 database に書き込まれていないデータのバッチを抱えたまま停止シグナルを受け取る。この二つはいずれも、CPU が低いままで、 replica 数が設定どおりであっても発生します。
言い換えれば、インフラにはまだ余力があるのに、ユーザーは 502 を見るということです。前回の記事 SaaS の高負荷対策:auto scaling では設計の誤りは救えない は何から scale するかに答えました。本記事は残る二つの問いに答えます。そしてこちらのほうが難しい問題です。 システムが過負荷に近づいたことを知るには何を測るのか、そしてrequest を落とさずに scale・デプロイするにはどうするのか。
1. 障害は「変更したその瞬間」に生まれる
静止しているシステムを安定させておくのは比較的簡単です。問題は、現実のシステムは決して静止しないことです。週に何度も新しい バージョンをデプロイし、auto scaling が負荷に応じて replica を増減させ、node はカーネルを更新されて drain され、container は別のマシンへ移されます。そのたびにプロセスのライフサイクルが断ち切られ、そこがもっとも request を落としやすい瞬間です。
基本的なミスは二つあり、この二つは対称的です。
ミス 1 — traffic を受け取るのが早すぎる。Node のプロセスがポート 3000 を開いた時点で、orchestrator はもう準備完了とみなし、request のルーティングを始めることがあります。しかしポートが開いたことは、サービス提供の準備が できたことを意味しません。Prisma の connection pool はまだ開いていないかもしれず、Redis も未接続、プロセス内キャッシュは 空、V8 はホットなコードの warm-up を終えていません。その instance に入る最初の request は明らかに遅くなるか、pool に connection がないためその場でエラーになります。
ミス 2 — システムから抜けるのが遅すぎる。orchestrator が SIGTERM を送り、プロセスは即座に
process.exit() を呼び、処理中だった request は connection reset に変わります。load balancer
はその instance が停止したことをまだ知らないため、数秒のあいだ新しい request を送り続けます。ユーザーには 502 が見えます。
サービス提供の準備ができているかどうかは、「プロセスが生きているか死んでいるか」だけの話ではありません。起動時、instance には準備のための時間が必要です。停止時にも、load balancer から抜けて処理中の仕事を終わらせる時間が必要です。この二つの フェーズのどちらかを飛ばせば、デプロイのたびに request を落とすことになります。ただ、エラー数が小さすぎてノイズに紛れて 見えないことがあるだけです。
2. システムが限界に近いことを知るには何を測るのか
指標は二つのグループに分けられます。一つ目は原因の指標です。CPU、RAM、ディスク帯域。これらはリソースが どう使われているかを示します。二つ目は結果の指標です。p99 latency、エラー率、キュー長。これらはユーザーと 後段のシステムがどんな影響を受けているかを示します。
よくある誤りは、一つ目のグループだけでアラートを組むことです。典型的な SaaS の API では、request の時間の大半は Postgres や Redis を待つ時間です。枯渇した connection pool を待って request がキューに並んでいるというだけの理由で、CPU が 20% を下回ったまま p99 が 8 秒に達することがあります。プロセスは計算で忙しいのではなく、待っています。そして待つことは CPU を上げません。
業界でよく使われる二つの枠組みが、見落としを防いでくれます。
- USE — リソース(CPU、connection pool、スレッド、ディスク)に使います。各リソースについて 三つ問います。Utilization(何パーセント使われているか)、Saturation(それを待ってどれだけの仕事が キューに並んでいるか)、Errors(拒否されていないか)。
- RED — サービス(各ルート、各キュー)に使います。三つの問い。Rate(毎秒どれだけの request か)、Errors(何パーセント失敗しているか)、Duration(処理時間の分布 — ここでは平均ではなくパーセンタイルでなければなりません)。
核心は Saturation という語にあります。Utilization はリソースがどれだけ使われているかを示します。 Saturation はそのリソースを待ってどれだけの仕事が並んでいるかを示します。utilization 100% でも待っている request が一つもない connection pool は健全です。pool は設計どおり全力で使われているだけです。同じ pool が utilization 100% で、connection を取ろうと待っている request が 60 件あるなら、それはシステムが過負荷に近づいている兆候です。アラートを 張るべきなのは、その 60 という数字のほうです。
Z-EDU には、きわめて直接的でコストの低い saturation 指標があります。学習の進捗は
write-behind の仕組みで書き込まれます。動画プレイヤーの ping は Redis
にしか触れず、worker が周期的にバッチで Postgres へ流し込みます。/health エンドポイントは
pendingProgress — database への書き込みを待っている進捗レコードの件数 — を返します。通常この値は 0
付近を上下します。worker が残りを絶えず処理し切っているからです。もしこれが上がったまま高い値で止まったなら、
worker は死んでいるか詰まっているとほぼ断定できます。
// health.controller.ts — health check は何か役に立つことを語れなければならない
@Get('/health')
async health() {
const [pendingProgress, auditWaiting, auditFailed] = await Promise.all([
this.progressBuffer.size(), // flush 待ちの Redis キー数
this.auditQueue.getWaitingCount(), // zedu-audit
this.auditQueue.getFailedCount(),
]);
return {
status: 'ok',
pendingProgress, // ~0 が正常。上がったまま止まる => worker が死んでいる。
auditWaiting,
auditFailed,
workerEnabled: process.env.WORKER_ENABLED !== '0',
};
}
注目すべきは、worker が死んでも CPU は上がらないことです。むしろ下がることも多い。毎秒の request 数も変わりません。 API は ping を受け取って 200 を返し続けるので、HTTP のエラー率も 0 のままです。インフラのダッシュボードは緑のままで、その裏 でデータは静かに Redis に溜まっていきます。非同期アーキテクチャでは、CPU や HTTP エラーだけでなく、滞留量そのものの指標に アラートを張らなければなりません。
| アラートを張るべき指標(結果 / saturation) | なぜ予兆になるのか | 単独で使うと誤解を招く指標 |
|---|---|---|
| pool から connection を待っている request 数 | キューは latency が急上昇する前に現れます。これこそ本物の早期警告です。 | CPU — I/O-bound では、皆が待っているあいだ CPU は暇です。 |
| キュー長が monotonic に増える | 投入速度 > 処理速度。これは「まもなく壊れる」の数学的定義です。 | throughput(req/s)— 高速にエラーを返しているときも高いままです。 |
pendingProgress が上がったまま止まる |
worker が消化できていないと明言しています。他の解釈はありません。 | HTTP ポートの uptime — worker が死んでずいぶん経っても API は 200 を返します。 |
| ルートごとの p99 latency | ユーザーが本当に痛みを感じるテールを捉えます。 | 平均 latency — テールを完全に覆い隠します。 |
| instance ごとの readiness 失敗率 | warm-up し切れない新しい instance を、p99 を押し上げる前に検知します。 | 稼働中の replica 数 — container を数えられても、提供できているとは限りません。 |
| container の RAM | トレンドで見る場合のみ有用です。一定の右肩上がり=リーク。 | 瞬間の RAM — たいてい OOM の直前まで真っ平らで、そこで垂直に立ち上がります。 |
3. なぜ平均ではなく p99 こそ心配すべきなのか
あるルートの平均 latency が 120ms だとします。悪くなさそうに聞こえます。しかし平均とは足して割った値であり、「すべての request が 120ms」と「99 件が 60ms、1 件が 6 秒」を区別できません。この二つはダッシュボード上では同じ数字になり、現実には まったく別物です。
パーセンタイルのほうが正直です。p99 = 6 秒とは、100 件の request のうち約 1 件が 6 秒以上かかるという意味です。「たった 1% なら許容できる」と思うなら、実際のページの動きに合わせて掛け算してみてください。
現実のアプリのページが API を一つだけ呼ぶことはめったにありません。コース詳細ページはコース情報、レッスン一覧、受講者の進捗、 ユーザーの権限を取りに行きます。仮に 8 回の呼び出しとしましょう。8 回すべてがテールの外に収まる確率は 0.99 の 8 乗、 およそ 92% です。つまりページ読み込みの約 8% は、少なくとも 1 件の遅い request を踏むということです。 request 層での「1% が遅い」は、体験の層では「8% のユーザーがページを遅いと感じる」に化けました。そしてページは、その中で もっとも遅い呼び出しと同じ速さにしかなりません。
テールは稀な例外ではありません。多数の API を並列に呼ぶ画面にとって、テールこそが普通の体験です。だからこそ、まともな SLO はすべてパーセンタイルで書かれ、平均で書かれることは決してありません。
これは scale するときにはさらに重要になります。テールは、システムが飽和し始めたときに真っ先に反応する場所です。 p99 がすでに 3 倍になっていても、平均はまだきれいなままでいられます。平均しか見ていなければ、知らせを受け取るのはいつも遅く、 たいていは顧客から報告を受けたあとです。
4. health check:liveness は readiness ではない
ここは多くのシステムが自ら障害を作り込んでいる場所なので、詳しく書く価値があります。
liveness が答える問いはこうです。このプロセスは kill して再起動すべきか? 再起動で直せるものだけ、 たとえばイベントループがまだ回っているか、プロセスが deadlock していないか、といったものだけを確認すべきです。
readiness は別の問いに答えます。この instance は今この瞬間 request を受け取るべきか? こちらは dependency を確認してかまいません。warm-up 中や shutdown 中に「まだ準備できていない」と答えるのは、ごく普通のこと です。
典型的なミスは、liveness probe から database を呼ばせることです。「DB と話せない API が生きていて何になる?」ともっともらしく 聞こえます。しかし結果はひどいものです。failover で Postgres が 3 秒だけ瞬断すると、すべての instance の liveness が同時に失敗し、orchestrator はそれらを皆殺しにして一斉に再起動します。復旧したばかりの database に、大量の新しい instance が同時に connection を張り、database はもう一度倒れます。health check を置く場所を間違えただけで、3 秒の障害が 15 分の障害に化けることがあるのです。
原則:liveness は再起動で直せるものだけを確認する。DB に障害が起きているとき、API を再起動しても何も直りません。むしろ悪化 させるだけです。外部の dependency は readiness の担当であり、readiness はプロセスを殺しません。traffic を受け取る輪からプロセスを外すだけです。
| 観点 | liveness | readiness |
|---|---|---|
| 問い | kill して再起動すべきか? | request を送ってよいか? |
| 失敗したときの結果 | container が kill される | load balancer から外される。プロセスは生きたまま |
| DB/Redis を確認してよいか? | だめ。再起動で DB は直りません。 | よい — それこそがこの probe の仕事です。 |
| 起動時の失敗 | 異常 | 正常 — warm-up 中です |
| shutdown 時の失敗 | 異常 | 正常 — drain 中であり、これが最初のステップでなければなりません |
| 書き方を誤ったときのリスク | 小さな障害がシステム全体のダウンに拡大する | 準備前の instance が traffic を受け取り、p99 が跳ね上がる |
// health.controller.ts — 二つのエンドポイント、二つの目的。一つにまとめてはいけない
@Controller()
export class HealthController {
private ready = false; // warm-up 完了で true、shutdown 開始で false
markReady() { this.ready = true; }
markDraining() { this.ready = false; }
// LIVENESS: イベントループが回っていることだけを示す。DB/Redis には絶対に触れない。
@Get('/livez')
livez() {
return { status: 'ok', uptime: process.uptime() };
}
// READINESS: dependency に触れてよい。失敗 => LB から外れるだけで、kill はされない。
@Get('/readyz')
async readyz(@Res({ passthrough: true }) res: Response) {
if (!this.ready) {
res.status(503);
return { status: 'draining_or_warming' };
}
try {
await this.prisma.$queryRaw`SELECT 1`; // pool はもう開いているか?
await this.redis.ping();
return { status: 'ok' };
} catch (err) {
res.status(503);
return { status: 'deps_unavailable' };
}
}
}
設定の層では二つ注意すべき点があります。プロセスがまだ boot 中の失敗を数えないための起動時の猶予期間と、 一度の瞬断に過剰反応しないための行動に移すまでの連続失敗回数です。
# Docker Swarm — HEALTHCHECK が container に traffic を流すかどうかを決める。
# liveness ではなく READINESS を指し、start_period は warm-up に足りる長さにする。
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 5s # 5 秒ごとに確認
timeout: 3s # 3 秒より遅ければ異常とみなす
retries: 3 # 3 回連続で失敗して初めて判定 — 瞬断対策
start_period: 30s # boot の猶予:最初の 30 秒の失敗はカウントしない
5. 無停止で scale out する:warm-up と stateless の条件
instance を一つ足すのは、とても安全な操作に聞こえます。しかし実際には二つの罠があります。
罠 1:instance がまだ冷えている。起動直後の Node プロセスにはほとんど何も揃っていません。Prisma の connection pool は空、Redis のハンドシェイクは未完了、プロセス内キャッシュにデータはなく、V8 はホットなコードを JIT していません。load balancer が最初の 1 秒から traffic を振り分ければ、新しい instance に入る request は目に見えて遅くなります。 皮肉なことに、p99 が悪いから scale out したのに、scale out した直後の数秒は p99 がさらに悪化しうるのです。これを見て 「scale しても効果がない」と誤って結論づけるチームは少なくありません。
対処法は、readiness を関門にすることです。instance は自分で warm-up を終えたあとで初めて準備完了を報告します。
当てずっぽうの sleep に頼らず、実際に完了した事実に頼ってください。
// main.ts — 本当に提供できる状態になってから初めて READY を報告する
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.setGlobalPrefix('api/v1');
await app.listen(3000); // ポートは開いたが、/readyz はまだ 503 を返す
// warm-up: 最初の request がやることになる作業を、そのままやっておく。
const health = app.get(HealthController);
await warmUp(app);
health.markReady(); // この瞬間から /readyz が 200 を返す
logger.log('ready to serve traffic');
async function warmUp(app: INestApplication): Promise<void> {
const prisma = app.get(PrismaService);
const redis = app.get(RedisService);
// 1. 最初の request に TCP + TLS のハンドシェイクを待たせず、pool に connection を開かせておく。
await Promise.all(
Array.from({ length: 5 }, () => prisma.$queryRaw`SELECT 1`),
);
// 2. Redis とハンドシェイクする。
await redis.ping();
// 3. ほぼすべての request が読む設定キーを先に読み込んでおく。
await app.get(SettingsService).preload();
}
罠 2、こちらのほうが危険:プロセスが本当は stateless ではない。水平 scale はすべての instance が等価であることを暗黙に前提としています。request がどの instance に入っても同じ結果を返さなければなりません。これはプロセスが自分の RAM に重要な state を持たない場合にだけ成り立ちます。
もっとも分かりやすい例が rate limit です。IP ごとのカウンタがプロセスの RAM、たとえば
Map<string, number> の中にあると、replica が 3 つなら各 IP は自分が設定したつもりの上限の 3
倍まで呼べてしまいます。さらに悪いことに、実効的な上限はランダムになります。load balancer がどの instance
に request を送るかに依存するからです。この状態で instance を足すのは、単なる scale ではなく、防御策を密かに緩める行為です。
Z-EDU の rate limit は IP ごとのカウントを Redis 経由で行うため、上限は replica が何台あっても変わらない
グローバルな数値になります。
同じ原則が残りの部分にも当てはまります。業務データは Postgres に、ファイルは RustFS(S3 互換。ブラウザが presigned URL で直接 PUT するため、instance のディスクに一時ファイルは不要)に、一時的な state と高速なデータは Redis に置きます。テストは とても単純です。負荷がかかっている最中に instance を無作為に 1 台 kill したとき、誰かがデータを失ったりエラーに なったりするか? 答えが「一部の人がログアウトされる」「途中のアップロードが壊れる」「カウンタがずれる」なら、その システムはまだ auto scale を安全に行えるほど stateless ではありません。
6. request を落とさずに scale in・再デプロイする
こちらのほうが難しい半分であり、忘れられがちな半分でもあります。replica を減らす、rolling update、node の drain
— この三つはすべて同じ事象に行き着きます。プロセスは SIGTERM
を受け取り、きれいに停止するための有限な時間しか与えられません。
正しい手順は四段階で、順序こそがすべてです。
- ただちに readiness = false にする。load balancer がそれに気づいて新しい request を送るのをやめるには、 health check の数サイクルが必要です。そのあいだ、プロセスは通常どおり提供し続けなければなりません。まだ request を拒否してはならず、traffic を受け取る輪から抜けようとしていることを知らせているだけです。
- 処理中の request が終わるのを待つ(drain)。新しい connection の受け入れを止め、処理途中の request は最後まで走らせます。期限は設けますが、その期限はもっとも遅い request の処理時間より長くなければなりません。
- キューの consumer を閉じ、実行中の job を待つ。下記参照 — ここがもっとも飛ばされやすいステップです。
- DB/Redis の接続を閉じ、それから終了する。request がまだ走っているのに pool を閉じるのは、それらを能動的 に 500 エラーに変える行為です。
ステップ 1 はきわめて飛ばされやすく、デプロイ時の多くの 502 の原因になっています。プロセスが listener を閉じた時点と、 load balancer がそれを知る時点のあいだには必ず遅れがあり、たいてい health check の数サイクル分ちょうどです。この 遅れのあいだに届いた request は、停止中の instance に送られてしまうことがあります。だからこそ、まず「準備できていない」と 知らせ、load balancer の更新を待ち、それから listener を閉じるのです。
// main.ts + shutdown.service.ts — 正しい順序の graceful shutdown
app.enableShutdownHooks(); // SIGTERM で Nest が onModuleDestroy/onApplicationShutdown を呼ぶ
@Injectable()
export class ShutdownService implements OnApplicationShutdown {
constructor(
private readonly health: HealthController,
private readonly progressFlusher: ProgressFlusher,
@InjectQueue('zedu-audit') private readonly auditQueue: Queue,
) {}
async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(`${signal} を受信、drain を開始`);
// (a) 先に load balancer から抜ける。この瞬間から /readyz は 503 を返す。
this.health.markDraining();
// (b) LB が気づくのを待つ:interval * retries + 安全マージン。
// 待っているあいだ、飛んでくる request は通常どおり提供される。
await sleep(15_000);
// (c) 新しい connection の受け入れを止め、実行中の request を待つ(close() で Nest がやってくれる)。
// (d) Redis バッファに残る write-behind のバッチを流し切る — データを置き去りにしない。
await this.progressFlusher.flushNow();
await this.auditQueue.close();
this.logger.log('drain 完了、終了');
}
}
キューの worker では、ルールはさらに厳しくなります。実行中の job が途中で kill されると、その書き込みバッチ
は失われるか、半分だけ書かれてしまいます。BullMQ には適した仕組みがあります。worker.close() は新しい job
の受け入れを止め、実行中の job の完了を待ちます。stop-grace-period を過ぎても job が終わらない場合、その job
はキューに残り、ロックが期限切れになったあと別の instance が拾い直します。ただしハンドラがidempotent
であることが条件です。BullMQ が保証するのは at-least-once であって、exactly-once ではありません。だからこそ Z-EDU
の進捗マージのルールは SQL の中にあります(GREATEST を使った INSERT ... ON CONFLICT)。同じバッチ
が二度走っても、進捗が巻き戻ることはありません。
// worker.shutdown.ts — worker は実行中の job を待つべきで、途中で kill してはならない
@Injectable()
export class WorkerShutdown implements OnApplicationShutdown {
constructor(private readonly workers: Worker[]) {}
async onApplicationShutdown(): Promise<void> {
// close(false) = 新しい job の受け入れを止め、実行中の job の完了を待つ。
// close(true) = 即座に強制停止 -> 未完了の job はキューに戻り、あとで再実行される。
await Promise.all(this.workers.map((w) => w.close(/* force */ false)));
}
}
// (2) を安全にする前提条件:ハンドラが idempotent であること。
// 二度目に走った job は、一度目と同じ結果を出さなければならない。
最後は orchestrator の層です。アプリが drain に 30 秒必要なのに Swarm が 10 秒で container を kill するなら、コード側の努力 はすべて無意味になります。これを決める設定は三つのグループに分かれます。
# docker-compose.yml (Swarm) — request を落とさない rolling update
services:
api:
image: registry.example/zedu:${TAG}
environment:
API_ENABLED: "1"
WORKER_ENABLED: "0"
healthcheck:
test: ["CMD", "node", "/app/healthcheck.js"] # /readyz を指す
interval: 5s
retries: 3
start_period: 30s
stop_signal: SIGTERM
# コード内の drain 時間(LB を待つ 15 秒 + もっとも遅い request)より必ず長く。
# 期限が切れると Swarm は SIGKILL を送るので、実際の drain 時間より余裕を持たせる。
stop_grace_period: 45s
deploy:
replicas: 3
update_config:
order: start-first # 新しい instance を立て、healthy になってから古い instance を落とす
parallelism: 1 # 一度に 1 replica:提供能力が常に足りている状態を保つ
delay: 10s # 各ステップの間で休止し、システムを落ち着かせる
failure_action: rollback
monitor: 60s # 各ステップ後 60 秒監視。失敗したら自動で rollback
rollback_config:
order: start-first
parallelism: 1
worker:
image: registry.example/zedu:${TAG} # まったく同じ image。役割だけが違う
environment:
API_ENABLED: "0"
WORKER_ENABLED: "1"
# worker は API より余裕が必要:実行中の job の完了を待たなければならない。
stop_grace_period: 90s
deploy:
replicas: 2
update_config:
order: stop-first # worker は LB から traffic を受け取らない -> 重ねる必要がない
parallelism: 1
立ち止まる価値のある点が二つあります。API の order: start-first は、新しい instance
を立ち上げ、それが healthy になってから初めて古い instance を落とすという意味です。おかげでデプロイ中も提供能力が
設計値を下回りません。その代償として、数秒のあいだ二つのバージョンのコードが並行して動きます。つまり両方のバージョンが同じ
database スキーマと互換でなければなりません。だから migration は加算的に進める必要があります。先に列を追加し、旧経路と新経路
の両方を読み書きし、古い列は次のデプロイで削除します。
対照的に worker は order: stop-first を使います。load balancer から traffic を受け取らないので、
重ねる理由がありません。古いものを落としてから新しいものを立てる。そのほうが単純で、リソースも節約できます。そして worker の
stop_grace_period は API より長くする必要があります。job は request よりはるかに長く走りうるからです。
7. 過負荷時の自衛:load shedding と fail-open
auto scaling は即座には起きません。指標が閾値を超えてから新しい instance が提供できるようになるまで、数十秒、ときには数分 かかります。その空白のあいだ、システムは能動的に仕事を断ることで自分を守らなければなりません。
それが load shedding です。すでに過負荷のシステムで全部を捌こうとすれば、たいてい全部が遅くなります。 request がキューに並び、クライアントが timeout し、クライアントが retry し、キューはさらに伸びます。5% の request を 429 で早めに断れば、残る 95% を速く保てることがあります。100% を抱え込もうとして、100% すべてを失敗させることもあるのです。 速く明確にエラーを返すほうが、長く待たせて timeout するより良いのが普通です。
rate limit は計画された load shedding です。Z-EDU は rate limit を IP ごとに Redis 経由で数えます — これは必須です。5 節で 述べたとおり、RAM 上のカウンタは 2 台目の replica が出た瞬間に壊れるからです。しかしそれは新しい問いを生みます。そしてこの問い に普遍的な正解はありません。Redis が死んだらどうするのか?
選択肢は二つしかありません。fail-closed:上限を確認できないなら request を拒否する。fail-open:確認できない なら通す。Z-EDU は fail-open を選びました。理由はきわめて実務的です。Redis は rate limit の dependency であって、業務の dependency ではありません。fail-closed にすると、補助的な部品がシステム全体の single point of failure に化けかねません。カウンタが数えられないというだけの理由で、受講者が学習に入れなくなるのです。これはシステム の他の部分とも一貫しています。Redis に障害が起きれば、キャッシュミスはそのまま DB に落ちますし(遅くはなりますが、正しい)、 write-behind は DB に直接書きます。
rate limit が性能を守っている場所では fail-open。お金やアカウントを守っている場所では fail-closed — ログインとパスワード再設定(rate limit こそがブルートフォースを止めているもの)、OTP/SMS/メールの送信(1 件ごと に実際に費用がかかる)、割り当てのあるサードパーティ API の呼び出し。こうした場所では、拒否するほうが門を開けるよりはるかに 安全です。
fail-open はタダではないので、トレードオフははっきり書いておきます。Redis に障害が起きているあいだ、システムには事実上 rate limit がありません。だから fail-open に切り替わるたびにメトリクスを出し、アラートを上げなければなりません。 黙って fail-open するのは非常に危険です。守りの層があるつもりでいて、実は何日も前から切れていた、ということが起こりえます。
8. auto scaling:どの指標で scale するか、そして振動の罠
ここでようやく auto scaling です。そしてこれは最初の一手ではなく、最後の一手です。原則は三つ。
一:I/O-bound なシステムを CPU で scale してはいけない。2 節で述べたとおり、DB を待つ API は p99 が 8 秒 でも CPU は 20% を下回りえます。その状況で CPU による auto scaling は発火しません。電話が鳴るなか、緑のダッシュボード を眺めることになります。滞留した仕事を反映する指標で scale してください。待っている request の数、キュー長、重要なルートの p99 latency です。
二:scale up は速く、scale down は遅く。この二つの方向は、誤りのコストが非対称です。不要な instance を足せばお金がかかります。負荷が上がりかけているときに instance を減らせば request を落とします。だから scale up の閾値は敏感に、観測時間は短く(1〜2 分)。scale down の閾値は厳しく、観測時間は長く(10 分以上)。そして 1 回に減らすのは 1 replica だけにします。
三:振動(flapping)に注意する。これは非常に陥りやすいループです。負荷が上がる → scale up → 新しい instance はまだ冷えていて warm-up 前 → p99 が一時的に悪化する → autoscaler はまだ足りないと勘違いする → さらに scale する。 逆方向もあります。scale down → 残った instance に負荷が集中する → 指標が閾値を超える → scale up → 負荷が分散する → 指標が 下がる → scale down。システムは replica を延々と足しては減らし、そのサイクルごとに drain と warm-up のリスクを引き連れます。
振動を避けるには、三つの手を同時に使うべきです。scale のたびに、warm-up 時間より十分に長い cooldown を置く こと。scale up と scale down の閾値のあいだに緩衝帯を設けること。たとえば p99 > 800ms で scale up するが、scale down は p99 < 300ms のときだけにする。そして 30 秒のスパイクだけでは scale が発火しない長さの 観測ウィンドウを取ること。
Z-EDU の設計における一つの利点があります。BullMQ の worker は API と同じプロセスで走りますが
WORKER_ENABLED=0 で無効化でき、monorepo 全体は単一の image にビルドされます。worker を API
と独立に scale したければ、同じ image を使い環境変数だけ変えたサービスをもう一つ立てるだけで済みます。コードの修正も再ビルド
も不要です。これが重要なのは、二つの役割がまったく別の指標で飽和するからです。API は p99 と connection 待ちの数で飽和し、
worker はキュー長と pendingProgress で飽和します。両者を一つのルールで scale させるのは、二重に誤りです。
9. チェックリスト:auto scaling を有効にする前にシステムが備えるべきもの
一行でもチェックが付かないなら、auto scaling は障害を防ぐ道具ではなく障害を生む機械になります。instance の増減を、まれな 手作業から、あなたが眠っているあいだに毎日自動で起きる出来事に変えてしまうからです。
- liveness と readiness が別々のエンドポイントである。liveness は DB/Redis に触れない。readiness は触れるが、readiness の失敗でプロセスは kill されない。
- readiness に本物の warm-up がある。instance が準備完了を報告するのは、pool が開き、Redis
とハンドシェイクし、設定を読み込んだあとだけ — 当てずっぽうの
sleepに頼らない。 - graceful shutdown が正しい順序である:readiness = false → load balancer の更新を待つ → 実行中の request を drain する → 書き込み途中のバッチを flush する → 接続を閉じる → 終了する。
stop_grace_periodが実際の drain 時間より大きい。期限が切れれば SIGKILL、交渉の余地は ありません。worker は API より余裕が必要です。- worker は
close()で実行中の job を待って閉じる。そしてハンドラは idempotent で、キューに戻された job が二度目に走っても正しい結果になる。 - プロセスが本当に stateless である:負荷中に instance を無作為に kill しても誰も何も失わない。 あらゆるカウンタ、セッション、分散ロックは Redis にあり、プロセスの RAM にはない。
- rolling update が
start-first+parallelism: 1を使っている。そして migration は加算的で、二つのバージョンのコードが数秒並行して動いてもスキーマが壊れない。 - アラートが saturation と結果の指標の上に組まれている — p99、キュー長、
pendingProgress、connection 待ちの request 数 — CPU と RAM だけではない。 - scale できない層の限界を計算してある:replica 数 × replica ごとの pool size は Postgres の
max_connectionsを超えないか? これは auto scaling が自ら database を落とすもっともよくある経路です。 - fail-open / fail-closed の判断が dependency ごとに書き出されている。そして劣化モードに落ちるたび にメトリクスが飛ぶ。
結論
zero downtime は、インフラの管理画面で有効化する機能ではありません。それは、どれも見落としやすい、小さく退屈な細部の総和です。
liveness から切り離された readiness エンドポイント、準備完了を報告する前に走る warm-up の関数、load balancer
が撤退に気づくのを待つ 15 秒、正しく設定された stop_grace_period という一つの数値、
process.exit() ではなく worker.close(false) を呼ぶこと。
どれ一つとして、人に見せて感心されるものではありません。しかし合わせれば、ピーク時間の真ん中でもデプロイできるシステムと、 午前 2 時にしかデプロイする勇気が出ないシステムとを分ける差になります。そして最後のものさしも単純です。ユーザーが もっとも多い時間帯にデプロイのボタンを押す勇気がないなら、あなたが持っているのは zero downtime ではなく、運です。