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 命名空间标识与主机名保持相同。父进程创建的标记文件,也能被两个子范围读取。这些正向观察明确了本例边界:网络范围改变了,而所检查的这些进程和文件系统属性仍然共享。不能由网络标识不同,推导其他所有隔离也已经建立。
...