WSGIサーバーにおいてSO_REUSEPORTを使って複数のworker process間で負荷分散する方法があるが、graceful reloadに悪影響がある実装が多い。解説と改善案を書いておく。
複数ポートとSO_REUSEPORTの違い
過去にuWSGIで複数のポートを使った負荷分散の方法を紹介した。
methane.hatenablog.jp
SO_REUSEPORTを使う場合も同じようにworker processごとにlisten socketを持つが、複数ポートの場合はnginxのようなリバースプロキシがどのworkerにリクエストを送るかを決めることができる (least_connアルゴリズムなど)。
一方でSO_REUSEPORTの場合は外からは1つのportに見えるので、どのsocketに接続するかはOSカーネルが決めることになる。Linuxの場合はデフォルトで リモートとローカルのaddress, port の 4-tuple のハッシュを使うが、eBPFでカスタマイズすることもできるらしい。
https://docs.ebpf.io/linux/program-type/BPF_PROG_TYPE_SK_REUSEPORT/
以下の話題は外部から見た時の挙動ではなくソケットの管理の話なので、複数ポートとSO_REUSEPORTの両方に共通する話になる。
問題のある実装: worker processでソケットを作る
SO_REUSEPORTを使わない場合は親プロセスで作ったlisten socketをforkなどの方法で子プロセスに渡しているのに、SO_REUSEPORTを使う場合は子プロセスで新しくbindしてSO_REUSEPORTを設定するという実装がよく見られる。
この実装の問題は各workerが完全に独立したbacklogを持つことになる点だ。Graceful restartをする時に新しいworker processは新しいlisten socketとbacklogを作るので、旧プロセスがacceptしていない接続は新しいプロセスに引き継がれない。
TCPの場合backlogに入った段階で3-way handshakeが実行されているので、クライアントから見たら接続を確立してリクエストを送信したのに相手から接続を切られることになる。送ったリクエストがリトライ不可能な場合は回復も困難だ。
改善方法: 親プロセスでソケットを作る
SO_REUSEPORTや複数ポートを使う場合も、通常の場合と同じく親プロセスでlisten socketを作るべきだ。worker processごとにポートを変えながら、あるいはSO_REUSEPORTを設定しながらソケットを作り、それを子プロセスに渡す。
graceful restartの時は1workerごとに再起動し、新しいworker processにも旧workerに渡したのと同じlisten socketを渡す。こうすることで旧プロセスと新プロセスがbacklogを共有し、未acceptの接続を新workerが処理できるようになる。
WSGIサーバーの状況
- uWSGI: SO_REUSEPORTに対応しているがworker間の負荷分散のためではなく複数のuWSGIインスタンスで同じポートをlistenするための機能なので無関係。複数portのbindも親プロセスでやっているのでgraceful restartで問題は起きない。
- Gunicorn:
reuse_port オプションを使うと SO_REUSEPORT を使う。間違った実装になっているので、負荷をかけながらgraceful restartするとクライアント側で [Errno 104] Connection reset by peer が発生することを確認した。 報告済み
- gunicornはシグナルを使って動的にworker数を増減する機能を提供しているので対策には限界がある
- Uvicorn: SO_REUSEPORTを使った負荷分散が 提案中 で、これも子プロセス側でbindしなおす実装だったので graceful restart で問題が出ることをコメント済み。
- Granian: SO_REUSEPORTを使った負荷分散が一度 間違った方法で実装された が、graceful restartの問題が発生して revertされた. 作者がload balancingについてやる気を失ってしまっため、親プロセスでのSO_REUSEPORTを実装してもらえるかは不明。
- 8/8追記: SO_REUSEPORTを使った負荷分散は再実装されていました。下で紹介する
net.ipv4.tcp_migrate_req の利用を前提としていて、それ以外の環境ではリロードでエラーになる可能性があります。
回避方法
問題があるWSGIサーバーで、SO_REUSEPORTをどうしても使いたい場合にどうするべきか?問題の回避策はあるかを考えてみた。
net.ipv4.tcp_migrate_req
SO_REUSEPORT の問題を軽減するために追加されたオプション。これを設定すれば古いworkerのbacklogに残っていた接続を他のworkerに自動的に移動してくれる。制限がありいつでも利用できる解決策ではない。
- システム全体に影響する。他のサービスが別の形で SO_REUSEPORTを使っていたら影響がでる可能性がある。
- rootが必要。
- ipv4 しか使えない。nginx+wsgiサーバーみたいな構成の場合は、wsgiサーバーをunixソケットではなくTCPにする必要がある。
graceful reloadを使わない?
そもそもgraceful reloadは必要なのか?
graceful reloadが使われる一番の目的は、アプリケーションコードやそれに同梱されているデータの更新だろう。その場合、新しいコンテナやインスタンスを立ち上げて、前段にいるALBなどの負荷分散対象を変更し、完全に古いサーバーにリクエストがなくなってからシャットダウンすればいいはずだ。
しかし graceful reload が使われるのはそれに限定されない。例えばアプリケーションがメモリリークやフラグメンテーションの問題を抱えていてじわじわメモリ使用量が増える場合、リクエスト数や消費メモリを基準にして worker を再起動することがある。 (例: gunicorn の max_requests オプション)
そのため、プロダクション環境で使うためのWSGIサーバーは graceful reload の挙動を無視しない方がいい。
リトライ?
acceptされずにcloseされたらリトライしたらいいのでは?
問題は、backlogに入った段階でTCPの3way handshakeは完了していて、クライアントがリクエストを送り始めていることだ。そのリクエストがHTTP POSTなどリトライ不可能であれば、 nginx などの WSGI サーバーの手前にいるリバースプロキシは勝手にリトライできない。クライアントに503 Bad Gatewayなどのエラーを返すことになるはずだ。
ハンドシェイク?
リバースプロキシがWSGIサーバーがacceptしたことを確認してからリクエストを送るようにすれば、リクエストを失敗させずに他のworkerに送れるのでは?
問題はそのacceptしたことを確認するプロトコルが限られることだ。HTTP/1.1は当然TCP接続が完了したらすぐにリクエストを送ってしまう。HTTP/2もupgradeなら最初の1リクエストはHTTP/1.1だし、Prior Knowledge方式でもサーバーからのsettingsフレームを待たずにリクエストを送ってしまう実装が多い。
TLS接続を使えば事前handshakeができるが、同一ホスト内のリバースプロキシとWSGIサーバーの間でTLSを使う必要があるのだろうか…
できることならば、Keep-Alive接続を前提として、リバースプロキシからworkerへの最初のリクエストは疎通確認のためのヘルスチェックをGETかHEADリクエストで送りたい。長時間 keep-alive 状態だった後にその接続を再利用する場合にも疎通確認を使える。
nginxやその他の主要なリバースプロキシでこれを実装しているものがあれば教えてください。