← 返回模块
3.6.6.1beta 可读 · 未来免费校验通过内容版本 2026-05-29

结构化日志、指标与追踪:可观测性三支柱

3.6.6 · 可观测性与系统设计 · 编程

凌晨 02:14,沪深300 期权做市的私募量化值班手机响起。一条 SSE 50ETF 的报价在落库前出现了 11 秒延迟,下一档 CFFEX IF 主力的对冲单也跟着卡住。你打开堡垒机,登进 feed-handler-consumer 的容器,敲下熟悉的 tail -f /var/log/...——什么都没有,因为容器里根本没有 /var/log/。再敲 kubectl logs -f deployment/feed-handler-consumer -n feed-prod,屏幕飞滚的是几千行散文体的中文混英文日志,没有 timestamp、没有 level、没有可解析结构。你试图在十分钟内回答盘前 09:30 之前必须答出的三个问题:『这一笔 50ETF 报价在哪里卡住?这台 pod 之前发生过同样的故障吗?要不要让 T+1 的清算流程提前回退?』——靠 grep 和肉眼,答不出来。这一课要做的事,就是把这个夜晚从你的职业生涯里彻底拿掉。

可观测性三支柱:先确立框架

生产系统的可观测性由三类信号组成,缺一不可:

  • ​日志 (logs)​​:带任意结构化负载的离散、带时间戳的事件。回答的问题是『这一笔请求到底发生了什么?』。每行写一次,存储成本高,查询成本中等。
  • ​指标 (metrics)​​:预先聚合的数值时间序列。回答的问题是『过去 N 分钟里所有请求的速率 / 延迟分位 / 错误数是多少?』。聚合成本低,但丢失单事件细节。
  • ​链路追踪 (traces)​​:跨服务边界的工作单元(span)的因果图。回答的问题是『这一笔慢请求,时间花在哪里、是哪个下游调用拖累了它?』。中等成本,揭示分布式工作的形状。

应记住的规则:『日志解释单个事件;指标揭示趋势;链路追踪揭示跨服务的因果;生产系统三者皆需,因为每一类都能回答另两类无法廉价回答的问题』。本课覆盖支柱 (1)——日志,因为日志是另两类信号通过关联 ID / trace ID 引用的底层基质。L2 覆盖指标,L3 覆盖链路追踪、日志聚合、以及三支柱的关联。

为什么必须是结构化 JSON,写到 stdout

不可妥协的规则只有一句:『JSON 一行一事件写到 stdout;不写散文体、不写 ASCII 表格、不写 print()』。

  • ​为什么 JSON​​:日志聚合器(Loki、Elasticsearch、Splunk、Datadog Logs、阿里云 SLS、腾讯云 CLS、华为云 LTS)原生解析 JSON。对散文体日志做 grok / 正则提取,是每次查询都要交的税,永远交不完。
  • ​为什么 stdout​​:运行在容器里的服务不应该写 /var/log/。orchestrator 负责采集——Kubernetes 通过 kubelet 抓取 stdout / stderr,compose 通过 Docker 的 JSON log driver 抓取,然后由 DaemonSet(Promtail / Fluent Bit / Grafana Agent)转发到聚合器。这条管道在国内私募的标准链路是 容器 stdout -> kubelet -> Fluent Bit DaemonSet -> 阿里云 SLS / Loki / Elasticsearch
  • ​为什么一行一事件​​:所有聚合器的流式解析器都假设换行符分隔的 JSON(application/x-ndjson)。多行的 Python 异常栈必须用 \n 转义后塞进 JSON 事件的 exception 字段,而不是写成连续多行。

两个 Python 库的取舍:structlog 是新项目的推荐默认(处理器链可组合、原生 contextvars 支持、JSON 输出、约 30 行就能配完);python-json-logger 是标准库 logging.Formatter 的 JSON 实现,留给已重度投入 stdlib logging 的存量项目,灵活性严格更差,但是一个合法的存量选择。本课的所有示例均使用 structlog

规范的 structlog 配置

Fenced Python 代码块,落在 feed_handler/_logging.py 模块里。开头是处理器链;处理器顺序非常重要,错位会导致 timestamp 缺失或 contextvars 不合并。

import os
import structlog


def configure_logging(service: str, version: str) -> None:
    structlog.configure(
        processors=[
            structlog.contextvars.merge_contextvars,
            structlog.processors.add_log_level,
            structlog.processors.TimeStamper(fmt='iso', utc=True),
            structlog.processors.StackInfoRenderer(),
            structlog.processors.format_exc_info,
            structlog.processors.EventRenamer('message'),
            structlog.processors.JSONRenderer(),
        ],
        wrapper_class=structlog.stdlib.BoundLogger,
        logger_factory=structlog.PrintLoggerFactory(),
        cache_logger_on_first_use=True,
    )
    structlog.get_logger().bind(
        service=service,
        version=version,
        pod_name=os.environ.get('HOSTNAME', 'local'),
    )

七个处理器各自的职责命名一遍:merge_contextvarsbind_contextvars 绑定的键合并进事件字典,使 async 任务共享请求作用域;add_log_levellevel 字段;TimeStamper(fmt='iso', utc=True) 给出聚合器都能解析的 ISO-8601 UTC 时间戳;StackInfoRendererformat_exc_info 把 traceback 序列化进 JSON 事件;EventRenamer('message') 把 structlog 默认的 event 键改名为聚合器约定的 messageJSONRenderer() 是终极输出。三个绑定的默认字段——serviceversionpod_name——保证每行日志都带部署上下文。

每条 Kafka 消息的埋点模式

Fenced Python 代码块,落在 feed_handler/consumer.py 的消费循环顶部:

import structlog
from uuid import uuid4

logger = structlog.get_logger()

for msg in consumer:
    structlog.contextvars.clear_contextvars()
    structlog.contextvars.bind_contextvars(
        symbol=msg.value()['symbol'],
        partition=msg.partition(),
        offset=msg.offset(),
        correlation_id=(
            dict(msg.headers() or []).get(b'correlation_id') or uuid4().hex.encode()
        ).decode(),
    )
    try:
        cursor.execute(
            'INSERT INTO ticks (...) VALUES (...)',
            (msg.value()['symbol'], msg.value()['price']),
        )
        logger.info('persisted_to_warehouse', rows=cursor.rowcount)
    except Exception:
        logger.error('persist_failed', exc_info=True)
        raise

四个绑定键的顺序——symbolpartitionoffsetcorrelation_id——是约定。clear_contextvars() 必须在 bind_contextvars() 之前,否则上一条消息的关联 ID 会泄漏到下一条。persisted_to_warehouse 是成功事件名(snake_case 动词),persist_failed 是失败事件名,exc_info=True 把异常栈塞进事件。

一行期望的日志输出

Fenced JSON 代码块,展示成功路径的一行日志,验证 JSON 结构合规:

{"timestamp":"2026-05-28T12:34:56.789012Z","level":"info","message":"persisted_to_warehouse","service":"feed-handler","version":"1.0.0","pod_name":"feed-handler-consumer-7c4f9d8b6-x2k4l","symbol":"AAPL","partition":3,"offset":1048576,"correlation_id":"a1b2c3d4e5f6","rows":1}

十一个键的顺序(timestamplevelmessageserviceversionpod_namesymbolpartitionoffsetcorrelation_idrows)是聚合器友好的形状。

kubectl logs + jq 的验证序列

Fenced bash 代码块,国内私募实战中最常用的四条命令:

kubectl logs -f deployment/feed-handler-consumer -n feed-dev | jq -c .
kubectl logs --since=10m deployment/feed-handler-consumer -n feed-dev | jq -c 'select(.level=="error")'
kubectl logs --since=10m deployment/feed-handler-consumer -n feed-dev | jq -c 'select(.correlation_id=="a1b2c3d4e5f6")'
kubectl logs --since=1h -l app=feed-handler -n feed-dev | jq -c 'select(.symbol=="AAPL" and .level=="error")'

第一条确认每行都是合法 JSON 流;第二条过滤所有错误;第三条按 correlation_id 串起一笔报价跨多 pod 的全链路;第四条用 -l app=feed-handler 标签选择器跨副本聚合查询。jq 是国内 SRE 与 quant developer 都应熟练的工具,等同于聚合器 LogQL 在 kubectl 边界的对应物。

日志级别的五条纪律

Inline-code 列举(顺序固定):

  1. DEBUG = 仅开发态使用,容器默认关闭,通过 LOG_LEVEL=debug 环境变量开启。
  2. INFO = 正常运行事件(consumer_startedorder_filled)。
  3. WARNING = 已降级但仍在恢复(reconnect_attemptedlag_above_threshold)。
  4. ERROR = 请求失败,值班可能需要介入(order_rejectedkafka_produce_failed)。
  5. CRITICAL = 立刻呼叫(database_unavailablekill_switch_activated)。

拒绝的六种反模式

Inline-code 列举(顺序固定,拒绝任何一条都视为合规失败):

  1. logger.info(f'order filled: {order}')——f-string 格式化,结构化字段丢失。
  2. logging.FileHandler('/var/log/feed-handler.log')——容器内文件输出,违反 stdout 契约。
  3. print() 出现在生产代码——没 level、没 timestamp、没结构。
  4. 日志凭据 / Authorization 头 / Postgres DSN 含密码 / Kafka SASL 密码。
  5. 日志整本订单簿或客户 PII——只记 ID,依赖 trace + metric 层补充数量。
  6. 多行 stack trace 拆成连续多行日志——必须用 \n 转义塞进 JSON 的 exception 字段。

关联 ID 在请求路径上的传递

请求从某个边界进入系统——一条 Kafka 消息、一次 HTTP 调用、一次定时任务触发——处理器的第一件事就是 correlation_id = msg.headers().get('correlation_id') or uuid4().hex,然后 structlog.contextvars.bind_contextvars(correlation_id=correlation_id)。在服务内部,所有 await 切点都会自动带上这个 ID;离开服务进入下游时,把 correlation_id 通过 X-Correlation-ID HTTP 头或 Kafka 消息头透传出去;下游服务在入口处重新绑定。这样一条 kubectl logs --since=10m -l app=feed-handler -n feed-dev | jq -c 'select(.correlation_id=="a1b2c3d4e5f6")' 就能跨所有服务找出这笔 50ETF 报价的全部日志行。在 JVM 系服务里这个概念叫 MDC(Mapped Diagnostic Context),polyglot 团队用 MDC 与 correlation_id 在同一句话里指代同一件事。

端到端:把 3.6.5 的 capstone 仪表化

3.6.5 的 capstone feed-handler(producer / consumer / monitor 三服务)就是本课的练习对象。startup 阶段绑定 serviceversionpod_nameos.environ['HOSTNAME'] 在 K8s 容器里给出 pod 名),每条 Kafka 消息绑定 symbolpartitionoffset。用 kubectl logs -f deployment/feed-handler-consumer | jq -r 'select(.level=="error")' 校验 JSON 良构 + jq-可查询。这一层是 L2 (Prometheus)、L3 (OpenTelemetry)、L4 (Loki) 全都建立其上的基质。

练习

Exercise

拿 3.6.5 capstone 的 feed-handler 服务,端到端加上结构化日志。(a) 在 pyproject.tomldependencies 里加入 structlog==24.1.0,运行 uv lock 重新锁定。(b) 撰写 feed_handler/_logging.py,包含本课的 configure_logging(service: str, version: str) -> None 函数与七处理器链。(c) 在 feed_handler/__main__.py 子命令分发之前调用一次 configure_logging("feed-handler", __version__)。(d) 在 feed_handler/consumer.py 每条 Kafka 消息处理顶部调用 structlog.contextvars.clear_contextvars() 然后 bind_contextvars(symbol=..., partition=..., offset=..., correlation_id=...)correlation_idmsg.headers() 抽取,若缺失则用 uuid4().hex 生成。(e) 把所有 print() 与裸 logging.info() 替换为 structlog.get_logger().info("<event_name>", **fields),事件名为 snake_case 动词(message_receivedpersisted_to_warehousepersist_failed)。(f) docker buildx build --tag feed-handler:1.1.0 --load . 重打镜像,kubectl rollout restart deployment/feed-handler-consumer -n feed-dev 重发,然后 kubectl logs -f deployment/feed-handler-consumer -n feed-dev | jq -c . 验证每行可解析。(g) 选一条 tick(一条 Kafka 消息),从 producer 日志拿到它的 correlation_id,运行 kubectl logs --since=10m -l app=feed-handler -n feed-dev | jq -c "select(.correlation_id==\"<id>\")" 验证跨服务路径可用一次 grep 命中。(h) 跑 kubectl logs --since=10m -l app=feed-handler -n feed-dev | jq -c "select(.message | tostring | test(\"password|secret|dsn\"))" 确认零命中,证明无凭据、无 PII、无完整订单簿外泄。

提示
处理器顺序错了 timestamp 就缺失或被覆盖。把 merge_contextvars 放最前,JSONRenderer 放最后,TimeStamperadd_log_level 排在中间,其它两个异常处理处理器紧贴 EventRenamer 之前。
提示
kubectl logs ... | jq -c . 报 parse error,多半是仍有 print() 残留,或某个 logger 用了 f-string。grep -n "print(\\|f'.*{.*}.*'" src/feed_handler 找出来全部替换。

跨区域阅读清单

structlog 官方文档以英文为主,社区中文译本散见知乎与掘金;阿里云 SLS 文档(help.aliyun.com/product/28958.html)给出容器日志采集与 JSON 自动解析的完整教程;腾讯云 CLS 文档(cloud.tencent.com/document/product/614)给出对应的腾讯云路径;极客时间《容器实战高手课》李程远关于容器日志的章节是本课在中文资料里的最佳补充;极客时间《SRE 实战》节译自 Google SRE Book,第三章覆盖三支柱的原始论述;阿里巴巴《阿里云 SRE 实践手册》(电子版)内含日志 / 监控 / 追踪三支柱的中文阐述;Grafana 中文文档(grafana.com/docs/)覆盖 Loki 与 LogQL 的语法。一条额外注释:国内量化 firm 的日志 retention 一般由合规 + 信息安全团队设置(热日志 7-30 天、冷日志 6-12 个月入 OSS / COS),quant developer 只需保证 emit 结构化 JSON 即可。

衔接到 L2

下一课把 L1 建立的结构化日志基质升级为指标维度——用 prometheus_client 给同一套 feed-handler 加 Counter / Gauge / Histogram 埋点,写 PromQL 查询率、分位与饱和度,把 dashboard JSON 提交进 git,并配 Alertmanager 路由 severity=page 走钉钉 / 飞书电话,severity=warn 走 channel。L1 的 logs_emitted_total{level} 计数器就是 L2 错误率指标的源。