容器化走到今天,已经不是什么新鲜词了,但每次带新人或者和兄弟团队做技术交流,我发现一个挺有意思的现象:大部分人能把docker run、docker build、docker-compose up敲得飞起,镜像仓库玩得贼溜,可一旦问到“容器到底是怎么在这台机器上跑起来的”“为什么容器里看到的进程和宿主机不一样”“镜像分层到底省了什么”,能讲清楚的人就断崖式减少。这就像会开车但不懂发动机,日常通勤没问题,一旦路上抛锚或者要改装动力,就只能干瞪眼。
这篇内容就是冲着这个断层来的。我打算把 Docker 从“怎么用”拉到“为什么能这么用”的层面,把它的底层关键技术掰开揉碎讲一遍。核心会围绕命名空间(Namespace)、控制组(Cgroup)、联合文件系统(UnionFS)、镜像分层与写时复制这几块硬骨头展开,同时把 Docker 的整体架构、容器与虚拟机的本质差异、网络与存储的底层逻辑串起来。适合已经能熟练使用 Docker 命令、想进一步理解其运行机制的后端开发、运维和平台工程同学,也适合刚接触容器、想一次性把地基打牢的新手——我会尽量用生活化的类比把抽象概念落地,保证不同基础的读者都能跟得上。
1. 先把整体架构和核心思路捋清楚
1.1 Docker 到底解决了什么问题
在容器普及之前,我们部署一个应用最头疼的是什么?是“在我机器上明明是好的”。开发本地跑通的代码,到了测试环境因为依赖版本不一致挂了,到了生产环境因为系统库缺失又挂了。传统虚拟机方案能解决环境隔离,但代价太重——每台虚拟机都要跑一个完整的操作系统内核,启动动辄几十秒,内存占用起步就是几百兆,一台物理机塞不下几个实例。
Docker 的核心思路是把“应用 + 依赖 + 运行时环境”打包成一个标准化的镜像,让这个镜像在任何装了容器运行时的机器上都能以几乎一致的方式跑起来。它复用了宿主机内核,不需要为每个实例再装一套操作系统,所以启动能压到秒级甚至毫秒级,单机密度也高得多。这里的关键词是复用内核和标准化交付,前者决定了它的轻量,后者决定了它的可移植性。
我个人的理解是,Docker 真正革命性的地方不在于“隔离”本身——隔离这件事操作系统早就提供了——而在于它把隔离能力封装成了对开发者友好的接口,把原本需要系统管理员才能玩转的内核特性,变成了一个Dockerfile加几条命令就能搞定的事。这个“降维”过程,才是它席卷整个行业的根本原因。
1.2 客户端-守护进程-容器运行时三层结构
Docker 的架构表面上看是 C/S 模式,但拆开来看其实是三层。最上面是客户端(Client),也就是我们敲的docker命令,它负责把用户指令翻译成 API 请求;中间是守护进程(dockerd),它接收请求、管理镜像、网络、存储卷,并调度容器生命周期;最底下是容器运行时(Runtime),真正负责创建和运行容器的脏活累活。
这里有个容易混淆的点:早期 Docker 守护进程直接调用libcontainer来创建容器,后来为了标准化,把运行时抽象成了containerd,再往下又拆出了符合 OCI 规范的runc。所以现在一条docker run的完整链路大致是:客户端发请求给 dockerd,dockerd 转给 containerd,containerd 调用 runc 去真正执行clone系统调用创建命名空间、配置 cgroup、挂载根文件系统,最后启动容器进程。
为什么要拆这么多层?核心是为了解耦和标准化。runc只认 OCI 运行时规范,不关心上层是 Docker 还是别的编排系统;containerd负责镜像拉取、容器生命周期管理这些通用能力;dockerd 则更多承担面向用户的 API 和镜像构建等职责。这种分层让每一层都能独立演进,也让整个生态不至于被单一实现绑死。理解这个链路,后面排查“容器起不来”这类问题时,你才知道该在哪一层看日志。
1.3 容器与虚拟机的本质差异
很多人把容器叫“轻量级虚拟机”,这个说法其实有误导性。虚拟机是通过 Hypervisor 虚拟出一套完整的硬件,然后在上面跑一个独立的操作系统内核,应用跑在这个内核之上;容器则是直接复用宿主机内核,通过内核提供的隔离机制,让进程“以为自己独占了一台机器”。
打个比方:虚拟机像是给每个住户单独盖一栋房子,地基、水电、墙体全套独立,互不干扰但成本高;容器则像是一栋大楼里隔出来的一个个房间,大家共用大楼的地基和水电主干(宿主机内核),但每个房间有自己的门锁、水电表(命名空间和 cgroup),彼此看不到对方在干什么。这个类比能解释很多现象:比如容器里不能随便改内核参数,因为内核是共享的;比如容器启动快,因为不用装系统;比如容器隔离性弱于虚拟机,因为共用内核意味着内核漏洞可能被利用来逃逸。
理解这个差异,你在做技术选型时就不会盲目——需要强隔离、跑不可信负载的场景,虚拟机或更重的隔离方案仍然有它的位置;而绝大多数微服务、CI 构建、开发环境这类场景,容器的轻量和密度优势是压倒性的。
2. 命名空间:让进程“看不见”彼此
2.1 命名空间到底是什么
命名空间(Namespace)是 Linux 内核提供的一种隔离机制,它的作用是让一组进程拥有独立的“视图”。同一个全局资源,在不同命名空间里的进程看到的可能是不同的东西。比如两个进程分别在两个网络命名空间里,它们各自看到的网卡、路由表、端口都是独立的,就像各自拥有一套网络栈。
这个机制的本质是给全局资源加上一层“命名空间标签”,内核在访问资源时会根据当前进程所属的命名空间做过滤。你可以把它理解成给每个进程发了一副“有色眼镜”,戴上不同颜色的眼镜,看到的世界就不一样。Docker 正是利用这一点,为每个容器创建一套独立的命名空间,让容器内的进程以为自己在一台独立机器上。
需要强调的是,命名空间只负责“视图隔离”,不负责“资源限制”。也就是说,它能让容器 A 看不到容器 B 的进程,但没法阻止容器 A 把 CPU 吃满。资源限制是 cgroup 的活,这两者分工明确,后面会细讲。
2.2 Docker 用到的几类命名空间
Linux 内核提供了多种命名空间,Docker 默认会为每个容器创建其中大部分。下面这张表是我整理的常用类型和它们各自隔离的内容,建议记牢,排查问题时非常有用:
| 命名空间类型 | 隔离的资源 | 典型表现 |
|---|---|---|
| PID | 进程 ID | 容器内进程 PID 从 1 开始,看不到宿主机进程 |
| Mount | 挂载点 | 容器有独立的文件系统挂载视图 |
| Network | 网络设备、协议栈、端口 | 容器有独立 IP、网卡、端口空间 |
| UTS | 主机名和域名 | 容器可以有自己的 hostname |
| IPC | 进程间通信资源 | 信号量、消息队列、共享内存相互隔离 |
| User | 用户和用户组 ID | 容器内 root 可映射为宿主机普通用户 |
| Cgroup | cgroup 视图 | 容器内看到的 cgroup 层级是独立的 |
这里面 PID 命名空间最直观:你在容器里执行ps aux,看到的进程很少,而且容器的主进程 PID 是 1。这个 PID 1 在容器语境下很特殊,它承担着类似 init 进程的职责,负责回收孤儿进程、处理信号。这也是为什么容器里跑的应用如果不处理 SIGTERM,docker stop会等超时后强杀——因为 PID 1 没把信号正确传递下去。
Network 命名空间是另一个高频使用点。每个容器默认有独立的网络命名空间,所以容器内的 80 端口和宿主机的 80 端口不冲突,端口映射的本质就是在宿主机上做了一层转发,把流量导到容器命名空间里的对应端口。User 命名空间则常被忽略,它能把容器内的 root 映射成宿主机的非 root 用户,是提升安全性的重要手段,只是默认没开,需要额外配置。
2.3 命名空间的实际创建过程
从系统调用层面看,创建命名空间靠的是clone()、unshare()、setns()这几个调用。clone()在创建新进程的同时可以指定新的命名空间,unshare()则让当前进程脱离某个命名空间,setns()把进程加入一个已存在的命名空间。
runc在启动容器时,大致会走这么一条路径:先通过clone()带上CLONE_NEWPID、CLONE_NEWNET、CLONE_NEWNS等标志创建子进程,子进程就落在了新的命名空间里;然后在新命名空间内挂载根文件系统、配置网络、设置 hostname;最后exec成容器的主进程。整个过程对用户是透明的,你只看到docker run之后容器就跑起来了。
这里有个实操中容易踩的坑:PID 命名空间有个特殊性,clone()创建的新 PID 命名空间只对子进程生效,调用者本身还在原来的命名空间里。所以你在宿主机上ps能看到容器进程(只是 PID 不同),但在容器里看不到宿主机进程。这个不对称是设计使然,不是 bug。理解这一点,你在做进程监控和信号处理时就不会犯迷糊。
提示:排查容器网络问题时,可以用
nsenter进入容器的网络命名空间,直接在里面执行ip addr、ss -tlnp等命令,比在宿主机上猜要高效得多。前提是你得先拿到容器主进程的 PID。
3. 控制组:给容器戴上“紧箍咒”
3.1 Cgroup 的职责与层级结构
如果说命名空间解决的是“看不看得见”的问题,那控制组(Cgroup)解决的就是“能用多少”的问题。它负责限制、统计和隔离一组进程对系统资源的使用,包括 CPU、内存、磁盘 I/O、网络带宽等。没有 cgroup,一个容器里的死循环就能把整台宿主机拖垮。
Cgroup 的组织方式是树形层级结构。系统启动时会挂载一个 cgroup 文件系统(通常在/sys/fs/cgroup),每个节点是一个“控制组”,组里可以挂进程,也可以有子组。资源限制配置在节点上,子组默认继承父组的限制。Docker 为每个容器创建一个独立的 cgroup 节点,把容器内所有进程挂进去,然后在这个节点上设置资源上限。
这里要提一下 cgroup 的两个版本。v1 时代每种资源(CPU、内存、IO)各自有一套独立的层级树,管理起来比较乱;v2 把所有资源统一到一棵树上,配置更清晰,也是现在主流发行版的方向。Docker 对两者都支持,具体用哪个取决于宿主机内核和配置。你在排查资源限制不生效的问题时,第一件事就是确认当前用的是 v1 还是 v2,因为两者的文件路径和参数名不一样。
3.2 CPU 和内存限制的底层逻辑
CPU 限制主要靠两个参数:cpu.cfs_quota_us和cpu.cfs_period_us。前者表示在一个周期内这个 cgroup 最多能用多少微秒的 CPU 时间,后者是周期长度,默认 100000 微秒(即 100 毫秒)。如果你设置--cpus=2,Docker 实际配置的是 quota 为 200000、period 为 100000,意思是每 100 毫秒内最多用 200 毫秒的 CPU 时间,等价于两个核的算力。
这个机制叫完全公平调度器(CFS)的带宽控制,它不是硬性把进程绑在某个核上,而是通过配额来限制总用量。所以你会看到设置了--cpus=1的容器,在top里 CPU 使用率可能偶尔冲到 100% 以上再被压回来,这是正常的,因为调度是按周期结算的。理解这一点,你在做性能压测时就不会被瞬时数值迷惑。
内存限制相对直接,核心文件是memory.max(v2)或memory.limit_in_bytes(v1)。超过限制时,内核的 OOM killer 会介入,杀掉 cgroup 内占用内存最多的进程。这里有个细节:容器内存达到上限被杀时,docker inspect里能看到OOMKilled: true,这是排查容器莫名重启的重要线索。另外memory.max之外还有memory.high(软限制),达到软限制会触发回收但不一定杀进程,适合做弹性控制。
3.3 资源限制配置的实操与验证
实际配置时,命令行参数和底层文件的对应关系值得记一下。下面这张表是我常用的对照,方便你在容器里直接验证:
| Docker 参数 | cgroup v2 文件 | 含义 |
|---|---|---|
--cpus=1.5 | cpu.max写150000 100000 | 每周期最多 150ms CPU 时间 |
--memory=512m | memory.max写536870912 | 内存硬上限 512MB |
--memory-swap=1g | memory.swap.max | 交换分区上限 |
--blkio-weight=500 | io.weight | IO 权重,默认 100 |
验证方法很直接:起一个容器,进去cat /sys/fs/cgroup/cpu.max和cat /sys/fs/cgroup/memory.max,就能看到实际生效的值。我习惯在压测前先做这一步,确认限制真的落到了 cgroup 上,而不是被上层编排系统覆盖了。
注意:
--memory设置后,如果容器内进程申请内存超过限制,不一定会立刻被杀,内核会先尝试回收页缓存。真正触发 OOM 的往往是匿名内存(堆内存)超限。所以你会遇到“容器内存显示没到上限却被杀了”的情况,这时候要看memory.events里的oom计数,而不是只看当前用量。
4. 联合文件系统与镜像分层:省空间的魔法
4.1 镜像分层的本质
Docker 镜像不是一个单一的大文件,而是一层层叠加起来的。每条Dockerfile指令(RUN、COPY、ADD等)都会生成一个新层,层与层之间通过联合文件系统(UnionFS)叠加成一个完整的根文件系统视图。这个设计的核心目的是复用:多个镜像如果基于同一个基础镜像,那基础层在磁盘上只存一份,内存里也只加载一份。
这个机制带来的直接好处是拉取快、存储省。比如你有十个微服务镜像都基于同一个 200MB 的基础镜像,传统方式要存 2GB,分层之后基础层只存一份,每个服务只额外存自己那几十 MB 的应用层。这也是为什么Dockerfile里推荐把不常变的指令放前面、常变的放后面——变的那层重建时,前面所有层都能命中缓存,构建速度天差地别。
我见过不少团队把COPY . .放在RUN npm install前面,结果每次改一行代码都要重新装一遍依赖,构建时间从几十秒涨到几分钟。这就是没理解分层缓存的代价。正确做法是先COPY package.json再RUN npm install,最后才COPY源码,这样依赖层能稳定命中缓存。
4.2 写时复制与存储驱动
联合文件系统在容器运行时体现为写时复制(Copy-on-Write)。镜像的所有层都是只读的,容器启动时会在最上面加一个可写层。容器内修改文件时,内核不会去动只读层,而是把该文件复制到可写层再修改。读操作则直接读只读层,多个容器共享同一份数据。
这个机制解释了为什么容器删了数据就没了——可写层随容器生命周期存在,容器一删,可写层就没了。要持久化数据必须用数据卷(Volume)或绑定挂载(Bind Mount),把数据写到宿主机上,绕开可写层。这也是新手最容易踩的坑之一:在容器里存了数据库文件,重启容器发现数据全丢。
存储驱动方面,早期有 AUFS、Device Mapper,现在主流是OverlayFS(具体是 overlay2)。OverlayFS 用 lowerdir 表示只读层、upperdir 表示可写层、merged 表示合并后的视图,性能好、实现简单,是大多数发行版的默认选择。不同驱动的性能和适用场景有差异,但 overlay2 在绝大多数场景下都是稳妥选择,除非你有特殊需求,否则不用折腾。
4.3 镜像构建的优化实践
理解了分层和写时复制,镜像优化就有了理论依据。我总结了几条实操中真正有效的做法:第一,合并RUN指令,把apt-get update和apt-get install写在同一层,避免缓存失效导致安装失败;第二,安装完清理缓存,rm -rf /var/lib/apt/lists/*要和安装在同一层,否则清理动作在下一层,上一层的大小还是省不掉;第三,用多阶段构建,把编译环境和运行环境分开,最终镜像只保留运行时需要的产物,体积能砍掉一大半。
多阶段构建值得单独说一句。以前大家习惯用一个Dockerfile装完所有编译工具,导致镜像里塞满了 gcc、make、各种开发库,动辄上 GB。多阶段构建允许你在前面的阶段做编译,最后COPY --from=builder只把二进制拷到精简的运行阶段,镜像能压到几十 MB。这个技巧在 Go、Rust 这类编译型语言的项目里效果尤其明显。
提示:查看镜像每层大小时,
docker history <image>能列出每一层的指令和大小,是定位“镜像为什么这么大”的第一手工具。配合dive这类工具还能看到每层具体增删了哪些文件。
5. 网络与存储的底层逻辑
5.1 容器网络是怎么连通的
Docker 默认创建三种网络:bridge、host、none。最常用的是 bridge,容器启动时会在宿主机上创建一对虚拟网卡(veth pair),一端插在宿主机的网桥上(默认docker0),另一端放进容器的网络命名空间。这样容器就有了独立 IP,通过网桥和宿主机、其他容器通信。
端口映射的本质是iptables 的 DNAT 规则。当你-p 8080:80时,Docker 在宿主机上加了规则,把发往宿主机 8080 端口的流量目标地址改写成容器 IP 的 80 端口。所以你在容器里ss -tlnp看到的是 80 端口在监听,宿主机上看到的是 8080 被占用,两者通过 NAT 关联。理解这一点,排查“端口映射不生效”时就知道该去看 iptables 规则,而不是在容器里瞎找。
跨主机通信则要靠 overlay 网络,它通过 VXLAN 把二层帧封装进 UDP 包,在不同宿主机的容器之间建立虚拟大二层网络。这个方案配置简单但有封装开销,大规模场景下性能不如直接路由方案。选型时要根据规模和性能要求权衡,小规模用 bridge 加端口映射足够,跨主机再考虑 overlay 或专门的网络插件。
5.2 数据卷与绑定挂载的取舍
数据持久化有两条路:数据卷(Volume)和绑定挂载(Bind Mount)。数据卷由 Docker 管理,存在/var/lib/docker/volumes下,跨容器共享方便,备份迁移也简单;绑定挂载直接把宿主机某个目录挂进容器,路径由你指定,适合开发时挂源码、挂配置文件。
两者的关键差异在于生命周期管理和可移植性。数据卷和容器解耦,容器删了卷还在,适合存数据库这类需要长期保留的数据;绑定挂载依赖宿主机目录结构,换台机器路径可能就不对了,所以更适合开发环境而非生产。生产环境我一般推荐数据卷,配合定期备份,省心。
还有个细节:绑定挂载时如果宿主机目录不存在,Docker 会自动创建(老版本行为),但权限和属主可能不符合预期,导致容器内进程写不进去。数据卷则没有这个问题,Docker 会按镜像里的属主初始化。这个差异在部署有状态服务时经常引发“权限拒绝”的报错,值得留意。
5.3 网络与存储的排查思路
网络问题排查有个固定套路:先在宿主机确认端口有没有被监听(ss -tlnp),再看 iptables 规则有没有正确下发(iptables -t nat -L -n),然后进容器网络命名空间确认服务在监听(nsenter -t <pid> -n ss -tlnp),最后从外部curl验证连通性。这个顺序能快速定位问题出在宿主机、NAT 规则还是容器内部。
存储问题则要看挂载点。docker inspect里的Mounts字段会列出所有挂载,确认源路径、目标路径、读写权限是否符合预期。如果容器内写文件报“Read-only file system”,多半是挂载时加了:ro或者根文件系统被设成了只读。这类问题看着吓人,其实查一下挂载配置就能定位。
6. 常见问题与排查技巧实录
6.1 容器启动失败的定位路径
容器起不来是最常见的问题,定位要分层看。第一步看docker logs <container>,应用层面的报错基本都能在这里找到;第二步看docker inspect的State字段,ExitCode和OOMKilled能告诉你是不是被信号杀掉或内存超限;第三步如果容器根本没进入运行状态,看docker events和守护进程日志,问题可能出在镜像拉取、存储驱动或运行时层面。
我遇到过一类典型情况:容器ExitCode是 137,很多人以为是应用崩溃,其实是收到了 SIGKILL,最常见的原因就是 OOM。这时候要结合docker inspect里的OOMKilled和 cgroup 的memory.events一起看,确认是内存限制设小了还是应用真有内存泄漏。把 137 一律当成“应用挂了”去查代码,方向就错了。
6.2 资源限制不生效的排查
设置了--memory却发现容器还是能把宿主机内存吃满,这种问题通常有三个原因。一是宿主机没开 cgroup 内存子系统,或者用的是 cgroup v1 但对应控制器没挂载;二是容器以--privileged运行,某些配置下会绕过部分限制;三是编排层(比如某些调度系统)覆盖了资源参数。排查时先进容器cat /sys/fs/cgroup/memory.max,看实际生效值,再逐层往上查配置来源。
还有一个隐蔽的坑:Java 应用在容器里默认按宿主机内存来算堆大小,导致容器内存限制 512MB 但 JVM 按 16GB 去申请堆,一启动就 OOM。解决办法是显式设置-Xmx或者用支持容器感知的 JVM 参数。这类“应用不感知容器限制”的问题在 Node、Python 里也有类似表现,本质是应用读的是宿主机视角的资源信息。
6.3 镜像体积膨胀的治理
镜像越做越大是团队常见病。治理思路分三步:先用docker history和dive找出大层,看是哪个指令贡献的;然后针对性优化,比如清理包管理器缓存、删掉编译产物、用多阶段构建;最后在 CI 里加体积门禁,超过阈值就报警,防止回退。
下面这张表是我整理的常见膨胀原因和对策,可以直接对照排查:
| 膨胀原因 | 表现 | 对策 |
|---|---|---|
| 包管理器缓存未清理 | apt/yum 缓存占几百 MB | 安装和清理写在同一 RUN 层 |
| 编译工具链残留 | 镜像里有 gcc、make | 多阶段构建,只拷产物 |
| 源码和测试文件 | 镜像里有 .git、tests | 用 .dockerignore 排除 |
| 基础镜像过大 | 基于完整发行版 | 换 slim/alpine 基础镜像 |
注意:换 alpine 虽然能显著减小体积,但它是 musl libc 而非 glibc,某些依赖 glibc 的二进制或库会跑不起来。切换前务必在测试环境验证,别为了省几十 MB 引入兼容性问题。
6.4 几个容易被忽略的实操心得
第一个心得:调试容器时善用docker run --rm -it --entrypoint sh <image>,直接进镜像看文件结构,比反复改Dockerfile重建快得多。第二个心得:docker system df能一眼看出镜像、容器、卷、构建缓存各占多少空间,清理前先看这个,避免误删有用的东西。第三个心得:生产环境给容器设--restart策略要谨慎,always在应用有启动依赖时可能导致无限重启风暴,配合健康检查用on-failure加次数限制更稳妥。
这些经验大多是从故障现场攒出来的,文档里不会写,但真到了排查的时候,往往就是这些细节决定你半小时搞定还是折腾一整天。容器技术本身不复杂,复杂的是它牵扯到内核、网络、存储、应用运行时这么多层面,任何一层出问题都表现为“容器不正常”。把底层机制吃透,排查时才能有的放矢,而不是靠重启大法碰运气。