Loopback addresses let a process communicate through its local IP stack without sending the exchange over a physical network link. IPv4 reserves 127.0.0.0/8 for loopback; 127.0.0.1 is the familiar member. IPv6 uses ::1. On Linux, the relevant local networking context is a network namespace. Two processes on the same physical machine can therefore have different answers to what counts as their own loopback service.
When it breaks
A client inside a container often fails when configured with 127.0.0.1 for a database running on the host or in another container. The client has asked for its own namespace’s listener. The spelling is correct but the topology is wrong. A published host port, a container interface address, and another process’s loopback are different destinations that must be selected according to the deployment.
Loopback is not a guarantee that every probe succeeds. The target port still needs a listener, the intended address family must be available, and local networking policy can affect delivery. A successful IPv4 request does not prove that a service also listens on ::1. Conversely, a refused connection can simply mean there is no listener at that address and port in the caller’s namespace.
Local delivery also does not eliminate the IP stack. Loopback traffic still uses sockets, protocol processing, and local routing decisions; it merely avoids a physical trip through the external network. Timing a request to loopback cannot validate switch configuration, remote routing, an internet firewall, or the public service name’s certificate. Keep those paths separate when interpreting a health check.
Check it quickly
Source review, 2026-10-19: this revised exercise uses IPv4 only. Docker’s none driver omits ::1; a separate IPv6 experiment must establish and verify that configuration first. Technical and execution acceptance remain on hold pending an authorized complete run. Results below are expectations, not reproduced measurements.
Save as check-loopback.py and run python3 check-loopback.py on a permitted Linux Docker host with the pinned image already present. Its claimed Python version remains unverified. The script forbids image pulls and publishes no ports. A serves IPv4 port 19091; B initially has no listener:
import json,re,subprocess,sys,tempfile,time
from pathlib import Path
image='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
server=r'''
import json,os,socket,sys,time
deadline=time.monotonic()+120
with socket.socket(socket.AF_INET,socket.SOCK_STREAM) as listener:
listener.bind(('127.0.0.1',19091));listener.listen(8);listener.settimeout(.25)
while time.monotonic()<deadline:
try:connection,peer=listener.accept()
except TimeoutError:continue
with connection:
connection.settimeout(1)
connection.sendall(json.dumps({'service':sys.argv[1],'netns':os.readlink('/proc/self/ns/net')}).encode())
'''
probe=r'''
import json,os,socket,time
try:
deadline=time.monotonic()+2
with socket.create_connection(('127.0.0.1',19091),timeout=1) as stream:
payload=b''
while True:
remaining=deadline-time.monotonic()
if remaining<=0:raise TimeoutError('response deadline')
stream.settimeout(min(1,remaining))
piece=stream.recv(1024)
if not piece:break
payload+=piece;assert len(payload)<=4096,'oversize response'
answer=json.loads(payload)
except ConnectionRefusedError:answer={'error':'ECONNREFUSED'}
print(json.dumps({'netns':os.readlink('/proc/self/ns/net'),'interfaces':socket.if_nameindex(),
'answers':{'127.0.0.1':answer}}))
'''
def docker(*args,timeout=30):
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=timeout)
assert result.returncode==0,result.stderr
return result.stdout
def owned_id(path):
value=path.read_text().strip()
assert re.fullmatch('[0-9a-f]{64}',value),'missing or invalid owned container ID'
return value
def remove_owned(paths):
errors=[]
for path in paths:
try:
cid=owned_id(path)
print('Cleanup target: '+cid,file=sys.stderr)
docker('rm','-f',cid,timeout=10)
except Exception as error:
errors.append(f'{path.name}: {error}')
if errors:raise RuntimeError('Cleanup not confirmed; inspect the recorded IDs: '+'; '.join(errors))
def inspect(cid,timeout=5):
result=json.loads(docker('exec',cid,'python3','-c',probe,timeout=timeout))
assert set(result['answers'])=={'127.0.0.1'},'missing or extra probe address'
assert re.fullmatch(r'net:\[\d+\]',result['netns']),'invalid namespace ID'
assert any(interface[1]=='lo' for interface in result['interfaces']),'loopback missing'
return result
def expected(result,identity):
return {'127.0.0.1':{'service':identity,'netns':result['netns']}}
def wait_for(cid,identity):
deadline=time.monotonic()+5
while True:
remaining=deadline-time.monotonic()
if remaining<=0:raise AssertionError('listener did not become ready')
result=inspect(cid,timeout=remaining)
if time.monotonic()>deadline:raise AssertionError('readiness deadline exceeded')
if result['answers']==expected(result,identity):return result
assert result['answers']=={'127.0.0.1':{'error':'ECONNREFUSED'}},'unexpected service response'
time.sleep(min(.05,max(0,deadline-time.monotonic())))
with tempfile.TemporaryDirectory(prefix='xbcatm-loop-') as work:
paths=[];ids=[]
try:
for identity,command in [('A',['python3','-u','-c',server,'A']),
('B',['python3','-c','import time;time.sleep(120)'])]:
path=Path(work)/(identity+'.cid');paths.append(path)
docker('create','--cidfile',str(path),'--pull','never','--network','none',
'--memory','128m','--pids-limit','64',image,*command)
cid=owned_id(path);ids.append(cid)
print('Owned container: '+cid,file=sys.stderr)
docker('start',cid)
a=wait_for(ids[0],'A');b_before=inspect(ids[1])
assert a['netns']!=b_before['netns']
assert b_before['answers']=={'127.0.0.1':{'error':'ECONNREFUSED'}}
docker('exec','-d',ids[1],'python3','-u','-c',server,'B')
b_after=wait_for(ids[1],'B');a_after=inspect(ids[0])
assert b_after['netns']==b_before['netns']
assert a_after['netns']==a['netns']
assert a_after['answers']==expected(a_after,'A')
report={'A':a,'B_before_listener':b_before,'B_after_listener':b_after,'A_still_A':a_after}
finally:
remove_owned(paths)
print(json.dumps(report,sort_keys=True))
A probe inside A should reach 127.0.0.1 and read identity A. The response carries the server’s network namespace identifier, which must match the probe’s own. The expected address key and full response are checked explicitly; an empty answer map cannot count as readiness. Wrong identities, malformed responses, timeouts, or unexpected errors fail rather than masquerading as success.
Inside B, the same probe should receive ECONNREFUSED while A remains available, and B’s namespace identifier must differ. Literal IPv4 avoids DNS and localhost resolution ambiguity. Only connection refusal counts as the intended negative result; other failures stop the exercise. Readiness retries only the expected refusal, with a five-second budget and each command limited to the remaining time.
After starting B’s listener on the identical address and port, B should return B, while A still returns A. Repeated probes must retain their namespace identities. The inventory checks for lo without requiring it to be the sole interface. No claim about a particular kernel’s extra interfaces is established here, and no IPv6 exchange is attempted.
Servers use a 120-second loop budget; responses have a two-second deadline and size limit. Docker calls have timeouts; process creation can delay them. Cleanup uses full IDs from private CID files, tries every recorded target, and must succeed before final JSON. Interrupted creation or daemon failure can leave cleanup uncertain: retain diagnostics and investigate, without deleting guessed names. Run ordinary Python without optimization that removes assertions.
Put probes in the context they claim to test
Run an application-local probe in the application’s namespace when the question is whether its listener responds. Run a separate probe from a representative client network when the question is whether users can reach the public endpoint. An internal success and an external failure can both be correct observations because the requests traverse different routing, forwarding, and authentication paths.
Processes sharing a network namespace also share loopback, even if they otherwise have separate process or filesystem environments. Host networking and intentionally shared container networking change the boundary. Before replacing localhost in a connection string, identify where the client executes and which namespace owns the server. Avoid treating container names or machine ownership as substitutes for that information.
A loopback listener can also be exposed indirectly by a proxy or tunnel that forwards requests to it. Local address scope is therefore different from user authorization. Authenticate the service as its contract requires, including requests originating from another local process. Use loopback to express the intended local transport path, and verify the complete forwarding chain when remote access is deliberately provided.
Sources
- RFC 1122 Internet host requirements, the reserved IPv4 loopback address block, checked 2026-10-19.
- RFC 4291 IPv6 addressing, the IPv6 loopback address and its scope, checked 2026-10-19.
- Linux network_namespaces(7), isolation of interfaces, routes and socket port numbers, checked 2026-10-19.
- Linux ip(7), local IPv4 delivery and socket addressing, checked 2026-10-19.
- Docker none network driver, no IPv6 loopback configured by the driver, checked 2026-10-19.
- Docker create, separate creation and pull policy, checked 2026-10-19.
- Docker run CID files, per-run identity capture, checked 2026-10-19.
- Docker start, starting and attaching to an existing container, checked 2026-10-19.
- Docker remove, removal by ID, checked 2026-10-19.
- Python subprocess, timeout and return-code behavior, checked 2026-10-19.
- Python 3.11 socket, stream reads and timeouts, checked 2026-10-19.