株式会社 TENTIAL でエンジニアをしている石井です。
社内システムの API サーバーを動かした際に、暇そうなコアがいるっぽく、クラスター化したら残りの暇してるコアに仕事を回せないかというお話です。
前提情報
API 技術スタック
- ランタイム
- Node.js 18 系
- ライブラリ/FW 等
- Nuxt3.6(Nitro) : 社内 Web 用
- node:http : 社内連携用
Node.js の特徴(後述する部分で挙動を把握しておく必要があるため)
- シングルスレッド構成で動き、使用される CPU コアは 1 つのみ
- スレッドはイベントループモデルで、キューに積まれたタスク非同期 I/O と
- スレッド内ではイベントループモデル(詳細説明は割愛)で動作
- 長期占有する同期処理(ブロック処理)が走るとその間はイベントループが待たされる
- シングルスレッドで待ち受けるため、C10K 問題(プロセス起動による pid 枯渇)が起きない
クラスター化前の状態
測定結果
- 後述する性能比較の改善前と同一なのでそちらを参照
原因考察
- シングルスレッド構成の動作なので、1 コアに処理が集中
- リクエスト増加により処理を捌ききれずイベントループが遅延
対策案
- コア当たりで捌ける量を増やす(同期処理の性能改善)
- 複数コアを使えるようにして全体で捌ける量を増やす(クラスター化)
今回は CPU コアを生かせて簡単に試せる案2
で検証してみる
クラスター化してみる
Nuxt3(Nitro)編
- Nitro がデフォルトでクラスター機能をサポートしているため、
nuxt.config.ts
にpreset
設定をした上で Nuxt3 のビルドを行うだけでビルドソースに反映される
詳細は Nitro 公式ドキュメントの ClusterMode に記載
nuxt.config.ts
export default defineNuxtConfig({ nitro: { preset: 'node-cluster', }, })
環境変数に下記追加でクラスター台数の制御も可能(任意)
NITRO_CLUSTER_WORKERS=クラスター化する台数(デフォルトはCPUコア数)
node:http 編
- node:http を使用しているケースでは、クラスター制御のために下記実装が必要
server.ts
import { createServer } from 'node:http' import cluster from 'node:cluster' import { cpus } from 'os' const PORT = process.env.PORT ?? 3000 const server = createServer((req, res) => { // 一般的なhttpサーバーなので省略 }) // クラスターを管理する親処理 const runPrimary = () => { // NODE_CLUSTER_WORKERSが定義されている場合はそちらを優先(デフォルトはCPUコア数) const nodeClusterWorkers = process.env.NODE_CLUSTER_WORKERS != null ? Number.parseInt(process.env.NODE_CLUSTER_WORKERS) : undefined const numCPUs = nodeClusterWorkers ?? (cpus().length > 0 ? cpus().length : 1) for (let i = 0; i < numCPUs; i++) { // クラスター生成 cluster.fork() } console.info('[INFO] create cluster', numCPUs) console.info(`[INFO] http server is running port: ${PORT}`) cluster.on('exit', (worker, code) => { console.info(`[INFO] worker ${worker.process.pid} dead`) if (code !== 0 && !worker.exitedAfterDisconnect) { // クラスター異常終了の場合はクラスターを再生成 cluster.fork() } }) } // クラスター毎にサーバーを立ち上げる処理 const runWorker = () => { // DBの初期設定やコネクション、serverの設定などはここでやる const workerName = `[worker ${process.pid}]` server.listen(PORT, () => { console.info(`[INFO] ${workerName} started` }) } if (cluster.isPrimary) { runPrimary() } else { runWorker() }
補足
- 親側プロセスの場合はクラスター管理のみ担当し、
cluster.fork
を使ってクラスターを増やす - 子プロセスの場合はサーバー起動を担当する
- Express 等構成が近いものなら、ある程度実装の流用は可能
- nitro のnode-cluster 実装が参考になる
性能比較(クラスター化以外は同一条件で測定)
検証スペック
- CPU コア数: 4
- メモリ: 32GB
改善前
- vegeta を用いて-rate=200 -duration=20s で測定
Requests [total, rate, throughput] 4000, 200.07, 116.27 Duration [total, attack, wait] 34.403s, 19.993s, 14.41s Latencies [min, mean, 50, 90, 95, 99, max] 14.41s, 22.898s, 23.41s, 28.726s, 28.878s, 29.146s, 29.211s Bytes In [total, mean] 1856000, 464.00 Bytes Out [total, mean] 9048000, 2262.00 Success [ratio] 100.00% Status Codes [code:count] 200:4000 Error Set:
- pidstat で見た計測結果
CPU使用率(コア平均) : 100%越え メモリ使用率 : 4.42%
結果
- レイテンシーが 90%ile で 28s 台
- スループットはレートの半分ぐらいしか出ず
- コアごとの CPU 使用率も 100%に達して限界
- メモリ使用率は余裕あり
改善後
- vegeta を用いて-rate=200 -duration=20s で測定
Requests [total, rate, throughput] 4000, 200.04, 199.98 Duration [total, attack, wait] 20.002s, 19.996s, 5.795ms Latencies [min, mean, 50, 90, 95, 99, max] 3.872ms, 6.966ms, 6.781ms, 8.313ms, 8.926ms, 10.998ms, 31.408ms Bytes In [total, mean] 1856000, 464.00 Bytes Out [total, mean] 9048000, 2262.00 Success [ratio] 100.00% Status Codes [code:count] 200:4000 Error Set:
- pidstat で見た計測結果
CPU使用率(コア平均): 47%程度 メモリ使用率 : 6%
結果
- レイテンシーが 90%ile で
8ms台まで改善
- スループットもほぼレート値の
199.98
まで出るよう改善 - コアごとの CPU 使用率も
50%以下
に抑えられている - メモリ使用率は若干上昇も元々余裕があるため許容範囲内
まとめ
効果
- クラスターにより処理分散され、各コアの処理待ちが減少しスループット向上
- 同じレートでも各コアの処理量は分散し減るため、CPU に余力が生まれる
副作用
- クラスターごとにプロセスが起動するため、メモリ使用量は元より増加する