BLOG

Socket migration for SO_REUSEPORT (Part 1)

TCP ソケットと `SO_REUSEPORT` オプションに関する問題を解決するために Linux カーネル v5.14 から取り込まれる予定のパッチセットについて 2 回に分けて解説します。 - https://lore.kernel.org/bpf/20210612123224.12525-1-kuniyu@amazon.co.jp/ - https://git.kernel.org/pub/scm/linux/kernel/git/bpf/bpf-next.git/commit/?id=1f26622b791b6a1b346d1dfd9d04450e20af0f41 Part 1 では `SO_REUSEPORT` オプション、カーネルの挙動と問題点、パッチセットの効果について解説し、 Part 2 ではカーネルの実装と修正方法、追加した eBPF の機能について解説します。 ## SO_REUSEPORT とは 従来、 Linux では一つの IP アドレス・ポート番号の組に対する TCP コネクションは一つのソケットのみで待ち受けることができました。トラフィックの多いサーバーでは単一のソケットに対する `accept()` がボトルネックになるため、 [v3.9 から SO_REUSEPORT というオプションが導入されました](https://git.kernel.org/pub/scm/linux/kernel/git/netdev/net-next.git/commit/?id=c617f398edd4db2b8567a28e899a88f8f574798d)。 `SO_REUSEPORT` を有効した複数のソケットは同一の IP アドレス・ポート番号への通信を待ち受けることが可能なため、複数のプロセスから異なるソケットに対して `accept()` を呼び出せるようになり、ボトルネックが解消されました。 まずは簡単な Python のスクリプトを実行して `SO_REUSEPORT` の効果を確認してみましょう。 ### クイズ 1 以下のスクリプト (`quiz1.py`) を実行した時、何が出力されるでしょうか。少しだけスクロールを止めて考えてみてください。 ``` import socket ADDR = '127.0.0.1' PORT = 8000 def get_server(): s = socket.socket() s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) s.bind((ADDR, PORT)) s.listen(32) return s def get_client(): c = socket.socket() c.connect((ADDR, PORT)) return c def main(): server_1 = get_server() server_2 = get_server() client = get_client() client.send(b'Hello World') server_2.settimeout(3) child = server_2.accept()[0] print(child.recv(1024)) if __name__ == '__main__': main() ``` #### 正解 何度かスクリプトを実行すると、おおよそ同じ頻度で 2 つの出力が確認できます。 `client` からのコネクションを `server_2` から `accept()` できた場合は以下のように表示され、 ``` $ python3 quiz1.py b'Hello World' ``` `accept()` できない場合はタイムアウトが発生して以下のように出力されます。 ``` $ python3 quiz1.py Traceback (most recent call last): File "quiz1.py", line 30, in <module> main() File "quiz1.py", line 26, in main child = server_2.accept()[0] File "/usr/lib64/python3.7/socket.py", line 212, in accept fd, addr = self._accept() socket.timeout: timed out ``` ### クイズ 2 では以下のスクリプト (`quiz2.py`) を実行した時、何が出力されるでしょうか。今回は `server_2` を作成する前に `client` が `connect()` を呼び出しており、 `server_2` の作成後に `server_1` が `close()` されている点に注意してください。 ``` import socket ADDR = '127.0.0.1' PORT = 8000 def get_server(): s = socket.socket() s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) s.bind((ADDR, PORT)) s.listen(32) return s def get_client(): c = socket.socket() c.connect((ADDR, PORT)) return c def main(): server_1 = get_server() client = get_client() client.send(b'Hello World') server_2 = get_server() server_1.close() server_2.settimeout(3) child = server_2.accept()[0] print(child.recv(1024)) if __name__ == '__main__': main() ``` #### 正解 今度は何度実行しても必ずタイムアウトが発生します。 ``` $ python3 quiz2.py Traceback (most recent call last): File "quiz2.py", line 33, in <module> main() File "quiz2.py", line 29, in main child = server_2.accept()[0] File "/usr/lib64/python3.7/socket.py", line 212, in accept fd, addr = self._accept() socket.timeout: timed out ``` #### 不正解...? 決して以下のように出力されることはありません。 ``` $ python3 quiz2.py b'Hello World' ``` ### クイズ 3 最後のクイズです。以下のスクリプト (`quiz3.py`) を実行した際、何が出力されるでしょうか。 `drop_ack(True)` は 3-way handshake (3WHS) の最後の ACK パケットのみを破棄します。したがってクライアントの視点では 3WHS が即座に完了するものの、サーバーの視点では 3WHS を完了する前に `server_1` が `close()` される点に注意してください。 ``` import socket import subprocess ADDR = '127.0.0.1' PORT = 80 def get_server(): s = socket.socket() s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) s.bind((ADDR, PORT)) s.listen(32) return s def get_client(): c = socket.socket() c.connect((ADDR, PORT)) return c def drop_ack(add=True): subprocess.run( 'iptables -{} INPUT -d {} -p tcp --dport {} ' '--tcp-flags SYN,ACK ACK -j DROP'.format( 'A' if add else 'D', ADDR, PORT ).split(" ") ) def main(): s1 = get_server() drop_ack(True) c1 = get_client() c1.send(b'Hello World') s2 = get_server() s1.close() drop_ack(False) s2.settimeout(3) c2 = s2.accept()[0] print(c2.recv(1024)) if __name__ == '__main__': main() ``` #### 正解 今回も必ずタイムアウトが発生します。 ``` $ sudo python3 quiz3.py Traceback (most recent call last): File "quiz3.py", line 48, in <module> main() File "quiz3.py", line 44, in main child = server_2.accept()[0] File "/usr/lib64/python3.7/socket.py", line 212, in accept fd, addr = self._accept() socket.timeout: timed out ``` #### 不正解...? クイズ 2 と同じく、このように出力されることはありません。 ``` $ sudo python3 quiz3.py b'Hello World' ``` ## カーネルの挙動と問題点 クイズ 1 では `accept()` に成功・失敗する 2 通りの結果が得られました。これよりカーネルは `SO_REUSEPORT` が有効なソケットに対してコネクションをランダムに割り当てることが分かります。 `SO_REUSEPORT` が有効なソケットは `listen()` した時点でコネクションを割り当てる対象になり、 `close()` (または `shutdown()`) するとその対象から外れます。このおかげで単純にプロセスを増減させれば、簡単にサーバーをスケールさせることができます。また設定変更などが生じた際は、新しいプロセスを起動して古いプロセスを停止することで、簡単にリロードを行えます。 その一方、[オプション導入当時の LWN.net の記事](https://lwn.net/Articles/542629/)からも分かるように、コネクションを特定のソケットに割り当てる挙動によって問題が発生することが知られています。この問題は 2 つに分類でき、クイズ 2 と 3 でそれぞれを簡単にシミュレートしています。そしてそのいずれもがサーバーのリロードは実際は単純に古いソケットを `close()` するだけでは上手くいかないことを示しています。 ### accept() できるコネクション クイズ 2 では `connect()` を呼び出した時点でサーバーのソケットは `server_1` のみが存在するため、カーネルはコネクションを `server_1` に割り当てます。しかし、 `server_1` を `close()` すると RST が送信されてこのコネクションは破棄されてしまいます。 ``` $ sudo tcpdump -i lo -nn -t ... IP 127.0.0.1.55042 > 127.0.0.1.8000: Flags [S], seq 2468297767, win 65495, options [mss 65495,sackOK,TS val 2200745635 ecr 0,nop,wscale 7], length 0 IP 127.0.0.1.8000 > 127.0.0.1.55042: Flags [S.], seq 2618403070, ack 2468297768, win 65483, options [mss 65495,sackOK,TS val 2200745635 ecr 2200745635,nop,wscale 7], length 0 IP 127.0.0.1.55042 > 127.0.0.1.8000: Flags [.], ack 1, win 512, options [nop,nop,TS val 2200745635 ecr 2200745635], length 0 IP 127.0.0.1.55042 > 127.0.0.1.8000: Flags [P.], seq 1:12, ack 1, win 512, options [nop,nop,TS val 2200745635 ecr 2200745635], length 11 IP 127.0.0.1.8000 > 127.0.0.1.55042: Flags [.], ack 12, win 512, options [nop,nop,TS val 2200745635 ecr 2200745635], length 0 IP 127.0.0.1.8000 > 127.0.0.1.55042: Flags [R.], seq 1, ack 12, win 512, options [nop,nop,TS val 2200745635 ecr 2200745635], length 0 ``` ある程度ソケットプログラミングに馴染みのあるユーザーであればこの点を不思議に思うことはないかもしれません。詳しくは Part 2 で説明しますが、カーネルは 3WHS が完了したコネクションをサーバーのソケットの accept キューに入れます。そのソケットを `close()` すると、それに伴って accept キューも破棄されるため、 accept キューに入ったコネクションも同様に破棄されます。 この問題を回避するには、サーバーのソケットを `close()` する前に、失敗するまで `accept()` を繰り返し実行する必要があります。いわゆる Connection Draining です。 ### accept() できないコネクション クイズ 3 でも `connect()` を呼び出した時点ではサーバーのソケットは `server_1` のみが存在し、最終的には `server_1` の `close()` によって RST が送信されます。クイズ 2 と違うのは 3WHS が完了する前に `server_1` が `close()` される点です。 ``` $ sudo tcpdump -i lo -nn -t ... IP 127.0.0.1.55044 > 127.0.0.1.8000: Flags [S], seq 2794889430, win 65495, options [mss 65495,sackOK,TS val 2200808677 ecr 0,nop,wscale 7], length 0 IP 127.0.0.1.8000 > 127.0.0.1.55044: Flags [S.], seq 3423506731, ack 2794889431, win 65483, options [mss 65495,sackOK,TS val 2200808677 ecr 2200808677,nop,wscale 7], length 0 IP 127.0.0.1.55044 > 127.0.0.1.8000: Flags [.], ack 1, win 512, options [nop,nop,TS val 2200808677 ecr 2200808677], length 0 IP 127.0.0.1.55044 > 127.0.0.1.8000: Flags [P.], seq 1:12, ack 1, win 512, options [nop,nop,TS val 2200808677 ecr 2200808677], length 11 IP 127.0.0.1.55044 > 127.0.0.1.8000: Flags [P.], seq 1:12, ack 1, win 512, options [nop,nop,TS val 2200808882 ecr 2200808677], length 11 IP 127.0.0.1.8000 > 127.0.0.1.55044: Flags [R], seq 3423506732, win 0, length 0 ``` クイズ 2 から `close()` の前に `accept()` を繰り返し実行する必要があると学びました。しかし、実際に試してみると上手くいかないことがわかるでしょう。 `server_1` を `close()` していた時点では 3WHS が完了しておらず、 accept キューにコネクションがないためです。 これより、カーネルは SYN を受信した時点でコネクションを特定のソケットに割り当てていることが分かります。基本的にこの挙動はユーザースペースで認知できないため、 3WHS を完了していないコネクションはユーザーが知らないうちに破棄される可能性があります。 [v4.6 で追加された eBPF の機能](https://git.kernel.org/pub/scm/linux/kernel/git/netdev/net-next.git/commit/?id=fd1914b2901badd942f008ce57bf4a938d29fde4)を利用すればこの問題は回避できます。これも詳しくは Part 2 で扱いますが、 eBPF のプログラムを `SO_REUSEPORT` が有効なソケットにアタッチすると、コネクションを割り当てるソケットを選択できるようになります。逆に eBPF で特定のソケットにコネクションを割り当てないようにすることもできます。したがって、以下のように eBPF を利用して Connection Draining を実装することでこの問題を回避できます。 - eBPF でコネクションを割り当てる際にカウンタをインクリメントする - eBPF でコネクションを割り当てないようにする - カウンタの数まで `accept()` を繰り返す、または、全てのタイマーが切れるまで `accept()` を繰り返す ## 解決策 クイズ 2 と 3 ではいずれも `server_1` を `close()` する時点で `server_2` が存在します。同じ IP アドレスとポート番号へのコネクションを扱える `server_2` であれば破棄されるコネクションを `accept()` できるはずです。つまり、 `server_1` に一度割り当てられたコネクションを `server_2` に移植できれば問題は解決します。 同様の議論は過去にも[メーリングリスト](https://lore.kernel.org/netdev/1443313848-751-1-git-send-email-tolga.ceylan@gmail.com/)で行われていました。[ちょうどこの時期に eBPF で Connection Draining が可能になった](https://lore.kernel.org/netdev/1450492683.8474.123.camel@edumazet-glaptop2.roam.corp.google.com/)こともあり、 [TCP](https://lore.kernel.org/netdev/1458830744.10868.72.camel@edumazet-glaptop3.roam.corp.google.com/) と [eBPF](https://lore.kernel.org/netdev/20160325162114.GA72479@ast-mbp.thefacebook.com/) で改修の難しさが割りに合わないため、 [eBPF による Connection Draining が推奨されていました](https://lore.kernel.org/netdev/1458925242.6473.41.camel@edumazet-glaptop3.roam.corp.google.com/)。 ただし Connection Draining にも欠点があり、セキュリティなどの理由から即座にプロセスを置き換えたい場合に問題になります。例えば Connection Draining した HTTP リクエストが WebSocket に Upgrade した場合、終了したいプロセスが想定以上に長く動作し、結局プロセスを強制終了してコネクションを破棄する必要が出てくるかもしれません。もし Connection Draining を行わずに新しいプロセスに移植できていれば、このコネクションは破棄されなかったかもしれません。コネクションを移植したところで同じ問題が起きる可能性はありますが、 Connection Draining で破棄されるコネクションを減らすことはできるでしょう。 ### net.ipv4.tcp_migrate_req さて、いよいよパッチセットのお話です。 v5.14 からは新しい sysctl ノブ `net.ipv4.tcp_migrate_req` が追加されます。これを有効にするとカーネルが本来コネクションを破棄していたタイミングで、同じ IP アドレスとポート番号への通信を待ち受けるソケットをランダムに選択してコネクションを移植します。つまり、クイズ 2 と 3 で*真に*正しい結果を取得できるようになります。 ``` $ sudo sysctl -w net.ipv4.tcp_migrate_req=1 net.ipv4.tcp_migrate_req = 1 $ python3 quiz2.py b'Hello World' $ sudo python3 quiz3.py b'Hello World' ``` 一見素晴らしい機能ですが、もちろん注意すべき点があります。それが `net.ipv4.tcp_migrate_req` のデフォルト値を `0` にした理由です。 最終的に `accept()` で取得するソケット (クイズ中の `child`) で使用できる機能は、コネクションが最初に割り当てられたサーバーのソケットに依存するため、そこに差異が生じると思わぬエラーが発生するかもしれません。例えばサーバーのソケットに `setsockopt()` で `TCP_SAVE_SYN` を設定すると、割り当てられたコネクションの SYN のデータをカーネルが内部的に保持します。保持されたデータは `accept()` したソケットに対して `getsockopt()` で `TCP_SAVED_SYN` を指定することで取得できます。もし新しいプロセスでのみ `TCP_SAVE_SYN` が有効になっていた場合、古いプロセスから移植されてきたコネクションに対して `TCP_SAVED_SYN` でデータの取得を試みるも失敗するでしょう。 このように移植元と移植先のソケットで `setsockopt()` レベルの差異がある場合や、そもそも違うプロセス (nginx と squid など) を同じポートで動作させて eBPF でコネクションを振り分けている場合などは、[別途追加した eBPF の機能](https://git.kernel.org/pub/scm/linux/kernel/git/bpf/bpf-next.git/commit/?id=d5e4ddaeb6ab2c3c7fbb7b247a6d34bb0b18d87e)で移植先を選択するようにしてください。詳しくは Part 2 で扱います。 ## その他の回避策 最後に Connection Draining 以外の二つの回避策を紹介します。 1 つ目は Unix ドメインソケットで `SCM_RIGHTS` を使用する fd passing です。サーバーのソケットをプロセス間で引き継げばそもそも `close()` を呼び出すことはありません。ただし[ユースケース次第でそのロジックは複雑になる](https://lore.kernel.org/bpf/20210428012734.cbzie3ihf6fbx5kp@kafai-mbp.dhcp.thefacebook.com/)でしょう。 2 つ目が最も簡単な方法です。お気づきかも知れませんが、この問題の発生頻度はそこまで高くない上に、問題が発生する時間もとても短いです。 RST を受信するとクライアント側で `connect()` が失敗しますが、リトライで容易に問題は回避できます。実際、クライアント側が堅牢に実装されていれば私はこの問題に気づくことはなかったでしょう。 ## おわりに Part 1 では eBPF が必要となる特殊なユースケースを除いて、ユーザースペースで知っておくべき内容は概ね解説できたと思います。実際に使ってみてもしバグを発見した場合は `netdev@vger.kernel.org` と `bpf@vger.kernel.org` を CC に入れて私にメールを送って頂ければ幸いです。