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。这说明修改源数据没有重写现有缓存状态,旧回答仍按照收到时的信息计时。

权威接着把地址改为 127.0.0.3,TTL 仍为三秒。直接查询立即看到新地址,缓存则继续返回 127.0.0.2。再等待三点三秒后,旧地址仍存在于缓存中。实验有意等待超过新配置 TTL 的时间,证明不能拿新数字作为更早那份回答的到期时间。只依据“已经等了新 TTL”就关闭旧端点,会忽略真实存在的旧寿命。

最后一次实测约在首次填充后的七点三五秒返回 127.0.0.3。整数秒到期与进程调度影响观测间隔,因此不能把八秒配置当作跨环境的精密秒表。关键是现有副本持续超过新三秒窗口。随后脚本将权威回滚至 127.0.0.2,立即查询缓存仍得到 127.0.0.3;等这一代缓存过期后,才获得恢复的地址。前后两次对照说明,切换和回滚都会留下已有回答,而不是只有正向迁移需要等待。

在改变发现数据前准备服务重叠期

先在旧目的地仍健康时降低 TTL,确认相关权威服务器数据一致,再等待旧的较长缓存寿命耗尽后进行计划迁移。观察对服务重要的解析器和客户端群体。一次公共解析器查询只能采样一种视图,不能证明所有用户已持有相同记录。若权威副本尚未同步完成,等待起点也不能简单取最早一次编辑保存的时间。

切换时,通过真实主机名和所需协议验证新端点,并让旧端点在计划重叠期内继续服务或安全转发流量。地址发现、TLS 身份、应用状态与连接排空分别承担不同责任。DNS 变更不能修复新服务器缺失的证书,也不会把旧进程上已经建立的连接移动过去。验收应覆盖这些真实行为,而不是只有解析结果已经变化。

第一次测试成功后仍保留可用回滚窗口。记录新地址何时成为权威、使用何种 TTL,以及哪些端点必须继续存在。客户端仍访问旧地址时,先确认它是重新查询、使用缓存,还是复用旧连接。这三类观察对应不同处理和时间预期;不能因为新 DNS 查询正确,就认定旧连接一定异常,也不能为了消除尾部流量立即删除仍有调用方的服务。

RFC 8767 描述刷新失败时受控提供过期数据的行为。本例没有启用该功能,实验中的旧回答仍处于普通缓存有效期内。遇到过期回答,应先检查真实解析器策略,再判断是否违反预期。围绕明确的缓存代次与端点重叠期制定迁移计划,比把区域编辑器的保存时间当作全球完成时刻更可靠,也更便于制定和验证回滚步骤。

延伸阅读

来源