朕喜技术(ZhenxiJS)
2026.10.02
1 分钟阅读

依赖探活「全绿」但依赖全挂: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)。

复现环境刻意造出「探活侧 / 业务侧 / 依赖侧」三处网络视图不一致:

容器地址能力角色
probe10.20.30.10/24有 NET_ADMIN、有 iproute2探活侧(跑 checkd)
localsvc与 probe 共享 netns只监听 127.0.0.1:8099回环依赖
sidecar10.20.30.40/24监听 0.0.0.0:8099 / :9110同网段依赖
peer10.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 里标志位的语义(这是本题最容易背错的一张表):

位名字语义是「能力」还是「状态」
0x1IFF_UP网卡已启用(admin up)状态 —— 链路可用性判据
0x2IFF_BROADCAST支持广播能力(恒置)
0x8IFF_LOOPBACK回环网卡属性
0x40IFF_RUNNING资源已分配 / 载波就绪(LOWER_UP)状态
0x1000IFF_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_name 16 字节 + ifr_flags 2 字节), 不能写成 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

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-02-0800-iff-up-vs-iff-multicast

分享这篇文章