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

坏掉的样子

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

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

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

快速验证

将脚本保存为 check-loopback.py,准备 Docker 后执行 python3 check-loopback.py。程序用相同固定 Python 3.11.16 镜像创建两个一次性容器,均关闭 Docker 网络接入且不发布宿主端口。容器 A 在一万九千零九十一端口启动 IPv4 和 IPv6 监听,容器 B 起初没有该端口的服务。所有测试完成后,两个容器都会被移除:

import json,subprocess,time,uuid

image='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
names=['xbcatm-loop-'+uuid.uuid4().hex[:10] for _ in range(2)]
server=r'''
import json,os,selectors,socket,sys
selector=selectors.DefaultSelector()
for family,address in [(socket.AF_INET,'127.0.0.1'),(socket.AF_INET6,'::1')]:
    listener=socket.socket(family,socket.SOCK_STREAM)
    if family==socket.AF_INET6:listener.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1)
    listener.bind((address,19091));listener.listen(8);selector.register(listener,selectors.EVENT_READ)
print('ready',flush=True)
while True:
    for key,events in selector.select():
        connection,peer=key.fileobj.accept()
        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
answers={}
for address in ['127.0.0.1','::1']:
    try:
        with socket.create_connection((address,19091),timeout=1) as stream:
            pieces=[]
            while True:
                piece=stream.recv(1024)
                if not piece:break
                pieces.append(piece)
            answers[address]=json.loads(b''.join(pieces))
    except ConnectionRefusedError:answers[address]={'error':'ECONNREFUSED'}
print(json.dumps({'netns':os.readlink('/proc/self/ns/net'),'interfaces':socket.if_nameindex(),'answers':answers}))
'''
def docker(*args):
    result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=30)
    assert result.returncode==0,result.stderr
    return result.stdout
def inspect(name):return json.loads(docker('exec',name,'python3','-c',probe))
def wait_for(name,identity):
    deadline=time.monotonic()+5
    while time.monotonic()<deadline:
        result=inspect(name)
        if all(answer.get('service')==identity for answer in result['answers'].values()):return result
        time.sleep(.05)
    raise AssertionError('listener did not become ready')
try:
    for name,command in [(names[0],['python3','-u','-c',server,'A']),
                         (names[1],['python3','-c','import time;time.sleep(120)'])]:
        docker('run','-d','--rm','--name',name,'--network','none','--memory','128m','--pids-limit','64',image,*command)
    a=wait_for(names[0],'A');b_before=inspect(names[1])
    assert a['netns']!=b_before['netns']
    assert all(v=={'error':'ECONNREFUSED'} for v in b_before['answers'].values())
    docker('exec','-d',names[1],'python3','-u','-c',server,'B')
    b_after=wait_for(names[1],'B');a_after=inspect(names[0])
    assert all(v.get('service')=='A' for v in a_after['answers'].values())
    for result in [a,b_after,a_after]:
        assert all(answer['netns']==result['netns'] for answer in result['answers'].values())
    assert all(any(interface[1]=='lo' for interface in result['interfaces']) for result in [a,b_before,b_after])
    print(json.dumps({'A':a,'B_before_listener':b_before,'B_after_listener':b_after,'A_still_A':a_after},sort_keys=True))
finally:
    for name in names:subprocess.run(['docker','rm','-f',name],capture_output=True,text=True,timeout=10)

在 A 内执行的探测访问 127.0.0.1 和 ::1,都读到服务身份 A。响应同时携带服务器的网络命名空间标识,与探测进程自身标识一致。这样把成功的应用响应与客户端所在网络范围关联起来,而不是假定宿主上所有看起来本地的地址都共享一个监听。地址、上下文和返回身份共同组成证据。

相同探测在 B 内执行时,两个地址都返回 ECONNREFUSED,而 A 仍然提供服务。B 的命名空间标识不同于 A。程序使用字面 IP 地址,因此结果差异不能归因于 DNS 搜索列表或某台主机对 localhost 的解释。失败来自请求 B 自己的本地端点,当时那里还没有启动匹配服务。

随后脚本在 B 内启动第二个服务器,使用完全相同的地址字符串和端口。B 现在通过两个地址族都收到身份 B,重新在 A 内执行探测仍收到 A。两个服务器共存且没有端口冲突。实验直接验证按命名空间隔离的回环和套接字端口空间,不只是展示两个不同容器名称或两份配置文本。

输出还包含接口清单,但断言没有假定接口名称只能出现 lo。当前内核会在新命名空间中创建尚未启用的隧道设备,它们不改变哪个回环监听返回了响应。验证依赖命名空间标识不同以及真实 IPv4、IPv6 交换,而不依赖脆弱的接口数量预期。仅凭看到额外接口名称,也不能宣布实验已经具有外部可达路径。

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

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

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

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

来源