VISASQ Dev Blog

ビザスク開発ブログ

gunicornのパフォーマンスチューニング

はじめに

gunicorn の設定難しくないですか?

弊社のアプリケーションサーバーの一部は Django + gunicorn で構成されています。 このサーバーでレイテンシが高いリクエストの処理中に他のリクエストのレイテンシが増加する事象が発生しました。

結論、この問題は gunicorn のワーカー、スレッドの設定を変更することで解決しました。 今回はその解決の過程で調べたことを紹介します。

前提

  • gunicorn 22.0.0
  • gunicorn のワーカーは gthread

公式ドキュメントに書いてあること

公式ドキュメントでワーカー、スレッドの調整については以下のように記載があります。

これを読んで 100req/s くらいまでの負荷であれば、コア数 2、ワーカー数 5、スレッド数 1 くらいの設定で十分なのかと思っていました。 実際問題が起きたサーバーは上記の設定でした。

検証

問題が起きたシチュエーションに近い設定で検証を行います。

  • 実行環境
    • Docker コンテナ(python公式イメージ 3.12.4-bookworm)に構築した Django + gunicorn
  • パフォーマンステストツール
    • Locust
  • シナリオ
    • レイテンシの低いリクエストを処理中に、レイテンシの高いリクエストを 1 回実行する。
  • 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
  • シナリオ
    • レイテンシの低いリクエストを処理中に、レイテンシの高いリクエストを 1 回実行する。
  • gunicorn 設定
    • ワーカー:2
    • スレッド:2

結果は以下の画像のようになりました。

/quick_requestのレイテンシの最大値は45msであり、/slow_requestの30000msに引っ張られる結果ではなくなりました。

まとめ

調査・検証で以下のことがわかりました。

  • gunicorn の gthread ワーカーはスレッド数を超えるリクエストがキューで待ちになる

つまり実環境でもスレッド数が少ない設定ではレイテンシが高いリクエストの処理中に後続のリクエストが詰まりやすくなります。 アプリケーション特性によりますが、レイテンシが高いリクエストと低いリクエストが混在する場合にはスレッド数を調整することで全体のパフォーマンスが改善するかもしれません。

ここまで読んでいただきありがとうございました。

ビザスクではエンジニアの仲間を募集しています! 少しでもビザスク開発組織にご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう! developer-recruit.visasq.works