CNAME 记录把一个别名与另一个 DNS 名称关联起来。通过别名解析地址时,解析器沿目标继续查找,直到获得请求的数据或遇到失败。这可以让公开主机名保持稳定,同时改变它背后的 DNS 依赖。它不会让浏览器跳转到另一个 URL,也不会替换应用正在请求的主机名;是否改变客户端可见地址,应由相应应用协议行为决定。
坏掉的样子
别名解析完全正常,目标服务器仍可能返回错误网站。HTTP 继续标识原始主机名,HTTPS 证书验证也关联所请求的服务身份。如果只配置目标名称对应的虚拟主机或证书,原别名请求未必能够工作。DNS 提供的是获得地址的路径,选中的服务器还必须接受用户和应用真正请求的名称。仅检查最终 IP 相同,无法验证这项应用契约。
CNAME 不能与普通 A、AAAA、TXT 等数据共存于同一个所有者名称。DNSSEC 元数据存在特定例外,但这不会让 CNAME 与 A 共存变得有效。普通区域顶点本来就需要 SOA 和 NS,因此在顶点放传统 CNAME 会产生冲突。服务商提供的别名展平等功能,应依据其实际文档理解,不能把界面里类似的名称直接当成标准 CNAME 规则发生变化。
别名链可能终止于不存在的数据,也可能形成循环。每个依赖还有自己的缓存寿命与管理边界;缩短别名 TTL,不会缩短以另一个记录集缓存的目标地址。同时,并非每增加一个别名都必然增加一次网络往返:服务器可能在同一回答中附带多段链,解析器也可能已经缓存相关数据。延迟分析应结合实际问答,而不是机械按链长计数。
快速验证
将脚本保存为 check-cname.py,准备 Docker 后执行 python3 check-cname.py。固定环境使用 BIND 9.18.49、dnsmasq 2.90 和小型 HTTP 服务。所有名称均属于隔离测试区域;解析配置修改和候选区域文件留在可丢弃容器内部,不改公共域名或宿主解析器。运行结束后,脚本清理自己创建的实例和临时镜像标签:
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
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import http.client,threading
records='alias 9 IN CNAME middle.lab.test.\nmiddle 12 IN CNAME target.lab.test.\ntarget 20 IN A 127.0.0.1\nloop-a 9 IN CNAME loop-b.lab.test.\nloop-b 9 IN CNAME loop-a.lab.test.'
class Handler(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def do_GET(self):
body=json.dumps({'host':self.headers['Host'],'path':self.path}).encode()
self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
with lab(records) as (root,update,cache,queries),ThreadingHTTPServer(('127.0.0.1',0),Handler) as server:
chain=query('alias.lab.test.');assert values(chain)==['127.0.0.1']
assert values(chain,'CNAME')==['middle.lab.test.','target.lab.test.']
assert [r.ttl for r in chain.answer]==[9,12,20]
invalid={}
for case,addition in [('same_owner','alias IN A 127.0.0.2'),('apex','@ IN CNAME target.lab.test.')]:
candidate=root/(case+'.zone');candidate.write_text((root/'zone').read_text()+addition+'\n')
checked=run('named-checkzone','lab.test',str(candidate),check=False)
assert checked.returncode!=0 and 'CNAME and other data' in checked.stdout+checked.stderr
invalid[case]={'returncode':checked.returncode,'diagnostic':'CNAME and other data'}
seen=set();name='loop-a.lab.test.';hops=[]
while name not in seen:
seen.add(name);answer=query(name,'CNAME');target=values(answer,'CNAME')[0];hops.append([name,target]);name=target
assert len(hops)==2 and name=='loop-a.lab.test.'
cache(port=53)
Path('/etc/resolv.conf').write_text('nameserver 127.0.0.1\noptions timeout:1 attempts:1\n')
nss=Path('/etc/nsswitch.conf');nss.write_text(re.sub(r'^hosts:.*$','hosts: dns',nss.read_text(),flags=re.M))
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
try:
client=http.client.HTTPConnection('alias.lab.test',server.server_port,timeout=2)
client.request('GET','/original');response=client.getresponse();body=json.loads(response.read());client.close()
assert response.status==200 and body=={'host':'alias.lab.test:'+str(server.server_port),'path':'/original'}
print(json.dumps({'chain':show(chain),'invalid_candidates':invalid,'loop_detected_by_client':hops,'HTTP':body,'HTTP_status':response.status},sort_keys=True))
finally:server.shutdown();thread.join(timeout=2)
"""
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)
查询 alias.lab.test 的地址,得到两条 CNAME 和最终 A:alias 指向 middle,middle 指向 target,target 的地址为 127.0.0.1。对应 TTL 分别为九秒、十二秒和二十秒。这个权威回答一次包含完整本地区域内的链,所以实验没有把两段别名描述成必然需要另外两次 DNS 交换,也没有把三个独立寿命压成一个统一 TTL。
两个独立候选区域分别制造无效组合:一个在别名所有者处增加 A,另一个在区域顶点增加 CNAME。named-checkzone 都以“CNAME and other data”拒绝。候选文件不会替换正在工作的有效区域。这种方式验证了配置限制,同时明确区分候选检查失败与已激活配置,避免把无效区域当作一种正常运行但效果特殊的配置。
另两个别名互相指向,形成圆环。示例客户端逐个明确查询 CNAME,记住访问过的名称,在两次跳转后检测到重复。这是对循环本身的有界验证,不是对所有递归解析器面对循环时的超时、重试次数或错误码进行测量。生产排障仍需查看实际解析器的诊断信息,不能用这个客户端循环检测器代替其完整实现。
最后,Python HTTP 客户端通过容器正常名称解析路径请求 alias.lab.test。服务器回显的 Host 仍为该别名加临时端口,路径为 /original,状态为 200。客户端没有被手工要求把目标名称代入 Host。这个结果证明解析可以沿 CNAME 链获得地址,同时保留原 HTTP 服务身份和请求路径;DNS 别名不会自动产生浏览器重定向。
移动依赖,同时维持服务契约
切换别名前,先让目标服务接受原主机名,按需要准备路由和证书,并验证两个地址族及服务商域名归属检查。指向外部服务的别名,还依赖该服务持续保留正确资源;悬空目标可能涉及归属与可用性问题,不能只因公开名称没变就认为没有风险。验收应覆盖应用请求,而不止 DNS 链末尾出现了一个地址。
迁移计划应考虑所有相关缓存记录集。修改别名目标与修改旧目标地址是不同操作。对照权威回答、递归回答和应用流量,才能区分缓存中的旧链段与新目的地配置错误。按所需重叠期保留旧端点,不应假定公开名称稳定就意味着切换立即完成。回滚同样会受到已经缓存的新别名关系影响,需要保留可服务的过渡路径。
让别名链保持可理解,为每段依赖明确负责人,并检查目标缺失和循环。如果目标是改变客户端看到的 URL,应使用合适的 HTTP 重定向;如果目标是建立 DNS 别名关系,则使用 CNAME 并单独验证应用。名称、地址和应用身份之间的关系应当由实际配置与请求结果共同说明,而不是依靠“指向另一台机器”的模糊描述。
延伸阅读
来源
- RFC 1034 domain names,别名处理、规范名称与循环处理,已核验 2026-09-12。
- RFC 2181 DNS clarifications,CNAME 共存限制与记录集 TTL 规则,已核验 2026-09-12。
- BIND 9.18.49 tools,使用 named-checkzone 验证候选区域数据,已核验 2026-09-12。