DNS 迁移与回滚文章封面,包含依次相连的 OBSERVE、DECIDE 与 ACT 面板。

DNS 迁移应提前降低 TTL,并保留回滚缓存窗口

DNS TTL 表示一条资源记录通常可以在缓存中保留多久,之后需要刷新。修改权威记录会改变未来回答,却不会向所有曾经存储该数据的解析器发送失效通知。因此,一次迁移既包含权威更新,也包含一段客户端可能合法发现不同地址的时期。规划服务可用性时,需要同时考虑新答案和仍然存在的旧答案,而不是只看控制台保存成功。 坏掉的样子 在修改地址的同一时刻降低 TTL,并不会缩短此前缓存回答的寿命。保存了旧长 TTL 的解析器尚未获知新短 TTL。此时立即移除旧服务,可能让那些客户端失败,即使所有权威查询都已返回新地址。问题来自服务重叠期与尚未消失的发现状态不匹配;反复检查权威端的新值正确,并不能解决使用旧缓存的客户端请求。 回滚也具有同样的不对称性。把区域改回去,不会从已经学到新地址的缓存里撤回该地址。短暂正常的新目的地,可能在回滚命令完成后继续收到请求。应让回滚目标与刚发布过的目标在相关重叠期内都可用,或者明确客户端访问任何一个时如何获得可接受结果。DNS 回滚不是立即停止新地址流量的开关。 也不要把每层缓存的原始 TTL 简单相加,作为传播时间。普通转发缓存返回剩余寿命,下游再对收到的值继续计时;不同解析器群体则可能在不同时间填充缓存。应用自有缓存、长连接、权威同步延迟以及配置的陈旧回答策略,还会让单个 TTL 无法成为端到端完成保证。应把这些行为分别调查,不能用一个“传播中”笼统覆盖。 快速验证 将脚本保存为 check-dns-move.py,准备 Docker 后执行 python3 check-dns-move.py。固定环境以 BIND 9.18.49 为权威,以 dnsmasq 2.90 为一个转发缓存,二者运行在隔离容器回环接口上。程序只修改自己的临时区域,每次递增序列号并确认权威重载完成,再观察缓存。整个过程不改变公共 DNS,也不调整系统时间: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,uuid recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends bind9=1:9.18.49-1~deb12u2 bind9-dnsutils=1:9.18.49-1~deb12u2 dnsmasq-base=2.90-4~deb12u2 python3-dnspython=2.3.0-1 && rm -rf /var/lib/apt/lists/*\nCMD ["/usr/bin/python3"]\n' program=r""" from contextlib import ExitStack,contextmanager from pathlib import Path from tempfile import TemporaryDirectory import json,re,signal,subprocess,time import dns.exception,dns.message,dns.query,dns.rcode,dns.rdatatype def run(*args,check=True): result=subprocess.run(args,capture_output=True,text=True,timeout=15) if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr) return result def query(name,kind='A',port=1053): return dns.query.udp(dns.message.make_query(name,kind),'127.0.0.1',port=port,timeout=1) def values(response,kind='A'): wanted=dns.rdatatype.from_text(kind) return [item.to_text() for rrset in response.answer if rrset.rdtype==wanted for item in rrset] def show(response): def section(records):return [{'name':str(rr.name),'type':dns.rdatatype.to_text(rr.rdtype),'ttl':rr.ttl,'data':[item.to_text() for item in rr]} for rr in records] return {'rcode':dns.rcode.to_text(response.rcode()),'answer':section(response.answer),'authority':section(response.authority)} def wait(predicate,timeout=15): deadline=time.monotonic()+timeout while time.monotonic()<deadline: try: if predicate():return except (OSError,dns.exception.Timeout):pass time.sleep(.05) raise AssertionError('DNS fixture condition did not become true') def stop(process): if process.poll() is None: process.terminate() try:process.wait(timeout=5) except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5) @contextmanager def lab(records,soa_ttl=8,minimum=4,extra=''): with TemporaryDirectory(prefix='xbcatm-dns-') as directory,ExitStack() as stack: root=Path(directory);zone=root/'zone';conf=root/'named.conf';log=root/'named.log';serial=1 def save(body): zone.write_text(f'$ORIGIN lab.test.\n$TTL 30\n@ {soa_ttl} IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {minimum})\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\n'+body+'\n') run('named-checkzone','lab.test',str(zone)) save(records) conf.write_text(f'''options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}}; listen-on-v6 {{none;}}; recursion no; dnssec-validation no; notify no; querylog yes; max-cache-size 8m; pid-file "{root}/named.pid"; session-keyfile none; {extra} }}; controls {{}}; zone "lab.test" {{ type primary; file "{zone}"; }}; ''') run('named-checkconf',str(conf)) output=stack.enter_context(log.open('w')) authority=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT) stack.callback(stop,authority) def ready(): assert authority.poll() is None,log.read_text() response=query('lab.test.','SOA');return response.rcode()==0 and response.answer[0][0].serial==serial wait(ready) def update(body): nonlocal serial serial+=1;save(body);authority.send_signal(signal.SIGHUP);wait(ready) def cache(port=1054,upstream=1053,size=150,options=()): logfile=root/('cache-'+str(port)+'.log');stream=stack.enter_context(logfile.open('w')) command=['dnsmasq','--no-daemon','--conf-file=/dev/null','--no-resolv','--no-hosts','--bind-interfaces', '--listen-address=127.0.0.1','--port='+str(port),'--server=127.0.0.1#'+str(upstream), '--cache-size='+str(size),'--log-queries','--log-facility=-',*options] process=subprocess.Popen(command,stdout=stream,stderr=subprocess.STDOUT);stack.callback(stop,process) def cache_ready(): assert process.poll() is None,logfile.read_text() return query('lab.test.','SOA',port).rcode()==0 wait(cache_ready);return process,logfile def queries(name,kind='A'): return sum(bool(re.search(r'query: '+re.escape(name.rstrip('.'))+r' IN '+kind+r'\b',line)) for line in log.read_text().splitlines()) yield root,update,cache,queries def record(address,ttl):return f'move {ttl} IN A {address}' with lab(record('127.0.0.2',8)) as (_,update,cache,queries): cache();before=queries('move.lab.test');started=time.monotonic();initial=query('move.lab.test.',port=1054) wait(lambda: queries('move.lab.test')==before+1) assert values(initial)==['127.0.0.2'] and initial.answer[0].ttl==8 update(record('127.0.0.2',3));lowered=query('move.lab.test.');cached_after_lower=query('move.lab.test.',port=1054) assert lowered.answer[0].ttl==3 and cached_after_lower.answer[0].ttl>3 update(record('127.0.0.3',3));new_authority=query('move.lab.test.');old_cache=query('move.lab.test.',port=1054) assert values(new_authority)==['127.0.0.3'] and values(old_cache)==['127.0.0.2'] time.sleep(3.3);after_new_ttl=query('move.lab.test.',port=1054) assert values(after_new_ttl)==['127.0.0.2'] observations=[] def changed(): answer=query('move.lab.test.',port=1054);observations.append(show(answer));return values(answer)==['127.0.0.3'] wait(changed,timeout=8);migration_delay=round(time.monotonic()-started,3) update(record('127.0.0.2',3));rollback_authority=query('move.lab.test.');rollback_cache=query('move.lab.test.',port=1054) assert values(rollback_authority)==['127.0.0.2'] and values(rollback_cache)==['127.0.0.3'] wait(lambda: values(query('move.lab.test.',port=1054))==['127.0.0.2'],timeout=6) print(json.dumps({'initial':show(initial),'lowered_authority':show(lowered),'cache_after_lower':show(cached_after_lower), 'after_address_change':{'authority':show(new_authority),'cache':show(old_cache)},'after_new_TTL_elapsed':show(after_new_ttl), 'new_address_observed_after_seconds':migration_delay,'rollback':{'authority':show(rollback_authority),'cache':show(rollback_cache)}, 'rollback_eventually_observed':True},sort_keys=True)) """ name='xbcatm-dns-'+uuid.uuid4().hex[:10] def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs) try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'Dockerfile').write_text(recipe) built=docker('build','-t',name,str(root));assert built.returncode==0,built.stderr result=docker('run','--rm','-i','--name',name,'--network','none','--memory','384m','--pids-limit','128',name, '/usr/bin/python3','-',input=program) assert result.returncode==0,result.stderr print(json.dumps(json.loads(result.stdout),sort_keys=True)) finally: docker('rm','-f',name);docker('image','rm',name) 开始时,move.lab.test 指向 127.0.0.2,TTL 为八秒。经 dnsmasq 查询后存入该回答,权威日志确认发生一次上游查询。程序随后保持地址不变,把权威 TTL 改为三秒。直接查询看到三秒,先前填充的缓存却仍显示大于三秒的剩余 TTL。这说明修改源数据没有重写现有缓存状态,旧回答仍按照收到时的信息计时。 ...

六月 18, 2026 · 2 分钟 · XB Field Notes
一条 DNS 策略记录授权一个证书颁发机构,同时拒绝另一个。

CAA 记录应授权你实际使用的签发者

CAA 收窄了证书签发策略,但一条错误的记录会像过期证书一样有效地阻断续期。

六月 17, 2026 · 1 分钟 · XB Field Notes