Z-SOFT
アーキテクチャ · 高負荷対策

SaaS の高負荷対策:auto scaling では設計の誤りは救えない

auto scaling が効くのは、その下の設計が十分に軽いときだけです。この記事では auto scaling を有効にする前にやるべきことを扱います。不要な仕事をリクエスト経路から外すこと、BullMQ で worker を API から分離すること、プロセスを stateless に保つこと、そして CPU だけを見るのではなく滞留した仕事量でアラートを鳴らすことです。例はすべて、実際に稼働している Z-EDU から取っています。

Z-SOFT 開発チーム 11分で読めます

システムが遅くなり始めたとき、多くのチームが最初に取る行動はインフラの管理画面を開いて replica を増やすことです。 簡単で速く、場合によっては正解でもあります。問題はそれ以外の場合です。instance を追加しても根本原因は直りません。 最適化されていない設計を、より多く並列に走らせるだけです。

1. auto scaling では設計の誤りは直せない

修正前の Z-EDU で実際にあった例を挙げます。動画プレーヤーは受講者ごとに 15 秒おきに進捗の ping を送っていました。 旧来の処理経路では ping 1 回につき database に 4 クエリを投げており、1 回の受講で発生する 20 回の ping は Postgres へのおよそ 80 クエリになります。同時に視聴している受講者数を掛ければ、API はもはや HTTP を捌くだけの存在ではなく、 DB へクエリを流し続ける存在になります。

ここで API が遅くなり始め、instance を倍にしたとしましょう。何が起きるでしょうか。各 instance は依然として 1 回の視聴セッションあたり 80 クエリを生成します。単一の Postgres へクエリを押し込む能力を倍にしただけです。latency は下がらず、p99 は悪化し、 connection pool は枯渇し、横方向に scale しにくい同じリソースを奪い合うプロセスが倍になります。 「scalability」を扱う多くの記事が見落としている核心はここです。

auto scaling が複製するのは、あなたが scale した層だけです。Postgres は複製しませんし、Redis も複製しません。 ディスク帯域も複製しません。ボトルネックが複製できない層にあるなら、instance を追加してもそのボトルネックを より速く締め上げるだけです。

正しい順序は逆です。「instance は何台必要か」と問う前に、「1 リクエストが本当にやらなければならない仕事はどれだけか」と問うべきです。 私たちがこれまで解析してきた遅い API の大半は同じ形をしていました。同期的なリクエスト経路の上で、不要な仕事をやりすぎているのです。 その仕事を外に出す方法は 2 つあります。cache によってリクエストを API まで到達させないこと、 そして queue によって付随的な仕事をその場でやらないことです。この 2 つが済んで初めて auto scaling に意味が出てきます。 そのとき複製する対象は、DB への圧力を生み続けるプロセスではなく、軽量なプロセスになっているからです。

2. 一部の処理をリクエスト経路から外す

Z-EDU で最も負荷を受け止めている層は API でも Redis でもありません。公開ルートに付けた @HttpCache decorator が設定する Cache-Control header です。CDN/Traefik がエッジでレスポンスを返すため、リクエストは NestJS まで届きません。 エッジで処理されたリクエストは backend にとってほぼ無コストです。connection も CPU も Prisma のクエリも消費しません。 コードを実行しないことに勝るコード内の最適化は存在しません。

しかしエッジの cache は諸刃の剣であり、私たちも危うく代償を払うところでした。@Public なルートを一括して cache するのは 一見合理的ですが、GET /exams/public/attempts/:id、つまりある受験者の答案が proxy に保持され、 他人に配信されうると気づくまでの話です。導き出した結論は、エッジの cache はルート単位の opt-in でなければならず、 グループ単位のデフォルトにしてはならない、ということです。認証における「public」は JWT が不要という意味であり、 cache における「public」は誰が見ても同じという意味です。この 2 つは同じではありません。

第 2 層は CacheService.wrap を通した Redis です。wrap の要点は結果を保存することだけではなく、 同じキーに対する miss を 1 回の計算にまとめる (single-flight) ことにあります。ホットなキーが期限切れになったとき、 数百の同時リクエストが一斉に DB へ殺到することはありません。計算しに行くのは 1 リクエストだけで、残りはその結果を待ちます。 これが cache stampede を防ぐ仕組みであり、まさに cache が期限切れになる瞬間に効いてきます。三層の cache、 バージョンによる invalidation、そして私たちが SCAN/KEYS を使わない理由については、 multi-tenant SaaS の三層キャッシュで個別に書きました。

3. worker を API から分離する:BullMQ と「同じ image、異なる役割」の原則

cache は読み込み側を処理します。書き込み側には別の仕組み、すなわち queue が必要です。Z-EDU では BullMQ を使い、 役割の明確な 2 つの queue を持っています。zedu-maintenance(進捗の整理、定期ジョブ)と zedu-audit(管理者操作のログ)です。

audit の queue は「リクエスト経路に置く必要のない仕事」の最も分かりやすい例です。以前は管理者の操作ごとに、 リクエストの中でログ用のクエリを 2 本追加で書いていました。ユーザーは「保存」を押し、ログの書き込みが終わるまで待ってから レスポンスを受け取っていたのです。ログはその後 200ms のうちに誰も読まないものですから、リクエストにそれを待たせるのは 割の悪い取引です。queue に入れれば、リクエストは早く返り、worker が後から書きます。

// audit.service.ts — ログをリクエスト経路から外す
@Injectable()
export class AuditService {
  constructor(@InjectQueue('zedu-audit') private readonly queue: Queue) {}

  // controller から呼ぶ。DB は await せず、enqueue だけを await する。
  async record(entry: AuditEntry): Promise<void> {
    await this.queue.add('write', entry, {
      removeOnComplete: 1000,
      removeOnFail: 5000,
      attempts: 3,
      backoff: { type: 'exponential', delay: 1000 },
    });
  }
}

// audit.processor.ts — worker で実行される。WORKER_ENABLED=0 なら実行されない。
@Processor('zedu-audit')
export class AuditProcessor extends WorkerHost {
  async process(job: Job<AuditEntry>): Promise<void> {
    // バッチで書き込む。requestId で idempotent なので retry しても行が重複しない。
    await this.prisma.auditLog.createMany({
      data: [toRow(job.data)],
      skipDuplicates: true,
    });
  }
}

上のコードの 3 つの点は言葉にしておく価値があります。これらが欠けると、queue は解決策から新たな障害の原因へと変わるからです。

ここからが運用上の重要な部分です。Z-EDU の worker は API と同じプロセスで動いており、 環境変数 WORKER_ENABLED=0 で止められます。美しくない妥協に聞こえるかもしれません。 きれいに独立したサービスへ分けないのはなぜか、と。分けるということは、2 つの codebase、2 つのビルドパイプライン、 2 つの image、そして同期を保つべき 2 組の設定を意味するからです。小さなチームにとって、それは毎日払う実コストです。

私たちのやり方は 1 つの image、複数の役割です。同じ artifact を使い、役割は実行時に環境変数で決めます。

// main.ts — 役割はビルド時ではなく実行時に決まる
const WORKER_ENABLED = process.env.WORKER_ENABLED !== '0';
const API_ENABLED = process.env.API_ENABLED !== '0';

const app = await NestFactory.create(AppModule, { bufferLogs: true });

if (API_ENABLED) {
  app.setGlobalPrefix('api/v1');
  await app.listen(3000);
} else {
  await app.init(); // worker 専用:HTTP ポートは開かない
}

// AppModule は WORKER_ENABLED のときだけ *Processor を読み込むので、
// API 専用のプロセスは BullMQ の consumer を一切登録しない。

平常時は 1 つのサービスが両方の役割をこなします。単純で、見るべきものが少なくて済みます。queue が滞留し始めたら、 まさにその image を使って worker 専用のサービスをもう 1 つ立ち上げます。違うのは環境変数だけです。 コードは変えず、ビルドし直さず、API を deploy し直しもしません。

# docker-compose.yml (Swarm) — 同じ image、異なる役割
services:
  api:
    image: registry.example/zedu:${TAG}
    environment:
      API_ENABLED: "1"
      WORKER_ENABLED: "0"   # API 専用:HTTP を捌くだけ
    deploy:
      replicas: 3
      update_config: { order: start-first, parallelism: 1 }

  worker:
    image: registry.example/zedu:${TAG}   # まったく同じ image
    environment:
      API_ENABLED: "0"
      WORKER_ENABLED: "1"   # worker 専用:queue を消費するだけ
    deploy:
      replicas: 2           # api とは独立に scale する

この設計の本当の価値は、scale の判断が容易になることです。アーキテクチャを変える必要も、新しい PR を merge する必要もありません。 replica 数を変えるだけです。その代わり、開発チームは規律を守らなければなりません。2 つの役割の差異は環境変数の中だけにあり、 コード内で hostname や build flag によって分岐してはいけません。それを破れば image は単一の artifact ではなくなり、 利点は消えてしまいます。

はっきり言っておくべき限界もあります。このモデルは、両方の役割を同居させて動かす場合にプロセス単位の障害分離を与えてくれません。 RAM を食い潰す worker は、同じプロセスの API に影響します。まさにそのために WORKER_ENABLED が存在します。 これは負荷が上がったときに開く弁であって、飾りではありません。

4. 横方向の scale は state が外にあるときだけ機能する

auto scaling が暗黙に前提としながら、あなたのために検査してはくれない前提条件があります。すべての instance が 等価でなければならないということです。リクエストがどの instance に入っても同じ結果になる。これが成り立つのは、 API のプロセスが自身の RAM に重要な state を保持していない場合だけです。

Z-EDU では state を意図的に外へ追い出しています。session/JWT はサーバー側に保存する必要がなく、業務データは Postgres に、 ファイルは RustFS(S3 互換。クライアントが presigned URL 経由で直接 PUT する)に、一時的で高速な state は Redis に置いています。 Node のプロセスは、いつ殺しても構わないものです。

最も分かりやすい例は rate limit です。IP ごとのリクエストカウンターがプロセスの RAM の中、たとえば Map<string, number> のような形で置かれているとすると、replica が 3 つある場合、 各 IP はあなたが想定した上限の 3 倍まで呼び出せてしまいます。さらに悪いことに、実際の上限は load balancer が リクエストをどこへ投げるかに左右されるランダムな値になります。私たちの rate limit は Redis を通して IP ごとに数えるので、 上限は instance 数に依存しないグローバルな数値になります。

auto scaling を有効にする前の簡単なテストがこれです。負荷がかかっている最中に instance を 1 つランダムに落としたとき、 誰かが何かを失うでしょうか。答えが「一部の人がログアウトされる」「途中のアップロードが壊れる」「カウンターが狂う」であれば、 あなたはまだ stateless ではなく、instance を追加しても負荷が減るどころかエラーが増えるだけです。

5. rate limit と fail-open の判断

rate limit を Redis に出すことでグローバルな計数の問題は解決しますが、新たな問いが生まれます。Redis に障害が起きたら どうするのかという問いです。これにはすべてのシステムに通じる唯一の正解はありません。

選択肢は 2 つしかありません。fail-closed:上限を確認できないならリクエストを拒否する。fail-open: 確認できないなら通す。Z-EDU は fail-open を選びました。

// rate-limit.guard.ts — Redis の停止が API を落としてはならない
async canActivate(ctx: ExecutionContext): Promise<boolean> {
  const req = ctx.switchToHttp().getRequest();
  const key = `rl:${routeOf(ctx)}:${clientIp(req)}`;

  try {
    const hits = await this.redis.incr(key);
    if (hits === 1) await this.redis.expire(key, WINDOW_SEC);
    if (hits > LIMIT) throw new ThrottlerException();
    return true;
  } catch (err) {
    if (err instanceof ThrottlerException) throw err; // 上限超過:やはりブロックする
    // Redis が応答しない -> FAIL-OPEN。アラートを発火させるためにログを残す。
    this.logger.error({ err }, 'rate-limit backend down, failing open');
    this.metrics.increment('ratelimit.fail_open');
    return true;
  }
}

理由はきわめて実務的です。Redis は rate limit の dependency であって、業務ロジックの dependency ではありません。 Redis が壊れたときに fail-closed にすれば、補助的なコンポーネントのせいでシステム全体が本物のユーザーを拒否しかねません。 Z-EDU の他の部分と同じ思想です。Redis が壊れたら cache miss はそのまま DB に降りますし(遅いが、正しい)、 write-behind は DB に直接書きます。Redis は高速化のための層であって、真実の source ではありません。

ただし、これは無償の選択ではないので、トレードオフをはっきり述べておく必要があります。Redis が壊れている間、 システムには実質的に rate limit がありません。攻撃者はまず Redis を過負荷にし、その空白を突くことができます。 機微な endpoint が JWT、RBAC、そしてサーバー側で固定した上限によってさらに守られている Z-EDU では、 このトレードオフは受け入れられます。

境界はどこにあるのでしょうか。その経路上で rate limit が唯一のセキュリティ制御である場合は fail-closed にします。 具体的には、ログインとパスワード再設定の endpoint(brute-force を止めているのはまさに rate limit です)、 OTP/SMS/メール送信の endpoint(1 リクエストごとに実際の費用がかかります)、割当のあるサードパーティを呼ぶ endpoint です。 そうした場所では、リクエストを拒否するほうが門を開けるよりはるかに安全です。実務的な規則は、rate limit が 性能を守っている場所では fail-open、金銭やアカウントを守っている場所では fail-closed です。

6. 正しい指標を見る:CPU が低いことは健全さを意味しない

仕事を queue に押し込むとき、あなたは同時にエラーの一部を視界の外へ押し出しています。以前ならエラー処理がリクエストを 500 で返し、 すぐに気づけました。worker を分離した後は、API は 200 を返し続け、dashboard は緑のままなのに、データが静かに書かれていない、 ということが起こりえます。これが非同期アーキテクチャの代償であり、その払い方は正しい場所を観測することです。

Z-EDU では /healthpendingProgress、つまり DB への書き込みを待っている進捗レコードの数を返します。 worker が 5 秒おきに Postgres へまとめて流すため、通常この数値は 0 の付近を上下します。もしこれが上昇して高い水準で止まったなら、 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(),
    this.auditQueue.getFailedCount(),
  ]);

  return {
    status: 'ok',
    pendingProgress,   // 通常は約 0。上昇して & 高止まり => worker が死んでいる。
    auditWaiting,
    auditFailed,
    workerEnabled: process.env.WORKER_ENABLED !== '0',
  };
}

ここから一般的な原則が導かれます。そしてそれは、この記事の中で最も価値のあるものです。

滞留した仕事量を反映する指標でアラートを鳴らしてください。queue の深さ、処理の遅延、書き込み待ちのレコード数です。 CPU が低いのに queue が膨れ上がっているシステムは、やはり障害中です。忙しくないのは、働くのをやめたからです。

CPU は原因の指標であって、結果の指標ではありません。負荷が compute-bound のときにのみ有用です。 典型的な SaaS システム、つまり I/O-bound で、時間の大半を DB と Redis の待ちに費やすシステムでは、connection pool が枯渇して p99 latency が 8 秒になっているのに CPU は 20% を下回っていることがありえます。その状況で CPU に基づく auto scaling は 発動しません。ユーザーが電話をかけてくる間、あなたは緑の dashboard を眺めることになります。

アラートを鳴らすべき指標 なぜ信頼できるのか 誤解を招きやすい指標
pendingProgress が上昇して高止まりする worker が消費できていないと直接告げています。他の解釈のしようがありません。 API の CPU — worker が死んでも CPU は上がりません。むしろ下がります。
Queue depth(getWaitingCount)が monotonic に増える 投入速度 > 処理速度。システムが過負荷に近づいている兆候です。 Throughput(req/s)— システムがエラーを高速に返しているときも高くなりえます。
attempts を使い切った後の failed ジョブ数 本当のデータ損失であり、自動 retry の道は尽きています。 HTTP のエラー率 — 非同期ジョブが死んでも HTTP エラーは一切発生しません。
ルートごとの p99 latency 分布の裾、つまりユーザーが実際に痛みを感じる場所を捉えます。 平均 latency — 裾を完全に隠してしまいます。
使用中の DB connection 数 / pool サイズ 大半の API の本当のボトルネックです。pool の枯渇は、CPU が暇でも遅さを招きます。 コンテナのメモリ — OOM の瞬間まで、たいてい平坦なままです。
ratelimit.fail_open が 0 でない Redis に問題が起きており、かつあなたには今 rate limit がありません。 HTTP ポートの Uptime — worker がとうに死んでいても API は 200 を返し続けます。

7. デプロイ:1 つの image、1 つの domain、複数の役割

Z-EDU は pnpm + Turborepo の monorepo で、GitLab CI を通じて Portainer Swarm へデプロイしています。monorepo 全体が 1 つの image にビルドされ、システムは proxy 層でのルーティングを伴う1 つの domain 上で提供されます。 /api は NestJS の API へ、/admin は Next.js の admin へ、/ は Next.js の web へ向かいます。

利点は「アーキテクチャとして美しい」ことではなく、食い違いうるものの数が減ることです。単一の image tag がシステム全体の状態を記述し、 rollback は TAG という変数を 1 つ変えるだけです。API が tag v1.4.2 で動いている横で admin が tag v1.3.9 で動き、API の契約がずれて壊れる、といったことは起こりません。domain が 1 つということは、CORS がなく、 cookie に cross-site の設定が不要で、忘れうる設定の層が存在しないということです。

払うべきトレードオフは、image が最小限より大きくなること(worker 専用でも Next.js の bundle を抱えたままです)、そして 小さな変更でも全体をビルドし直すことです。Z-EDU の規模では、CI の数分はバージョンのずれをデバッグする数時間よりはるかに安上がりです。 これは条件付きのトレードオフです。サービスが 40 個、チームが 8 つあるなら答えは変わります。自分たちの実際の規模で選んでください。 エンジニアが 5,000 人いる会社のブログ記事で選んではいけません。

8. auto scaling を有効にする前のチェックリスト

1 行でもチェックが付いていないなら、auto scaling は状況を良くするどころか悪化させます。

結論

auto scaling は有用な運用ツールであり、私たちも今なお使っています。しかしそれは最後の一歩であって、最初の一歩ではありません。 それが答えるのは「同じ仕事量で、どうすればより多くのリクエストを捌けるか」という問いです。「なぜ 1 リクエストがこれほど多くの 仕事をしなければならないのか」という問いには答えてくれません。

正しい順序はこうです。不要な仕事をリクエスト経路から外し(エッジの cache、付随的な仕事のための queue)、state をプロセスの外へ出し、 本当の詰まりどころを計測し、それから初めて複製する。この順序に従えば、思っていたより少ない instance で足りると分かることがよくあります。 逆にやれば、より大きなインフラ請求書と、以前とまったく同じくらい遅いシステムが手に入ります。違うのは、 今度は複数の場所で同時に遅いということだけです。

Z-SOFT はこのようなシステムを開発しています

本記事で紹介した技術は、すべて自社プロダクトの本番環境で実際に稼働しているものです。高負荷に耐え、セキュアで、 長期にわたって運用できるシステムをお考えでしたら、Z-SOFT の開発チームにぜひご相談ください。

関連記事