地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【Linux】SO_REUSEPORTに入門してGracefulなrestartを目指す

ジムのマッサージ機に乗りながらsocket(7)のmanを読んでいたらSO_REUSEPORTというオプションがあって気になって調べたのでメモ

SO_REUSEPORT (Linux 3.9 以降)

Permits multiple AF_INET or AF_INET6 sockets to be bound to an identical socket address. This option must be set on each socket (including the first socket) prior to calling bind(2) on the socket. To prevent port hijacking, all of the processes binding to the same address must have the same effective UID. This option can be employed with both TCP and UDP sockets.

Man page of SOCKET

ざっくりいうとSO_REUSEPORTは同一ポートに複数のlisten ソケットが bind できるようになるというものらしい。accept(2)の負荷分散の改善だったりの話題が記載されていた。この問題自体は単一のソケットに対して複数プロセスがepoll(2)してるケースで通信可能になったときに全てのプロセスが起こされてaccept(2)しにいってしまうという問題。SO_REUSEPORTはこれを解決するよ的な話が書かれていた。

WebサーバでのThundering Herdは過去の話? | monolithic kernel

このレイヤの性能改善の話は正直難しくてまだよくわかっていないが機能の説明だけ見ると自作デーモンとかをサクッとGraceful-restartに対応させるのに便利なんじゃないかと思ったのでそっちの方向でみていく。

Graceful-restartとは

ググるとITに関わらず色々な世界で使われる単語のようなのでこの記事では「TCPサーバとして動いているプロセスをダウンタイム無しでプログラムを入れ替える」と定義して進める。

ダウンタイムとは

以下のような簡易tcpサーバを立てる。

#!/usr/bin/env python
import socket

def main():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("0.0.0.0", 80))
    s.listen(5)

    while True:
        conn, addr = s.accept()
        print("accepted!")
        ret = conn.recv(16)
        conn.close()


if __name__ == '__main__':
    main()

このときプロセスの内容を更新したいとなった場合に取る手順は以下。

  • ① プロセスの終了をするためにシグナルを送る
  • ② プロセスの終了を待つ
  • ③ プロセスを起動させる

②と③の間はコネクションを受けれない。この時間がダウンタイムとなる。最小手順にしてるのでコードの更新やプロセスの初期化処理(cofigパースとかコネクションプール生成とか)といった時間が加算される。

どうするのが良いのか

  • 複数サーバいて前段にロードバランサがいるならdrain機能とかあるならそこでリクエストが来ないよう止めて安全に再起動させちゃう
  • そういった機能が搭載されたミドルウェアを使う(Server::Starterはとてもとてもすごく便利だった。。)

みたいな選択肢がありそう。ただ上で書いたサンプルが既に稼働していてLBも入れれない&ミドルウェア導入も無理!みたいな場合にサンプルコードに実装するなら以下のようになるイメージ(実装イメージであって動くは不明。。)

① Graceful-restartを行う用のシグナルハンドラを設定する
② シグナルを受けたらfork(2)を実行し新しいコードを読み込んだ子プロセスを生成する(親プロセスのlisten socketも子プロセスが引き継ぐ)
③ 親プロセスはaccept(2)を停止し子プロセスのみでaccept(2)を実行する
④ 親プロセスはシグナルなり正常終了のパスを作るなりして終了する

なんか大変そう

なんで? <- 同一ポートに複数のlisten ソケットが bindできないから

で、やっと登場するのが「SO_REUSEPORT」!!

もう一回貼っておく。SO_REUSEPORTがあれば複数プロセスがbindできるので複数プロセスが立ち上げることができるという仕組み。指定しないとOSError: [Errno 98] Address already in useという何回見たかわからないエラーとなってしまう。

SO_REUSEPORT (Linux 3.9 以降)

Permits multiple AF_INET or AF_INET6 sockets to be bound to an identical socket address. This option must be set on each socket (including the first socket) prior to calling bind(2) on the socket. To prevent port hijacking, all of the processes binding to the same address must have the same effective UID. This option can be employed with both TCP and UDP sockets.

Man page of SOCKET

ソースは一行修正で対応することが可能。複数プロセスであることが分かりやすいようにpidも出すように修正してみる。

--- s.py 2021-08-25 15:18:32.046623000 +0000
+++ s2.py 2021-08-25 15:18:45.729623000 +0000
@@ -1,16 +1,15 @@
 #!/usr/bin/env python
-import socket,os
+import socket

     s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
     s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

-        print(f"accepted!: {os.getpid()}")
+        print("accepted!")
         ret = conn.recv(16)
         conn.close()

実践

2コンソールからプロセスを起動する。どちらも起動できてる時点でオプションが有効化されていることがわかる。ローカルからncを複数回打つことでそれぞれでacceptが成功していることがわかった。便利!プロセス1をアップデートするために停止してもプロセス2がacceptし続けるのでダウンタイム0でコードを更新させることが可能ということがわかった。(ただしshutdownをGracefulにする必要はある)

# コンソール1
[root@c2cdb3a47b61 ]# python3 s.py
accepted!: 241

# コンソール2
[root@c2cdb3a47b61 ]# python3 s.py
accepted!: 242

straceも貼っておく。setsockopt(2)でSO_REUSEPORTを指定してあることが確認できた。

socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 3
setsockopt(3, SOL_SOCKET, SO_REUSEPORT, [1], 4) = 0
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
listen(3, 5)                            = 0

実験していて気づいたこと(未解決。。😭)

今回の実験をやってて気になったのが1点あった。3way handshake済かつaccept待ちの状態のコネクションがある場合にプロセスを終了するとクライアントにFINが飛ぶこと。

ざっくりやったこと

① ソースを修正してコネクションがaccept待ちになるようにする
② プロセスを2つ起動する。ncを使ってconnect(2)を発行。プロセス1、プロセス2と呼ぶ
③ ssコマンドを使ってプロセス1がaccept(2)待ちになってることを確認
④ プロセス1を終了させる。tcpdumpでクライアントにfinが来てることを確認

Linuxをわかってないポイントなんだろうけど個人的にはプロセス2が代わりにaccept(2)してくれたりしないのかなと思っていた。acceptキューはどうやらソケットごとに持つらしい。なんかの記事で1本化もするよみたいな話題を見た気がしたが違ったらしい。やるならGraceful-shutdownを実装してNONBLOCKINGで呼び出し、加えてタイムアウト機能とかも必要になりそう。

以下は作業時のメモログ。

# 2プロセスあげた時のss
State       Recv-Q Send-Q     Local Address:Port                    Peer Address:Port
LISTEN      0      5                      *:80                                 *:*
LISTEN      0      5                      *:80                                 *:*

# accept待ちにした時のss。Recv-Qのカウントが上がっている
State       Recv-Q Send-Q     Local Address:Port                    Peer Address:Port
LISTEN      0      5                      *:80                                 *:*
LISTEN      1      5                      *:80                                 *:*
ESTAB       0      0             172.17.0.2:80                        172.17.0.1:64716

# プロセス1を終了させた時のtcpdump。クライアントにfinが飛んでいる。
00:45:17.338669 IP6 ::1.80 > ::1.62532: Flags [F.], seq 1, ack 1, win 6371, options [nop,nop,TS val 3122313103 ecr 1463932082], length 0
00:45:17.338707 IP6 ::1.62532 > ::1.80: Flags [.], ack 2, win 6371, options [nop,nop,TS val 1463943117 ecr 3122313103], length 0
00:45:17.338749 IP6 ::1.62532 > ::1.80: Flags [F.], seq 1, ack 2, win 6371, options [nop,nop,TS val 1463943117 ecr 3122313103], length 0
00:45:17.338791 IP6 ::1.80 > ::1.62532: Flags [.], ack 2, win 6371, options [nop,nop,TS val 3122313103 ecr 1463943117], length 0

感想

metacpan.org

Server::Starterがすごすぎると改めて感じた。