AI 工程实践
OpenTelemetry 真满盘实验:5 个 200 之后,为什么必须异卷恢复
不配置 file_storage.max_size,只用 65,536-byte tmpfs 触发真实 ENOSPC;验证五批已接受、一批 503、queue gauge 多计,以及数据库字节一致迁移后的恢复与定向重放。
时间与证据
上一篇 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...001 到 c400...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 内核,size、uid、gid 和 mode 都是明确 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:
- storage anchor 持续挂载 64 KiB 故障卷,避免 tmpfs 因无人挂载而被重新创建。
- 停止 Collector,故障卷只读挂载。
- 创建 262,144-byte recovery tmpfs。
- 复制完整 bbolt 文件,分别计算源和目标 SHA-256 与 logical bytes。
- 只有
source hash == target hash且 bytes 一致,才启动 replacement Collector。 - 原故障卷保持不变,便于回查。
这不是声称生产必须用 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。
更完整的生产合同应包含:
- 每租户 ingest quota 与 byte-based budget。
- available-byte 和 inode 的提前告警。
- 503 归一化、retry-after 与幂等身份。
- 原卷快照、扩容或异卷迁移 runbook。
- 恢复前后 database hash、loaded items 和 backend identity reconciliation。
- 对内部路径、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
关联可复用项目
本文已经进入以下工程项目;项目页提供固定版本、运行命令和结果工件。
- 已独立复现
本文在 64 KiB tmpfs 制造真实 ENOSPC,执行字节一致异卷复制,恢复已接受五批并只重放被拒第六批。
Source ledger
来源账本
以下来源用于核对事实、日期与当时可用范围。厂商自报性能不视为本站独立复现。
- Docker tmpfs mounts documentation at the last modifying commit
- Collector Contrib file storage extension README at v0.156.0 commit
- Collector Contrib bbolt max-size and storage error handling at v0.156.0 commit
- Collector persistent queue offer and recovery behavior at v0.156.0 commit
- OpenTelemetry Collector exporter helper persistent queue documentation at v0.156.0 commit
- Jaeger v2.19.0 query extension documentation
讨论