news 2026/9/30 22:43:23

容器原理揭秘:namespace、cgroup 与镜像分层

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
容器原理揭秘:namespace、cgroup 与镜像分层

1. 引言

很多开发者把容器当作"轻量虚拟机",但这是一个常见的误解。虚拟机里跑的是完整的操作系统内核,而容器里跑的只是一个加了隔离的进程——它和宿主机共享同一个 Linux 内核,只是通过 namespace 和 cgroup 这两大机制,让这个进程"看起来"像一台独立的小机器。

本文不依赖 Docker,而是直接用 Linux 原生的unshare、nsenter、lsns等命令,从零手搓一个"容器",把 namespace、cgroup、镜像分层这些概念逐一拆开讲清楚。读完你会明白:容器不是魔法,它只是 Linux 内核早已提供的进程隔离能力,被 Docker 等工具包装成了易用的产品形态。

本文是系列的第 14 篇,前置依赖为第 01 篇(Linux 基础)与第 08 篇(进程与信号)。建议先掌握进程、PID、挂载等基础概念再阅读本文。

2. 容器本质:加了隔离的进程

2.1 先破除"迷你虚拟机"的迷思

虚拟机(VM)通过 Hypervisor 虚拟化硬件,每个 VM 内部运行一个完整的 Guest OS,包括独立的内核。这意味着:

  • 每个 VM 都要占用大量内存和磁盘(内核 + 系统库 + 应用);
  • 启动一个 VM 通常需要几十秒;
  • VM 之间的隔离是硬件级别的,安全性较高。

而容器完全不同:

  • 容器与宿主机共享同一个内核;
  • 容器只是一个普通的用户态进程,只是被 namespace 和 cgroup 包裹了一层;
  • 启动一个容器只需毫秒级,资源开销极小。

一句话总结:VM 是"虚拟的机器",容器是"加了隔离的进程"。

2.2 namespace:让进程"看不见"外面的世界

namespace 是 Linux 内核提供的一种机制,它把系统资源(如 PID、网络、挂载点、主机名等)做了隔离,让 namespace 内的进程只能看到属于自己的那部分资源,仿佛自己运行在一台独立的机器上。

Linux 目前支持 8 种 namespace:

namespace隔离的资源关键作用
PID进程号容器内进程 PID 从 1 开始,看不到宿主机其他进程
Network网络栈独立的网卡、IP、路由、防火墙规则
Mount挂载点容器内看到的文件系统与宿主机不同
UTS主机名与域名容器内hostname独立
IPC进程间通信隔离 System V IPC 和 POSIX 消息队列
User用户 ID 与组 ID容器内 root 映射为宿主机普通用户
Cgroupcgroup 根目录容器内看到独立的 cgroup 层级
Time系统时间容器内可拥有独立的时钟偏移(较新内核)

其中前 6 种是 Docker 默认启用的核心隔离,也是本文重点。

2.3 cgroup:限制进程"能用多少资源"

namespace 解决的是"看得见什么"的问题,cgroup 解决的是"能用多少"的问题。

cgroup(Control Groups)是 Linux 内核提供的资源限制机制,可以对进程组做:

  • CPU 使用上限与权重分配;
  • 内存使用上限;
  • 磁盘 I/O 带宽限制;
  • 网络带宽限制;
  • 进程数量限制(pids)。

Docker 的--cpus、--memory、--pids-limit等参数,底层都是通过 cgroup 实现的。

3. 手搓一个"容器":unshare 实战

3.1 准备工作

本文所有命令都在 Linux 环境执行(推荐 Ubuntu 22.04+ 或 CentOS 7+,内核 4.x 以上)。先确认环境:

# 查看内核版本uname-r# 确认 unshare 命令可用whichunshare nsenter lsns

如果lsns不存在,先安装 util-linux:

# Ubuntu / Debiansudoaptinstall-yutil-linux# CentOS / RHELsudoyuminstall-yutil-linux

3.2 用 unshare 创建隔离的 PID namespace

unshare命令可以创建一个新的 namespace,并在其中运行指定命令。先看最简单的例子——隔离 PID namespace:

sudounshare--pid--fork--mount-procbash

进入新 shell 后,执行:

# 查看当前进程 PIDecho$$# 查看进程列表psaux

你会惊讶地发现:$$输出的是1,ps aux只能看到少数几个进程,而宿主机上成百上千的进程全部"消失"了。

这是因为:

  • --pid创建了新的 PID namespace,新 shell 成为该 namespace 的 PID 1;
  • --fork让 unshare 先 fork 一个子进程再进入新 namespace(否则 PID 1 语义不完整);
  • --mount-proc重新挂载/proc,让ps只能看到当前 namespace 内的进程。

退出后回到宿主机,再执行ps aux,一切恢复原样。

3.3 隔离主机名:UTS namespace

再叠加 UTS namespace,让容器拥有独立的主机名:

sudounshare--pid--fork--mount-proc--utsbash

在新 shell 中修改主机名:

hostnamemy-containerhostname

此时hostname输出my-container,而宿主机的主机名完全不受影响。这就是 Docker 里--hostname参数的底层原理。

3.4 隔离网络:Network namespace

网络隔离是容器最常用的能力之一。创建独立的网络 namespace:

sudounshare--netbash

在新 shell 中查看网络:

ipaddr

你会发现只有lo回环接口,且默认是 down 状态,没有任何物理网卡。这就是"容器有独立网络栈"的直观体现。

启动回环接口:

iplinksetlo upping127.0.0.1

3.5 组合起来:一个"伪容器"

把上面几种 namespace 组合起来,就得到了一个最简"容器":

sudounshare--pid--fork--mount-proc--uts--net--ipcbash

在这个 shell 里:

  • PID 从 1 开始;
  • 主机名可独立修改;
  • 网络栈独立;
  • IPC 隔离;
  • /proc只显示本 namespace 的进程。

这就是 Docker 容器最核心的运行时骨架。当然,真正的容器还差最后一块拼图——文件系统隔离(Mount namespace + 镜像分层),我们下一节讲。

3.6 用 nsenter 进入已有 namespace

nsenter可以进入一个已存在的 namespace,这正是docker exec的底层原理。

先在一个终端启动一个"容器":

sudounshare--pid--fork--mount-proc--utsbashecho$$# 记下这个 PID,比如 12345

然后在另一个终端:

# 进入该进程的 PID namespacesudonsenter--target12345--pid--mount--utsbash

进入后执行ps aux,你会看到和容器内一致的进程列表。这就是docker exec的工作方式——它并不是进入容器的 PID 1,而是创建一个新进程并把它加入目标 namespace。

4. 镜像分层:overlayfs 与写时复制

4.1 为什么容器镜像能"共享"

Docker 镜像由多层只读层组成,底层机制是 overlayfs(Overlay Filesystem)。overlayfs 把多个目录层叠加成一个统一视图:

  • lowerdir:只读的镜像层,可被多个容器共享;
  • upperdir:容器自己的可写层;
  • merged:容器内看到的最终文件系统。
+-----------------------+ | merged(容器视角) | +-----------------------+ | upperdir(可写层) | +-----------------------+ | lowerdir 层 3 | +-----------------------+ | lowerdir 层 2 | +-----------------------+ | lowerdir 层 1 | +-----------------------+

4.2 写时复制(CoW)

写时复制(Copy-on-Write)是 overlayfs 的核心优化:当容器要修改一个只读层中的文件时,不会直接改底层文件,而是先把该文件复制到 upperdir,再在副本上修改。这样:

  • 多个容器共享同一份只读镜像层,互不影响;
  • 每个容器只保存自己修改的部分,磁盘占用极小;
  • 镜像层可以被大量容器并发共享,启动速度极快。

4.3 手动体验 overlayfs

不依赖 Docker,直接用 mount 命令体验 overlayfs:

# 准备目录mkdir-p/tmp/overlay/{lower1,lower2,upper,work,merged}# 在只读层放一些文件echo"from lower1">/tmp/overlay/lower1/file1.txtecho"from lower2">/tmp/overlay/lower2/file2.txt# 挂载 overlayfssudomount-toverlay overlay\-olowerdir=/tmp/overlay/lower1:/tmp/overlay/lower2,\upperdir=/tmp/overlay/upper,workdir=/tmp/overlay/work\/tmp/overlay/merged# 查看合并视图ls/tmp/overlay/mergedcat/tmp/overlay/merged/file1.txtcat/tmp/overlay/merged/file2.txt

现在修改 merged 中的文件,观察写时复制:

echo"modified">/tmp/overlay/merged/file1.txt# 底层只读层不变cat/tmp/overlay/lower1/file1.txt# 仍是 "from lower1"# 修改被复制到 upperdircat/tmp/overlay/upper/file1.txt# 输出 "modified"

这就是 Docker 镜像分层的全部秘密:镜像层只读共享,容器层写时复制。

4.4 层共享与缓存

Docker 构建镜像时,每一层都会生成一个内容哈希。如果某层的内容与已有层完全一致,Docker 会直接复用缓存,不会重新构建。这就是为什么:

  • 多个镜像共享基础层时,磁盘占用很小;
  • 修改 Dockerfile 时,只有变更层之后的层会重建;
  • 拉取镜像时,已存在的层会被跳过。

5. 用 strace 和 lsns 验证"容器不是迷你虚拟机"

5.1 lsns:查看 namespace 关系

lsns可以列出系统上所有的 namespace 及其关联进程:

sudolsns

输出示例:

NS TYPE NPROCS PID USER COMMAND 4026531835 pid 123 1 root /sbin/init 4026531836 net 123 1 root /sbin/init 4026531837 mnt 123 1 root /sbin/init 4026531838 uts 123 1 root /sbin/init

启动一个容器后再次执行lsns,你会看到新增的 namespace 条目,且其 PID 指向容器内的进程。这直观地证明:容器进程与宿主机进程共享同一个内核,只是被 namespace 隔离。

5.2 strace:观察系统调用

strace可以跟踪进程的系统调用。用 strace 观察容器内进程,会发现它调用的都是普通的 Linux 系统调用(clone、execve、open、read等),与宿主机进程没有本质区别:

# 在容器内运行一个简单命令,并跟踪其系统调用sudostrace-f-etrace=clone,execve unshare--pid--fork--mount-procbash-c"echo hello"

输出中可以看到clone调用携带了 namespace 相关的 flag(CLONE_NEWPID、CLONE_NEWNS等),这正是容器创建的底层机制。如果容器是虚拟机,这里应该出现的是硬件虚拟化相关的调用(如KVM_CREATE_VM),而实际并没有——这从系统调用层面证明了容器只是加了隔离的进程。

5.3 对比:VM 与容器的系统调用差异

维度虚拟机容器
内核独立 Guest OS 内核共享宿主机内核
系统调用经过 Hypervisor 虚拟化直接调用宿主机内核
启动时间秒级到分钟级毫秒级
资源开销每个 VM 一套完整 OS仅进程级开销
隔离级别硬件级内核 namespace/cgroup 级

6. 坑位总结

6.1 PID 1 与僵尸进程

容器内的第一个进程是 PID 1,它承担着"收养孤儿进程、回收僵尸进程"的职责。如果 PID 1 进程没有正确处理 SIGCHLD 信号,容器内就会出现大量僵尸进程无法回收。

# 在容器内观察僵尸进程psaux|grepdefunct

解决方案:让 PID 1 进程(如应用主进程)正确实现信号处理,或使用tini等 init 系统作为容器入口。

6.2 docker exec 是新进程而非 PID 1

docker exec并不是"进入"容器的 PID 1,而是在目标 namespace 中创建一个全新的进程。这意味着:

  • docker exec启动的进程 PID 不是 1;
  • 它不继承 PID 1 的环境变量(除非显式传递);
  • 它退出后不会影响容器主进程。
# 在容器内查看 exec 进程的 PIDdockerexec<container>echo$$# 输出通常不是 1

6.3 容器内 systemd 不可用

由于容器与宿主机共享内核,且 PID namespace 隔离,容器内无法运行 systemd 作为 PID 1(systemd 需要完整的系统初始化能力)。常见表现:

  • systemctl start xxx报错;
  • 无法管理宿主机级服务。

解决方案:容器内使用轻量 init(如 tini、s6),或直接以应用进程作为 PID 1。

7. 总结

本文从零开始,用unshare手搓了一个"容器",并拆解了容器底层的三大核心机制:

  1. namespace:隔离进程的"视野"(PID、网络、挂载、UTS、IPC、User);
  2. cgroup:限制进程的"食量"(CPU、内存、I/O);
  3. overlayfs + CoW:实现镜像分层共享与写时复制。

通过lsns和strace的验证,我们从系统调用层面确认:容器不是迷你虚拟机,而是加了隔离的进程。理解这些底层原理,能帮助你在生产环境中更好地排查容器问题、优化镜像构建、设计更合理的资源限制策略。

8. 思考与练习

  1. 用unshare组合出包含 PID、UTS、Network、Mount 四种隔离的"容器",并验证各自效果。
  2. 在容器内启动一个后台进程,观察它退出后是否变成僵尸进程,思考 PID 1 的职责。
  3. 手动挂载 overlayfs,验证写时复制行为,并对比 Docker 镜像层的共享机制。
  4. 用strace对比容器进程与普通进程的系统调用差异,总结两者的本质区别。
  5. 思考:为什么容器内不能运行 systemd?如果必须运行,有哪些替代方案?
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 22:36:11

CST电磁仿真零基础入门:从矩形波导到时域求解器的实战路线

先说个现象&#xff1a;每次有年轻人问我CST怎么入门&#xff0c;我第一反应不是甩教程链接&#xff0c;而是先反问一句——你知道你要仿的对象&#xff0c;在物理上到底发生了什么吗&#xff1f;CST这类三维电磁场仿真工具&#xff0c;在高速仿真领域几乎是标配。信号速率上到…

作者头像 李华
网站建设 2026/9/30 22:34:08

豆包千问智能体下线后,用TaoToken统一API通道重建Agent工作流

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 22:29:11

BSP基础知识

linux启动流程概括设备上电后&#xff0c;SoC 片内 BootROM 从 Flash 读第一段启动代码到 片内 SRAM 执行&#xff0c;完成 DDR 初始化和最小硬件 init&#xff1b;然后加载 Bootloader到 DDR 。解压boot代码并把控制权交给Bootoader&#xff0c;Bootloader 从 Flash kernel 分…

作者头像 李华
网站建设 2026/9/30 22:26:21

MinerU 4.0四档解析与定位器:将RAG检索命中率提升至91%的实践

做RAG知识库这一年多&#xff0c;我最深的体会是&#xff1a;检索命中率上不去&#xff0c;十有八九不是embedding选得不够好&#xff0c;而是喂给索引的文档本身就没解析好。直到我把解析环节换成MinerU 4.0&#xff0c;这个问题才算真正有了解法。今年我接手的一个内部知识库…

作者头像 李华