「メトリクスが”何が遅いか”を教えてくれるなら、プロファイリングは”なぜ遅いか”を教えてくれる」と佐藤CTOは言った。「フレームグラフ1枚で、何日もかかるデバッグが数分で終わることもある。」
1. プロファイリングの種類
| 種類 | 対象 | ツール例 | 用途 |
|---|---|---|---|
| CPU プロファイリング | CPU 実行時間 | perf, V8 Profiler | ホットパスの特定 |
| メモリプロファイリング | ヒープ使用量 | heapdump, Chrome DevTools | メモリリーク検出 |
| I/O プロファイリング | ディスク/ネットワーク | strace, tcpdump | I/O ボトルネック |
| ロックプロファイリング | 同期待ち時間 | async_hooks | 競合の検出 |
サンプリング vs インストルメンテーション
| 方式 | 仕組み | オーバーヘッド | 精度 |
|---|---|---|---|
| サンプリング | 定期的にスタックトレースを記録 | 低(1-5%) | 統計的近似 |
| インストルメンテーション | 関数の入出口にフックを挿入 | 高(10-50%) | 正確 |
2. Node.js CPU プロファイリング
// Node.js 組み込みの V8 Profiler を使った CPU プロファイリング
import { Session } from 'node:inspector/promises';
import { writeFileSync } from 'node:fs';
class CpuProfiler {
private session: Session;
constructor() {
this.session = new Session();
}
async start(): Promise<void> {
this.session.connect();
await this.session.post('Profiler.enable');
await this.session.post('Profiler.start');
console.log('CPU profiling started');
}
async stop(outputPath: string): Promise<void> {
const { profile } = await this.session.post('Profiler.stop') as any;
writeFileSync(outputPath, JSON.stringify(profile));
await this.session.post('Profiler.disable');
this.session.disconnect();
console.log(`CPU profile saved to ${outputPath}`);
}
}
// Express ミドルウェアとしてのプロファイリング
import { Request, Response, NextFunction } from 'express';
function profilingMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
if (req.headers['x-enable-profiling'] !== 'true') {
return next();
}
const session = new Session();
session.connect();
await session.post('Profiler.enable');
await session.post('Profiler.start');
const originalEnd = res.end;
res.end = (async function (this: Response, ...args: any[]) {
const { profile } = await session.post('Profiler.stop') as any;
session.disconnect();
// プロファイル結果をレスポンスヘッダーに添付
const profileId = `profile-${Date.now()}`;
// 実際にはオブジェクトストレージに保存
console.log(`Profile ${profileId}: ${JSON.stringify(profile).length} bytes`);
this.setHeader('X-Profile-Id', profileId);
return originalEnd.apply(this, args);
}) as any;
next();
};
}
3. フレームグラフの読み方
フレームグラフは、CPU 使用時間をスタックトレースごとに可視化したもの。
graph TD
subgraph Main["main() ← ルート関数"]
subgraph PR["processRequest()"]
V["validate()"]
subgraph S["serialize()"]
J["JSON()"]
CO["compress()"]
end
end
subgraph HDB["handleDB()"]
Q["query()"]
subgraph T["transform()"]
M["map()"]
end
end
end
classDef root fill:#fee2e2,stroke:#ef4444
classDef mid fill:#fef3c7,stroke:#f59e0b
classDef leaf fill:#dbeafe,stroke:#3b82f6
class Main root
class PR,HDB mid
class V,S,Q,T,J,CO,M leaf
フレームグラフの解析ルール
- 幅が広い = CPU 時間を多く消費(最適化候補)
- 高さが深い = コールスタックが深い(不要な再帰の可能性)
- プラトー(平坦な頂上) = リーフ関数で時間を消費
- 左右の並びに意味はない(アルファベット順が一般的)
// Node.js で 0x を使ったフレームグラフ生成
// npm install -g 0x
// 0x -- node dist/server.js
// プログラム的にフレームグラフデータを生成
import { createWriteStream } from 'node:fs';
interface StackFrame {
functionName: string;
lineNumber: number;
columnNumber: number;
scriptId: string;
url: string;
children: StackFrame[];
selfTime: number; // この関数自体の実行時間(μs)
totalTime: number; // 子を含む合計時間(μs)
}
function generateFlameGraphData(profile: any): string[] {
const stacks: string[] = [];
function traverse(node: any, stack: string[]): void {
const name = node.callFrame?.functionName || '(anonymous)';
const currentStack = [...stack, name];
if (node.hitCount > 0) {
stacks.push(`${currentStack.join(';')} ${node.hitCount}`);
}
for (const child of node.children || []) {
traverse(child, currentStack);
}
}
traverse(profile.head, []);
return stacks;
}
// 出力例(folded stack format):
// main;processRequest;validate 15
// main;processRequest;serialize;JSON 42
// main;processRequest;serialize;compress 28
// main;handleDB;query 65
// main;handleDB;transform;map 33
4. メモリプロファイリング
// ヒープスナップショットの取得と分析
import { Session } from 'node:inspector/promises';
class MemoryProfiler {
private session: Session;
constructor() {
this.session = new Session();
}
async takeHeapSnapshot(outputPath: string): Promise<void> {
this.session.connect();
const chunks: string[] = [];
this.session.on('HeapProfiler.addHeapSnapshotChunk', (m: any) => {
chunks.push(m.params.chunk);
});
await this.session.post('HeapProfiler.takeHeapSnapshot');
this.session.disconnect();
const { writeFileSync } = await import('node:fs');
writeFileSync(outputPath, chunks.join(''));
console.log(`Heap snapshot saved: ${outputPath}`);
}
// メモリリーク検出: 2つのスナップショットを比較
async detectLeaks(): Promise<void> {
const before = process.memoryUsage();
// テスト対象の処理を実行...
// await runTestScenario();
// GC を強制実行 (--expose-gc フラグが必要)
if (global.gc) {
global.gc();
}
const after = process.memoryUsage();
const heapGrowth = after.heapUsed - before.heapUsed;
const rssGrowth = after.rss - before.rss;
console.log(`Heap growth: ${(heapGrowth / 1024 / 1024).toFixed(2)} MB`);
console.log(`RSS growth: ${(rssGrowth / 1024 / 1024).toFixed(2)} MB`);
if (heapGrowth > 10 * 1024 * 1024) {
console.warn('Potential memory leak detected!');
}
}
}
// メモリリークのよくあるパターン
class LeakExamples {
// パターン1: クロージャによる参照保持
private handlers: Function[] = [];
addHandler(data: Buffer): void {
// data が handlers 配列経由でずっと参照される
this.handlers.push(() => {
console.log(data.length);
});
}
// パターン2: イベントリスナーの未解除
private emitter = new (require('events').EventEmitter)();
startListening(): void {
// removeListener しないとリーク
this.emitter.on('data', (chunk: Buffer) => {
this.processChunk(chunk);
});
}
private processChunk(chunk: Buffer): void {
// ...
}
// パターン3: Map/Set の無制限成長
private cache = new Map<string, object>();
cacheResult(key: string, value: object): void {
// サイズ制限がないため無限に成長する
this.cache.set(key, value);
// 修正: LRU キャッシュを使う
}
}
5. ブラウザプロファイリング
// Performance API を使ったブラウザ側の計測
class BrowserPerformanceProfiler {
// User Timing API でカスタム計測
static measureAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
performance.mark(`${name}-start`);
return fn().then((result) => {
performance.mark(`${name}-end`);
performance.measure(name, `${name}-start`, `${name}-end`);
return result;
});
}
// Long Task API で重い処理を検出
static observeLongTasks(): void {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`Long task detected: ${entry.duration.toFixed(1)}ms`, {
startTime: entry.startTime,
name: entry.name,
// attribution で原因を特定
attribution: (entry as any).attribution,
});
}
});
observer.observe({ type: 'longtask', buffered: true });
}
// LayoutShift の監視
static observeLayoutShifts(): void {
let clsScore = 0;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as any[]) {
if (!entry.hadRecentInput) {
clsScore += entry.value;
console.log(`Layout shift: ${entry.value.toFixed(4)}, CLS total: ${clsScore.toFixed(4)}`);
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
}
// リソースタイミングの収集
static getResourceTimings(): Record<string, number[]> {
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
const byType: Record<string, number[]> = {};
for (const entry of entries) {
const ext = entry.name.split('.').pop() || 'other';
if (!byType[ext]) byType[ext] = [];
byType[ext].push(entry.duration);
}
return byType;
}
}
コラム: Continuous Profiling とは
本番環境で常時低オーバーヘッドのプロファイリングを行う手法。
- Datadog Continuous Profiler: 1% のサンプリングレートで常時稼働
- Pyroscope: オープンソースの Continuous Profiling プラットフォーム
- Google Cloud Profiler: GCP 環境での常時プロファイリング
利点:
- 本番環境でしか再現しないボトルネックを発見
- デプロイ前後の性能変化を可視化
- 特定のリクエストパターンに紐づく CPU/メモリ消費を分析
まとめ
| トピック | 要点 |
|---|---|
| プロファイリング種類 | CPU/メモリ/I-O/ロック、目的に応じて使い分け |
| サンプリング方式 | 低オーバーヘッドで本番環境にも適用可能 |
| フレームグラフ | 幅=CPU時間、高さ=コールスタック深度 |
| メモリプロファイリング | ヒープスナップショット比較でリーク検出 |
| ブラウザプロファイリング | Performance API/Long Task/LayoutShift |
チェックリスト
- サンプリングとインストルメンテーションの違いを説明できる
- フレームグラフの読み方を理解した
- Node.js の CPU/メモリプロファイリングを実行できる
- メモリリークの一般的なパターンを知っている
- ブラウザの Performance API を活用できる
次のステップへ
プロファイリングでボトルネックを特定できるようになった。次は アムダールの法則とスケーラビリティ を学び、性能改善の理論的限界を理解しよう。
推定読了時間: 30分