CubeS3lvol 三层测试体系完全指南:从离线单测到 S3 数据面端到端回归
2026/9/16 8:23:13 网站建设 项目流程

CubeS3lvol 三层测试体系完全指南:从离线单测到 S3 数据面端到端回归

【免费下载链接】CubeSandboxInstant, Concurrent, Secure & Lightweight Sandbox for AI Agents.项目地址: https://gitcode.com/GitHub_Trending/cu/CubeSandbox

本篇指南以 CubeS3lvol 存储组件(SPDK 驱动层)自带的 test/README.md 为核心骨架,系统讲解该仓库test/目录下从integration/dataplane/tools/的三层测试架构。你将掌握如何用make check一键运行 24 个测试套件、理解每个套件对应的真实缺陷类型(WAL 并发写、快照隔离、跨 lvstore 导出、文件系统 FLUSH 等),并学会用s3lvol_rpc.pys3_prefix_rm.py等工具进行失败诊断与残留清理。

三层测试结构与设计动机

CubeS3lvol 是一个"基于 S3 的块存储"组件:通过 NVMe/TCP 目标(s3lvol_tgt)将卷数据保存在对象存储中,本地磁盘只用于 WAL 与元数据日志。其核心思路可参见 CubeS3lvol/README.md。正因数据路径贯穿 DPDK/SPDK、NVMe-oF、真实 S3 后端,测试被刻意设计成依赖递增的三层,改动代码后应按由快到慢、由廉价到侵入的顺序执行:

目录内容依赖耗时
integration/按模块划分的断言式测试,直接链接libs3bsdev.amake check批量无需凭据、无需 root约 1 分钟
dataplane/端到端:target + nvmf + 真实 S3 + 真实块设备root、真实 bucket 与凭据、nvme-clifio每个 3–6 分钟
tools/上述两层共用的小工具及手工调试工具

这种分层的直接好处写在 test/run_all.sh 的注释里:10 个 integration 测试可在任何环境运行,2 个额外需要真实 S3 凭据,10 个 dataplane 脚本则需要 root、凭据、可写的/data以及整台机器 nvme 栈的独占使用权。若没有统一入口,"运行测试"就退化为记住 22 条手工调用命令。

一条命令跑全部:make check 与 run_all.sh

顶层 Makefile 提供了三条快捷入口:

make check # 24 个套件(等价于 test/run_all.sh;dataplane 需要 root + S3) make check-offline # 无需凭据和 root 的套件(test/run_all.sh --offline) make check-rules # 仅运行分层规则检查(test/tools/check_layering.sh)

更细粒度地控制运行范围,直接使用 run_all.sh:

test/run_all.sh --list # 仅列出将运行什么、环境具备什么,不执行 test/run_all.sh --no-dataplane # 两层 integration(含需要 S3 的),不含 dataplane test/run_all.sh --offline # 只需离线 integration 与离线工具 test/run_all.sh # 环境允许的一切

run_all.sh存在的根本原因(见 test/README.md 与 run_all.sh):各套件的前置条件曾经各自漂移——哪些需要凭据、哪些需要 root、哪些需要可写/data,现在统一收敛到run_all.sh --list的输出与它打印的 skip 原因里,而不是放在一个"每新增一个套件就会过时"的计数中。--list会同时打印本机探测结果:root 是否有、凭据是否有、endpoint/bucket/region 是什么(从/data/cubelet/s3.cfg读取,可用环境变量S3LVOL_TEST_BUCKET覆盖)。

运行前的环境探测逻辑

run_all.sh对环境的判定直接决定哪些套件会变成 SKIP(run_all.sh):

  • rootid -u是否为 0;
  • 凭据:已导出的AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY优先;否则回退读取s3.cfg
  • S3 可用:凭据 + endpoint + bucket 三者齐全才算HAVE_S3=1
  • dataplane 阻塞器:若目标进程s3lvol_tgt已在运行、或/data/cubelet/rcow/active_lvols中仍有活动卷记录(或该文件损坏无法解析),则整组 dataplane 套件被 SKIP——这是防止"上次残留把本次运行搅乱"的第一道防线。

此外run_all.sh会读取s3.cfg中的path_style/no_tls标志,并通过S3LVOL_TEST_PATH_STYLE/S3LVOL_TEST_NO_TLS/S3LVOL_TEST_S3FLAGS导出给 dataplane 脚本,使其对 MinIO(path-style + 明文 HTTP)与云 COS(virtual-hosted + HTTPS)两类后端一视同仁。

五个防止"假绿"的设计决策

test/README.md明确列出每一条决策都对应一种"看起来全绿、实际什么都没测"的运行方式,这也是整个测试体系最值得借鉴的部分:

  • Skipped 不等于 Passed。无法运行的套件以 SKIP 形式上报并附原因,汇总时单独计数;退出码2专门留给"全部通过但部分套件因环境无法运行",与0严格区分——二者共用退出码正是 CI 假绿的机制。实现见 run_all.sh:只有"因显式标志而跳过"才允许与通过共用0,因环境被迫跳过的会输出PASSED, but some suites could not run并返回 2。
  • 先构建。dataplane 脚本拒绝针对比源码更旧的二进制运行(见下文check_binary_fresh.sh);不先构建,陈旧代码树的整个后半段都会变成 skip。
  • 串行。每个 dataplane 脚本都假定独占本机:固定 RPC socket、nvme 连接、以及编译进模块的bstore.jsonrcow_active_lvols路径。两个同时跑不会干净地失败,而是互相穿插。
  • 失败不停跑。完整一轮需数分钟,在第一个红点就停会丢掉"坏了一个还是坏了十个"——这是最有价值的信息。失败被收集并在最后统一重放。
  • 从廉价到侵入排序,激活与控制放最后。它们对遗留全局状态最敏感,因此也最适合作为"前面所有套件是否清理干净"的最终裁决。

一个真实踩过的坑:失败后故意保留残留

文档记录了一个在第二次运行中真实命中过的陷阱(test/README.md):失败的 dataplane 脚本会故意保留其 S3 前缀与bstore.json条目(便于诊断),因此同一脚本的下一次运行会发现 lvstore 已被记录、走 attach 路径而非 create 路径,从而以与原问题毫无关系的方式失败("没有走 create 路径"、"无法创建 ctl-a")。run_all.sh现在会在失败汇总后直接打印清理命令(run_all.sh):

test/tools/s3_prefix_rm.py -e <ep> -b <bucket> -r <region> -p <lvs>/ # 并手动从 /data/cubelet/rcow/bstore.json 中移除 <lvs> 条目

integration/:无凭据的断言式单测层

make -C test/integration check

运行 10 个不需要 S3 凭据的套件(spawner / thread_bounce / journal / wal / cache / flush / export / statefile / local_dev / checkpoint),当前共 491 个断言。journalwal/tmp下创建 aio 镜像,cachelocal_dev/data下,全部不触碰网络。

这层的构建规则在 test/integration/Makefile 中有非常细致的说明,其中两条最关键:

  • 两种链接方式不可混用s3_client_test/s3_spawner_test链接spdk_thread_stub.c(从普通 pthread 提交、owner_thread恒为 NULL,bounce 分支运行时不可达);s3_thread_bounce_test必须链接真实 spdk_thread + spdk_env_dpdk 并传--no-huge,才能真正驱动线程弹跳——它也因此成为整棵树中第一个到达spdk_env_init()的链接,是"Dropped DPDK constructor"这类链接错误最廉价的探针(40 秒内跑完)。
  • DPDK 始终静态且--whole-archive:bdev_aio、accel 等模块靠构造函数自注册、无显式符号引用,普通链接会直接丢弃它们,这正是s3_journal_test/s3_wal_test/s3_cache_test/s3_local_dev_test必须--whole-archive的原因。

各套件对应的真实缺陷

每个 integration 套件都锚定一类真实缺陷,而非泛泛的"写了能读回":

  • cache:读缓存允许 miss,但绝不允许返回错误数据、绝不允许复用他人仍在使用的槽位。旧 uuid 必须 miss、淘汰必须放过 pinned 槽位、populate 在旧版本被读时必须让出。它用墙钟时间等待 I/O 而非轮询计数(原因见 HANDOFF 5.20)。[11][13]节是驻留位图——槽位只持有对象的一部分,未持有的块保留着前一租户的字节,三节分别钉死:未填充区间必须 miss(含仅部分重叠的读)、短对象尾部的整块/半块边界、uuid 更换或槽位复用后旧区间不可读。
  • journal[12]节用仅 4 块(4×64=256 条记录即可环绕)构造第二个 journal,因为默认 16 MiB journal 需要 262144 条记录才能环绕一次——真实机器根本无法被推进到那条路径。
  • export:只测 manifest 且不启动 DPDK——重点是"可解析但错误的 manifest 必须被拒绝",因为这是唯一没有警示音的失败模式:坏 uuid 仍能 GET 回内容,读到的却是别人的数据。其中一条用例直接使用从活机上抓取的真实 manifest,而非人工构造。
  • statefile:只测文件 I/O。两个崩溃形态:残留的部分.tmp不得泄漏进读取(模拟 rename 前崩溃,目标文件仍是完好的旧内容);过短文件(就地写入崩溃的经典残留)必须被拒绝而非作为空对象交给 JSON 解析器。外加环境覆盖策略:绝对路径生效、相对路径被拒、解析结果被缓存。
  • local_dev:测试超级块的四个拒绝路径及各自精确 errno——坏 magic / 坏 CRC 为-EILSEQ,未知版本为-EPROTO,双 bdev 布局缺少 cache bdev 重开为-EINVAL。损坏通过直接重写后备文件的前 4 KiB植入(损坏是外部的,不是模块自身会走的路径)。这里还踩过一个陷阱:struct s3_super_block约 200 字节,而磁盘块是整整 4 KiB,用结构体当读写缓冲区会栈溢出。
  • checkpoint:测试加载 checkpoint 的拒绝路径。它 stub 掉s3_head/s3_get_range/s3_put/s3_delete使加载同步完成,并彻底排除s3_client_aws.o的链接,但仍链接 bdev/thread 库——因为s3_chunk_map.o的 insert/remove 引用s3_journal_append_*,会拖入s3_journal.os3_local_dev.o。对象被手工构造且两个 CRC 都正确,然后逐字段破坏:坏 magic / 坏版本 / 头 CRC / 几何不匹配 / 尺寸不匹配 / 条目 CRC,各配精确 errno,而合法对象则以正确的 LSN 与 generation 加载。

AWS_INSTALL_DIR 的探测

AWS_INSTALL_DIR通常无需显式给出:mk/s3lvol.common.mk 会探测/usr/local/aws/opt/aws/usr/local(判据是include/aws/s3/s3_client.h是否存在)。显式传空值仍表示"使用系统库"。

两个需要真实 S3 的套件

export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... ./test/integration/s3_client_test --endpoint <host> --bucket <name> ./test/integration/s3_bs_dev_test --endpoint <host> --bucket <name>

s3_client_test有 1 个xfail:COS 忽略If-None-Match: *(HANDOFF 5.5)。它仍会运行并打印结果,只是不计为失败——否则该套件将永远红,而一个永远红的测试就不是测试了。若某天真通过,会以XPASS单独列出:意味着后端长出了新能力(create-once 可以转而依赖服务端而非 uuid 唯一性),这是值得跟进的事件,而不是需要刷新的数字。run_all.sh中对该 xfail 的注释(run_all.sh)补充说明:MinIO 后端会遵守该头并产生 XPASS,被套件的结果统计接受。

dataplane/:端到端数据面回归

export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... ./test/dataplane/run_dataplane_test.sh -e cos.ap-nanjing.myqcloud.com -b <bucket> -r ap-nanjing ./test/dataplane/run_recovery_test.sh -e cos.ap-nanjing.myqcloud.com -b <bucket> -r ap-nanjing ./test/dataplane/run_snapshot_test.sh -e cos.ap-nanjing.myqcloud.com -b <bucket> -r ap-nanjing ./test/dataplane/run_export_test.sh -e cos.ap-nanjing.myqcloud.com -b <bucket> -r ap-nanjing ./test/dataplane/run_selfimport_test.sh # 读取 /data/cubelet/s3.cfg,无参数 ./test/dataplane/run_snapdelete_test.sh # 同上 ./test/dataplane/run_cubecow_client_test.sh # 同上;cubecow/Cubelet RPC 顺序 ./test/dataplane/run_activation_test.sh # 同上 ./test/dataplane/run_fs_test.sh # 同上;真的执行 mkfs.xfs + mount ./test/dataplane/run_guards_test.sh # 同上;两道误删防线 ./test/dataplane/run_control_test.sh # 同上;驱动 scripts/ 下的脚本

run_all.sh的 dataplane 段(run_all.sh)按依赖关系精心排序:export 系(export → srcdel → selfimport → derived → decouple_queue → snapshot_cancel → snapshot_converge → agent_template)共享 manifest 故紧挨在一起;snapdelete 与 pending_delete(其拒绝正是延迟删除队列的输入)相邻;fs 排在块级套件之后(两者同时失败时,说 dd 语言的那个更容易读);guards 因其"不扰动主机状态"的本性可置于任意位置;activation 与 control 殿后。

run_dataplane_test.sh:单进程打通读写路径

单进程链路s3lvol_tgt -> lvstore on S3 -> lvol -> bdev -> nvmf tcp -> kernel nvme -> /dev/nvmeXnY -> dd/fio(run_dataplane_test.sh),流程:create → write → flush → disconnect/reconnect → 从 S3 读回 → fio verify → resize → delete lvol。它特意针对五类缺陷:

  1. 写入数据逐字节读回(dd + sha256,及 fio 自带 verify);
  2. max_num_segments = 1是否真正生效——bs_dev 对iovcnt > 1拒绝-ENOTSUP,若 bdev 层不拆分,iodepth > 1+ 大块 fio 必然暴露,且事后 grep 日志确认是哪一层的问题;
  3. 从未写入区域的读返回零(blobstore 对未分配 chunk 的假设);
  4. 同一 chunk 内的并发写存活——这就是 WAL 与 flusher 存在的理由:步骤 8 用 1 MiB 块、iodepth 8,nvmf 拆成 8 个并发命令落进同一 chunk;
  5. 关停顺序正确(步骤 10):unload 必须排干 flusher、让 blobstore 写最终元数据、再 flush、关日志,最后才释放 journal 与本地设备。

还有一个重要的方法细节:关键读取全部使用iflag=direct(页缓存会掩盖 S3 里的垃圾);而 WAL 路径下进程内 overlay 会先应答读,因此步骤 6b 先 flush lvstore 排空 overlay,步骤 7 才断开、重连并重读——此时数据只能来自 S3。

run_recovery_test.sh:三进程崩溃恢复

attach 于干净卸载之后、attach 于 SIGKILL 之后、校验两个数据段。覆盖 owner-marker 契约与 checkpoint(含定时器触发)。最后一步专门追猎 unload 与 checkpoint 轮询器之间的竞争:卸载前写入数十 MiB 数据把 flusher 排空拉伸到秒级,使 destroy 后的窗口必然跨越 1 秒轮询周期——断言日志在最终 "Destroying s3_bs_dev" 之后没有 checkpoint 开始。这个套件曾经复现过一次 use-after-free(HANDOFF 7.16)。

run_snapshot_test.sh:快照与克隆的隔离性

原卷、快照、克隆各自拥有独立的 NVMe namespace,三者互不影响。核心断言即隔离:写 A → 快照 → 用 B 覆盖原卷,快照必须仍读到 A;克隆快照后,克隆初始读 A(直通父层),向克隆写 C 后三者读 C/A/B。另验证:对快照的写被拒绝、删除有克隆的快照被拒绝(-EBUSY)。

run_export_test.sh:跨 lvstore 导出/导入(沙箱 pause/resume)

两个 lvstore 共享同一进程:同一 bucket 下的两个前缀、各自独立本地设备。断言按重要性排序:导入卷读源数据 → 写它不影响源 →unload + attach 目标 lvstore 后克隆仍可打开且数据不变(测试 imports 注册表与 esnap 回调:blobstore 在加载期间同步询问父层)→ decouple,然后 release 导出,release 后再 unload + attach 仍能打开卷(专门守卫"decouple 必须改元数据"——只换内存中的 back_bs_dev 能通过此前所有断言,恰恰在这里死掉)。还验证零拷贝导出除 manifest 外不产生任何东西(稀疏性成为 manifest 的属性:64 MiB 卷中的 8 MiB 数据必须命名 8 个 chunk 而非 64 个),以及导入进行中拒绝 release。

两条容易被忽略的路径也在其中:快照继承 esnap 依赖——给导入的克隆拍快照后删掉克隆,release 仍必须被拒绝(否则对象会被从活快照脚下删除);以及import --decouple的后台形式——验证 import 在拷贝完成前就返回、完成后卷无需父层即可读数据。

步骤 7 重启前会显式打一次 checkpoint。此前每次运行日志都是No checkpointckpt lsn 0:checkpoint 轮询器间隔 60 秒而一次运行约 40 秒,所以"带 checkpoint 重放"从未被 export/import 场景测过。它测的东西很具体:克隆必须在加载期间打开,checkpoint 后其映射来自 checkpointjournal 尾部,而非一整本 journal——断言要求skippedckpt lsn同时非零(只看其一也会被空 journal 路径满足)。

步骤 11 专门测克隆链lvol -> snap1 -> snap2是真实用法,也是零拷贝最安静地崩掉的地方——后续快照只持增量,父层仍持有大部分数据。三条断言:(a) 仍为零拷贝;(b) manifest 命名的 chunk 数多于 snap2 自己拥有的;(c) B 侧两个区域都能读回。(b) 是真正的哨兵:链断开时 (c) 也会失败,但表现为"导入成功、读着正常、继承的那半是零"——而 (b) 直指父层未被解析。

步骤 13 打印时延。零拷贝的意义是这些数字不随卷大小增长,所以全绿时也打印——一个"其实还在拷数据"的交接不会失败任何断言,只会在这里露馅(毫秒变秒)。RPC 客户端的 python 启动开销(本机约 37 ms)先作为基线测出并扣除,否则会淹没被测量对象。它还打印本次导出是否触发了 drain——唯一随脏数据变化的时延分量。

rcow_export_snapshot现在立即返回导出 uuid,不代表导出完成(manifest 异步发布),时延只有在rcow_get_snapshot_status轮询到DONE后才算落定——仍然测量"发起导出到真正完成"的全过程。rcow_get_snapshot_status接受export_uuidsnapshot_name(二选一,都传会被拒绝),成功时返回export_status(INPROGRESS / DONE / NONE)与deletable(YES / NO,当场计算:导出进行中、活动/未知租约或本地 esnap 读者仍引用零拷贝导出、或克隆数多于一时为 NO)。空闲的 REF 导出不钉住快照:删除快照即释放该导出。两种查询形式仅区别"不存在"的含义:按 uuid 查不到走失败路径;按 snapshot_name 查,只有快照本身缺失才失败,而存在却从未导出的快照返回export_status: NONE——这正是快照名查询形式存在的原因。

run_selfimport_test.sh:导出-导入退化为本地克隆

export_snapshot+import_lvol在同一 lvstore 内现在退化为本地克隆(RPC 回复的mode字段区分local_cloneesnap)。export_snapshot本身完全没改:manifest 写时不可能知道会被本地消费还是跨机消费,决策只能放在导入侧。退化条件要求 endpoint、bucket、prefix、快照名与 lvol uuid 全部匹配。套件还验证生命周期不变量:删除源快照释放其导出,复用快照名于另一个 blob 或可写 lvol 不会复活该导出。为什么值得退化:本地克隆的父层被 blobstore 钉住(有克隆的快照不可删),而 esnap 克隆的父层靠导出租约保护直到克隆 decouple 或删除——所以它更安全,不只是更快。

run_cubecow_client_test.sh:Cubelet 实际发出的 RPC 组合

Cubelet 从不直接调 JSON-RPC;cubecow 始终按固定顺序使用同样的 11 个rcow_*方法,本套件驱动的正是这个顺序。既有套件覆盖机制(导出、克隆隔离、双进程导入),但不覆盖 Cubelet 真正发出的组合:seal(ext4、umount、inactive 快照、删除工作卷)、从同一模板克隆 N 个并 resize 其一、同时导出三个快照并按snapshot_name轮询、被拒的模板删除其错误不得含not found、失败的导入不得留下命名 lvol、以及import(decouple=true)后不等 decouple 直接rcow_active_bdev。它读s3.cfg,使用自己的 lvstore / WAL / 注册表。

run_snapdelete_test.sh:23 个断言的删除语义

源卷仍存活时删除其快照,及该操作的边界。关键事实:快照化把原 blob 变成新快照的克隆,再次快照则插入链中,所以对 lvol L 有 s0/s1/s2 时形状是s0 <- s1 <- s2 <- L每个快照的 clone_count 恰为 1bs_is_blob_deletable()blobstore.c:8704)只在>1时无条件拒绝;恰好为 1 时它把快照合并进那个克隆spdk_lvol_destroy()配合先查克隆的 lvol 以便修复它。本套件存在的原因:pre-check 曾把阈值写成>0,使最普通的"删旧快照"报 EBUSY,且该错误被误认为 blobstore 限制(HANDOFF 7.22)。

它断言数据而非返回码——删除是合并,只看状态码对"返回 0 但丢掉了被合并的 cluster"视而不见,而该故障要数周后才浮出。布局刻意安排得让错误合并无法显得正确:卷分四个 4 MiB 区域,区域 N 只在快照 N 之前写入,故每区域只存在于链的一环;区域 0 要遍历整条链才能读到,丢 cluster 的合并会先坏区域 0/1 而非卷自己写过的区域 3。每次删除后四个区域全部重读。

还覆盖:存活快照仍读到其捕获的历史(区域 0-2 存在、区域 3 为零);2 个克隆的快照必须被拒,且拒绝发生在任何回退之前(数据不变 + 卷仍可写);lvstore 仍可 unload——这是 pre-check 存在的全部理由,来得太晚的拒绝会留下无法关闭的 lvol。

run_fs_test.sh:把卷当磁盘用的唯一套件

rcow_create_lvol->rcow_active_bdev->mkfs.xfs-> mount,写 47 个文件 → freeze → 快照 → 挂载快照并按文件逐个 md5 比对。其他套件用 dd/fio/blkdiscard(对齐、direct、已知偏移)说话;文件系统则完全不同:小型未对齐元数据写、每请求数百段、每次 journal 提交一次 NVMe FLUSH。最后一条绝非假设——SPDK_BDEV_IO_TYPE_FLUSH最初实现为spdk_blob_sync_md(),宿主的mkfs.xfs曾使 target 中止(HANDOFF 7.17),而彼时 15 个套件 / 643 个断言全绿,只因没有套件像块磁盘那样用卷。第 [12] 节现在按名 grepblob_verify_md_op

核心断言在文件粒度而非块粒度:从冻结快照挂载别处读到的文件集与内容必须与 freeze 瞬间的原卷完全一致,后续变更一个都不可见。"完全一致"是两个 manifest(各文件 md5,排序后)的diff,而非抽查;且先断言 manifest 非空——比较两个空目录同样会成功,却什么也没证明。三类变更刻意设计,因为它们的传播路径不同:创建文件占用快照从未有过的 cluster;删除释放快照仍引用的 cluster;就地覆盖必须走 CoW 且不得修改共享 cluster。

另一半价值在于怎么挂载快照——曾两次量错猜错:快照在 bdev 层拒绝写,但 nvmf 无处上报(blockdev --getro恒为 0;run_snapshot_test.sh第 [8] 节从另一侧钉住该值)。XFS 在挂载时看块设备的只读标志决定能否写——xlog_find_tail()里的xlog_clear_stale_blocks()会写,只有xfs_readonly_buftarg()为真时才跳过。所以默认标志下那次写总发生且总被拒,mount(8) 报出极具误导性的can't read superblock(内核侧是log recovery write I/O error)。注意 "recovery" 与日志脏不脏无关——日志干净时也会发生,前两版正是栽在这里。三行结论被断言,且两个失败都匹配特定内核消息(只断言"它失败了"毫无价值——任何无关故障也会失败):

快照取自blockdev --setromount -o ro,nouuid
未挂载文件系统EIO,can't read superblock
未挂载文件系统可挂载,Ending clean mount
已挂载、冻结的文件系统诚实拒绝:recovery required on read-only device,需-o norecovery

操作规则因此是:挂载快照前先blockdev --setro;快照来自活文件系统时再加-o norecovery——xfs_freeze不覆盖日志(xfs_quiesce_attr()只强制日志并回收 inode,不写卸载记录),只读设备无法重放。两种情况都需要nouuid:快照与原卷同 UUID,XFS 拒绝在原卷挂载时重复挂载。xfs_repair -n只对干净日志的快照运行——文件都能正确读取时元数据仍可能不一致,这是数周后才会浮出的故障;脏日志下-n拒绝重放并报出无法对平的可用块数,那是在抱怨日志,而非快照。

run_guards_test.sh:24 个断言的两道误删防线

防线 1:状态文件路径可配置。/var/tmp/bstore.json/var/tmp/rcow_active_lvols曾是整台机器共享的编译期常量——包括测试套件在内,其中两个在清理时会rm -f活动注册表。这并非假设:它真的一次删掉了运行中实例的注册表。现在模块通过S3LVOL_ACTIVE_FILE/S3LVOL_BSTORE_FILEvbdev_s3lvol_statefile.c)解析它们,rcow_common.shRCOW_*变量映射导出。套件断言:覆盖生效(条目写入私有路径,/var/tmp什么都没有)、变量未设时历史默认仍成立(生产依赖此点)、运行后宿主的两个文件逐字节不变

外加一条:每节点单 blobstore 策略不得失败开放。它曾是pick_one() != NULL,而pick_one()对 0 和 >1 匹配都返回 NULL,所以只在恰好一个时拦截,两个就放行——而两个可达(attach刻意不受限;跨 lvstore 搬卷需要两个同时加载)。现在改为计数。注意:限制在 create,不在同时加载几个上——不要给attach加同样的检查run_export_test.sh需要两个同时加载来构建克隆链。

防线 2:create 拒绝已有 blobstore 的前缀。lvstore 名即 bucket 中的键前缀(s3_bs_dev_createprefix = lvs_name),前缀下meta/checkpoint名字固定,在其上再建 blobstore 会覆盖前者的 chunk map——静默地,因为数据对象以 uuid 命名、永不相撞,只是不再可达。owner marker 挡不住这个:它回答"此刻是否有人在写",干净卸载会释放它,正常关停的 lvstore 在那里不留痕迹。两条普通入口:本地bstore.json丢失、rcow_start.sh看不到前缀被占而回退到 create;或两节点派生同名而第一个已干净关停。create 现在先 HEAD<prefix>/meta/checkpoint

套件钉住的是该防线的边界:有 checkpoint 时拒绝;干净卸载后仍拒绝(恰是 owner marker 看不到、也是防线存在的全部理由);force=true放行(接管前缀必须仍可能,日志会说明检查被跳过);attach 不受影响——否则"create 被拒"将与"此 lvstore 已死"无法区分,这是此类防线最常见的次级故障。防线看不到的窗口写在代码注释里:create 后、任何 checkpoint 前的干净关停,会留下一个只含 uuid 命名对象的前缀,而s3_list_objects()仍无法列出它(-ENOTSUP)。

run_control_test.sh:驱动控制脚本、测顺序而非单个 RPC

驱动scripts/rcow_{start,stop,recovery}.sh,九个节 48 个断言:一次 start/stop 往返;连接后热插拔 namespace(AEN;宿主须自行发现);干净重启后卷回到同一 subsystem/nsid 且数据不变;冷读进行中移除 namespace;SIGKILL 后 recovery 先确认 owner marker 已死再强制 attach;指向已不存在卷的注册表条目被拒并从注册表移除;"进程已加载注册表但未挂载任何 namespace"被报为不一致而非健康。

第 [5b] 节(带进行中读的去激活)对应一次真实崩溃spdk_nvmf_subsystem_pause()中 nsid0 意为"一个 namespace 都不暂停"nvmf.h的措辞),模块曾传 0——于是 remove_ns 前什么都没静默,resume 释放 bdev channel 时读仍在飞行,bdev 层的assert(TAILQ_EMPTY(&ch->io_submitted))中止了进程。去激活空闲卷永远不会命中它——第一次命中它的是 udev 探测新设备。因此本节故意跑一个 32 MiB 冷读(数据在 S3、overlay 答不了、I/O 保证在飞行)。

最后一节同时是另一次崩溃的回归哨兵:spdk_json_next()在对象末尾返回NULL,注册表解析循环在解引用前测试it < end(NULL 满足它),所以重启后第一次rcow_get_bdev每次都段错误。没有其他路径到那行代码——注册表惰性加载(首次激活式 RPC 才读它),其他测试要么从空文件开始、要么(如 replay)在首次调用前删除它。

它的隔离与其他脚本不同:lvstore 名、WAL 镜像、运行目录都是测试专属,但bstore.jsonrcow_active_lvols路径编译进模块、整机共享。因此脚本在开始时若rcow_active_lvols非空(本机正有东西在提供卷)就拒绝运行,结束时只删自己的条目。运行目录每轮开始整体删除——target 日志是 append-only 且最终检查要 grep 它:上一轮留下的 assert 会让下一轮变红,而真正产生它的那一轮全绿,两端都错。

dataplane 公共约定

环境变量

环境变量作用
S3LVOL_KEEP_S3成功时也保留 S3 对象
S3LVOL_KEEP_LOGS成功时也保留 target 日志
S3LVOL_TGT_CPUMASKtarget 的-m,默认0x3
S3LVOL_WAL_FILE/S3LVOL_WAL_MB/S3LVOL_JOURNAL_MB本地设备镜像的位置与布局
S3LVOL_SRC_WAL_FILE/S3LVOL_DST_WAL_FILErun_export_test.sh:两个 lvstore 各自的设备镜像
S3LVOL_CKPT_INTERVAL_SECrecovery 测试第三次 attach 的 checkpoint 间隔,默认 5 秒

成功与失败的清理语义

成功时脚本自己清理 S3 对象与本地镜像;失败时一切都保留,并打印删除命令——失败时这些对象与 WAL 镜像是唯一证据,不要急着删。

bucket 必须是专用的。清理按前缀(<lvs_name>/)删除,而-p ''可以删掉整个 bucket;不要指向存有其他数据的 bucket。

tools/:三层共用的调试工具

s3lvol_rpc.py:发送一次 JSON-RPC

s3lvol 的 RPC 方法定义在本仓库,SPDK 的scripts/rpc.py不认识它们,因此自定义方法一律走这个工具。成功时result打到 stdout;失败时error打到 stderr 且退出码为 1。

test/tools/s3lvol_rpc.py rcow_get_lvstores test/tools/s3lvol_rpc.py rcow_checkpoint_lvstore '{"lvs_name":"dpvs"}' test/tools/s3lvol_rpc.py --sock /var/run/s3lvol.sock --timeout 60 bdev_get_bdevs

两种无需自带方法的模式:

test/tools/s3lvol_rpc.py --ls # 以表格列出 rcow_get_lvstores, # 含 DEL / PEND 列 test/tools/s3lvol_rpc.py --retry-pending # 重新发起被拒、现已可删的快照删除

s3lvol_rpc.py有一个值得注意的契约细节(见 s3lvol_rpc.py):每个外部 RPC 无论成败都以{bool_value, string_value}信封应答,且两者都是 JSON-RPC成功——若原样透传,所有失败都会被报告为退出码 0。该工具负责解包:bool_value为真则string_value上 stdout、退出 0;为假则上 stderr、退出 1,从而在一个地方恢复契约,而非在测试套件与脚本中约 200 个调用点各做一遍。--raw关闭解包,用于查看 target 实际发出的线上内容。

--retry-pending作用于被拒快照删除留下的 pending-delete 标记(--ls中的PEND)。约 60 秒的轮询器已经能完成租约导出、多余克隆与已结束的 decouple;--retry-pending针对的是无租约导出、失败销毁、以及不想等轮询器的情形。标记持久化到<prefix>/meta/pending-deletes.json(那次 PUT 前的崩溃会遗忘意图)。用rcow_cancel_pending_delete撤销标记;完整契约见主 README 的 "Retrying a refused snapshot delete" 一节,包括轮询器已提交 destroy 时的竞争。run_pending_delete_test.sh第 [11] 步用rcow_pending_load_hold延迟该注册表的 HEAD 与 GET 跨越 unload(仅测试用;生产从不停放 attach)。

调试挂死 target 时它尤其有用:被测试脚本遗留的进程可以直接询问其状态。

s3_prefix_rm.py:按前缀删除 S3 对象

rcow_unload_lvstore刻意不删对象(下次 attach 需要它们),所以每轮测试都留些残留;崩溃的一轮还会留下 owner marker,使下一次 create 直接报 -EBUSY。这个脚本就是清理工具。

纯标准库手写 SigV4——测试机既没有 aws-cli 也没有 boto3,而需要清理的场合往往正是没有 target 在跑的时候(进程已崩溃)。凭据只从环境读取。

test/tools/s3_prefix_rm.py -e cos.ap-nanjing.myqcloud.com -b <bucket> -r ap-nanjing -p rcvs/ --list test/tools/s3_prefix_rm.py -e cos.ap-nanjing.myqcloud.com -b <bucket> -r ap-nanjing -p rcvs/ test/tools/s3_prefix_rm.py -e <minio-host> -b <bucket> --path-style -p ''

check_binary_fresh.sh:拒绝针对陈旧二进制跑测试

启动 target 的五个 dataplane 脚本(dataplane/recovery/snapshot/export/activation)都会先调用它。若源码比app/s3lvol_tgt/s3lvol_tgt新,立即失败。

检查存在的原因是一次真实事故(check_binary_fresh.sh):顶层 Makefile 默认目标当时不含app/make只重建库、二进制原地不动,于是一整轮 39/39 + 32/32 + 32/32 + 55/55 全绿——用的却是三小时前、甚至不含待验证修复的二进制。绿跑中没有任何线索;唯一痕迹是日志里一个早已改名的函数名。陈旧二进制的绿比红更糟:它是指向错误方向的证据,而人们会把它当作结论。Makefile 已修复,但通往该状态的路不止一条(make shared、中断的构建、重建了另一棵树),所以检查放在测试侧——放错结论会被得出的那一侧。

它只比较 mtime。若确实需要旧二进制(比如二分定位),设S3LVOL_SKIP_FRESH_CHECK=1

check_layering.sh:三条分层硬规则

integration 测试假设lib/可独立链接;本检查强制维持该假设的三条分层规则(HANDOFF 第 8 节),作为make check/make check-rules的一部分运行。

调试失败时的已知陷阱

test/README.md收尾部分浓缩了实测踩过的四个坑:

  • target 无法启动、报Cannot create lock on core 0:上一轮的 target 还活着。pkill -f s3lvol_tgt
  • pgrep -x s3lvol_tgt找不到明显在跑的目标:SPDK 启动 reactor 后会把主线程改名为reactor_0/proc/<pid>/comm里不再有s3lvol_tgt。应匹配/proc/<pid>/exercow_target_instances()就是这么做的)。这不是琐事:rcow_start.sh曾用pgrep -x判断"target 是否已在运行",检查静默失效后放进了第二个 target,新进程接管了/var/run/s3lvol.sock(RPC server 绑定前会 unlink 它)——第一个进程仍活着,握着 WAL 镜像,却再也够不着。
  • create 报-EBUSY:S3 里还留着别人的 owner marker(通常是上一轮被 kill 的)。确认无进程运行后,用s3_prefix_rm.py清掉,或带force: trueattach。scripts/rcow_start.sh在 marker 指向本节点且该 pid 未运行时自动确认并重试。
  • 改了头文件但行为看起来没变:曾经可能(Makefile 不追踪头依赖,同一 struct 在不同.o里有不同布局)。-MMD -MP已修复;仍存疑就make clean一次排除。
  • 改了代码但行为没变,且脚本报陈旧二进制:执行它让你做的make。注意顶层默认目标现在也会构建二进制;make shared只构建库、不更新s3lvol_tgt

总结:这套测试体系可以带走什么

CubeS3lvol 的test/目录是一份可复用的"存储组件回归测试设计"样本。值得带走的实践包括:用退出码 2 隔离"环境不允许"与"真实失败";把套件前置条件集中在单一入口而非散布各处;对合并类操作(快照删除、导出导入)断言数据而非返回码;用墙钟时间而非轮询计数等待 I/O;把"陈旧二进制"当作一等公民拒绝而非提醒;以及让最敏感于全局状态的套件殿后,作为整轮清理是否彻底的最终裁决。配合 CubeS3lvol/README.md 与 run_all.sh,任何开发者都可以在三分钟内在自己的机器上复现这套从离线单测到 S3 数据面端到端的完整回归。

【免费下载链接】CubeSandboxInstant, Concurrent, Secure & Lightweight Sandbox for AI Agents.项目地址: https://gitcode.com/GitHub_Trending/cu/CubeSandbox

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询