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 发现可以减少硬编码端点配置,同时把责任转移到记录和客户端选择逻辑中。应验证普通请求、首选端点失败,以及之后的替代连接。这样的证据比只确认区域文件里存在服务记录,更直接地证明系统实现了有用行为,也能说明缓存、优先级与重试策略究竟如何共同影响请求结果。

延伸阅读

来源