1. 容器与“隔离”这件事,得从内核说起
很多人上手 Docker 之后,第一反应是:“这不就是个轻量级虚拟机吗?”确实,从用户视角看,docker run丢一条命令,一个带独立文件系统、独立网络、独立进程视图的环境就起来了,跟虚拟机好像没差。但只要你去翻过 Docker 的底层实现,就会知道它跟虚拟机走的完全不是一条路。
虚拟机靠的是 Hypervisor 模拟硬件,每个虚拟机自带一个完整内核,隔离是硬件级别的硬隔离。而 Docker 靠的是 Linux 内核自带的两个核心机制:Namespace 和 Cgroup。Namespace 负责让容器里的进程“看不见”宿主机上的其他进程、文件、网络之类的东西;Cgroup 则负责限制容器能用多少 CPU、多少内存、多少磁盘 IO。这两兄弟补在一起,才构成了你平时看到的容器隔离效果。
我刚开始研究这块时,也走过弯路。那时候在生产环境里用 Docker 跑 Java 服务,经常出现容器里看到的 CPU 和内存跟宿主机top看到的对不上,后来才发现是没搞懂 Cgroup 的统计口径。所以这篇我打算把 Docker 背后的资源隔离内核知识一次讲透,不仅讲 namespace 和 cgroup 是什么,还会带上可以直接敲的排查命令和踩坑经验,适合已经在用 Docker、想深入理解原理的读者。
2. Namespace 逐层拆解:容器里的“平行世界”
2.1 先理解 Namespace 到底改变了什么
Namespace 不是什么新东西,它的雏形可以追溯到 Unix 时代的 chroot。chroot 能把进程的文件系统根目录换掉,让进程以为/就是某个目录。Namespace 把这个思路往前推进了一大步:不光是文件系统,进程号、网络栈、主机名、IPC 资源、用户 ID 这些全局资源,都可以给进程“重新造一份视图”。
我比较喜欢的类比是“单人办公室”。宿主机是一个大开间,所有工位共享灯光、空调、网络,谁都能看到谁。Namespace 相当于给其中一个员工隔出一间独立办公室,他看到的灯光、空调开关、电话线都是“自己的”,但其实物理上还是那套设施,只是通过隔断做了视图隔离。
从内核实现上看,每个进程都维护着自己的 namespace 集合。通过clone()系统调用创建进程时,可以传入CLONE_NEWPID、CLONE_NEWNS、CLONE_NEWNET等标志,要求内核为新进程创建新的 namespace。Docker 实质上就是把容器里的第一个进程用这些标志拉起来,后续所有子进程继承这些 namespace。
2.2 PID Namespace:容器里的“一号进程”假象
PID namespace 是我认为最直观也最能说明问题的一个。你在容器里执行ps -ef,会发现 PID 1 是你的入口进程,比如 nginx 或者 Java 进程,而且根本看不到宿主机上那一堆乱七八糟的进程。这不是 Docker 对 ps 做了过滤,而是 PID namespace 让容器内进程看到的 PID 编号从 1 重新编排了。
内核在创建 PID namespace 时,会给新空间里的第一个进程分配 PID 1。在容器里 PID 1 不只是编号特殊,它还承担了一个关键职责:僵尸进程回收。因为内核规定,PID namespace 里进程孤儿会被 PID 1 接管,PID 1 必须负责调用wait()回收僵尸进程。所以很多基础镜像会跑一个特殊的 init 进程(比如 tini),目的就是在你那个 Java 主进程 fork 出的子进程变僵尸时,能有人来收尸。
宿主机上的 PID 是全局唯一的,你可以用lsns -t pid看到当前系统里所有 PID namespace,也可以用ls -l /proc/<pid>/ns/pid查看某个进程所属的 namespace。我通常排查“容器里进程杀不掉”这类问题时会先看这个。
2.3 Mount Namespace 与容器文件系统
Mount namespace 解决的问题是:容器里的/、/etc、/var这些路径,跟宿主机看着一样,但内容完全不同。
以前 chroot 也能做到类似效果,但有个痛点:chroot 之后,如果进程拿到特权或者通过某些路径逃逸,还是可能访问到宿主机文件系统。Mount namespace 更彻底,它给进程一份独立的挂载点视图。内核维护了一个挂载树,每个 namespace 都有自己的挂载树副本,你在容器里mount一块新的设备或者绑定挂载一个目录,宿主机和其他容器完全感知不到。
Docker 镜像的分层结构依赖的也是这个机制。镜像层是只读的,容器层是临时的可写层,通过 overlayfs 这类联合文件系统把它们拼成一个完整的根文件系统。当你docker run的时候,Docker 会先准备好所有镜像层,然后创建一个新的 mount namespace,在 namespace 里完成 overlayfs 的挂载,最后才切根进去。
有个细节容易踩坑:在容器里执行mount命令,默认情况下是可以的,但宿主机不会看到这个挂载。反过来,在宿主机上挂载一个目录,容器里也看不到,除非你把挂载点共享进去。这也是为什么docker run -v的卷挂载需要在创建容器时手动指定,而不是容器起来之后再想办法。
2.4 UTS、IPC、Network 与 User Namespace
这四个 namespace 常被放在一起说,但各自管的事完全不同。
UTS namespace 隔离的是主机名和域名。你在容器里改hostname,宿主机和其他容器都无感。这个比较浅,一般不会出什么问题,但如果你用--hostname指定容器名,实际生效的就是 UTS namespace 里的 nodename。
IPC namespace 隔离的是 System V IPC 和 POSIX 消息队列。说白了就是进程间通信的“信号灯”和“信箱”。容器 A 创建的共享内存段,容器 B 里用ipcs是看不到的。这项隔离对大多数业务应用无感,但如果你的应用用了 System V 信号量做分布式锁,得注意别指望容器之间能共用。
Network namespace 是重头戏。每个容器拿到一个虚拟网络设备,通常是veth对,一端连着容器,一端连着 Docker 创建的网络桥(默认叫 docker0 或者 bridge)。容器内部有一个完全独立的网络协议栈:自己的 IP 地址、路由表、iptables 规则、监听端口。所以容器里iptables -L看到的规则跟宿主机是两套。
User namespace 则是把容器里的 UID/GID 映射到宿主机的 UID/GID 上。比如容器里 root(UID 0)映射到宿主机 UID 1000,这样容器里就算拿到 root 权限,对宿主机文件的访问权限也仅限于 UID 1000 能访问的范围。Docker 默认不开启 user namespace 映射,因为会引入一些文件权限的兼容问题,但如果你跑的是多租户场景,强烈推荐开启。
2.5 如何在系统里观察 Namespace
理论讲完了,必须上手看。我最常用的三个命令:
# 查看当前系统里的 namespace 概况 lsns # 查看某个进程属于哪些 namespace ls -l /proc/<pid>/ns/ # 进入一个 namespace 排查问题 nsenter -t <pid> -n ip addrlsns输出里会列出 namespace 类型、inode 编号以及该 namespace 里有多少个进程。/proc/<pid>/ns/目录下每个文件都是一个 namespace 的句柄,如果多个进程的某个 namespace 文件指向同一个 inode,说明它们共享这个 namespace。
nsenter是我用来“钻进容器里看真相”的神器。比如容器网络通了但从外部连不上,我会拿到容器进程的 PID,然后nsenter -t <pid> -n tcpdump -i eth0直接看到容器网卡上的包。
3. Cgroup:隔离的“另一半”,资源上限控制
3.1 隔离和限制是两回事
Namespace 解决的是一部分问题:让进程“看不到”其他东西。但光有 namespace,你会发现一个严重问题——容器里的进程可以吃掉宿主机全部 CPU 和内存,直到把宿主机打挂。因为隔离不等于限额。你需要另一种机制:Cgroup。
Cgroup 全称是 Control Groups,翻译过来就是“控制组”。它把一组进程放进一个控制组里,然后对这个组的 CPU、内存、IO、进程数等资源设定上限。Docker 的--cpus、--memory、--pids-limit参数,底层全部落在 Cgroup 上。
Cgroup 经历了两个大版本。v1 里每个资源类型有独立的层级,比如cpu、memory、blkio各有自己的目录;v2 则统一挂载在一个层级下,并通过cgroup.controllers和cgroup.subtree_control来控制哪些子系统生效。当前主流发行版默认用 v2,Debian 12、Ubuntu 22.04 之后的版本基本都是这样。老一点的 CentOS 7 还在默认 v1,写脚本时要注意区分。
3.2 CPU 限制的两种维度
Docker 里限制 CPU 有两个常用参数:--cpus和--cpu-shares。很多人搞混这两个,我重点说下。
--cpus是精确额度,比如--cpus=1.5,意思是在内核调度周期内,这个容器最多占用 1.5 个 CPU 核心。底层对应 Cgroup 的cpu.max(v2)或cpu.cfs_quota_us(v1)。实现原理基于 CFS(完全公平调度器):内核把 CPU 时间切成一段一段的调度周期(默认 100ms),然后限制这个容器在每个周期内最多运行多少毫秒。比如--cpus=1.5,实际配置就是quota=150000us,period=100000us。
--cpu-shares则不是硬限制,而是权重。它决定的是当宿主机 CPU 资源竞争激烈时,谁的优先级更高。假设容器 A shares=1024,容器 B shares=2048,CPU 满负荷时 B 拿到的 CPU 时间大约是 A 的两倍。但如果宿主机很空闲,A 也可以用到全部 CPU。
我在线上见过不少配置失误,比如只设置了--cpu-shares没设置--cpus,导致一个服务把宿主机 CPU 跑满。也有反过来的:--cpus=0.1然后发现服务响应巨慢,吞吐量上不去。我的经验是,核心在线服务用--cpus做硬保障,辅助任务是优先用--cpu-shares控权重。
3.3 内存限制的细节与 OOM
Docker--memory参数设置容器的内存上限,对应 Cgroup 的memory.max。超过这个限制后,内核会先尝试回收 Cache 和 Page Cache,这些回收不了再触发 OOM Killer。
关键点是:容器内进程看到的内存统计和宿主机看到的可能不一样。容器里free -m读取的是/proc/meminfo,在没有完全隔离的情况下,它看到的是宿主机总内存,但因为 Cgroup 限制了使用上限,实际能分配到的内存到memory.max就截止了。所以会出现容器里 free 显示内存 32G,但应用一申请超过 2G 就被 OOM 掉的诡异现象。
排查内存超限,我一般两步走:
# 先看容器的 cgroup 内存统计 cat /sys/fs/cgroup/memory.max cat /sys/fs/cgroup/memory.current # 再看内存事件计数,确认是否触发过回收或 OOM cat /sys/fs/cgroup/memory.eventsmemory.events里的oom_kill如果大于 0,说明内核曾经启动过 OOM Killer 干掉了容器里的进程。遇到这类问题,通常不是调大内存限额就完事,而是要找到内存泄漏或者堆外内存增长点。
3.4 IO 与 PIDs 控制
除了 CPU 和内存,容易被忽略的是 IO 和进程数限制。
Cgroup 对磁盘 IO 的控制稍微复杂。v2 里用io.max可以对读写带宽和 IOPS 分别做限制,但需要注意写入缓存的影响。因为很多写入是先到页缓存再异步刷盘,io.max限制的是实际磁盘访问速率,而不是应用写入速率。Docker 命令里对应--device-read-bps、--device-write-bps这类参数。
进程数限制用--pids-limit更实用。它对应 Cgroup 的pids.max。我遇到过容器里跑 Java 应用,线程池配置不合理,疯狂创建线程,结果宿主机 PID 空间被耗尽,其他容器也跟着遭殃。设置了--pids-limit=512之后,即使线程池崩了,最多影响到容器本身。
4. Docker 怎样把这些内核能力组合起来
4.1 创建容器时的内核调用链
从内核视角看,docker run的执行路径大致是:Docker 守护进程解析参数后,调用runc(当前主流的容器运行时)来创建容器。runc会构造一个配置结构体,里面包含 namespace 类型、Cgroup 参数、rootfs 路径、挂载点、环境变量、工作目录等信息,然后通过这些配置调用 Linux 的clone()或unshare()系统调用创建新的进程。
clone()是核心。它可以一次性携带多个 namespace 创建标志,内核在创建进程时就会把这些 namespace 一并创建出来。再配合pivot_root()或chroot()切换根文件系统,以及挂载/proc、/sys等伪文件系统,容器就“活”了。之后runc还会把进程的 Cgroup 配置写入 cgroupfs,比如向/sys/fs/cgroup/下对应的组写入限制值。
我建议你手动试一次unshare来理解这个过程,效果比读十篇文章都好:
# 创建一个新的 PID + Mount namespace,并挂载干净的 /proc unshare --pid --fork --mount-proc bash # 在容器视角里查看进程 ps -ef你会发现 bash 变成 PID 1,而且看不到外面的进程。这就是 Docker 底层最朴素的雏形。当然 Docker 还做了 device cgroup、selinux、capabilities 等大量加固,但骨架就是上面这套。
4.2 Docker 默认启用了哪些 Namespace
Docker 创建容器时,默认会启用 6 个 namespace:PID、Mount、UTS、IPC、Network、User 中除了 User 外,前五个都默认开启。User namespace 默认是关闭的,但这不意味着 Docker 没有做用户权限相关的事,它默认通过 capability 机制削减了容器内 root 的权限。
这意味着什么?容器里的 root 跟宿主机 root 不是一回事。即使容器内进程是 UID 0,它缺少CAP_SYS_ADMIN、CAP_SYS_MODULE等危险权限,很多内核操作做不了。最典型的就是容器里mount一个文件系统,默认会被拒绝(No permission),因为没有CAP_SYS_ADMIN权限。
这个设计是有取舍的。开启 user namespace 更安全,但会造成文件属主映射混乱。假设宿主机上某目录属主是 UID 1000,把它挂进 user namespace 映射后的容器里,容器里的 root 可能都无法访问。所以在单机开发环境,我通常会权衡一下,如果只是自用就关掉 user namespace 保持简单;如果跑了不相关的业务,宁可忍受文件映射的麻烦也要开。
4.3 特权模式:隔离的边界在哪
docker run --privileged是一个让人又爱又恨的参数。它本质上是把容器内进程能拿到的权限几乎全部放开,并且允许容器访问宿主机所有设备节点。从 namespace 的视角看,privileged 容器仍然有自己的 PID、Mount、Network namespace,但从 capability 和设备访问角度看,它和宿主机进程几乎没有区别。
这意味着 privileged 容器的 Mount namespace 保护形同虚设。容器里可以重新挂载宿主机根文件系统,可以直接操作宿主机上的磁盘。Docker 文档也明确警告不要在生产环境使用这个模式。
我在一些 CI 环境里见过维护者图省事直接--privileged,因为某些内核模块或者设备访问需要高权限。如果你确实需要部分特权,我建议优先考虑用--cap-add加白名单的方式,比如只需要SYS_PTRACE就只加这个,而不是把所有能力都塞进去。能少给就少给,这是我在生产环境用容器半年多换来的最大教训。
5. 常见问题与排查技巧实录
5.1 容器里看到宿主机进程怎么办
有回同事反馈说容器里ps -ef能看到宿主机 PID 为 1 的 systemd 进程。第一反应是容器出了问题,实际上他用的镜像是直接跑的某种带 pid namespace 特性的监控工具,或者是通过--pid=host启动的容器。
docker run --pid=host会让容器共享宿主机的 PID namespace,也就是放弃进程隔离。这在某些场景是必要的,比如你需要从容器里采集宿主机全部进程的监控指标。但如果你不想这样,检查一下 docker inspect 里的PidMode字段:
docker inspect <容器名> --format '{{.HostConfig.PidMode}}'输出host说明共享了宿主 PID namespace。正常情况下应该为空或container:<id>。这种情况不算故障,但我建议默认不要开,除非你清楚这个容器必须看到所有进程。
5.2 Cgroup 权限报错排查
另一个高频问题:容器启动时或运行时,日志里冒出failed to write to /sys/fs/cgroup/... permission denied之类的报错。
原因通常是容器里的进程尝试写 Cgroup 的配置目录。默认非特权容器没有权限修改 Cgroup 设置,因为这是影响全局资源的操作。有些应用(比如 systemd 的某些版本、或者 Java 应用里的某些监控组件)会尝试在启动时调节进程所在 Cgroup 的限制值,这类操作在容器里被拒绝了。
我的解决套路是:如果应用确实要调整线程数或内存阈值,优先调应用本身的参数,而不是换容器权限;遇到特别老实的应用必须写 Cgroup,可以给容器额外加CAP_SYS_ADMIN并设置 proper cgroup namespace。实在不行就把那部分能力从应用层面裁掉,别跟内核权限硬顶。
5.3 Namespace 泄漏和僵尸进程
最后一个是运维层面的问题:容器退出了,但某些 namespace 迟迟没有被内核回收,在lsns里还能看到。
造成这个现象最常见的原因是 namespace 里仍有存活进程。容器主进程退了,但如果还有子进程逃逸出来(比如守护进程被 fork 后没有随容器退出),namespace 的引用计数就不会归零,内核不会回收它。这也是为什么容器里最好只跑一个前台进程,并且确保它别随意 daemonize。
我排查时这么干:
# 找到 namespace 里幸存的进程 lsns -t pid -o pid,ns,command | grep <namespace_inode> # 用 ps 找出属于该 namespace 的全部进程 ps -eo pid,ns,cmd | grep <namespace_inode>找到残留进程直接 kill 掉,namespace 就会被回收。这个问题在频繁发布服务的环境里比较突出,建议在监控里加上 ns 数量指标,一旦异常增长及时处理。
5.4 问题速查表
| 现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
| 容器里能看到宿主机全部进程 | 容器使用了 host PID namespace | docker inspect --format '{{.HostConfig.PidMode}}' | 改用默认模式 |
| 容器 OOM 被杀,但内存利用率不高 | 页缓存被回收后仍超限 | cat /sys/fs/cgroup/memory.events | 调整内存上限或优化内存分配 |
| 服务响应慢,CPU 使用率波动大 | --cpu-shares权重低,资源被抢占 | docker statscat /sys/fs/cgroup/cpu.max | 换成--cpus配额 |
| 容器无法挂载文件系统 | 缺少CAP_SYS_ADMIN | capsh --print | 显式加 capability 或升特权 |
容器退出后lsns仍有 namespace | 子进程残留 | ps -eo pid,ns,cmd | 清理残留进程 |
6. 最后再分享点个人经验
写到这里,我想把自己在实践中最深的一个体会讲出来:Namespace 提供的是“你看不见我”的隔离,不是“你不能伤害我”的安全边界。很多新手把容器当成一个沙箱来用,跑不可信代码、开 privileged、直接挂宿主机目录,这些都是高危操作。隔离是为了资源管理和故障域,安全隔离需要额外叠加 Seccomp、AppArmor、user namespace 映射等一整套能力。
还有一个小技巧,排查容器网络问题时,最有效的工具不是docker exec里敲ip,而是用nsenter直接进入容器所在网络命名空间,在“容器自己的视角”下用tcpdump抓包。我在一个网络超时问题里就是靠这招找到宿主机网卡和容器网卡之间的一层规则丢包,而不是应用层的问题。
这类内核知识看起来很长线,甚至有点偏底层,但相信我,搞清楚资源隔离这层逻辑之后,你再回头处理容器网络不通、OOM、CPU 抢占这些问题,会清晰很多。