依赖探活「全绿」但依赖全挂:TCP 连上的判定对象不对 + 网卡标志位读成 IFF_MULTICAST(健康检查假绿)
日期:2026-10-02 | 环境:Docker Compose + Python 3.12 | 第97题(分支
2026-10-02-0800)关键词:依赖探活假绿、健康检查误报、探活全绿但服务不可用、
SIOCGIFFLAGS、IFF_UP、IFF_MULTICAST、0x1、0x1000、ioctl 网卡标志位、Network is unreachable、Errno 101、no-route(101)、TCP 连接成功 ≠ 依赖可用、 判据对象错误、UDP connect 不发包、skipped被当成通过、退出码 0 却有依赖挂了、 Linux 网络编程、运维可观测性、NetworkManager 误报网卡 UP
一、问题背景
checkd 是依赖探活守护:按业务契约 conf/contract.env 逐项探活所有必需依赖,
只有全部依赖在线才允许业务订阅方订阅数据流,并把结论写进 state/report.json、用退出码表达结论。
契约写得很直白,语义是**「必须逐项探活、且必须全部成功」**:
PROBE_REQUIRED=svc-local:app|svc-lan:app|svc-lan:metrics|svc-wan:wan_app
现场却出现自相矛盾的现象:
- 探活每次都报 四项全
UP、ok: true、missing: []、退出码 0; - 业务侧的探活代跑器
probe_agent.py也报state=UP、exit_code=0; - 但真正要消耗依赖的组件里一个都用不了:
connect("10.20.30.40", 8099)直接[Errno 101] Network is unreachable。
探活说「都在线」,业务说「一个都用不了」,而两边都不报错。 这就是典型的假绿(false green)。
复现环境刻意造出「探活侧 / 业务侧 / 依赖侧」三处网络视图不一致:
| 容器 | 地址 | 能力 | 角色 |
|---|---|---|---|
probe | 10.20.30.10/24 | 有 NET_ADMIN、有 iproute2 | 探活侧(跑 checkd) |
localsvc | 与 probe 共享 netns | 只监听 127.0.0.1:8099 | 回环依赖 |
sidecar | 10.20.30.40/24 | 监听 0.0.0.0:8099 / :9110 | 同网段依赖 |
peer | 10.20.30.11/24 | 无 NET_ADMIN、无 iproute2 | 业务侧(只能 ioctl 读标志位) |
二、复现步骤
cd scenario/2026-10-02-0800
docker compose up -d --build
sleep 5
# 正常态:四项都"看起来"连得上
docker compose exec -T probe python3 /srv/app/checkd.py ; echo "exit=$?"
# ★ 一键把依赖真的弄挂,看探活还报不报绿
bash ops/repro-break.sh
# 完整验收(会真的停依赖、改网卡标志位)
bash ops/verify-host.sh
docker compose exec -T probe bash /srv/ops/verify.sh
repro-break.sh 依次演示三件事,每一件都与输出矛盾:
复现 1:依赖已停,探活仍全绿(reason=local-ok)
=== [1] 停掉 sidecar(同网段依赖 10.20.30.40)===
容器状态:3day-sidecar 已停止
[checkd] 探活 4 项:up=4 down=0 skipped=0 ← 依赖停了还全绿
[checkd] UP svc-lan:app 10.20.30.40 iface=eth0 verdict=up reason=local-ok ← 根本没发包!
[checkd] UP svc-lan:metrics 10.20.30.40 iface=eth0 verdict=up reason=local-ok ← 根本没发包!
reason=local-ok 就是"这一项没有发包、结论来自本地自查"的标记。
复现 2:对照组 —— 回环依赖停了能正常报 down
=== [2] 停掉 localsvc(回环依赖 127.0.0.1:8099)===
[checkd] 探活 4 项:up=3 down=1 skipped=0
[checkd] DOWN svc-local:app 127.0.0.1 iface=lo verdict=down reason=refused ← 探针本身会工作
[checkd] UP svc-lan:app 10.20.30.40 iface=eth0 verdict=up reason=local-ok ← 依然假绿
这一项能判 down,说明探针逻辑不是全坏,问题出在"判据对象"上:
走了"真的建连"路径的项能判对,走了"本地自查"路径的项永远判 up。
复现 3:网卡已 down,仍报 state=UP
=== [3] 把 peer 的网卡断掉(内核 IFF_UP 清零),看它上报什么 ===
内核 flags : 0x1043 IFF_UP = True
断链后 peer 上报:
iface=eth0
addr=10.20.30.40
state=UP ← 网卡已 down,仍报 UP
exit_code=0 ← 还报成功
内核事实:
flags = 0x1002 IFF_UP = False IFF_RUNNING = False IFF_MULTICAST = True ← 0x1000 恒真
内核 flags=0x1002,把位展开:
0x1002 = 0x1000 | 0x0002
IFF_UP (0x1) = False ← 这才是「网卡是否启用」
IFF_BROADCAST (0x2) = True
IFF_MULTICAST (0x1000) = True ← is_up() 读的是这一位,恒真
三、根本原因
根因 1:判据对象错误 —— 把「本机有没有路由」当成「依赖在不在线」
# app/probe.py::judge() 修复前
ok, why = route_usable(ip, port) # UDP connect,一个包都不发
if not ok:
return {"verdict": "skipped", "reason": why}
if up: # up = netinfo.is_up(iface)
return {"verdict": "up", "reason": "local-ok"} # ← 本地自查冒充在线证据
online, code = probe_one(ip, port) # 只有走到这里才真的发包建连
route_usable() 用 UDP socket.connect((ip, port))。
UDP 连接是非破坏性、不发包的:内核只做一次路由表查找并把结果缓存进 socket,
只要路由表里还有匹配项就直接返回成功。
它回答的问题是:「本机有没有到该地址的路由」。 业务要回答的问题是:「对端那个服务还在不在」。
这两者在「目标已经消失、但路由还在」时必然分叉 —— 而这恰好就是「依赖挂掉」的典型情形:
sidecar 容器停了,10.20.30.0/24 这条 proto kernel 的链路路由还在,
于是本地自查永远通过,探活永远报 up/local-ok。
这就是判据对象错误:拿一个「自己这边」的指标,去证明一件「对方那边」的事实。
根因 2:skipped 被当成了「没问题」
judge() 在本地自查失败时返回 skipped。
而 report.py 的口径里 skipped 与 down 是不同 verdict,
虽然 missing 会把两者都列出来,但一旦 ok 的判定漏掉 skipped,
「拿不到数据」就退化成「没问题」——给自己留了个静默放行的后门。
ip link set eth0 down 会连地址上的路由一起撤掉(down 时 ip route 为空),
此时本地自查返回 no-route(101),本该是硬失败,却被包成了 skipped。
根因 3:网卡标志位读错位 —— IFF_MULTICAST 是能力位,不是状态位
# app/netinfo.py::is_up() 修复前
return bool(flags(ifname) & IFF_MULTICAST) # 0x1000
linux/if.h 里标志位的语义(这是本题最容易背错的一张表):
| 位 | 名字 | 语义 | 是「能力」还是「状态」 |
|---|---|---|---|
0x1 | IFF_UP | 网卡已启用(admin up) | 状态 —— 链路可用性判据 |
0x2 | IFF_BROADCAST | 支持广播 | 能力(恒置) |
0x8 | IFF_LOOPBACK | 回环网卡 | 属性 |
0x40 | IFF_RUNNING | 资源已分配 / 载波就绪(LOWER_UP) | 状态 |
0x1000 | IFF_MULTICAST | 支持组播 | 能力(网卡存在即恒置 1) |
IFF_MULTICAST 描述的是网卡能不能收发组播帧(能力),
与它现在是不是通的(状态)没有任何关系。容器 eth0 从创建那一刻起 0x1000 就是 1,
所以 is_up() 恒返回 True —— 一个「永远 UP」的函数。
补充一个易混点:IFF_RUNNING (0x40) 更接近「载波就绪」,
但它是驱动上报的,虚拟设备/某些驱动下语义不一致;
要判定「网卡是否启用」,标准答案就是 IFF_UP (0x1)。
若要判定「链路是否真的通了」,应组合看 IFF_UP && IFF_RUNNING。
根因 4:ioctl 读取本身的两个细节坑
ifreq = struct.pack("16sH", ifname.encode(), 0)
res = fcntl.ioctl(s, SIOCGIFFLAGS, ifreq)
flags = struct.unpack("H", res[16:18])[0]
struct格式必须是16sH(ifr_name16 字节 +ifr_flags2 字节), 不能写成16sI:内核写回的是short,偏移错了会读到垃圾值。- 偏移是
res[16:18]而不是res[16:20]—— 差 1 个字节就会得到完全不同的"标志位", 而程序不会报错,只会静默地判错。这类 bug 最难查。
四、解决办法
只改 app/ 下 3 个文件,conf/ 里的契约与登记表一字未动。
1. app/netinfo.py —— 改读 IFF_UP
def is_up(ifname: str) -> bool:
"""网卡是否「已启用」。判据:IFF_UP (0x1) —— admin up。
注意不要用 IFF_MULTICAST (0x1000):那是「网卡支持组播」的能力位,
只要网卡存在就恒置 1,与链路状态无关,用它判定会得到「永远 UP」。
"""
try:
return bool(flags(ifname) & IFF_UP)
except OSError:
return False
2. app/probe.py::judge() —— 把「在线」的唯一来源收敛到真建连
# 本地前置检查只用来解释失败原因,不参与「在线」判定
ok, why = route_usable(ip, port)
if not ok:
return {"ip": ip, "verdict": "down", "reason": why}
# 唯一的「在线」证据:真的连上一次
online, code = probe_one(ip, port, timeout=timeout)
if online:
return {"ip": ip, "verdict": "up", "reason": "ok"}
return {"ip": ip, "verdict": "down", "reason": code}
要点:
- 删掉
if up: return up/local-ok短路分支(根因 1); no-route归为down而不是skipped(根因 2);- 「up」现在只有一个来源:
probe_one()真的建连成功、reason=ok。
3. app/report.py —— 把契约语义显式写死
# 契约书面语义:必须逐项探活、且必须全部成功。
# skipped = 「拿不到数据」,与 down 等价,都算失败,绝不能因为「没测出来」而放行。
if policy_all_required:
ok = len(down) == 0 and len(skipped) == 0
五、验证
5.1 正常态:四项都是真建连(reason=ok)
docker compose exec -T probe python3 /srv/app/checkd.py; echo "exit=$?"
[checkd] 探活 4 项:up=4 down=0 skipped=0
[checkd] UP svc-local:app 127.0.0.1 iface=lo verdict=up reason=ok
[checkd] UP svc-lan:app 10.20.30.40 iface=eth0 verdict=up reason=ok
[checkd] UP svc-lan:metrics 10.20.30.40 iface=eth0 verdict=up reason=ok
[checkd] UP svc-wan:wan_app 10.200.254.17 iface=eth0 verdict=up reason=ok
exit=0
local-ok 彻底消失。
5.2 关键复现:判据「有牙」
bash ops/repro-break.sh
=== [1] 停掉 sidecar ===
[checkd] 探活 4 项:up=2 down=2 skipped=0
[checkd] DOWN svc-lan:app 10.20.30.40 iface=eth0 verdict=down reason=timeout ← 立刻失败
[checkd] DOWN svc-lan:metrics 10.20.30.40 iface=eth0 verdict=down reason=timeout
=== [2] 停掉 localsvc ===
[checkd] DOWN svc-local:app 127.0.0.1 iface=lo verdict=down reason=refused
=== [3] 断掉 peer 的网卡 ===
断链后 peer 上报:
state=DOWN ← 与内核 IFF_UP 一致
exit_code=1
内核事实:
flags = 0x1002 IFF_UP = False IFF_RUNNING = False IFF_MULTICAST = True
=== [4] 恢复环境 ===
[checkd] 探活 4 项:up=4 down=0 skipped=0 ← 恢复后回到 0
停依赖立刻失败、断网卡上报 DOWN、恢复后回到 0 —— 三条都对上了。
5.3 撤默认路由:验证「拿不到数据」= 失败
docker compose exec -T probe bash -c 'ip route del default; python3 /srv/app/checkd.py; echo exit=$?; ip route add default via 10.20.30.1'
[checkd] DOWN svc-wan:wan_app 10.200.254.17 iface=eth0 verdict=down reason=no-route(101)
[checkd] 订阅闸门:拒绝(缺失 ['svc-wan:wan_app'])
exit=1
5.4 全套验收
bash ops/verify-host.sh
=== A. 正常态(依赖全部真实可达) ===
PASS A1 checkd 正常态退出码 0
PASS A2 四项必需探活均判定 up
PASS A3 没有 down/skipped 残留
PASS A4 没有把「本地自查」当作在线依据
=== B. 回环依赖真的挂掉 -> 必须失败 ===
PASS B1 回环依赖不可达时 svc-local 不再判 up(实际 down)
PASS B2 缺失项里点名 svc-local:app
=== C. 同网段依赖真的挂掉 -> 必须失败 ===
PASS C1 同网段依赖不可达时不再全绿(up=2)
PASS C2 缺失项里点名 svc-lan
=== D. 业务侧上报必须与内核 IFF_UP 一致 ===
PASS D1 链路可用:上报 UP 且内核 IFF_UP 置位
PASS D2 链路被断:上报 DOWN 且内核 IFF_UP 未置位(判据读的是 IFF_UP)
=== E. 报告可外部独立复核 ===
PASS E1 report.json 每条判定都带 verdict/reason/iface/port
PASS E2 报告口径与契约(必须全部成功)一致
PASS=12 FAIL=0
RESULT: OK
docker compose exec -T probe bash /srv/ops/verify.sh # PASS=5 FAIL=0
docker compose exec -T probe python3 /srv/client/check_deps.py # RESULT: OK(外部独立探针)
六、经验与避坑
1. 判据必须指向"被观测对象",别用本地指标证明远端事实
这是本题最大的教训。「本机有路由」≠「对端服务在」。
route_usable()(UDP connect)是个典型陷阱:它不发包、永远成功、看起来像个"便宜的预检查",
但它的观测对象是本机路由表,而我们要的结论是对端可用性。
「预检查」和「结论」是两个不同的命题,把前者当后者的证据 = 判据对象错误。
避坑规则:任何一个健康检查的前置检查,返回的只能是 「跳过 / 失败原因」,绝不能是「通过」。 只有真实打到对端的那一次交互,才能产出「通过」。
2. 给"通过"设一个可审计的来源
修复后的代码里,up 只有一个来源:probe_one() 返回 ok。
任何一种 verdict 都应该能追溯到唯一的、真实的观测动作。
报告里带上 reason 字段,就能一眼看出「这一项到底发包了没有」——
reason=local-ok 这种标记本身就是最强的告警信号。
同类可查标记:cached、assumed-ok、skipped-ok、from-cache、
static、precheck、short-circuit。见到这些,就该怀疑是不是假绿。
3. IFF_UP / IFF_MULTICAST / IFF_RUNNING 别搞混
- 判「网卡是否启用」→
IFF_UP (0x1) - 判「链路是否真的通」→
IFF_UP (0x1) && IFF_RUNNING (0x40) IFF_BROADCAST (0x2)、IFF_MULTICAST (0x1000)是能力位,恒置,永远不要用来判状态。
记忆锚点:能力位(BROADCAST / MULTICAST)在网卡创建时就被内核置上,跟运行状态无关。 很多「网卡永远显示 UP」的 bug(含 NetworkManager、各类监控 agent 的误报)都出自这里。
现成对照工具(有 iproute2 时):
ip -o link show # 输出里的 <BROADCAST,MULTICAST,UP,LOWER_UP>
# 只有 UP / LOWER_UP 是状态位,BROADCAST/MULTICAST 是能力位
4. skipped / unknown / not-tested 在「必须全部成功」的语义下就是失败
契约写的是 "all required must succeed"。
「我没测出来」「我跳过了」在业务上是拿不到数据,和「依赖挂了」等价。
默认拒绝(fail-closed) 才是安全方向;
把 skipped 从失败集合里摘出去,就等于给自己开后门。
5. ioctl 结构体偏移差一个字节不报错,只判错
struct.pack("16sH", ...) + res[16:18] 是 SIOCGIFFLAGS 的标准写法。
写成 16sI / res[16:20] 不会崩,只会静默读到垃圾位。
凡是位运算判定,都该写一个"造出已知 flags 再断言"的单元测试,
比如本题断链后应当断言 flags & 0x1 == 0 且 flags & 0x1000 == 1。
6. Docker 实操坑(复现时必踩)
app/是:ro只读挂载,docker compose cp app/xxx.py probe:/srv/app/会报Error response from daemon: mounted volume is marked read-only。 正确做法是在宿主机上改文件(容器立刻可见),无需 rebuild。docker compose exec默认分配 TTY:脚本化调用必须加-T, 否则非交互环境报the input device is not a TTY。- 静态 IPAM 不会自动下发默认路由:跨子网目标(本题
svc-wan在10.200.254.17) 需要容器启动时显式ip route add default via <gw>,否则从第一步就no-route,复现不可重复。 - 改网卡标志位需要
NET_ADMIN:peer故意没有这个 capability, 验收脚本靠docker run --network container:3day-peer --cap-add NET_ADMIN起一个旁路容器 进同一个 netns 去改。这个手法在做网络故障注入时非常实用。 repro-break.sh打印的 flags 是"改前/改后"两个时刻, 看到0x1043紧接着0x1002不是自相矛盾,判断依据看第二行的IFF_UP =值。
7. 一句话总结
「探活说在、业务说不在」时,先去看探活到底有没有真的发包。 一个恒真的判据(读了能力位、走了本地自查、把 skipped 当通过)比一个会报错的判据危险得多: 前者静默地骗人,后者至少还会响。
8. 本机 Docker daemon 的可见范围(rootless / overlayfs)
把仓库克隆到 /tmp 或 /root 下再 docker compose up,容器里的 bind mount 会是空的,
表现为 sidecar/localsvc 容器直接 Exited (1):
$ docker compose exec -T probe bash -c 'ls /srv/app'
sidecar.py # ← 只看到一个"目录",文件全不见
$ docker logs 3day-localsvc
/usr/local/bin/python3: can't find '__main__' module in '/srv/sidecar.py'
连 docker run -v /tmp/x:/x:ro alpine ls /x 都是空目录,而宿主机 ls 一切正常。
docker info 显示 Docker Root Dir: /home/rootless/.local/share/docker(rootless)。
复验一律在共享目录(如 /workspace)下做。排查要点:先用
docker run --rm -v <hostdir>:/x:ro alpine ls /x 单独验证挂载 ——
空目录 = daemon 看不见该路径;有文件 = 才是 compose/代码问题。
别把「容器里看不到文件」误判成「bind mount 写错了」。
9. git checkout main 会清掉只在分支上的 scenario/ 目录
本仓库的 scenario/<分支名>/ 只在各自分支上,main 不跟踪。
切回 main 后该目录只剩 .gitignore 忽略的 state/ 运行时产物,
git status 显示 ?? scenario/<分支>/。想再验一次要先 git checkout <分支>。
附:最小复现 / 验证命令清单
cd scenario/2026-10-02-0800
docker compose up -d --build && sleep 5
bash ops/repro-break.sh # ★ 停依赖/断网卡,看还报不报绿
bash ops/verify-host.sh # 全套验收 A~E(12 项)
docker compose exec -T probe bash /srv/ops/verify.sh # 容器内静态检查 F/G
docker compose exec -T probe python3 /srv/client/check_deps.py # 外部独立探针
docker compose down -v
