NginxでのeBPFとSO_REUSEPORTを使ったQUICコネクション受信処理

Jun-ya Kato
nttlabs
Published in
14 min readJul 26, 2021

はじめに

2021年7月12日にNgnixブログに掲載された記事 “Our Roadmap for QUIC and HTTP/3 Support in NGINX” では、QUICとHTTP/3機能を2021年末にはメインラインへマージする計画が言及されています。現在のHTTP3/QUIC対応Nginxは、専用の開発ブランチ (nginx-quic)で開発が進められていますが、常に最新のリリース (7月26日時点で1.21.1)を取り込んでおり、HTTP3/QUIC以外の最新機能も利用可能です。筆者も、昨年から開発ブランチの動作を試しており、HTTP3/QUICでの大きな負荷をかけても良好なパフォーマンスを示しています。

さて、Nginxブログで言及されたHTTP3/QUICに関する機能の一つとして、“eBPFを使ったマルチプロセスアーキテクチャ” という項目がありました。QUIC特有の仕様に対応するため、Linuxカーネル内のSO_REUSEPORTを拡張する追加機能をeBPFを使って挿入しています(図1)。Nginxのコードリーディングしたところ、興味深い仕組みでしたので記事にしてみます。

図1: QUIC対応のためLinuxカーネルへのeBPFによる機能の挿入

SO_REUSEPORT機能による負荷分散

eBPFによる追加機能の解説の前にSO_REUSEPORTについて解説します。SO_REUSEPORT機能はLinuxカーネル 3.9から実装されたL4ロードバランサです (FreeBSD 12.0-RELEASE以降のカーネルでは同等機能がSO_REUSEPORT_LBとして実装されています)。Nginxは、図2のようにマルチコアに分散させるため複数のワーカープロセスを立ち上げておき、Linuxカーネルに受け入れたTCPパケットを振り分けさせます。

図2: 到着するTCPパケットをSO_REUSEPORT機能が振り分ける様子

ワーカープロセスが開いたソケットでSO_REUSEPORT機能を使うと、異なるワーカープロセス間であっても443などの同一のポートに対して、bind() & listen()を発行して待ち受けができるようになります。通常は一つのポートを待ち受けるできるプロセスは一つだけで、他のワーカープロセスが待ち受けようとするとエラーEADDRINUSE が返されます。

カーネルにTCP接続要求が到着し3ウェイハンドシェイクが成立した後のSO_REUSEPORT機能の振り分けルールは、同じコネクションに属するデータは常に同じワーカープロセスとなるように設定されています。TCPコネクションを4つの情報 (宛先アドレス, 送信元アドレス, 宛先ポート, 送信元ポート)を使いフロー識別をおこないます。同一のTCPコネクションのパケットは常に同一のワーカープロセスへ転送されるように制御されており、本機能はUDPパケットの到着に対しても同様に作用します。

なお、NginxがSO_REUSEPORTを使うための機能は、2015年にNginxバージョン1.9.1で実装されたもので、当時はQUICではなくHTTP(S) over TCPを処理するためのものでした。

QUIC/UDPへのSO_REUSEPORT適用時の問題点

TCP/UDPにおけるコネクション (フロー)の概念と、QUICでのコネクションの概念の違いが問題となります。SO_REUSEPORT機能はUDPパケットに対しても、TCPと同じく宛先アドレス, 送信元アドレス, 宛先ポート, 送信元ポートの4つの情報をもとにフローを識別することは前述しました。TCP/UDPとQUICの大きな違いは、QUICではコネクションをコネクションIDと呼ぶ識別子で区別するです。しかもQUICの下層にあるUDPレイヤの宛先アドレス, 送信元アドレス, 宛先ポート, 送信元ポートの4つの情報は可変であり、いずれかが変化してもQUICコネクションを維持することが可能です。QUICの特徴的な機能であるコネクションマイグレーションや0-RTTが実現しています。

逆に、SO_REUSEPORT機能の立場から見ると、QUICコネクションは同一なのに、UDPレイヤの4つの情報は予告なく変化する可能性があると言えます。ひとたび変化すると新しいフローが現れたと解釈し、転送先のワーカープロセスも変化するでしょう。新たな転送先のワーカープロセスは元のワーカープロセスからコネクション状態のマイグレーションを受けていない限り接続を維持することができません。

eBPFを使ってLinuxカーネル内にQUICコネクションを識別する機能を挿入する

Linuxカーネルが持つSO_REUSEPORT機能に対して、QUICコネクションを識別して振り分ける機能をeBPFを使って挿入します。識別機能をもう少し詳細に見ていきます。要点は、QUICコネクションを受け入れるネゴシエーションの際に、

“コネクションID” ⇔ “そのコネクションを担当するワーカープロセス”

の対応関係(※1)を、eBPF map (ユーザメモリ上に確保されたKVS)に記録しておくことです。ネゴシエーション完了した後に到着するQUIC/UDPパケットに対しては、コネクションIDをキーとしてmapを検索すれば、そのコネクションを担当していたワーカープロセスを知ることができます。

(※1)正確にはコネクションIDの先頭8バイトと、ワーカープロセスにつながるソケットのディスクリプタを対応付けます。

振り分けルール設定の詳細

説明を単純化するため典型的なQUICコネクションの初期接続のシーケンスを例に取り、eBPFを使いSO_REUSEPORT機能を拡張する様子を、図3を用いて見ていきます。図3において、Nginxが動作するホストはLinuxカーネルとワーカ(ユーザプロセス)を分けて書いてあります。

図3: コネクション確立処理とコネクションIDを使った振り分けの様子

シーケンス①について

クライアントからNginxに初期接続のInitialが届きます。クライアントは、QUICパケットのDCID (Destination Connection ID)にランダム値を、SCID (Source Connection ID) にはクライアント自身を示すコネクションIDを設定してInitialを送信してきます。このランダム値はクライアントが生成したデタラメな値でNginxは予測することができません。

次に、SO_REUSEPORT機能はeBPF mapに対して、DCIDの先頭8バイトをキーとして転送先ワーカープロセスを探す検索を行います。この段階ではDCIDはデタラメな値なので、eBPF mapに転送先は記録されておらず結果なしが返ってきます。SO_REUSEPORT機能はデフォルトルール(※2)で転送先ワーカープロセスXを決め、ソケット-Xへデータを転送します。

(※2)このときは、送信元アドレスやポートなどのUDPレイヤの情報を使って振り分けます。

ワーカープロセスXはソケットからデータをrecv() してInitialを受信することで、QUICコネクション確立処理を開始します。確立処理の過程でNginx側を表す20バイトのコネクションIDの生成しますが、そのうち先頭の8バイトにはソケットに紐付けられたクッキーSO_COOKIEの値 C_XYZ(※3)を書き込んでおきます(図3の★部分)。残り12バイトはランダム値です。生成と同時に図3の★部分の直後において、

C_XYZ ⇔ ソケット-X

の対応関係をeBPF mapにしまっておきます。

(※3)C_XYZはソケットに対して一意の値で、getsockopt(sockfd, SOL_SOCKET, SO_COOKIE, &cookie, &len)を発行することで得られます。

シーケンス②について

Nginxのワーカープロセス上でコネクション確立処理が完了したら、上記で生成したC_XYZを含むコネクションIDを、QUICパケットのSCIDに設定してクライアントに通知します。

シーケンス③について

シーケンス③の段階では、クライアントから送られてくるQUICパケットのDCIDにはNginx側で決めた値が設定されています。さらに、この時点ではeBPF mapに、DCIDの先頭8バイトのC_XYZのキーに対して、値(ソケット-X)が格納済みです。mapをC_XYZで検索するとワーカープロセスXにつながるソケット-Xを得ることができます。SO_REUSEPORT機能はUDPレイヤの情報を参照せずに(言い換えるとUDPレイヤの情報がどのように変化しても)、QUICコネクションを識別することができ、適切なワーカープロセスへのデータを転送を継続できます。

まとめ

QUICとTCP/UDPではコネクション (フロー)を識別する概念が大きく異なります。Nginxが実装しているQUICコネクションの識別方法と、Linuxカーネル内にSO_REUSEPORT機能をeBPFを使って拡張する様子について解説しました。

付録には二点の補足を記載していますので、あわせてご参照ください。

  1. QUICコネクションの識別にコネクションIDを使いましたが、注意すべき点を補足します
  2. 図3のシーケンスに対応する、Nginxのコードにコメントをつけたものを掲載しておきます。

参考にした資料

付録1. コネクションIDとミドルボックス

本記事ではコネクションIDが変化する状況について言及しませんでしたが、不変の値ではなくエンドホスト間のネゴシエーションによって動的に変更されます。本記事が対象としたNginxは、QUICコネクションを終端するホストであり、ワーカープロセス自身がコネクションIDを変更する主体なので、正しく実装すれば問題は生じません。

しかしホストではないミドルボックスでは、ネゴシエーション中身を覗くことはできません。ミドルボックスが新しいコネクションIDを観測しても、変更された過程を知ることはできないため、コネクション追跡はできません。FW, IDS, IPS, DPI, NAT等のミドルボックスがコネクションIDを使ってQUICパケットを制御することは難しそうです。

付録2. Nginxコード断片へのコメント

図3 “★C_XYZを含むコネクションIDを生成” の部分

nginx-quic/src/event/quic/ngx_event_quic_connid.c/*
* 図3 "★C_XYZを含むコネクションIDを生成" に該当する処理
*/
ngx_int_t
ngx_quic_create_server_id(ngx_connection_t *c, u_char *id)
{
/*
* 20バイトのサーバ(Nginx)自身を指すコネクションIDをランダムに作る
*/

if (RAND_bytes(id, NGX_QUIC_SERVER_CID_LEN) != 1) {
return NGX_ERROR;
}
#if (NGX_QUIC_BPF)
/*
* ngx_quic_bpf_attach_id()の内部でコネクションIDの先頭8バイトは
* ソケットのクッキー (SO_COOKIE)で置き換えられる
*/
if (ngx_quic_bpf_attach_id(c, id) != NGX_OK) {
ngx_log_error(NGX_LOG_ERR, c->log, 0,
"quic bpf failed to generate socket key");
/* ignore error, things still may work */
}
#endif
return NGX_OK;
}
#if (NGX_QUIC_BPF)static ngx_int_t
ngx_quic_bpf_attach_id(ngx_connection_t *c, u_char *id)
{
.
.
/*
* getsockopt()で得たクッキー (SO_COOKIE)の値を得る (cooike)
*/
if (getsockopt(fd, SOL_SOCKET, SO_COOKIE, &cookie, &optlen) == -1) {
....
}
/* cooikeをDCIDの先頭8バイトの位置に書き込む
* idは20バイトのコネクションIDで、頭8バイト部分を上書きする
*/
ngx_quic_dcid_encode_key(id, cookie);
return NGX_OK;
}
#endif

図3 C_XYZとソケット-Xの対応関係をmapに格納する部分

nginx-quic/src/event/quic/ngx_event_quic_bpf.c/*
* クライアントからの Initial を処理する場面で呼ばれる
*/

static ngx_int_t
ngx_quic_bpf_group_add_socket(ngx_cycle_t *cycle, ngx_listening_t *ls)
{
uint64_t cookie;
.
.
/*
* ワーカープロセスにつながるソケット(ls->fd)に対応する
* クッキー (SO_COOKIE) を cookie 変数へ取得する。内部で
* getsockopt(ls->fd, SOL_SOCKET, SO_COOKIE, &cookie, &len)
* が呼ばれている
*/
cookie = ngx_quic_bpf_socket_key(ls->fd, cycle->log);
if (cookie == (uint64_t) NGX_ERROR) {
return NGX_ERROR;
}
/*
* eBPF map に ソケット(ls->fd)とクッキー(cookie)の対応関係を書き込む
*/
/* map[cookie] = socket; for use in kernel helper */
if (ngx_bpf_map_update(grp->map_fd, &cookie, &ls->fd, BPF_ANY) == -1) {
...
}
.
.
}

図3 Linuxカーネル内でDCID(先頭8バイト)をキーに転送先を検索する処理

下記のコードは、Nginxのワーカープロセス(アプリケーション)として実行されるプログラムではありません。eBPFバイトコードにコンパイルされ、Linuxカーネル内に挿入される機能であることに注意してください。

nginx-quic/src/event/quic/bpf/ngx_quic_reuseport_helper.cSEC(PROGNAME)
int ngx_quic_select_socket_by_dcid(struct sk_reuseport_md *ctx)
{
.
.
/*
* data[1] が到着したQUICパケットのDCIDを指し
* 示すように(uint64_t *)型のポインタが進められている
*/
dcid = &data[1];
.
.
/*
* DCIDの先頭8バイトを切り出してキーとする
*/
key = ngx_quic_parse_uint64(dcid);
.
.
/*
* ngx_quic_sockmap は "key" : "ワーカープロセスへつながるソケット"
* 対応関係を保持するマップ。bpf_sk_select_reuseport()で、keyを使っ
* mapからソケットを検索し、存在すればそのソケットの先にあるワーカープロ
   * セス)へデータが転送される
*/
rc = bpf_sk_select_reuseport(ctx, &ngx_quic_sockmap, &key, 0);
.
.

}

さいごに

NTT研究所はハイパフォーマンスなWebサービスに関心のある仲間を募集中です。ご連絡をお待ちしています。

--

--