AI 工程实践

OpenTelemetry 只读队列实验:旧数据没坏,Collector 为什么仍启动失败

把同一份三批 bbolt persistent queue 从读写挂载切成只读:验证 Collector exit 1、健康与 OTLP 不可用、数据库哈希不变,以及恢复读写后的三批排空和单批定向重放。

发布:2026/07/16更新:2026/07/16
同一 OpenTelemetry bbolt 队列先在读写挂载中接受三批,只读 replacement Collector 启动失败且数据库哈希不变,恢复读写后排空三批并只重放故障窗口中的第四批,Jaeger 最终收齐四个唯一 ID
图 1:2026-07-16 只读持久队列实验。`ro` replacement 在提供 health 或 OTLP 前 exit 1;旧数据库字节保持不变,恢复 `rw` 后三批已接受数据与一批未接受补偿被分开验证。

时间与证据

上一篇 真实 ENOSPC 实验证明,底层文件系统写满时,Collector 可能让 queue gauge 多计失败入队项;恢复不能只看进程是否重启,还要核对数据库、loaded items 和 Jaeger 稳定 ID。

这次不改变容量,也不在运行中写满磁盘。我先让固定 Collector v0.156.0 在读写 volume 中持久化三批数据,停机后把同一 named volumero 挂载给 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 注入或生产流量验证。

实验设计

要回答的四个问题

需要分别回答四个问题:

  1. 只读数据库里已有的三批数据会不会在失败启动时被改变?
  2. Collector 能否只读打开 persistent queue 并继续提供接收服务?
  3. 恢复写权限后,旧的三个 HTTP 200 能否独立恢复?
  4. 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 -> rwcreate_directory=false 避免把“只读目录不能创建”与“已有 bbolt 不能以运行所需方式打开”混成同一条支路。

Docker volume 文档说明 volume 可通过只读挂载限制容器写入;file storage 文档则明确该扩展以 bbolt 提供持久化键值存储。配置能说明预期,是否真正 fail closed 仍以运行结果为准。

四个稳定身份,不做自动重试

一条 trace 使用 d500...0001,span ID 从 d500...001d500...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=3queue=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...001d500...002d500...003。故障窗口的 d500...004 仍 missing,证明 transport-level ECONNREFUSED 没有被误记为已接受。

随后 runner 只重放 batch 4。最终 Collector 为 accepted=1sent=4queue=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 前,至少应验证:

  1. 启动前权限探针:用 Collector 的实际 UID/GID 在目标目录创建、fsync、rename、删除临时文件,不能只执行 test -r
  2. 配置 diff:volume mount mode、Kubernetes readOnly、security context、UID/GID 与目录 ownership 属于发布合同。
  3. crash-loop 告警:区分配置解析、存储权限、数据库锁、损坏和 OOM,避免统一成“服务不健康”。
  4. 旧卷保护:修权限前保留快照或只读副本;不要通过递归 chmod 777 掩盖 ownership 错误。
  5. 恢复对账:保存启动 loaded items/bytes、sent、queue depth 与 backend stable IDs。
  6. 补偿清单:只重放未获接收确认或明确拒绝的 ID;不把所有历史请求重新发送。
  7. 错误边界:外部只返回稳定的不可用状态,容器路径、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 后已确认 3161831633316883378633788 端口,以及相关容器、network 和 volumes 均无残留。

失败与边界

第一,这是启动期只读挂载,不是运行中 remount。已经打开的文件描述符、内核页缓存和 transaction 进行到一半时收到只读错误,可能产生不同结果。

第二,本轮没有注入 EIOEROFS 表示策略或挂载不允许写;设备错误、网络文件系统断连、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=0sent=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

关联可复用项目

本文已经进入以下工程项目;项目页提供固定版本、运行命令和结果工件。

Source ledger

来源账本

以下来源用于核对事实、日期与当时可用范围。厂商自报性能不视为本站独立复现。

  1. Docker volume read-only mount documentation at a fixed commit
    Docker官方文档文章资料来源发布:2025/04/03本站核验:2026/07/16
  2. Collector Contrib file storage extension README at v0.156.0 commit
    OpenTelemetry官方文档文章资料来源发布:2026/07/07本站核验:2026/07/16
  3. Collector Contrib file storage client open path at v0.156.0 commit
    OpenTelemetry官方仓库文章资料来源发布:2026/07/07本站核验:2026/07/16
  4. Collector exporter helper persistent queue documentation at v0.156.0 commit
    OpenTelemetry官方文档文章资料来源发布:2026/07/06本站核验:2026/07/16
  5. Jaeger v2.19.0 query extension documentation
    Jaeger官方文档文章资料来源发布:2026/06/03本站核验:2026/07/16

讨论

正在加载评论...