bigywater.tech
Docker 系列 · 第一篇

同一个进程,宿主机看到的 PID 和容器里看到的 PID,为什么完全不是一个数?

本机出网流量被一个本地代理接管,而 Docker Desktop 的 VM 是独立网络命名空间、连不到这个代理,索性用本机交叉编译的静态 Go 二进制打包成 FROM scratch 镜像——完全不需要拉取任何基础镜像,一样能真实起容器、真实验证命名空间隔离。这一篇本机真实验证了 PID / UTS / 网络三种命名空间,压轴的一组实验直接拿 docker inspect 和容器自己打印的 os.Getpid() 对照,证明了"同一个 OS 进程,从不同命名空间视角看到的是两个完全不同的数字"这件事。

608 vs 1
本机真实实测:同一个容器进程,宿主机(Docker VM)视角是 608,容器内部自己看是 1
.2 / .3
本机真实实测:两个同时运行的容器,各自拿到不同的真实 IP,网络命名空间互不共享

怎么绕开这台机器拉不了镜像的限制

本机出网流量被一个本地代理接管,Docker Desktop 的 Linux VM 是独立网络命名空间,连不到这个代理——docker pull 会直接卡死在 DNS 解析上。这不是 Docker 本身的限制:不走这个代理、直接联网的话 docker pull 完全正常。

真实实测
$ docker pull alpine:3.20
Error 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:scratch
hello from inside a container, pid = 1

下面全部实验都是照这个套路来的:程序想验证什么(PID、hostname、网络接口),就在 Go 程序里直接调用标准库打印出来,重新编译、重新起容器,看真实输出。

真实源码:runc 认识的 8 种命名空间,和它们对应的真实内核标志位

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)——容器进程从被创建的那一刻起,就活在一套全新的命名空间里,不是启动之后再"搬"进去的。

真实实测:默认情况下,容器有自己的 PID / hostname / 网络,但 rootfs 不是纯空的

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,地址从同一个子网池里分配,互不冲突也互不可见。

真实实测(压轴):同一个进程,两个命名空间给出两个不同的 PID

这是整篇最直接的证据——不是"理论上应该隔离",而是同一个真实操作系统进程,从宿主机(这里是 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=host 关掉隔离,真实 PID 立刻现形

如果容器共享宿主机的 PID 命名空间(不新建自己的),就不会再有"1 号进程"这层包装了。

真实实测
$ docker run --rm --pid=host nsdemo:scratch
pid = 715

--pid=host 让 runc 在创建这个容器进程时,不再传 CLONE_NEWPID 这个标志位——容器进程直接加入宿主机既有的 PID 命名空间,os.Getpid() 看到的就是它在宿主机里真实的编号(715),不再是 1。这也是为什么 --pid=host 被认为是有安全风险的选项——容器里的进程能直接看到、甚至(权限够的话)操作宿主机上的其它进程。

演示:同一个进程,两种命名空间视角

对应上面 ns_c1/ns_c2 的真实数据,切换"宿主机视角"和"容器内部视角",看同一个进程报告的 PID 怎么变。

PID 命名空间 · 双视角对照
容器视角看到的 PID

数据直接取自上面真实实测的 ns_c1(host 608 / 容器内 1)和 ns_c2(host 658 / 容器内 1),演示动画只是把"同一个进程、不同命名空间视角报告不同 PID"这件事逐步呈现出来。

参考与说明

  • 本文全部真实实验(FROM scratch + 本机交叉编译静态二进制构建镜像、默认命名空间隔离下的 PID/hostname/网络接口、/etc 下被运行时注入的文件、两个并发容器的独立 IP、宿主机与容器内部视角下同一进程的不同 PID、--pid=host 关闭隔离后的真实效果)均在本机真实运行的 Docker Desktop 29.7.2(Linux VM,containerd/runc 作为底层运行时)上完成,过程中没有任何一次成功的镜像拉取,数据未做删改。
  • 源码引用(libcontainer/specconv/spec_linux.go 的 namespaceMapping、libcontainer/configs/namespaces_syscall.go 的 namespaceInfo/CloneFlags)取自 opencontainers/runc 仓库 v1.5.1 标签。
  • 演示动画的双视角对照数据直接取自真实实测的 docker inspect/docker top/容器日志输出,没有额外模拟或编造的数字。
  • 没有涉及:Mount 命名空间配合 pivot_root 怎么把镜像的每一层联合挂载成容器根文件系统(下一篇镜像分层会展开)、User 命名空间的 UID/GID 映射、Cgroup 命名空间怎么让容器内部看到的 /sys/fs/cgroup 路径也是"从 0 开始"的(下一篇 cgroups 展开)、runc 实际调用 clone(2) 之前那段用 C 写的 nsenter 引导代码的细节。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号