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 配置差异与路由隔离。某个共享标记文件可见,也不代表所有配置路径都必然使用相同视图。

需要通信时,应明确建立虚拟链路、路由、转发和过滤。网络命名空间提供资源边界,真正配置决定哪些数据能够通过。拆除时,应先停止服务拥有的进程并释放相关引用,再期待范围完全消失。删除名称是生命周期中的一项管理动作,不是通用的进程终止命令,也不能代替对残留服务和资源的实际检查。

来源