AI 工程实践
OpenTelemetry 只读队列实验:旧数据没坏,Collector 为什么仍启动失败
把同一份三批 bbolt persistent queue 从读写挂载切成只读:验证 Collector exit 1、健康与 OTLP 不可用、数据库哈希不变,以及恢复读写后的三批排空和单批定向重放。
时间与证据
上一篇 真实 ENOSPC 实验证明,底层文件系统写满时,Collector 可能让 queue gauge 多计失败入队项;恢复不能只看进程是否重启,还要核对数据库、loaded items 和 Jaeger 稳定 ID。
这次不改变容量,也不在运行中写满磁盘。我先让固定 Collector v0.156.0 在读写 volume 中持久化三批数据,停机后把同一 named volume以 ro 挂载给 replacement Collector,再恢复为 rw。镜像、配置、数据库字节、queue capacity、consumer、fsync、输入格式和后端保持不变。
固定结果位于 labs/otel-readonly-storage/results/2026-07-16-results.json:
artifact bytes 11,667
artifact SHA-256 01a0e7be8e4e105e81beae1848d8cf661d1de8fa94bad15c8407d059f3a82725
payload SHA-256 77752925c565bcd4a5ed365c0432ee1c6bc9c5eb17ecc16301c6a7a0c4258daf
artifact assertions 11 / 11
工件测试会重新计算五个输入文件哈希,验证 rw -> ro -> rw 合同、HTTP 与进程边界、错误脱敏、数据库字节一致、replacement loaded items、定向重放和 Jaeger 终态。
证据等级是 reproduced。本轮真实运行 Docker read-only mount、bbolt、Collector persistent queue、OTLP/HTTP、Jaeger Badger 和 query API;它不是云盘事故、文件系统损坏、在线 remount、EIO 注入或生产流量验证。
实验设计
要回答的四个问题
需要分别回答四个问题:
- 只读数据库里已有的三批数据会不会在失败启动时被改变?
- Collector 能否只读打开 persistent queue 并继续提供接收服务?
- 恢复写权限后,旧的三个 HTTP 200 能否独立恢复?
- Collector 不可用时从未获得接收确认的第 4 批,是否会被错误当成旧队列数据?
只验证“最终 Jaeger 有四个 span”无法回答这些问题,因为重放全部输入也可能得到相同终态。实验必须保留恢复前的数据库身份、恢复后的三项集合,以及补偿前仍缺失的第 4 个 ID。
固定合同
只改变挂载模式
Collector 配置保持:
file_storage/queue:
directory: /var/lib/otelcol/file_storage
create_directory: false
fsync: true
timeout: 2s
Compose 中唯一的故障开关是:
- collector_queue:/var/lib/otelcol/file_storage:${COLLECTOR_QUEUE_MODE:-rw}
执行顺序固定为 rw -> ro -> rw。create_directory=false 避免把“只读目录不能创建”与“已有 bbolt 不能以运行所需方式打开”混成同一条支路。
Docker volume 文档说明 volume 可通过只读挂载限制容器写入;file storage 文档则明确该扩展以 bbolt 提供持久化键值存储。配置能说明预期,是否真正 fail closed 仍以运行结果为准。
四个稳定身份,不做自动重试
一条 trace 使用 d500...0001,span ID 从 d500...001 到 d500...004。每个请求只有一个 span,sender 记录 automaticRetries=0。
| batch | 发送时点 | 预期权威状态 |
|---|---|---|
| 1 | 初始 Collector 为 rw |
已接受并持久化 |
| 2 | 初始 Collector 为 rw |
已接受并持久化 |
| 3 | 初始 Collector 为 rw |
已接受并持久化 |
| 4 | replacement 为 ro 且退出 |
未取得 Collector 确认 |
最后只允许重放 batch 4。这样 Jaeger 的 1 至 3 必须来自旧队列恢复,而不是应用补发。
结果
读写基线形成三个可核对的 200
Jaeger 停止后,前三批均返回:
{ "partialSuccess": {} }
故障注入前 metrics 为:
receiver accepted 3
receiver refused 0
exporter sent 0
exporter failed 0
exporter enqueue failed 0
queue size / capacity 3 / 20
这里的三个 HTTP 200 与 accepted=3、queue=3 闭合。它们尚未到达 Jaeger,因此权威状态是“Collector 已接受并持久化”,不是“后端已提交”。
只读 replacement 在提供服务前退出
初始 Collector 停止并删除后,同一 volume 改为 ro。replacement 的结果为:
container status exited
exit code 1
OOM killed false
health ECONNREFUSED
OTLP batch 4 ECONNREFUSED
日志明确包含 read-only file system 和容器内路径 /var/lib/otelcol/file_storage。固定工件只保存分类布尔值与匹配行数,不保存完整日志;检查结果确认没有出现宿主 /var/lib/docker/volumes mountpoint。
这条结果说明,固定版本没有降级成“只读查看旧 queue、同时继续接收新数据”。file storage 是发送队列的可变状态面:启动恢复、dispatch 状态回退、item 删除和 metadata 推进都可能需要 transaction。不能因为当前动作看起来只是“读取旧数据”,就给运行时只读权限。
失败启动没有改变旧数据库
在只读 replacement 前后,我都用独立 helper 只读挂载 volume 并计算 stat 与 SHA-256:
bytes before / after 65,536 / 65,536
mode 0600
uid / gid 10001 / 10001
SHA-256 before 016407f7fe9f0099f58ce89128f9206d57397651cd5073c2c343e0a4b3a98bf9
SHA-256 after 016407f7fe9f0099f58ce89128f9206d57397651cd5073c2c343e0a4b3a98bf9
因此本轮能够排除“replacement 先部分修改数据库,再因只读错误退出”。但字节一致只证明数据库文件没有变化,不自动证明它语义健康;后续 replacement 真正加载并交付三个稳定 ID,才完成可恢复性验证。
恢复读写后只排空三个旧项
移除失败容器,把同一 volume 恢复为 rw,再启动新 Collector。启动日志报告:
Loaded queue metadata
itemsSize 3
bytesSize 4,245
dispatchedItems 1
没有新 ingress 时 metrics 为:
accepted 0
sent 3
queue 0 / 20
Jaeger 此时只包含 d500...001、d500...002、d500...003。故障窗口的 d500...004 仍 missing,证明 transport-level ECONNREFUSED 没有被误记为已接受。
随后 runner 只重放 batch 4。最终 Collector 为 accepted=1、sent=4、queue=0,Jaeger 为:
4 total
4 unique
0 duplicate
0 missing
本轮没有观察到重复,不等于所有后端都具备 exactly-once。结论只覆盖固定 Jaeger 版本、相同 stable identity 和这一次顺序化重放。
为什么启动失败比单批 503 更危险
ENOSPC 运行期实验中,Collector 仍在提供 health 和 receiver;上游至少获得了五个 200 与一个明确 503。本轮只读错误发生在 extension 启动阶段,整个 Collector 在接收端口就绪前退出。两类故障需要不同告警:
| 故障面 | 运行期 ENOSPC | 启动期只读 mount |
|---|---|---|
| Collector 进程 | 仍运行 | exit 1 |
| health | 可用 | 不可用 |
| 上游结果 | 200/503 可区分 | transport refusal |
| queue 旧数据 | 当前进程中部分可见 | 只能由后续可写进程恢复 |
| 主要告警 | enqueue failure + free bytes | crash loop + storage EROFS |
| 补偿边界 | 明确拒绝的稳定 IDs | 未取得任何接收确认的稳定 IDs |
如果编排器只看“容器会自动重启”,只读配置错误会形成 crash loop;每次重启都不会让 volume 变得可写。正确动作是阻止无意义重启洪泛、保留原 volume、修正 mount/security context,并在恢复后对账稳定 ID。
生产门禁
部署 persistent queue 前,至少应验证:
- 启动前权限探针:用 Collector 的实际 UID/GID 在目标目录创建、fsync、rename、删除临时文件,不能只执行
test -r。 - 配置 diff:volume mount mode、Kubernetes
readOnly、security context、UID/GID 与目录 ownership 属于发布合同。 - crash-loop 告警:区分配置解析、存储权限、数据库锁、损坏和 OOM,避免统一成“服务不健康”。
- 旧卷保护:修权限前保留快照或只读副本;不要通过递归
chmod 777掩盖 ownership 错误。 - 恢复对账:保存启动 loaded items/bytes、sent、queue depth 与 backend stable IDs。
- 补偿清单:只重放未获接收确认或明确拒绝的 ID;不把所有历史请求重新发送。
- 错误边界:外部只返回稳定的不可用状态,容器路径、volume 名和内部错误留在受控诊断面。
权限探针需要可写测试,因为 bbolt 不是只读配置文件。readable=true 只能证明旧文件能被读取,不能证明 queue 能执行 transaction、恢复 dispatched items 或删除已发送记录。
复现
npm run lab:otel-readonly:test
npm run lab:otel-readonly:run
第一条对冻结工件执行 11 项断言。第二条从空 containers、network 和 volumes 启动新实验,把候选结果写到 /tmp/younis-ai-lab-otel-readonly-storage.json,不会覆盖已发布工件。
runner 默认在 finally 中执行:
docker compose down --volumes --remove-orphans
正式 capture 后已确认 31618、31633、31688、33786、33788 端口,以及相关容器、network 和 volumes 均无残留。
失败与边界
第一,这是启动期只读挂载,不是运行中 remount。已经打开的文件描述符、内核页缓存和 transaction 进行到一半时收到只读错误,可能产生不同结果。
第二,本轮没有注入 EIO。EROFS 表示策略或挂载不允许写;设备错误、网络文件系统断连、fsync timeout 和数据损坏需要独立实验,不能由本结果推导。
第三,数据库哈希不变不是通用保证。固定 replacement 在 open 阶段 fail closed;不同版本、不同 storage extension 或不同故障时点可能先改变文件。
第四,ECONNREFUSED 不代表服务端明确拒绝业务。它只证明本次 TCP 连接没有进入 Collector。上游是否重试仍需稳定身份、预算、退避和最终对账。
第五,四个 synthetic spans 不是吞吐 Benchmark。1 KiB padding 只让队列项具备可核对大小,不代表真实 Agent trace 分布或生产压力。
第六,Jaeger 终态不证明 exactly-once。固定后端最终按相同 span identity 查询到四个唯一对象;其他存储可能追加重复记录、覆盖或采用不同查询语义。
复盘
第一个结论是“文件没坏”和“服务能启动”是两件事。只读 helper 可以计算完整 SHA-256,仍不意味着 Collector 能用这个数据库承担 persistent queue。恢复路径需要可写 transaction,而不是只读导入。
第二个结论是 crash-loop 阶段没有 receiver counter 可供闭合。batch 4 只留下客户端 ECONNREFUSED,Collector 的 accepted/refused 都不会增加。因此上游必须保留自己的 stable identity 与 delivery ledger,不能只依赖服务端 metrics 判断需要补偿的集合。
第三个结论是恢复与补偿必须分两步。replacement 在 accepted=0 时 sent=3,先证明三个旧 200 的交付;batch 4 仍 missing,再允许单项重放。最终 4/4 只是最后一层证据,不替代中间集合验证。
商业价值
这类故障常来自部署模板、volume mount、UID/GID、只读根文件系统或安全加固,而不是代码业务逻辑。若团队只测试“镜像能启动且配置能解析”,权限错误会在发布后把遥测入口整体关闭,同时让值班人员误以为旧数据库损坏。
可售卖的工程能力不是一个 chmod 命令,而是一套发布和恢复合同:部署前写权限探针、mount/security-context diff、crash-loop 归因、旧卷保护、loaded item 对账、稳定 ID 补偿和错误脱敏。它能减少盲目删除 volume、全量重放和不可解释数据缺口造成的事故成本。
从全栈工程迁移到 AI 系统工程
传统后端会区分数据库可读、事务可写、进程 readiness 和业务请求是否提交。AI 系统的 trace、评测事件、Agent checkpoint、工具调用 outbox 和模型请求缓冲同样需要这些边界。
本轮体现的迁移能力包括:
- 把容器 mount mode 当成数据一致性合同,而不是部署细节。
- 用客户端 transport、进程状态、日志分类、文件哈希、queue metadata 和 backend query 构造证据链。
- 区分已接受恢复集合与未接受补偿集合。
- 在故障工件中保留可验证摘要,同时不保存宿主 mountpoint 和容器 ID。
- 用真实 failure mode 驱动 runbook 与发布门禁,而不是只写 happy-path 架构图。
面试表达
**30 秒版本:**我让 OpenTelemetry Collector 在 Jaeger 停止时把三个稳定 span 写入 bbolt persistent queue,然后用同一 volume 的只读挂载替换 Collector。replacement 在提供 health 或 OTLP 前 exit 1,第 4 批得到 ECONNREFUSED;失败前后 65,536-byte 数据库 SHA-256 一致。恢复读写后,新进程 loaded 3、sent 3,第 4 个 ID 仍缺失;只重放第 4 批后 Jaeger 4 unique、0 duplicate、0 missing。
**3 分钟版本:**实验固定 Collector 0.156.0、Jaeger 2.19.0、20-batch queue、单 consumer、fsync 和四个稳定 ID,只把 volume 从 rw 切 ro 再切回 rw。前三批 HTTP 200 与 accepted=3、queue=3 闭合。停机后记录数据库 size、mode、uid/gid 和 SHA;ro replacement exit 1,日志分类为 read-only filesystem,health 与 OTLP 都拒绝连接,数据库字节未变。恢复 rw 后,启动日志 loaded 3 items/4,245 bytes,accepted=0、sent=3,Jaeger 只有前三个 ID。故障窗口中的第四个没有服务端接收证据,因此只对它执行带稳定身份的补偿,最终无重复无缺失。生产上我会把可写权限探针、crash-loop 分类、旧卷保护、loaded item/backend ID 对账和补偿 ledger 放进部署与恢复门禁。
方法披露
正文与图表由 AI 辅助整理;实验设计、Docker 运行、只读故障注入、工件冻结、哈希复核、11 项断言和结论核对均在本地完成。公开工件不保存 synthetic padding 原文、完整 Collector 日志、容器 ID、Docker 宿主 mountpoint、数据库凭证或个人路径。
修订记录
2026-07-16:初版发布;完成三批读写基线、只读 replacement 启动失败、数据库字节一致性、读写恢复、单批补偿和 Jaeger 四身份终态验证。
Reusable projects
关联可复用项目
本文已经进入以下工程项目;项目页提供固定版本、运行命令和结果工件。
- 已独立复现
本文把原 queue volume 只读挂载给 replacement,验证进程 fail closed、数据库未变,恢复读写后加载三批并定向重放。
Source ledger
来源账本
以下来源用于核对事实、日期与当时可用范围。厂商自报性能不视为本站独立复现。
- Docker volume read-only mount documentation at a fixed commit
- Collector Contrib file storage extension README at v0.156.0 commit
- Collector Contrib file storage client open path at v0.156.0 commit
- Collector exporter helper persistent queue documentation at v0.156.0 commit
- Jaeger v2.19.0 query extension documentation
讨论