rr 记录与确定性回放:从偶发崩溃反向定位到 Trace 治理
一个本地服务每运行几百次才会在校验分支崩溃。Core 能告诉你最后停在 abort(),却不能回答几万条指令之前是谁第一次把余额改成负数;普通 GDB 重新运行又迟迟碰不到相同调度。此时真正缺少的不是更多断点,而是一段可以向前、向后反复检查的执行历史。
rr 在受支持的 Linux 主机上记录一组用户态进程与线程,回放时把 GDB 接到保存的执行上。它能复现记录边界内的寄存器、内存与系统调用输入,但不是任意操作系统、CPU、虚拟机、设备和外部服务的通用时间机器。先证明主机可记录,再讨论反向执行;不支持的环境应明确拒绝,而不是靠放宽安全开关伪造成功。记录条件本身也是故障证据的一部分,它决定结论能否复核。
安装后用最小录制判断平台
rr 的日常入口只有一个可执行文件,但其可用性依赖 Linux 内核、CPU 微架构和硬件性能计数器。当前上游基线是 Linux 4.7 及以上,并只支持列出的 Intel、部分 AMD Zen 与部分 AArch64 微架构;准确矩阵集中在 rr 仓库的系统要求。发行版包、源码构建参数和支持范围会演进,不能只凭“Linux + PMU”推断可用。Windows 与 macOS 不能直接运行 rr;WSL、容器或虚拟机也不会因为宿主 CPU 看起来合适就自动满足要求。
优先从 Linux 发行版的签名仓库安装 rr、GDB、C 编译器和 binutils。包名或版本不足时,再按 rr 官方构建说明 从经过校验的源码构建,并保留源码提交、编译器、CMake 选项和安装前缀。企业内网应把包及其依赖同步进批准的软件仓库;不要让故障机临时访问公共下载站,也不要把来源不明的静态二进制放进证据环境。
# Debian/Ubuntu 系常见入口,实际包版本以组织镜像为准
sudo apt-get update
sudo apt-get install -y rr gdb gcc binutils
# Fedora 系常见入口
sudo dnf install -y rr gdb gcc binutils
rr --version
gdb --version
uname -srmo
lscpu | sed -n '1,25p'
mkdir -p evidence/rr-traces
preflight_trace="$(pwd)/evidence/rr-traces/preflight"
test ! -e "$preflight_trace" || {
printf 'refuse to overwrite existing trace: %s\n' "$preflight_trace" >&2
exit 1
}
rr record -o "$preflight_trace" /bin/true
record_status=$?
test "$record_status" -eq 0 || exit "$record_status"
rr replay -a "$preflight_trace"
replay_status=$?
printf 'preflight-record=%s preflight-replay=%s\n' \
"$record_status" "$replay_status"
test "$replay_status" -eq 0
rr traceinfo "$preflight_trace" | sed -n '1,40p'
rr rm "$preflight_trace"上游 rr 没有通用的 rr check 子命令。这里用 /bin/true 建立一次最小 Trace,再用 replay -a 无调试器回放;只有 record、replay 都返回 0,traceinfo 能读取头部,且 rr rm 只删除该冒烟 Trace,才算最小平台链路通过。record 会实际触发 rr 对微架构、特定硬件性能事件、perf_event_open 权限和基础内核能力的检查;它比版本输出更接近真实门禁,但仍不能覆盖业务程序将要使用的全部系统调用和设备路径。把 rr 版本、内核、CPU 型号、两个退出码和 traceinfo 摘要写入实验清单,后续 Trace 才能解释自己来自什么平台。
rr 依赖的不是任意 PMU 事件,而是能稳定计数的特定硬件事件,例如 x86 上用于确定调度点的 retired conditional branches。虚拟机要单独检查虚拟 CPU 与 PMU;KVM/QEMU、云主机、桌面虚拟机、热迁移和嵌套虚拟化可能隐藏、仿真或改变计数器。容器共享宿主内核和 CPU 能力,还可能被 seccomp、capability、perf_event_paranoid 或 cgroup 策略限制。容器内最小录制失败时,先确认宿主能否完成同一冒烟实验,再定位运行时缺少的精确调用或权限。--privileged、关闭整套 seccomp 或全局放宽 perf/ptrace 不是安装步骤;无法给出最小、短时、可审计授权时,应把实验移到隔离的受支持主机。perf_event_paranoid 的可接受值随 rr 与内核实现变化,保留当前值和原始错误,不照抄旧手册全局改 sysctl。
用一个确定崩溃证明记录与反向搜索
下面的程序故意把全局余额从 100 改成 -900,随后调用 abort()。全局变量便于设置 watchpoint,-O0 与 -fno-omit-frame-pointer 则让第一轮教学实验保留清楚的源码位置。它不含业务数据,也不访问外部服务。
#include <stdio.h>
#include <stdlib.h>
volatile int balance = 100;
static void apply_fee(int fee) {
balance -= fee;
}
static void validate_balance(void) {
if (balance < 0) {
fprintf(stderr, "invalid balance=%d\n", balance);
abort();
}
}
int main(void) {
apply_fee(1000);
validate_balance();
return 0;
}在专用目录编译,并把 trace 输出位置固定到当前实验目录。显式 -o 可以避免事后从用户级默认 trace 根目录猜哪一份属于本次故障。
mkdir -p build evidence/rr-traces
cc -g3 -O0 -fno-omit-frame-pointer rr-balance-demo.c \
-o build/rr-balance-demo
sha256sum build/rr-balance-demo | tee evidence/binary.sha256
readelf -n build/rr-balance-demo | sed -n '/Build ID/p' \
| tee evidence/binary.build-id
trace_dir="$(pwd)/evidence/rr-traces/balance-failure"
test ! -e "$trace_dir" || {
printf 'refuse to overwrite existing trace: %s\n' "$trace_dir" >&2
exit 1
}
rr record -o "$trace_dir" \
./build/rr-balance-demo
record_status=$?
printf 'recorded-program-exit=%s\n' "$record_status"
find "$trace_dir" -maxdepth 1 -type f -printf '%f %s bytes\n'预期标准错误包含 invalid balance=-900,被记录程序以异常状态结束,指定 trace 目录中出现 rr 的元数据和数据文件。rr record 显示程序失败并不等于记录失败;判断要同时看 trace 目录是否完整、rr 是否报告保存位置,以及随后能否回放。反过来,只有目录但 replay 无法打开,也不能算成功证据。
进入回放后先向前运行到 SIGABRT,确认当前值与调用栈,再设置 watchpoint 并反向寻找最后一次写入:
rr replay evidence/rr-traces/balance-failure(rr) continue
(rr) bt
(rr) print balance
(rr) watch -l balance
(rr) reverse-continue
(rr) bt
(rr) list
(rr) print balance第一次 continue 的预期证据是程序再次收到 SIGABRT,调用栈经过 validate_balance 与 main,balance 为 -900。reverse-continue 应在 apply_fee 对 balance 的写入附近停下,watchpoint 的旧值与新值包含 100 和 -900;不同 GDB 版本在反向方向显示 old/new 的顺序可能不同,因此以源码位置、地址与两端值共同判断。此时再 continue,应回到同一失败,而不是重新启动一个新进程碰运气。
还可以按事件位置分段缩短搜索:先在失败点记录调用栈和 when 输出,再用 reverse-next、reverse-step 或 reverse-finish 逐层退回。rr 的反向执行会在内部恢复检查点后向前重放到目标位置,不是把 CPU 指令原地倒放;因此同一反向命令可以重复到达同一历史状态。反向单步仍受优化、内联和调试信息质量影响;正式构建中变量显示为 optimized out,并不表示 rr 丢了执行,只表示当前符号不足以把机器状态还原成该源码变量。
反向实验必须接受“不支持”
最重要的反例不是让 rr 勉强生成一个目录,而是在候选 VM、容器或新 CPU 上先做最小录制,并接受明确失败:
set +e
probe_dir="$(pwd)/evidence/rr-traces/platform-probe"
test ! -e "$probe_dir" || {
printf 'refuse to overwrite existing trace: %s\n' "$probe_dir" >&2
exit 1
}
rr record -o "$probe_dir" /bin/true \
>evidence/rr-probe.stdout 2>evidence/rr-probe.stderr
probe_status=$?
set -e
printf 'rr-probe-exit=%s\n' "$probe_status"
sed -n '1,120p' evidence/rr-probe.stderr
if [ "$probe_status" -eq 0 ]; then
rr replay -a "$probe_dir"
rr rm "$probe_dir"
fi预期负向证据是非零退出码和可归类的原因,例如 CPU/微架构不受支持、硬件计数器不可用、perf_event_open 被拒绝或虚拟化没有正确暴露 PMU。失败后保留输出,若 probe_dir 只是 rr 启动失败留下的非 Trace 残片,先用 rr traceinfo "$probe_dir" 确认它不可回放,再按后文的根目录边界清理,不能使用 rr rm -f 跳过身份判断。某些全局选项可以强制微架构假设或放宽内部保护,但它们不是“让所有机器变得受支持”的开关;没有上游明确边界、独立验证和故障复现对照时,不应进入团队模板。
第二类反例是 Trace 与当前 rr/平台不相容。复制或归档前先记录版本和摘要,换机后先在隔离目录执行 traceinfo、ps 与 replay;若 rr 拒绝 Trace 格式、指令架构或记录程序实际使用的 CPU 特性,就保留原始 Trace、原 rr 包和原分析环境,不要原地升级后覆盖唯一可用工具链。内核版本不是简单的“必须相同”字段,但仍应记录,便于解释 rr 与宿主能力差异。把“能复制目录”与“能确定性回放”分成两项验收。
第三类反例来自记录边界外的共享内存。若被记录进程通过共享映射与记录树外的进程、设备或特殊内核接口交换状态,外部写入没有进入同一记录控制域,回放就不能承诺重演那段交互。可行的改造是让协作进程由被记录程序启动并一起进入记录树,或把外部输入替换成可控文件/套接字夹具;无法收拢的数据库、GPU、RDMA、浏览器共享段或设备交互,应改用日志、协议捕获、应用级事件记录或专用模拟器。
rr 为什么能向后走
普通 debugger 连接的是正在执行的进程,状态一旦继续就会被覆盖;Core 保存的是一个时刻的寄存器与内存,能切线程和栈帧,却没有此前的执行顺序。rr 在 record 阶段监督一组进程,控制线程调度,记录影响用户态执行的非确定输入,并把内存映射与必要文件内容保存到 Trace。replay 阶段按记录事件重演用户态状态,并向 GDB 暴露调试接口。被记录程序的网络发送、文件写入等外部系统调用不会再次原样作用于真实世界;终端中看到的标准输出通常是 rr 重放的已记录结果,不代表服务又执行了一次真实请求。
单核执行是机制的一部分,不是可忽略的性能参数。把记录对象约束到一个 CPU,有助于控制线程通过共享内存发生的竞争顺序;代价是原本能在多核并行的程序会明显变慢。对 CPU 密集型线程池,record 时间可能远大于正常运行;对依赖真实并行窗口才触发的竞态,串行化甚至会让故障消失。rr 很适合“已经能在记录条件下触发、但向前很难定位”的错误,不保证把所有多核竞态都变得更容易出现。
系统调用、信号、进程创建和用户态共享内存只有在 rr 支持并实际控制的边界内才可回放。内核自身的 bug、记录树外共享内存、外部设备状态、实时约束和不受支持的新系统调用都可能越界。这里的“确定性”应读作:对成功记录且被支持的 Trace,回放保持指令级控制流以及受控地址空间、寄存器和系统调用结果;它不是对整个分布式系统重新执行一次,也不是用回放验证远端服务仍处于相同状态。
Trace 身份还要绑定 ELF、DWARF 与源码
rr 能重放机器执行,不会自动证明当前源码就是录制时源码。ELF Build ID 是连接可执行文件、分离调试信息和符号服务的稳定索引,但 Build ID 相同只说明这些对象声明同一构建身份,不能替代制品摘要、源码提交和构建清单。正式构建还可能把 DWARF 拆到 .debug、.dwo 或 .dwp 文件;只归档主 ELF,回放时仍会看到函数地址,却可能失去局部变量、类型和源码行。
录制前保存以下证据,回放机再逐项核对:
sha256sum build/rr-balance-demo
readelf -n build/rr-balance-demo | sed -n '/Build ID/p'
readelf --string-dump=.gnu_debuglink build/rr-balance-demo 2>/dev/null || true
file build/rr-balance-demo
git rev-parse HEAD
git status --shortgit status --short 不是为了把整个工作树塞进 Trace,而是暴露“相同提交上还有未提交源码”的身份缺口。使用 split DWARF、debuginfod 或内部符号服务时,还要保存调试文件摘要、服务命中日志和离线回放所需的最小副本。分析不可信 Trace 前,将 GDB 的自动加载限制到受控目录;来自源码树或符号包的 .gdbinit、Python pretty-printer 和 auto-load 脚本都是可执行输入,不能因为 Build ID 匹配就自动获得信任。
反向实验可以从归档副本中暂时移走分离调试文件:同一 Trace 应仍能 replay 到相同 SIGABRT,但 list、局部变量或源码行明显退化;恢复匹配的调试文件后这些信息重新出现。若换上一份同名但 Build ID 不同的 ELF 或调试文件,GDB 必须暴露 mismatch 或无法解析,而不是用相似函数名继续下结论。这样才能区分“执行历史完整”和“源码解释完整”两种成功。
把 rr 接进故障复现工作流
不要给所有测试默认套一层 rr。先用普通测试筛出偶发失败,再对候选用例进行有限次数记录,并为每次运行分配独立目录:
case_id="balance-failure-${CI_JOB_ID:-local}"
trace_dir="evidence/rr-traces/$case_id"
record_stdout="evidence/$case_id.record.stdout"
record_stderr="evidence/$case_id.record.stderr"
mkdir -p evidence/rr-traces
test ! -e "$trace_dir" || {
printf 'refuse to overwrite existing trace: %s\n' "$trace_dir" >&2
exit 1
}
timeout 10m rr record -o "$trace_dir" ./build/rr-balance-demo \
>"$record_stdout" 2>"$record_stderr"
status=$?
printf 'record-or-timeout-exit=%s\n' "$status"
test "$status" -ne 124 || {
printf 'record timed out; do not archive partial trace\n' >&2
exit 1
}
rr traceinfo "$trace_dir" >"$trace_dir/traceinfo.txt"
grep -F 'invalid balance=-900' "$record_stderr"
set +e
timeout 10m rr replay -a "$trace_dir" \
>"$trace_dir/replay.stdout" 2>"$trace_dir/replay.stderr"
replay_status=$?
set -e
if [ "$replay_status" -ne 124 ] \
&& grep -F 'invalid balance=-900' "$trace_dir/replay.stderr"; then
rr pack "$trace_dir"
du -sh "$trace_dir"
find "$trace_dir" -type f ! -name files.sha256 -exec sha256sum {} + \
>"$trace_dir/files.sha256"
else
printf 'trace is incomplete or replay failed; do not pack/archive\n' >&2
exit 1
fitimeout 返回的状态同时承载“程序退出”和“外层超时”两种可能,不能单独证明录制成功;只有 traceinfo 可读、autopilot replay 在同一预算内完成,并再次出现本用例的错误签名,才进入 rr pack。本例的 abort() 会让 record 或 autopilot replay 按信号语义返回非零,因此流水线按预期退出类型与输出签名判断,不能把所有非零都当 Trace 损坏。traceinfo 的输出是供人和工具读取的文本,不声明 JSON 契约,因此保存为 .txt 并随 rr 版本一起归档。rr pack 会把回放所需的外部映射文件复制进 Trace、消除外部链接依赖,使其适合传输,但不会消除 CPU 特性要求,也不能替代摘要、加密和兼容性检查。CI runner 还要有 CPU/PMU 标签,不能把某个 runner 的冒烟成功推广成整个池的能力。
团队保存的最小关联信息包括:测试或故障 ID、源码提交、可执行文件摘要与 Build ID、编译器和优化参数、rr/GDB/内核/CPU 身份、启动参数的脱敏版本、trace 大小、校验值和 replay 验证结果。参数、环境变量和工作目录可能含凭证,不应原样塞进公开日志。可执行文件、分离符号与 trace 的保留窗口必须重叠,否则 trace 仍在却无法可靠映射源码。
Trace 是一份可执行的敏感内存证据
rr Trace 可能包含进程内存、系统调用返回数据、读入的文件内容、内存映射、命令行、环境变量、请求数据和已经释放但仍残留的字节。它的敏感级别至少与 full core 相同。replay 会重建被记录程序的用户态路径,但记录期的外部系统调用副作用通常由 rr 模拟;真正仍会作用于分析机的是 GDB 自动加载脚本、Python 扩展、用户主动调用的 shell 命令、调试器函数调用及其他分析工具。不要从不可信工单直接 rr replay;先在无网络、最小权限的分析环境校验来源与摘要,关闭不必要的自动加载,并把 Trace 和配套符号都视为不可信输入。
受控传输至少做四件事:源目录权限只给案件成员;归档或对象存储启用加密;下载使用短时凭证并留审计;跨机前记录 trace、二进制和工具链摘要。分析机应默认断开业务网络,避免回放过程中触发未被记录边界隔离的插件、调试脚本或辅助工具访问。脱敏后的调用栈可以进入复盘,原始 trace 不应作为聊天附件或长期共享盘材料。
容量按“单次 trace 峰值 × 同时记录任务 × 重试次数 × 副本数 × 保留窗口”估算,再加 rr pack 临时空间、可执行文件、符号与下载缓存。记录会增加 CPU、磁盘 I/O 与运行时长;单核执行还可能拖长测试占用。达到配额时优先保存首个可 replay、构建身份正确的代表性 trace,后续相同签名只留计数;不同构建、异常地址或输入摘要不能草率去重。
清理要删除指定 Trace,也要证明没有误删
实验完成后先保存允许长期保留的脱敏结论,再关闭 replay 会话。GDB 中使用 quit 只结束回放,不会删除 trace。删除时显式指向本次目录,先打印解析后的绝对路径并确认它位于专用根目录,禁止对变量为空的路径执行递归删除。
trace_root="$(realpath evidence/rr-traces)"
trace_dir="$(realpath evidence/rr-traces/balance-failure)"
case "$trace_dir" in
"$trace_root"/*) ;;
*) printf 'refuse to delete outside trace root: %s\n' "$trace_dir" >&2; exit 1 ;;
esac
du -sh "$trace_dir"
find "$trace_dir" -maxdepth 1 -type f -printf '%m %u %g %s %f\n'
rr rm "$trace_dir"
test ! -e "$trace_dir"
find "$trace_root" -mindepth 1 -maxdepth 1 -printf '%f\n'rr rm 在不带 --force 时会先确认目标可识别为 Trace;外层绝对路径检查再防止变量指向专用根目录之外。最后一个 find 用来确认其他案件 Trace 仍在,而不是用 rm -rf evidence/rr-traces/* 一次清空。若目标是启动失败留下的非 Trace 残片,先保留失败日志并由两人核对解析路径和文件清单,再使用组织批准的证据清理工具;不要为了省事改成 rr rm --force。若 Trace 还存在于 CI artifact、压缩包、对象存储历史版本、分析机下载目录、备份或调试器缓存,案件销毁记录要逐个位置核销。临时 runner 也应复查短时 capability、调试组、网络隔离策略和挂载卷是否已经恢复。
何时选择 rr,何时换工具
可以在受支持 Linux 平台稳定完成最小 record/replay、错误能在单核记录条件下触发、问题依赖用户态历史且 Trace 可以按敏感证据治理时,rr 能把“偶发失败”变成可反复搜索的调试对象。只需要最后崩溃栈时,Core 更轻;需要观察真实多核竞争时,ThreadSanitizer、运行时竞争检测、eBPF/调度跟踪或应用级事件记录更合适;问题跨数据库、消息系统和远程服务时,应把分布式 trace、请求录制和服务夹具组合起来。
团队准入不以“成功演示一次反向执行”为终点,而以五项事实为准:目标 runner 的最小 record/replay 可重复通过;正向 replay 能回到同一失败;反向 watchpoint 能定位写入;不支持主机会明确拒绝;指定 Trace 能在权限、容量、保留和销毁规则下完整退出。这样 rr 才是一条可维护的故障证据链,而不是一条只在某台开发机上成立的技巧。
