DNS 发布服务候选项,支持 SRV 的客户端按优先级连接所公布的端点。

SRV 提供目标和端口,客户端必须理解其选择规则

SRV 通过优先级、权重、端口和目标主机名描述服务位置。它的所有者通常组合服务标签、传输标签与域名,例如 _echo._tcp.lab.test。理解该服务的客户端可以先查 SRV,再通过 A 或 AAAA 取得目标地址,连接记录给出的端口。普通主机名查询不会自动执行这套应用相关的发现流程,服务名与查询方式必须由调用方契约明确。 坏掉的样子 发布 SRV 不会让所有现有客户端自动采用它。应用需要规定服务标签,并实际支持相应发现行为。只接受普通主机名和配置端口的浏览器或库,可能根本不询问 SRV。因此,手工查询 SRV 成功,不能证明应用已经改变端点选择,也不能据此宣布今后不再需要更新配置。验证应沿真实调用方进行,不能止于区域里已有记录。 优先级和权重不是可互换字段。在可达目标中,较低数值优先级更优先;权重只影响相同优先级记录之间的选择。较高数值备机即使具有很大权重,也不能越过可用的低数值目标。把所有记录按权重统一排序,或直接采用报文中的首项,都可能反转服务所有者设计的主备关系。解释结果前,应确认客户端是否正确使用这些字段。 发现也不证明健康。监听停止后,目标仍可继续被发布,SRV 与目标地址记录集还具有各自缓存寿命。客户端需要连接截止时间与适当重试策略。连接被拒绝后重试读取,与重复可能已产生副作用的操作不同;端点故障转移必须符合应用协议,不能因为还有候选项,就无条件把每个业务动作再执行一遍。 快速验证 将脚本保存为 check-srv-discovery.py,准备 Docker 后执行 python3 check-srv-discovery.py。固定 BIND 9.18.49 环境发布测试 _echo._tcp 服务,两个真实 HTTP 监听在不同临时端口提供可识别的只读响应。HTTP 只是实验中便于观察的载荷;示例是自定义 SRV 客户端,不代表通用 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 @contextmanager def named_process(root,config): root.mkdir(exist_ok=True);conf=root/'named.conf';log=root/'named.log';conf.write_text(config) run('named-checkconf',str(conf)) with log.open('w') as output: process=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT) try: def ready(): assert process.poll() is None,log.read_text() return re.search(r'\brunning\s*$',log.read_text(),re.M) is not None wait(ready);yield process,log finally:stop(process) from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer import http.client,threading class Handler(BaseHTTPRequestHandler): def log_message(self,*args):pass def do_GET(self): body=self.server.identity.encode();self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body) servers=[];threads=[] try: for identity in ('primary','backup'): server=ThreadingHTTPServer(('127.0.0.1',0),Handler);server.identity=identity thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start();servers.append(server);threads.append(thread) records=f'''_echo._tcp 30 IN SRV 10 1 {servers[0].server_port} first.lab.test. _echo._tcp 30 IN SRV 20 65535 {servers[1].server_port} second.lab.test. _absent._tcp 30 IN SRV 0 0 0 . first 30 IN A 127.0.0.1 second 30 IN A 127.0.0.1''' with lab(records) as (_,update,cache,queries): srv=query('_echo._tcp.lab.test.','SRV');entries=sorted(srv.answer[0],key=lambda r:r.priority) assert [r.priority for r in entries]==[10,20] and [r.weight for r in entries]==[1,65535] def discover(): attempts=[] for record in entries: addresses=values(query(str(record.target))) for address in addresses: client=http.client.HTTPConnection(address,record.port,timeout=.5) try: attempts.append(str(record.target));client.request('GET','/');response=client.getresponse();body=response.read().decode() assert response.status==200;return {'identity':body,'attempted_targets':attempts,'selected_port':record.port} except OSError:continue finally:client.close() raise AssertionError('all service endpoints failed') first=discover();assert first['identity']=='primary' and first['attempted_targets']==['first.lab.test.'] servers[0].shutdown();servers[0].server_close() assert len(query('_echo._tcp.lab.test.','SRV').answer[0])==2 failed_over=discover();assert failed_over['identity']=='backup' and len(failed_over['attempted_targets'])==2 absent=query('_absent._tcp.lab.test.','SRV');assert str(absent.answer[0][0].target)=='.' ordinary=query('lab.test.','A');assert ordinary.rcode()==0 and not ordinary.answer print(json.dumps({'SRV':show(srv),'healthy_selection':first,'after_primary_stopped':failed_over, 'ordinary_A_lookup':show(ordinary),'unavailable_service':show(absent)},sort_keys=True)) finally: for server in servers:server.shutdown();server.server_close() for thread in threads: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) 第一目标优先级为十、权重为一,备机优先级为二十、权重为六万五千五百三十五。两者分别公布真实监听端口,目标名都解析到回环地址。客户端读取 SRV,按不同优先级组排序,再解析目标并连接记录端口。两个监听都健康时,只访问第一目标并收到主节点身份,说明备机的大权重不会跨越优先级组改变这次选择。 ...

三月 13, 2027 · 3 分钟 · XB Field Notes