因果对照:证明「是这条规则挡住的」
「加了规则之后就连不上了」只证明时间相关,不证明因果。用三组对照把它升级为因果:有规则时阻断、移除同一条规则后放行、计数器增量落在该规则上;并说明时间窗对比为何必须确认窗口内确有触发事件,以及两类常见的取证失误。
问题:时间上「之后」,不等于因果上的「因为」
把一条规则加上去,连接随即失败。这个观察只建立了一个事实:两件事在时间上相邻。它没有排除任何竞争解释。
常见的竞争解释至少有四类:
| 混淆项 | 为什么它也能解释现象 |
|---|---|
| 重启 | 服务、隧道、容器在重启瞬间都会短暂不可达,任何在重启后做的观察都被污染 |
| 缓存 | 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:<端口>/
# 取出探针的输出,连接正常建立
三个实现细节:
pkts要读增量,不要读绝对值。 复现前先取一次基线,差值才是这次触发带来的;绝对值会把历史流量一起算进来。- 这条路径上不要做 SNAT/MASQUERADE。 一旦经过地址转换,规则看到的源地址会变成 veth 那一端的地址,按来源匹配的规则就不再有意义。
--ctstate NEW只匹配新连接。 如果组 ② 之前已经建立了连接,conntrack 会让后续包走 ESTABLISHED 而绕过规则。每次触发要么换源端口,要么先清掉对应的 conntrack 表项。
为什么伪造来源,而不是「从别的机器试」
| 维度 | 伪造来源(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 归零。于是:
- 永远用同一实验会话内的前后两次读数之差,不要跨会话比较绝对值;
pkts为 0 有两种含义——「从未匹配」和「匹配过但中途被重置」,要结合规则与链的生命周期判断是哪一种;- 基线的读取要写进实验脚本,不要靠记忆。
把相关性升级为因果,最小集合是:一个只翻转单一变量的反向实验(移除规则后现象翻转),加上一个机械的观测值(计数器增量落在预期的那条规则上)。前者排除混淆项,后者排除「规则只写在配置里、实际没匹配」。两者都缺的时候,剩下的只是时间上的巧合。