How to stop running out of ephemeral ports and start to love long-lived connections の副読本
を読んでいて理解できなかったところをまとめた記事です。
元記事の簡単な概要
エフェメラルポートが枯渇しないようにローカルポートの再利用をしたい。
TCP の普通のユースケースでは自動で再利用されるので困らない。
送信元アドレスを固定する場合はうまく再利用できなかったが 2015 年から IP_BIND_ADDRESS_NO_PORT が追加され問題なく再利用できるようになった。
送信元ポートも固定する場合は SO_REUSEADDR を使うことで再利用ができる。
ところが UDP ではローカルポートの再利用がうまく動かないし、TCP のような簡単な回避策はない。
いかにして UDP でローカルポートの再利用を達成するかの試行錯誤が書かれている。
詳細は元記事を参照。
元記事で理解できなかったところ
理解できなかったのは以下の2点です。
主に UDP についての箇所が理解できなかったのでそれを掘り下げていきます。
前提
この記事は linux 5.16.8 の時点の情報をもとに書かれています。
linux におけるソケットはユーザーランドからは socket という構造体が見えていますが、カーネルランドでは sock という構造体です。
sk という変数名で頻繁に登場します。
この記事で扱うソケットは主に AF_INET というアドレスファミリを持つソケットです。
IPv4 と IPv6 でも挙動が異なりますが、この記事では IPv4 を扱います。
UDP のソケットは udp_table という構造体で管理されています。
udp_table には hash, hash2 という2つのハッシュテーブルがあり2種類の検索を行うことができます。
hash はキーがローカルポートで, hash2 はキーがローカルポートとローカルアドレスになっています。
ハッシュテーブルのキーごとに存在するのがスロットで、スロットは連結リストでエントリを複数保持します。
udp_table におけるエントリは sock 構造体です。
bind の挙動
bind は IP_BIND_ADDRESS_NO_PORT (linux 4.2 以降) が設定されていなければ、その時点でローカルポートを確定します。
ローカルポートの確定方法はプロトコルによって異なります。
プロトコルによって異なる処理は sk_prot で抽象化されています。実態は tcp なら tcp_prot、udp なら udp_prot です。
ローカルポートの確定のための処理は get_port です。
UDP の場合の処理を追っていきます。
udp_prot の get_port は udp_v4_get_port という関数が指定されています。
ローカルポートが指定されていない場合はローカルポートレンジから使用中ではないローカルポートを取得します。
ローカルポートが指定されている場合はそのローカルポートが使用中かどうかを調べます。
使用中か調べるための関数は udp_lib_lport_inuse です。
udp_table の hash を用いることで同一ローカルポートを使っているソケットを列挙することができます。
hash からソケットのリストを走査し、
- ローカルポートが等しい
- どちらかが SO_REUSEADDR=1 を設定していない
- ローカルアドレスが等しい、またはどちらかがワイルドカードである
- どちらかが SO_BINDTODEVICE で明示的にバインドするデバイスが指定されている場合、同じデバイスがバインドされている
- どちらかが SO_REUSEPORT=1 を設定していない、またはソケットに紐づく uid が異なる
以上の条件をすべて満たすソケットが存在するときにそのローカルポートは使用中とみなします。
簡単に言うとローカルポート、ローカルアドレスが等しい、SO_REUSEADDR=1 も SO_REUSEPORT=1 も設定されていないソケットがある場合、そのローカルポートは使用中とみなされます。
指定したローカルポートが使用中でない、ローカルポートレンジから使用中ではないローカルポートが取得できた場合 bind は成功します。
成功した場合、udp_table の hash と hash2 の対応するスロットの 先頭 にエントリとしてソケットを追加します。
余談: SO_REUSEADDR or SO_REUSEPORT?
UDP における SO_REUSEADDR と SO_REUSEPORT の使い分けは異なるユーザでアドレスとポートを共有するかで異なるということです。
異なるユーザで共有することを想定してない場合は SO_REUSEPORT を使うのが望ましいでしょう。
つまり UDP で接続情報の衝突判定を行わなずに SO_REUSEADDR=1 か SO_REUSEPORT=1 を指定すると接続先が同一かつローカルポートが重複した場合に overshadowing が発生してしまうということです。
クライアントとして UDP を使う場合は SO_REUSEADDR, SO_REUSEPORT を指定するのは慎重になったほうが良いでしょう。(そもそも意味なくオプションを指定することはないと思いますが)
SOCK_DIAG_BY_FAMILY の挙動
https://man7.org/linux/man-pages/man7/sock_diag.7.html
man にはインターフェースは書かれていましたがどういう挙動をするのかわからなかったのでソースコードから調査しました。
netlink インターフェースに対して SOCK_DIAG_BY_FAMILY を要求したとき __sock_diag_cmd で処理が行われます。
アドレスファミリごとに処理をするハンドラが異なり、AF_INET の場合は inet_diag_handler が登録されています。
ハンドラの dump が実行されます、inet_diag_handler では inet_diag_handler_cmd が呼び出されます。
SOCK_DIAG_BY_FAMILY で NLM_F_DUMP が有効でない場合には inet_diag_cmd_exact で処理されます。
プロトコルごとに処理するハンドラが異なり、UDP の場合は udp_diag_handler が登録されています。
ハンドラの dump_one が実行されます、udp_diag_handler では udp_diag_dump_one が呼び出されます。
udp_diag_dump_one は udp_dump_one を呼び出すだけです。
udp_dump_one は AF_INET の場合 __udp4_lib_lookup でソケットの検索を行います。
MEMO:
ソースコードを読む人向けですが udp_dump_one に src and dst are swapped for historical reasons
というコメントがあり以降の関数では命名は変わらず用途が逆転します。注意してください。
これによって netlink インターフェースに SOCK_DIAG_BY_FAMILY を要求したときに TCP でも UDP でも src と dst を逆にしないと望んだ結果は得られません。
TCP の場合は inet_diag_find_one_icsk で後続の関数に渡すときに src と dst を入れ替えているので素直にソースコードを読むことができます。
__udp4_lib_lookup では udp4_lib_lookup2 関数を用います。
udp4_lib_lookup2 は udp_table の hash2 のローカルポートとローカルアドレスと紐づくスロットを走査し最もリクエストに一致する有効なソケットを返します。
有効で一致度が等しいソケットが複数存在する場合、走査順が早いソケットが優先されます。
以下の条件を一つでも当てはまるソケットを無効なソケットとして扱います。
- リクエストされた送信元ポートとソケットの送信元ポートが一致しない
- リクエストされた送信元アドレスとソケットの送信元アドレスが一致しない
- ソケットに送信先アドレスが存在し、リクエストされた送信先アドレスと一致しない
- ソケットに送信先ポートが存在し、リクエストされた送信先ポートと一致しない
- ソケットが特定のデバイスにバウンドされていていて、リクエストされたデバイスと一致しない
リクエストとソケットの一致度を計算する関数が net/ipv4/udp.c に存在する compute_score です。
- ソケットのアドレスファミリーが AF_INET である場合 2 点, そうでない場合 1 点
- ソケットに送信先アドレスが存在し、リクエストされた送信先アドレスと一致する場合 4 点
- ソケットに送信先ポートが存在し、リクエストされた送信先ポートと一致する場合 4 点
- ソケットが特定のデバイスにバウンドされている場合 4点
- ソケットに対して来たパケットを処理したCPUコアと現在のCPUコアが一致する場合 1 点
すべてを合計した結果が一致度として計算されます。
有効なソケットが存在しなかった場合、リクエストされた送信先アドレスをワイルドカードアドレスにして再度 udp4_lib_lookup2 を呼び出します。 得られたソケットの情報、SO_COOKIE の値を返します。
_connectx_udp の挙動
まずソケットを一意に識別できる値 SO_COOKIE を getsocketopt で取得します。
次にローカルアドレスとローカルポートを再利用するために setsocketopt で SO_REUSEADDR=1 にします。
そして bind を実行します、これは失敗する場合がありますが後述します。
他のソケットが同一のローカルアドレス、ローカルポートで bind できないように setsocketopt で SO_REUSEADDR=0 にします。
bind の挙動で説明した通り同一のローカルアドレス、ローカルポートで bind しているソケットに SO_REUSEADDR=0 のソケットが存在する場合、ローカルポートは使用中であるとみなされます。
つまりこれ以降、同一のローカルアドレス、ローカルポートへの bind が失敗するようになります。
通常では同一のローカルアドレス、ローカルポートではすべてのソケットが SO_REUSEADDR=1 か単一のソケットが SO_REUSEADDR=0 にしかなりませんがその前提が壊れます。
カーネル側から想定されていない状態ですが致命的な問題は発生してないので許容しています。
bind が成功して SO_REUSEADDR=0 にする前に複数のソケットが同一ローカルアドレス、ローカルポートで bind に成功している可能性があります。 _netlink_udp_lookup を呼び出して同一接続情報のソケットが存在するか確認します。
_netlink_udp_lookup は完全に一致するソケットがあればそのソケットの SO_COOKIE の値を返します。
完全に一致するソケットがない場合は同一送信元アドレス、同一送信元ポートのソケットで送信先アドレス、送信先ポートが未定のソケットの SO_COOKIE の値を返します。
複数存在する場合は走査順で早いソケットの SO_COOKIE の値を返します。
ソケットは bind が成功するたびにハッシュテーブルのスロットの先頭に追加されるので一番最後に bind が成功したソケットの SO_COOKIE の値になります。
_netlink_udp_lookup で返ってきた SO_COOKIE の値と最初に取得した SO_COOKIE を比較して一致してない場合に失敗とします。
完全に一致するソケットがある場合はすべてのソケットが失敗し、それ以外の場合だと最後に bind に成功したソケット以外は失敗します。
最後に bind に成功したソケットは connect を行います。
試行の成否に関わらずソケットは SO_REUSEADDR=1 に戻し、再度同一ローカルポート、ローカルアドレスの再利用ができる状態にします。
まとめると
一時的に SO_REUSEADDR=0 にすることでローカルアドレス、ローカルポート単位で bind を制限します。
新規に bind ができない区間では _netlink_udp_lookup の結果が一意になります。
ローカルアドレス、ローカルポート単位で同時に一つのソケットしか connect できないようになっています。
これによりローカルアドレスとローカルポートを再利用しつつ、同一接続情報のソケットが作られないようにできます。
SO_REUSEADDR をロックのように使って SOCK_DIAG_BY_FAMILY で衝突判定を行うという手法です。
余談: TCP でローカルポートが積極的に再利用されるのはなぜ?
TCP の場合だとローカルアドレス、ローカルポートの再利用が普通に行われます。
https://elixir.bootlin.com/linux/v5.16.8/source/include/net/inet_hashtables.h#L42
以上のコメントを読んで FTP に言及されており、TCP で最適化が進んだのは FTP があったからなのではと思いました。
個人の感想レベルなのでより正確な情報を知っている方は教えていただけると幸いです。
終わりに
これは会社の勉強会で話すために調査し、話した内容をまとめたものです。
議論を深めるきっかけをくれた勉強会の参加者の皆さんありがとうございました。