有用的名称为人和软件提供一个可以跨地址变化继续使用的引用,但这个引用需要明确范围与负责人。机器的本地主机名、服务 DNS 名称和实例标识可以有意不同。清楚命名有助于理解日志和部署,而不是假定一个标签自动建立系统中的所有其他身份。规划名称时,应先说明它标识的是哪类对象,以及希望保持稳定多久。
坏掉的样子
设置本地主机名,本身不会向权威区域发布 A 或 AAAA。注册可能由独立部署或网络管理软件完成,但那条流程必须实际存在。反过来,修改 DNS 地址也不会重命名内核的本地主机名。混淆两者,会产生本地标签看似正确、客户端却无法发现服务的机器。排障时应分别检查本地状态、注册动作和权威数据,不能用其中一个代替其他证据。
短名称依赖查询上下文。即使两台机器都使用正常 DNS 配置,service 在不同搜索域下仍可能指向不同目的地。复制的运行手册命令因此可能到达与作者测试不同的环境。配置中不能容忍歧义时,应使用完整目标 DNS 名称;交互操作使用短名时,也应保留解释它所需的上下文。名称看起来一样,并不意味着它在所有进程里具有相同范围。
人类可读的含义也有限制。app-prod-01 可以提示角色、环境与实例,却不是权限、归属或当前工作负载的权威证据。复用旧实例名称还可能混淆历史日志。服务名称与替换机器的生命周期不同时,应分别命名,并使用运维元数据记录变化事实,不要试图把所有信息都塞进主机名。方便阅读的前缀也不能成为授予访问权限的依据。
快速验证
将脚本保存为 check-hostnames.py,准备 Docker 后执行 python3 check-hostnames.py。固定环境使用 BIND 9.18.49、dnsmasq 2.90 和 Python 3.11,解析器及 NSS 配置都位于可丢弃容器中。本实验关闭转发缓存,使搜索域和地址变化可以直接观察,不必等待上一代缓存寿命;这项设置用于隔离变量,不代表所有生产客户端都应关闭缓存:
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
import socket
records='service 30 IN A 127.0.0.2\nservice.branch 30 IN A 127.0.0.3\n_echo._tcp 30 IN SRV 0 0 8080 service.lab.test.'
with lab(records) as (root,update,cache,queries):
cache(port=53,size=0)
nss=Path('/etc/nsswitch.conf');nss.write_text(re.sub(r'^hosts:.*$','hosts: dns',nss.read_text(),flags=re.M))
def search(domain):Path('/etc/resolv.conf').write_text('nameserver 127.0.0.1\nsearch '+domain+'\noptions timeout:1 attempts:1\n')
def address(name):
return json.loads(run('/usr/bin/python3','-c','import socket,json,sys;print(json.dumps(sorted({r[4][0] for r in socket.getaddrinfo(sys.argv[1],80,socket.AF_INET,socket.SOCK_STREAM)})))',name).stdout)
local_name=socket.gethostname();unpublished=query(local_name+'.lab.test.');assert unpublished.rcode()==dns.rcode.NXDOMAIN
lower=query('service.lab.test.');mixed=query('SeRvIcE.LaB.TeSt.');assert values(lower)==values(mixed)==['127.0.0.2']
search('lab.test');first=address('service');absolute_first=address('service.lab.test.');assert first==absolute_first==['127.0.0.2']
search('branch.lab.test');second=address('service');absolute_second=address('service.lab.test.');assert second==['127.0.0.3'] and absolute_second==first
update(records.replace('service 30 IN A 127.0.0.2','service 30 IN A 127.0.0.4'));changed=address('service.lab.test.')
assert changed==['127.0.0.4'] and socket.gethostname()==local_name
invalid=root/'invalid.zone';invalid.write_text((root/'zone').read_text()+'bad_name IN A 127.0.0.1\n')
check=run('named-checkzone','-k','fail','lab.test',str(invalid),check=False)
assert check.returncode!=0 and 'bad owner name' in check.stdout+check.stderr
assert values(query('_echo._tcp.lab.test.','SRV'),'SRV')==['0 0 8080 service.lab.test.']
print(json.dumps({'local_hostname_published_in_zone':False,'case_insensitive_A':values(mixed),
'short_name_by_search_domain':{'lab.test':first,'branch.lab.test':second},'absolute_name_unchanged_by_search':[absolute_first,absolute_second],
'DNS_address_after_change':changed,'local_hostname_unchanged':True,'invalid_host_owner_rejected':check.returncode,'underscore_SRV_owner_accepted':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)
脚本读取容器本地主机名,再查询该标签位于 lab.test 下的名称。由于没有发布相应区域记录,权威返回 NXDOMAIN。这说明当前环境缺少这项注册,不是在断言所有安装都不会自动注册主机名。对 service.lab.test 分别使用全小写与混合大小写查询,都返回 127.0.0.2,展示 DNS 中 ASCII 名称比较不区分大小写。
搜索域为 lab.test 时,新应用进程查询 service 得到 127.0.0.2;改为 branch.lab.test 后,同一短输入得到 127.0.0.3。而绝对名称 service.lab.test. 在两个上下文中都返回 127.0.0.2。末尾根点明确标记这个实验中的绝对 DNS 名称,使结果不依赖搜索列表补全。这个对照解释了短命令为何可能随运行环境改变目标。
随后权威把 service.lab.test 改到 127.0.0.4,新查询看到新地址,本地主机名却保持原样。这验证 DNS 映射与机器标签独立。它不证明已有连接自动移动,也不证明其他缓存立即刷新;那些行为有各自寿命,被有意排除在此次关闭缓存的比较之外。稳定名称的价值,是让调用方保留引用,而不是取消所有迁移与状态管理工作。
最后,A 记录候选所有者 bad_name 在 named-checkzone -k fail 下被判为不合格主机名称;有效的 _echo._tcp SRV 所有者仍被接受并可查询。这区分了严格主机地址名称检查与更广泛的 DNS 命名空间。服务标签使用下划线,不表示普通主机名适用同样规则,也不表示每个 DNS 标签都必须按主机标签验证。
按对象寿命与使用者选择名称
采用有文档、可预测的约定,提供足够信息区分环境和实例,同时避免在公开名称中放入敏感或频繁变化的细节。替换稳定服务名背后的机器时,应有意识地更新发现数据与服务配置,保留所需地址重叠期。稳定拼写便于调用方继续引用,但 TLS、路由和应用状态仍需提前准备。名称设计应支持这些操作,而不是掩盖它们的存在。
资产清单和故障证据中,应并列记录完整域名与本地主机名。显示时可以保留大小写,但不要把仅 ASCII 大小写不同的 DNS 名称当成两个服务。关联数据集时,也应按标识符实际规则规范化;任意应用标识或 URL 路径可能具有与 DNS 主机名不同的大小写语义。不能因为某一层比较不敏感,就对整条字符串执行同样转换。
部署前,依据消费这些名称的系统规则检查生成结果。DNS 标签、证书身份、操作系统主机名与服务发现标签彼此相关,却不是可互换的验证范围。内核接受一个名称,不等于它适合作普通互联网主机名。让人的理解和技术范围都保持明确,名称才能在日常操作、替换实例和复盘历史时持续提供帮助,而不成为另一种隐含假设。
延伸阅读
来源
- RFC 1123 Internet host requirements,主机名推荐语法与命名要求,已核验 2026-09-12。
- RFC 4343 DNS case insensitivity,DNS 中 ASCII 名称不区分大小写的比较,已核验 2026-09-12。
- Python 3.11 socket documentation,gethostname 与应用地址查询,已核验 2026-09-12。
- RFC 2782:DNS SRV 记录,服务与协议标签有意使用下划线前缀,已核验 2026-10-15。