1本のスレッドを全員で使っている

第9章では、Node.jsのJavaScriptが1本のメインスレッドで動き、libuvのイベントループがtimers → poll → checkといったフェーズを回しながらコールバックを1つずつ実行する仕組みを見ました。 ネットワークI/Oはepollやkqueueに任せ、ファイルI/Oや一部の暗号処理は4本のスレッドプールに任せます。 この分業のおかげで、Node.jsは1スレッドでも数万の接続を同時に抱えられます。

裏を返すと、メインスレッドは全リクエストの共有資源です。 あるリクエストのコールバックが3秒かかる計算を始めると、その3秒間イベントループは次のフェーズに進めません。 epollがほかの接続のデータ到着を知らせていても、それを処理するコールバックを実行するスレッドが空いていないからです。 Node.js公式ガイド「Don't Block the Event Loop」は、この状態を「イベントループをブロックする」と呼び、1回のコールバックにかかる時間を小さく保つよう求めています。

本章では、実務でイベントループが止まる代表的な3パターン(正規表現、巨大JSON、スレッドプール枯渇)を見たあと、重い処理の逃がし先と、止まっていないかを計測する方法を整理します。

sequenceDiagram
  participant A as リクエストA
  participant L as メインスレッド
  participant B as リクエストB
  participant C as リクエストC
  A->>L: 重い同期処理を開始
  B->>L: 到着(epollは検知済み)
  C->>L: 到着(epollは検知済み)
  Note over L: Aの処理が終わるまで<br/>次のコールバックを実行できない
  L-->>A: 3秒後に応答
  L-->>B: やっと応答
  L-->>C: やっと応答
1つのコールバックが長いと、I/Oの準備ができているほかのリクエストも全員待たされる

正規表現がCPUを食い尽くす — ReDoS

Cloudflare 2019年7月2日の障害

2019年7月2日、CloudflareのWAF(Webアプリケーションファイアウォール)に追加された1本のルールが、世界中のエッジサーバーのCPUを約27分間100%に張り付かせました。 原因はルール内の正規表現に含まれていた.*(?:.*=.*)という部分です。 バックトラッキング型のエンジン(PCRE)は、この形の正規表現に対して入力長に応じて試行回数が爆発的に増えることがあり、CPUがHTTPリクエストを処理する余力を失いました。 Node.jsの話ではありませんが、「1つの重い処理がスレッドを占有すると全員が止まる」構図はイベントループとまったく同じです。

破滅的バックトラッキングとは

JavaScriptの正規表現エンジン(V8のIrregexp)もPCREと同じバックトラッキング型です。 マッチに失敗すると、直前の選択に戻って別の分け方を試します。 量指定子が入れ子になった(a+)+$に"aaaa!"を与えると、エンジンは「aaaaを外側の繰り返しで何回に分けるか」をすべて試してから失敗を確定します。 4文字なら分け方は8通りですが、n文字なら2n-1通りに増えます。30文字で約5億通りです。

graph TD
  S["入力 aaaa! に (a+)+$ を適用"] --> A1["[aaaa] の1グループ"]
  S --> A2["[aaa][a] の2グループ"]
  S --> A3["[aa][aa] の2グループ"]
  S --> A4["[a][aaa] など残り5通り"]
  A1 --> F1["! で $ が失敗"]
  A2 --> F2["! で $ が失敗"]
  A3 --> F3["! で $ が失敗"]
  A4 --> F4["すべて失敗"]
  F1 --> R["n文字なら 2^(n-1) 通りを試してから<br/>マッチ失敗を確定"]
  F2 --> R
  F3 --> R
  F4 --> R
入れ子の量指定子は、失敗する入力に対して分け方の組み合わせをすべて試す

Node.js公式ガイドは、危険な例として/(\/.+)+$/を挙げています。 パスのような文字列を検証するつもりの正規表現ですが、スラッシュを大量に並べた末尾にマッチしない文字を付けた入力を送られると、メインスレッドが何秒も返ってきません。 攻撃者はリクエスト1本でサーバー全体を止められます。これがReDoS(Regular expression Denial of Service)です。

const re = /(\/.+)+$/;

for (const n of [28, 32, 36, 40]) {
  const input = '/'.repeat(n) + '\n'; // . は改行にマッチしないので $ が必ず失敗する
  const t0 = performance.now();
  re.test(input);
  console.log(n, (performance.now() - t0).toFixed(0) + 'ms');
}
// 筆者の手元(Node 24): 28 → 37ms, 32 → 47ms, 36 → 299ms, 40 → 2036ms
// 4文字増えるごとにおよそ7倍。たった41バイトの入力でメインスレッドが2秒止まる

ReDoSへの対策

対策は3段構えです。1つ目は、ユーザー入力に当てる正規表現から入れ子の量指定子((x+)+、(x*)*)や、重なり合う選択肢を取り除くことです。 2つ目は、正規表現に渡す前に入力長を制限することです。指数的な爆発も、入力が短ければ実害が出る前に終わります。 3つ目は、線形時間で動く正規表現エンジンを使うことです。GoogleのRE2はバックトラッキングをせず、入力長に比例する時間でマッチを終えます。 npmのre2パッケージでNode.jsから使えます。後方参照など一部の構文が使えない代わりに、最悪計算量が保証されます。 Cloudflareも障害後、WAFの正規表現エンジンを線形時間のものへ移行すると公表しました。

巨大JSONのparseとstringify

JSON.parseとJSON.stringifyは同期関数で、途中でイベントループに制御を返しません。 Node.js公式ガイドの例では、50MBのJSONに対してJSON.stringifyが約0.7秒、JSON.parseが約1.3秒かかります。 管理画面の「全件エクスポート」や、外部APIから受け取った巨大なレスポンスをそのままparseするコードは、その間すべてのリクエストを止めています。

対処の第一歩は、そもそも巨大なJSONを1回で扱わないことです。ページネーションで分割する、JSON Lines(1行1レコード)にして1行ずつ処理する、といった設計で1回あたりのデータ量を小さくできます。 どうしても大きなデータを読む場合は、ストリーミングパーサ(npmのstream-jsonなど)でストリームから少しずつ組み立てます。 チャンクごとにコールバックが分かれるので、その合間にほかのリクエストが処理されます。 parse結果がまとめて必要なら、後述のworker_threadsにparseごと任せる方法もあります。 ただしworkerからメインスレッドへオブジェクトを渡すときには構造化複製(コピー)が発生するので、結果が巨大だとその受け渡しの時間も無視できません。

スレッドプール枯渇 — 4本を埋めると何が起きるか

メインスレッドが空いていても、別の場所で詰まることがあります。第9章で見たとおり、libuvのスレッドプールは既定で4本です。 非同期のfs、dns.lookup、crypto.pbkdf2やscrypt、zlibの非同期APIは、すべてこの4本を取り合います。

たとえばログインAPIがパスワード検証にcrypto.pbkdf2を使っていて、同時に4件のログインが来たとします。 4本のスレッドが鍵導出で埋まっている間、ほかのリクエストのfs.readFileはキューで待ちます。 見落としやすいのがdns.lookupです。http.requestや多くのDBクライアントはホスト名を解決するときに既定でdns.lookup(getaddrinfo)を呼ぶので、 外部APIの呼び出しまで「名前解決待ち」で遅くなります。メインスレッドのCPU使用率は低いのに外向きのリクエストだけ遅い、という分かりにくい症状になります。

import { pbkdf2 } from 'node:crypto';
import { lookup } from 'node:dns';

const t0 = Date.now();
// スレッドプール4本を鍵導出で埋める
for (let i = 0; i < 4; i++) {
  pbkdf2('pw', 'salt', 1_000_000, 64, 'sha512', () => {
    console.log('pbkdf2 done', Date.now() - t0, 'ms');
  });
}
// dns.lookup も同じプールを使うので、上の4本が終わるまで始まらない
lookup('example.com', () => {
  console.log('lookup done', Date.now() - t0, 'ms');
});
// 筆者の手元の出力例: pbkdf2 done 207 ms → lookup done 217 ms → pbkdf2 done 219 ms …
// 名前解決そのものは10ms程度なのに、空きスレッドが出るまで約200ms待たされている

対策は、まずUV_THREADPOOL_SIZEを処理内容に合わせて増やすことです(最大1024)。この値はプールが作られる前、つまりプロセス起動時に環境変数で渡します。 ただしスレッドを増やしてもCPUコア数以上に鍵導出が速くなるわけではありません。 CPUを使う暗号処理が多いならworker_threadsやコア数の多い別サービスへ分け、名前解決はc-aresを使うdns.resolve4系や接続の再利用(keep-alive)で回数を減らします。

重い処理の逃がし方

イベントループを止めない方法は、大きく「細かく分けて合間に譲る」と「別のスレッドやプロセスに任せる」の2つです。 どちらを選ぶかは、処理の重さと、メモリを共有したいかどうかで決まります。

方法実行場所向いている処理注意点
setImmediateで分割同じメインスレッド数十〜数百ms程度の反復処理(配列の集計など)総処理時間は縮まない。分割の粒度を自分で決める必要がある
worker_threads同じプロセス内の別スレッド(別V8 isolate)画像処理、巨大JSONのparse、ハッシュ計算などのCPU処理起動コストがあるのでプールで再利用する。データはコピーかtransfer、SharedArrayBufferで共有
child_process別プロセスffmpegなど外部コマンドの実行、クラッシュを隔離したい処理プロセス起動とIPCのコストが大きい。メモリは共有しない
cluster複数のNodeプロセス(ポート共有)リクエスト全体のスループットを複数コアに広げたいとき1リクエスト内の重い処理は速くならない。プロセス数ぶんメモリを使う
別サービス化別のサーバーやジョブキュー数秒以上かかるバッチ、動画変換、レポート生成キュー・リトライ・結果通知の設計が必要になる

setImmediateで分割する

ループの途中でsetImmediateを挟むと、そこでいったんイベントループに制御が戻り、pollフェーズでほかの接続のI/Oコールバックが実行されます。node:timers/promisesのsetImmediateを使うと、awaitで書けます。

import { setImmediate as yieldToLoop } from 'node:timers/promises';

export async function sumChunked(items: number[]): Promise<number> {
  let total = 0;
  for (let i = 0; i < items.length; i++) {
    total += items[i];
    // 1万件ごとにイベントループへ制御を返す
    if (i % 10_000 === 0) await yieldToLoop();
  }
  return total;
}

ここでprocess.nextTickやPromise.resolve()を使っても効果はありません。 第9章で見たとおり、nextTickとマイクロタスクはフェーズを進める前にすべて消化されるので、イベントループは次のフェーズへ進めないままです。

実測: /blockと/offload

筆者の手元(M3 ProのMac)で、3秒間CPUを回すエンドポイントを2通りに実装して比べました。/blockはメインスレッドでループを回し、/offloadは同じループをworker_threadsに任せます。 どちらかを叩いている最中に、別のターミナルから軽い/pingを叩いて応答時間を測ります。

// server.ts
import http from 'node:http';
import { Worker } from 'node:worker_threads';

function busyLoop(ms: number): number {
  const end = Date.now() + ms;
  let n = 0;
  while (Date.now() < end) n++;
  return n;
}

function runInWorker(ms: number): Promise<number> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(new URL('./busy-worker.ts', import.meta.url), {
      workerData: { ms },
    });
    worker.once('message', resolve);
    worker.once('error', reject);
  });
}

http.createServer(async (req, res) => {
  if (req.url === '/block') {
    res.end(String(busyLoop(3000)));        // メインスレッドで3秒回す
  } else if (req.url === '/offload') {
    res.end(String(await runInWorker(3000))); // workerで3秒回す
  } else {
    res.end('pong');
  }
}).listen(3000);
// busy-worker.ts(別スレッド・別V8 isolateで動く)
import { parentPort, workerData } from 'node:worker_threads';

const end = Date.now() + workerData.ms;
let n = 0;
while (Date.now() < end) n++;
parentPort?.postMessage(n);
# ターミナル1: 重いエンドポイントを叩く
curl -s localhost:3000/block > /dev/null

# ターミナル2: その直後に軽いエンドポイントの応答時間を測る
curl -s -o /dev/null -w '%{time_total}s\n' localhost:3000/ping

結果は、/blockの最中は/pingが約2.8秒待たされ、/offloadの最中は0.00秒台で返りました。/blockでも/pingの接続自体はカーネルが受け付けていますが、それを処理するコールバックを実行できるのは3秒のループが終わったあとです。 なお、この例はリクエストごとにWorkerを作っています。Workerの起動にはミリ秒単位のコストがあるので、本番ではpiscinaなどのワーカープールで使い回します。

止まっていないかを計測する

monitorEventLoopDelayとELU

イベントループが止まっているかどうかは、2つの指標で継続的に観測できます。perf_hooks.monitorEventLoopDelay()は、一定間隔(resolution、既定10ms)で動くタイマーが予定よりどれだけ遅れて発火したかをヒストグラムに記録します。値の単位はナノ秒です。performance.eventLoopUtilization()(ELU)は、イベントループがepoll_waitなどでI/Oを待っていなかった時間の割合です。 ELUが1に近いほど、ループはJavaScriptの実行に追われていて待つ暇がない状態です。

import { monitorEventLoopDelay, performance } from 'node:perf_hooks';

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
let last = performance.eventLoopUtilization();

setInterval(() => {
  const elu = performance.eventLoopUtilization(last); // 前回からの差分
  last = performance.eventLoopUtilization();
  const p99ms = h.percentile(99) / 1e6; // ナノ秒 → ミリ秒
  const maxms = h.max / 1e6;
  console.log('delay p99=' + p99ms.toFixed(1) + 'ms max=' + maxms.toFixed(1) + 'ms ELU=' + elu.utilization.toFixed(2));
  h.reset();
}, 5000).unref();

この値をメトリクスとしてAPMやダッシュボードに送っておくと、「レイテンシが悪化したとき、イベントループ遅延も同時に跳ねていたか」で原因を切り分けられます。 遅延が跳ねていればメインスレッドのCPU処理、跳ねていなければ外部I/Oの遅さやスレッドプールの待ちを疑います。 2022年にZalandoが公開したインシデント事例も、イベントループ遅延を手がかりに原因を追ったものでした。

どこで止まっているかを探す

止まっていると分かったら、次はどの関数がCPUを使っているかを調べます。node --cpu-prof server.jsで起動すると、終了時に.cpuprofileファイルが書き出され、Chrome DevToolsのPerformanceパネルで開けます(v22.4.0でstable)。0xはフレームグラフをHTMLで生成するツールです。Linuxならperfとnode --perf-basic-profを組み合わせると、JavaScriptの関数とカーネル内の処理を1枚のプロファイルで見られます。 かつて定番だったclinic.jsは保守が止まっているため、新しく導入するなら上の選択肢を使います。

まとめ

イベントループは全リクエストで1本のスレッドを共有しているので、1回のコールバックの長さがそのままほかの全員の待ち時間になります。 典型的な原因は、入れ子の量指定子による破滅的バックトラッキング(ReDoS)、巨大JSONの同期parse/stringify、そして4本しかないスレッドプールの取り合いです。 CPUを使う処理はsetImmediateで分割するか、worker_threads・child_process・cluster・別サービスへ逃がし、monitorEventLoopDelayとELUで止まっていないかを常に観測します。

ここまでは1つのNode.jsプロセスの内側を見てきました。次章では外側に出て、そのプロセスを包むコンテナを扱います。 コンテナの正体はnamespacesとcgroupsで制限されたただのプロセスです。そこから、本番で突然OOMKilledになる理由、SIGTERMを受けたときの正しい終わり方、PID 1問題を説明します。

理解度チェック

問題 0 / 50%
Q1

正規表現/(a+)+$/が"aaaa…a!"のような入力で極端に遅くなる主な理由はどれですか?

キーボード: 1〜4 で選択、Enter で回答