An SRV record describes a service target using priority, weight, port, and a target hostname. Its owner normally combines a service label, transport label, and domain, such as _echo._tcp.lab.test. A client that understands that service can query SRV, obtain target addresses through A or AAAA, and connect to the advertised port. Ordinary hostname lookup does not automatically perform this application-specific discovery sequence.
When it breaks
Publishing SRV does not make every existing client use it. The application needs a defined service label and supported discovery behavior. A browser or library expecting an ordinary hostname and configured port may never ask for SRV. A successful DNS response to a manual SRV query therefore cannot establish that the application’s endpoint selection has changed or that redeploying configuration is now unnecessary.
Priority and weight are not interchangeable. Lower numeric priority is preferred among reachable targets. Weight influences selection among records with the same priority; a large weight on a higher-priority-number backup does not override an available lower-numbered target. Simply sorting every record by weight or selecting the first wire-order entry can reverse the service owner’s intended primary and backup relationship.
Discovery also does not prove health. A target can remain published after its listener stops, and cached SRV and address RRsets have independent lifetimes. A client must apply connection deadlines and appropriate retry behavior. Retrying a read after connection refusal is different from repeating an operation whose side effects might already have occurred, so endpoint failover must remain consistent with the application protocol.
Check it quickly
Save this as check-srv-discovery.py and run python3 check-srv-discovery.py with Docker. The pinned BIND 9.18.49 environment publishes a test _echo._tcp service. Two real HTTP listeners provide identifiable read-only responses on different temporary ports. HTTP is used as the fixture’s observable payload; the example is a custom SRV-aware client, not a claim that generic HTTP clients implement this service label:
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)
The first target has priority 10 and weight 1; the backup has priority 20 and weight 65535. Each advertises its actual listener port, and both target names resolve to loopback. The client reads SRV, orders these distinct priority groups, resolves each target, and connects using the record’s port. With both listeners healthy, it reaches only the first target and receives the primary identity.
The program stops the primary listener while leaving DNS unchanged. A fresh SRV query still contains both records. Reusing the previously discovered candidate set, the client attempts the primary, encounters connection failure, and then successfully contacts the backup. The result records both attempted names and the selected port. DNS did not remove the failed target; the client’s connection handling performed the transition.
A separate service has one SRV record whose target is the root name, .. RFC 2782 defines that form as explicit service unavailability at the domain, so it is not an instruction to resolve the root and connect there. Meanwhile, an ordinary A query for lab.test returns no address despite the _echo._tcp records being present. The query type and discovery name are part of the contract.
Match the client algorithm to the published record set
This fixture intentionally has one target per priority group. Its priority ordering is sufficient for those records and does not implement weighted ordering among equal-priority peers. A general client needs the complete selection behavior appropriate to its protocol, including equal-priority weights and the unavailable-service case. Test those branches with the actual library rather than assuming an SRV-capable label implies full conformance.
SRV targets should name hosts with address records and must not be aliases under RFC 2782’s rules. Check the target’s A and AAAA data, port listener, and required service identity. A returned address alone does not verify TLS certificates, authentication, or application readiness. Some protocols define additional discovery records or identity rules; their documented behavior takes precedence over a generic example’s convenient layout.
Refresh discovery according to the relevant cache lifetimes and deployment policy. Changing SRV does not withdraw a cached target, and changing a target address does not modify an already established connection. During planned removal, account for retained candidates and drain existing work. Observe authoritative publication, resolver answers, and actual connection attempts to distinguish stale discovery from a reachable server rejecting the application operation.
Keep ownership of service labels and update permissions explicit. DNS-based discovery can reduce hard-coded endpoint configuration, but it shifts responsibility into records and client selection logic. Verify a normal request, a failed preferred endpoint, and the resulting alternate connection. Those observations demonstrate the useful behavior more directly than merely confirming that a service record exists in a zone file.
Further reading
- CAA records should authorize the issuer you actually use
- DNS changes need a cache-aware rollback window
Sources
- RFC 2782 DNS SRV records, priority, weight, port, target, and explicit service unavailability, checked 2026-09-12.
- RFC 1035 domain names, address records and DNS caching, checked 2026-09-12.
- BIND 9.18.49 reference, authoritative zone data and service records, checked 2026-09-12.