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

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: true
  • app.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。

用户侧症状:

  1. CNAME 记录能解析,报表全记 MISS;
  2. 7 秒前的查询判 EXPIRED,200 秒前的反而 HIT —— 越新越容易过期;
  3. 带 DNSSEC 签名的条目(rrsig=1)过期时间口径不对;
  4. NXDOMAIN / NODATA 负缓存一个都命不中;
  5. stored_at 在未来的条目(时钟偏移)静默当有效数据用,skew 恒 0;
  6. CNAME 成环或链过长时 loop 恒 0;
  7. 同一 (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//4eff 只挂当前条目、eff_all 给全组擦同一值DNSSEC 折算口径错位
§6 负缓存 min(eff, NEG_TTL_S)effective_ttl 完全没吃 NEG_TTL_S3600s 的负缓存 TTL 不设上限
§4 `age<0 且age>CLOCK_SKEW_S` → 整组 SKEW
§5.1 查的 rrtype 无条目但有 CNAME → 先跟随classify 从不预跟随CNAME 不跟随,能解析的全记 MISS
§5.2 chain > MAX_CHAIN → LOOPif chain > 2 硬编码 + 目标固定 (data,"A")跳数口径错、环判不出
§5.4/5.5 NXDOMAIN/NODATA → NEG_HITreturn "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-closedcontract.py 用 DEFAULTS 兜底 + except OSError: pass缺键/未知键/越界静默跑绿
§10 判据表非空 + 输出 checksBAD_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

经验与避坑

  1. 「退出码恒 0」+「空判据表」是假绿的双保险。任何监控服务上线前先问两句: 进程真的会返回非 0 吗?判据表真的非空吗?BAD_FIELDS = () 这种写法比 bug 更危险—— 它让 bug 永远不被发现。
  2. 写健康检查不要 import 被测结算代码,否则被测代码的口径 bug 会被健康检查「继承」, 两边一起错、一起绿。独立按契约重算才是真体检。
  3. 退出码本身就是契约。0 正常 / 2 有真问题 / 3 契约不可用 必须分层, 上游告警系统就是看 rc 的。
  4. 配置加载 fail-closed:DEFAULTS 兜底 + except: pass 会让「缺 key / 未知 key / 越界」 全部静默跑绿。宁可启动失败(rc=3)也不要带着错配置出报表。
  5. 同一 (key, rrtype) 多版本一定按 stored_at 挑最新,别按 TTL、别按上游 id。 TTL 大小和版本新旧毫无关系 —— 这正是「越新越容易过期」的错因。
  6. age == effective_ttl 仍有效(严格大于才过期),age < 0 但在 CLOCK_SKEW_S 内 也视为有效,只有 |age| > CLOCK_SKEW_S 才整组 SKEW。
  7. RFC 2308 负缓存 TTL 有上限:min(SOA minimum, 本地 NEG_TTL_S),NXDOMAIN 与 NODATA 是两种负缓存,不能合并。
  8. CNAME 环检测既要数跳数也要记 visited:自指/互指环若只数跳数,会先跑满 MAX_CHAIN, 把 LOOP 拖成 EXPIRED 或 MISS。
  9. docker compose + COPY . /workspace 不是挂载:改 app/ 必须 up -d --build, 否则 exec 进去跑的还是旧镜像。
  10. 判定闸门要有牙: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'

版权声明

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

分享这篇文章