回环地址让进程通过本地 IP 协议栈通信,无须把交换送到物理网络链路。IPv4 为回环保留 127.0.0.0/8,其中最熟悉的是 127.0.0.1;IPv6 使用 ::1。在 Linux 中,相关的本地网络范围是网络命名空间。因此,同一台物理机器上的两个进程,可能对“自己的回环服务”具有不同答案,不能只按机器归属判断。

坏掉的样子

容器内客户端把数据库地址配置为 127.0.0.1,却希望访问宿主或另一容器时,经常失败。客户端实际请求的是自己命名空间中的监听,地址拼写没有错,拓扑理解却错了。宿主发布端口、容器接口地址和另一进程的回环,是不同目的地。选择连接配置前,应先确定客户端运行在哪里,以及真正服务由哪个网络范围提供。

回环也不保证任何探测都成功。目标端口需要监听,预期地址族需要可用,本地网络策略还可能影响交付。IPv4 请求成功不代表服务也监听 ::1;连接被拒绝,则可能只是调用方命名空间内对应地址与端口没有服务。不能因回环无须外部网线,就把协议栈、监听状态和访问规则当成始终有效的前提。

本地交付没有消除 IP 协议栈。回环流量仍经过套接字、协议处理和本地路由决策,只是没有沿外部物理网络绕行。测量一次回环请求,不能验证交换机配置、远程路由、互联网防火墙或公共服务名称的证书。解释健康探测时,应把这些路径分开,否则内部服务正常可能被误写成所有用户都能成功访问。

预期 IPv4 实验把 127.0.0.1:19091 放在两个隔离网络命名空间,各自服务本地调用方。IPv6 ::1 仍是需要独立配置的通用概念,本脚本不验证它。技术与执行验收等待实际操作验证。
相同地址,不同本地范围。仅为 IPv4 预期实验,IPv6 需要独立配置。不声称已有复现运行或通过技术验收。

快速验证

2026-10-19 源码审阅说明:修订后的实验仅使用 IPv4。Docker 的 none 驱动不配置 ::1,独立的 IPv6 实验必须先建立并核验这项配置。技术与执行验收仍暂缓,等待获准环境中的完整运行。下文均为预期结果,不是本次复现的测量,静态修订并不证明实际就绪。

保存为 check-loopback.py,在获准的 Linux Docker 主机上运行 python3 check-loopback.py,固定镜像须事先已存在。其宣称的 Python 版本尚未重新确认。脚本禁止自动拉取镜像,不发布宿主端口。A 在一万九千零九十一端口提供 IPv4 服务;B 起初没有监听:

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 内探测 127.0.0.1,预期读到身份 A。响应携带服务器的网络命名空间标识,必须与探测进程自己的标识一致。程序显式检查预期地址键和完整响应,因此空的答案映射不能被当成就绪。身份错误、响应格式损坏、超时和非预期错误都使实验失败,不会伪装为成功;只有符合当前检查目标的返回才被认可。

在 B 内,相同探测预期得到 ECONNREFUSED,而 A 仍然可用;B 的命名空间标识必须不同。字面 IPv4 地址避开 DNS 与 localhost 解析歧义。只有连接拒绝才算预期负向结果,其他故障会停止实验。就绪检查只对预期拒绝重试,使用五秒预算,每次命令受剩余时间约束,不能把其他错误一概解释成服务尚未启动。

在 B 启动相同地址和端口的监听后,预期 B 返回 B,而 A 仍返回 A。重复探测还必须保持各自命名空间标识一致。接口清单要求存在 lo,但不要求它是唯一接口。本次没有建立某个内核创建额外接口的实测结论,也不尝试 IPv6 交换。因此,这个实验仅检查 IPv4 端点归属,不能把预期结果扩大成双栈、外部路由或公网访问均已验证。

服务器循环预算为一百二十秒;响应设有两秒总期限和大小限制。Docker 调用设置超时,但进程创建可能延迟超时生效。清理使用私有 CID 文件中的完整容器标识,对每个已记录目标都尝试执行,并在全部成功后才打印最终 JSON。创建中断或守护进程故障仍可能使清理不确定:应保留诊断并调查,不能删除猜测名称对应的容器。请使用普通 Python,不要启用会移除断言的优化模式;脚本报错时,不应把部分输出写成完整实验成功。

让探测运行在它声称检查的范围内

如果问题是应用自身监听能否响应,应在应用的网络命名空间执行本地探测。如果问题是用户能否到达公网端点,则应从代表性客户端网络另行探测。内部成功与外部失败可以同时成立,因为请求经过不同路由、转发和认证路径。应在监控名称与说明中明确这些差异,避免把局部结果扩大为完整可用性。

共享网络命名空间的进程也共享回环,即使它们具有不同进程或文件系统环境。宿主网络模式,以及有意共享的容器网络配置,都会改变边界。修改连接字符串里的 localhost 前,先查明客户端执行位置和服务所属范围。容器名称、进程名称或机器拥有者,都不能代替实际命名空间信息。

回环监听还可以被代理或隧道间接暴露:远端请求由中间组件转发到本地服务。因此,本地地址范围与用户授权不同。服务仍应按契约验证访问者,包括另一条本地进程发来的请求。用回环表达预期本地传输路径,在确实提供远程访问时检查完整转发链,才能准确说明哪些客户端最终能够使用服务。

来源