TENTIALのテックブログ

株式会社TENTIALのエンジニアチームが開発や組織のよもやまを謳っていきます

Nuxt3(Nitro)とnode:httpでAPIサーバーのクラスター化を試してみる

株式会社 TENTIAL でエンジニアをしている石井です。

社内システムの API サーバーを動かした際に、暇そうなコアがいるっぽく、クラスター化したら残りの暇してるコアに仕事を回せないかというお話です。

前提情報

API 技術スタック

  • ランタイム
    • Node.js 18 系
  • ライブラリ/FW 等
    • Nuxt3.6(Nitro) : 社内 Web 用
    • node:http : 社内連携用

Node.js の特徴(後述する部分で挙動を把握しておく必要があるため)

  • シングルスレッド構成で動き、使用される CPU コアは 1 つのみ
  • スレッドはイベントループモデルで、キューに積まれたタスク非同期 I/O と
  • スレッド内ではイベントループモデル(詳細説明は割愛)で動作
  • 長期占有する同期処理(ブロック処理)が走るとその間はイベントループが待たされる
  • シングルスレッドで待ち受けるため、C10K 問題(プロセス起動による pid 枯渇)が起きない

クラスター化前の状態

測定結果

  • 後述する性能比較の改善前と同一なのでそちらを参照

原因考察

  • シングルスレッド構成の動作なので、1 コアに処理が集中
  • リクエスト増加により処理を捌ききれずイベントループが遅延

対策案

  1. コア当たりで捌ける量を増やす(同期処理の性能改善)
  2. 複数コアを使えるようにして全体で捌ける量を増やす(クラスター化)

今回は CPU コアを生かせて簡単に試せる案2で検証してみる

クラスター化してみる

Nuxt3(Nitro)編

  • Nitro がデフォルトでクラスター機能をサポートしているため、nuxt.config.tspreset設定をした上で 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 に余力が生まれる

副作用

  • クラスターごとにプロセスが起動するため、メモリ使用量は元より増加する