veth 对由两个互联的虚拟以太网设备组成。从一端发送的帧到达另一端,再由接收网络栈或网桥处理。两端可以放在不同 Linux 网络命名空间,因此常用于容器网络。创建这一对设备只提供链路,地址、路由、桥接关系、转发和访问策略都需要另外配置。不能把接口已经出现,等同于完整网络已经准备好。

坏掉的样子

接口显示管理状态 UP,仍可能因为 veth 对端关闭而没有载波。只读取 UP 标志,会让尚未完成的配置看起来已经就绪。应检查两个端点,并区分管理状态与 LOWER_UP。路由也可能已经指向这个接口,但链路仍无法承载有效流量,所以路由列表本身不是连通性检查,更不能代替一次真实应用交换。

veth 对不会把 IP 载荷限制为对端接口自身地址。路由选择报文经过哪个链路,随后以太网转发或接收网络栈决定下一步处理。发往容器直接邻居之外的流量,也可以通过这一对设备到达路由器或网桥。声称“不发给对端的包都会留在本地”,混淆了链路交付与 IP 目的地选择,容易误导容器出口排障。

两端生命周期也相互关联。删除一个 veth 端点,会移除另一个端点,即使对端由另一个命名空间管理。因此宿主侧清理动作可能删除容器接口。影响活跃工作负载时,应有意识地安排停止流量、调整路由和删除设备的顺序。命名空间分离了管理视图,却没有让成对设备变成两项互不影响的资源。

快速验证

将脚本保存为 check-veth.py,准备 Docker 后执行 python3 check-veth.py。固定 Linux 容器使用 Python 3.11.16、iproute2 6.1.0,以及用于嵌套命名空间的 NET_ADMIN 和 SYS_ADMIN。它只创建一次性虚拟设备。左右命名空间各获得一个端点,分别配置 192.0.2.1/30 和 192.0.2.2/30,没有移动现有宿主网卡:

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 iproute2=6.1.0-3 iputils-ping=3:20221126-1+deb12u1 nftables=1.0.6-2+deb12u2 tcpdump=4.99.3-1 traceroute=1:2.1.2-1 procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*\nCMD ["python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,os,select,signal,subprocess,time

def run(*args,check=True,input=None,timeout=8):
    result=subprocess.run(args,input=input,capture_output=True,text=True,timeout=timeout)
    if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
    return result
def ns(name,*args,**kwargs):return run('ip','netns','exec',name,*args,**kwargs)
def ip(name,*args,**kwargs):return ns(name,'ip',*args,**kwargs)
def stop(process):
    if process.poll() is None:
        process.terminate()
        try:process.wait(timeout=2)
        except subprocess.TimeoutExpired:process.kill();process.wait(timeout=2)
def line(process,timeout=3):
    assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded'
    value=process.stdout.readline();assert value,'child exited without a response'
    return value
def spawn(stack,command):
    process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
    stack.callback(stop,process);return process
@contextmanager
def lab(names):
    with TemporaryDirectory(prefix='net-path-') as directory,ExitStack() as stack:
        root=Path(directory);proc=root/'proc';proc.mkdir();run('mount','-t','proc','proc',str(proc))
        stack.callback(run,'umount',str(proc))
        for name in names:
            run('ip','netns','add',name);stack.callback(run,'ip','netns','delete',name,check=False)
            ip(name,'link','set','lo','up')
        def setting(name,key,value):
            assert name in names
            path=proc/'sys'/key.replace('.','/')
            return ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r}); p.write_text({str(value)!r}); print(p.read_text().strip())').stdout.strip()
        yield root,stack,setting
def wire(left,left_if,right,right_if,left_addresses,right_addresses):
    run('ip','link','add',left_if,'type','veth','peer','name',right_if)
    for name,interface,addresses in [(left,left_if,left_addresses),(right,right_if,right_addresses)]:
        run('ip','link','set',interface,'netns',name)
        for address in addresses:ip(name,'address','add',address,'dev',interface,*(['nodad'] if ':' in address else []))
        ip(name,'link','set',interface,'up')
def nft(name,script):return ns(name,'nft','-f','-',input=script)
def counters(name):
    data=json.loads(ns(name,'nft','-j','list','ruleset').stdout);found={}
    for entry in data['nftables']:
        rule=entry.get('rule',{})
        if 'comment' in rule:
            for expression in rule['expr']:
                if 'counter' in expression:found[rule['comment']]=expression['counter']['packets']
    return found

def server(stack,name,address,port=18080,label='server'):
    code=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
import json,socket,sys
address,port,label=sys.argv[1],int(sys.argv[2]),sys.argv[3]
class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        body=json.dumps({'server':label,'peer':self.client_address,'path':self.path}).encode()
        self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body)
    def log_message(self,*args):pass
class Server(ThreadingHTTPServer):
    address_family=socket.AF_INET6 if ':' in address else socket.AF_INET
    def server_bind(self):
        if self.address_family==socket.AF_INET6:self.socket.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1)
        super().server_bind()
service=Server((address,port),Handler);print('ready',flush=True);service.serve_forever()
'''
    process=spawn(stack,['ip','netns','exec',name,'python3','-u','-c',code,address,str(port),label])
    assert line(process).strip()=='ready';return process
def request(name,address,port=18080):
    code=r'''
import http.client,json,sys
client=http.client.HTTPConnection(sys.argv[1],int(sys.argv[2]),timeout=.7)
try:
    client.request('GET','/lab-visible-message');response=client.getresponse();body=json.loads(response.read())
    assert response.status==200;print(json.dumps({'ok':True,'status':response.status,'body':body}))
except OSError as error:print(json.dumps({'ok':False,'type':type(error).__name__,'errno':error.errno}))
finally:client.close()
'''
    return json.loads(ns(name,'python3','-c',code,address,str(port)).stdout)

with lab(['left','right']) as (root,stack,setting):
    run('ip','link','add','left0','type','veth','peer','name','right0')
    run('ip','link','set','left0','netns','left');run('ip','link','set','right0','netns','right')
    ip('left','addr','add','192.0.2.1/30','dev','left0');ip('right','addr','add','192.0.2.2/30','dev','right0')
    ip('left','link','set','left0','up')
    before=json.loads(ip('left','-j','link','show','left0').stdout)[0]
    assert 'LOWER_UP' not in before['flags']
    failed=ns('left','ping','-n','-c','1','-W','1','192.0.2.2',check=False);assert failed.returncode!=0
    ip('right','link','set','right0','up')
    after={name:json.loads(ip(name,'-j','link','show',interface).stdout)[0] for name,interface in [('left','left0'),('right','right0')]}
    assert all('LOWER_UP' in v['flags'] for v in after.values())
    ip('left','neigh','flush','dev','left0');ip('right','neigh','flush','dev','right0')
    successful=ns('left','ping','-n','-c','1','-W','1','192.0.2.2');assert successful.returncode==0
    daemon=server(stack,'right','192.0.2.2',label='right');response=request('left','192.0.2.2');assert response['ok']
    ip('right','addr','add','203.0.113.9/32','dev','lo');ip('left','route','add','203.0.113.9/32','via','192.0.2.2')
    server(stack,'right','203.0.113.9',label='beyond-link-address');routed_response=request('left','203.0.113.9');assert routed_response['ok']
    ip('left','link','delete','left0');gone=ip('right','link','show','right0',check=False);assert gone.returncode!=0 and 'does not exist' in gone.stderr
    print(json.dumps({'one_end_up_flags':before['flags'],'one_end_up_ping_exit':failed.returncode,
        'both_up_flags':{n:v['flags'] for n,v in after.items()},'both_up_ping_exit':successful.returncode,'http':response,'other_ip_destination':routed_response,
        'deleting_one_removes_peer':True,'peer_absent_diagnostic':gone.stderr.strip()}))
"""
name='xbcatm-netpath-'+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)
        build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr
        result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN','--cap-add','SYS_ADMIN',
            '--memory','256m','--pids-limit','128',name,'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)

起初只启用 left0,它的标志包含 UP 和 NO-CARRIER,没有 LOWER_UP,因为 right0 仍关闭。单次 ping 失败。这里同时提供链路状态和实际失败交换,没有用“接口创建成功”来替代对端已经配置正确的证据。管理状态说明操作者希望设备工作,载波状态则反映配对链路当前能否成立。

启用 right0 后,两端都报告 LOWER_UP。脚本清理失败尝试留下的邻居条目,再进行正常状态比较,避免残留解析计时影响结果。新的 ping 成功,从左侧访问 192.0.2.2 的真实 HTTP 请求也收到右侧服务身份。清理动作明确可见,实验没有承诺所有已经等待中的应用请求都会在启用链路后瞬间恢复。

随后右侧在回环接口配置 203.0.113.9/32,左侧添加经 192.0.2.2 到该地址的主机路由。第二个绑定 203.0.113.9 的 HTTP 监听,仍通过同一 veth 对成功响应。报文的 IP 目的地不同于对端链路地址,但所选路径依然使用这条虚拟以太网链路。这个对照直接检验地址与传输路径的区别。

第二个目的地属于右侧命名空间本地,因此本例没有声称继续经过另一个路由器转发。它只隔离链路邻居地址与最终 IP 目的地之间的差异。更大拓扑可以把接收端接入网桥或路由网络,但这些额外组件需要独立配置和验证。不能由这次成功,就推导容器已经自动获得公网出口或任意跨网段访问能力。

最后删除 left0,右侧执行 ip link show right0 得到设备不存在的诊断。对端是被移除了,而不只是进入管理关闭状态;两个命名空间本身在清理之前仍存在。这个结果区分成对设备生命周期与容纳某一端的命名空间生命周期。清理日志需要反映真正被删除的资源,不能把所有变化都写成同一种“断网”。

按层次检查实际路径

先确认两端归属的命名空间及运行时赋予的名称,再查看管理状态、载波、地址、邻居解析和失败目的地的路由。链路变化后,旧邻居状态可能延迟恢复;缺失路由则可能让报文根本没有发上链路。即使高层客户端都只显示超时,这些原因也需要不同修复。应保留各层直接证据,而不是只围绕一个接口标志反复修改。

一对设备不是多端口交换机。网桥可以在以太网层连接多个此类链路,路由器则在 IP 网络之间转发。启用端点不会自动建立这些行为,也不会自动添加互联网路由。文档应记录真实连接关系与过滤位置,尤其当容器运行时创建设备,而其他组件管理路由或防火墙时,不能假定其中一方已经完成了所有配置。

把删除视为同时影响两端的操作,并验证相关工作负载结果。如果吞吐量或延迟要求推动 MTU、队列或卸载配置调整,应测量相应路径,不能仅因链路是虚拟设备就推断性能。本实验验证成对交付、明确就绪、正确目的地路由以及关联删除,不代表完成了一整套容器网络,也不建立统一性能承诺。

来源