はじめに
株式会社サイバーエージェント 24 卒 SRE 内定者の後藤 廉(@ren510dev)です。
Kubernetes が好きです。
普段は、セキュアオーバーレイネットワークプロトコルの研究・開発をしています。
以前、開発しているオーバーレイネットワークプロトコルのプロトタイプを検証・評価した際に、TCP のスループットが極端に劣化するという事象に直面しました。
今回の記事では、ソケットプログラミングの紹介を交えつつ、Raw ソケットを使用したユーザ空間プログラムによるカスタムプロトコルに TCP を乗せる際に気を付けることと、NIC(Network Interface Card)に備わっているオフロード機能及びマルチコアスケールを利用したパフォーマンス改善策について紹介したいと思います。
結論だけ知りたい人は こちら
【ぼやき】TCP はめんどくさい
※ TCP/UDP の通信メカニズムに関してある程度理解がある方は呼び飛ばして下さい。
なぜ、TCP はめんどくさいのか。
簡単に言うと "非常に賢く、頭の良いプロトコル" だからです。
我々が普段から使用しているインターネットは TCP/IP と呼ばれるネットワーク規約(ルール)の上で動作します。
中でも、TCP/IP スイートにおけるトランスポートレイヤのプロトコルは大きく TCP と UDP の 2 種類に大別されます。
これらは、しばしば次のように表現されます。
正確な『TCP』と軽快な『UDP』
なぜ、このように呼ばれるのでしょうか?
TCP: Transmission Control Protocol
TCP に備わる 3 つの代表機能
- 3 ウェイハンドシェイク(Three-way handshaking)
- 輻輳制御機構(Congestion control)
- 再送制御機構(Retransmission control)
一度は耳にしたことがある用語だと思いますが簡単に解説します。
TCP は 3-way handshaking と呼ばれる手続きによって、通信前に相手端末とコネクションを確立してからデータ送受信を開始します。
輻輳制御機構は、ネットワーク帯域や相手端末からのレスポンス(RTT: Round-Trip Time 等)を都度確認しながら、ウィンドウサイズと呼ばれる、一度に送信できるデータ量を制御・調節します。
以下に、輻輳制御アルゴリズムについて示します。
まず、スロースタートと呼ばれるメカニズムにより最初は送信データ量を少なくしておき、その後徐々に増加させます。
そして、通信が逼迫しはじめると、輻輳制御機構によって一旦データ量を減らし、再度増加を試みます。
常に輻輳を回避しながら送受信することで、安定した通信品質を得ることができます。
※ 今回は TCP の輻輳制御アルゴリズムを示す際によく使用される 1988 年頃に登場した Tahoe を載せていますが、Linux カーネル version 2.6.19 以降では CUBIC、その他 Reno, New Reno 等、Loss-based アルゴリズムが標準搭載されています。
搭載されている輻輳制御アルゴリズムは以下のカーネルパラメータで確認できます。
$ sudo sysctl -a 2>&1 | grep -i tcp_available_congestion_control
net.ipv4.tcp_available_congestion_control = reno cubic ## CUBIC が輻輳制御に使用されている
また、TCP は一定期間レスポンスがない場合や、何らかの理由でパケットが損失した場合には、再送制御機構を用いて、再度データを送り直します。
これらの複雑な機能を搭載することで、確実に相手端末にメッセージを送り届けることができ、パケットの欠損やフラグメント化されたデータの順序を維持します。
TCP は一般的な WEB サービスに使用される HTTP/HTTPS, gRPC, DB 接続、メール送受信の際に使用される SMTP/POP/IMAP 等、提供サービスの信頼性を保証しなければならない場面で基盤プロトコルとして使用されます。
カスタムプロトコルを定義して、その上で TCP を動かす『TCP over カスタムプロトコル』の場合は、輻輳制御や再送制御についても考慮した設計をしなければなりません。
実装したプログラムの処理が遅すぎるとボトルネックが発生し、輻輳制御によって TCP はウィンドウサイズを上げてくれません。
また、受信側でフラグメント化されたパケットを正しく処理できない場合、レスポンスを返すことができず、再送制御によって 大量に Retransmission が発生します。
フラグメントデータの順序ずれは特に TCP スループットが低下する原因の一つであるため、パケット順序を維持した正確な処理も必要です。
UDP: User Datagram Protocol
TCP と比べると UDP は非常に軽快で単純なプロトコルです。
TCP に備わっている、データを確実に相手端末に送り届ける仕組みや、ネットワーク環境に合わせたパケットサイズの制御機能はありません。
極端な言い方をすれば、無理やり相手端末にパケットを送り付けるだけです。
これだけ聞くと、TCP の方が優位に思えるかもしれませんが、UDP にもちゃんと優れた点があります。
UDP は、細やかな制御機能が無い分、非常に高速に動作します。また、ネットワーク環境に左右されず(影響を受けにくく)メッセージを送信できるため、一度に送れるデータ量も TCP より多くなります。
実際に TCP と UDP のパケットフォーマットには次のような違いがあり、TCP が 20 Mbyte(オプションによっては最大 60 Mbyte)であるのに対して UDP はたったの 8 Mbyte です。
そのため、UDP は例えばビデオストリーミングや映像配信等、トラフィックが比較的重くなる傾向にあるサービスで使用されます。
なぜならば、リアルタイム性を重要視する一方で、多少の映像の乱れや、通信の遮断は対して問題にならないからです。
また、UDP では Hole Punching と呼ばれる機能を実装することができます。
これは、NAPT(Network Address Port Translation)を挟んでプライベートネットワークに存在する端末に対して、グローバルネットワークから通信を開始したい場合に有効的に機能します。
つまり、任意のサービスにおいて、NAPT Traversal の必要がある場合、UDP を使用すれば簡単に実装できるのです。
UDP Hole Punching は 実際には STUN(Session Traversal Utilities for NAT/NAPT)や TURN(Traversal Using Relays around NAT/NAPT)といった技術と組み合わせて使用されることがほとんどであり、身近なところでは Skype やモンストのマルチプレイでも利用されています。
NAPT Traversal のメカニズムに関しては任天堂からも非常にわかりやすい記事が投稿されています。
NAPT Traversal は、通信前に事前にコネクションの確立が必要ない UDP ならではの特性を活かしたソリューションです。(TCP を使った方法を検討する論文もありました。が、結局のところ UDP に頼る必要がありそうです。)
HTTP/3(IETF 版の HTTP over QUIC)や Wireguard を始めとし、近年では UDP をベースとしたプロトコルやサービスが非常に多くなっているように思えます。
これは、時代の背景(IoT デバイスの協調処理, 分散コンピューティングの増加, 高度なリアルタイム性, web3 の発展)により、P2P 通信の需要が高まっているからだと私は考えています。
UDP は TCP のような再送制御がないため、どんな状況でも基本的にはデータを送り続けます。
また、スループット(Bitrate)が低い場合、どの程度通信環境が劣悪なのかは Jitter(遅延値のゆらぎ)やパケットロス率が示します。
ネットワークソケットとシステムコール
受信パケットをユーザ空間に持ち上げて処理する際や、既存のプロトコルにアクセスする際は、ソケットをオープンしてカーネルからデータを傍受するソケットプログラミングが必要になります。
ソケットプログラミングでは大きく システムコール と ソケットオープン という概念が登場します。
Socket API
ネットワークソケット とはアプリケーションがインターネットを介してデータを送受信するための仕組みを抽象化したものです。
開発者は Socket API と呼ばれるインターフェースを介してネットワーク上の他のユーザ(プロセス)と接続します。
また、インターネットを介してデータの送受信が行われるサービス(Web アプリケーションもそのうちの一つ)を開発する場合は必ず、ディスクリプタ(FD: File Descriptor) と呼ばれる I/O 窓口を準備しなければなりません。
I/O に用いられるファイルディスクリプタのうち、特にネットワーク送受信に用いられるディスクリプタを ソケットディスクリプタ と呼びます。
ユーザ空間で動作するプログラムからソケットディスリプタをオープンする場合、システムコール(ハイパバイザコール) によってカーネルの機能を呼び出します。
ネットワーク上のデータ送受信も、アプリケーションが内蔵ディスクに対してデータを読み書きする(io.Reader
, io.Writer
)のと同じ原理で行われます。
アプリケーションはソケットを使ってネットワークに接続することで、同じくネットワークに接続されている別のアプリケーションと通信を行うことができます。
一方のマシンでアプリケーションがソケットに書き込んだ情報を、相手のマシンで動作するアプリケーションが読み取ることで、インターネットを通じた相互通信が成立します。
ネットワークソケットの種類
ソケットには
- スタンダードソケット(ストリームソケット・データグラムソケット)
- Raw ソケット
の 2 種類存在します。
双方の違いは、プロトコルヘッダを自分で処理するかどうかです。
前者は、コールする度にヘッダサイズやチェックサムを自動で計算してくれるため容易に扱うことができ、一般的なサービス開発で用いられます。
後者は、通常処理されてしまう IP ヘッダや TCP/UDP ヘッダを含んだ Raw パケットをユーザ空間で操作・処理できるため、ローレベルなネットワークプログラミングやカスタムプロトコルの実装、パケットスニッフィング、セキュリティ調査等において用いられます。
以下に、一般的に用いられるスタンダードソケットと Raw ソケットが扱うパケットの違いを示します。
Raw ソケット
Raw ソケットを使用するプログラムにおいて、生成したソケットからパケット操作を行う関数の動作を図に示すと大体以下のようになります。
Raw ソケットの場合、ソケットインターフェースは PF_PACKET
を指定することにより、変更が一切加えられていない受信直後の Raw パケットを、そのままユーザ空間のアプリケーションで受け取ることができます。
また、ソケットオプションとして SOCK_DGRAM
を指定した場合、IP レイヤからアプリケーションレイヤまでのデータを取得できます。
SOCK_RAW
を指定した場合、ソケットは IEEE 802.3 フレームの IEEE 802.2 LLC ヘッダの生成や解析を行うことが可能なため、データリンクレイヤレベルで操作ができます。
一方で、Raw ソケットを用いた場合にも、自身の MAC アドレス以外の宛先が指定されたイーサフレームを受信した場合、受信ソケットで読み出し可能なデータになる前に破棄されてしまいます。
そのため、ルータやブリッジ、リピータ等のネットワーク中継機を実装する場合は、自身のアドレスとは無関係の宛先が指定されているパケットを受信するためプロミスキャスモード(Promiscuous Mode)で実装しておきます。
Wireshark / tcpdump 等のパケット解析ツールはプロミスキャスモードを実装しているため、インターフェースを通る全てのパケットを確認することができるようになっています。
Raw ソケットは柔軟にデータを操作できる一方で、プロトコルヘッダを自分で処理しなければならない ため、ヘッダサイズやチェックサム、IP フラグメントを計算する際には注意が必要です。
Go 言語の場合、syscall
パッケージを用いて Raw ソケットをオープンできます。
- Debian において IPv4 受信ソケットを作成する例
$ cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 11 (bullseye)"
NAME="Debian GNU/Linux"
VERSION_ID="11"
VERSION="11 (bullseye)"
VERSION_CODENAME=bullseye
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
// htons (Host to Network Short) function converts a 16-bit unsigned integer
// from host byte order to network byte order (big-endian).
// It swaps the byte order from little-endian to big-endian or vice versa.
func htons(host uint16) uint16 {
return (host&0xff)<<8 | (host >> 8)
}
// RecvIPv4RawSocket creates a raw socket for receiving IPv4 packet.
func (ifi *net.Interface) RecvIPv4RawSocket() (int, error) {
fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(htons(syscall.ETH_P_IP)))
if err != nil {
return -1, err
}
addr := syscall.SockaddrLinklayer{
Protocol: htons(syscall.ETH_P_ALL),
Ifindex: ini.Index,
}
if err := syscall.Bind(fd, &addr); err != nil {
return -1, err
}
return fd, nil
}
※ Windows や macOS ではリングプロテクション等によってソケットが開けない可能性があります。
- システムコールによってディスクリプタから受信データを直接リード
func main() {
// Specifying the receiving NIC.
ifi, err := net.InterfaceByName("eth0")
if err != nil {
log.Fatal(err)
}
// Call descriptor.
fd, err := ifi.RecvIPv4RawSocket()
if err != nil {
log.Fatal(err)
}
for {
// Create a byte slice to store the read data.
buffer := make([]byte, 1024)
// Use syscall.Read to read from the file descriptor
// The return value n is the number of bytes read
size, err := syscall.Read(fd, buffer)
if err != nil {
panic(err)
}
}
}
また、net/http
を用いて HTTP サーバを構築する際も、関数の中身を覗いてみると ストリームソケットを開いている部分を見つけることができます。
(近年の高級言語では豊富な抽象化が取り入れられているので、開発者がソケットやディスクリプタの存在を意識することはないでしょう。)
package main
import (
"fmt"
"net/http"
)
func helloWorld(w http.ResponseWriter, r *http.Request){
fmt.Fprintf(w, "Hello, World!")
}
func main() {
http.HandleFunc("/", helloWorld)
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}
BSD ソケットの例
func setDefaultSockopts(s, family, sotype int, ipv6only bool) error {
if runtime.GOOS == "dragonfly" && sotype != syscall.SOCK_RAW {
// On DragonFly BSD, we adjust the ephemeral port
// range because unlike other BSD systems its default
// port range doesn't conform to IANA recommendation
// as described in RFC 6056 and is pretty narrow.
switch family {
case syscall.AF_INET:
syscall.SetsockoptInt(s, syscall.IPPROTO_IP, syscall.IP_PORTRANGE, syscall.IP_PORTRANGE_HIGH)
case syscall.AF_INET6:
syscall.SetsockoptInt(s, syscall.IPPROTO_IPV6, syscall.IPV6_PORTRANGE, syscall.IPV6_PORTRANGE_HIGH)
}
}
if family == syscall.AF_INET6 && sotype != syscall.SOCK_RAW && supportsIPv4map() {
// Allow both IP versions even if the OS default
// is otherwise. Note that some operating systems
// never admit this option.
syscall.SetsockoptInt(s, syscall.IPPROTO_IPV6, syscall.IPV6_V6ONLY, boolint(ipv6only))
}
if (sotype == syscall.SOCK_DGRAM || sotype == syscall.SOCK_RAW) && family != syscall.AF_UNIX {
// Allow broadcast.
return os.NewSyscallError("setsockopt", syscall.SetsockoptInt(s, syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1))
}
return nil
}
最大転送サイズとデータフラグメント
ネットワーク伝送の際、一度に送信できる最大のメッセージサイズを決定する MTU(Maximum Transmission Unit) と MSS(Maximum Segment Size) という指標が存在します。
MTU: Maximum Transmission Unit
MTU は IP レイヤの概念であり、ネットワーク伝送路に介在するネットワーク機器や宛先ホストが受信できる IP ヘッダを含めた最大サイズ(バイト数)を指します。
端末上で動作するアプリケーションは MTU を超えてデータを送信することはできません。
MTU の値が大きいほど、一度に多くのデータを送信できるため、効率的な通信が可能となります。
では、MTU 値は大きければ大きいほど良いのかと言われるとそういう訳でもありません。
ネットワークを介して相手端末と通信する場合、基本的にはネットワーク経路上にルータやスイッチと言ったハードウェアボックスが存在します。
それらで MTU が制限されている場合、データはフラグメント処理され、かえって通信遅延の原因になる場合があります。
一般的に、イーサフレームの最大サイズは規格上 1518 Bytes までとなっており、イーサヘッダとトレーラを除いた 1500 Bytes がインターフェースのデフォルトの MTU 値 となっています。
$ ifconfig | grep mtu | egrep -v lo
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
wlan0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
MTU 値は ip
, ifconfig
コマンド等で確認することができます。
例えば、この例では、eth0
, wlan0
は共に MTU 1500 であることが分かります。
近年では、フレームサイズが最大 9000 Bytes にも及ぶジャンボフレームという規格もあります。
MSS: Maximum Segment Size
MSS は TCP おいて利用される概念で、TCP セグメントのデータ部分の最大サイズを指します。
TCP は、TCP Window Scaling によって ウィンドウサイズを最大 65,535 Byte まで増加させます。
TCP はデータを複数のセグメントという単位に分割(フラグメント)して送信します。
MSS の値は基本的に、IP と TCP のヘッダ 40 Bytes 分を差し引いた 1460 Bytes になります。
例えば、3 MBytes の 本 Qiita 記事を閲覧する場合、MTU 1500 の NIC を搭載した端末では、単純計算でセグメントサイズ 1460 Bytes のデータ を 2048 個(3 MBytes * 1024 KB / 1500 bytes
)に分割して受信します。
通常、MSS は MTU に合わせて設定され、MTU が小さい場合には MSS もそれに合わせて小さくなります。
IP フラグメンテーション
IP フラグメントはネットワーク伝送路に存在するルータ等のハードウェアボックスが行う作業で、ネットワーク機器の MTU に従って分割されます。
しかし、IP フラグメントは双方の端末でも経路に介在するネットワーク機器でもパケットフラグメント/デフラグメントが発生するため、通信効率は非常に悪くなります。
そのため、現在では PMTUD(Path MTU Discovery)によってパケット分割を禁止する DF ビット(Don't Fragment)が有効になっている場合が殆どかと思います。
PMTUD 機能は、経路上にあるリンクの最小 MTU 値を検出し、送信元へ ICMP(TypeCode 3 or 4)でその最小 MTU 値の情報を返送することで、MTU サイズを自動修正します。
MSS 値を超えるデータを TCP 通信に乗せる場合はパケット分割が必要になりますが、クライアントやサーバ上で TCP が確実にフラグメンテーションを行っている場合 IP フラグメントは発生しません。
カスタムプロトコルとオーバーレイネットワーク
カスタムプロトコル
通信やデータ転送のために特定のアプリケーションによって独自に開発された通信プロトコルのことを指します。
ここでは、TCP/IP をベースとしていることを前提とします。
カスタムプロトコルのメリット
- 特定の用途への最適化
- 標準プロトコルで対応することができないソリューションを実現できる
- 効率性の向上
- 通信データの形式や転送手順を最適化して、効率的なデータ転送を実現することができる
- セキュリティ
- 特定のセキュリティ要件に対応することができる
オーバーレイネットワーク
オーバーレイネットワークとは、通常の物理的なネットワークの上に構築される論理的なネットワークを指します。
オーバーレイネットワークは、既存のネットワークインフラストラクチャを利用して、新しい機能やサービスを提供するための手段として使用されます。
例えば、VPN は、公共のインターネット上に専用の通信経路(トンネル)を作り出して、物理的なネットワーク上で仮想的な通信を確立しています。
従って、VPN は、オーバーレイネットワークの一例と見做すことができます。
近年では VPN ソリューションとして OSS にもなっている Wireguard が非常に注目を集めていますよね。
WireGuard は Go 言語ベースのプロトコルの中でも非常に効率的にパケット処理できるに書かれています。
また、P2P ネットワークもオーバーレイネットワークの一形態であり、エンドノード同士は物理ネットワークの障壁を受けることなく直接通信することができます。
TCP スループットが上がらなかった理由とその解決策
私が開発に携わっているオーバーレイネットワークプロトコルは、P2P に基づき端末間を直接接続することができる TCP/IP 上のカスタムプロトコルです。
IP レイヤ及びトランスポートレイヤの上に独自のオーバーレイネットワークレイヤを定義し、仮想 IP パケット及び UDP ベースのプロコルとして動作します。
ここで、ポイントとなるのは、『アプリケーションは仮想 IP アドレスに従って通信をする』ということです。
従って、データの Write 先は仮想インターフェース(Virtual I/F)となります。
TCP/IP に独自レイヤを追加するため、一般的な IP パケットにカスタムヘッダを追加することでペイロードサイズは通常よりも大きくなります。
また、Raw ソケットを用いてパケットのチェックサムやフラグメント計算を自力で行う必要があるため、MTU の計算やヘッダサイズに注意が必要です。
TCP における MSS は 1460 Mbyte ですが、開発しているプロトコルでは、トランスポートレイヤの上に、オーバーレイネットワークレイヤ(仮想 IP ヘッダ + カスタムプロトコルヘッダ及び HMAC + UDP ヘッダ)を追加しているため、仮想インターフェースの MTU は 1368 に設定 されます。
TCP MSS (1460 Bytes) - {Virtual IP Header (20 Bytes) + CutomProtocol Header (64 Bytes) + UDP Header (8 Bytes)} = Virtual I/F MTU (1368 Bytes)
アプリケーションは、仮想 IP に基づいた通信を行うことで、実 IP アドレスを用いることによるネットワーク障壁(NAPT による通信遮断, P2P 通信おけるネットワーク脅威)の影響を受けずにオーバーレイネットワーク上でトンネル通信を確立することができます。
ここで、UDP ベースでカプセル化処理をするのは、上記でも述べた通り、NAPT Traversal を実現するためです。
TCP スループットの劣化が発生した際のネットワークアーキテクチャ
開発したカスタムプロトコルは端末にインストールすることで利用できるようになっています。
しかし、世の中には出荷後にプログラムの変更が困難な端末や、既存サービスへの影響を鑑みて追加プログラムのインストールを避ける専用サービスサーバも存在します。
そこでカスタムプロトコルを外部端末から提供するためのアダプタプログラム(一般的なプロトコルとカスタムプロトコルを相互に変換する)を開発し、オーバーレイネットワーク用ゲートウェイ装置を導入しようとプロトタイプ実装を開始しました。
カスタムプロトコルを実装した端末同士の通信(A)と、カスタムプロトコルを実装した端末とアダプタ端末を介した通信(B)における最も大きな違いは ユーザ空間プログラムが仮想 NIC を介してデータを取得するか、物理 NIC を介して取得するか の違いです。
カスタムプロトコルを実装した端末同士の通信品質は TCP, UDP 共に 30 Mbps を上回っており、アプリケーションレイヤで実装したプロトコルとしては非常に良いパフォーマンスを発揮してくれていました。
しかし、なぜかアダプタを介した通信では、UDP スループットが 30 Mbps を発揮するのに対して、TCP では数 Kbps 程度しか性能を得られませんでした。
またこの状況は、アダプタ配下に存在する端末から、カスタムプロトコルを実装した端末への Outgoing ストリームのみで発生しました。
パケット解析ツールにおいて MTU を上回るメッセージが観測される
スループット改善に向け、実際に Wireshark で観測してみると、TCP では再送が大量に発生しているに加え、MTU を上回るメッセージが確認されました。
MTU 1500 なのになぜ... 🤔
結論、MTU を上回るメッセージが観測されたのは、Linux における GRO(Generic Receive Offload) によって受信したパケットが NIC でデフラグメントしていたことが原因でした。
GRO は、カーネルに代わって NIC が受信したパケットをデフラグメント(再結合処理)する機能です。
そもそも、NIC を指定してパケットをキャプチャするはずの Wireshark や tcpdump はなぜ、デフラグメントパケットを観測してしまうのでしょうか。
この仕組みを理解するために、Linux におけるパケット受信フローを確認します。
NAPI を用いたパケット受信
Linux カーネル version 2.6 以降では NAPI(New API)という仕組みによって 割り込み と ポーリング を組み合わせてパケットを受信処理します。
割り込みによる受信パケットの処理フロー
-
【NIC ハードウェア受信】
- 端末はパケットを受信すると NIC の内部メモリに置く。
-
【ハードウェア割り込み】
- パケットを受信したことを知らせるために、NIC からホストの CPU にむけてハードウェア割り込みをかける。
- ハードウェア割り込みがかけられた CPU(NIC ドライバ)は NIC 上のパケットをカーネル上のリングバッファに置く。
- 以降、非同期的なパケット処理が可能なため、ソフトウェア割り込みをスケジュールしてカーネルに移譲する。
-
【ソフトウェア割り込み】
- リングバッファからパケットを取り出し、ソフトウェア割り込みハンドラでプロトコル処理したのち、ソケットキューにパケットがスタックされる。
-
【アプリケーション受信】
- システムコールで
read
,recv
,recvfrom
等のソケット関数が呼ばれるとソケットキューからアプリケーションへ受信データがコピーされる。 - ここで初めて Raw ソケットが登場し、パケットキャプチャツールで受信パケットを観測できる。
- システムコールで
おそらく、NIC からリングバッファに引き渡される時点で既にパケットがデフラグメントされているのだろうと考えられます。
受信時は NIC がハードウェアレベルで処理をした後(まとまった単位)のパケットがキャプチャされるため、実際に NIC が受信したパケットとは異なる場合があります。(今回の場合、この仕組みを知ることがトラブルシューティングの最も重要な鍵でした。)
NIC オフローティングの機能は仮想インターフェース(TUN デバイス)には適用されておらず、物理 NIC に対して適用されます。
近年の Linux カーネルでは、NIC オフロード系のパラメータがデフォルトで有効化されており、これによってカーネルはまとまった単位で NIC とパケットをやり取りできるため、処理負荷やソフトウェア割り込みを抑えることができます。非常に頭が良い...。
### オフロード系カーネルパラメータの確認
$ ethtool -k [対象 NIC]
が、今回はこの機能に苦しめられることになりました。😮💨
答え
Raw ソケットを用いて実装したことで、MTU が 1500 を上回るパケットもアプリケーションで処理することはできますが、まとまった単位で取得されたパケットに対してカスタムヘッダ(開発したプロトコルの BaseHeader)を一括で付与するとパケットをアダプタ端末(物理 NIC)から送り出す際に、再び MTU に合わせてフラグメント処理が発生します。
相手端末(仮想 NIC)もバラバラになった IP データグラム(MTU 1368 以下)を受信するものの、カスタムヘッダが各セグメントに対して付与されていないため、正しく処理することができなくなります。
これは一部のデータだけでも届いていればスループットを稼ぐことができる UDP では問題が浮き彫りになりづらくても、TCP のようなインタラクティブプロトコルの場合は、データが相手端末側で正しく処理されない(処理できない)場合、再送が大量に発生して通信スループットは劣化する一方です。
NIC オフローディングに着目したスループットの改善
この問題に対処するため、上図における地点 A の部分における GRO の機能をオフにします。
#!/bin/sh
INTERNAL_INTERFACE=[対象 NIC]
ethtool -K ${INTERNAL_INTERFACE} gro off
ethtool -k ${INTERNAL_INTERFACE} | grep generic-receive-offload
generic-receive-offload: off
その結果、MTU に合わせたパケットサイズでアプリケーションレイヤのプログラムが処理できるため、カスタムヘッダを個々のセグメントデータに付与することができます。
ソフトウェア割り込みによる単一 CPU コアへの負荷集中
次に問題になりそうなのが、GRO をオフにすることによるカーネル負荷の増大です。
今回は NIC オフロード機能に着目をして、それらの機能を使用しない方法を取りましたが、GRO を使用しなければカーネルは個々のデータに対して都度ソフトウェア割り込みをかけるため、単一 CPU コアが頭打ちになる可能性があります。
パケット受信に伴う一回一回のハードウェア割り込みおよびソフトウェア割り込み負荷は大したことはなくても、例えば、64 Bytes フレーム, 1 Gbps ワイヤーレートでトラフィックを受信したとすると、約 1,500,000 回/sec(1,000,000,000 Bytes / 64 Bytes = 15,625,000)もの割り込みが発生することになります。ソフト割り込みは優先度の高いタスクであるため、割り込みを受けている CPU は割り込みハンドラの処理以外何もできなくなります。
- プロセッサ情報の確認
#!/bin/bash
P_CPU=$(fgrep 'physical id' /proc/cpuinfo | sort -u | wc -l)
CORES=$(fgrep 'cpu cores' /proc/cpuinfo | sort -u | sed 's/.*: //')
L_PRC=$(fgrep 'processor' /proc/cpuinfo | wc -l)
H_TRD=$((L_PRC / P_CPU / CORES))
echo -n "${L_PRC} processer"
[ "${L_PRC}" -ne 1 ] && echo -n "s"
echo -n " = ${P_CPU} socket"
[ "${P_CPU}" -ne 1 ] && echo -n "s"
echo -n " x ${CORES} core"
[ "${CORES}" -ne 1 ] && echo -n "s"
echo -n " x ${H_TRD} thread"
[ "${H_TRD}" -ne 1 ] && echo -n "s"
echo
- プロセッサ使用率の確認
$ watch -n 1 'w && mpstat -P ALL'
CPU %usr %nice %sys %iowait %irq %soft %steal %guest %idle
all 31.73 0.00 1.47 0.13 0.00 0.96 0.06 0.00 65.64
0 70.41 0.00 5.10 0.00 0.00 15.31 0.00 0.00 9.18
1 68.04 0.00 3.09 0.00 0.00 0.00 0.00 0.00 28.87
2 53.06 0.00 3.06 0.00 0.00 0.00 0.00 0.00 43.88
3 47.47 0.00 2.02 0.00 0.00 0.00 1.01 0.00 49.49
4 49.45 0.00 1.10 0.00 0.00 0.00 0.00 0.00 49.45
5 44.33 0.00 2.06 0.00 0.00 0.00 0.00 0.00 53.61
6 38.61 0.00 2.97 0.99 0.00 0.00 0.00 0.00 57.43
7 32.63 0.00 1.05 0.00 0.00 0.00 0.00 0.00 66.32
8 29.90 0.00 1.03 1.03 0.00 0.00 0.00 0.00 68.04
9 10.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 90.00
10 8.08 0.00 1.01 0.00 0.00 0.00 0.00 0.00 90.91
11 6.12 0.00 0.00 0.00 0.00 0.00 0.00 0.00 93.88
12 10.00 0.00 2.00 0.00 0.00 0.00 0.00 0.00 88.00
13 11.00 0.00 1.00 0.00 0.00 0.00 0.00 0.00 88.00
14 17.71 0.00 0.00 0.00 0.00 0.00 0.00 0.00 82.29
15 11.22 0.00 1.02 0.00 0.00 0.00 0.00 0.00 87.76
こちらの例では、softirq(Software Interrupt ReQuest)に負荷が集中した結果、CPU0 は idle(CPU Idle time)率が他のコアと比較して低いことが分かります。
10 Gbps リンクにおいてワイヤーレートが向上する例では、CPU のクロック周波数が頭打ちになり、CPU 処理のうち割り込み処理の割合が大きくなっていきます。
近年のハイパースレッディング PC の場合、殆どはマルチコアを搭載しています。
複数のコアに対してランダムにハードウェア割り込みをかけると、パケットを並列処理するため TCP のような単一のストリームデータ内に存在するセグメントに順序保証が要求されるプロトコルでは、パケットの並べ直し(Reorder)が必要になります。
これは、TCP における Reordering 問題として広く知られています。
TCP Reordering を避けるため、カーネルは基本的に同じ CPU コアに対してハードウェア割り込みをかけます。
また、ソフトウェア割り込みはハードウェア割り込みを受けたコアと同じコアに割り込みをかけるため、ハードウェア割り込みハンドラでメモリアクセスした構造体をソフト割り込みハンドラにも引き継ぎます。(L1, L2 キャッシュの効率化)
その結果、特定の CPU にハードウェア割り込みとソフト割り込みが集中するため、上のように一部のコアだけがビジー状態になります。
RPS によるマルチコアスケーリングの利用
マルチコアスケールさせるため、RPS(Receive Packet Steering) と呼ばれるソフトウェアを使用します。(NIC 依存を避ける)
RPS はソフトウェア割り込みハンドラを使用してバッファからパケットを取得した後、プロトコル処理する前に他の CPU コアへフォワーディングします。
これで、ハードウェア割り込みがかかったコアとは別のコアがプロトコル処理できるようになり、受信パケットを並列に処理します。
RPS は、ソフトウェア割り込みを効率的に使用できるため、一回のハードウェア割り込みでも並列分散で処理することが可能になります。
-
rx-N
コアに対して RPS を適用させる場合は、以下のシェルを叩けば良い。
#!/bin/bash
target=[任意のNIC]
cpu_count=$(grep -c ^processor /proc/cpuinfo)
for ((cpu = 0; cpu < $cpu_count; cpu++)); do
echo "f" >"/sys/class/net/$target/queues/rx-$cpu/rps_cpus"
done
単一コアへの集中を回避した後、ユーザ空間アプリケーション(Raw ソケット以降)でマルチスレッド処理します。
こちらは、Go 言語を使用しているため、アプリケーションレベルの処理は Goroutine に頑張ってもらいます。
GRO(Off)+ RPS を適用する前と後でスループットを比較すると以下のようになります。
- GRO(OFF)+ RPS を適用前
### サーバサイド
[ ID] Interval Transfer Bitrate
[ 5] 0.00-1.00 sec 6.43 KBytes 52.6 Kbits/sec
[ 5] 1.00-2.00 sec 75.8 KBytes 621 Kbits/sec
[ 5] 2.00-3.00 sec 231 KBytes 1.89 Mbits/sec
[ 5] 3.00-4.00 sec 225 KBytes 1.84 Mbits/sec
[ 5] 4.00-5.00 sec 226 KBytes 1.85 Mbits/sec
[ 5] 5.00-6.00 sec 230 KBytes 1.88 Mbits/sec
[ 5] 6.00-7.00 sec 225 KBytes 1.84 Mbits/sec
[ 5] 7.00-8.00 sec 231 KBytes 1.90 Mbits/sec
[ 5] 8.00-9.00 sec 225 KBytes 1.84 Mbits/sec
[ 5] 9.00-10.00 sec 225 KBytes 1.84 Mbits/sec
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bitrate
[ 5] 0.00-10.00 sec 1.86 MBytes 1.56 Mbits/sec receiver
### クライアントサイド
[ ID] Interval Transfer Bitrate Retr Cwnd
[ 5] 0.00-2.00 sec 121 KBytes 495 Kbits/sec 46 2.57 KBytes
[ 5] 2.00-4.00 sec 463 KBytes 1.90 Mbits/sec 142 2.57 KBytes
[ 5] 4.00-6.00 sec 463 KBytes 1.90 Mbits/sec 142 2.57 KBytes
[ 5] 6.00-8.00 sec 463 KBytes 1.90 Mbits/sec 142 2.57 KBytes
[ 5] 8.00-10.00 sec 424 KBytes 1.74 Mbits/sec 140 2.57 KBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bitrate Retr
[ 5] 0.00-10.00 sec 1.89 MBytes 1.58 Mbits/sec 612 sender
[ 5] 0.00-10.00 sec 1.86 MBytes 1.56 Mbits/sec receiver
- GRO(OFF)+ RPS を適用後
### サーバサイド
[ ID] Interval Transfer Bitrate
[ 5] 0.00-1.00 sec 3.68 MBytes 30.9 Mbits/sec
[ 5] 1.00-2.00 sec 3.42 MBytes 28.7 Mbits/sec
[ 5] 2.00-3.00 sec 3.68 MBytes 30.8 Mbits/sec
[ 5] 3.00-4.00 sec 4.36 MBytes 36.6 Mbits/sec
[ 5] 4.00-5.00 sec 3.62 MBytes 30.4 Mbits/sec
[ 5] 5.00-6.00 sec 3.84 MBytes 32.2 Mbits/sec
[ 5] 6.00-7.00 sec 4.34 MBytes 36.4 Mbits/sec
[ 5] 7.00-8.00 sec 4.45 MBytes 37.4 Mbits/sec
[ 5] 8.00-9.00 sec 3.62 MBytes 30.4 Mbits/sec
[ 5] 9.00-10.00 sec 3.94 MBytes 33.1 Mbits/sec
[ 5] 10.00-10.33 sec 1.21 MBytes 31.1 Mbits/sec
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bitrate
[ 5] 0.00-10.33 sec 40.2 MBytes 32.6 Mbits/sec receiver
### クライアントサイド
[ ID] Interval Transfer Bitrate Retr Cwnd
[ 5] 0.00-2.00 sec 7.85 MBytes 32.9 Mbits/sec 9 191 KBytes
[ 5] 2.00-4.00 sec 7.99 MBytes 33.5 Mbits/sec 0 230 KBytes
[ 5] 4.00-6.00 sec 7.69 MBytes 32.2 Mbits/sec 0 236 KBytes
[ 5] 6.00-8.00 sec 7.69 MBytes 32.2 Mbits/sec 0 245 KBytes
[ 5] 8.00-10.00 sec 7.13 MBytes 29.9 Mbits/sec 0 266 KBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bitrate Retr
[ 5] 0.00-10.00 sec 38.4 MBytes 32.2 Mbits/sec 9 sender
[ 5] 0.00-10.07 sec 37.9 MBytes 31.6 Mbits/sec receiver
TCP スループットは 1.56 Mbps から 32.6 Mbps に約 20 倍 向上し、再送も殆ど発生しなくなりました 😇
おわりに
今回の記事では、ソケットプログラミングを用いてプロトコルを実装する場合において、通信スループットの改善策を考察・紹介しました。
ネットワークレベル、プロトコルレベルのチューニング・パフォーマンス改善は Web サービス開発と比較して非常に複雑です。
実装したアプリケーションに問題があるのか、カーネルに問題があるのか、ネットワーク環境・ルーティング経路のハードウェアボックスに問題があるのか、トラブルが発生した際に考えなければならないことが非常に多いからです。
今回は、NIC オフロードやマルチコアスケールに着目をした TCP のパフォーマンスチューニングについてまとめましたが、他にもネットワーク性能を向上させるスキームは多々存在するので、また良さげな方法を見つけたら紹介したいと思います。
参考
- RFC793 - Transmission Control Protocol
- RFC2581 - TCP Congestion Control
- Resolve IPv4 Fragmentation, MTU, MSS, and PMTUD Issues with GRE and IPsec
- Queueing in the Linux Network Stack
- Scaling in the Linux Networking Stack
- Receive Packet Steering (RPS) - Red Hat Enterprise Linux 6 Performance Tuning Guide
- SMP IRQ affinity
- Linux packet-forwarding
- Linux サーバの TCP ネットワークのパフォーマンスを決定するカーネルパラメータ
- Linux でロードバランサやキャッシュサーバをマルチコアスケールさせるためのカーネルチューニング