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,按不同优先级组排序,再解析目标并连接记录端口。两个监听都健康时,只访问第一目标并收到主节点身份,说明备机的大权重不会跨越优先级组改变这次选择。
程序随后停止主监听,保持 DNS 不变。新的 SRV 查询仍包含两条记录。客户端复用此前发现的候选集合,先尝试主节点,遇到连接失败后再成功访问备机。结果记录两个尝试名称和最终端口。失败目标没有被 DNS 摘除,转换动作来自客户端连接处理。这个对照区分了发现数据更新与利用现有候选项完成一次故障转移。
另一个服务只有一条 SRV,目标为根名称 .。RFC 2782 将这种形式定义为该域明确不提供服务,而不是要求解析根并连接它。与此同时,普通 A 查询 lab.test 没有地址回答,尽管 _echo._tcp 记录存在。这说明查询类型和服务发现名称都是契约的一部分,不能只因同属一个域,就假定不同查询会自动得到等价服务位置。
让客户端算法匹配所发布的集合
本例有意让每个优先级组只有一个目标,因此排序足以处理这些记录,没有实现同组内的加权顺序。通用客户端需要遵循协议完整选择行为,包括同优先级权重和明确不可用案例。应使用真实库测试相应分支,不能看到“支持 SRV”的标签就假定完全符合标准。示例的边界被明确限定,避免简化代码被误当成完整通用客户端。
按照 RFC 2782,SRV 目标应是具有地址记录的主机名称,不能是别名。检查目标 A、AAAA、端口监听和所需服务身份。地址返回本身不验证 TLS 证书、身份认证或应用就绪状态;有些协议还规定额外发现记录或身份规则,应以其文档为准。不能为了复用一个方便布局,忽略真正协议对于主机名、端口和认证对象的定义。
按相关缓存寿命与部署策略刷新发现结果。修改 SRV 不会撤回缓存目标,修改目标地址也不会改变既有连接。计划移除节点时,需要考虑保留候选项的调用方,并排空现有工作。结合权威发布、解析器回答和实际连接尝试,才能区分旧发现数据与已经可达但拒绝业务操作的服务器;两类问题需要不同的处理路径。
明确服务标签归属和记录修改权限。DNS 发现可以减少硬编码端点配置,同时把责任转移到记录和客户端选择逻辑中。应验证普通请求、首选端点失败,以及之后的替代连接。这样的证据比只确认区域文件里存在服务记录,更直接地证明系统实现了有用行为,也能说明缓存、优先级与重试策略究竟如何共同影响请求结果。
延伸阅读
来源
- RFC 2782 DNS SRV records,优先级、权重、端口、目标与明确服务不可用,已核验 2026-09-12。
- RFC 1035 domain names,地址记录与 DNS 缓存,已核验 2026-09-12。
- BIND 9.18.49 reference,权威区域数据与服务记录,已核验 2026-09-12。