Linux TCPネットワークスタック最適化:高トラフィックに耐えうるsysctl、バッファサイズ、BBRのチューニング

Linux tutorial - IT technology blog
Linux tutorial - IT technology blog

現実の課題:10,000同時接続で発生する「見えないボトルネック」

モニタリングのグラフ上では何一つ問題がないように見えます。CPU使用率は35%前後で余裕があり、メモリ消費量は16GB中約4GBにとどまり、NVMeディスクのレイテンシも1ms未満を維持しています。それにもかかわらず、ユーザーからはレスポンスの遅延が報告され、トラフィック急増時にはNginxが502 Bad Gatewayや504 Gateway Timeoutエラーを散発的に記録します。

dmesg や /var/log/syslog を確認すると、本当の原因が浮かび上がってきます。

TCP: request_sock_TCP: ポート443でSYNフラッドの可能性があります。リクエストを破棄しています。
TCP: net_ratelimit: 893件のコールバックが抑制されました

netstat -s を実行すると、ネットワークスタックが高負荷に耐えきれず破綻していることが確認できます。

$ netstat -s | grep -i listen
    14201 回ソケットのlistenキューがオーバーフローしました
    18450 件のLISTENソケット宛てSYNが破棄されました

このケースでは、アプリケーションコード自体がボトルネックになっているわけではありません。Nginxやランタイムがソケットディスクリプタを受け取るよりも前の段階で、Linuxカーネルが正当な受信TCPハンドシェイクを破棄してしまっているのです。

根本原因の分析:なぜLinuxのデフォルト設定は高負荷下で破綻するのか

カーネルのデフォルト設定値は、組み込み機器から軽量な仮想マシンに至るまで、幅広い環境での互換性を重視して設計されています。これにより低スペックなシステムでもメモリ不足(OOM)によるパニックを防ぐことができます。しかし残念ながら、毎秒数千リクエストを処理する高スループットなWebサービスにおいては、この保守的なベースラインが深刻なボトルネックとなってしまいます。

1. 枯渇する接続バックログキュー

すべてのTCP接続は、3ウェイハンドシェイク(SYN → SYN-ACK → ACK)から始まります。カーネルはこの接続処理プロセスを2つの内部キューに分割して管理します。

  • SYNバックログキュー(SYN Backlog Queue): クライアントが SYN パケットを送信し、確認応答を待っているハーフオープン状態の接続を追跡します。標準カーネルでは、net.ipv4.tcp_max_syn_backlog の上限が128または256エントリに制限されていることが少なくありません。
  • Listen Acceptキュー(Listen Accept Queue): ハンドシェイクが完了し、アプリケーションワーカー(Nginx、Node.js、Goなど)が accept() を呼び出すのを待っている接続を格納します。このキューは net.core.somaxconn によって制御され、デフォルト値は128や4096に設定されていることがよくあります。

突発的なタイムセールやトラフィックの急増が発生すると、わずか数ミリ秒で128スロットのキューが溢れかえります。キューが満杯になると、カーネルは新規のSYNパケットを警告なしに破棄します。その結果、クライアント側は停止し、TCPの再送タイムアウトを数秒間待たされた挙句、リクエストを完全に中断してしまうケースが多発します。

2. エフェメラルポートの枯渇と接続の入れ替わり

リバースプロキシやマイクロサービスは、データベースや内部サービスに対して大量のアウトバウンド接続を確立します。TCPセッションが正常に終了した際、遅延パケットを取りこぼさないよう、クライアントソケットは固定で60秒間 TIME_WAIT 状態に遷移します。さらに、リモートピアが終了用のFINパケットを送信しない場合、孤立したソケットが FIN-WAIT-2 状態のまま滞留してしまうことがあります。

Linuxのエフェメラルポート範囲(net.ipv4.ip_local_port_range)は、デフォルトで 32768–60999 に設定されています。これは、接続先IPアドレス1つあたり約28,231個の利用可能ポートしかないことを意味します。もしプロキシが単一のバックエンドエンドポイントに対して毎秒500リクエストを処理した場合、1分足らずでポートが完全に枯渇します。その結果、後続の接続試行は直ちに EADDRNOTAVAIL(要求されたアドレスを割り当てられません)エラーで失敗します。

3. 不足しているTCPウィンドウバッファ

高速ネットワークや広域ネットワークにおけるデータスループットは、帯域遅延積(Bandwidth-Delay Product: BDP)に左右されます。

BDP (バイト) = 回線速度 (バイト/秒) × ラウンドトリップタイム (秒)

フランクフルトとアシュバーン間のサーバーを結ぶ、往復遅延時間(RTT)が75msの10Gbps回線を考えてみましょう。この通信路の帯域幅を限界まで使い切るには、伝送中(in-flight)のデータを保持するために以下のバッファサイズが必要になります。

(10,000,000,000 / 8) × 0.075 = 93,750,000 バイト (~89.4 MB)

Linuxのデフォルト受信バッファは、多くの場合4MB〜6MB程度にとどまります。TCPウィンドウがBDP全体を収容できない場合、送信側はデータ送信を一時停止してACK(確認応答)の到着を待たなければなりません。その結果、せっかくの10Gbps回線も、本来の性能の15%未満しか発揮できず遊休状態になってしまいます。

4. パケットロスベースの輻輳制御(Cubic)

大半のLinuxディストリビューションでは、デフォルトで Cubic が採用されています。Cubicは、パケットのドロップが発生するとネットワークスイッチの物理バッファで輻輳が起きたと判断し、輻輳ウィンドウを即座に半分へと縮小します。

しかし現代のネットワーク環境では、この前提が成り立ちません。ノイズの多いモバイル回線やWi-Fi接続、あるいはマルチテナントのクラウドネットワークでは、バッファの枯渇ではなく一時的な電波干渉や揺らぎによって日常的にパケットロスが発生します。Cubicはこうした状況でも不必要に伝送速度を絞り込んでしまい、突発的なレイテンシのスパイクを引き起こす原因となります。

現実的な解決策の検討:誤った通説 vs 本番環境向けチューニング

こうした制限に直面したエンジニアリングチームは、通常、次の3つのアプローチのいずれかをとります。

  1. ハードウェア増強による力技: vCPUを倍増させたりRAMを追加したりしても、カーネルキューの枯渇は解決しません。128コアと512GBのRAMを搭載したベアメタルサーバーであっても、net.core.somaxconn が128のまま固定されていれば接続のドロップは避けられません。
  2. 盲目的なコピペによる修正: フォーラムの古いスニペットをそのまま適用すると、深刻な副作用を招きます。例えば、net.ipv4.tcp_tw_recycle の有効化はマイクロ秒単位で正確なTCPタイムスタンプに依存します。そのため、企業などの共有NATルーターの配下にいるクライアントはハンドシェイクの検証に失敗し、オフィス全体のアクセスが遮断される事態に陥ります。なお、Linuxカーネルはバージョン4.12でこの設定項目を完全に廃止しました。
  3. 体系的なカーネルチューニング: 想定されるバーストトラフィック量に基づいてキューサイズを設計し、物理RAMの容量に合わせてTCPメモリベクトルを調整し、ボトルネック帯域ベースの輻輳制御アルゴリズムへと切り替えるアプローチです。

本番環境向けチューニング手順ガイド

以下の手順に従って本番環境のLinuxインスタンスをチューニングし、激しいトラフィック急増時でも安定したスループットを維持できるように最適化しましょう。

ステップ1:現在のカーネル制限値を確認する

設定値を変更する前に、現在のシステムのベースライン設定を確認します。

# 接続キューの制限値を確認
sysctl net.core.somaxconn
sysctl net.ipv4.tcp_max_syn_backlog

# 利用可能および現在有効な輻輳制御モジュールを確認
sysctl net.ipv4.tcp_available_congestion_control
sysctl net.ipv4.tcp_congestion_control

ステップ2:ソケットキューとネットワークリングバッファを拡張する

双方のバックログを拡張し、予期せぬバーストトラフィックが発生しても即座にドロップせず、メモリ内に安全にバッファリングできるようにします。

# 確立済みソケットに対するlisten acceptキューを拡張
sudo sysctl -w net.core.somaxconn=65535

# ハーフオープン状態のハンドシェイク向けSYNバックログキューを拡張
sudo sysctl -w net.ipv4.tcp_max_syn_backlog=65535

# ネットワークインターフェースドライバの受信パケットバックログを増大
sudo sysctl -w net.core.netdev_max_backlog=65535

ステップ3:ポート枯渇を防止し、滞留ソケットを回収する

エフェメラルポートのプールを拡大し、外向き接続においてソケットを安全に再利用できるようにします。

# エフェメラルポートの範囲をフルに開放(55,000以上のローカルポートを提供)
sudo sysctl -w net.ipv4.ip_local_port_range="10240 65535"

# プロトコルのタイムスタンプが一致する場合、外向き接続でTIME_WAITソケットを安全に再利用
sudo sysctl -w net.ipv4.tcp_tw_reuse=1

# クローズ処理されない孤立したFIN-WAIT-2接続を60秒ではなく15秒で破棄
sudo sysctl -w net.ipv4.tcp_fin_timeout=15

ステップ4:TCPウィンドウサイズとメモリベクトルを拡張する

Linuxはソケットバッファを、バイト単位の3つの値を持つベクトル [min, default, max] で管理します。システムは需要に応じてこの範囲内で動的にバッファ領域を割り当てます。

8GB〜16GBのRAMを搭載したシステムでは、高スループット接続向けにソケットあたり最大16MB〜32MBを割り当てます。

# OSの送受信バッファの最大制限値を設定(32MB)
sudo sysctl -w net.core.rmem_max=33554432
sudo sysctl -w net.core.wmem_max=33554432

# デフォルトバッファを256KBに設定
sudo sysctl -w net.core.rmem_default=262144
sudo sysctl -w net.core.wmem_default=262144

# TCP受信メモリ: [最小4KB, デフォルト128KB, 最大32MB]
sudo sysctl -w net.ipv4.tcp_rmem="4096 131072 33554432"

# TCP送信メモリ: [最小4KB, デフォルト128KB, 最大32MB]
sudo sysctl -w net.ipv4.tcp_wmem="4096 131072 33554432"

ステップ5:Google BBR輻輳制御を有効化する

Googleは、パケットロスベースの輻輳制御の限界を打破するためにBBR(Bottleneck Bandwidth and Round-trip propagation time)を開発しました。BBRはパケットロスを輻輳の指標とみなすのではなく、実際のリンク帯域幅と往復遅延時間を継続的に測定します。回線の物理的な容量に合わせてデータを送信するため、ネットワークスイッチのバッファ溢れを防ぎ、キューイング遅延を大幅に削減します。

BBRを利用するにはパケットペーシングスケジューラであるFair Queueing(fq)が必要ですが、これはLinuxカーネル4.9以降でネイティブにサポートされています。

# Fair Queueing(FQ)スケジューラを有効化
sudo sysctl -w net.core.default_qdisc=fq

# TCP輻輳制御をBBRに切り替え
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr

カーネルがBBRモジュールを正常にロードしたことを確認します。

$ sysctl net.ipv4.tcp_congestion_control
net.ipv4.tcp_congestion_control = bbr

$ lsmod | grep bbr
tcp_bbr                20480  16

ステップ6:再起動後も設定を永続化する

sysctl -w による変更は揮発性メモリ上でのみ保持されます。再起動後も設定を有効にするため、/etc/sysctl.d/ 配下に専用の設定ファイルを作成してまとめます。

cat << 'EOF' | sudo tee /etc/sysctl.d/99-network-tuning.conf
# 接続キュー
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.core.netdev_max_backlog = 65535

# エフェメラルポートとソケットの再利用
net.ipv4.ip_local_port_range = 10240 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15

# 動的TCPバッファサイジング(8GB〜16GB RAM向け)
net.core.rmem_max = 33554432
net.core.wmem_max = 33554432
net.core.rmem_default = 262144
net.core.wmem_default = 262144
net.ipv4.tcp_rmem = 4096 131072 33554432
net.ipv4.tcp_wmem = 4096 131072 33554432

# ペーシングとBBR輻輳制御
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
EOF

# 設定をディスクから即座に適用
sudo sysctl --system

ステップ7:Webサーバーとアプリケーションのバックログを調整する

カーネルのacceptキューの上限は、net.core.somaxconn とアプリケーションの listen() システムコールに渡されるバックログ値のうち、小さい方の値に制限されます。仮にカーネル側で65,535を許可していても、Nginx側が511接続しか要求していなければ、実際の上限は511のままとなります。

拡張したキュー容量を最大限に活用できるよう、/etc/nginx/nginx.conf のNginx仮想ホスト設定を更新します。

server {
    listen 80 backlog=65535;
    listen 443 ssl http2 backlog=65535;
    server_name api.example.com;
    # ...
}

アクティブなセッションを切断することなく変更を反映するため、Nginxをリロードします。

sudo nginx -t && sudo systemctl reload nginx

検証とリアルタイムヘルスチェック

ソケット統計コマンド(ss)を使用して、サービスが拡張されたキューの余裕を持って稼働しているか確認します。

# HTTPおよびHTTPSのリスニングTCPソケットを確認
ss -lnt '( sport = :http or sport = :https )'

出力される各カラムに注目してください。リスニングソケットにおいて、Send-Q はバックログの最大サイズを表し、Recv-Q はアプリケーションの accept() 呼び出しを待機している接続数を表します。

State      Recv-Q  Send-Q   Local Address:Port   Peer Address:Port
LISTEN     0       65535          0.0.0.0:443         0.0.0.0:*
LISTEN     0       65535          0.0.0.0:80          0.0.0.0:*

Send-Q が 65535 を示していれば、パケットのドロップや散発的なゲートウェイエラーを発生させることなく、大規模な同時接続バーストをサーバーがスムーズに吸収できるようになります。

Share: