跨服务排查总靠猜?用 W3C Trace Context 串起一次请求,cpolar 给外部同学开只读链路视图
线上出问题,最怕的不是报错,而是「每个服务的日志都正常,但拼起来就是不对」。
一个请求进来,网关转给 A 服务,A 服务调 B 服务,B 服务又去读写缓存和数据库。等你去查的时候,四五个服务各自有各自的日志文件,各有各的时间戳,靠时间先后硬猜是哪条请求对应哪一步。猜错一次,排查方向就跑偏,来回折腾半天。
我最近在本地联调一个两跳的服务链路时就被这个折腾过。后来用 W3C Trace Context 标准把一次请求串起来:请求进来带一个traceparent头,每个服务把同一个 trace id 往下传,日志里就能按 trace id 把所有环节对出来。
这篇文章写的是一个隔离环境里的最小实验提纲:用两个最小 HTTP 服务,手动串一次 trace 透传,再把链路视图做成只读页,最后用 cpolar 把这个只读页开给不在内网的同学核对。
说明:本文是可执行实验提纲,步骤和头部格式都是按规范写的。本轮没有实际安装、没有实际启动服务,所以文中不出现任何实测延迟或吞吐数值。你自己照步骤跑出来的结果才是真实结果。
图1:日志各写各的,缺少统一字段把同一次请求绑定在一起,只能靠时间戳硬猜。
1 为什么两个服务就能把排查搞崩
先别急着上 APM 平台。绝大多数「日志对不上」的场景,本质就一句话:没有一个稳定的字段能把同一次请求的所有环节绑定在一起。
常见的三种做法,各有各的难受:
- 靠时间戳对。两个服务的机器时间不一定同步,几十毫秒的偏差就够你把两条不同的请求对到一起。
- 靠请求参数对。用户的请求参数未必唯一,批量任务里重复率更高。
- 靠前端发一个自增 ID。自增 ID 只覆盖同步调用的第一跳,往下传就容易断。
W3C Trace Context 要解决的就是这个问题。它定义了traceparent和tracestate两个 HTTP 头,把「这次请求的追踪上下文」标准化,任何实现了规范的服务都能接住并往下传。
我选它而不是某个厂商的 SDK,是因为它足够小、足够清楚。你不需要装一整套平台,只要两个服务都懂traceparent,日志就能对上。
2 traceparent 头到底长什么样
这是这篇文章最需要写准的一部分,格式错一个字符就对不上。按 W3C Trace Context 规范,traceparent的结构是:
traceparent: <version>-<trace-id>-<parent-id>-<trace-flags>拆开看,四段用短横线连接:
version:版本号,2 位十六进制。当前规范是00。trace-id:整条链路的 ID,32 位十六进制(16 字节)。这是跨服务对齐的关键字段,全链路共享同一个值。parent-id:当前这一跳的 span ID,16 位十六进制(8 字节)。注意它指的是「发起本次调用的上游」的 span id。trace-flags:追踪标志,2 位十六进制。01表示采样,00表示不采样。
举一个格式正确的例子:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01这里00是版本,4bf92f3577b34da6a3ce929d0e0e4736是 trace id,00f067aa0ba902b7是 parent id,01表示采样。
划重点:trace id 是 32 位、span/parent id 是 16 位,这两个长度写反了接收方直接丢弃,链路就断在那里。校验时优先看长度和十六进制字符。
图2:traceparent 四段结构,trace-id 32 位是跨服务对齐的关键,parent-id 16 位。
3 环境准备:两个最小 HTTP 服务
实验只需要 Python 3 和标准库,不引入框架也行;为了写起来清楚,这里用http.server起两个最小服务。先建一个目录:
mkdir -p trace-demo && cd trace-demo接下来就是两个文件:一个是下游服务service-b.py,一个是上游服务service-a.py。
先想清楚谁调谁:A 收到的请求里如果带了traceparent,就沿用它并把当前 span 作为新的 parent 生成下游头;如果没带,就新建一条链路。B 收到后只负责把 trace 信息记进日志。
这一步的验证标准很简单:两个服务各自的日志里,出现同一串 trace id。
4 写下游服务:只负责接住并记录 trace
先写service-b.py。它的职责很单纯:收到请求,把traceparent解析出来,打印 trace id 和本跳 span id。
# service-b.py import json from http.server import BaseHTTPRequestHandler, HTTPServer def parse_traceparent(value): # 结构:version-traceid-parentid-flags parts = value.split("-") if len(parts) != 4: return None version, trace_id, parent_id, flags = parts # traceid 必须 32 位,parentid 必须 16 位 if len(trace_id) != 32 or len(parent_id) != 16: return None return { "version": version, "trace_id": trace_id, "parent_id": parent_id, "flags": flags, } class Handler(BaseHTTPRequestHandler): def do_GET(self): tp = self.headers.get("traceparent", "") parsed = parse_traceparent(tp) if parsed is None: # 没有有效 traceparent 时,明确记录缺失,便于排查 print("[service-b] traceparent 缺失或格式错误:", repr(tp), flush=True) self.send_response(400) self.end_headers() self.wfile.write(b"bad traceparent") return print("[service-b] 收到 trace_id=", parsed["trace_id"], " parent_id=", parsed["parent_id"], flush=True) self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps({ "service": "b", "trace_id": parsed["trace_id"], "parent_id": parsed["parent_id"], }).encode("utf-8")) if __name__ == "__main__": # 监听本机 9002,仅本机可见 HTTPServer(("127.0.0.1", 9002), Handler).serve_forever()这里有个容易写错的地方:trace_id的校验是 32 位、parent_id是 16 位,别顺手都写成 32。如果 B 收到请求直接返回 400,先看打印出来的repr(tp),多半是上游生成格式不对,而不是 B 的问题。
5 写上游服务:生成并透传 traceparent
再写service-a.py。它要干两件事:如果没有traceparent就新建一条链路,然后把请求转发给 B,转发时必须带上traceparent。
# service-a.py import json import os import secrets import urllib.request from http.server import BaseHTTPRequestHandler, HTTPServer def new_traceparent(): # version=00, trace-id 16 字节, parent-id 8 字节, flags=01(采样) version = "00" trace_id = secrets.token_hex(16) # 32 位十六进制 span_id = secrets.token_hex(8) # 16 位十六进制 flags = "01" return f"{version}-{trace_id}-{span_id}-{flags}" def call_downstream(traceparent): req = urllib.request.Request( "http://127.0.0.1:9002/", headers={"traceparent": traceparent}, ) with urllib.request.urlopen(req, timeout=5) as resp: return json.loads(resp.read().decode("utf-8")) class Handler(BaseHTTPRequestHandler): def do_GET(self): # 上游如果已有 traceparent 就延续,否则新建一条 incoming = self.headers.get("traceparent") traceparent = incoming if incoming else new_traceparent() print("[service-a] 透传 traceparent=", traceparent, flush=True) try: downstream = call_downstream(traceparent) except Exception as exc: self.send_response(502) self.end_headers() self.wfile.write(str(exc).encode("utf-8")) return self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps({ "service": "a", "traceparent": traceparent, "downstream": downstream, }).encode("utf-8")) if __name__ == "__main__": HTTPServer(("127.0.0.1", 9001), Handler).serve_forever()注意 A 转发时用的是同一个 traceparent,也就是把当前这一跳的 span 作为下游看到的 parent。真正严格的实现里,每跳应该生成新的 span id 并把当前 span 保留在tracestate,但作为最小实验,先保证 trace id 一路不变,链路就能对上。
6 实测验证:一次请求串起两个服务
开两个终端,先把 B 跑起来,再跑 A:
# 终端 1 python3 service-b.py # 终端 2 python3 service-a.py然后在第三个终端发一次请求:
curl -s http://127.0.0.1:9001/你会看到 A 返回一段 JSON,里面带有它生成的traceparent。同时两个终端里会出现类似这样的日志:
[service-a] 透传 traceparent= 00-<32位>-<16位>-01 [service-b] 收到 trace_id= <32位> parent_id= <16位>这里的关键是:A 日志里的 trace id 和 B 日志里的 trace id 必须是同一串。如果不一样,基本是转发时没把traceparent带上,检查call_downstream里 headers 那行。
图3:一次请求穿过两个服务,trace id 一路不变,两跳日志即可对齐。
再验证一遍「接得上」的链路:手动指定一个 traceparent 发过去,看两个服务是否沿用同一串。
curl -s -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \ http://127.0.0.1:9001/如果 A 返回的traceparent里那串 trace id 还是4bf92f3577b34da6a3ce929d0e0e4736,说明透传生效了。这一步不是为了测试而测试,而是确认「外部传进来的上下文没有被覆盖」,这正是跨服务排查能对齐的前提。
7 把链路视图做成只读页
排查时把日志甩给同事看不方便,做一个只读页更直观。新增一个view.py,把最近几次请求的 trace 信息渲染成一个静态页面,并且只做成只读——只展示,不提供任何写入接口。
# view.py import json from http.server import BaseHTTPRequestHandler, HTTPServer # 演示用的静态数据;真实场景里由上面两个服务写入 RECORDS = [ {"hop": "a", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7"}, {"hop": "b", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "a1b2c3d4e5f60718"}, ] PAGE = """<!doctype html> <html lang="zh-CN"> <head><meta charset="utf-8"><title>Trace 只读视图</title></head> <body> <h1>Trace 只读视图</h1> <p>仅展示,无写入能力。</p> <table border="1" cellpadding="6"> <tr><th>hop</th><th>trace_id</th><th>span_id</th></tr> {rows} </table> </body> </html>""" class Handler(BaseHTTPRequestHandler): def do_GET(self): rows = "".join( f"<tr><td>{r['hop']}</td><td>{r['trace_id']}</td>" f"<td>{r['span_id']}</td></tr>" for r in RECORDS ) html = PAGE.format(rows=rows) self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() self.wfile.write(html.encode("utf-8")) def do_POST(self): # 只读页:显式拒绝任何写入 self.send_response(405) self.end_headers() if __name__ == "__main__": HTTPServer(("127.0.0.1", 9003), Handler).serve_forever()启动它:
python3 view.py浏览器打开http://127.0.0.1:9003,应该看到一张表格,两行记录共享同一个 trace id。到这一步,「一次请求被串起来」这件事就变成了一个可以被外部同学直接看的东西。
8 用 cpolar 把只读视图开给外部同学
现在的问题是:视图只跑在127.0.0.1:9003,外网访问不了。让同事进内网不现实,这时候用 cpolar 把本地端口映射成一个公网 HTTP 地址就行。
按 cpolar 官方文档,HTTP 隧道直接用命令行指定本地端口:
cpolar http 9003如果本地还没配好账号,先在 cpolar Web UI(本机http://127.0.0.1:9200)登录,或者在命令行写入 token:
cpolar authtoken <你的-token>命令回车后,终端会输出一段公网地址,形如https://xxxx.r3.cpolar.cn,把它发给外部同学即可。
几个提醒:
- 免费套餐的随机公网地址会在 24 小时内变化,同事核对时最好当场发当次地址,不要用旧链接。
- 只开这一个只读端口,不要把 9001、9002 一起映射出去,避免把上游服务也暴露了。
- 想让地址固定下来,需要基础套餐或以上才能绑定固定二级子域名;临时核对用随机地址就够。
外部同学打开这个地址,看到的应该是第 7 步那张只读表格,而不是任何可交互的写入页面。这也是验证标准之一:cpolar 公网地址打开的必须是只读页。
图4:只读视图跑在 127.0.0.1:9003,经 cpolar 隧道映射成公网地址供外部同学核对。
9 常见误区与排查顺序
排查的时候按下面顺序走,能省不少时间:
- 先确认头格式。
traceparent是不是 4 段、trace id 是否 32 位、span id 是否 16 位。格式不对,接收方会直接丢掉。 - 再确认是否真的透传。在 A 的转发代码里打印 headers,看看
traceparent有没有带上。漏了这一步,B 收到的就是新生成的上下文。 - 再看 trace id 对不对得上。两个服务日志里 trace id 一致,链路才算串上。
- 最后才看只读页和公网地址。前面都对,但公网打不开,先检查本地
9003能不能访问,再看 cpolar 隧道是否在线。 - 别忽视只读性。如果外部访问到的是可写入的接口,说明映射错了端口,立刻停掉重新指定。
10 总结
到这里,一个小实验就闭环了:两个最小服务通过 W3C Trace Context 的traceparent头把一次请求串起来,日志里可以通过同一个 trace id 对上每一跳;再把链路做成只读页,用 cpolar 开给不在内网的同学核对。
几个关键步骤捋一下:
- 头格式是
version-traceid-spanid-flags,trace id 32 位、span id 16 位,这两点写错链路必断。 - 上游转发时要沿用同一个
traceparent,trace id 才能一路不变。 - 只读页只展示不写入,映射时只开这一个端口。
再往下扩展,可以把tracestate也接进来记录更多上下文,或者把只读页做成按 trace id 查询的形式。这样做的好处在排查场景里很明显:你不用再把一堆日志甩来甩去,给外部同学一个链接,他自己就能把链路对清楚。
本文为隔离环境中的可执行实验提纲,正文未在真实环境安装或启动任何服务,未记录实测数值。所有命令与头部格式以 W3C Trace Context 规范与 cpolar 官方文档口径为准。