ChromeでGitHubのスレッドなどを読む時はGeminiで翻訳すると良い

GitHub Issue や技術系の記事をざっと流し読みする時、日本語に翻訳した方が圧倒的に効率がいいです。しかしChromeの翻訳機能を使うと文章中の inline code block がぐちゃぐちゃになってしまいます。

元の文章

Chromeの翻訳機能

Chromeの右上の「Geminiに相談」を押して「翻訳して」と頼むと、次のような感じで翻訳してくれるので読みやすいです。

Gemini翻訳

また、原文を左、翻訳を右(サイドバー)にと並べて読めるのも、原文を置き換える形で翻訳するよりも使いやすい。毎日英語でGitHubを使ってる人は試してみて。

AI Coding時代のOSSライブラリのメンテ

結論: 今までよりさらに、コンパクトに低レベル、最小限のコア機能だけを提供しよう

今年になって明らかにPull Requestもセキュリティ報告も増えました。しかもセキュリティ報告は、だれがこんな使い方するんだよというようなケースが多く、サポート外として蹴るか一応CVE登録するか毎回悩みます。今までと同じやり方では簡単にパンクしてしまうでしょう。

Pull Requestを作る側は完全にAI任せにしてくることもありますが、メンテナとしてはきちんとPRを理解して、将来まで責任を持つ覚悟がないとマージできません。どこまでAI任せにできるかの非対称さがスループットの大きな差になります。

ライブラリが肥大化すると、ほとんど誰にも関係しないような、世の中に一人でも危険になるのか判らないCVEを年に何件も発行することになります。ライブラリの使用者は毎週大量のCVEに関連したアップデートをこなす必要が発生し、その中身を確認することができなくなります。その隙を狙ってサプライチェーンアタックが発生する可能性も増えます。

そこで、ライブラリを持続可能な状態でメンテナンスするためには、ライブラリの機能を必要最小限に絞ることが重要です。 「あった方が便利な関数」は、そのライブラリで提供する必要がないのなら、より上位のレイヤーのライブラリやアプリケーション側で実装してもらいましょう。 ごくごく一部の人にしか使われないけれど、上位レイヤーでは実現できないからライブラリに実装する必要がある機能については、必要な人に fork して自身で追加しメンテナンスしてもらいましょう。

便利関数を上位レイヤーで実装してメンテするのも、自分が必要な機能を追加したforkを作ってメンテするのも、AI Coding時代には大幅に容易になりました。去年までと比べて、これからはライブラリ側で実装するかどうかの判断基準を大幅に厳しくすることが全体のベストバランスにつながると思います。

Gunicorn の gthread と freethreading

前回は sync worker が uwsgi の置き換えになるかという視点で調べていましたが、次は gthread について調べてみます。 sync worker はシンプルに次のような処理をしていました。

  • accept (複数socketをlistenしている時は select->accept)
  • リクエストを受け取る
  • wsgiアプリを実行する
  • レスポンスを返す
  • close

gthread は次のような処理になります。

  • メインスレッド
    • epoll等で listen socket やリクエスト待ちの接続を監視する
    • listen socket の場合: accept してスレッドプールに投げる
    • リクエスト待ちの接続の場合: スレッドプールに投げる
  • スレッドプール
    • リクエストを受け取る
    • wsgiアプリを実行する
    • レスポンスを返す
    • keep-alive可能な場合はメインスレッドの監視対象に戻す。それ以外はclose

ポイント

  1. keep-aliveに対応している。keep-alive中の接続はスレッドを占有しない。
  2. スレッド数の上限以上にkeep-alive可能。worker_connectionsという設定で接続数の上限を指定し、その接続数に到達したら新規acceptを止める。

nginx などのリバースプロキシの後ろで動かす場合、クライアントとのkeep-aliveはリバースプロキシ側でやっているので、 gthread の worker_connections はスレッド数と同じにするのが良いと思います。でないと余裕があるworkerよりも先にスレッドが埋まってるworkerがacceptしてしまう可能性があるので、負荷の偏りがひどくなり、99%tileのレスポンスタイムが酷く悪化する可能性があります。

freethreadingとの相性

よく知られている通りPythonにはGILがあり複数スレッドが同時に動くことができません。gthreadの場合メインスレッドもGILを必要とするので競合が発生しやすくなります。競合するとオーバーヘッドが発生するので、例えば3スレッドがGILの取りあいをしているとスレッドあたりの性能が3分の1以下に下がることもあり得ます。

もし1workerでそのマシンのCPU利用率を十分に出せるまでスケールするのであれば1workerだけで動かすことも可能で、その場合は上で述べたworker間の負荷分散の問題にも悩まされなくなります。

試しに gthread を 3.14と3.14tで動かしてみます。

# gunicorn_conf.py
workers = 1
threads = 2
accesslog = None
bind = "127.0.0.1:5000"
# benchmark_app.py
def application(environ, start_response):
    body = b"Hello, World!\n"
    start_response(
        "200 OK",
        [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))],
    )
    return [body]

起動コマンド:

$ .venv/bin/gunicorn  -c gunicorn_conf.py benchmark_app:application

ベンチマークコマンド:

$ ./hey -c 2 -z 10s http://127.0.0.1:5000/

結果

# 3.14
Summary:
  Total:        10.0003 secs
  Slowest:      0.0046 secs
  Fastest:      0.0001 secs
  Average:      0.0002 secs
  Requests/sec: 10032.0812

  Total data:   1404536 bytes
  Size/request: 14 bytes

# 3.14t
Summary:
  Total:        10.0018 secs
  Slowest:      0.0057 secs
  Fastest:      0.0001 secs
  Average:      0.0001 secs
  Requests/sec: 17708.5014

  Total data:   2479638 bytes
  Size/request: 14 bytes

2倍にまではなりませんでしたが、かなりの性能向上をしているのがわかります。

ちなみにGIL版の性能は前回の記事のsync workerに若干負けています。HTTP keep-aliveを使って再接続がなくなる分よりも、mainスレッドとスレッドプール間でのやりとりが発生する方が重いのでしょう。

まとめ

単純に性能が欲しい時はsync workerが一番良いです。負荷分散が理想的で、freethread版Pythonを使わなくてもGILがほとんど問題にならないのでOSのフェアなスケジューリングにも助けられます。時々遅くなる外部APIを利用していて、その時にworkerが詰まってサービスが停止するのを回避したいという時にはgthreadが選択肢になるでしょう。

gunicornのuwsgi vs http vs picohttpparser

gunicornの開発が今年活発化していて、uwsgiプロトコルの対応と、picohttpparserを使った高速なhttp parserが導入されている。

高速なhttp parserはオプションで、 gunicorn をインストールする時に gunicorn[fast] でインストールするか、 gunicorn_h1c をインストールすれば自動で使われる。 --http-parser=fast オプションを使えば gunicorn_h1c がインストールされてない時や古い時にエラーにできる。ちなみに今公開されているドキュメントだと高速なparserはASGI専用みたいに読めてしまうが、実際にはWSGIでも使われるので訂正のPRを出してある。

さて、uwsgi から gunicorn に乗り換える場合に、 uwsgi protocol を使うべきか http を使うべきか。とりあえず性能を測ってみた。 gunicornを1workerで動かし、スレッドなし、keep-aliveなし (sync worker) だ。

以降、「結果」までがCodexに書かせたレポート。

測定条件

  • nginx 1.24.0
  • Gunicorn 26.1.0
  • gunicorn-h1c 0.6.8
  • CPython 3.14.2(通常版、GIL有効)
  • sync worker、1 worker
  • Gunicornのアクセスログは無効
  • Hello, World!\nを返すWSGIアプリ
  • hey -z 10s -c 2で各条件を3回測定
  • HTTPはnginxの8000番、uwsgi protocolは8001番ポートを使用

実行コマンド

HTTP / auto

.venv/bin/gunicorn benchmark_app:application \
  --workers 1 --worker-class sync \
  --bind 127.0.0.1:5000 \
  --protocol http --http-parser=auto

./hey -z 10s -c 2 http://127.0.0.1:8000/

HTTP / python

.venv/bin/gunicorn benchmark_app:application \
  --workers 1 --worker-class sync \
  --bind 127.0.0.1:5000 \
  --protocol http --http-parser=python

./hey -z 10s -c 2 http://127.0.0.1:8000/

uwsgi protocol

.venv/bin/gunicorn benchmark_app:application \
  --workers 1 --worker-class sync \
  --bind 127.0.0.1:5000 \
  --protocol uwsgi

./hey -z 10s -c 2 http://127.0.0.1:8001/

結果

Protocol / Parser 1回目 (req/sec) 2回目 (req/sec) 3回目 (req/sec) 平均 (req/sec)
HTTP / auto 11,567 11,749 11,744 11,687
HTTP / python 12,762 12,081 12,076 12,306
uwsgi protocol 11,755 11,884 11,740 11,793

3回の平均では、HTTPのpython parserはautoより約5.3%高速だった。uwsgi protocolはHTTPのautoより約0.9%高速、python parserより約4.2%低速だった。

この環境ではgunicorn_h1cがインストールされているため、HTTPのautoは高速parserを利用する。uwsgi protocolではHTTP parserを使用しない。

(ここから人間執筆にもどる)

考察

意外なことに picohttpparserを使うよりpure Python parserの方が高速だった。Codexに調査させたところ、 gunicorn_h1c のインターフェイスがWSGIアプリ向けに最適化されてなくて、途中で一段余計な変換が挟まっているようだ。 hey が出すHTTPヘッダーが少ないこともパース速度の差を出にくくしているだろう。

しかしgunicorn_h1c の採用のメリットは、他でも利用されているパーサーを使うことで脆弱性が入りにくく見つけやすくなることもある。gunicornも十分に使われているプロダクトなのでどちらを信頼するかは微妙なところだけれど。

uwsgi vs http はどちらのプロトコルを使っても性能上の問題は全くない。1000req/secで1リクエストあたりのオーバーヘッド1msと考えたら、1万req/secなら0.1msで、レスポンスタイムへの影響は十分に小さい。5000req/secでも十分なくらいだ。

その上でどちらを選ぶかだが、uwsgiプロトコルにはユーザーベースも将来性もないように見える。gunicornのuwsgi プロトコル対応もユーザーが少ない分信頼しにくい。例えばテストしていたときにすぐに Content-Lengh ヘッダを設定しないWSGIアプリでレスポンスが壊れるバグを見つけてしまった。(報告済み)

今までuwsgiを使ってたところをgunicornに置き換える場合は選択肢になるけれど、これから選ぶならHTTPの方が良いだろう。

一応、uwsgi と http のメリデメを比較してみる。

uwsgi のメリット:

  • バイト数: 文字列 というバイナリプロトコルになっているのでパーサーの細かな振る舞いによる脆弱性が入りにくい
  • X-Forwarded-Forが一段追加で入ることがなかったり、nginxからwsgiサーバーにHTTPヘッダ以外の形で情報を渡すことが可能

httpのメリット:

  • nginxに依存しない。traefikやEnvoyへの移行がしやすくなる
  • 情報が多い。モダンなベストプラクティスが利用できる

おまけ: uwsgi の性能

上記の性能を uwsgi と比較するための参考値。明らかに gunicorn よりも速いけれど、これから新規に uwsgi を使うことはお勧めしない。

以下、Codexによるレポート

uWSGI: HTTP socketとuwsgi protocolの比較

uWSGI socket 1回目 (req/sec) 2回目 (req/sec) 3回目 (req/sec) 平均 (req/sec) 中央値 (req/sec)
--http-socket 35,046 35,853 35,801 35,567 35,801
--uwsgi-socket 35,308 32,712 35,043 34,354 35,043

3回の平均では、--http-socketがuwsgi protocolより約3.5%高速だった。uwsgi protocolの2回目に低下があるため、中央値で比較すると差は約2.2%となる。

測定条件

  • uWSGI 2.0.30
  • CPython 3.14.2
  • single process、1 thread
  • uWSGIを同じP-coreへ固定
  • 各protocolで5秒ウォームアップ後に測定
  • hey -z 10s -c 2で各3回
  • uWSGIのリクエストログは無効
  • nginx経由(HTTP: 8000番、uwsgi protocol: 8001番)

SO_REUSEPORTを使ったプロセス間負荷分散とgraceful reload

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やその他の主要なリバースプロキシでこれを実装しているものがあれば教えてください。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。