秒間10万リクエストを捌くNode.jsサーバーの作り方
TechEmpowerのフレームワーク・ベンチマークでは、単純応答系で10万RPS超を達成するNode.jsスタックが複数報告されています¹。加えて、同等クラスのハードウェア上での公開事例や一般的な検証では、32 vCPUクラスのx86サーバー上でFastify構成が概ね100k〜150k RPS、p95レイテンシ5–8ms程度を示すケースがあり、前提条件が揃えば到達は十分に現実的です。落とし穴は、アプリのロジック以前に、カーネル・HTTP・シリアライズ・マルチコア活用・計測方法のどこかでボトルネックを作ってしまうことにあります。エビデンスに基づき、達成に必要な実装と運用の勘所を、コードと数値を交えて整理します。なおTLS終端やDB I/Oを含む複合トラフィックでは前提が変わるため、ここではL4/L7終端は外部に逃し、アプリ層のHTTP/1.1 Keep-AliveとJSON応答を対象に解説します。
10万RPSは現実的か――前提条件と測定の土台
現実性はワークロードの性質とプラットフォームの土台で決まります。対象はHTTP/1.1の短いJSONレスポンスで、外部I/Oを伴わないパスを想定します。Node.jsは単スレッド(1つのイベントループで処理)ですが¹³、プロセスを並べるclusterで全コアを使い切れば、NICとCPUが許す限りスケールします³。Linux 5.10+、Node 20 LTS以降¹⁰、10GbE以上のネットワーク、16〜32 vCPU程度のマシンを基準にすると、単一ホストでも10万RPS到達が見えてきます。測定はautocannonやwrk(HTTPベンチマークツール)を使い⁷⁹、長めのウォームアップと一定の接続数を維持してスループットとレイテンシを同時に観察します。ターゲット指標は、RPS(Requests Per Second: 秒間リクエスト数)、p95/p99レイテンシ(95/99パーセンタイルの応答時間)、CPU使用率、イベントループ遅延⁶(loopの滞留時間)、GC時間(ガーベジコレクションに要した時間)、ソケット数です。遅延やGCが跳ねるときは、シリアライズ⁵とログ(同期I/O)¹¹、あるいはコネクション管理を疑うのが近道です。
計測条件の明示と目安
代表的な条件として、Node 22.x、Fastify 5系⁸、JSON 200〜400バイト、Keep-Alive有効、接続数1000前後、パイプラインは10程度に設定します⁷(パイプライニングは1本のTCP接続で複数のリクエストを連続投入する手法)。例えば、c6i.8xlarge相当のインスタンス上で、アプリがCPU 85–90%に張り付いた状態で100k RPS超を複数分間安定維持し、p95は5–8ms、p99は12–20msと報告されるケースがあります。ベースラインの素のhttpサーバー²だとレイテンシが僅かに下がる傾向が見られる一方で、機能のないベンチマーク専用構成になります。現実のアプリではFastifyなどのフレームワークを選び、スキーマ駆動とシリアライズ最適化で差分を取り戻すのが定石です⁵⁸。
実装の基礎――最小HTTPからFastify最適化まで
基礎は、無駄なオブジェクト割り当てを避け、Keep-Aliveを適正化し²、ログを抑制し(同期I/Oを避ける)¹¹、安定したシリアライズ経路を固定することです⁵。最小構成で上限を知り、そこからフレームワーク化して機能と保守性を取り込みます。
素のhttpでの最短レイテンシ実装
JSONを返すだけの最小実装で、Nodeのサーバータイムアウト類とヘッダを意図的に調整します²。環境はESMを前提にしています。
import http from 'node:http';
import { setInterval } from 'node:timers';
const payload = Buffer.from(JSON.stringify({ ok: true }));
const server = http.createServer((req, res) => {
if (req.url !== '/health') {
res.statusCode = 200;
res.setHeader('content-type', 'application/json');
res.setHeader('content-length', String(payload.length));
res.end(payload);
return;
}
res.statusCode = 204;
res.end();
});
server.keepAliveTimeout = 2500; // 短めでFDを回す
server.headersTimeout = 5000;
server.requestTimeout = 0; // アプリ側で管理
server.on('clientError', (err, socket) => {
socket.destroy();
});
server.listen(3000, '0.0.0.0', () => {
console.log('http server on :3000');
});
// 監視用: イベントループ遅延を粗く見る
let last = performance.now();
setInterval(() => {
const now = performance.now();
const delay = now - last - 1000;
if (delay > 50) console.warn('loop delay', delay.toFixed(1),'ms');
last = now;
}, 1000);
この段階での上限RPSが理論的な天井に近い値です。ここで100k RPSに達しない場合、OSやNICの設定、ロードジェネレータ(wrk/autocannon)の性能⁷⁹、あるいはコネクション枯渇が疑われます。イベントループの歪みはperf_hooks等で把握できます⁶。
Fastifyでスキーマ駆動とシリアライズ最適化
実運用ではルーティング、バリデーション、フックが必要になります。Fastifyはスキーマコンパイル済みシリアライザでオーバーヘッドを抑えられます⁵⁸。
import Fastify from 'fastify';
import pino from 'pino';
// 高負荷環境では同期I/Oを避け、ログは重要イベントに限定
const logger = pino({ level: process.env.LOG_LEVEL || 'info', enabled: process.env.LOG !== '0' });
const app = Fastify({ logger, trustProxy: true, keepAliveTimeout: 2500, requestTimeout: 0 });
const replySchema = {
200: {
type: 'object',
properties: { ok: { type: 'boolean' }, v: { type: 'number' } },
additionalProperties: false
}
};
app.get('/', { schema: { response: replySchema } }, async (req, reply) => {
return { ok: true, v: 1 };
});
app.get('/health', { schema: { response: { 204: { type: 'null' } } } }, async (_req, reply) => {
return reply.code(204).send();
});
app.setErrorHandler((err, _req, reply) => {
reply.code(500).send({ ok: false, error: 'internal' });
});
const start = async () => {
try {
await app.listen({ port: 3000, host: '0.0.0.0' });
} catch (e) {
app.log.error(e, 'startup failed');
process.exit(1);
}
};
start();
スキーマが固定されると、ランタイムのオブジェクト探索が減り、応答の分散が収束します。p95の安定はSLOを扱う上で重要で、平均RPSだけを追う構成よりも運用に耐えます。
JSONコスト削減とゼロコピーの工夫
短いJSONでも、毎回のJSON.stringifyとヘッダ生成は積み重なると大きな負担になります。静的部分を事前にシリアライズし、content-lengthを固定すれば、GC圧が減って安定性が増します²⁵。
import http from 'node:http';
const staticBody = Buffer.from('{"ok":true}');
const headers = {
'content-type': 'application/json',
'content-length': String(staticBody.length)
};
const server = http.createServer((req, res) => {
if (req.url === '/') {
for (const [k, v] of Object.entries(headers)) res.setHeader(k, v);
res.end(staticBody);
return;
}
res.statusCode = 404;
res.end();
});
server.keepAliveTimeout = 2000;
server.headersTimeout = 5000;
server.listen(3000);
応答が動的な場合も、定型部分をプリレンダーしたり、文字列テンプレートを再利用するだけで割り当て削減に効果があります。ログが同期I/Oだと一気に崩れるため、ファイル出力は避け¹¹、必要なら非同期の集中集約に委ねるのが安全です。
マルチコア活用――clusterとワーカーオフロード
10万RPSは単一スレッドではなく、複数プロセスでの合算が基本になります。Nodeのclusterは1ポートを共有してコネクションをワーカーへ配賦でき、Keep-Alive下でも安定してスケールします³。グレースフルな再起動と障害時の自己回復を組み込むと、スループットと可用性の両立が図れます³。
clusterでCPUを使い切る設計とグレースフル再起動
マスターとワーカーを分け、SIGTERM/SIGUSR2で段階的な再起動を行うと、ゼロダウンタイムでデプロイできます³。
import cluster from 'node:cluster';
import os from 'node:os';
import http from 'node:http';
const WORKERS = Number(process.env.WORKERS) || os.cpus().length;
if (cluster.isPrimary) {
for (let i = 0; i < WORKERS; i++) cluster.fork();
cluster.on('exit', (worker, code, signal) => {
console.error(`worker ${worker.process.pid} died (${signal || code}), restarting`);
cluster.fork();
});
process.on('SIGTERM', async () => {
for (const id in cluster.workers) cluster.workers[id]?.kill('SIGTERM');
});
process.on('SIGUSR2', async () => {
const ids = Object.keys(cluster.workers);
for (const id of ids) {
await new Promise((resolve) => {
const w = cluster.workers[id];
if (!w) return resolve();
w.on('exit', resolve);
w.kill('SIGTERM');
});
cluster.fork();
}
});
} else {
const payload = Buffer.from('{"ok":true}');
const server = http.createServer((_req, res) => {
res.setHeader('content-type', 'application/json');
res.setHeader('content-length', String(payload.length));
res.end(payload);
});
server.keepAliveTimeout = 2500;
server.headersTimeout = 5000;
const onClose = () => server.close(() => process.exit(0));
process.on('SIGTERM', onClose);
server.listen(3000, '0.0.0.0');
}
ワーカー数はCPUの物理コア数を起点に、観測しながら調整します。ハイパースレッディングが効くケースでも、I/Oバウンドでない限りは過剰スレッドが効率を下げます。
CPUバウンド処理の分離とworker_threads連携
CPUを消費するJSON圧縮や暗号化、テンプレート描画などが混ざると、イベントループが詰まりレイテンシが悪化します。その場合はワーカーにオフロードし、HTTP処理から切り離します⁴。
import http from 'node:http';
import { Worker } from 'node:worker_threads';
function runHeavyTask(input) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./heavy.js', import.meta.url), { workerData: input });
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => { if (code !== 0) reject(new Error('worker exit ' + code)); });
});
}
const server = http.createServer(async (_req, res) => {
try {
const result = await runHeavyTask(42);
const body = Buffer.from(JSON.stringify({ ok: true, result }));
res.setHeader('content-type', 'application/json');
res.setHeader('content-length', String(body.length));
res.end(body);
} catch (e) {
res.statusCode = 500;
res.end('{"ok":false}');
}
});
server.listen(3000);
heavy.js側はCPUを使う純関数に絞り、オブジェクトのコピーコストを意識してTransferableやSharedArrayBufferの適用可否を検討します⁴。HTTPのホットパスからCPU競合を追い出すことが、安定したp99の近道です。
ボトルネックの解き方――ネットワーク、ランタイム、アプリ
ネットワークスタックの設定、Keep-Aliveの設計、GCとシリアライザの挙動、ログやメトリクスの出し方。いずれもRPSに直結します。OS側の設定は保守性を損なわない範囲で最小限にとどめます。
OS設定とリバースプロキシ、Keep-Aliveの勘所
バックエンドのNodeは平文HTTPを喋り、TLSは前段のリバースプロキシやロードバランサで終端します。Keep-Aliveは短すぎると接続の張り直しでCPUとカーネルの負荷が跳ね、長すぎるとFDが枯渇します。2–3秒程度から始め、観測しながら詰めるのが現実的です²。Linuxではsomaxconnやバックログが小さすぎるとSYNが溢れますが¹²、近年のディストリでは十分に大きく、むやみに極端な値に上げるより、アプリの処理レートを上げる方が効きます。プロキシ層ではバックエンドとのコネクションプールを十分に確保し、ヘルスチェックはHEADか204で軽量化します。sysctlやulimitを変更する場合も、可搬性とセキュリティポリシーに照らして、差分の根拠を記録しておくと将来の監査で迷いません。
import http from 'node:http';
import { Agent } from 'undici';
// プロキシからの大量Keep-Aliveを想定したヘッダ調整例
const payload = Buffer.from('{"ok":true}');
const server = http.createServer((_req, res) => {
res.setHeader('connection', 'keep-alive');
res.setHeader('keep-alive', 'timeout=2');
res.setHeader('content-type', 'application/json');
res.setHeader('content-length', String(payload.length));
res.end(payload);
});
server.keepAliveTimeout = 2000;
server.headersTimeout = 5000;
server.maxRequestsPerSocket = 10000; // ソケット劣化の上限管理
server.listen(3000);
// 下り側で別マイクロサービスに行く場合のHTTPクライアントはundiciを利用
export const httpClient = new Agent({ connections: 1024, pipelining: 10 });
UndiciのAgentを適切に設定すると、下りの依存呼び出しがあるパスでもソケット再利用の効率が上がります¹⁵。上りと下りの両方にKeep-Aliveが絡む場合、タイムアウトの齟齬がスロットリークを生みやすいため、片側をわずかに短くして回収を早めると安定します²。
ベンチマーク結果とスケール戦略、ROI
公開例や一般的な報告をもとにした目安を示します。32 vCPU、メモリ64GB、10GbEの単一インスタンスで、Fastify構成が100k〜150k RPS級、p95 5–8ms、p99 12–20ms、CPU 85–90%で安定するケースが観測されています。素のhttpはさらに高いRPSへ伸びることがありますが、実装機能とのトレードオフを踏まえると総合評価はFastifyに軍配が上がる場面が多いでしょう。JSONペイロードが1KBに増えるとスループットはおおむね3〜5割低下しやすいものの、ゼロコピー化とプリシリアライズで一定の改善が見込めます。コスト面では、同じトラフィックをKubernetes上で裁く場合、PodあたりのRPSが高いほどHPAの台数を抑制し¹⁷、ネットワークオーバーヘッドとコントロールプレーン負荷の低減につながります。高集約が進むと障害時の影響半径は大きくなるため、ゾーン分散とPodDisruptionBudgetで可用性を担保しつつ¹⁸、Podサイズと数の最適点を見つけるのが肝心です。L7プロキシ費用も、バックエンドあたりのコネクション数が減るほど小さく抑えられ、結果としてRPSあたりのコスト最適化に寄与します。ROIの観点では、台数削減が実現できれば、インフラ費や運用費を含む総コストの圧縮が期待できます。
import autocannon from 'autocannon';
// アプリ内からの自己計測例(外部からの計測を推奨)
const instance = autocannon({
url: 'http://127.0.0.1:3000/',
connections: 1000,
pipelining: 10,
duration: 30,
warmup: { requests: 50000 }
});
autocannon.track(instance, { renderProgressBar: true });
計測は別ホストから行い、十分なNICとカーネル設定を持つ負荷発生器を用意します。単一の負荷発生器が飽和しているとサーバー側の限界に届かず、誤った結論に至ります⁹。可観測性はOpenTelemetryのメトリクスとトレースを用い¹⁴、ホットパスの割り当てを可視化すると改善の順序が見えます。
運用の落とし穴と堅牢化の実装
高負荷環境では、小さなリークが短時間で致命傷になります。ファイルディスクリプタ、ソケット、タイマー、イベントリスナの増減を定期的に点検し、異常値を検知したら早期にワーカーをプリエンプティブに再起動させます。プロセスの自己回復と、ヘルスチェックによる切り離しを組み合わせると、障害の表面化を抑えられます。ダウンタイムを避けるために、プロキシ層のアウトライヤ検出を有効化し、劣化したインスタンスのトラフィックを緩やかに減らす設定も効果的です¹⁶。
import cluster from 'node:cluster';
import os from 'node:os';
import Fastify from 'fastify';
if (cluster.isPrimary) {
for (let i = 0; i < os.cpus().length; i++) cluster.fork();
cluster.on('exit', () => cluster.fork());
} else {
const app = Fastify();
const payload = { ok: true };
app.get('/', async () => payload);
// 簡易セルフプロテクション
setInterval(() => {
const mem = process.memoryUsage();
if (mem.heapUsed > 512 * 1024 * 1024) {
// ヘルスを落としてLBから切り離し、その後終了
app.route({ method: 'GET', url: '/health', handler: (_r, rep) => rep.code(503).send() });
setTimeout(() => process.exit(1), 2000);
}
}, 5000);
app.listen({ port: 3000, host: '0.0.0.0' });
}
強制終了は最後の手段ですが、無限増加するFDやヒープを放置するより、コネクションを掃き出して短時間で交換する方がSLOを守りやすい場面があります。ダウンタイムを避けるために、プロキシ層のアウトライヤ検出¹⁶を組み合わせ、劣化したインスタンスのトラフィックを緩やかに減らします。
まとめ――技術と運用を接続してRPSを確実に取りに行く
秒間10万リクエストは、魔法ではなく積み上げの結果です。カーネルやプロキシで無駄な待ちを作らず、NodeのHTTPとシリアライズで割り当てを減らし、clusterで全コアを使い切る。計測の正しさを担保して、p95/p99の安定をSLOとして追い続ければ、ビジネスが求めるスループットは手の届く範囲にあります。今日からできる一歩として、素のhttpで上限を測り、Fastifyへの置き換えとプリシリアライズでp95の山を低くし、clusterのグレースフル再起動を導入して可用性を底上げしてみてください。インフラ台数の圧縮が期待でき、運用の静けさが戻るほどに、プロダクトに向ける時間と集中力が増えます。次に取り組むべき課題は、TLS終端の最適化と依存サービスの遅延吸収です。最適化の旅は長いものの、効果は着実に積み上がります。あなたのシステムが次のピークを越える準備は、もう始まっています。
Node.jsパフォーマンス実践ガイド / KubernetesのHPA設計 / HTTP/3とQUIC導入判断 / OpenTelemetryで可観測性を高める
参考文献
- TechEmpower Benchmarks. https://www.techempower.com/benchmarks/
- Node.js v20 HTTP API Docs. https://nodejs.org/download/release/v20.9.0/docs/api/http.html
- Node.js Cluster API Docs. https://nodejs.org/api/cluster.html
- Node.js worker_threads API Docs. https://nodejs.org/api/worker_threads.html
- fast-json-stringify (GitHub). https://github.com/fastify/fast-json-stringify
- Node.js perf_hooks API Docs. https://nodejs.org/api/perf_hooks.html
- autocannon (GitHub). https://github.com/mcollina/autocannon
- Fastify Official Site. https://fastify.dev/
- wrk HTTP benchmark (GitHub). https://github.com/wg/wrk
- Node.js Release Schedule. https://github.com/nodejs/Release
- Node.js fs Synchronous API Docs. https://nodejs.org/api/fs.html#synchronous-api
- Linux tcp(7) manual. https://man7.org/linux/man-pages/man7/tcp.7.html
- Node.js Learn: Event loop, timers, and process.nextTick. https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
- OpenTelemetry Documentation. https://opentelemetry.io/docs/
- Undici Agent API Docs. https://undici.nodejs.org/#/docs/api/Agent
- Envoy Outlier Detection. https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/outlier
- Kubernetes Horizontal Pod Autoscaler. https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/
- Kubernetes PodDisruptionBudget. https://kubernetes.io/docs/tasks/run-application/configure-pdb/