Chips

因果对照:证明「是这条规则挡住的」

· 约 7 分钟 · 2522 字

「加了规则之后就连不上了」只证明时间相关,不证明因果。用三组对照把它升级为因果:有规则时阻断、移除同一条规则后放行、计数器增量落在该规则上;并说明时间窗对比为何必须确认窗口内确有触发事件,以及两类常见的取证失误。

问题:时间上「之后」,不等于因果上的「因为」

把一条规则加上去,连接随即失败。这个观察只建立了一个事实:两件事在时间上相邻。它没有排除任何竞争解释。

常见的竞争解释至少有四类:

混淆项 为什么它也能解释现象
重启 服务、隧道、容器在重启瞬间都会短暂不可达,任何在重启后做的观察都被污染
缓存 DNS、conntrack、应用层连接池里的旧状态过期,行为会自己变化
另一条规则 加规则时往往同时 reload 了整套规则集,真正起作用的是其中另一条
服务自身变化 部署了新版本、改了绑定地址、证书续期,都与防火墙无关

要区分它们,需要的是反事实:如果只去掉这条规则、其它什么都不动,现象会不会变?反事实无法直接观察,只能构造。对照实验就是构造它的手段。

三组对照

一个能站住的因果判断,需要三组而不是一组:

操作 观测 期望 它排除什么
保留规则,发起触发 连接结果 阻断 「规则存在时会发生」
移除规则,同一触发 连接结果 放行 因果方向:只翻转了这一个变量
保留规则,读计数器增量 pkts 变化 增加,且落在该规则行 拦截确实发生在这条规则上

第 ① 组单独看几乎没有信息量——它和「加了规则就连不上」是同一句话,仍然只是相关性。很多排查停在这一步就宣布结案。

第 ② 组是关键的因果证明,因为它是反向实验。在这一组里,服务、缓存、主机状态、其它规则全部保持不变,唯一被翻转的量就是那条规则本身。如果现象随之翻转,上面那些混淆项就都被排除了:重启和缓存不会因为你 iptables -D 掉一行而恰好再发生一次。

第 ③ 组针对一个很隐蔽的失败模式:规则存在、文本正确,但根本没匹配到任何流量。判断一条规则是否真的在工作,唯一的办法是看它的计数器。这也解释了为什么有些「已经加固过」的配置实际为零防护——写在配置里的意图,和内核实际执行的路径是两回事。

用 network namespace 伪造一个外部来源

要让第 ② 组成立,触发必须可重复,且源地址可控。用 network namespace 加一对 veth 造一个「外部主机」:

# 造一个独立网络栈,源地址取文档保留段 203.0.113.0/24
ip netns add probe
ip link add veth-p type veth peer name veth-h
ip link set veth-p netns probe
ip addr add 203.0.113.1/24 dev veth-h
ip link set veth-h up
ip netns exec probe ip addr add 203.0.113.5/24 dev veth-p
ip netns exec probe ip link set veth-p up
ip netns exec probe ip route add default via 203.0.113.1

被访问的一侧,以宿主上监听的一个端口为例。如果测的是发布出来的容器端口,机制略有不同(那段流量走 FORWARD 而不是 INPUT,见 /posts/docker-bypasses-ufw.html):

# 宿主上起一个一次性探针
python3 -m http.server <端口> --bind 0.0.0.0

三组对照依次执行:

# 组 ①:规则在位,从「外部」发起触发 → 期望超时
ip netns exec probe curl -s -m 3 -o /dev/null -w '%{http_code}\n' http://203.0.113.1:<端口>/
# 无输出,curl 退出码 28(超时)

# 组 ③:先读基线,触发,再读一次,取增量
iptables -L DOCKER-USER -n -v | grep DROP
# 0      0 DROP  all  --  *  *  0.0.0.0/0  0.0.0.0/0  ctstate NEW
ip netns exec probe curl -s -m 3 http://203.0.113.1:<端口>/ ; true
iptables -L DOCKER-USER -n -v | grep DROP
# 3    180 DROP  all  --  *  *  0.0.0.0/0  0.0.0.0/0  ctstate NEW   ← 增加了 3 个包

# 组 ②:只移除那一条,其它不动,同一来源重试 → 期望放行
iptables -D DOCKER-USER -m conntrack --ctstate NEW -j DROP
ip netns exec probe curl -s -m 3 http://203.0.113.1:<端口>/
# 取出探针的输出,连接正常建立

三个实现细节:

为什么伪造来源,而不是「从别的机器试」

维度 伪造来源(netns) 真实外部主机
可控 源地址、端口、时序全部自己指定 源地址由运营商决定,可能变化
可重复 一条脚本,随时重建 依赖那台机器的状态与网络
精确匹配 能指定 203.0.113.0/24 命中按来源写的规则分支 无法命中特定网段的规则
外部依赖 无,纯本机 依赖上游链路、DNS、对方防火墙
风险 流量只在本机内部 真的把端口暴露给公网

要验证「按来源网段放行」这类规则,用真实机器几乎做不到——手上不会恰好有一台属于 203.0.113.0/24 的机器。伪造来源是唯一能精确构造这个条件的方式。

另一类证据:时间窗对比

上面的对照组回答「是谁挡住的」。还有一类问题:修复是否生效。这里最常见的错误是只看修复后有没有报错——修复之后窗口里如果什么都没发生,「没有报错」就是空证据。

方法是把窗口切清楚,并确认窗口内确实有触发事件

# 修复前的窗口
journalctl --since '2026-09-09 07:47:00' --until '2026-09-09 07:55:00' | grep -c '<报错模式>'
# 1

# 修复后:先确认窗口内有触发,再看报错数
systemctl daemon-reload
journalctl --since '2026-09-09 07:56:00' | grep -c 'Reloading\.'
# 2   ← 窗口内确实重新运行了 generator
journalctl --since '2026-09-09 07:56:00' | grep -c '<报错模式>'
# 0

daemon-reload 会重新执行全部 generator,所以它之后的窗口对「generator 是否还会报错」才有意义;而这个意义完全来自那两条 Reloading. 记录。没有它们,0 只说明窗口内什么都没发生。

窗口内有触发 有报错 结论
未修复
有效证据
空证据,什么都证明不了

最后一行是这类排查里最容易自我欺骗的位置:数字好看,但实验根本没做。(这个案例的完整过程在 /posts/systemd-fstab-generator-duplicate.html。)

两类取证失误

管道替换退出码

cmd 2>&1 | tee /tmp/out.log; echo $?

这个 $? 取到的是 tee 的退出码,而 tee 只要能把字节写出去就返回 0。于是 echo $? 恒为 0,与 cmd 是否成功毫无关系——「命令成功」这个结论完全无据。它危险的地方在于输出的数字看起来很正常。

# 解法一:管道中任一环失败即整体失败
set -o pipefail
cmd 2>&1 | tee /tmp/out.log
rc=$?

# 解法二:不进管道,单独取
cmd 2>/tmp/err.log; rc=$?
echo "rc=$rc stderr_bytes=$(stat -c%s /tmp/err.log)"

任何形式的「看这条命令到底成没成功」,只要中间有管道,就必须先怀疑退出码,并同时看 stderr 的字节数——空的 stderr 加 0 退出码才是完整的证据。

把「自己打错了」当成系统故障

排查中出现的错误信息,先分清来源:是 shell 报的(命令不存在、路径打错),还是被排查的程序报的。

/usr/lib/systemd/systemd-fstab-generator /tmp/g1 /tmp/g2 /tmp/g3
# bash: /usr/lib/systemd/systemd-fstab-generator: No such file or directory

正确路径是 /usr/lib/systemd/system-generators/systemd-fstab-generator——少了一层目录,纯粹是手误。command not found / No such file or directory 属于输入错误,不是系统状态。区分方法:

command -v <命令>; echo "rc=$?"     # 127 = shell 里根本没有这个命令

把输入错误当成新故障,会让排查方向整体偏移,还会凭空多出一个假的「症状」。在把任何现象记入时间线之前,先确认那条命令本身是对的。

结论:优先用计数器作为因果证据

证据类型 谁写的 特性
计数器(pkts / bytes 内核在数据面上机械累加 不说谎;但会重置
日志 程序自己写 可能降级、限流、漏写,有解释空间

日志是程序的叙述,程序可以选择不说;计数器是内核的记账,不存在「没记」这个选项。所以在能拿到计数器的地方,它比日志更适合当因果证据。

但计数器有一个必须留意的性质:它会重置。规则被删除重建、整套规则集 reload、dockerd 重启重建自己的链,都会让 pkts 归零。于是:

把相关性升级为因果,最小集合是:一个只翻转单一变量的反向实验(移除规则后现象翻转),加上一个机械的观测值(计数器增量落在预期的那条规则上)。前者排除混淆项,后者排除「规则只写在配置里、实际没匹配」。两者都缺的时候,剩下的只是时间上的巧合。

← 全部文章