新旧版本同时运行 — 在字节中追踪悄悄出错的值
目标
让旧版本(v1)和新版本两个客户端进程,各自用自己的 .proto 读取一个新版本(v2)服务器发送的同一份字节, 按照不一致的名称 → 字节中的字段编号 → 两个 .proto 的差异的顺序缩小原因范围。把这个过程固化为测试脚本 之后,处理一个三个服务以不同原因表现出相同症状的事件。
为什么重要
在前面的实验中,你学到了改编号会让旧客户端读错的规则。在现场,出发点不是规则,而是 症状——结算数字对不上,错误日志却一行也没有。这时把两个版本并排启动, 用两双眼睛读同一个响应,不一致的名称立刻就会显现;只要知道这个名称在旧版本中是哪个编号,就能在字节 转储中挑出那一条记录。名称只存在于各自的 .proto 中,字节里只有编号——用工具确认这一事实的过程, 本身就是诊断流程。而修复永远在新版本一侧——因为旧版本大多已经部署出去, 无法重新构建。
步骤
- 在 7100 上启动演示 v1 服务器(
python3 /opt/fixtures/grpcc/gcsvc.py --proto /opt/fixtures/grpcc/demo/v1.proto --message Order --data /opt/fixtures/grpcc/demo/data.json --port 7100 &),只用标准库socket和struct编写/root/grpc/skew/tap.py <port> <id> <out-file>(参数依次为端口、id、要保存的文件)。把 GetRequest(int32 id = 1)装进 gRPC 消息帧发送,并把响应帧(含 5 字节头部)整个保存下来。把 id 2 的响应保存到/root/grpc/skew/01-resp.bin。 - 在 7101 上启动演示 v2 服务器(
--proto /opt/fixtures/grpcc/demo/v2.proto),用旧版本(/opt/fixtures/grpcc/gcclient.py --proto /opt/fixtures/grpcc/demo/v1.proto ...)和新版本(--proto /opt/fixtures/grpcc/demo/v2.proto)读取同一个 id 2。两个版本读法不同的每个名称,在/root/grpc/skew/02-diff.txt中写一行<name> old=<old-value> new=<new-value>(依次为名称、旧版本的值、新版本的值;如果新版本中没有该名称,写new=(none))。 - 用
gcclient.py --dump demo --port 7101 --id 2把字节展开成记录行,针对第 2 步中的每个名称,在/root/grpc/skew/03-cause.txt中写下<name> field=<number-in-v1> wire=<wire-type-on-the-wire> cause=<code>(名称、旧版本赋予的编号、该记录实际的 wire type 、原因代码)。代码是enum-renumberedfield-renumberednumber-reusedsint-changedwire-type-changed之一。 - 在保留演示 v2 新功能(
PENDING、coupon、currency)的同时,把修改为旧版本也能正确读取的 schema 写入/root/grpc/skew/04-v2.proto。用这个 schema 再启动一个服务器,用两个客户端确认。 - 编写
/root/grpc/skew/skewtest.py <port> <old.proto> <new.proto> <message> <id,id,...>(端口、旧 .proto、新 .proto、消息名、id 列表)。用两个 schema 读取同一个响应,对每个不一致的名称打印MISMATCH id=<id> <name> old=<value> new=<value>并 exit 1;没有不一致时最后一行打印OK并 exit 0。新版本删除的名称,旧版本读到默认值属于正常;旧版本不认识的新 enum 值显示为数字,也属于正常。 - 用
incident gc-silent-skew触发事件并阅读工单。修复之前,区分每个服务的原因,在/root/incident/gc-silent-skew/report.txt中写下三行<service>: <code>(服务名: 代码),并为每个服务附上一行能说明原因的gc-tap输出,写成evidence: <tap-line>(原样粘贴那一行,共三行)。 - 修复三个服务——结算批处理(旧版本)必须能正确读取所有记录,新版本客户端也必须原样读取 v2 的字段。要修改的是
/srv/gc/<service>/v2.proto,修改后用gc-svc restart <service>重新启动(尖括号处为服务名)。
参考
- 素材:
/opt/fixtures/grpcc/—gcsvc.py(新版本服务器,只在启动时读取 .proto)、gcclient.py(客户端,--dump不用 schema 输出记录行)、skewlib.py(解析器、编码器、解码器)、demo/。 - gRPC 消息帧依次是 1 字节压缩标志、4 字节长度(大端)、消息(gRPC over HTTP/2 中的 Length-Prefixed-Message)。真正的 gRPC 把这个帧放在 HTTP/2 上,本实验的服务器则直接放在 TCP 上。服务器会故意把响应拆开发送。
- 评分器会用从未见过的服务器和随机记录重新运行你的
tap.py、04-v2.proto和skewtest.py。把值写死的话,当场就会失败。 - 在事件中修改旧版本的
v1.proto、手动调整data.json、把 v2 整个回退成 v1、在该端口上启动其他程序代替服务器,都不算修复。 - 常见错误:修改了
.proto却没有重启服务器;在修复之后才抓取证据行(只有修复前的字节才能说明原因)。
手工收发 gRPC 消息帧
只用 socket 和 struct 编写 /root/grpc/skew/tap.py <port> <id> <out-file>(端口、id、要保存的文件),从 7100 上启动的演示 v1 服务器取得 id 2 的响应帧(含 5 字节头部),保存到 /root/grpc/skew/01-resp.bin。
方向:请求和响应都装在“1 字节压缩标志 + 4 字节长度(大端)+ 消息”的帧中。请求消息只有一个 GetRequest——在字段 1 上以 varint 写入 id。
工具:struct.pack(">I", n) 与 struct.unpack,以及一个反复调用 sock.recv() 直到收满所需长度的小函数。服务器会故意把响应拆开发送,一次 recv() 只返回 5 字节的头部。
具体:收完 5 字节头部后,再按其中的长度接收消息体,把两者拼接后写入文件。对于不存在的 id,服务器会发送长度为 0 的帧,所以长度为 0 时不能等待消息体。如果导入并使用 skewlib 或 gcclient,这一步会失败。
同一份字节,两种世界观
用旧版本(v1.proto)和新版本(v2.proto)客户端读取演示 v2 服务器(7101)上 id 2 的响应,对每个读法不同的名称,在 /root/grpc/skew/02-diff.txt 中写下 <name> old=<old-value> new=<new-value>(名称、旧版本的值、新版本的值)。
方向:服务器只有一个,字节也只有一份。不同的只是客户端带着的 .proto,而且哪一方都不会报错。
工具:运行两次 python3 /opt/fixtures/grpcc/gcclient.py --proto <v1-or-v2.proto> --message Order --port 7101 --id 2(--proto 分别填 v1 或 v2),把输出存到文件里,按名称比对。
具体:新版本中没有的名称写 new=(none),但如果此时旧版本读到的是默认值(0 或空字符串),就不算不一致——已删除的字段在旧版本看来是默认值,这是正确的。值相同的名称不要写。
从不一致的名称到字节中的编号
查看 gcclient.py --dump demo --port 7101 --id 2 的记录行和两个 .proto,对第 2 步中的每个名称,在 /root/grpc/skew/03-cause.txt 中写下 <name> field=<number> wire=<wire> cause=<code>(名称、编号、wire type 、原因代码)。
方向:字节中没有名称。先在 v1.proto 中找到“旧版本给这个名称的编号”,再在转储中看这个编号的记录实际是以哪种 wire type 写入的,最后看在这个编号上 v1 与 v2 的声明有什么不同。
工具:转储行中的 field=、wire=、raw=,两个 .proto 中相同编号的行,enum 块中的值编号。
具体:如果旧名称以不同编号留在 v2 中(只是编号被挪动了),怀疑 field-renumbered;旧名称消失、该编号被别的名称使用,怀疑 number-reused;名称相同但 enum 值编号被挤开,怀疑 enum-renumbered;wire type 本身不同,怀疑 wire-type-changed;同样是 VARINT 但数字大约变成两倍,怀疑 sint-changed(150 的 ZigZag 是 300)。
保留新功能,也让旧版本读对
在保留演示 v2 的 PENDING、coupon、currency 的同时,把修改为旧版本也能正确读取的 schema 写入 /root/grpc/skew/04-v2.proto。
方向:旧版本认识的编号的含义(名称、wire 上兼容的类型、enum 值编号)一个也不改,新东西放到新编号上。如果整个回退到 v1,新版本的字段会消失,从而失败。
工具:第 3 步的表——每种原因各有一处需要回退。用修改后的文件再启动一个服务器,运行两次 gcclient.py 确认。
具体:enum 的新值放到末尾编号;把新字段从别的名称用过的旧编号上移开、换到新编号,再用 reserved 封存那个旧编号。sint32 与 int32、fixed32 与 int32 在 wire 上互不兼容。
编写用两双眼睛阅读的测试
编写 /root/grpc/skew/skewtest.py <port> <old.proto> <new.proto> <message> <id,id,...>(端口、旧 .proto、新 .proto、消息名、id 列表)——对每个不一致的名称输出 MISMATCH id=<id> <name> old=<value> new=<value> 并 exit 1;没有不一致时最后一行输出 OK 并 exit 0。
方向:向服务器询问一次,用两个 schema 读取这一份字节,并按名称逐一比对。这是抓住不报错的兼容性事故的唯一方法。
工具:/opt/fixtures/grpcc/skewlib.py 中的 call、load、decode_view、default_of、show(sys.path.insert(0, "/opt/fixtures/grpcc"))。
具体:必须避开两种误报——新版本中没有的名称,若旧版本的值是该类型的默认值则属正常;旧版本不认识的新 enum 值(新版本的名称不在旧 enum 中)显示为数字也属正常。评分器会启动五对从未见过的有害组合(每种原因一对)和一对兼容组合,还会混入不存在的 id(空响应)来查询——只比较两个 .proto 或只看第一个 id 都会失败。
事件:三个服务,相同症状,不同原因
运行 incident gc-silent-skew。新版本部署之后,结算批处理(旧版本)在 orders、ledger、stock 三个服务上都不报错地读出了错误的值。修复之前,区分每个服务的原因,在 /root/incident/gc-silent-skew/report.txt 中写下三行 <service>: <code>(服务名: 代码)。代码是 enum-renumbered、field-renumbered、number-reused、sint-changed、wire-type-changed 之一。再为每个服务附上一行能说明原因的 gc-tap <service>(服务名)输出,写成 evidence: <tap-line>(原样粘贴那一行,每个服务恰好一行)。用错误的代码评分三次,事件会被锁定,需要重新触发(原因会重新确定)。
方向:三个服务的症状相同,原因各不相同。每个 v2 中也混有安全的变更(新字段、enum 末尾的新值、deprecated、声明顺序),所以不是要看全部差异,而是要挑出旧版本读错的那个编号上的那一处差异。
工具:用 gc-old、gc-new(或第 5 步的 skewtest)找出不一致的名称,在 v1.proto 中查看该名称的编号,在 gc-tap 中查看该编号的记录。
具体:证据行是修复之前的 gc-tap 输出,不做任何改动地整行粘贴(<service> id=… field=… wire=… raw=… bytes=…,开头是服务名)。必须是导致问题的那个字段编号所在的行。
事件:旧版本不动,让新版本兼容
修复三个服务。结算批处理(旧版本,/srv/gc/<service>/v1.proto)无法重新部署,新版本也不能整个回退。修改 /srv/gc/<service>/v2.proto,再用 gc-svc restart <service> 重新启动(尖括号处为服务名),让旧版本能正确读取所有记录,新版本客户端也能原样读取 v2 的字段。已删除的旧字段的编号用 reserved 封存。
方向:修复就是回退第 6 步中挑出的那一处差异。连安全的变更也回退的话,新版本会被破坏。
工具:gc-svc restart <service>(服务器只在启动时读取 .proto)、gc-old 与 gc-new,以及对三个服务运行第 5 步的 skewtest。
具体:旧名称恢复为旧编号、旧类型、旧 enum 编号;占用了旧编号的新字段移到空闲编号,再把那个旧编号设为 reserved。不要改动 v1.proto 和 data.json。