凌晨 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_contextvars 把 bind_contextvars 绑定的键合并进事件字典,使 async 任务共享请求作用域;add_log_level 写 level 字段;TimeStamper(fmt='iso', utc=True) 给出聚合器都能解析的 ISO-8601 UTC 时间戳;StackInfoRenderer 与 format_exc_info 把 traceback 序列化进 JSON 事件;EventRenamer('message') 把 structlog 默认的 event 键改名为聚合器约定的 message;JSONRenderer() 是终极输出。三个绑定的默认字段——service、version、pod_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
四个绑定键的顺序——symbol、partition、offset、correlation_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}
十一个键的顺序(timestamp、level、message、service、version、pod_name、symbol、partition、offset、correlation_id、rows)是聚合器友好的形状。
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 列举(顺序固定):
DEBUG= 仅开发态使用,容器默认关闭,通过LOG_LEVEL=debug环境变量开启。INFO= 正常运行事件(consumer_started、order_filled)。WARNING= 已降级但仍在恢复(reconnect_attempted、lag_above_threshold)。ERROR= 请求失败,值班可能需要介入(order_rejected、kafka_produce_failed)。CRITICAL= 立刻呼叫(database_unavailable、kill_switch_activated)。
拒绝的六种反模式
Inline-code 列举(顺序固定,拒绝任何一条都视为合规失败):
logger.info(f'order filled: {order}')——f-string 格式化,结构化字段丢失。logging.FileHandler('/var/log/feed-handler.log')——容器内文件输出,违反 stdout 契约。print()出现在生产代码——没 level、没 timestamp、没结构。- 日志凭据 /
Authorization头 / Postgres DSN 含密码 / Kafka SASL 密码。 - 日志整本订单簿或客户 PII——只记 ID,依赖 trace + metric 层补充数量。
- 多行 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 阶段绑定 service、version、pod_name(os.environ['HOSTNAME'] 在 K8s 容器里给出 pod 名),每条 Kafka 消息绑定 symbol、partition、offset。用 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.toml 的 dependencies 里加入 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_id 从 msg.headers() 抽取,若缺失则用 uuid4().hex 生成。(e) 把所有 print() 与裸 logging.info() 替换为 structlog.get_logger().info("<event_name>", **fields),事件名为 snake_case 动词(message_received、persisted_to_warehouse、persist_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、无完整订单簿外泄。
提示
merge_contextvars 放最前,JSONRenderer 放最后,TimeStamper 与 add_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 错误率指标的源。