目次
- C10K問題:1万接続の壁はどこにあったのか
- 「接続ごとに1つ」のモデルが行き詰まった
- イベント駆動(多重化I/O)という答え
- 3つのモデル:Nginx・Node.js・Goはどう違うか
- Nginx:CPUコア数分のワーカーが、それぞれイベントループを回す
- Node.js:1スレッドのイベントループで、ブロックすると全員が止まる
- Go:goroutineで「スレッドのように書けて、イベント駆動のように動く」
- モデルの比較
- CPUコア数とワーカー数の目安
- CPUバウンドならコア数、I/Oバウンドなら増やしてよい
- 同時接続数とスループットを混同しない
- リトルの法則:同時処理数 = 秒間リクエスト数 × 処理時間
- 「接続」と「処理中リクエスト」も違う
- トラブル事例:アクセスは少ないのに全リクエストが遅くなる
- 症状
- 原因
- 対処
- まとめ
「Gunicornのワーカー数はいくつにすればいいですか」「Node.jsはシングルスレッドなのに、なぜ何千接続も捌けるのですか」「同時接続1,000に耐えたいと言われたが、それは秒間1,000リクエストのことなのか」。こうした質問は、サーバーが接続をどのように扱っているかのモデルを知らないと、感覚で答えるしかなくなります。
この記事では、1接続に1プロセス/1スレッドを割り当てる方式と、イベントループで多数の接続を少数のスレッドで扱う方式の違いを、C10K問題という歴史的な背景から説明します。そのうえで、Nginx・Node.js・Goがそれぞれどのモデルを採用しているか、CPUコア数とワーカー数の目安、I/Oバウンドかどうかで判断がどう変わるか、そして「同時接続数」と「スループット」を混同しない考え方までを扱います。
C10K問題:1万接続の壁はどこにあったのか
「接続ごとに1つ」のモデルが行き詰まった
C10K問題とは、1999年頃に提起された「1台のサーバーで同時に1万(10K)のクライアント接続を扱うにはどうすればよいか」という課題です。当時の主流だったApacheのprefork方式は、接続ごとに1つのプロセスを割り当てていました。プロセスはそれぞれ独立したメモリ空間を持ち、生成コストも大きいため、数千接続でメモリと生成コストが限界に達します。スレッドはプロセスより軽量ですが、それでも1スレッドあたりデフォルトで数MB(Linuxのpthreadでは8MB)の仮想スタック領域を確保し、スレッド数が増えるほどOSのスケジューラによるコンテキストスイッチ(実行中のスレッドを切り替える処理)のコストが増大します。1万スレッドを切り替えながら動かすのは、CPUの多くを「切り替え」そのものに費やすことになります。
根本的な問題は、Webサーバーの接続の大半が「何もしていない」ことです。クライアントがリクエストを送ってくるのを待つ、DBの応答を待つ、ネットワークにレスポンスを書き出すのを待つ。こうした待ち時間にプロセスやスレッドを1つ占有させるのは無駄です。「待っているだけの接続にリソースを割り当てない」方法が必要でした。
イベント駆動(多重化I/O)という答え
解決策となったのが、OSが提供するI/O多重化の仕組み(Linuxではepoll、BSD/macOSではkqueue)です。これは「この数千個のソケットのうち、読み書きできる状態になったものだけ教えてほしい」とOSに頼めるAPIで、1つのスレッドがループを回しながら、準備ができた接続だけを順に処理します。これがイベントループです。待っている接続はメモリ上の小さな構造体を占有するだけで、スレッドもプロセスも消費しません。Nginxが登場当初からApacheより圧倒的に多くの接続を少ないメモリで捌けたのは、このモデルを採用したからです。
3つのモデル:Nginx・Node.js・Goはどう違うか
Nginx:CPUコア数分のワーカーが、それぞれイベントループを回す
Nginxは、マスタープロセスの下にworker_processesで指定した数のワーカープロセスを起動し、各ワーカーが独立したイベントループでepollを使って接続を扱います。1ワーカーが同時に扱える接続数の上限がworker_connectionsで、理論上の最大クライアント数は「ワーカー数 × worker_connections」です。ワーカー数をautoにするとCPUコア数と同じになります。コア数より多くしても、イベントループはCPUを待たせない設計なので、単にコンテキストスイッチが増えるだけです。
ただし、Nginx自身は静的ファイル配信やプロキシに徹しており、アプリケーションのコードを実行しません。重い処理はバックエンドに渡すため、Nginxのイベントループがブロックされることは基本的にありません。この前提が崩れると(たとえばLuaモジュールで重い計算をする)、Nginxでもイベントループの詰まりが起きます。
Node.js:1スレッドのイベントループで、ブロックすると全員が止まる
Node.jsは、JavaScriptを実行するスレッドは1つで、その1スレッドがイベントループを回します。ファイルI/Oやネットワーク、DBアクセスはlibuvという内部ライブラリを通じて非同期に処理され、完了時にコールバック(あるいはPromiseの解決)としてループに戻ってきます。そのため、1プロセスで数千の同時接続を保持していても、実際に動いているのは「今まさに処理すべきコールバック」だけです。
この設計の代償は、JavaScriptのコードが同期的にCPUを使い続けると、その間はすべての接続の処理が止まることです。大きなJSONのJSON.parse、画像処理、暗号化、正規表現の暴走などが典型です。CPUを本格的に使うには、clusterモジュールやworker_threadsでコア数分のプロセスやスレッドを立てるか、重い処理を別のジョブワーカーに逃がします。Node.jsの非同期処理の基本はNode.js入門で解説しています。
// 悪い例:イベントループを塞ぐ同期処理。この間、他のリクエストは一切進まない
app.get('/report', (req, res) => {
const rows = loadAllRowsSync(); // 数百MBを同期で読む
const csv = rows.map(toCsvLine).join('\n'); // CPUを数秒占有
res.send(csv);
});
// 改善例:CPUを使う部分を worker_threads に逃がす
const { Worker } = require('node:worker_threads');
app.get('/report', (req, res) => {
const worker = new Worker('./build-csv.js');
worker.once('message', (csv) => res.send(csv));
worker.once('error', (e) => res.status(500).send(e.message));
});
Go:goroutineで「スレッドのように書けて、イベント駆動のように動く」
Goは、接続ごとにgoroutine(ゴルーチン)を起動する、一見すると「1接続1スレッド」に見えるモデルです。しかしgoroutineはOSのスレッドではなく、Goのランタイムが管理する軽量な実行単位で、初期スタックは2KB程度、必要に応じて伸びます。ランタイムはgoroutineをGOMAXPROCS(デフォルトでCPUコア数)個のOSスレッドの上で切り替えて動かし、ネットワークI/Oで待ちに入ったgoroutineは内部的にepollで管理されます。つまり、開発者は「1接続1goroutine」の素直なコードを書くだけで、内部ではイベント駆動と同じ効率が得られます。数万のgoroutineを同時に持つことは珍しくありません。
// Go: net/http は接続ごとに goroutine を起動する。開発者は同期的に書けばよい
func handler(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(), "SELECT ...") // 待ち中は他のgoroutineが動く
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
// ...
}
// http.ListenAndServe(":8080", http.HandlerFunc(handler))
Go言語でのWeb開発の始め方はGo言語入門|Web開発の始め方を参照してください。
モデルの比較
| モデル | 代表例 | 接続あたりのコスト | CPU処理の扱い | 注意点 |
|---|---|---|---|---|
| 1接続1プロセス | Apache prefork、Gunicorn sync | 大(数十MB) | ワーカー数まで並列 | 同時接続数がワーカー数で頭打ち |
| 1接続1スレッド | Apache worker、Java Servlet(従来型) | 中(スタック分) | スレッド数まで並列 | 数千を超えるとスイッチコスト増 |
| イベントループ | Nginx、Node.js、Python asyncio | 小(構造体のみ) | 1ループ=1コア。複数プロセスで補う | 同期処理で全接続が止まる |
| 軽量スレッド(M:N) | Go goroutine、Java仮想スレッド | 小(KB単位) | ランタイムがコアに割り当て | 共有状態の排他制御は必要 |
CPUコア数とワーカー数の目安
CPUバウンドならコア数、I/Oバウンドなら増やしてよい
ワーカー数を決める最初の問いは「その処理はCPUを使っているのか、待っているのか」です。CPUバウンド(計算そのものに時間がかかる)な処理なら、コア数を超えるワーカーを立てても並列には動けず、切り替えの無駄が増えるだけなので、ワーカー数はコア数に揃えます。I/Oバウンド(DBや外部APIの応答を待つ時間が支配的)な処理なら、待っている間にCPUが空くため、コア数より多いワーカーを立てるほど同時に待てる数が増え、スループットが上がります。
一般的な出発点としては次のような目安があります。あくまで初期値であり、負荷テストで調整する前提です。
| サーバー | ワーカーの単位 | 初期値の目安 |
|---|---|---|
| Nginx | worker_processes | auto(コア数)。worker_connectionsは1,024〜4,096 |
| Node.js | clusterのプロセス数 | コア数(os.availableParallelism()) |
| Gunicorn(sync) | workers | 公式ドキュメントの目安は (2 × コア数) + 1 |
| Gunicorn(gthread) | workers × threads | workersはコア数、threadsを2〜4でI/O待ちを吸収 |
| Uvicorn / Gunicorn+UvicornWorker | workers | コア数(各ワーカー内はasyncioで多重化) |
| Go | GOMAXPROCS | デフォルト(コア数)のまま。ワーカー数の設定自体が不要 |
# Gunicorn の例(4コアのサーバー、I/O待ちが多いAPIを想定)
gunicorn app:app \
--workers 4 \
--worker-class gthread --threads 4 \
--worker-connections 1000 \
--timeout 30 --graceful-timeout 30 \
--bind 0.0.0.0:8000
# Node.js: コア数分のプロセスを cluster で起動
# const cluster = require('node:cluster');
# const n = require('node:os').availableParallelism();
# if (cluster.isPrimary) { for (let i = 0; i < n; i++) cluster.fork(); } else { startServer(); }
もう1つ忘れてはならないのがメモリです。ワーカーを増やすとワーカー数分のメモリを消費します。Pythonのアプリ1ワーカーが200MB使うなら、9ワーカーで約1.8GB必要です。ワーカー数の上限はCPUだけでなく「メモリに収まるか」で決まることも多く、コンテナのメモリ制限の中でOOM Killerに落とされる原因の筆頭でもあります。CPUやメモリの実測はLinuxサーバーのパフォーマンス監視入門を参照してください。
同時接続数とスループットを混同しない
リトルの法則:同時処理数 = 秒間リクエスト数 × 処理時間
「同時接続1,000に耐えたい」という要件と「秒間1,000リクエスト(RPS)を捌きたい」という要件はまったく別物です。両者をつなぐのがリトルの法則で、系の中に同時に存在するリクエスト数(L)は、到着率(λ、RPS)と平均滞在時間(W、レスポンスタイム)の積で決まります。
たとえば秒間200リクエストで平均応答時間が0.1秒なら、同時に処理中のリクエストは平均20個です。この場合、Gunicornのワーカーが20あれば理論上は足ります。逆に、同じ200RPSでも外部APIを待って応答に2秒かかる処理なら、同時に400個が滞留し、ワーカーが20しかなければ残りは待ち行列に並び、応答時間はさらに悪化するという悪循環に入ります。「RPSは低いのにワーカーが足りない」のは、処理時間が長いことが原因で、ワーカーを増やすより処理時間(多くはDBクエリや外部API)を短くする方が効きます。
「接続」と「処理中リクエスト」も違う
さらに、TCP接続の数と処理中のリクエストの数も一致しません。Keep-Aliveで維持されているだけの接続、WebSocketで開きっぱなしの接続は、接続としては存在しますがCPUをほとんど使いません。Nginxのworker_connectionsが扱うのはこの「接続」の数で、バックエンドのワーカー数が扱うのは「処理中のリクエスト」の数です。同時接続10,000をNginxで受けても、バックエンドに同時に流れる処理は数十〜数百というのが普通で、この差を吸収するのがイベント駆動のNginxを前段に置く意味です。要件を聞くときは「ピーク時のRPS」「許容する応答時間」「維持したい接続数」を分けて確認してください。負荷テストの実施方法は負荷テストのやり方とツール選びにまとめています。
トラブル事例:アクセスは少ないのに全リクエストが遅くなる
症状
Node.js(Express)で動く社内向けAPIで、月末になると全エンドポイントの応答が数秒〜十数秒に悪化する。アクセス数は普段と変わらず秒間数リクエスト程度で、CPU使用率は1コアだけが100%に張り付き、他のコアは空いている。
原因
月末に管理者が実行する「全取引データのCSVエクスポート」が、数万行のデータを同期的に整形してからレスポンスを返す実装だった。この処理がイベントループを数秒間占有し、その間に到着した他のリクエストはすべて待たされていた。Node.jsは1プロセス1スレッドで動くため、1つの重い同期処理がすべての接続を止める、というモデルの性質そのものが原因だった。4コアのサーバーだったが、Node.jsを1プロセスで動かしていたため残り3コアは使われていなかった。
対処
短期対策としてclusterでコア数分のプロセスを起動し、1プロセスが塞がっても他のプロセスがリクエストを受けられるようにした。恒久対策として、CSV生成をworker_threadsに移し、さらに大きなエクスポートはリクエストの中で完了させず、バックグラウンドジョブとして生成してからダウンロードリンクを返す非同期の設計に変更した。イベントループの遅延を検知するためにperf_hooks.monitorEventLoopDelayの値を監視に追加し、同様の同期処理が紛れ込んだときに気づけるようにしている。
まとめ
C10K問題は「接続ごとにプロセスやスレッドを割り当てる」モデルの限界を示し、epollを使ったイベント駆動がその答えになりました。Nginxはコア数分のワーカーがイベントループを回し、Node.jsは1スレッドのイベントループなので同期処理で全接続が止まり、Goはgoroutineで同期的に書きながらイベント駆動と同じ効率を得ます。ワーカー数はCPUバウンドならコア数、I/Oバウンドなら多めが出発点で、メモリの上限と負荷テストで調整します。要件を整理するときはリトルの法則を使い、同時接続数・処理中リクエスト数・RPS・応答時間を分けて考えてください。Nginx側のworker_connectionsやkeepaliveの具体的な調整はNginxパフォーマンスチューニングで扱っています。
アプリケーションサーバーの構成設計や性能改善でお困りの際は、Harmonic Societyのシステム開発・インフラ支援にご相談ください。
Harmonic Society
この記事の内容、自社の業務でも活かせそうですか?
ローカルLLM・AI・クラウドなどの技術導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK。まずは30分の無料相談から。売り込みはしません。
関連記事
Related / 9 articles
Notes & Insights
- プログラミング
DDoS攻撃の仕組みと対策入門|レイヤー別の防御とCDN・クラウドの活用
DDoS攻撃をボリューム型・プロトコル型・アプリ層に分けて仕組みを解説し、自前サーバーで防げない理由、CloudflareやAWS Shieldの標準防御、オリジンIPの隠し方、レートリミットとBot対策、攻撃を受けたときの初動、費用が跳ね上がるDenial of Walletへの備えまでわかります。
- プログラミング
WAFとは?仕組み・導入パターン・誤検知対策|Webアプリを攻撃から守る実践ガイド
WAFがファイアウォールやIDSと何が違うのか、シグネチャとマネージドルールの仕組み、Cloudflare WAF・AWS WAF・ModSecurityの比較、フォーム送信がブロックされる誤検知の調査と例外設定、ログ監視、WAFが代替できないことまで実践的に解説します。
- プログラミング
セキュリティヘッダー入門|CSP・HSTS・X-Frame-Optionsの設定と効果を実践解説
CSP・HSTS・X-Frame-Options・X-Content-Type-Optionsなど主要セキュリティヘッダーが防ぐ攻撃と、CSPのReport-Onlyからの段階導入、nonce/hash、HSTS preloadの不可逆リスク、Nginx・Next.js・Astroでの設定例、確認方法を解説します。
- プログラミング
クラウドの通信費(Egress)入門|データ転送量課金の仕組みと転送コストを抑える設計
クラウドの「受信無料・送信有料」の原則、AZ間・リージョン間・インターネット向けの単価差、NATゲートウェイ処理料の罠、CDNで転送量を減らす方法、バックアップやログ転送の見落とし、請求書で転送料を特定する手順を解説。想定外の請求を防げます。
- プログラミング
秘密情報をGitに入れない仕組み|.gitignore・git-secrets・履歴から漏れた鍵の削除
APIキーや.envをGitにコミットしてしまう典型経路と、.gitignore・.env.exampleの運用、pre-commitでのgitleaks検知、GitHub secret scanningの活用、漏れた鍵の無効化と履歴書き換え(git filter-repo)の手順を解説。仕組みで再発を防げます。
- プログラミング
開発・ステージング・本番環境の分離設計|環境差分をなくす構成とアクセス制御
開発・ステージング・本番それぞれの目的と、構成をコードで揃える方法、環境別の設定注入、本番データを使わないテストデータ戦略、ステージングの保護(Basic認証・IP制限・noindex)、コストを抑える運用までを解説。環境差分による本番障害を防げます。
- プログラミング
ngrok・Cloudflare Tunnelでローカルを公開|Webhook開発とデモ環境の作り方
NAT内のローカル環境にStripeやLINEのWebhookを届けるトンネリングの仕組みを解説。ngrok・Cloudflare Tunnel・localtunnelの比較、固定ドメインと認証、リクエスト検査、公開時のセキュリティ、自宅サーバー公開への応用までわかります。
- プログラミング
ローカル開発環境のHTTPS化|mkcert・hostsファイル・自己署名証明書の正しい使い方
ローカル開発をHTTPS前提にすべき理由(Secure Cookie・Service Worker・OAuth)と、mkcertでローカルCAを作りhostsで独自ドメインを割り当ててVite・Next.js・Dockerで使う手順を解説。証明書警告を無視する癖の危険も理解できます。
- プログラミング
localhost・0.0.0.0・127.0.0.1の違い|ポートとUnixソケットを理解して「つながらない」を解決
localhost・127.0.0.1・0.0.0.0の意味の違い、Dockerで外から接続できない原因、host.docker.internal、ポート競合の調べ方、Unixソケットの利点と権限、1024未満ポートの制約を解説。「つながらない」を仕組みから解決できます。
Harmonic Society
「読んで終わり」にせず、自社の業務で試してみませんか?
AI・ローカルLLM・クラウドの導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK・売り込みなし。
無料・30分・オンラインOK|1営業日以内に返信します