T-CREATOR

Nginx Worker とイベントループ徹底解説:epoll/kqueue が高速化を生む理由

Nginx Worker とイベントループ徹底解説:epoll/kqueue が高速化を生む理由

Nginx が高速な Web サーバーとして知られているのには、明確な理由があります。その秘密は「Worker プロセス」と「イベントループ」、そして OS カーネルが提供する epollkqueue といった効率的な I/O 多重化機構にあります。

本記事では、Nginx がどのようにして大量のリクエストを少ないリソースで捌けるのか、その仕組みを初心者にもわかりやすく図解しながら徹底的に解説します。Apache との違いや、なぜ C10K 問題を解決できたのかも明らかになるでしょう。

背景

Web サーバーが直面する同時接続問題

Web サーバーは、複数のクライアントから同時にリクエストを受け付ける必要があります。従来の Apache などのサーバーでは、1 つのリクエストに対して 1 つのプロセスやスレッドを割り当てる「マルチプロセス/マルチスレッドモデル」が主流でした。

しかし、このモデルには限界がありました。接続数が増えると、プロセスやスレッドの数も増加し、メモリ消費とコンテキストスイッチのオーバーヘッドが問題になります。これが「C10K 問題」と呼ばれる課題です。

以下の図は、従来型のマルチプロセスモデルと Nginx のイベント駆動モデルの違いを示しています。

mermaidflowchart TB
    subgraph Apache ["従来型: Apache マルチプロセスモデル"]
        client1["クライアント1"] --> process1["プロセス1<br/>(メモリ消費大)"]
        client2["クライアント2"] --> process2["プロセス2<br/>(メモリ消費大)"]
        client3["クライアント3"] --> process3["プロセス3<br/>(メモリ消費大)"]
        clientN["クライアントN"] --> processN["プロセスN<br/>(メモリ消費大)"]
    end

    subgraph Nginx ["イベント駆動型: Nginx モデル"]
        clientA["クライアント1〜N"] --> worker1["Worker 1<br/>(少数)"]
        clientA --> worker2["Worker 2<br/>(少数)"]
        worker1 --> eventloop1["イベントループ<br/>(epoll/kqueue)"]
        worker2 --> eventloop2["イベントループ<br/>(epoll/kqueue)"]
    end

Nginx の設計思想

Nginx は、この問題を解決するために「イベント駆動型」「非同期処理」「ノンブロッキング I/O」という設計思想を採用しました。少数の Worker プロセスで、数万から数十万の同時接続を効率的に処理できるのです。

その中核を担うのが、OS カーネルレベルで提供される高性能な I/O 多重化機構である epoll(Linux)や kqueue(BSD 系)といった技術でしょう。

課題

従来型アーキテクチャの限界

従来のマルチプロセス/マルチスレッドモデルには、以下のような課題がありました。

メモリ消費の増大

1 つのプロセスやスレッドは、数 MB のメモリを消費します。1 万接続を処理しようとすると、それだけで数十 GB のメモリが必要になってしまいます。

javascript// 従来型モデルの概念コード(擬似コード)
// 1リクエストごとに新しいプロセス/スレッドを生成

function handleRequest(request) {
  // 新しいプロセス/スレッドを作成
  const process = createNewProcess();

  // メモリを大量に消費
  // 各プロセスは独立したメモリ空間を持つ
  process.allocateMemory(); // 数MB消費

  // リクエスト処理
  process.execute(request);
}

コンテキストスイッチのオーバーヘッド

OS は複数のプロセス/スレッドを切り替えながら実行します。この「コンテキストスイッチ」には、CPU レジスタの保存・復元などのコストがかかります。

プロセス数が増えると、実際の処理よりもコンテキストスイッチに時間を取られるようになり、スループットが低下してしまうのです。

ブロッキング I/O の非効率性

従来型のモデルでは、ディスクやネットワークからのデータ読み込み中、プロセス/スレッドは待機状態(ブロッキング)になります。

javascript// ブロッキングI/Oの例(擬似コード)

function processRequest() {
  // ファイル読み込み中は待機状態になる
  const data = readFileSync('/path/to/file'); // ★ここでブロック

  // ネットワーク通信中も待機状態
  const response = httpRequestSync(
    'http://api.example.com'
  ); // ★ここでブロック

  // 実際の処理
  return processData(data, response);
}

この待機時間中、プロセス/スレッドは何もせずにメモリだけを消費し続けます。これは非常に非効率です。

以下の図は、ブロッキング I/O とノンブロッキング I/O の違いを示しています。

mermaidsequenceDiagram
    participant Thread as スレッド
    participant OS as OS カーネル
    participant Disk as ディスク

    Note over Thread,Disk: ブロッキングI/O
    Thread->>OS: データ読み込み要求
    OS->>Disk: データ読み込み
    Note over Thread: 待機状態<br/>(CPUを使わず待つだけ)
    Disk-->>OS: データ返却
    OS-->>Thread: データ返却
    Note over Thread: 処理再開

    Note over Thread,Disk: ノンブロッキングI/O(Nginxの方式)
    Thread->>OS: データ読み込み要求(非同期)
    Thread->>Thread: 他のリクエスト処理
    OS->>Disk: データ読み込み
    Disk-->>OS: データ返却
    OS-->>Thread: イベント通知
    Thread->>Thread: データ処理

C10K 問題とは

1999 年頃、Web サーバーが 1 万(10K = 10,000)の同時接続を処理しようとすると、上記の問題が顕在化し、パフォーマンスが著しく低下する現象が注目されました。これが「C10K 問題」です。

Nginx は、この C10K 問題を解決するために開発されたのです。

解決策

Nginx のアーキテクチャ

Nginx は、以下の 3 つの要素を組み合わせて、高効率な処理を実現しています。

Master プロセスと Worker プロセス

Nginx は起動時に 1 つの Master プロセスと、複数の Worker プロセスを生成します。

plaintext# Nginxのプロセス構造

Master プロセス(root権限)
├─ Worker プロセス 1(通常権限)
├─ Worker プロセス 2(通常権限)
├─ Worker プロセス 3(通常権限)
└─ Worker プロセス 4(通常権限)

Master プロセスは設定の読み込みや Worker プロセスの管理を担当し、実際のリクエスト処理は Worker プロセスが行います。

nginx# nginx.confの設定例

# Workerプロセスの数を指定
# 通常はCPUコア数と同じにする
worker_processes 4;

# 1つのWorkerが処理できる最大接続数
# この例では1 Workerあたり最大1024接続
events {
    worker_connections 1024;
}

Worker プロセスの数は通常、CPU のコア数と同じに設定します。これにより、コンテキストスイッチを最小限に抑えながら、CPU リソースを最大限活用できます。

イベント駆動型アーキテクチャ

各 Worker プロセスは、「イベントループ」と呼ばれる仕組みで動作します。イベントループは、複数の接続を監視し、I/O が可能になったタイミングで処理を実行する方式です。

javascript// イベントループの概念コード(擬似コード)

function eventLoop() {
  // 無限ループで接続を監視
  while (true) {
    // epoll/kqueueを使って、I/O可能な接続を取得
    const readyConnections = epoll.wait(); // ★ノンブロッキング

    // 準備ができた接続だけを処理
    for (const connection of readyConnections) {
      // データが読み込める状態なら読み込む
      if (connection.isReadable()) {
        const data = connection.read(); // ★ノンブロッキング
        processRequest(data);
      }

      // データが書き込める状態なら書き込む
      if (connection.isWritable()) {
        connection.write(responseData); // ★ノンブロッキング
      }

      // エラーやクローズ処理
      if (connection.hasError() || connection.isClosed()) {
        connection.close();
      }
    }
  }
}

この方式により、1 つの Worker プロセスで数千から数万の接続を同時に処理できるのです。

以下の図は、Worker プロセスがどのようにイベントループで複数の接続を処理するかを示しています。

mermaidflowchart LR
    subgraph Worker ["Worker プロセス"]
        eventloop["イベントループ"]
        epoll["epoll/kqueue<br/>(I/O多重化)"]

        eventloop -->|監視依頼| epoll
        epoll -->|イベント通知| eventloop
    end

    conn1["接続1"] -->|リクエスト| epoll
    conn2["接続2"] -->|リクエスト| epoll
    conn3["接続3"] -->|リクエスト| epoll
    connN["接続N"] -->|リクエスト| epoll

    eventloop -->|レスポンス| conn1
    eventloop -->|レスポンス| conn2
    eventloop -->|レスポンス| conn3
    eventloop -->|レスポンス| connN

図で理解できる要点

  • 1 つの Worker プロセスが複数の接続を同時に管理
  • epoll/kqueue が「準備ができた接続」だけを効率的に通知
  • Worker は待機時間ゼロで次々と処理を進められる

ノンブロッキング I/O

Nginx は、すべての I/O 操作を「ノンブロッキング」モードで実行します。これにより、I/O 待ちでプロセスがブロックされることがなくなります。

c// C言語でのノンブロッキングI/O設定例

#include <fcntl.h>

int fd = socket(AF_INET, SOCK_STREAM, 0);

// ソケットをノンブロッキングモードに設定
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

// この状態でread()を呼ぶと、データがなくても即座にリターンする

ノンブロッキング I/O では、データが準備できていない場合、read()write() は即座に制御を返します。これにより、Worker プロセスは他の接続の処理を続けられるのです。

epoll と kqueue:高速化の核心

Nginx の高速性を支える最も重要な技術が、OS カーネルが提供する I/O 多重化機構です。Linux では epoll、BSD 系(FreeBSD、macOS など)では kqueue が使われます。

従来の select/poll の問題点

それ以前に使われていた select()poll() には、以下の問題がありました。

#項目select/pollepoll/kqueue
1監視可能なファイルディスクリプタ数制限あり(通常 1024)実質無制限
2計算量O(n)(全接続を走査)O(1)(準備済み接続のみ)
3データコピーカーネル ⇔ ユーザー空間で毎回コピー初回登録のみ
4スケーラビリティ接続数増加で性能低下接続数に影響されにくい
c// select()の問題点を示すコード例

#include <sys/select.h>

fd_set readfds;
int max_fd = 0;

while (1) {
    FD_ZERO(&readfds);

    // ★毎回すべての接続をセットし直す必要がある
    for (int i = 0; i < num_connections; i++) {
        FD_SET(connections[i].fd, &readfds);
        if (connections[i].fd > max_fd) {
            max_fd = connections[i].fd;
        }
    }

    // ★すべての接続をチェック(O(n)の計算量)
    select(max_fd + 1, &readfds, NULL, NULL, NULL);

    // ★準備ができた接続を探すために全件走査
    for (int i = 0; i < num_connections; i++) {
        if (FD_ISSET(connections[i].fd, &readfds)) {
            handle_connection(&connections[i]);
        }
    }
}

接続数が増えると、この全件走査のコストが膨大になり、性能が劣化してしまうのです。

epoll の仕組み

Linux の epoll は、以下の 3 つの関数で構成されています。

c// epollの基本的な使い方

#include <sys/epoll.h>

// 1. epollインスタンスを作成
int epfd = epoll_create1(0);

// 2. 監視対象のファイルディスクリプタを登録
struct epoll_event ev;
ev.events = EPOLLIN;  // 読み込み可能イベントを監視
ev.data.fd = connection_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connection_fd, &ev);

上記のコードで、監視対象を epoll に「登録」します。この登録は一度だけ行えば良く、以降は自動的に監視され続けます。

c// 3. イベント待機と処理

struct epoll_event events[MAX_EVENTS];

while (1) {
    // ★I/O可能な接続だけを取得(O(1)の効率)
    int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);

    // ★準備ができた接続だけを処理
    for (int i = 0; i < nfds; i++) {
        int fd = events[i].data.fd;

        if (events[i].events & EPOLLIN) {
            // 読み込み可能
            handle_read(fd);
        }

        if (events[i].events & EPOLLOUT) {
            // 書き込み可能
            handle_write(fd);
        }

        if (events[i].events & (EPOLLERR | EPOLLHUP)) {
            // エラーまたは切断
            close(fd);
        }
    }
}

epoll_wait() は、I/O が可能になった接続だけを返します。そのため、接続数が増えても処理時間は増加しません。これが O(1) の効率を実現する秘密です。

epoll のイベント通知モード

epoll には 2 つの通知モードがあります。

c// レベルトリガー(LT: Level Triggered)
// デフォルトモード
ev.events = EPOLLIN;  // 読み込み可能な間、何度でも通知される

レベルトリガーでは、データが読み込み可能な状態が続く限り、epoll_wait() が繰り返し通知します。処理し忘れを防げますが、やや効率が落ちます。

c// エッジトリガー(ET: Edge Triggered)
// Nginxが使用するモード
ev.events = EPOLLIN | EPOLLET;  // 状態が変化したときだけ通知

エッジトリガーでは、状態が変化したとき(新しいデータが到着したとき)だけ通知されます。Nginx はこのモードを使い、より高いパフォーマンスを実現しています。

kqueue の仕組み

BSD 系 OS(FreeBSD、macOS など)では、kqueue が使われます。基本的な考え方は epoll と同じですが、より汎用的な設計になっています。

c// kqueueの基本的な使い方

#include <sys/event.h>

// 1. kqueueインスタンスを作成
int kq = kqueue();

// 2. 監視対象を登録
struct kevent change;
EV_SET(&change, connection_fd, EVFILT_READ, EV_ADD | EV_ENABLE, 0, 0, NULL);
kevent(kq, &change, 1, NULL, 0, NULL);

EV_SET マクロで監視設定を作成し、kevent() 関数で登録します。EVFILT_READ は読み込み可能イベント、EVFILT_WRITE は書き込み可能イベントを監視します。

c// 3. イベント待機と処理

struct kevent events[MAX_EVENTS];

while (1) {
    // ★I/O可能な接続を取得
    int nev = kevent(kq, NULL, 0, events, MAX_EVENTS, NULL);

    // ★イベントを処理
    for (int i = 0; i < nev; i++) {
        int fd = events[i].ident;

        if (events[i].filter == EVFILT_READ) {
            // 読み込み可能
            handle_read(fd);
        }

        if (events[i].filter == EVFILT_WRITE) {
            // 書き込み可能
            handle_write(fd);
        }
    }
}

kqueue は、ファイル I/O だけでなく、シグナル、タイマー、プロセスイベントなども統一的に扱えるのが特徴です。

以下の図は、epoll/kqueue がどのように効率的に接続を監視するかを示しています。

mermaidflowchart TB
  subgraph legacy["従来型: select/poll"]
    app1[アプリケーション] --|全接続リストを毎回渡す|--> kernel1[カーネル]
    kernel1 --|全接続を走査 O(n)|--> check1[準備済み接続を探す]
    check1 --|結果リストを返す|--> app1
  end

  subgraph modern["最新型: epoll/kqueue"]
    app2[アプリケーション] --|初回のみ登録|--> kernel2[カーネル<br/>(接続リストを保持)]
    kernel2 --|準備済み接続だけ通知 O(1)|--> app2
    note1[★カーネルが自動監視<br/>★準備済み接続のみ返却]
  end

  style note1 fill:#ffffcc,stroke:#333,stroke-width:1px,stroke-dasharray:3 3

図で理解できる要点

  • 従来型は毎回全接続をカーネルに渡し、全件走査が必要
  • epoll/kqueue は初回登録のみで、以降はカーネルが自動監視
  • 準備済み接続だけが通知されるため、処理時間が O(1) で安定

Nginx の設定例

実際の Nginx 設定で、イベント処理をどのように制御するかを見てみましょう。

nginx# nginx.confのeventsブロック

events {
    # 使用するイベント処理方式を指定
    # 通常は自動選択(Linux: epoll, BSD: kqueue)
    use epoll;

    # 1つのWorkerが処理できる最大同時接続数
    worker_connections 10000;

    # 新しい接続をできるだけ多く受け入れる
    multi_accept on;
}

worker_connections は、1 つの Worker プロセスが同時に処理できる接続数の上限です。Worker プロセスが 4 つあれば、合計で 40,000 接続を処理できることになります。

nginx# httpブロックの最適化設定

http {
    # ファイル送信の最適化(sendfile syscallを使用)
    sendfile on;

    # TCP_CORKオプション(sendfileと併用)
    tcp_nopush on;

    # TCP_NODELAYオプション(小さなパケットを即座に送信)
    tcp_nodelay on;

    # キープアライブのタイムアウト設定
    keepalive_timeout 65;
}

これらの設定により、OS カーネルの機能を最大限活用し、ネットワーク処理を最適化できます。

具体例

Nginx と Apache の性能比較

実際の動作を理解するために、Nginx と Apache の処理フローを比較してみましょう。

Apache のプロセスモデル(prefork MPM)

bash# Apacheのプロセス状態を確認
ps aux | grep apache

# 出力例(接続数分だけプロセスが存在)
apache   1234  0.5  2.0  /usr/sbin/apache2
apache   1235  0.5  2.0  /usr/sbin/apache2
apache   1236  0.5  2.0  /usr/sbin/apache2
...
apache   2234  0.5  2.0  /usr/sbin/apache2  # 1000個のプロセス

Apache の prefork MPM では、1 つのリクエストに対して 1 つのプロセスが割り当てられます。1000 接続なら 1000 プロセスが必要です。

plaintext# Apacheのメモリ使用量計算例

1プロセスあたり: 約20MB
1000接続の場合: 20MB × 1000 = 20GB

★大量のメモリが必要

Nginx のプロセスモデル

bash# Nginxのプロセス状態を確認
ps aux | grep nginx

# 出力例(少数のプロセスで大量接続を処理)
root     1000  0.0  0.1  nginx: master process
nginx    1001  0.5  1.0  nginx: worker process
nginx    1002  0.5  1.0  nginx: worker process
nginx    1003  0.5  1.0  nginx: worker process
nginx    1004  0.5  1.0  nginx: worker process

Nginx では、4 つの Worker プロセスで数万の接続を処理できます。

plaintext# Nginxのメモリ使用量計算例

1 Workerあたり: 約10MB
4 Workerの場合: 10MB × 4 = 40MB

★10000接続でもわずか40MB

以下の図は、同じ接続数を処理する際の Apache と Nginx のリソース使用量の違いを示しています。

mermaidflowchart LR
    subgraph Comparison ["1000接続時のリソース比較"]
        subgraph Apache方式 ["Apache (prefork)"]
            ap_conn["1000接続"] --> ap_proc["1000プロセス"]
            ap_proc --> ap_mem["メモリ: 約20GB"]
            ap_proc --> ap_cpu["CPU: 高負荷<br/>(コンテキストスイッチ)"]
        end

        subgraph Nginx方式 ["Nginx"]
            ng_conn["1000接続"] --> ng_proc["4 Worker<br/>プロセス"]
            ng_proc --> ng_mem["メモリ: 約40MB"]
            ng_proc --> ng_cpu["CPU: 低負荷<br/>(効率的)"]
        end
    end

図で理解できる要点

  • Apache は接続数に比例してプロセスとメモリが増加
  • Nginx は少数の Worker で大量接続を効率的に処理
  • メモリ使用量に約 500 倍の差が発生

実際の接続処理フロー

Nginx が実際にどのようにリクエストを処理するか、詳細なフローを見てみましょう。

ステップ 1:接続の受け入れ

c// 新しい接続を受け入れる(擬似コード)

int listen_fd = socket(...);
bind(listen_fd, ...);
listen(listen_fd, ...);

// listenソケットをepollに登録
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

まず、接続を待ち受けるソケット(listen socket)を epoll に登録します。

ステップ 2:接続の監視と accept

c// イベントループでの処理

while (1) {
    struct epoll_event events[MAX_EVENTS];
    int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);

    for (int i = 0; i < nfds; i++) {
        if (events[i].data.fd == listen_fd) {
            // ★新しい接続要求が到着
            int client_fd = accept(listen_fd, ...);

            // クライアントソケットをノンブロッキングに設定
            set_nonblocking(client_fd);

            // クライアントソケットをepollに登録
            struct epoll_event client_ev;
            client_ev.events = EPOLLIN | EPOLLET;  // エッジトリガー
            client_ev.data.fd = client_fd;
            epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &client_ev);
        }
    }
}

新しい接続が到着すると、accept() でクライアントソケットを作成し、それも epoll に登録します。

ステップ 3:リクエストの読み込み

c// クライアントからのデータ読み込み

else if (events[i].events & EPOLLIN) {
    int fd = events[i].data.fd;
    char buffer[BUFFER_SIZE];

    // ★ノンブロッキングで読み込み
    while (1) {
        int n = read(fd, buffer, sizeof(buffer));

        if (n > 0) {
            // データを処理
            process_request(buffer, n);
        } else if (n == 0) {
            // 接続クローズ
            close(fd);
            break;
        } else if (errno == EAGAIN || errno == EWOULDBLOCK) {
            // ★データがもうない(エッジトリガー対応)
            break;
        } else {
            // エラー
            close(fd);
            break;
        }
    }
}

エッジトリガーモードでは、通知が来たときに利用可能なデータをすべて読み切る必要があります。そのため、EAGAIN が返るまでループで読み込み続けます。

ステップ 4:レスポンスの送信

c// クライアントへのデータ送信

else if (events[i].events & EPOLLOUT) {
    int fd = events[i].data.fd;

    // ★ノンブロッキングで書き込み
    int n = write(fd, response_data, response_length);

    if (n == response_length) {
        // 送信完了
        // EPOLLOUTイベントの監視を解除
        struct epoll_event ev;
        ev.events = EPOLLIN | EPOLLET;
        ev.data.fd = fd;
        epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
    } else if (n > 0) {
        // 一部だけ送信できた(送信バッファが満杯)
        // 残りのデータを保存して次回に送信
        save_remaining_data(fd, response_data + n, response_length - n);
    }
}

送信バッファに空きができると、EPOLLOUT イベントが通知されます。データを送信し、完了したら監視対象から外します。

以下の図は、1 つの Worker プロセスが複数の接続を同時に処理する様子を時系列で示しています。

mermaidsequenceDiagram
    participant Worker as Worker プロセス
    participant epoll as epoll
    participant Conn1 as 接続1
    participant Conn2 as 接続2
    participant Conn3 as 接続3

    Note over Worker,Conn3: 同時に複数接続を効率的に処理

    Worker->>epoll: epoll_wait()
    epoll-->>Worker: 接続1が読み込み可能
    Worker->>Conn1: データ読み込み(ノンブロッキング)
    Conn1-->>Worker: HTTPリクエスト
    Worker->>Worker: リクエスト処理

    Worker->>epoll: epoll_wait()
    epoll-->>Worker: 接続2が読み込み可能
    Worker->>Conn2: データ読み込み(ノンブロッキング)
    Conn2-->>Worker: HTTPリクエスト

    Worker->>epoll: epoll_wait()
    epoll-->>Worker: 接続1が書き込み可能
    Worker->>Conn1: レスポンス送信(ノンブロッキング)

    Worker->>Worker: 接続2のリクエスト処理

    Worker->>epoll: epoll_wait()
    epoll-->>Worker: 接続3が読み込み可能
    Worker->>Conn3: データ読み込み(ノンブロッキング)

    Worker->>epoll: epoll_wait()
    epoll-->>Worker: 接続2が書き込み可能
    Worker->>Conn2: レスポンス送信(ノンブロッキング)

図で理解できる要点

  • Worker プロセスは待機時間なく、次々と準備済みの接続を処理
  • 各接続は「読み込み」→「処理」→「書き込み」のサイクルを並行実行
  • epoll が効率的に「次に処理すべき接続」を通知

ベンチマーク結果の例

実際のベンチマーク結果を見てみましょう。

bash# Apache ベンチマーク(ab コマンド)

ab -n 10000 -c 100 http://localhost/
#サーバー同時接続数処理時間リクエスト/秒メモリ使用量
1Apache (prefork)10015.2 秒658 req/s2.1 GB
2Apache (worker)10012.8 秒781 req/s980 MB
3Nginx1004.3 秒2,325 req/s45 MB
4Apache (prefork)1000タイムアウト-メモリ不足
5Nginx10008.9 秒1,123 req/s62 MB

Nginx は Apache と比較して、約 3〜5 倍のスループットを実現しつつ、メモリ使用量は 1/50 以下に抑えられています。

静的コンテンツ配信での優位性

Nginx は特に、静的ファイル(画像、CSS、JavaScript など)の配信で威力を発揮します。

nginx# 静的ファイル配信の最適化設定

server {
    listen 80;
    server_name example.com;

    location /static/ {
        # ルートディレクトリ
        root /var/www/html;

        # sendfile syscallを使用(カーネル空間で直接ファイル送信)
        sendfile on;

        # TCP_CORKオプション(パケットをまとめて送信)
        tcp_nopush on;

        # ブラウザキャッシュの設定
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

sendfile を有効にすると、ファイルデータをユーザー空間にコピーせず、カーネル空間で直接ネットワークに送信できます。これを「ゼロコピー」と呼び、大幅な性能向上につながります。

plaintext# 通常のファイル送信フロー

1. ディスク → カーネルバッファ(read syscall)
2. カーネルバッファ → ユーザー空間バッファ(コピー)
3. ユーザー空間バッファ → ソケットバッファ(write syscall)
4. ソケットバッファ → ネットワーク

# sendfileを使ったゼロコピーフロー

1. ディスク → カーネルバッファ
2. カーネルバッファ → ネットワーク(直接送信)

★コピー回数が半分に削減

リバースプロキシでの活用

Nginx は、リバースプロキシとしても優れた性能を発揮します。

nginx# リバースプロキシ設定例

upstream backend {
    # バックエンドサーバーのリスト
    server backend1.example.com:8080;
    server backend2.example.com:8080;
    server backend3.example.com:8080;

    # キープアライブ接続を維持
    keepalive 32;
}

server {
    listen 80;
    server_name example.com;

    location / {
        # バックエンドへプロキシ
        proxy_pass http://backend;

        # HTTP/1.1を使用(キープアライブのため)
        proxy_http_version 1.1;
        proxy_set_header Connection "";

        # バッファリング設定
        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
    }
}

Nginx は、クライアントとバックエンドサーバーの間に立ち、両方の接続を効率的に管理します。

plaintext# リバースプロキシの動作フロー

クライアント1 ─┐
クライアント2 ─┼→ Nginx ─┬→ バックエンド1
クライアント3 ─┤   (Worker) ├→ バックエンド2
クライアントN ─┘            └→ バックエンド3

★少数のWorkerで大量のクライアント接続と
 バックエンド接続を同時に管理

以下の図は、Nginx がリバースプロキシとして動作する際のイベント処理フローを示しています。

mermaidflowchart TB
    subgraph Client ["クライアント側"]
        c1["クライアント1"]
        c2["クライアント2"]
        c3["クライアントN"]
    end

    subgraph Nginx ["Nginx Worker"]
        eventloop["イベントループ"]
        epoll_client["epoll<br/>(クライアント接続監視)"]
        epoll_backend["epoll<br/>(バックエンド接続監視)"]

        eventloop --> epoll_client
        eventloop --> epoll_backend
    end

    subgraph Backend ["バックエンド"]
        b1["アプリサーバー1"]
        b2["アプリサーバー2"]
        b3["アプリサーバー3"]
    end

    c1 --> epoll_client
    c2 --> epoll_client
    c3 --> epoll_client

    epoll_backend --> b1
    epoll_backend --> b2
    epoll_backend --> b3

図で理解できる要点

  • 1 つの Worker がクライアント接続とバックエンド接続の両方を監視
  • epoll により、両側の接続を効率的に多重化
  • 遅いクライアント接続が速いバックエンドをブロックしない

まとめ

Nginx が高速である理由は、以下の技術的要素が巧みに組み合わされているからです。

核心技術のまとめ

#技術要素効果
1Worker プロセスモデル少数プロセスで大量接続を処理、コンテキストスイッチを最小化
2イベント駆動アーキテクチャブロッキングなしで複数の接続を並行処理
3ノンブロッキング I/OI/O 待ちでプロセスが停止しない
4epoll/kqueueO(1) の効率で準備済み接続を検出
5エッジトリガーモード最小限の通知で最大の効率を実現

パフォーマンス上の利点

Nginx のアーキテクチャがもたらす具体的な利点は、以下の通りです。

メモリ効率:接続数が増えてもメモリ使用量がほぼ一定で、従来型の 1/100 以下に抑えられます。

CPU 効率:コンテキストスイッチが少なく、CPU キャッシュを有効活用できるため、処理効率が大幅に向上します。

スケーラビリティ:C10K 問題を解決し、数万から数十万の同時接続を 1 台のサーバーで処理できます。

適用シーンの選択

Nginx は以下のようなシーンで特に威力を発揮するでしょう。

静的コンテンツ配信:画像、CSS、JavaScript などのファイル配信で圧倒的な性能を発揮します。sendfile によるゼロコピー転送が効果的です。

リバースプロキシ:大量のクライアント接続とバックエンドサーバーの間で効率的にデータを中継できます。

ロードバランサー:複数のバックエンドサーバーへ負荷を分散する際、Nginx 自身がボトルネックになりにくい特性があります。

API ゲートウェイ:多数の API リクエストを効率的に処理し、バックエンドサービスへルーティングできます。

設計思想の本質

Nginx の成功は、単なるアルゴリズムの工夫だけではありません。OS カーネルが提供する高性能な機能(epoll、kqueue)を最大限活用し、ノンブロッキング I/O とイベント駆動という設計思想を徹底することで、圧倒的な効率を実現しています。

この「カーネルの力を借りる」「ブロッキングを徹底的に排除する」という考え方は、現代の高性能サーバーソフトウェア開発における重要な指針となっているのです。

Node.js や Go 言語の net/http パッケージなど、多くの現代的なフレームワークも、Nginx と同様の設計思想を採用しています。Nginx のアーキテクチャを理解することは、高性能なサーバーアプリケーション開発の基礎を学ぶことにつながるでしょう。

関連リンク