本机出网流量被一个本地代理接管,而 Docker Desktop 的 VM 是独立网络命名空间、连不到这个代理,索性用本机交叉编译的静态 Go 二进制打包成 FROM scratch 镜像——完全不需要拉取任何基础镜像,一样能真实起容器、真实验证命名空间隔离。这一篇本机真实验证了 PID / UTS / 网络三种命名空间,压轴的一组实验直接拿 docker inspect 和容器自己打印的 os.Getpid() 对照,证明了"同一个 OS 进程,从不同命名空间视角看到的是两个完全不同的数字"这件事。
本机出网流量被一个本地代理接管,Docker Desktop 的 Linux VM 是独立网络命名空间,连不到这个代理——docker pull 会直接卡死在 DNS 解析上。这不是 Docker 本身的限制:不走这个代理、直接联网的话 docker pull 完全正常。
$ docker pull alpine:3.20Error response from daemon: failed to resolve reference "docker.io/library/alpine:3.20": failed to do request: Head "https://registry-1.docker.io/v2/library/alpine/manifests/3.20": context deadline exceeded
解决办法很简单:FROM scratch 是 Docker 内置的一个特殊镜像,代表"完全空白的文件系统",不需要从任何地方拉取。用本机 CGO_ENABLED=0 GOOS=linux 交叉编译出一个静态链接的 Go 二进制(不依赖任何动态库,单文件就能在 Linux 容器里跑),COPY 进 scratch 镜像,docker build 全程只在本机磁盘上操作,一次网络请求都不用发:
$ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o app main.go $ cat Dockerfile FROM scratch COPY app /app ENTRYPOINT ["/app"] $ docker build -t nsdemo:scratch . $ docker run --rm nsdemo:scratchhello from inside a container, pid = 1
下面全部实验都是照这个套路来的:程序想验证什么(PID、hostname、网络接口),就在 Go 程序里直接调用标准库打印出来,重新编译、重新起容器,看真实输出。
OCI 运行时规范定义了 8 种命名空间类型,Docker 实际调用的 runc 把每一种都翻译成 Linux 内核认识的 CLONE_NEW* 标志位。
runc · libcontainer/specconv/spec_linux.go · runc @ v1.5.1, L45 namespaceMapping = map[specs.LinuxNamespaceType]configs.NamespaceType{ specs.PIDNamespace: configs.NEWPID, specs.NetworkNamespace: configs.NEWNET, specs.MountNamespace: configs.NEWNS, specs.UserNamespace: configs.NEWUSER, specs.IPCNamespace: configs.NEWIPC, specs.UTSNamespace: configs.NEWUTS, specs.CgroupNamespace: configs.NEWCGROUP, specs.TimeNamespace: configs.NEWTIME, }
runc · libcontainer/configs/namespaces_syscall.go · runc @ v1.5.1, L8 var namespaceInfo = map[NamespaceType]int{ NEWNET: unix.CLONE_NEWNET, NEWNS: unix.CLONE_NEWNS, NEWUSER: unix.CLONE_NEWUSER, NEWIPC: unix.CLONE_NEWIPC, NEWUTS: unix.CLONE_NEWUTS, NEWPID: unix.CLONE_NEWPID, NEWCGROUP: unix.CLONE_NEWCGROUP, NEWTIME: unix.CLONE_NEWTIME, } // CloneFlags 把所有需要新建(非共享)的命名空间的标志位合并成一个 uintptr, // 最终交给 clone(2)/unshare(2) 这两个真正的系统调用。
链路很直接:Docker 把容器配置翻译成 OCI 运行时规范里的 "pid"/"network" 这些字符串,runc 的 namespaceMapping 把字符串转成内部的 NamespaceType,namespaceInfo 再把 NamespaceType 转成真实的 CLONE_NEWPID/CLONE_NEWNET 等内核常量,最后拼成一个标志位传给 clone(2)——容器进程从被创建的那一刻起,就活在一套全新的命名空间里,不是启动之后再"搬"进去的。
FROM scratch 镜像里只有我们自己的二进制,但容器真跑起来之后,根目录下平白多出了几样东西。
// Go 程序:打印 pid、hostname、根目录列表、网络接口
fmt.Println("pid =", os.Getpid())
fmt.Println("hostname =", hostname)
fmt.Println("root dir entries =", ...)
fmt.Println("network interfaces =", ...)
pid = 1
hostname = 3840fe1a7333
root dir entries = [.dockerenv app dev etc proc sys]
network interfaces = [lo(127.0.0.1/8) eth0(172.17.0.2/16) ...]
pid = 1:容器里的第一个进程,天然就是 1 号进程(PID 命名空间从 1 开始重新计数)。hostname 默认就是容器 ID 的前 12 位(UTS 命名空间让每个容器可以有自己的主机名,不用跟宿主机同名)。但镜像里明明只 COPY 了一个 app,dev/etc/proc/sys 和 .dockerenv 是运行时(runc)强制挂载/创建进去的——/proc、/sys 是容器要正常工作离不开的伪文件系统,/etc/hosts//etc/resolv.conf//etc/hostname 是 Docker 专门生成、绑定挂进来的,负责让容器不用自带任何网络配置文件也能解析域名、找到自己的 IP:
$ docker run --rm nsdemo2:scratch # 打印 /etc 目录 + /etc/resolv.conf- hostname - hosts - mtab - resolv.conf --- /etc/resolv.conf --- # Generated by Docker Engine. nameserver 192.168.65.7
命名空间隔离的是"视角",不代表容器是彻底孤立、什么都没有的黑箱——为了让容器出厂就能联网、能解析域名,运行时会主动塞进来这几个最基本的文件。
串行跑两次同一个镜像,两次都会拿到 172.17.0.2(旧的容器退出后 IP 被回收复用)——要证明"互相独立"必须让两个容器同时活着。
$ docker run -d --name ns_c1 nsdemo3:scratch
$ docker run -d --name ns_c2 nsdemo3:scratch
$ docker inspect -f '{{.Name}}: {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' ns_c1 ns_c2
/ns_c1: 172.17.0.2
/ns_c2: 172.17.0.3
两个容器一直并发存活,各自拿到 .2/.3 两个不同的 IP——默认 bridge 网络下,每个容器的网络命名空间里都有一块独立的 eth0,地址从同一个子网池里分配,互不冲突也互不可见。
这是整篇最直接的证据——不是"理论上应该隔离",而是同一个真实操作系统进程,从宿主机(这里是 Docker Desktop 的 Linux VM)和容器内部两个视角看,报告的 PID 数字完全不一样。
$ docker inspect -f '{{.State.Pid}}' ns_c1 # 宿主机视角的真实 PID
$ docker inspect -f '{{.State.Pid}}' ns_c2
$ docker logs ns_c1 # 容器自己打印的 pid
$ docker logs ns_c2
$ docker top ns_c1 # 交叉验证宿主机视角的 PID
ns_c1 host 真实 PID: 608 ns_c2 host 真实 PID: 658
容器内部打印: pid = 1 容器内部打印: pid = 1
docker top ns_c1:
UID PID PPID ... CMD
root 608 583 ... /app3
ns_c1 这个进程,docker inspect 和 docker top 两个独立命令都确认它在宿主机的 PID 命名空间里编号是 608,但它自己调用 os.Getpid() 拿到的是 1——这不是两个进程,是同一个 /app3 进程,只是 /proc 文件系统按"你是从哪个 PID 命名空间在问"给出不同的编号。ns_c2 同理是 658 对 1。
如果容器共享宿主机的 PID 命名空间(不新建自己的),就不会再有"1 号进程"这层包装了。
$ docker run --rm --pid=host nsdemo:scratchpid = 715
--pid=host 让 runc 在创建这个容器进程时,不再传 CLONE_NEWPID 这个标志位——容器进程直接加入宿主机既有的 PID 命名空间,os.Getpid() 看到的就是它在宿主机里真实的编号(715),不再是 1。这也是为什么 --pid=host 被认为是有安全风险的选项——容器里的进程能直接看到、甚至(权限够的话)操作宿主机上的其它进程。
对应上面 ns_c1/ns_c2 的真实数据,切换"宿主机视角"和"容器内部视角",看同一个进程报告的 PID 怎么变。
| 容器 | 视角 | 看到的 PID |
|---|
数据直接取自上面真实实测的 ns_c1(host 608 / 容器内 1)和 ns_c2(host 658 / 容器内 1),演示动画只是把"同一个进程、不同命名空间视角报告不同 PID"这件事逐步呈现出来。