みーのぺーじ

みーが趣味でやっているPCやソフトウェアについて.Python, Javascript, Processing, Unityなど.

Django async viewを使用してCloud Runで並行処理をする

一般的にWebサーバーの開発は非同期処理と相性がよいです.Webサーバーは不特定多数の人から任意の時刻にリクエストを受信して処理し,レスポンスを返します.Webサーバーの処理の多くは,データベースなど外部のリソースにアクセスすることなので,I/O boundになりがちです.非同期処理にすれば,複数のリクエストについて並列処理を行うことで,割り当てられたCPUをより効率的に使用できます.

Djangoで非同期関数を実装

以前はPythonで非同期処理を実装するのは大変でしたが,asyncioの導入,asgiを実装したuvicornの登場,Djangoがasync viewsを追加したことなど,最近はとても使いやすくなりました.

例えば,Django で以下のように非同期関数を定義して,uvicornを使って実行するだけで,並行処理が可能になります.

import asyncio
import random
from django.http import HttpRequest, HttpResponse

async def slow_view(request: HttpRequest):
    t = random.uniform(0, 2)
    await asyncio.sleep(t)
    return HttpResponse(f"Slow view <br/>t = {t:.3f}")

リクエストを受信してから,0秒から2秒の間待機し,レスポンスを返すだけの単純な非同期関数です.

uvicornを起動するには以下のようなスクリプトを追加します.

run.py

import os

if __name__ == "__main__":
    import uvicorn

    port = int(os.environ.get("PORT", 8000))
    uvicorn.run(
        "asyncserver.asgi:application",
        host="0.0.0.0",
        port=port,
        log_level="info",
        proxy_headers=True,
    )

Cloud Runでは環境変数PORTにポート番号が指定されるので,上記のように取得しています.

Cloud Runにデプロイする

Google Cloud Runでは,作成したコンテナをWebサーバーとして公開できます.非同期処理に対応させれば,Cloud Runの「インスタンスあたりの最大同時リクエスト数=(concurrency)」を活用できます.

以下のようなDockerfileで,コンテナイメージを作ります.

Dockerfile

FROM python:3.9-slim-bullseye
WORKDIR /app
COPY requirements.txt /tmp/pip-tmp/
RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \
   && rm -rf /tmp/pip-tmp
COPY . .
RUN python manage.py migrate --noinput
CMD ["python", "run.py"]

requirements.txt

django==4.1
uvicorn[standard]==0.18.2

ところで,プロダクション環境へデプロイする場合,uvicornのドキュメントによると,gunicorn -k uvicorn.workers.UvicornWorkerのように起動してgunicornでプロセス管理をすることが推奨されています*1.しかし,FastAPIのドキュメントには,コンテナ環境の場合はgunicornは使わなくてもよいと記載されています*2.この記事ではuvicorn単独で使用することにします.

また,uvicorn[standard]を使用しているのは,uvloopを使用するためです.uvloopはCythonで実装されており,asyncioと比較して2-4倍高速なのだそうです.

負荷試験

Concurrencyを80,max instancesを1に設定して,Cloud Runにデプロイしました.locustで負荷試験を実施しました.

locustfile.py

from locust import HttpUser, task, constant_throughput

class MyUser(HttpUser):
    wait_time = constant_throughput(1)

    @task
    def get_slow(self):
        self.client.get("/main/slow")

Cloud RunのMetricsを確認したところ,以下のようになりました.

Request countは概ね80 rpsに対して,container instance countは active が 1,billable container instance timeも 1 s/sとなり,平均1秒かかる処理を80個並行処理できたと言えます.

Locustの統計データは以下のようになりました.

80 rpsに張り付き,それ以上ユーザーを増やしてもresponse timeが伸びるだけで性能は上がりませんでした.

なお,負荷試験中に送信した全11879個のリクエストは全て200で正常なレスポンスとなりました.Cloud Runにはロードバランサが付属しているので,Webサーバーの処理が追いつかなくてもロードバランサでリクエストがしばらく待機できるので適切の処理されたのだと思われます.

まとめ

並行処理に対応したWebサーバーをCloud Runにデプロイできることが分かりました.性能や品質も期待したものでした.

今回はほぼI/O boundな処理について検討したので,concurrency 80でも十分処理ができました.CPU boundな処理や,同期関数が増えると,非同期処理の恩恵が減るので,concurrencyの値は状況に応じて調整するべきです.

参考