はじめに
gunicorn の設定難しくないですか?
弊社のアプリケーションサーバーの一部は Django + gunicorn で構成されています。 このサーバーでレイテンシが高いリクエストの処理中に他のリクエストのレイテンシが増加する事象が発生しました。
結論、この問題は gunicorn のワーカー、スレッドの設定を変更することで解決しました。 今回はその解決の過程で調べたことを紹介します。
前提
- gunicorn 22.0.0
- gunicorn のワーカーは gthread
公式ドキュメントに書いてあること
公式ドキュメントでワーカー、スレッドの調整については以下のように記載があります。
https://docs.gunicorn.org/en/stable/design.html#how-many-workers
https://docs.gunicorn.org/en/stable/design.html#how-many-threads
これを読んで 100req/s くらいまでの負荷であれば、コア数 2、ワーカー数 5、スレッド数 1 くらいの設定で十分なのかと思っていました。 実際問題が起きたサーバーは上記の設定でした。
検証
問題が起きたシチュエーションに近い設定で検証を行います。
- 実行環境
- パフォーマンステストツール
- Locust
- シナリオ
- gunicorn 設定
- ワーカー:4
- スレッド:1
Locust のコードは以下の通りです。
/quick_request
は即座にレスポンスを返すエンドポイント、/slow_request
が 30 秒 sleep してからレスポンスを返すエンドポイントです。
from datetime import datetime import time from locust import HttpUser, task, between import logging class QuickUser(HttpUser): wait_time = between(0, 1) @task(1) def quick(self): start_time = time.time() self.client.get("/quick_request") class SlowUser(HttpUser): fixed_count = 1 wait_time = between(0, 1) @task(1) def slow(self): start_time = time.time() self.client.get("/slow_request")
/slow_request
に 1 回リクエストしつつ、40 のユーザーが/quick_request
に 1 秒以下の間隔でリクエストするシナリオを実行しました。
実行時間は 40 秒です。
結果は以下の画像のようになりました。
注目するのは/quick_request
のレイテンシの最大値が約30000msとなっている点です。
/slow_request
のレイテンシに引っ張られる形で/quick_request
のレイテンシが増加したことが分かります。
gunicorn の debug ログを確認すると、/slow_request
を処理中のワーカーが/quick_request
の接続を受け入れることでレイテンシの増加が発生しているようでした。
リバースエンジニアリング
なぜ検証結果のようになったのか、gunicorn のソースコードを読んで考えます。 ワーカーが接続を作成し、リクエストを処理する部分を確認しました。
gthread ワーカーのメインループはgunicorn.workers.gthread.ThreadWorker.run()
です。
events = self.poller.select(1.0)
でselectors
を使って対象のポートへのリクエストを待機しています。
リクエストが来るとaccept
メソッドで接続を作成し、enqueue_req
でスレッドプールに接続を submit しています。
スレッドプール(concurrent.futures.thread.ThreadPoolExecutor
)ではキューにタスクが登録され、キューのタスクはスレッドが空くまで待ちになることがわかります。
つまりワーカーごとにキューがあり、スレッドが空くまでリクエストはキューで待機することになります。 リクエストは非同期で処理されるためワーカーのメインループはブロックされません。 よって処理中もワーカーは新規の接続を受け入れることができ、後続のリクエストはキューで待ちになる可能性があります。
検証 2
リバースエンジニアリングの結果から、スレッド数 1 だと 1 回の重いリクエストで後続のリクエストが待ちになる可能性があることがわかりました。 ではスレッド数を増やして検証を行ってみましょう。
gunicorn の設定をワーカー=4、スレッド=1、からワーカー=2、スレッド=2 に変更して同じシナリオの検証を実施しました。
- 実行環境
- Docker コンテナに構築した Django + gunicorn
- パフォーマンステストツール
- Locust
- シナリオ
- gunicorn 設定
- ワーカー:2
- スレッド:2
結果は以下の画像のようになりました。
/quick_request
のレイテンシの最大値は45msであり、/slow_request
の30000msに引っ張られる結果ではなくなりました。
まとめ
調査・検証で以下のことがわかりました。
- gunicorn の gthread ワーカーはスレッド数を超えるリクエストがキューで待ちになる
つまり実環境でもスレッド数が少ない設定ではレイテンシが高いリクエストの処理中に後続のリクエストが詰まりやすくなります。 アプリケーション特性によりますが、レイテンシが高いリクエストと低いリクエストが混在する場合にはスレッド数を調整することで全体のパフォーマンスが改善するかもしれません。
ここまで読んでいただきありがとうございました。
ビザスクではエンジニアの仲間を募集しています! 少しでもビザスク開発組織にご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう! developer-recruit.visasq.works