はじめに
Node.jsでAPIやバッチを作っていると、ある日突然こういう相談が来ます。
- 「たまにレスポンスが遅い」
- 「CPUが跳ねる時間帯がある」
- 「メモリが増え続けて落ちる」
- 「なぜかタイムアウトが増えた」
この手の問題は、コードを眺めて“それっぽい箇所”を直しても再発しがちです。理由は単純で、パフォーマンス問題は 症状(遅い/重い)と原因(どこが詰まっているか)が一致しない ことが多いからです。
Node.jsには、最初から「計測するための仕組み」がかなり揃っています。
- DevToolsでCPUプロファイルを見る
--trace-*でイベントループやGCの気配を掴む- ヒープスナップショットでメモリリークを追う
perf_hooksでアプリ内計測を仕込む
この記事では、外部の大規模APMに頼る前に、手元と本番に近い環境で再現→計測→原因の切り分けをできるようになることを目標にします。 “速くするテクニック集”ではなく、まず原因を当てる技術を中心に進めます。
座学
1) まず分ける:CPUボトルネック vs I/O待ち vs メモリ問題
Node.jsの遅さは大きく3種類に分けて考えると迷子になりません。
- CPUボトルネック
例:JSONの巨大変換、暗号化、正規表現、画像処理、重いループ
特徴:CPU使用率が上がる、イベントループが詰まりやすい、同時リクエストで悪化
計測:CPUプロファイル(Inspector)、0x/Clinic Flame、--cpu-profなど - I/O待ち(DB/外部API/FS/ネットワーク)
例:DBが遅い、外部APIが遅い、DNSやTLSで遅い
特徴:CPUは低いのに遅い、待ち時間が支配的、並行リクエストで滞留が増える
計測:アプリ内タイミング計測、ログ相関、場合によっては分散トレース - メモリ問題(リーク / キャッシュ肥大 / 断片化)
例:配列に溜め続ける、Mapにキーを残す、リクエスト単位オブジェクトを保持
特徴:RSS/heapが増え続ける、GCが増えて遅くなる、最後はOOMで落ちる
計測:ヒープスナップショット、--trace-gc、--heapsnapshot-signalなど
ここで大事なのは、体感の遅さ=CPUが原因とは限らないこと。例えばメモリリークでGCが頻発すると、CPU使用率がそこそこ上がり、症状は“CPUっぽい”のに実態は“メモリ”だったりします。
2) Node.js計測の基本方針:外から→中へ
おすすめの順番はこれです。
- 外側(症状の観測):遅いのはいつ、どのエンドポイント、どれくらい?
- 軽い計測(ログ/タイミング):どこで時間を使っている?
- 重い計測(プロファイル/スナップショット):CPUやメモリの“犯人”を確定する
いきなりプロファイラを回すと情報量が多すぎて迷いやすいので、まず“当たりをつける”のがコツです。
3) よく使う道具箱(Node標準中心)
- Inspector(Chrome DevTools):CPUプロファイル、ヒープスナップショット、アロケーション
--trace-gc:GCが多い/重いかの当たりをつける--trace-event-categories:イベントループやV8のイベントをトレースperf_hooks:アプリ内の区間計測、EventLoop遅延の観測--cpu-prof/--heap-prof:本番寄りで“ファイル出力”できるプロファイル- (補助)0x / Clinic.js:フレームグラフで“重い関数”を見やすくする
ハンズオン
ここからは、手元で再現できる“遅さ”を作り、計測して原因を当てます。題材はミニHTTPサーバーです(フレームワークなし)。
0) 準備:遅いサーバーを作る
package.json(ESM想定)
{
"name": "node-perf-intro",
"type": "module",
"scripts": {
"dev": "node src/server.js"
}
}
src/server.js
import http from 'node:http';
import { performance, monitorEventLoopDelay } from 'node:perf_hooks';
import crypto from 'node:crypto';const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();function cpuHeavy(ms) {
// わざとCPUを占有する(悪い例)
const end = Date.now() + ms;
let x = 0;
while (Date.now() < end) {
x = (x + Math.sqrt(Math.random())) % 1000;
}
return x;
}function json(res, code, body) {
const data = JSON.stringify(body);
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
res.end(data);
}const server = http.createServer(async (req, res) => {
const requestId = crypto.randomUUID();
const start = performance.now(); try {
if (req.url?.startsWith('/cpu')) {
// /cpu?ms=200 のように叩く
const url = new URL(req.url, `http://${req.headers.host}`);
const ms = Number(url.searchParams.get('ms') ?? '200');
cpuHeavy(ms);
return json(res, 200, { ok: true, requestId, kind: 'cpu', ms });
} if (req.url?.startsWith('/io')) {
// I/O待ちっぽい遅さ(擬似的にsleep)
const url = new URL(req.url, `http://${req.headers.host}`);
const ms = Number(url.searchParams.get('ms') ?? '200');
await new Promise(r => setTimeout(r, ms));
return json(res, 200, { ok: true, requestId, kind: 'io', ms });
} if (req.url === '/stats') {
// Event Loopの詰まり具合を可視化
return json(res, 200, {
eventLoopDelay_ms: {
mean: histogram.mean / 1e6,
p95: histogram.percentile(95) / 1e6,
max: histogram.max / 1e6
}
});
} return json(res, 404, { error: 'Not Found', requestId });
} finally {
const dur = performance.now() - start;
// まずは雑でもいいのでタイミングを残す
console.log(JSON.stringify({ requestId, url: req.url, duration_ms: dur }));
}
});server.listen(3000, () => console.log('listening on :3000'));
起動:
npm run dev
負荷をかける(別ターミナル):
# CPUを占有する遅さ
for i in {1..20}; do curl -s "http://localhost:3000/cpu?ms=200" >/dev/null & done; wait
curl -s "http://localhost:3000/stats" | jq# I/O待ちの遅さ
for i in {1..50}; do curl -s "http://localhost:3000/io?ms=200" >/dev/null & done; wait
curl -s "http://localhost:3000/stats" | jq
ここで /cpu はイベントループを詰まらせやすく、/io は比較的詰まりにくいはずです。/stats の eventLoopDelay が増えるなら「CPU占有・同期処理で詰まっている」可能性が上がります。
1) InspectorでCPUプロファイルを取る(まずこれ)
起動をInspector付きにします。
node --inspect src/server.js
Chromeで chrome://inspect を開き、対象プロセスに接続 → DevTools の Performance でプロファイルを記録します。
手順のコツ:
- 記録開始
/cpu?ms=200を複数回叩く(負荷を発生させる)- 記録停止
- 重い関数(
cpuHeavy)がどれだけ時間を食っているか確認
見たいポイント:
- Mainスレッドでどの関数が時間を占有しているか
- コールスタックが深い場合、どの呼び出し経路が“本体”か
Math.sqrtやJSON.stringifyなど、標準関数側が支配的になっていないか
“CPUボトルネック”は、この段階でかなりの確率で特定できます。
2) Event Loop遅延をメトリクス化する(軽くて強い)
monitorEventLoopDelay() は、アプリが詰まっているかを非常にシンプルに示します。
- p95が跳ねる → 体感の遅さに直結しやすい
- maxが跳ねる → たまに“フリーズ”がある
- meanが上がる → 常に詰まり気味
本番でも、APMがなくても、この指標をログに出すだけで“詰まり”の検知ができます。
例えば一定間隔でログ出力するだけでも有効です(ただしログ量は制御)。
3) --trace-gcでGCの気配を見る(メモリ系の当たりをつける)
次は「遅さの正体がGCかも?」を疑うケースです。
起動:
node --trace-gc src/server.js
この出力が頻発し、かつ停止時間が大きい(長いPause)なら、
- オブジェクトを作りすぎ
- メモリが増えてGCが苦しい
- リークで回収できない
などを疑います。
“GCが多い”と分かったら次は ヒープスナップショット で「何が溜まってるか」を見に行くのが定石です。
4) ヒープスナップショットで“溜めている犯人”を当てる
Inspector(DevTools)の Memory タブで Heap snapshot を取ります。
ポイント:
- 1回だけでなく、負荷をかけながら 複数回 取って比較する
- “増え続ける型(Object/Array/Map/Closureなど)”を探す
- Retainers(保持している参照元)で「なぜ回収されないか」を追う
メモリリークは「作っている場所」より「保持している場所」が原因のことが多いです。
例えば、リクエストごとのデータをグローバル配列にpushしていたり、Mapのキーが消されなかったり、イベントリスナーが外されず残り続けたり。
5) 本番寄りで“ファイル出力”する:--cpu-prof / --heap-prof
Inspectorを開けない環境でも、Nodeはプロファイルをファイルに吐けます。
例:CPUプロファイル
node --cpu-prof src/server.js
負荷をかけてから停止すると、.cpuprofile が出力されます。
これをChrome DevToolsのPerformanceで読み込めば解析できます。
本番での考え方:
- 常時ONは重いので、短時間だけ 取得する
- 事故を避けるため、プロファイルファイルの保存先・容量を管理する
- “遅い時間帯にだけ採取”できるよう、運用手順を用意する
6) 計測結果から改善へ繋げる(典型パターン)
最後に「原因が分かった後にどう直すか」の方向性も整理します。
- CPUが原因だった
- 同期の重い処理をI/O中にやっていないか
- 巨大JSON変換や正規表現を見直す
- 可能ならワーカー化(Worker Threads / 別プロセス / 別サービス)
- I/O待ちが原因だった
- DBクエリの見直し(インデックス、N+1、タイムアウト)
- 外部APIの並列数制御、キャッシュ、リトライ設計
- タイムアウトとサーキットブレーカー的な防御
- メモリ(GC/リーク)が原因だった
- 保持している参照を断つ(Map削除、リスナー解除、キャッシュ上限)
- 大きいオブジェクトを“共有”しない(リクエストに閉じる)
- ヒープに溜めない設計(ストリーミング、ページング)
“速くする”より先に、“何が原因かを外さない”ことが勝ち筋です。
まとめ
Node.jsの診断・パフォーマンス計測は、道具が揃っているぶん、最初は「何から触ればいいか」で迷います。この記事でやったように、
- イベントループ遅延で詰まりを検知
- InspectorでCPUプロファイルを取り、重い関数を確定
--trace-gcとヒープスナップショットでメモリ問題を追う- 必要なら
--cpu-profで本番寄りに採取
という順番で進めると、問題の種類を外しにくくなります。
「遅いから最適化する」ではなく、
「遅い理由を計測で当ててから直す」
この流れを持てるだけで、改善の精度も再現性も一気に上がります。
コメントを残す