Linux 网络命名空间分离接口、路由、邻居表、套接字端口和包过滤规则等网络资源。它与其他命名空间共享内核,不是独立操作系统。进程通常继承父进程的网络命名空间,工具也可以在另一个范围中执行进程。这种隔离针对网络资源,不会自动涵盖全部进程身份和文件系统访问,因此需要按实际机制说明边界。
坏掉的样子
运行 ip netns exec 不会自动获得完整容器隔离。PID、用户身份和主机名隔离属于其他机制。应用可能看到不同路由,却仍与父环境共享普通文件和主机名。如果把一个网络命名空间当成完整沙箱,就会赋予它从未配置过的保证。应区分实际改变了哪类资源,以及哪些属性仍然来自原有运行环境。
新命名空间也不会自动得到有用的外部连接。接口需要有意放置和配置,本地回环可能需要启用。移动接口会改变管理它的命名空间,原范围不再把该设备列为自己的接口。因此,从错误范围执行配置命令,可能报告接口不存在,尽管预期设备仍位于另一个命名空间。诊断前应先确认命令的执行上下文。
名称还有独立生命周期。ip netns 按约定在 /run/netns 暴露引用,通常也能通过 /var/run/netns 访问。删除名称会移除这项引用,但进程或其他引用仍可维持命名空间存活。如果清理流程把名称列表中消失,直接等同于所有服务终止,就可能留下网络资源和运行中的进程。名称只是管理入口,不是进程所有权的完整表达。
快速验证
将脚本保存为 check-network-namespace.py,准备 Docker 后运行 python3 check-network-namespace.py。固定环境使用 Python 3.11.16 和 iproute2 6.1.0,通过 NET_ADMIN 与 SYS_ADMIN 在一次性容器内进行嵌套命名空间操作。临时 proc 挂载支持其中的网络参数设置,不移动宿主接口,也不发布宿主端口;实验资源随容器清理:
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(['blue','green']) as (root,stack,setting):
probe='import json,os,socket;print(json.dumps({"net":os.readlink("/proc/self/ns/net"),"pid":os.readlink("/proc/self/ns/pid"),"hostname":socket.gethostname()}))'
parent=json.loads(run('python3','-c',probe).stdout)
views={name:json.loads(ns(name,'python3','-c',probe).stdout) for name in ['blue','green']}
assert len({parent['net'],views['blue']['net'],views['green']['net']})==3
assert all(v['pid']==parent['pid'] and v['hostname']==parent['hostname'] for v in views.values())
for name in ['blue','green']:
ip(name,'link','add','dummy0','type','dummy');ip(name,'addr','add','192.0.2.10/24','dev','dummy0');ip(name,'link','set','dummy0','up')
ip('blue','route','add','203.0.113.0/24','dev','dummy0')
assert json.loads(ip('blue','-j','route','show','203.0.113.0/24').stdout)
assert not json.loads(ip('green','-j','route','show','203.0.113.0/24').stdout)
setting('blue','net.ipv4.ip_forward',0);setting('green','net.ipv4.ip_forward',1)
forwarding={name:ns(name,'sysctl','-n','net.ipv4.ip_forward').stdout.strip() for name in ['blue','green']};assert forwarding=={'blue':'0','green':'1'}
marker=root/'shared.txt';marker.write_text('shared filesystem')
assert ns('blue','cat',str(marker)).stdout==ns('green','cat',str(marker)).stdout=='shared filesystem'
daemon=server(stack,'blue','192.0.2.10',label='blue')
named=request('blue','192.0.2.10');assert named['ok'] and named['body']['server']=='blue'
run('ip','netns','delete','blue');assert not Path('/run/netns/blue').exists()
assert daemon.poll() is None and os.readlink(f'/proc/{daemon.pid}/ns/net')==views['blue']['net']
by_pid=run('nsenter','-t',str(daemon.pid),'-n','python3','-c',
'import http.client,json; c=http.client.HTTPConnection("192.0.2.10",18080,timeout=1);c.request("GET","/");r=c.getresponse();print(json.dumps({"status":r.status,"body":json.loads(r.read())}));c.close()')
surviving=json.loads(by_pid.stdout);assert surviving['status']==200 and surviving['body']['server']=='blue'
print(json.dumps({'parent':parent,'namespace_views':views,'blue_only_route':True,'forwarding':forwarding,
'same_file_visible':True,'before_name_deleted':named,'name_deleted_process_alive':True,'after_name_deleted':surviving}))
"""
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)
父环境、blue 和 green 报告三个不同网络命名空间标识,但 PID 命名空间标识与主机名保持相同。父进程创建的标记文件,也能被两个子范围读取。这些正向观察明确了本例边界:网络范围改变了,而所检查的这些进程和文件系统属性仍然共享。不能由网络标识不同,推导其他所有隔离也已经建立。
两个子范围各自创建 dummy0,都使用 192.0.2.10/24。相同名称和地址能够共存,因为它们属于不同网络上下文。只有 blue 增加到 203.0.113.0/24 的路由,查询 green 确认没有对应条目。实验检查生效的独立路由表,不是只打印两个名称就宣布隔离成功,也没有要求地址文本必须跨所有命名空间唯一。
程序明确把 blue 的 IPv4 转发设为零、green 设为一,再分别读取确认。这验证参数作用范围,没有演示真实报文转发。dummy 接口没有物理对端,配置地址和路由也不表示能够访问外部服务。应保留“参数已经不同”和“某条通信路径实际可用”之间的区别,避免把管理状态当成应用交付结果。
真实 HTTP 服务器在 blue 的 192.0.2.10 启动。通过命名空间名称执行客户端,得到状态二百和身份 blue。脚本随后删除 blue 名称,确认引用路径消失,但服务器进程和它的网络命名空间标识仍存在。移除命名引用没有杀死进程,也没有令监听立即消失,这是清理和故障恢复中很重要的生命周期差异。
利用这个明确属于实验的服务器 PID,nsenter 在仍存活的网络命名空间执行另一个客户端,再次得到状态二百与身份 blue。这证明删除名称后应用仍在工作,而不只是 proc 中还有一个条目。最后,清理栈停止自己启动的进程并移除其他命名资源,容器生命周期负责收回实验整体范围,不涉及现有宿主服务。
分开管理名称、进程归属与通信配置
记录服务的进程归属,以及运维命令实际使用的网络范围。命名空间标识适合比较当前活跃上下文,不应把短暂标识当作永久资产身份。名称意外消失时,先检查相关自有进程,再判断网络状态是否已经释放。不能仅凭列表为空,就假定可以在没有旧状态和旧服务的前提下创建替代环境。
对普通配置文件,ip netns exec 可以创建挂载命名空间,将按网络名称准备的文件绑定到惯常位置,例如使用独立解析器配置。这不等于复制了全部私有文件系统。重现客户端环境时,应记录这些额外选择,避免混淆 DNS 配置差异与路由隔离。某个共享标记文件可见,也不代表所有配置路径都必然使用相同视图。
需要通信时,应明确建立虚拟链路、路由、转发和过滤。网络命名空间提供资源边界,真正配置决定哪些数据能够通过。拆除时,应先停止服务拥有的进程并释放相关引用,再期待范围完全消失。删除名称是生命周期中的一项管理动作,不是通用的进程终止命令,也不能代替对残留服务和资源的实际检查。
来源
- Linux network_namespaces(7),网络命名空间隔离的资源,已核验 2026-09-12。
- Linux ip-netns(8),名称引用、命令执行及进程存活时的删除语义,已核验 2026-09-12。
- Linux ip-link(8),接口创建与命名空间归属,已核验 2026-09-12。