A veth pair consists of two interconnected virtual Ethernet devices. A frame transmitted through one is delivered to the other, where the receiving network stack or bridge handles it. The two ends can reside in separate Linux network namespaces, making the pair useful for container networking. Creating the pair supplies a link; addresses, routes, bridge membership, forwarding, and access policy are additional configuration.
When it breaks
An interface marked administratively UP can still lack carrier because its veth peer is down. Reading only the UP flag can therefore make an incomplete setup look ready. Inspect both endpoints and distinguish administrative state from LOWER_UP. A route may already name the interface even while the paired link cannot carry useful traffic, so a route listing alone is not a connectivity check.
The pair does not restrict IP payloads to the peer interface’s own address. Routing chooses which link should carry a packet, and Ethernet forwarding or the receiving stack decides what happens next. Traffic destined beyond a container’s immediate neighbor can traverse the pair toward a router or bridge. Saying that every packet not addressed to the peer stays local confuses link delivery with IP destination selection.
Endpoint lifetime is coupled too. Deleting one veth endpoint removes its peer, even if the peer is managed from another namespace. A cleanup action in the host-side context can therefore remove a container’s interface. Stopping traffic, removing routes, and deleting the pair need an intentional order when they affect a live workload; namespace separation does not make the two devices independent resources.
Check it quickly
Save this as check-veth.py and run python3 check-veth.py with Docker. The pinned Linux container uses Python 3.11.16 and iproute2 6.1.0, with NET_ADMIN and SYS_ADMIN for nested namespaces. It creates only disposable virtual devices. The left and right namespaces receive one endpoint each, with 192.0.2.1/30 and 192.0.2.2/30 respectively:
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)
Initially only left0 is brought up. Its flags include UP and NO-CARRIER, with no LOWER_UP, because right0 remains down. A one-packet ping fails. This gives both a link-state observation and an actual unsuccessful exchange; neither is replaced by assuming that creation of the interface means the other endpoint has been configured correctly.
After bringing right0 up, both endpoints report LOWER_UP. The script clears neighbor entries from the failed attempt before making the healthy comparison, avoiding residual neighbor-resolution timers. A new ping succeeds, and a real HTTP request from left to 192.0.2.2 returns the right service’s identity. The cleared state is explicit; the exercise does not promise instantaneous recovery for every already-pending application request.
Next, the right namespace adds 203.0.113.9/32 on its loopback interface. The left namespace installs a host route to that address through 192.0.2.2. A second HTTP listener bound to 203.0.113.9 responds successfully across the same veth pair. The packet’s IP destination is not the peer’s link address, yet its selected path still uses that virtual Ethernet link.
This second destination is local to the right namespace, so the fixture does not claim to forward onward through another router. It isolates the distinction between a link’s neighboring address and a packet’s final IP destination. A larger topology can connect the receiving endpoint to a bridge or routed network, but those additional components need their own configuration and verification.
Finally, deleting left0 makes ip link show right0 fail in the right namespace with a device-not-found diagnostic. The peer has been removed, not merely placed administratively down. Both namespaces themselves still exist until cleanup. This separates the paired device lifecycle from the lifetime of the namespace that happened to contain one endpoint.
Diagnose the layers in order
Confirm which namespace owns each end and which names the runtime assigned. Then inspect administrative and carrier state, address configuration, neighbor resolution, and the route for the failing destination. A stale neighbor entry can delay recovery after a link change, while an absent route can fail before any frame is transmitted. These conditions require different repairs even when a high-level client reports only a connection timeout.
A pair is not a multiport switch. A bridge can join several such links at Ethernet level, while a router forwards between IP networks. Enabling an endpoint does not automatically add either behavior or an internet route. Document the actual attachment and filtering points, especially when a container runtime creates the devices and another component manages routes or firewall policy.
Treat deletion as a change to both endpoints and verify the affected workload afterward. If throughput or latency motivates tuning MTU, queues, or offload behavior, measure the relevant path rather than inferring performance from the link being virtual. This exercise verifies paired delivery, link readiness, destination routing, and coupled removal.
Sources
- Linux veth(4), paired transmission, carrier state and namespace use, checked 2026-09-12.
- Linux ip-link(8), veth creation, link state, movement and deletion, checked 2026-09-12.
- Linux network_namespaces(7), network boundaries and virtual-device lifetime, checked 2026-09-12.