Python 日志最佳实践:从 print 到结构化、可观测的生产级日志
一、为什么该告别 print
很多脚本起步时都用 print 打日志,简单直接。但一旦进入生产环境,print 的问题就暴露了:它没有级别、没法按需开关、不能自动切分文件、无法带上时间/位置信息,更无法把日志结构化后交给采集系统检索。
Python 标准库自带 logging,只要用对,就能做到:分级输出、按大小/时间轮转、输出 JSON 便于检索、给每条日志附加请求上下文。本文把这些实践一次讲清楚。
二、logging 的四个核心对象
- Logger:日志入口,代码里通过
logging.getLogger(__name__)获取,按名字组织成树。 - Handler:决定日志写到哪里(控制台、文件、Syslog…),一个 Logger 可挂多个 Handler。
- Formatter:决定日志长什么样(纯文本或 JSON)。
- Filter:在日志真正输出前做过滤或注入额外字段(例如注入 request_id)。
记住一句话:Logger 负责“要不要记”,Handler 负责“记到哪”,Formatter 负责“长什么样”。
三、最小可用配置
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
logger.info("service started")
logger.warning("disk usage %.1f%%", 86.5)
注意:logger.info("disk usage %s", value) 采用惰性格式化,只有该级别真正输出时才会拼字符串,比 f-string 更省。
四、结构化日志:直接输出 JSON
线上做检索、聚合、告警时,纯文本很难解析。自定义一个 JSON Formatter 即可:
import json, logging
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
"file": record.filename,
"line": record.lineno,
}
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger = logging.getLogger("app")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
结构化之后,日志平台就能按 level、logger、ts 直接聚合与检索。
五、日志轮转:别让磁盘被写满
单文件一直写迟早撑爆磁盘。用 RotatingFileHandler(按大小)或 TimedRotatingFileHandler(按时间):
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
# 按大小:单文件 50MB,保留 5 个备份
h1 = RotatingFileHandler("app.log", maxBytes=50 * 1024 * 1024,
backupCount=5, encoding="utf-8")
# 按天:每天切分,保留 14 天
h2 = TimedRotatingFileHandler("app.log", when="midnight",
backupCount=14, encoding="utf-8")
实务上:容器环境优先把日志打到 stdout,交给采集侧处理;物理机/裸进程才需要文件轮转。
六、上下文追踪:给每条日志带上 request_id
排查一个请求的多条日志时,靠 request_id 串起来最省事。用 contextvars + Filter 实现:
import contextvars, uuid, logging
request_id = contextvars.ContextVar("request_id", default="-")
class RequestIdFilter(logging.Filter):
def filter(self, record):
record.request_id = request_id.get()
return True
# 在请求入口设置
token = request_id.set(uuid.uuid4().hex[:8])
fmt = logging.Formatter("%(asctime)s %(levelname)s [%(request_id)s] %(message)s")
h = logging.StreamHandler()
h.setFormatter(fmt)
h.addFilter(RequestIdFilter())
logger = logging.getLogger("app")
logger.addHandler(h)
logger.setLevel(logging.INFO)
之后同一请求链路里的每条日志都会带同一个 request_id,检索时按它过滤即可。
七、分级与命名规范
- DEBUG:仅本地排查用,线上不开。
- INFO:关键业务节点(启动、请求完成、任务成功)。
- WARNING:可恢复的异常(重试成功、降级)。
- ERROR:需要人工介入的失败。
- CRITICAL:服务不可用。
Logger 命名用 logging.getLogger(__name__),自然形成 package.module 的层级,便于按模块单独调级别。
八、采集到华为云 LTS
把日志打到 stdout / 文件后,可用华为云日志服务(LTS)做采集:在 ECS 上安装 ICAgent,配置采集路径或容器 stdout,即可在 LTS 控制台按关键字、level、request_id 检索,并配置告警规则。JSON 结构化日志在这里的优势最明显——字段可直接作为检索维度。
九、几个常见坑
- Handler 重复添加:模块被多次 import 或函数里反复
addHandler,会导致一条日志打印多次。初始化只做一次。 - root logger 乱用:业务代码用
getLogger(__name__),不要直接logging.info()往 root 打。 - 多进程写同一文件:多个 worker 同时写会错乱,容器环境统一写 stdout 更稳。
- 格式化开销:用
%s惰性格式化,别在info()里先f-string拼好再传。 - 异常只打 message:用
logger.exception(...)或在exc_info=True时保留堆栈,否则排查时没有调用栈。
十、总结
一套可落地的 Python 日志方案 = 分层 Logger + 轮转 Handler + JSON Formatter + request_id 上下文注入 + 采集平台。把这几块标准化成项目模板后,日志就从“调试用的 print”变成了真正的可观测性资产:出问题时能按级别和请求链路快速定位,而不是在一堆文本里肉眼 grep。
- 点赞
- 收藏
- 关注作者
评论(0)