Back to Journal
02 / Entry· 6 min read

一篇可复制到任意项目的 Python 日志配置指南

通用日志配置指南:支持多环境、JSON输出、追踪与集中采集。

🔊 系统朗读

一套好的日志方案不应该把输出位置写死。下面这套 Python 标准库方案遵循一个简单模型:

  • 默认只输出到控制台,开箱可用,适合脚本、开发环境和容器;
  • 通过环境变量或应用设置,可同时开启控制台与文件;
  • 后端可扩展到 Elasticsearch、Loki、CloudWatch、Kafka 等集中日志系统;
  • 所有输出保持统一的时间、等级、模块名、Trace ID 和消息格式。

它适用于命令行工具、后台任务、Web 服务和 Agent 项目。示例使用 Python 3.10+ 类型标注,日志核心仅依赖标准库 logging

不要记录 API Key、Cookie、密码、私钥、完整请求头或未经脱敏的隐私数据。运行日志必须加入 .gitignore,不要提交到版本库。

0. 最快接入:一个通用模块

创建 logging_setup.py。不要命名为 logging.py,以免遮蔽 Python 标准库:

text
your-project/
├── logging_setup.py       # 复制下文代码
├── main.py
├── logging.yaml           # 可选:部署级配置
└── logs/                  # 运行时生成,加入 .gitignore

若将模块放进包内,例如 my_app/logging_setup.py,后文 YAML 中的类路径要改为 my_app.logging_setup.TraceIdFilter

1. 通用日志模块:默认控制台,可按设置开启文件

把以下代码复制到 logging_setup.py。默认参数为 enable_console=Trueenable_file=False,因此没有任何设置时只向控制台输出。将两个开关同时设为 True,即可同时写控制台和文件。

python
"""可复用的日志初始化、输出目标和 Trace ID 上下文。"""

import json
import logging
import logging.config
from collections.abc import Mapping
from contextvars import ContextVar, Token
from datetime import datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
from zoneinfo import ZoneInfo

trace_id: ContextVar[str] = ContextVar("trace_id", default="-")


class TraceIdFilter(logging.Filter):
    """把当前 Trace ID 注入每条 LogRecord,供 formatter 使用。"""

    def filter(self, record: logging.LogRecord) -> bool:
        record.trace_id = trace_id.get()
        return True


class TimezoneFormatter(logging.Formatter):
    """以指定 IANA 时区输出人类可读日志。"""

    def __init__(
        self,
        format: str | None = None,
        datefmt: str | None = None,
        style: str = "%",
        timezone_name: str = "UTC",
    ) -> None:
        super().__init__(fmt=format, datefmt=datefmt, style=style)
        self._timezone = ZoneInfo(timezone_name)

    def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
        value = datetime.fromtimestamp(record.created, tz=self._timezone)
        return value.strftime(datefmt) if datefmt else value.isoformat(timespec="milliseconds")


class JsonFormatter(logging.Formatter):
    """输出一行一个 JSON 对象(NDJSON),适合日志采集器。"""

    def __init__(self, timezone_name: str = "UTC") -> None:
        super().__init__()
        self._timezone = ZoneInfo(timezone_name)

    def format(self, record: logging.LogRecord) -> str:
        payload: dict[str, Any] = {
            "timestamp": datetime.fromtimestamp(
                record.created, tz=self._timezone
            ).isoformat(timespec="milliseconds"),
            "level": record.levelname,
            "logger": record.name,
            "trace_id": getattr(record, "trace_id", "-"),
            "message": record.getMessage(),
        }
        if record.exc_info:
            exc_type, exc_value, _ = record.exc_info
            payload["exception"] = {
                "type": exc_type.__name__ if exc_type else "Exception",
                "message": str(exc_value),
                "stacktrace": self.formatException(record.exc_info),
            }
        return json.dumps(payload, ensure_ascii=False, default=str)


def new_trace_id() -> str:
    return uuid4().hex


def set_trace_id(value: str) -> Token[str]:
    return trace_id.set(value)


def reset_trace_id(token: Token[str]) -> None:
    trace_id.reset(token)


def build_logging_config(
    *,
    level: str = "INFO",
    log_format: str = "text",
    timezone_name: str = "UTC",
    enable_console: bool = True,
    enable_file: bool = False,
    file_path: str = "logs/app.log",
    max_bytes: int = 10 * 1024 * 1024,
    backup_count: int = 5,
) -> dict[str, Any]:
    """构建标准 dictConfig;至少保留控制台,避免无日志输出。"""
    formatter_name = "json" if log_format.lower() == "json" else "standard"
    handlers: dict[str, dict[str, Any]] = {}

    if enable_console:
        handlers["console"] = {
            "class": "logging.StreamHandler",
            "level": level,
            "formatter": formatter_name,
            "filters": ["trace_id"],
            "stream": "ext://sys.stdout",
        }

    if enable_file:
        handlers["file"] = {
            "class": "logging.handlers.RotatingFileHandler",
            "level": level,
            "formatter": formatter_name,
            "filters": ["trace_id"],
            "filename": file_path,
            "maxBytes": max_bytes,
            "backupCount": backup_count,
            "encoding": "utf-8",
        }

    if not handlers:
        handlers["console"] = {
            "class": "logging.StreamHandler",
            "level": level,
            "formatter": formatter_name,
            "filters": ["trace_id"],
            "stream": "ext://sys.stdout",
        }

    return {
        "version": 1,
        "disable_existing_loggers": False,
        "filters": {"trace_id": {"()": TraceIdFilter}},
        "formatters": {
            "standard": {
                "()": TimezoneFormatter,
                "format": (
                    "%(asctime)s | %(levelname)-8s | %(name)s | "
                    "trace_id=%(trace_id)s | %(message)s"
                ),
                "datefmt": "%Y-%m-%d %H:%M:%S",
                "timezone_name": timezone_name,
            },
            "json": {"()": JsonFormatter, "timezone_name": timezone_name},
        },
        "handlers": handlers,
        "root": {"level": level, "handlers": list(handlers)},
    }


DEFAULT_LOGGING_CONFIG = build_logging_config()


def configure_logging(config: Mapping[str, Any] | None = None) -> None:
    """应用外部 dictConfig;不传配置时使用默认控制台输出。"""
    logging_config = dict(config) if config else DEFAULT_LOGGING_CONFIG
    _create_log_directories(logging_config)
    logging.config.dictConfig(logging_config)


def _create_log_directories(config: Mapping[str, Any]) -> None:
    """为含 filename 的文件 handler 自动创建父目录。"""
    handlers = config.get("handlers", {})
    if not isinstance(handlers, Mapping):
        return

    for handler in handlers.values():
        if not isinstance(handler, Mapping):
            continue
        filename = handler.get("filename")
        if isinstance(filename, str) and filename:
            Path(filename).expanduser().parent.mkdir(parents=True, exist_ok=True)

日志默认格式:

text
2026-07-20 16:00:00 | INFO     | my_app.service | trace_id=4a8c... | 任务已完成
字段格式占位符用途
时间%(asctime)s事件发生时间;datefmt 控制为 年-月-日 时:分:秒
等级%(levelname)-8sDEBUGINFOWARNINGERRORCRITICAL-8s 用于对齐。
模块%(name)slogging.getLogger(__name__) 自动给出的模块路径。
Trace ID%(trace_id)s同一次请求、消息或后台任务的关联标识。
消息%(message)s业务代码写入的日志消息。

timezone_name 默认是 UTC,文本和 JSON formatter 都使用同一个时区。通过 LOG_TIMEZONEbuild_logging_config(timezone_name="Asia/Shanghai") 传入 IANA 时区名即可调整,例如 Asia/ShanghaiAmerica/Los_Angeles。需要确保部署系统提供 IANA 时区数据库;部分精简镜像或 Windows 环境可能需要额外提供 tzdata

JSON 格式:NDJSON 与 UTC/可配置时区

设置 LOG_FORMAT=json 后,每条日志输出为一行 JSON(NDJSON),控制台、文件、Fluent Bit、Filebeat 和 OpenTelemetry Collector 都能逐行解析:

json
{"timestamp":"2026-07-20T16:00:00.123+00:00","level":"INFO","logger":"my_app.service","trace_id":"4a8c...","message":"任务已完成"}

固定字段为 timestamplevelloggertrace_idmessage;在 logger.exception(...) 或带有异常信息的日志中,还会增加 exception.typeexception.messageexception.stacktrace。JSON 的 timestamp 使用 timezone_name:默认 UTC 为 +00:00,设置 LOG_TIMEZONE=Asia/Shanghai 后会输出 +08:00json.dumps() 会正确转义换行、引号和非 ASCII 字符,因此一条日志始终保持一行。

2. 环境变量或应用设置:选择输出目标

入口只在初始化阶段配置日志。下面示例以环境变量作为最通用的设置来源;也可以替换成 Django、FastAPI、Pydantic Settings、配置中心或任意项目自己的设置对象。

python
# main.py
import logging
import os

from logging_setup import (
    build_logging_config,
    new_trace_id,
    reset_trace_id,
    set_trace_id,
    configure_logging,
)

logger = logging.getLogger(__name__)


def env_flag(name: str, default: bool) -> bool:
    value = os.getenv(name)
    if value is None:
        return default
    return value.strip().lower() in {"1", "true", "yes", "on"}


def configure_application_logging() -> None:
    environment = os.getenv("APP_ENV", "development")
    default_level = "DEBUG" if environment == "development" else "INFO"

    config = build_logging_config(
        level=os.getenv("LOG_LEVEL", default_level),
        log_format=os.getenv("LOG_FORMAT", "text"),
        timezone_name=os.getenv("LOG_TIMEZONE", "UTC"),
        enable_console=env_flag("LOG_CONSOLE", True),
        enable_file=env_flag("LOG_FILE", False),
        file_path=os.getenv("LOG_FILE_PATH", "logs/app.log"),
    )
    configure_logging(config)


def main() -> None:
    token = set_trace_id(new_trace_id())
    try:
        configure_application_logging()
        logger.info("应用启动")
        # 执行业务逻辑
    except Exception:
        logger.exception("应用运行失败")
        raise
    finally:
        reset_trace_id(token)


if __name__ == "__main__":
    main()

建议的环境设置如下:

场景APP_ENVLOG_CONSOLELOG_FILELOG_FORMATLOG_TIMEZONE输出结果
未设置任何变量developmenttruefalsetextUTC默认仅控制台
本地开发developmenttruefalsetextAsia/Shanghai控制台 + DEBUG 级别。
传统服务器productiontruetruetextjsonUTC同时控制台和滚动文件
Docker/KubernetesproductiontruefalsejsonUTCNDJSON 写 stdout,由平台采集。

如果 LOG_CONSOLE=falseLOG_FILE=false,通用模块会回退到控制台,防止应用完全失去日志。要实现“只写文件”,请设置 LOG_CONSOLE=falseLOG_FILE=true;但生产环境通常不建议隐藏控制台日志。

3. YAML 配置:显式组合 console、file 和自定义后端

使用 YAML 的项目可以直接把 logging 字典交给 configure_logging()root.handlers 就是输出目标的总开关:

yaml
logging:
  version: 1
  disable_existing_loggers: false

  filters:
    trace_id:
      (): logging_setup.TraceIdFilter

  formatters:
    standard:
      (): logging_setup.TimezoneFormatter
      format: "%(asctime)s | %(levelname)-8s | %(name)s | trace_id=%(trace_id)s | %(message)s"
      datefmt: "%Y-%m-%d %H:%M:%S"
      timezone_name: Asia/Shanghai
    json:
      (): logging_setup.JsonFormatter
      timezone_name: UTC

  handlers:
    console:
      class: logging.StreamHandler
      level: INFO
      formatter: standard
      filters: [trace_id]
      stream: ext://sys.stdout

    file:
      class: logging.handlers.RotatingFileHandler
      level: INFO
      formatter: standard
      filters: [trace_id]
      filename: logs/app.log
      maxBytes: 10485760
      backupCount: 5
      encoding: utf-8

  root:
    level: INFO
    handlers: [console, file]

要让某个目标输出 JSON,只需把该 handler 的 formatter 改为 json;例如容器环境通常将 console.formatter 改为 json,传统服务器可让 console 保持 standardfile 使用 json,以同时兼顾本地阅读和采集。timezone_name 可用任意有效 IANA 时区名,建议集中设置为 UTC,不要让不同 handler 混用时区。

选择不同的 root.handlers,就得到不同组合:

yaml
# 开发、容器或默认模式:只输出控制台
handlers: [console]

# 传统服务器:同时输出控制台与文件
handlers: [console, file]

# 使用自定义集中日志 handler 后:控制台与集中日志
handlers: [console, elasticsearch]

YAML 的 handlers 只是声明能力,只有出现在 root.handlers 或某个具名 logger 的 handlers 列表中才会真正生效。每个使用 %(trace_id)s 的 handler 都必须包含 filters: [trace_id]

若 YAML 加载可能失败,可先调用 configure_logging() 应用默认控制台配置,再加载 YAML 并二次调用 configure_logging(config["logging"])。这样解析失败也有可读的诊断日志;业务模块仍不应自行配置日志。

将运行日志目录加入 Git 忽略:

gitignore
logs/

4. 文件日志切分:大小、时间或平台托管

同一个文件 handler 只选择一种切分策略。

策略 A:按文件大小切分

RotatingFileHandler 适合日志量有波动、重点是限制磁盘占用的传统服务器:

yaml
file:
  class: logging.handlers.RotatingFileHandler
  filename: logs/app.log
  maxBytes: 10485760  # 10 MiB
  backupCount: 5
  encoding: utf-8

当前文件达到 10 MiB 后会变为 app.log.1,更早文件依次递增;超过 5 个备份时自动删除最旧文件。磁盘占用大致不超过 60 MiB(当前文件加 5 个备份,实际略有波动)。

策略 B:按时间切分

TimedRotatingFileHandler 适合按天归档、按日期查询的服务。用下面的 file 配置替换策略 A:

yaml
file:
  class: logging.handlers.TimedRotatingFileHandler
  filename: logs/app.log
  when: midnight
  interval: 1
  backupCount: 14
  utc: true
  encoding: utf-8

它会在每天 UTC 零点后的下一次日志写入时切分,通常生成 app.log.2026-07-20,并保留 14 天。when 也可用 H(小时)和 D(天)。

策略 C:平台托管

Docker、Kubernetes、ELK、Loki 和云日志平台通常只保留 console,由平台完成采集、轮转、索引和保留:

yaml
root:
  level: INFO
  handlers: [console]

多进程服务不要让多个进程无协调地写同一个本地文件;此时优先选择平台托管。

5. 扩展到 Elasticsearch 等集中日志后端

首选:应用 stdout → 采集器 → Elasticsearch

生产环境最稳妥的架构通常是:

text
Python 应用 → stdout → Fluent Bit / Filebeat / OpenTelemetry Collector → Elasticsearch → Kibana

应用只保留 console handler,采集器负责批量发送、重试、缓存、断网续传、索引生命周期和权限管理。这种方式不会因为 Elasticsearch 短暂不可用而阻塞业务线程,也是容器环境的首选。

日志平台要求 JSON 时,设置 LOG_FORMAT=json 或将 YAML handler 的 formatter 设为 json;采集器即可逐行解析。字段已包含时间、级别、logger 名称、Trace ID、消息和异常堆栈,适合建立 Elasticsearch 索引和 Kibana 查询。

备选:应用内自定义 Elasticsearch handler

标准库没有 Elasticsearch handler,但 dictConfig 支持加载自定义或第三方 handler。实现后可如下声明:

yaml
handlers:
  elasticsearch:
    class: my_app.logging_handlers.ElasticsearchHandler
    level: INFO
    formatter: standard
    filters: [trace_id]
    hosts: ["https://es.example.internal:9200"]
    index: app-logs

root:
  level: INFO
  handlers: [console, elasticsearch]

ElasticsearchHandler 应由项目自己实现或选用经过维护的库,并至少满足以下条件:

  1. 业务线程只写入有界队列,由后台线程/进程批量写 ES;不能同步等待网络请求;
  2. 设置连接和请求超时、指数退避与最大重试次数;
  3. 队列满、ES 不可用或认证失败时,有明确策略:丢弃低级别日志、写本地降级文件或发送监控告警;
  4. 使用批量写入,并为索引设置生命周期策略,避免无限增长;
  5. 不在日志 handler 内再次记录同一个 logger 的错误,避免递归日志风暴;
  6. URL、用户名和令牌通过环境变量或密钥管理服务提供,绝不写入 YAML 或日志。

相同的扩展模式也适用于 Kafka、CloudWatch、Sentry、Loki 等后端:将目标实现为独立 handler,并保留 console 作为可观测性和故障降级出口。

6. Trace ID:请求、异步任务与下游调用

ContextVar 会随 asyncio 任务上下文传播,因此适合异步 Web 服务和 Agent 工作流。请求入口优先复用上游的 Trace ID;没有时生成新的,并在结束时复位:

python
async def handle_request(request):
    incoming_trace_id = request.headers.get("X-Trace-ID")
    current_trace_id = incoming_trace_id or new_trace_id()
    token = set_trace_id(current_trace_id)

    try:
        logger.info("收到请求")
        response = await dispatch(request)
        response.headers["X-Trace-ID"] = current_trace_id
        return response
    except Exception:
        logger.exception("请求处理失败")
        raise
    finally:
        reset_trace_id(token)

不可信公网请求应限制 Trace ID 的长度和字符集,或始终由网关生成。向下游服务发请求时,把当前 Trace ID 放在约定请求头中,即可关联跨服务调用。

7. 业务模块固定写法与上线检查

所有业务模块只需要:

python
import logging

logger = logging.getLogger(__name__)

记录变量时使用参数化日志,异常块中使用 logger.exception()

python
logger.info("订单处理完成: order_id=%s", order_id)

try:
    process_order(order_id)
except OSError:
    logger.exception("订单处理失败: order_id=%s", order_id)
    raise

不要在业务模块调用 basicConfig()dictConfig() 或自行添加 handler;这会造成重复输出、格式不一致,甚至影响其他库的日志。

上线前检查:

  1. 默认配置能在没有环境变量时只输出控制台;
  2. 传统服务器明确启用 LOG_FILE=true[console, file]
  3. 每个使用 %(trace_id)s 的 handler 都配置了 TraceIdFilter
  4. 日志目录有写权限,且 logs/ 已加入 .gitignore
  5. 文件轮转只选择大小或时间其中一种;
  6. 容器环境优先 stdout + 采集器,不直接同步写 Elasticsearch;
  7. 日志中不包含凭据、令牌、密码、Cookie、私钥和未经脱敏的用户数据。

复制 logging_setup.py 后,默认行为就是控制台输出;通过环境变量、YAML 的 handler 组合或自定义 handler,可以逐步扩展为控制台 + 文件、平台采集或 Elasticsearch 等集中日志后端。

分享
← 返回博客列表
🎁 有邀请福利哦,点击查看
🎁