DNS 缓存命中统计「四个绿灯账全错」:TTL 版本、rrsig 折算、负缓存 TTL、时钟偏移、CNAME 跟随全踩了一遍
问题背景
内网递归 DNS 的缓存命中统计服务 dnscache-tally(Python 3.11 + docker compose)出了个经典事故:
所有监控信号都是绿的,报表全是错的。
$ python3 -m app.service
counts={"expired": 2, "hit": 12, "lookups": 34, "loop": 0, "miss": 20, "neg_hit": 0, "served": 12} ok=True
$ echo $?
0
$ python3 -m app.health
{"degraded": false, "issues": []}
$ echo $?
0
app.service退出码0、报告ok: trueapp.health报degraded: false, issues: []- 报告里
counts八个字段齐全,checksum也是 16 位十六进制 bash ops/repro.sh末尾也是RESULT: OK
四个绿灯,账全错。唯一说真话的是不 import 被测代码的外部独立探针:
RESULT: BAD(不一致 23 条)。判定闸门 bash ops/verify.sh 是 PASS=7 FAIL=14。
用户侧症状:
- CNAME 记录能解析,报表全记
MISS; - 7 秒前的查询判
EXPIRED,200 秒前的反而HIT—— 越新越容易过期; - 带 DNSSEC 签名的条目(
rrsig=1)过期时间口径不对; - NXDOMAIN / NODATA 负缓存一个都命不中;
stored_at在未来的条目(时钟偏移)静默当有效数据用,skew恒 0;- CNAME 成环或链过长时
loop恒 0; - 同一
(key, rrtype)多版本时「当前条目」挑错版本。
技术栈关键词:Python / DNS / 缓存命中率 / TTL / DNSSEC rrsig / RFC 2308 负缓存 /
CNAME 链 / 时钟偏移 clock skew / 退出码分层 / fail-closed / 健康检查假绿 / docker compose。
复现步骤
cd scenario/2026-10-24-1600
docker compose up -d --build
docker compose exec -T app bash
bash ops/repro.sh # 问题态:探针 RESULT: BAD(不一致 23 条)
bash ops/verify.sh # 问题态:PASS=7 FAIL=14 / RESULT: BAD
契约在 docs/contract.md(唯一权威口径),外部探针 client/dnstally_probe.py 不 import app/,
按契约独立重算后与 reports/tally.json 逐字段比对。约束:只能改 app/,
docs/ conf/ input/ client/ ops/ 一字不动,改考卷/探针让判定变绿等于造假。
问题态关键输出:
不一致: lookup seq=7 字段 result 期望 'HIT' 实际 'MISS'
不一致: lookup seq=8 字段 result 期望 'LOOP' 实际 'MISS'
不一致: lookup seq=13 字段 result 期望 'EXPIRED' 实际 'HIT'
... 共 23 条不一致
RESULT: BAD(不一致 23 条)
根本原因
对照契约逐条对齐后,app/ 里埋了 12 个口径 bug(前 8 个是数据错,后 4 个是「假绿」元凶):
| 契约条款 | 问题版写法 | 后果 |
|---|---|---|
§3 当前条目 = (stored_at, 行序) 最大者 | items.sort(key=lambda e: e["ttl"]) | TTL 最大当最新版本(1800 的旧版本盖掉 900 的新版本) |
§2.3 rrsig=1 → ttl*3//4 | eff 只挂当前条目、eff_all 给全组擦同一值 | DNSSEC 折算口径错位 |
§6 负缓存 min(eff, NEG_TTL_S) | effective_ttl 完全没吃 NEG_TTL_S | 3600s 的负缓存 TTL 不设上限 |
| §4 `age<0 且 | age | >CLOCK_SKEW_S` → 整组 SKEW |
| §5.1 查的 rrtype 无条目但有 CNAME → 先跟随 | classify 从不预跟随 | CNAME 不跟随,能解析的全记 MISS |
§5.2 chain > MAX_CHAIN → LOOP | if chain > 2 硬编码 + 目标固定 (data,"A") | 跳数口径错、环判不出 |
| §5.4/5.5 NXDOMAIN/NODATA → NEG_HIT | return "MISS";无「目标无该类型但有负缓存」分支 | neg_hit 恒 0 |
§7 ok = expired==0 and skew==0 and loop==0 | 硬编码 "ok": True | 报告自洽性造假 |
| §9 rc 0/2/3 分层 | service.main() 恒 return 0 | 退出码分层失效 |
| §2.1 fail-closed | contract.py 用 DEFAULTS 兜底 + except OSError: pass | 缺键/未知键/越界静默跑绿 |
§10 判据表非空 + 输出 checks | BAD_FIELDS = () | 空判据表假绿,health 恒 degraded:false |
| §10 不 import 被测代码 | (本来就没 import,修的时候要保持) | 健康检查要独立复算 |
一句话总结:「数据结构(版本/TTL 口径)→ 状态判定(过期/skew)→ 解析路径(CNAME 跟随/环)→ 报告自洽(ok)→ 进程契约(退出码)→ 健康检查(判据表)」六层里,每一层都漏了,而且 退出码恒 0 + 空判据表把所有这些错都盖成了绿灯。
解决办法
1) app/contract.py:fail-closed 策略加载
REQUIRED = ("CLOCK_EPOCH", "NEG_TTL_S", "CLOCK_SKEW_S", "MAX_CHAIN")
def verify(cfg, src):
for k in REQUIRED: # 缺键
if k not in cfg:
raise ContractError("%s: 缺少必填 key %s" % (src, k))
for k in cfg: # 未知键
if k not in REQUIRED:
raise ContractError("%s: 未知 key %s" % (src, k))
if cfg["NEG_TTL_S"] < 0 or cfg["CLOCK_SKEW_S"] < 0:
raise ContractError(...)
if not 1 <= cfg["MAX_CHAIN"] <= 16: # 越界
raise ContractError(...)
读文件时非整数直接 ContractError,OSError 也转 ContractError,绝不回退默认值。
2) app/tally.py:生效条目 + 状态判定 + CNAME 跟随
def build_current(kv, cfg):
...
for gk, items in groups.items():
items.sort(key=lambda e: (e["stored_at"], e["idx"])) # §3 版本口径
cur = items[-1]
cur["eff"] = effective_ttl(cur["ttl"], cur["rrsig"], gk[1], cfg["NEG_TTL_S"])
current[gk] = cur
return current, groups
def effective_ttl(ttl, rrsig, rrtype, neg_ttl):
t = ttl * 3 // 4 if rrsig == "1" else ttl # §2.3
if rrtype in {"NXDOMAIN", "NODATA", "SOA"}:
t = min(t, neg_ttl) # §6 RFC 2308
return t
def entry_state(gk, at, current, groups, cfg):
if gk not in current:
return "miss"
for e in groups[gk]: # §4 整组 skew
age = at - e["stored_at"]
if age < 0 and abs(age) > cfg["CLOCK_SKEW_S"]:
return "skew"
cur = current[gk]
age = at - cur["stored_at"]
return "expired" if age > cur["eff"] else "ok" # 严格大于
CNAME 跟随 + 环检测(visited 集合 + 跳数双保险):
def classify(gk, at, current, groups, cfg):
visited = {gk}
chain = 0
while True:
state = entry_state(gk, at, current, groups, cfg)
if state == "miss":
return ("NEG_HIT", 0) if _neg_hit(gk[0], ...) else ("MISS", 0)
if state == "skew": return "SKEW", 0 # §5 优先级 SKEW 最高
if state == "expired": return "EXPIRED", 0
if gk[1] in ("NXDOMAIN", "NODATA"): return "NEG_HIT", 0
if gk[1] != "CNAME": return "HIT", 1 # 链本跳不计 served
chain += 1
if chain > cfg["MAX_CHAIN"]: return "LOOP", 0
target = current[gk]["data"]
nxt = (target, "CNAME") if (target, "CNAME") in current else (target, "A")
if nxt in visited: return "LOOP", 0 # 自指/互指环
visited.add(nxt); gk = nxt
lookup 预跟随:(key, rrtype) 无条目但 (key, "CNAME") 存在 → 先转 CNAME。
counts 用显式映射 RESULT_KEYS = {"NEG_HIT": "neg_hit", ...},别用 res.lower()。
3) app/service.py:退出码分层
try:
rep = tally.run()
except contract.ContractError:
return 3
except loader.FactError:
return 2
write_report(rep)
return 0 if rep["ok"] else 2
4) app/health.py:真判据表(不 import 结算代码)
只读 conf/policy.tsv + input/*.tsv + reports/tally.json 独立复算,11 条判据:
lookup 逐条存在性/at/字段齐全、条数一致、顺序与输入行序一致、
六类之和 == lookups、served == sum(served)、ok 与 counts 自洽、clock_epoch 与策略一致、
entries 与输入组数一致、checksum 16 位、per_type 键字典序。
输出 {"checks": 11, "degraded": false, "issues": []},有问题 → rc=2,契约坏 → rc=3。
验证
docker compose up -d --build
docker compose exec -T app bash -c 'bash ops/repro.sh | tail -3; bash ops/verify.sh | tail -2'
== repro
期望 checksum=bb7c59b561c699e0 ok=False
RESULT: OK
probe rc=0
RESULT: OK
== verify
PASS=21 FAIL=0
RESULT: OK
$ python3 -m app.service
counts={"expired": 3, "hit": 21, "lookups": 34, "loop": 3, "miss": 4, "neg_hit": 2, "served": 21, "skew": 1} ok=False checksum=bb7c59b561c699e0
$ echo $?
2
$ python3 -m app.health
{"checks": 11, "degraded": false, "issues": []}
$ echo $?
0
契约坏三种情况(缺 MAX_CHAIN / 未知 key BOGUS / MAX_CHAIN=20)→ rc=3 ✅
health 真的会叫(篡改 reports/tally.json 的 counts.hit 和一条 lookup 的 result):
{"checks": 11, "degraded": true, "issues": ["counts 六类之和 != lookups", "counts.served != sum(served)"]}
rc=2
经验与避坑
- 「退出码恒 0」+「空判据表」是假绿的双保险。任何监控服务上线前先问两句:
进程真的会返回非 0 吗?判据表真的非空吗?
BAD_FIELDS = ()这种写法比 bug 更危险—— 它让 bug 永远不被发现。 - 写健康检查不要 import 被测结算代码,否则被测代码的口径 bug 会被健康检查「继承」, 两边一起错、一起绿。独立按契约重算才是真体检。
- 退出码本身就是契约。
0 正常 / 2 有真问题 / 3 契约不可用必须分层, 上游告警系统就是看 rc 的。 - 配置加载 fail-closed:
DEFAULTS兜底 +except: pass会让「缺 key / 未知 key / 越界」 全部静默跑绿。宁可启动失败(rc=3)也不要带着错配置出报表。 - 同一
(key, rrtype)多版本一定按stored_at挑最新,别按 TTL、别按上游 id。 TTL 大小和版本新旧毫无关系 —— 这正是「越新越容易过期」的错因。 age == effective_ttl仍有效(严格大于才过期),age < 0但在CLOCK_SKEW_S内 也视为有效,只有|age| > CLOCK_SKEW_S才整组 SKEW。- RFC 2308 负缓存 TTL 有上限:
min(SOA minimum, 本地 NEG_TTL_S),NXDOMAIN 与 NODATA 是两种负缓存,不能合并。 - CNAME 环检测既要数跳数也要记 visited:自指/互指环若只数跳数,会先跑满
MAX_CHAIN, 把 LOOP 拖成 EXPIRED 或 MISS。 docker compose+COPY . /workspace不是挂载:改app/必须up -d --build, 否则exec进去跑的还是旧镜像。- 判定闸门要有牙:
ops/negative.sh那种「把修好的实现逐一回滚、verify 必须转红」的负向闸门 才是真闸门。本轮实测发现它的锚点绑在问题版代码原句上(AnchorMiss), 且 health 侧完全没有「health 会不会叫」的断言 —— 闸门本身也要被审计。
复现命令速查
cd scenario/2026-10-24-1600
docker compose up -d --build
docker compose exec -T app bash -c 'bash ops/repro.sh; bash ops/verify.sh; bash ops/negative.sh'
