Pythonのロギング:本番環境向けの設定
1. 概要
print()は小さなスクリプトを書く際には十分です。ただし本番環境では異なります。ログレベルでフィルタリングし、ログを集約システムに統合し、複数のログ行を通じてリクエストを追跡し、ログの書き込みがアプリケーションを遅くしてはいけません。
この記事はPythonの標準loggingモジュールを本番環境向けに設定する方法を説明します。読み終わったら、モジュール別logger、dictConfig、JSON形式ログ、自動的に付与されるrequest_id、QueueHandlerによる非同期ログ出力、およびpytestのcaplogを使ったログテストなど、完全な設定を構築できるようになります。すべての例はPython 3.12を使用し、標準ライブラリのみが必要です。テストの部分のみpytestが必要です。
2. printとbasicConfigが不十分な理由
注文を処理するサービスを「とりあえず完成させる」というスタイルで書いたコードを見てみましょう:
import logging
logging.basicConfig(level=logging.INFO)
def place_order(order_id: str, amount: float) -> None:
print(f"Placing order {order_id}")
logging.info(f"Order {order_id} amount={amount}")
place_order("ORD-1001", 250_000)
Placing order ORD-1001
INFO:root:Order ORD-1001 amount=250000
このコードには3つの問題があります:
printはレベル、タイムスタンプがなく、設定で無効化できません。logging.info(...)はルートロガーに書き込みます。ログがどのモジュールから来たのか分かりませんし、各部分のレベルを個別に調整できません。basicConfigは最初の呼び出しでのみ効果があります。root loggerがハンドラーをまだ持たない場合です。別のライブラリが最初に呼び出した場合、あなたの設定は警告なしに無視されます。
3. getLogger(__name__)によるモジュール別logger
最初のルール:各モジュールは独自のloggerを作成し、__name__で名前を付けます。
# shop/orders.py
import logging
logger = logging.getLogger(__name__) # logger名: "shop.orders"
class OrderService:
def place_order(self, order_id: str, quantity: int) -> None:
logger.info("Placing order %s with quantity %d", order_id, quantity)
if quantity > 50:
logger.warning("Large order %s: quantity=%d", order_id, quantity)
logger名はドット区切りの階層構造を形成します。shop.ordersはshopの子で、shopはrootの子です。レコードは子loggerから親loggerに上がります(これをpropagationといいます)。したがって、rootまたはshopレベルでハンドラーをアタッチし、各ブランチのレベルを個別に調整するだけで十分です。たとえば、shop.paymentsをDEBUGに設定して、残りの部分をINFOに保つことができます。
パラメータの渡し方にも注意してください:f文字列ではなく%sを使用し、引数として渡します。レベルが無効の場合、文字列はフォーマットされないため、決して出力されないログのコストを無駄にしません。さらに、ログ集約ツールは同じメッセージテンプレートのログをグループ化できます。
注意: ライブラリ(他のユーザーが使用するために発行するパッケージ)では、ハンドラーを設定しないでください。
getLogger(__name__)を呼び出すだけで、アプリケーションに決定を任せます。必要に応じて、logging.getLogger("your_lib").addHandler(logging.NullHandler())を追加します。
4. dictConfigによる集約設定
本番環境では、すべての設定を1つの場所に集め、アプリケーション起動時に1度だけ呼び出すべきです。logging.config.dictConfigはこの目的に適しています。
# shop/logging_config.py
import logging.config
import os
def setup_logging() -> None:
level = os.getenv("LOG_LEVEL", "INFO")
logging.config.dictConfig({
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"plain": {
"format": "%(asctime)s %(levelname)-8s %(name)s - %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "plain",
},
},
"loggers": {
"shop": {"level": level},
"urllib3": {"level": "WARNING"},
},
"root": {"level": "WARNING", "handlers": ["console"]},
})
設定内の主要なポイント:
"disable_existing_loggers": Falseは実際には必須です。デフォルト値のTrueは、dictConfig実行前に作成されたすべてのloggerを無効化します。これはあなたが各モジュールの先頭で import したloggerも含みます。結果としてログが消えますが、エラーは出ません。- ハンドラーはrootにのみアタッチされます。logger
shopはレベルのみを設定し、レコードはrootまで伝播して出力されます。 - rootは
WARNINGに設定され、サードパーティライブラリからのうるさいINFOログを遮断します。shopブランチのコードは引き続きINFOでログします。 - レベルは
LOG_LEVEL環境変数から読み込まれます。問題を調査する際、コードを変更せずにDEBUGを有効にできます。
エントリーポイントで関数を呼び出します:
# main.py
from shop.logging_config import setup_logging
from shop.orders import OrderService
setup_logging()
OrderService().place_order("ORD-1001", quantity=80)
2026-10-18 09:15:02,114 INFO shop.orders - Placing order ORD-1001 with quantity 80
2026-10-18 09:15:02,114 WARNING shop.orders - Large order ORD-1001: quantity=80
stdoutへの出力は、コンテナ内で実行するときの正しい選択です。DockerまたはKubernetesはこのストリームからログを収集するため、ファイルを自分で管理したりログローテーションを心配する必要はありません。
5. JSON形式の構造化ログ
LokiやElasticsearch、CloudWatchなどのログ集約システムは、自由形式のテキストよりもJSONを検索する方がはるかに優れています。外部ライブラリなしでフォーマッターを自分で書くことができます:
# shop/json_formatter.py
import json
import logging
from datetime import datetime, timezone
_RESERVED = set(vars(logging.LogRecord("", 0, "", 0, "", None, None))) | {"message", "asctime"}
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
for key, value in record.__dict__.items():
if key not in _RESERVED and not key.startswith("_"):
payload[key] = value
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, default=str)
仕組み:
_RESERVEDはLogRecordのデフォルト属性(pathname、lineno、argsなど)を含みます。- 他のすべての属性はカスタムフィールドと見なされ、JSONに追加されます。これらのフィールドは通常、
extra=パラメータまたはフィルタから来ます(セクション6を参照)。 default=strは、DecimalやUUIDなどのタイプに出会ったときにjson.dumpsがエラーにならないようにします。
dictConfigでフォーマッターを宣言し、特殊キー"()"を使用します:
"formatters": {
"json": {"()": "shop.json_formatter.JsonFormatter"},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "json",
},
},
extraでビジネスフィールドを追加します:
logger.info("Payment captured", extra={"order_id": "ORD-1001", "amount": 250000})
{"ts": "2026-10-18T02:15:02.114+00:00", "level": "INFO", "logger": "shop.payments", "message": "Payment captured", "order_id": "ORD-1001", "amount": 250000}
注意:
extra内のキーは、LogRecordの既存の属性名と重複してはいけません。たとえば、extra={"name": "..."}またはextra={"message": "..."}はKeyError: "Attempt to overwrite 'name' in LogRecord"をスローします。
6. contextvarsを使用した自動request_id付与
毎回のログ呼び出しでextra={"request_id": ...}を渡すのは冗長で忘れやすいです。より良い方法は、request_idをContextVarに保存し、Filterでそれをすべてのレコードに自動的に付与することです。
# shop/request_context.py
import logging
from contextvars import ContextVar
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
class RequestIdFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id_var.get()
return True # レコードをブロックしない、データを補加するのみ
ContextVarはスレッドとasyncioの両方で安全です。各タスクまたはリクエストは独自の値を保持し、あるリクエストが別のリクエストのIDを「借りる」ことはありません。
フィルターをloggerではなく、ハンドラーにアタッチします。loggerにアタッチされたフィルターは、そのloggerから直接作成されたレコードに対してのみ実行され、子loggerから伝播してくるレコードには実行されません。
"filters": {
"request_id": {"()": "shop.request_context.RequestIdFilter"},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "json",
"filters": ["request_id"],
},
},
各リクエストの開始時に値を設定します。例えば、ミドルウェアで:
import uuid
from shop.request_context import request_id_var
def handle_request(order_id: str) -> None:
token = request_id_var.set(uuid.uuid4().hex[:12])
try:
OrderService().place_order(order_id, quantity=3)
finally:
request_id_var.reset(token)
{"ts": "...", "level": "INFO", "logger": "shop.orders", "message": "Placing order ORD-1002 with quantity 3", "request_id": "9f2c1ab07d3e"}
これでrequest_idでフィルタリングするだけで、リクエストの全体的な流れが見えます。
7. 例外ログの正しい記録
logger = logging.getLogger("shop.payments")
class PaymentService:
def charge(self, order_id: str, amount: int) -> bool:
try:
raise ConnectionError("gateway timeout")
except ConnectionError:
logger.exception("Charge failed for order %s", order_id)
return False
logger.exception(...)はERRORレベルで記録し、スタックトレースを自動的に添付します。これはexceptブロック内でのみ呼び出します。別のレベルでスタックトレースと共に記録したい場合は、logger.warning("...", exc_info=True)を使用します。
よくある2つのミス:
logger.error(f"Charge failed: {e}")はスタックトレースを失います。調査する際、「gateway timeout」という1行だけが見え、それがどこで発生したかは分かりません。- 各レイヤーでログと再スローの両方を行うこと。同じ例外が5回ログに出現します。例外処理を行うレイヤー、つまり再スローしないことに決めた場所でログしてください。
8. ログがアプリケーションをブロックしない:QueueHandler
StreamHandlerがstdoutに書き込むのは通常十分な速度です。しかし、syslogやHTTP、SMTPなどを経由するハンドラーやディスクへの書き込みが遅い場合、リクエストを処理しているスレッド自体がブロックされます。これを回避するために、レコードをキューに入れ、バックグラウンドスレッドが実際の出力先に書き込みます:
import atexit
import logging
import logging.handlers
import queue
def setup_async_logging(target: logging.Handler) -> None:
log_queue: queue.Queue = queue.Queue(-1)
queue_handler = logging.handlers.QueueHandler(log_queue)
listener = logging.handlers.QueueListener(
log_queue, target, respect_handler_level=True
)
listener.start()
atexit.register(listener.stop) # 終了時に残りをフラッシュ
root = logging.getLogger()
root.handlers = [queue_handler]
処理フロー:
- アプリケーションスレッドは、レコードを
queueにputすることだけを行います。この操作はほぼ即座です。 QueueListenerは独立したスレッドで実行され、キューからレコードを取り出し、target(例えばSysLogHandler)に転送します。atexit.register(listener.stop)は、プロセス終了前に最後のログが書き込まれることを保証します。
注意:
RequestIdFilterなどのフィルターは、handler送信先ではなく、QueueHandlerにアタッチする必要があります。handler送信先はリスナースレッドで実行され、そのスレッドのContextVarは元のリクエストの値を持たないからです。
9. pytestのcaplogでログをテストする
ビジネスロジックの警告、例えば大量注文は、テストされるべき動作です。dev依存関係にpytestを追加します:
pip install pytest
# tests/test_orders.py
import logging
from shop.orders import OrderService
def test_large_order_logs_warning(caplog):
with caplog.at_level(logging.INFO, logger="shop.orders"):
OrderService().place_order("ORD-2001", quantity=80)
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
assert warnings[0].name == "shop.orders"
assert warnings[0].getMessage() == "Large order ORD-2001: quantity=80"
def test_small_order_does_not_warn(caplog):
with caplog.at_level(logging.INFO, logger="shop.orders"):
OrderService().place_order("ORD-2002", quantity=2)
assert all(r.levelno < logging.WARNING for r in caplog.records)
tests/test_orders.py .. [100%]
2 passed in 0.03s
caplog.at_levelはwithブロック内で一時的にlogger shop.ordersのレベルを下げます。caplog.recordsは元のLogRecordを含むため、レベル、loggerの名前、およびextraフィールド(存在する場合)をチェックできます。
注意:
caplogはrootロガーにハンドラーをアタッチします。設定でloggershopに対して"propagate": Falseを設定した場合、テストはレコードをキャッチしません。
10. よくあるミスと避けるべきこと
- モジュールレベルでのロギング設定(import時に
basicConfigまたはdictConfigを呼び出す)。エントリーポイントで1回だけ設定してください。 - ハンドラーが2回アタッチされる。例えば、
propagateがTrueのままshopとrootの両方にアタッチされている場合。各ログ行が2回出力されます。ハンドラーは1つのレイヤーにのみアタッチすべきです。 - 機密データを記録する。パスワード、トークン、カード番号です。リクエストオブジェクト全体をログしないでください。必要な場合は、これらのフィールドをマスクする
Filterを書いてください。 - 複数プロセスアプリケーションで
RotatingFileHandlerを使用する(複数workerを持つGunicorn)。複数のプロセスが同じファイルをローテーションすると、ログが失われます。stdoutに出力するか、各workerから単一の書き込みプロセスにQueueHandler経由でプッシュしてください。 - ホットループで
INFOを過度にログしたい。ストレージコストと雑音が非常に速く増加します。このようなログはDEBUGに置くべきです。 - 開発環境でJSONを使用したい。JSONは読みにくいため、環境変数に応じてフォーマッターを選択してください。開発環境では
plain、本番環境ではjson。
11. まとめ
本番環境向けのロギング設定は複雑ではなく、一貫性が必要です。すべてのモジュールでgetLogger(__name__)を使用します。dictConfigの単一呼び出しで設定を集約し、disable_existing_loggers: Falseを設定します。JSON形式をstdoutに出力し、ContextVarとFilter経由でrequest_idを付与し、logger.exceptionを使ってスタックトレースを保持します。handler送信先が遅い場合は、QueueHandlerとQueueListenerに切り替えます。
最後に、ログをシステムの動作の一部と見なし、caplogでテストしてください。午前2時に問題が発生した時、構造化されたこれらのログ行があれば、原因を非常に迅速に特定できます。