AI 工程实践

OpenTelemetry 真满盘实验:5 个 200 之后,为什么必须异卷恢复

不配置 file_storage.max_size,只用 65,536-byte tmpfs 触发真实 ENOSPC;验证五批已接受、一批 503、queue gauge 多计,以及数据库字节一致迁移后的恢复与定向重放。

发布:2026/07/15更新:2026/07/15
六批稳定 span 进入 OpenTelemetry Collector;64 KiB tmpfs 在第五批后写满,第六批收到 ENOSPC,数据库字节一致迁移到 256 KiB 恢复卷后发出五批,再重放一批使 Jaeger 收齐六个唯一 ID
图 1:2026-07-15 物理 ENOSPC 实验。故障卷 available=0,当前进程 gauge 为 6,但只有 5 批收到 200;数据库迁移到有空闲空间的卷后恢复 5 批,再定向重放被拒绝的第 6 批。

时间与证据

上一篇 OpenTelemetry 存储上限实验把 bbolt max_size 设为 65,536 bytes,证明应用级上限可以在 queue 仍显示 6/20 时拒绝两批。但它没有让底层文件系统耗尽:拒绝来自 bbolt 配置,而不是 Linux ENOSPC

这一次我保留 Collector v0.156.0、Jaeger v2.19.0、20-batch queue、单 consumer、fsync=true、六个稳定 ID 和每批 4,096-byte synthetic padding,只改变存储边界:

file_storage/queue:
  directory: /var/lib/otelcol/file_storage
  create_directory: true
  fsync: true
  timeout: 2s

配置中没有 max_size。Collector 的目录挂到 Docker local volume,而该 volume 的 mount options 固定为:

driver_opts:
  type: tmpfs
  device: tmpfs
  o: "size=65536,uid=10001,gid=10001,mode=0700"

固定结果位于 labs/otel-enospc-recovery/results/2026-07-15-results.json,共 16,178 bytes,文件 SHA-256 为 294a7776647affe5b847b182b701a4a7f9a0059b18d3981e31f1ed5f760302ba,加入自引用字段前的 payload SHA-256 为 35501a001b5a2e6c38c1f0e6370bbdd234a2841123aae0290691d78a8f88e8be。11/11 工件测试会重新计算五个输入文件哈希,并验证 HTTP 分界、counter/gauge、真实 tmpfs 容量、available bytes、数据库迁移、恢复 ID、定向重放、Jaeger 终态和隐私边界。

证据等级保持 reproduced。本轮真实运行 Linux tmpfs、bbolt、Collector persistent queue、OTLP/HTTP、Jaeger Badger 与 query API;它没有模拟 SSD 硬件损坏、Kubernetes ephemeral-storage eviction、网络文件系统或云盘扩容。

实验设计

六个稳定 ID,不做自动重试

一条 trace 使用 c400...0001,span IDs 从 c400...001c400...006。每次应用请求只含一个 span,sender 的 automaticRetries=0

batch span ID 入口调用
1 c400...001 唯一一次
2 c400...002 唯一一次
3 c400...003 唯一一次
4 c400...004 唯一一次
5 c400...005 唯一一次
6 c400...006 唯一一次

Jaeger 先停止,六批串行进入 Collector。这样每个 HTTP response 都能对应一个唯一身份,不会把 SDK 自动重试、Collector retry queue 和最后的人工补偿混为一条路径。

什么证据才足以叫“真实 ENOSPC”

Docker tmpfs 文档说明 tmpfs 直接映射到 Linux 内核,sizeuidgidmode 都是明确 mount options。本轮不能只看 compose 文本,还同时保存运行时三组证据:

/proc/mounts: source=tmpfs type=tmpfs
df: total / used / available / used percent
stat: bbolt logical bytes / allocated bytes

故障必须同时满足:filesystem type 是 tmpfs、total=65,536、used=65,536、available=0、Collector log 与 HTTP body 都出现 no space left on device。如果只有 max_size reached,那仍是上一篇的应用级路径;如果只有磁盘使用率接近 100%,也不能证明某次写入真正收到了 ENOSPC。

为什么恢复要换卷,而不是只重启

bbolt 文件在故障点的 logical size 为 131,072 bytes,但 allocated 只有 65,536 bytes。它是一个已经越过文件系统容量的稀疏文件:逻辑地址空间能扩展,不代表底层还有块可写。

persistent queue 重启不只是“只读打开数据库”。它还可能要把 shutdown 时处于 dispatched 状态的 item 放回队列、推进 read metadata、删除已发送 item 和提交 bbolt transaction。原卷 available=0 时,不能假设这些恢复写操作一定成功。

因此固定流程采用 out-of-place recovery:

  1. storage anchor 持续挂载 64 KiB 故障卷,避免 tmpfs 因无人挂载而被重新创建。
  2. 停止 Collector,故障卷只读挂载。
  3. 创建 262,144-byte recovery tmpfs。
  4. 复制完整 bbolt 文件,分别计算源和目标 SHA-256 与 logical bytes。
  5. 只有 source hash == target hash 且 bytes 一致,才启动 replacement Collector。
  6. 原故障卷保持不变,便于回查。

这不是声称生产必须用 cp 修数据库,而是把两个问题拆开:先证明输入数据库没有被迁移过程改变,再证明给它可写空间后能恢复哪些稳定 ID。

结果

五个 200,第六个是真实文件系统 503

前五批返回 HTTP 200:

{ "partialSuccess": {} }

第六批返回 HTTP 503:

{
  "code": 14,
  "message": "write /var/lib/otelcol/file_storage/exporter_otlp_http_jaeger_traces: no space left on device"
}

这与上一篇的 the storage extension has run out of available space 不同:本轮错误包含内核语义和 Collector 内部绝对路径。若 Collector receiver 直接暴露到不可信客户端,这个 body 会泄露部署目录。生产边界应对外归一化为稳定错误码和可操作的 retry contract,把完整路径只留在受控日志中。

Counter 是 5/1,Gauge 仍显示 6/20

故障时 metrics 为:

receiver accepted       5
receiver refused        1
exporter enqueue failed 1
exporter sent           0
exporter failed         0
queue size/capacity     6 / 20

accepted 5 + refused 1 与六个 HTTP responses 闭合。enqueue_failed=1 说明第六批没有成功进入持久队列,但 gauge 比已接受数量多 1,恰好等于失败入队次数。

persistent_queue.go显示该版本在存储 transaction 前推进内存 metadata,失败路径不直接回滚,而是依赖后续 drain 修复。源码用于解释,不替代运行结果;真正的证明是 gauge=6、replacement loaded=5,以及 Jaeger 在补偿前只有五个稳定 IDs。

Logical bytes 不是实际占用

观察面 空队列 ENOSPC
tmpfs total 65,536 65,536
tmpfs used 20,480 65,536
tmpfs available 45,056 0
bbolt logical bytes 32,768 131,072
bbolt allocated bytes 20,480 65,536
queue gauge 0 6
persisted/accepted batches 0 5

这里出现四个不同量:逻辑文件长度、已分配块、queue gauge 和持久 item 数。任何一个都不能单独替代另外三个。尤其不能看到 logical=131,072 就断言磁盘实际写了 128 KiB;tmpfs 只分配了 64 KiB,available 已经归零。

迁移先证明字节没变

故障数据库与恢复数据库的身份为:

source bytes   131,072
target bytes   131,072
source SHA-256 1f0dcc318ab16f37c8eeeab1aa64f5bc06cc64227abcb1d8f24b1360509c3721
target SHA-256 1f0dcc318ab16f37c8eeeab1aa64f5bc06cc64227abcb1d8f24b1360509c3721
byte identical true

普通复制会把稀疏空洞实际写到 recovery tmpfs,因此目标卷状态变成 total=262,144、used=131,072、available=131,072。这个变化不是数据库内容变化,而是物理块分配方式变化;文件哈希与 logical bytes 仍完全相同。

Replacement 恢复五批,再补一批

replacement Collector 启动日志报告:

Loaded queue metadata
itemsSize        5
bytesSize   22,405
dispatchedItems  1

没有新 ingress 时,它给出:

accepted 0
sent 5
queue 0 / 20

Jaeger 此时正好包含 IDs 1 至 5,第六个 ID 仍 missing。随后 runner 只重放 batch 6;最终 Collector 为 accepted 1、sent 6、queue 0,Jaeger 为:

6 total
6 unique
0 duplicate
0 missing

这条顺序非常重要。如果一开始重放全部六批,最终 6/6 可能掩盖重复投递,也无法证明五个 HTTP 200 的数据来自数据库恢复。

ENOSPC 告警应该看什么

只看 queue utilization 会严重误判。本轮 6/20 只有 30%,但文件系统已经 100% 使用;只看 logical file size 也会误判,因为 131,072-byte logical file 在 65,536-byte tmpfs 上仍可能存在。

建议至少联合:

信号 用途
filesystem available bytes 真实可写空间
filesystem inode availability 区分块耗尽与 inode 耗尽
exporter enqueue failed 入口到持久队列的失败
receiver refused 已明确返回调用方的拒绝
queue size / capacity 逻辑 item 压力,但不能单独使用
storage error class 区分 ENOSPC、max-size、permission
restart loaded items / bytes 磁盘权威恢复集合
backend stable identity set 最终业务数据完整性

告警应在 available bytes 进入恢复空间预算之前触发,而不是等 df=100%。恢复空间预算还要包含 bbolt transaction、metadata 更新、日志、临时文件和复制策略,不应把“当前文件 allocated bytes”直接当成最低剩余空间。

复现

npm run lab:otel-enospc:test
npm run lab:otel-enospc:run

第一条对固定工件执行 11 项断言。第二条从空 volumes 运行新实验,把候选结果写到 /tmp/younis-ai-lab-otel-enospc-recovery.json,不会覆盖已发布工件。

runner 的 finally 默认执行:

docker compose down --volumes --remove-orphans

只有显式传入 --keep-stack 才保留现场。正式 capture 后已再次确认相关容器、network、volumes 和五个端口均无残留。

失败与边界

第一,64 KiB 是故障注入规模,不是生产建议。真实 Collector queue 必须按峰值流量、平均/尾部 payload、下游最长不可用时间、数据库增长阶梯与恢复余量共同预算。

第二,五批不是通用容量公式。padding、序列化、bbolt page reuse、空闲页、版本和 shutdown timing 都会改变边界。本轮只证明固定输入在固定镜像上的 5/1 分界。

第三,tmpfs 是真实 ENOSPC,但不是持久介质。它适合确定性故障注入;主机重启、容器平台调度、云盘扩容、文件系统 journal 和 SSD failure mode 不在本轮范围。

第四,字节一致不等于数据库语义一定健康。本轮 replacement 能加载五个 items 并查询五个稳定 IDs,才把“复制一致”推进为“可恢复”;生产还应在隔离环境运行数据库检查、保留原卷快照并制定回退点。

第五,HTTP 200 表示 Collector 已接受,不是下游已经提交。五个 200 在 Jaeger 停止时只进入持久队列;只有 replacement sent=5 与 Jaeger ID 集合才证明最终交付。

第六,503 不等于可盲目重试。调用方必须用稳定 idempotency key 或稳定 span identity,设置退避与 retry budget,并先确认 Collector/下游状态;否则恢复与重试可能产生重复。

复盘

本轮最重要的变化不是把错误消息从 max size 换成 ENOSPC,而是恢复前提发生了变化。应用级上限提高后,原卷本身仍有物理可写空间;真实满盘时,读取 metadata 之后的 requeue、delete 和 transaction commit 仍可能需要新块,因此“进程能启动”不是“queue 能恢复”的充分条件。

第二个判断是入口成功、内存 gauge 和磁盘权威集合必须分开。HTTP 200 与 accepted counter 闭合为五批;gauge 的第六批对应失败的 enqueue;replacement loaded items 再次独立证明持久集合只有五批。三者如果被合并成一个“queue depth”,值班人员就无法决定该扩容、恢复还是通知上游补偿。

第三个判断是恢复动作本身也要有数据合同。源卷保留、源目标 bytes/hash 相同、目标卷有明确 free space、replacement 在 accepted=0 时 sent=5、拒绝 ID 仍 missing,最后才允许重放第六批。这个顺序让每一步都能回退或对账,而不是用最终 6/6 掩盖中间是否丢失或重复。

商业价值

这类实验直接影响可观测平台的容量和事故成本。只按 queue item 数采购磁盘,会在 payload 变大或 bbolt 增长阶梯跳变时提前满盘;只看磁盘百分比,又无法判断哪些请求已经向调用方返回 200、哪些已收到 503。

更完整的生产合同应包含:

  1. 每租户 ingest quota 与 byte-based budget。
  2. available-byte 和 inode 的提前告警。
  3. 503 归一化、retry-after 与幂等身份。
  4. 原卷快照、扩容或异卷迁移 runbook。
  5. 恢复前后 database hash、loaded items 和 backend identity reconciliation。
  6. 对内部路径、volume 名与错误细节的外部响应脱敏。

它把“加大磁盘”变成可以验收的恢复过程:源数据是什么、迁移有没有改变字节、恢复了哪些已接受 ID、哪些明确拒绝项仍需补偿、最终是否重复或缺失。

从全栈工程迁移到 AI 系统工程

传统全栈系统常把 200/500、数据库事务和业务状态闭合起来;遥测管道同样需要这套思路,只是中间多了 receiver、persistent queue、exporter retry 与 backend commit。

本轮迁移出的能力包括:

  • 从 HTTP、metrics、文件系统和 backend query 构造跨层证据链。
  • 用稳定 ID 区分恢复数据与补偿数据。
  • 区分逻辑容量、文件逻辑长度和物理块容量。
  • 把恢复设计成可回滚的数据迁移,而不是一次无证据重启。
  • 在可观测性组件自身故障时仍保留审计边界。

这些能力同样适用于向量库 WAL、Agent 任务队列、模型请求缓冲、评测事件存储和异步工具调用结果。

面试表达

**30 秒版本:**我没有设置 OpenTelemetry file storage 的 max_size,而是把 bbolt 放到真实 64 KiB tmpfs。前五批返回 200,第六批返回带 no space left on device 的 503;metrics 是 accepted 5/refused 1,但 queue gauge 显示 6/20。停机后把 131,072-byte 稀疏数据库字节一致迁移到 256 KiB tmpfs,replacement 恢复五批,只重放第六批后 Jaeger 6 unique、0 missing。

**3 分钟版本:**我固定 Collector v0.156.0、Jaeger v2.19.0、单 consumer、fsync、六个稳定 ID 和无自动重试,只把应用级 max-size 替换成 Linux tmpfs 容量。运行时 /proc/mounts 证明 type=tmpfs,df 证明 available=0,HTTP 与日志都返回 ENOSPC。bbolt logical 131,072 但 allocated 65,536,说明文件长度不能替代物理占用;gauge 6 又比 accepted/persisted 5 多一。恢复时我不在满卷上直接重启,而是停机复制到有 128 KiB free space 的恢复卷,源目标文件 hash 完全一致。replacement loaded=5、sent=5,Jaeger 先出现 1-5,第六个仍 missing;定向重放 6 后终态无重复无缺失。生产告警必须联合 available bytes、enqueue failures、refused、loaded items 和 backend IDs,外部 503 还应隐藏内部文件路径。

方法披露

正文与图表由 AI 辅助整理;实验设计、Docker 运行、故障现场读取、工件冻结、哈希复核、源码定位、11 项断言和最终结论均在本地完成。公开工件不保存 synthetic padding 原文、容器 ID、Docker mountpoint、数据库凭证或主机路径。

修订记录

  • 2026-07-15:初版发布;完成真实 tmpfs ENOSPC、5/1 HTTP 分界、queue gauge 多计、稀疏文件观察、数据库字节一致异卷迁移、五批恢复、第六批定向重放和 Jaeger 六身份终态验证。

Reusable projects

关联可复用项目

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

Source ledger

来源账本

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

  1. Docker tmpfs mounts documentation at the last modifying commit
    Docker官方文档文章资料来源发布:2025/04/03本站核验:2026/07/15
  2. Collector Contrib file storage extension README at v0.156.0 commit
    OpenTelemetry官方文档文章资料来源发布:2026/07/07本站核验:2026/07/15
  3. Collector Contrib bbolt max-size and storage error handling at v0.156.0 commit
    OpenTelemetry官方仓库文章资料来源发布:2026/07/07本站核验:2026/07/15
  4. Collector persistent queue offer and recovery behavior at v0.156.0 commit
    OpenTelemetry官方仓库文章资料来源发布:2026/07/06本站核验:2026/07/15
  5. OpenTelemetry Collector exporter helper persistent queue documentation at v0.156.0 commit
    OpenTelemetry官方文档文章资料来源发布:2026/07/06本站核验:2026/07/15
  6. Jaeger v2.19.0 query extension documentation
    Jaeger官方文档文章资料来源发布:2026/06/03本站核验:2026/07/15

讨论

正在加载评论...