A Linux network namespace separates networking resources such as interfaces, routes, neighbor tables, socket ports, and packet-filter rules. It shares the kernel with other namespaces; it is not a separate operating system. A process normally inherits its parent’s network namespace, and tools can execute a process in another one. The resulting isolation covers networking resources, not every aspect of process identity or filesystem access.

When it breaks

Running ip netns exec does not automatically provide the full isolation of a container. PID namespaces, user namespaces, and hostname isolation are separate mechanisms. An application may see different routes while still sharing ordinary files and a hostname with its parent environment. Treating one network namespace as a complete sandbox can therefore assign it guarantees that were never configured.

A new namespace also does not automatically receive useful external connectivity. Interfaces need deliberate placement and configuration, and local loopback may need to be brought up. Moving an interface changes which namespace manages it; the old context will no longer list that device as its own. A configuration command executed from the wrong namespace can report a missing interface even though the intended device still exists elsewhere.

Namespace names have their own lifecycle. ip netns conventionally exposes handles beneath /run/netns, commonly also reachable through /var/run/netns. Deleting a name removes that handle, but a process or another reference can keep the namespace alive. Cleanup that equates disappearance from the named list with termination of every service can leave networking resources and a running process behind.

Check it quickly

Save this as check-network-namespace.py and run python3 check-network-namespace.py with Docker. The pinned environment uses Python 3.11.16 and iproute2 6.1.0. NET_ADMIN and SYS_ADMIN permit nested namespace operations inside a disposable container. A temporary proc mount supports namespace-local network settings; no host interface is moved and no host port is published:

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)

The parent, blue, and green report three different network namespace identifiers. Their PID namespace identifiers and hostnames remain equal. A marker file created by the parent is also readable in both children. Those positive observations establish the intended scope: networking has changed, while these particular process and filesystem properties remain shared in the fixture.

Each child creates its own dummy0 with address 192.0.2.10/24. The identical names and addresses coexist because they belong to different networking contexts. Only blue receives a route for 203.0.113.0/24. Querying green’s routes confirms that it has no corresponding entry. The example verifies separate effective tables rather than merely printing the two namespace names.

The program explicitly sets IPv4 forwarding to zero in blue and one in green, then reads the values back through each namespace. This is a setting-isolation check, not a packet-forwarding demonstration. The local dummy interfaces have no physical peer. Configured addresses and routes can be inspected without implying that either namespace can reach an external service.

A real HTTP server starts in blue on 192.0.2.10. A normal named-namespace request receives status 200 and identity blue. The script then deletes the blue name and confirms its handle is absent, while the server process and its network namespace identifier remain. The process has not been killed by removing the naming reference.

Using that known server PID, nsenter executes another client in the surviving network namespace. It again receives status 200 and identity blue. This establishes continued application operation after name deletion, not just the presence of a proc entry. The fixture finally stops its own process and removes remaining named resources through its cleanup stack and container lifetime.

Keep naming, process ownership, and connectivity separate

Record both a service’s process ownership and the networking context used for management. Namespace identifiers help compare currently live contexts; do not turn an ephemeral identifier into a permanent inventory identity. If a name disappears unexpectedly, inspect the relevant owned process before concluding that its networking state has vanished or creating a replacement with assumed empty state.

For ordinary configuration files, ip netns exec can create a mount namespace and bind namespace-specific files into their conventional locations. This supports arrangements such as per-namespace resolver configuration; it does not establish a private copy of every filesystem object. Document those additional choices when reproducing a client’s environment so that DNS settings and route isolation are not confused.

Build connectivity through explicit virtual links, routes, forwarding, and filtering as required. A network namespace supplies a boundary for those resources, while their configuration determines communication. During teardown, stop the processes and release references owned by the service before expecting the namespace to disappear completely. Name deletion is one management operation within that lifecycle, not a universal process-termination command.

Sources