みーのぺーじ

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

Pythonのリスト内包表記内で同じ名前の変数を使う

以下のようなPythonスクリプトを見かけました.

a = [1, 2, 3, 4, 5]
a = [a*2 for a in a]
print(a)

最初に宣言した変数aと同じ名前の変数aをリスト内包表記内で使用しています.

初めて見た時は意味がわからなかったのですが,問題なく動作します.

[2, 4, 6, 8, 10]

Pythonのリスト内包表記内はスコープが別になるので,リスト内包表記内の変数aと,外の変数aがそれぞれ独立して存在するわけです.

この事実を示すために,以下のスクリプトを実行してみます.

a = [1, 2, 3, 4, 5]
[print(a*2) for a in a]
print(a)

実行すると,リスト内包表記内の変数aの様子が分かりますし,外の変数aの中身は変わっていないことが分かります.

2
4
6
8
10
[1, 2, 3, 4, 5]

とはいうものの読みやすいように普通に書いてほしいと思いました.

a = [1, 2, 3, 4, 5]
b = [i*2 for i in a]
print(b)

MacOS 10.15.7 の Python 3.8.8で検証しました.

Djangoの開発サーバーではデータベース接続を維持してはいけない

Djangoのrunserverコマンドを使用して開発している時に,たくさんのリクエストを行うとデータベース接続数が次第に増えていき,

FATAL: sorry, too many clients already`

というエラーが発生する現象に遭遇した時のまとめです.

環境

  • Django 3.1.6
    • 'CONN_MAX_AGE': 120
    • command: python manage.py runserver
  • Postgres 12
    • max_connections 100

Postgresの状況を確認する

データベースの起動コマンドに以下のオプションを追加して,Postgresの接続ログを有効にします.

postgres -c log_destination=stderr -c log_connections=on -c log_disconnections=on

データベースの接続数を取得するには,以下のクエリを実行します.

SELECT count(*) FROM pg_stat_activity;

データベースの設定を確認するには,以下のクエリを実行します.

SELECT * FROM pg_settings;

max_connectionsという値が,最大接続数を表します.

接続ログを確認する

データベースの接続を再利用するために,Djangoの'CONN_MAX_AGE'を有効にした場合と無効にした場合を比較しました.

'CONN_MAX_AGE': 120

CONN_MAX_AGEを1以上に設定すると,以下のように指定した時間が経過すると切断されている場合もありましたが,既存の接続を再利用せずに新たな接続が徐々に作成されていく状態でした.

2021-02-19 07:24:10.978 UTC [91] LOG:  connection received: host=172.20.0.7 port=47576
2021-02-19 07:24:10.980 UTC [91] LOG:  connection authorized: user=t database=t
...
2021-02-19 07:26:11.387 UTC [91] LOG:  disconnection: session time: 0:02:00.410 user=t database=t host=172.20.0.7 port=47576

'CONN_MAX_AGE': 0

設定通り,データベースに接続した直後に切断されています.全体としてデータベースの接続数は一定でした.

2021-02-19 07:39:12.753 UTC [119] LOG:  connection received: host=172.21.0.4 port=43554
2021-02-19 07:39:12.755 UTC [119] LOG:  connection authorized: user=t database=t
2021-02-19 07:39:12.771 UTC [119] LOG:  disconnection: session time: 0:00:00.017 user=t database=t host=172.21.0.4 port=43554

runserverの仕様

Djangoのドキュメントに記載がありました.

The development server creates a new thread for each request it handles, negating the effect of persistent connections. Don’t enable them during development.

Databases | Django documentation | Django

開発サーバーの設定では,'CONN_MAX_AGE': 0と設定するべきのようです.

uWSGI

uWSGIでは,psycopg2の非同期処理に対応しているため,CONN_MAX_AGEを有効して接続を再利用することができます.

uGreen – uWSGI Green Threads — uWSGI 2.0 documentation

開発環境と本番環境で設定が異なるため,注意が必要です.

Pythonのloggingでデバッグ

Pythonのloggingはとても便利です.

logging --- Python 用ロギング機能 — Python 3.9.1 ドキュメント

logging.debug(msg, *args, **kwargs)関数には便利なキーワード引数が存在します.

stack_info

logging.info("test", stack_info=True)

これだけで,スタック情報がロギングメッセージに追加されます.例えば,興味がある関数が複数回呼び出される原因を同定したい時に便利です.

DjangoのModel.save()が複数回呼び出されてデータベースのアクセスが無駄に多い原因を調べる時は,以下のようになります.

class AppleModel(models.Model):
    def save(self, *args, **kwargs):
        logger.info("save()", stack_info=True)
        super().save(self, *args, **kwargs)

これを実行すると,以下のようになりました.

2021-02-09 15:02:16,709 INFO: save()
Stack (most recent call last):
...
File "/app/utils/views.py", line 56, in perform_create
  serializer.save(owner=owner)
File "/opt/venv/lib/python3.8/site-packages/rest_framework/serializers.py", line 205, in save
  self.instance = self.create(validated_data)
File "/app/apple/views.py", line 34, in create
  return AppleModel.objects.create(
File "/opt/venv/lib/python3.8/site-packages/django/db/models/manager.py", line 85, in manager_method
  return getattr(self.get_queryset(), name)(*args, **kwargs)
File "/opt/venv/lib/python3.8/site-packages/django/db/models/query.py", line 447, in create
  obj.save(force_insert=True, using=self.db)
File "/app/apple/models.py", line 69, in save
  logger.info("save()", stack_info=True)
File "/app/utils/views.py", line 143, in perform_update
  serializer.save()
File "/opt/venv/lib/python3.8/site-packages/rest_framework/serializers.py", line 200, in save
  self.instance = self.update(self.instance, validated_data)
File "/app/apple/views.py", line 69, in update
  instance.save()
File "/app/apple/models.py", line 69, in save
  logger.info("save()", stack_info=True)

これをみれば,AppleModel.objects.create()関数がsave()を呼び出し,自分が作成したviewsでもsave()を呼び出しているため,2回実行されていることが分かります.

Djangoのドキュメントにも,同じことが記載されています.

QuerySet API reference | Django documentation | Django

stacklevel

1より大きい数値を指定すると,その数だけスタックがスキップされます.

この機能を使うには,loggingのフォーマットを'{asctime} {levelname} {filename}-{lineno}: {message}'と設定して,ファイル名と行番号を表示するようにします.

logger.info("save()", stacklevel=1)

とデフォルトの状態で実行すると,以下のようにlogger.info()が存在するファイル名と行番号が表示されます.

2021-02-09 15:18:33,334 INFO models.py-69: save()

stacklevelを変更すると,logger.info()を呼び出した関数のファイル名と行番号に変わりました.

logger.info("save()", stacklevel=2)
2021-02-09 15:17:48,146 INFO views.py-69: save()

呼び出し元が重要なデバッグでは,このようにstacklevelを指定することで,ソースコードを目で追いかける必要がなくなります.

これ以外にも,exc_infoextraという引数が存在し,ドキュメントに詳しく記載されています.

logging --- Python 用ロギング機能 — Python 3.9.1 ドキュメント

デバッグを効率よく行うためにも,これらの機能を積極的に活用していきたいと思います.

Pythonのグローバル変数のメモ

以下の記事がとても有用だったのでメモします.

Why am I getting an UnboundLocalError when the variable has a value? - Programming FAQ - Python 3

以下のスクリプトは実行されます.

x = 10
def bar():
    print(x)
bar()
10

x=1を追加すると,UnboundLocalErrorが発生します.

x = 10
def bar():
    print(x)
    x = 1
bar()
Exception has occurred: UnboundLocalError
local variable 'x' referenced before assignment
  File "C:\Users\a\b\r.py", line 5, in bar
    print(x)
  File "C:\Users\a\b\r.py", line 9, in <module>
    bar()

Pythonのスコープについて

In Python, variables that are only referenced inside a function are implicitly global. If a variable is assigned a value anywhere within the function’s body, it’s assumed to be a local unless explicitly declared as global. *1

関数内で変数を参照するだけならば,その変数はグルーバル変数になる.関数内で変数に代入すると,その変数はローカル変数となる.グローバル変数として扱うために,globalで明示しなければならない.

Djangoで作成したDockerイメージをVS Codeでデバッグする

VS CodeのPython拡張機能を使うことで,Pythonスクリプトを簡単にデバッグすることができます.これをDockerで実行中のDjangoプロジェクトで使用できるようにします.

Python - Visual Studio Marketplace

debugpyとVS Codeの設定

Microsoftが開発しているPython用のデバッガーを使います.

GitHub - microsoft/debugpy: An implementation of the Debug Adapter Protocol for Python

対象となるスクリプトに組み込んで,httpリクエストをlistenするようにしておき,VS CodeのPython拡張機能でデバッグを開始して接続します.

以下のような,ValueErrorを発生させるPythonスクリプトと,localhostのport 9001のdebugpyに接続するlaunch.jsonを用意します.

r.py

import debugpy

debugpy.listen(("localhost", 9001))
print("Waiting for VSCode debugger.")
debugpy.wait_for_client()
a = 1
b = "ab"
raise ValueError()

launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Python: Remote (port 9001)",
            "type": "python",
            "request": "attach",
            "port": 9001,
            "host": "localhost",
        },
    ]
}

これらを実行すると,以下のようにVS Codeでデバッグすることができます.ValueErrorの直前で実行されているb = "ab"により作成された変数bの内容なども確認することができています.

f:id:atsuhiro-me:20210202193927p:plain

Dockerで使用

debugpyはhttpリクエストを利用しているので,かなり自由度が高く,少し設定を工夫すればDockerで実行中のPythonスクリプトもデバッグすることができます.

まず,port 9001にホストからアクセスできるように設定します.

docker-compose.yaml

version: '3'

services:
  sample:
    ports:
      - "9001:9001"

localhostを0.0.0.0に変更します.

import debugpy

debugpy.listen(("0.0.0.0", 9001))
print("Waiting for VSCode debugger.")
debugpy.wait_for_client()

これでデバッグができるようになります.

Djangoのリロードに対応する

manage.pyに以下のようなコードを追加します.

def main():
    if not os.environ.get("RUN_MAIN"):
        import debugpy
        debugpy.listen(("0.0.0.0", 9001))
        print("Waiting for vscode debugger...")
        debugpy.wait_for_client()
        print("connected.")

django/autoreload.py at master · django/django · GitHub

これにより,ソースコードの変更でDjangoがリロードしてもデバッグが継続されるようになります.

pathMappings

Dockerの中とホストのパスが一致しないと,エラーが発生したときにソースコードのファイルが正しく自動で開かれないので,launch.jsonにpathMappingsを設定します.

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Python: Remote (port 9001)",
            "type": "python",
            "request": "attach",
            "port": 9001,
            "host": "0.0.0.0",
            "pathMappings": [
                {
                    "localRoot": "${workspaceFolder}/django",
                    "remoteRoot": "/workdir"
                }
            ],
        },
    ]
}

Debugging configurations for Python apps in Visual Studio Code

まとめ

Djangoのエラー表示は親切なのですが,debugpyを使用した方が分かりやすいので,これらの設定をおすすめします.

Djangoからpostgresデータベースに効率よく接続する

Djangoで作成したWebアプリケーションをCloud Runで実行して,Cloud SQL postgresに接続する負荷テストを実施していたところ,以下のエラーが発生しました.

psycopg2.OperationalError: FATAL: remaining connection slots are reserved for non-replication superuser connections

どうやらデータベースの最大同時接続数を超えたようです.

Cloud RunでMax instancesを8,Concurrencyを8,gunicornでworkersを1,threadsを8に設定しており,db-f1-microの最大同時接続数は25です*1

1個のCloud Runインスタンスからデータベースに対して1個の接続ならば,8 < 25 なので上記のエラーは発生しないはずですが,どうやら1個のスレッドに対して1個の接続が存在するために8 * 8 > 25 のためエラーとなったようです.また,データベースのCPU使用率は50%程度に上昇していました.

データベースの同時接続数を減らす

Gunicornのドキュメントにworker_classの説明があり,同期と非同期について記載されています.

Settings — Gunicorn 20.0.4 documentation

Design — Gunicorn 20.0.4 documentation

非同期処理に対応したworkerを使用するにあたって,psycopg2に対応している必要があります.非同期処理で複数のリクエストを処理している1個のprocessに対して1個のデータベース接続を確立するようにできれば,データベースの同時接続数を減らすことができます.このことは,psycogreenに詳しく記載があります.

psycogreen · PyPI

要約すると,eventletはそのままpsycopg2に対応していて,gevent はmonkeypatchすれば対応するようで,uWSGIはそのまま対応しているようです*2

eventletを試しましたが問題があり起動しませんでしたので,uWSGIを試しました.以下のソースコードがCloud Runで動作しました.

uwsgi.conf.yaml

uwsgi:
  http: 0.0.0.0:$(PORT)
  chdir: /app/
  module: sample.wsgi:application
  http-keepalive: 620
  master: true
  processes: 1

settings.py

DATABASESCONN_MAX_AGEを指定するのが重要です.

Databases | Django documentation | Django

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'sample-db',
        'USER': 'sample-user',
        'PASSWORD': "password",
        'HOST': os.environ.get("DB_HOST"),
        'PORT': os.environ.get("DB_PORT"),
        'CONN_MAX_AGE': 60
    }
}

requirements.txt

django==3.1.5
psycopg2-binary==2.8.6
uWSGI==2.0.19.1

Dockerfile

...
CMD ["uwsgi","uwsgi.conf.yaml"]

Quickstart for Python/WSGI applications — uWSGI 2.0 documentation

Cloud Runでmax instance 8, concurrency 4 に設定し,データベースの読み出しを行うリクエストを用いて負荷試験を行ったところ,少なくとも600 rpsの性能が確認され,このときのデータベースの同時接続数は最大で11でした.1個のインスタンスが複数のリクエストを処理するために1個のデータベース接続を用いてることが分かります.この間のデータベースのCPU使用率は10%以下で,効率よくデータベースにアクセスできるようになりました.

Djangoのソースコードは一切変更することなく,起動方法をGunicornからuWSGIに変更するだけで,パフォーマンスが向上したのは驚きです.

トレンド

f:id:atsuhiro-me:20210128013119p:plain

Google Trendsで比較すると,最近はGunicornの方が人気のようですが,みーはuWSGIが気に入りました.

Cloud RunでDjangoの快適なlogging設定

Cloud RunでDjangoのプロジェクトを快適に実行するためのloggingについてまとめます.

実行環境

Python 3.8.6 on macOS 10.15.7

Python logging

以下のウェブサイトに記載されていますが.自分なりにまとめます.

Logging HOWTO — Python 3.8.7 documentation

logging.Logger, logging.Handler, logging.Formatter, logging.Filterの4個のClassが登場します.

logging.Logger

ログを書き込むための入り口です.

import logging
logging.basicConfig(level=logging.DEBUG)
logging.debug("debug")
logging.info("info")
logging.warning("warning")
logging.error("error")

これを実行すると,以下のように出力されます.

DEBUG:root:debug
INFO:root:info
WARNING:root:warning
ERROR:root:error

print()を使うと直感的でよいのですが,ソースコードからprint()の挿入削除を繰り返すことになり面倒です.ログをレベルに分けておくと,出力するレベルを変更することで同様の操作ができて便利です.

以下のレベルをよく用います.

  • DEBUG: 詳細な情報
  • INFO: 想定通りの情報.例えば,webサーバーにリクエストがあり正常にレスポンスした.
  • WARNING: 想定外の情報だがエラーではない.例えば,webサーバーにリクエストがあったがファイルが見つからなかった.
  • ERROR: エラーが発生した.例えば,webサーバーにリクエストがあり,処理中にZeroDivisionErrorが発生した.

上記の例では,level=logging.DEBUGと設定しているので,ログが4個出力されています.level=logging.WARNINGに変更すると,以下のようにログが2個だけ出力されます.

WARNING:root:warning
ERROR:root:error

logging.info()logging.RootLoggerinfo()を呼ぶ関数で,logging.RootLogger()logging.Loggerを継承しており,import loggingを実行するときに自動的に作成されます*1

logging.Loggerは直接インスタンス化するのではなく,logging.getLogger(name)を使ってインスタンス化します *2.例えば以下のように使います.

import logging
logger = logging.getLogger(__name__)
logger.debug("debug")
logger.info("info")
logger.warning("warning")
logger.error("error")
warning
error

logging.Handler

logging.Loggerによって書き込まれたログを扱う方法を決定します.標準出力に書き込んだり(logging.StreamHandler),ファイルに書き込んだり(logging.FileHandler),メールを送信したりと(logging.handlers.SMTPHandler),多種多様な対応が可能です.

以下の例では,"test.log"にログを書き出すhandlerを使用します.

import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
handler = logging.FileHandler("test.log", encoding="utf-8")
handler.setLevel(logging.INFO)
logger.addHandler(handler)
logger.debug("debug")
logger.info("info")
logger.warning("warning")
logger.error("error")

test.log

info
warning
error

具体的なハンドラの例は,以下に記載されています.

logging.handlers — Logging handlers — Python 3.8.7 documentation

logging.Formatter

ログのフォーマットを決定します.以下の例では,フォーマットを指定しているので,出力されるログには時刻などの情報が追加されます.

import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
handler = logging.FileHandler("test.log", encoding="utf-8")
handler.setLevel(logging.INFO)
formatter = logging.Formatter(
    '%(asctime)s %(name)s %(levelname)s: %(message)s'
)
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.debug("debug")
logger.info("info")
logger.warning("warning")
logger.error("error")

test.log

2021-01-23 17:27:42,686 __main__ INFO: info
2021-01-23 17:27:42,690 __main__ WARNING: warning
2021-01-23 17:27:42,690 __main__ ERROR: error

フォーマットの詳細は以下の表が参考になります.

logging — Logging facility for Python — Python 3.8.7 documentation

logging.Filter

ログの情報を変更したり,削除したりします.以下の例では,nameを変更するフィルターを使います.

import logging


class SampleFilter(logging.Filter):
    def filter(self, record):
        record.name = "sample"
        return True


logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
handler = logging.FileHandler("test.log", encoding="utf-8")
handler.setLevel(logging.INFO)
formatter = logging.Formatter(
    '%(asctime)s %(name)s %(levelname)s: %(message)s'
)
handler.setFormatter(formatter)
f = SampleFilter()
logger.addFilter(f)
logger.addHandler(handler)
logger.debug("debug")
logger.info("info")
logger.warning("warning")
logger.error("error")
2021-01-23 17:34:40,860 sample INFO: info
2021-01-23 17:34:40,860 sample WARNING: warning
2021-01-23 17:34:40,861 sample ERROR: error

"main"が"sample"に置換されています.

logging.config.dictConfig

loggingの設定をカスタマイズしていくと,上記のソースコードのように可読性が低下していきますので,logging.config.dictConfig(config)を使用して辞書形式で一括設定すると便利です.

import logging
from logging.config import dictConfig
import json

with open("config.json", "r", encoding="utf-8") as f:
    dictConfig(json.load(f))

logger = logging.getLogger(__name__)
logger.debug("debug")
logger.info("info")
logger.warning("warning")
logger.error("error")

config.json

{
    "version": 1,
    "disable_existing_loggers": false,
    "formatters": {
        "file_formatter": {
            "format": "%(asctime)s %(name)s %(levelname)s: %(message)s"
        }
    },
    "handlers": {
        "file_handler": {
            "class": "logging.FileHandler",
            "level": "INFO",
            "formatter": "file_formatter",
            "filename": "test.log",
            "mode": "a",
            "encoding": "utf-8"
        }
    },
    "root": {
        "level": "INFO",
        "handlers": [
            "file_handler"
        ]
    }
}
2021-01-23 17:50:32,000 __main__ INFO: info
2021-01-23 17:50:32,001 __main__ WARNING: warning
2021-01-23 17:50:32,001 __main__ ERROR: error

DjangoのLOGGINGの設定

Djangoでは,今までやっていた設定ファイルの読み出しなど面倒な作業をsetup()関数が一括で設定してくれます.しなければならないことはsettings.pyLOGGINGに設定内容を記載するだけです.以下の設定でloggingが使えるようになります.

settings.py

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{asctime} {levelname} {module} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'formatter': 'verbose'
        },
    },
    'loggers': {
        'django': {
            'handlers': ['console'],
            'level': 'INFO',
        }
    }
}

banana/views.py

import logging

from django.http import JsonResponse

logger = logging.getLogger("django")


def sample_banana(request):
    logger.debug("debug")
    logger.info("info")
    logger.warning("warning")
    logger.error("error")
    return JsonResponse({"banana": 1})
January 23, 2021 - 08:58:10
Django version 3.1.5, using settings 'sample.settings'
Starting development server at http://0.0.0.0:8004/
Quit the server with CONTROL-C.
2021-01-23 08:58:43,230 INFO views info
2021-01-23 08:58:43,231 WARNING views warning
2021-01-23 08:58:43,231 ERROR views error
2021-01-23 08:58:43,235 INFO basehttp "GET /banana/sample HTTP/1.1" 200 13

DjangoはデフォルトでいくつかのLoggerを作成してくれます."django", "django.request", "django.server", などがあります.使い分ければ便利かもしれませんが,Loggerが存在しなければ一つ上の階層のLoggerが使われる仕様を利用して,上記のように"django" Loggerのみに上書きすれば,全てのログを一括して扱うことができます.

なお,"disable_existing_loggers"をTrueにすればよさそうに見えますが,デフォルトで作成される"django.request"などのLoggerを無効にするだけなので,一部のログが出力されなくなります.

詳細は以下に記載されています.

Logging | Django documentation | Django

Cloud Logging

Cloud Logging | Google Cloud

Google Cloud Platformで提供されているログ管理サービスです.「リアルタイムでのログの管理とスケーラブルな分析」と宣伝されており,面倒なログ管理をアウトソーシングすることができます.

認証の準備

"django-logger@test-project.iam.gserviceaccount.com" などという名前のサービスアカウントを作成し,"roles/logging.logWriter"を設定します.

json形式のcredentialをダウンロードし,GOOGLE_APPLICATION_CREDENTIALSという名前の環境変数にパスを指定します.

Djangoをローカルで実行してログを送信

google-cloud-loggingという名前のCloud LoggingのClientを使います.

Python Client for Cloud Logging — google-cloud-logging documentation

requirements.txt

Django==3.1.5
google-cloud-logging==2.0.2

settings.py

import google.cloud.logging
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{asctime} {levelname} {module} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'cloud_logging': {
            'class': 'google.cloud.logging.handlers.CloudLoggingHandler',
            'client': google.cloud.logging.Client(),
            'formatter': 'verbose'
        },
        'console': {
            'class': 'logging.StreamHandler',
            'formatter': 'verbose'
        },
    },
    'loggers': {
        'django': {
            'handlers': ['cloud_logging', 'console'],
            'level': 'INFO'
        },
    }
}

これを実行し,リクエストを送信すると,Cloud Loggingにログが送信されます.

f:id:atsuhiro-me:20210123192959p:plain

console handlerも追加しているので,標準出力にもログが表示されるようにしています.

2021-01-23 10:33:53,925 INFO views info
2021-01-23 10:33:53,926 WARNING views warning
2021-01-23 10:33:53,926 ERROR views error
2021-01-23 10:33:53,926 INFO basehttp "GET /banana/sample HTTP/1.1" 200 13

これらの設定で例外の情報もログに出力されるようになります.以下にように例外を意図的に発生させ,ログを確認しました.

views.py

def sample_banana(request):
    logger.debug("debug")
    logger.info("info")
    logger.warning("warning")
    logger.error("error")
    raise ValueError("invalid banana")

f:id:atsuhiro-me:20210123194402p:plain

logging.Formatterの調整

Cloud Loggingでは,自動的にレベルや時刻が表示されるので,logging.Formatterではメッセージのみ表示するのがよさそうです.

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{message}',
            'style': '{',
        },
    },
    'handlers': {
        'cloud_logging': {
            'class': 'google.cloud.logging.handlers.CloudLoggingHandler',
            'client': google.cloud.logging.Client(),
            'formatter': 'verbose',
        },
        'console': {
            'class': 'logging.StreamHandler',
            'formatter': 'verbose'
        },
    },
    'loggers': {
        'django': {
            'handlers': ['cloud_logging', 'console'],
            'level': 'INFO'
        },
    }
}

views.py

def sample_banana(request):
    logger.debug("debug")
    logger.info("info")
    logger.warning("warning")
    logger.error("error")
    return JsonResponse({"banana": 1})


def error_banana(request):
    raise ValueError("Invalid banana.")

f:id:atsuhiro-me:20210123203528p:plain

項目の重複がなくなり見やすくなりました.

Cloud Run

上記で作成したDjango プロジェクトをCloud Runで実行してみます."console" loggerが不要なので削除し,以下の設定にしました.

settings.py

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'handlers': {
        'cloud_logging': {
            'class': 'cloud_logging.CloudLoggingHandler',
            'client': google.cloud.logging.Client()
        },
        'console': {
            'class': 'logging.StreamHandler'
        },
    },
    'loggers': {
        'django': {
            'handlers': ['cloud_logging'],
            'level': 'INFO'
        },
    }
}

200ms毎に12個のログを出力するviewを作成します.

views.py

import logging
import time

from django.http import JsonResponse

logger = logging.getLogger("django")

def sample_banana(request):
    for i in range(12):
        logger.info(f"{i}")
        time.sleep(0.2)
    return JsonResponse({"banana": 1})

呼び出すと以下のようなログが生成されます.

f:id:atsuhiro-me:20210124035314p:plain

また,Cloud Loggingのtrace context*3に対応するために,以下のようなCloudLoggingMiddlewareCloudLoggingHandlerを新たに追加しました.

cloud_logging.py

import json
import logging
import os
import re
import threading

from django.http import JsonResponse
from google.cloud.logging_v2 import _helpers

logger = logging.getLogger("django")
re_trace_context = re.compile(r'^([0-9a-z]+)/([0-9]+)')
th = threading.local()
project_id = os.environ.get("PROJECT_ID")

class CloudLoggingMiddleware(object):
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        meta = request.META
        trace_context = meta.get('HTTP_X_CLOUD_TRACE_CONTEXT', "")
        m = re_trace_context.match(trace_context)
        if m:
            th.trace = f"projects/{project_id}/traces/{m.group(1)}"
            th.span_id = m.group(2)
        labels = {}
        user = request.user
        labels["user_pk"] = f"{user.pk}" if user.is_authenticated else "guest"
        th.labels = labels
        #
        response = self.get_response(request)
        #
        th.__dict__.clear()
        return response

class CloudLoggingHandler(logging.StreamHandler):

    def __init__(self, client, name="python", stream=None):
        super(CloudLoggingHandler, self).__init__(stream)
        self.logger = client.logger(name)

    def emit(self, record):
        self.logger.log_struct(
            {
                "message": super(CloudLoggingHandler, self).format(record),
                "python_logger": record.name
            },
            severity=_helpers._normalize_severity(record.levelno),
            labels=getattr(th, "labels", None),
            trace=getattr(th, "trace", None),
            span_id=getattr(th, "span_id", None)
        )

CloudLoggingMiddlewareは,requestからtrace idspan idを取り出して,threading.local()に保存します.このようにすることで,マルチスレッドなwebサーバー環境でも動作します*4.ついでに,Djangoのユーザーとしてログインしていたら,user pkをラベルに追加します.

取得したコンテキストをlog_struct()を呼び出すときに取得し設定します.このようにすることで,いつもどおりにloggingを使用するだけで自動的に必要なコンテクストを設定することができます.

上記のCloudLoggingMiddlewareCloudLoggingHandlerは,以下のソースコードを参考に作成しました.

Cloud RunでDjangoをマルチスレッドで実行する

以下のgunicorn-config.pyを使ってgunicornを起動します.

workers = 3
worker_class = 'sync'
threads = 3
preload_app = True

workerとthreadsを使ってマルチスレッドにします.Cloud Runの制約のため,preload_appを有効にしています.以下のGunicornログから,workerが正常に3個起動したことを確認しました.

Using worker: threads
[INFO] Booting worker with pid: 3
[INFO] Booting worker with pid: 6
[INFO] Booting worker with pid: 11

Settings — Gunicorn 20.0.4 documentation

Cloud Runで並列処理を許可し,複数のインスタンスが起動しないようにするために,以下の設定を追加しました.

Concurrency 9
Max instances 1

複数のHTTPリクエストを並行して送信する

以下のPythonスクリプトを使って,Cloud RunのURLに対して複数のHTTPリクエストを並行して送信します.

import requests
import asyncio

url = "https://xxxxxxxx.a.run.app/xxx"

def send_request(i):
    print(i)
    requests.get(url)

async def run(loop):
    async def run_req(i):
        return await loop.run_in_executor(None, send_request, i)

    tasks = [run_req(i) for i in range(10)]
    return await asyncio.gather(*tasks)

loop = asyncio.get_event_loop()
loop.run_until_complete(run(loop)

呼び出すviewは200msごとにログを出力し,これがマルチスレッドで実行されるので,以下のように複数のリクエストのログが入り乱れます.これは多数のリクエストを並行して処理するWebサーバーでは日常的な現象です.

f:id:atsuhiro-me:20210124035455p:plain

このログを追跡するのは困難です.幸いなことに,全てのログにtrace idを設定しているので,一連のログのみを表示することができます.

f:id:atsuhiro-me:20210124035905p:plain

以上で,Django のログインユーザーと,Cloud Runのリクエストログと,アプリケーションログが全て関連付きました.

Cloud Trace

上記でコンテキストの設定時に,span idも指定しているので,このままCloud Traceでトレースすることができます.

例えば,先程の200ms毎に12個のログを出力するviewをトレースするとこのようになります.

f:id:atsuhiro-me:20210124042133p:plain

説明通りに処理されていることが視覚的に分かります.

まとめ

Cloud LoggingとDjangoが関連して動作するようにすることで,Webサーバーのログがとても見やすくなりました.