Node.jsの診断・パフォーマンス計測 入門|“遅い・重い”を勘で直さないためのプロファイリング実践

はじめに

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計測の基本方針:外から→中へ

おすすめの順番はこれです。

  1. 外側(症状の観測):遅いのはいつ、どのエンドポイント、どれくらい?
  2. 軽い計測(ログ/タイミング):どこで時間を使っている?
  3. 重い計測(プロファイル/スナップショット):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 は比較的詰まりにくいはずです。/statseventLoopDelay が増えるなら「CPU占有・同期処理で詰まっている」可能性が上がります。


1) InspectorでCPUプロファイルを取る(まずこれ)

起動をInspector付きにします。

node --inspect src/server.js

Chromeで chrome://inspect を開き、対象プロセスに接続 → DevTools の Performance でプロファイルを記録します。
手順のコツ:

  1. 記録開始
  2. /cpu?ms=200 を複数回叩く(負荷を発生させる)
  3. 記録停止
  4. 重い関数(cpuHeavy)がどれだけ時間を食っているか確認

見たいポイント:

  • Mainスレッドでどの関数が時間を占有しているか
  • コールスタックが深い場合、どの呼び出し経路が“本体”か
  • Math.sqrtJSON.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の診断・パフォーマンス計測は、道具が揃っているぶん、最初は「何から触ればいいか」で迷います。この記事でやったように、

  1. イベントループ遅延で詰まりを検知
  2. InspectorでCPUプロファイルを取り、重い関数を確定
  3. --trace-gcとヒープスナップショットでメモリ問題を追う
  4. 必要なら --cpu-profで本番寄りに採取

という順番で進めると、問題の種類を外しにくくなります。

「遅いから最適化する」ではなく、
「遅い理由を計測で当ててから直す」
この流れを持てるだけで、改善の精度も再現性も一気に上がります。


投稿日

カテゴリー:

投稿者:

タグ:

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です