Docker 架构与底层原理深度剖析:容器到底是个什么东西
Docker 架构与底层原理深度剖析:容器到底是个什么东西
第一次有人问我”容器和虚拟机到底有什么区别”时,我背了一通”容器轻量、共享内核、启动快”的标准答案,对方点点头,我自己心里却发虚——这些话说得没错,但完全没触及本质。后来在生产环境把一个 Java 服务从虚机迁到 Docker,踩了一连串 OOM、PID 耗尽、/dev/shm 太小、时区错乱、镜像层膨胀到 4 GB 的坑之后 ,我才真正理解:容器不是一类新的虚拟化,它就是被特殊配置的 Linux 进程。这篇文章就把这句话拆开讲透,从 namespace、cgroup、union filesystem 到 Docker 引擎架构与 OCI 标准,把我们这些年踩过的坑一起复盘。
一、容器到底是什么:一句话戳破窗户纸
先把最核心的结论摆在前面:
容器 = cgroup(控制你能用多少)+ namespace(控制你能看见什么)+ unionfs(决定你文件系统长什么样)+ 一个正常的 Linux 进程。
没有 hypervisor,没有硬件模拟,没有 guest kernel。你在容器里跑的那个 Java 进程,和宿主机上的 sshd 一样,都是同一个 kernel 调度出来的普通进程。它之所以”以为”自己独占了系统,是因为内核给它戴上了 namespace 这副眼罩;它之所以不能把整机内存吃光,是因为 cgroup 给它设了上限。理解这一点,后面所有的现象都能自洽解释。
先上一张整体架构图,建立全局认知:
1 | ┌──────────────────────────────────────────────────────────┐ |
二、namespace:容器能”看见”什么
namespace 是 Linux 内核提供的一种隔离机制,它让一个进程只能看到系统资源的一个子集。通俗讲,就是给进程戴上”眼罩”。Linux 目前提供了 8 种 namespace,容器场景主要用到下面 6 种。
| namespace | 隔离对象 | 一句话作用 |
|---|---|---|
| PID | 进程号 | 容器里 PID=1,看不到宿主机其他进程 |
| NET | 网络栈 | 独立的网卡、路由表、iptables、端口空间 |
| IPC | System V IPC、POSIX 消息队列 | 进程间通信隔离 |
| MNT | 挂载点视图 | 看到独立的文件系统层次 |
| UTS | hostname、domainname | 容器有自己的主机名 |
| USER | 用户/组 ID 映射 | 容器里的 root 在宿主机是 nobody |
最直观的感受是 PID namespace。我们进到任意一个运行中的容器里执行 ps:
1 | # 宿主机上 |
而在宿主机上,这个 Java 进程的真实 PID 可能是 18362。这就是 PID namespace 的魔法:内核给这个进程维护了一套独立的 PID 编号空间,容器里看 PID=1,宿主机看是 18362,两者通过映射表对应。
一个关键坑:容器里的 PID 1 不好当。 PID 1 在 Linux 里有特殊语义——它会成为孤儿进程的收养者,并且默认会忽略 SIGTERM、SIGINT 等常见信号(除非显式注册 handler)。我们早期直接用 shell 启动 Java:
1 | # 错误写法 |
结果 docker stop 要等 10 秒(SIGKILL 超时)才杀掉容器,因为真正占用 1 号的是 sh,Java 收到的是 SIGTERM 也没人转发。后来改成 exec 让 Java 直接接管 PID 1,或者用 tini 这样的 init 进程才解决:
1 | # 正确写法:exec 让 java 替代 shell 占据 PID 1 |
USER namespace 是个常被忽视的能力。默认情况下容器不启用 USER namespace,容器里的 root 就是宿主机的 root。我们有一次挂载了宿主机的目录进容器,容器里一个 chown -R root:root /data 下去,宿主机上那个目录的属主全变了,吓得我们赶紧把这个能力在生产环境关掉。启用 USER namespace 后,容器内的 UID 0 会映射到宿主机的一个非特权 UID,安全性大幅提升,但代价是一些遗留镜像的权限检查会出问题,落地要灰度。
三、cgroup:容器能用”多少”
namespace 管的是看得见什么,cgroup(Control Groups)管的是能用多少资源——CPU、内存、磁盘 I/O、设备访问等。这是防止”一个容器跑飞,整机被拖垮”的关键防线。
生产环境我们最常显式限制的就是内存和 CPU:
1 | docker run -d \ |
这里有两个我们吃过血亏的点,值得展开。
第一,memory-swap 默认是 memory 的 2 倍。 很多人以为设了 --memory=4g 就万事大吉,实际上容器最多能用到 8G(4G 内存 + 4G swap),一旦走 swap 性能直接跳水。我们的一个 Java 服务 GC 抖动排查了两天,最后发现是 cgroup swap 没关,内存压力被 swap 掩盖了。生产环境一律 --memory-swap 设成和 --memory 相等来禁用 swap。
第二,OOM Killed 是容器最常见的”无疾而终”。 当容器进程的内存超过 memory 上限,内核会直接 OOM killer 杀掉占用最多的进程,并在 dmesg 里留下记录:
1 | [12345.678] Memory cgroup out of memory: Killed process 18362 (java) |
对应到 docker inspect 里,OOMKilled 字段会是 true:
1 | docker inspect my-app --format '{{.State.OOMKilled}}' |
Java 在容器里还有一个经典陷阱: 老版本 JDK(8u191 之前)的 JVM 默认按宿主机内存算堆大小,一台 128G 的机器上跑容器,JVM 一上来就按 32G 算堆,直接撞爆 4G 的 cgroup 限制。解决办法要么升级到 8u191+(支持 -XX:+UseContainerSupport 自动识别 cgroup),要么显式 -Xmx。
CPU 限制这边反而坑少一些。--cpus=2.0 是 CFS bandwidth 控制,保证容器在 100ms 周期内最多用 2 个核的 CPU 时间;--cpu-shares 是权重,只在 CPU 繁忙时生效。生产经验是:CPU 限流可以容忍(大不了慢一点),内存绝不能超(超了就是被杀),所以内存限制是必选项,CPU 限制更多是用来防”吵闹的邻居”。一个反直觉的现象是 CPU throttle 会让延迟毛刺飙升——我们有个 P99 一直卡在 800ms 的接口,把 --cpus 从 2 提到 4 之后直接降到 90ms,根因就是 CFS 周期内配额用完被强制挂起 。排查可以用 /sys/fs/cgroup/cpu/.../cpu.stat 里的 nr_throttled 计数。
四、union filesystem:镜像是怎么”叠”出来的
到这里我们已经能让一个进程隔离运行、限额运行了,但还差最后一块——文件系统。每个容器都需要一个独立的根文件系统(rootfs),但要是每个容器都拷贝一份完整的 OS,磁盘和分发成本都受不了。这就是 union filesystem(联合文件系统)要解决的问题。
Docker 镜像是由一层层只读文件系统叠加起来的,每一层对应 Dockerfile 里的一条指令:
1 | ┌─────────────────────────────┐ |
关键机制是 copy-on-write(COW,写时复制):
- 镜像层全部只读,多个容器共享同一份镜像层,省磁盘、省内存(page cache 也共享)。
- 容器启动时,Docker 在最上面叠一个可写层。容器里任何写操作——修改文件、新建文件、删除文件——都落在这个可写层里,镜像层丝毫不差。
- 修改一个镜像里的大文件时,COW 会把整个文件从下层复制到可写层再修改,而不是改原文件。这意味着在容器里改一个 1G 的日志文件,会瞬间占用 1G 额外空间。
Docker 目前默认的存储驱动是 overlay2(早期叫 aufs、devicemapper,都已淘汰)。overlay2 直接基于内核的 OverlayFS,性能和稳定性都最好。查看一个容器的分层:
1 | docker history my-app:1.2.0 |
这里有个我们反复踩的优化点:层的数量和顺序直接决定镜像大小。下面这种写法会让最终镜像膨胀到 1.2 GB :
1 | # 反例:每条 RUN 都是一层,apt 缓存没清 |
因为每条 RUN 都是一个独立层,第 2、3 层装的东西即使被第 4 层”删”了,那几层只读镜像依然带着完整文件,最终大小不会减小。正确做法是用 && 把”安装+清理”压到一层:
1 | # 正例:单层内完成安装与清理 |
优化后同样的镜像降到 480 MB 。多阶段构建(multi-stage build)是另一个降镜像体积的利器,编译期用重镜像(maven/gradle),运行期只 COPY 产物到轻量镜像(distroless/alpine),最终镜像能再砍一半。
五、Docker 引擎架构:从大单体到 OCI 标准
理解了底层三件套,再看 Docker 引擎本身。早期 Docker 就是一个 dockerd 大单体,什么活都干。随着容器生态膨胀,Docker 公司把核心拆分并捐献给 CNCF,形成了今天的分层架构和 OCI(Open Container Initiative)标准。
1 | ┌─────────────────────────────────────────────────────────┐ |
每一层职责清晰:
- dockerd:守护进程,对外暴露 REST API,负责
docker build、docker pull、卷与网络的高级抽象。它是”管理者”,自己不直接跑容器。 - containerd:从 dockerd 拆出来的核心运行时,管理容器完整生命周期(创建、启动、停止、监控)、镜像分发、快照。今天 Kubernetes 直接用 containerd,跳过 dockerd(通过 CRI 接口)。
- runc:OCI 运行时规范的参考实现。它是真正干活的——调用
clone()建 namespace、写 cgroup 文件、pivot_root 切 rootfs,最后exec启动容器进程。命令行直接runc run也能起容器。
OCI 标准的意义在于解耦。 早期 Docker 既定镜像格式又定运行时,事实垄断。社区担心被单一厂商绑死,于是 2015 年成立 OCI,定义了两个规范:
- OCI Image Spec:镜像该长什么样(manifest、config、layer tar)。
- OCI Runtime Spec:容器运行时该怎么做(一份
config.json描述 namespace、cgroup、mount,任何符合规范的 runtime 都能跑)。
今天 Docker 镜像、containerd 镜像、Kubernetes pod 用的镜像都是 OCI 格式,可以互相通用;运行时除了 runc 还有 Kata Containers(每个容器跑在一个轻量虚机里,强隔离)、gVisor(用户态内核拦截 syscall)。这就是标准化的红利。我们生产上对隔离性要求极高的安全审计容器,就换成过 Kata,对业务镜像零改动 。
六、镜像 vs 容器:常被混淆的两个概念
面试时很多人把”镜像”和”容器”混着说,但它们是两个明确不同的东西:
- 镜像(image):一个静态的、只读的、分层的文件系统快照。可以理解成”类”(class)。它本身不会运行,存在本地或镜像仓库里。
- 容器(container):镜像的运行实例,= 镜像 + 可写层 + 一个进程。可以理解成”对象”(instance)。
1 | 镜像 my-app:1.2.0 (只读) |
一个直观的对比:docker images 列出的是镜像,docker ps 列出的是容器。同一个镜像可以 run 出 N 个互不干扰的容器,每个容器有自己的可写层、自己的网络栈、自己的进程空间。
七、容器 vs 虚拟机:本质差异
现在终于能把开头的那个问题讲透了。容器和虚拟机的根本区别在于隔离的层次:
| 维度 | 虚拟机 | 容器 |
|---|---|---|
| 隔离层 | hypervisor + guest kernel | namespace + cgroup(共享 host kernel) |
| 内核 | 每个 VM 一个完整内核 | 所有容器共享一个宿主机内核 |
| 启动时间 | 30 秒 ~ 几分钟 | 100ms ~ 2 秒 |
| 资源开销 | 每个 VM 几 GB 内存起 | 几十 MB |
| 隔离强度 | 强(硬件级虚拟化) | 弱(进程级,共享内核) |
| 密度 | 单机十几台 VM | 单机上百个容器 |
| 跨内核兼容 | 强(Linux 镜像跑在 Windows 上没问题) | 弱(Linux 容器跑不了 Windows 二进制) |
1 | 虚拟机 容器 |
隔离强度的代价在容器这一侧是真实存在的。 容器共享内核意味着:内核漏洞(如 Dirty COW)会影响所有容器;某个容器触发 kernel panic 会把整台机器打挂;namespace 的逃逸漏洞历史上出现过多次。所以对隔离性要求极高的场景(多租户公有云、不可信代码运行),虚拟机或 Kata Containers 更合适。我们内部可信的服务集群才大规模用容器。
跨内核兼容也常被低估。 “一次构建到处运行”是有前提的——容器镜像里装的是用户态二进制,内核系统调用还是宿主机的。M1 Mac 上 build 的 linux/arm64 镜像,push 到 x86 生产机上跑不起来;CentOS 6 的 glibc 旧,构建出的镜像在新内核的 Ubuntu 上能跑,反过来就可能段错误。docker buildx 做多架构镜像、CI 环境和运行环境对齐,这些细节我们都在生产里趟过。
八、生产环境踩坑复盘
挑三个最典型、最有代表性的坑复盘一下。
坑一:Java 进程数暴涨触发 --pids-limit 限流。 某次上线后服务偶发性卡死,线程池任务积压,CPU、内存都正常。docker stats 看着没事,最后是 dmesg 里翻到 pids: cgroup limit reached。原因是这个 Java 服务用线程池处理请求,峰值并发上来线程数飙到 500+,触发了我们设的 --pids-limit=512。教训:Linux 的 PID 概念里,线程(LWP)也算 PID,512 对线程密集型 Java 应用太小。我们把限制调到 2048 并加了监控告警 。
坑二:/dev/shm 默认只有 64M。 一个用了 RocksDB 的服务在容器里报 Unable to allocate memory for mmap,排查发现 RocksDB 默认用 /dev/shm(共享内存 tmpfs)做缓存,而 Docker 默认只给容器 64M 的 /dev/shm。解决方法是 docker run --shm-size=1g,或者挂载自己的 tmpfs。这类”在虚机上没问题、上容器就炸”的坑,本质都是容器有一些默认的、跟虚机不一样的小限制。
坑三:容器一重启,日志全没了。 早期我们把日志直接写到容器内的 /var/log/app.log,没做 volume 挂载。某次容器 OOM 重启,可写层被清掉,故障现场全失。日志、数据这类需要持久化的东西,必须通过 -v / --mount 挂到宿主机或网络存储上,容器可写层只放 ephemeral 数据。后来我们统一改成应用日志走 stdout/stderr,由 Docker logging driver(json-file 或 fluentd)收集出去,彻底和容器生命周期解耦。
九、生产经验小结
把零散的点收一下,作为团队落地 Docker 的 checklist。
- 内存限制必选,CPU 限制可选。
--memory是底线,--memory-swap一定设成和--memory相等禁用 swap。 - PID 1 要稳。 用
exec形式的 ENTRYPOINT,复杂场景上tini,确保信号能正确传递、僵尸进程能回收。 - 镜像要瘦。 多阶段构建 + 单层内清理 apt 缓存 + 选 alpine/distroless 基础镜像,把镜像体积压到最小。
- 日志走 stdout。 不要在容器内写文件日志,统一 stdout,交给 logging driver 收集。
- 持久化数据挂 volume。 容器可写层随时可丢,数据库、日志、配置等持久数据一律
-v挂出去。 - 健康检查必加。
HEALTHCHECK指令让编排系统能基于应用层健康判断,而不是只看进程在不在。 - 可信集群才用容器。 强隔离、多租户、不可信代码场景上 Kata 或虚机。
- CI 和运行环境对齐。 别让构建机的 glibc/kernel 版本和生产机差太多,少踩玄学段错误。
这篇是 Docker/容器系列的”地基”篇,重点把容器这个概念从三层底座(namespace / cgroup / unionfs)讲透,并落到引擎架构与生产踩坑。容器的网络模型(bridge / host / overlay / macvlan,以及 Kubernetes 的 CNI 与 Service)和存储(volume / bind mount / tmpfs,以及分布式存储插件)都是更复杂的话题,会在这个系列后续的专门篇章里展开,这里先点到为止。
还有一个容易被忽视的细节:docker stop 的优雅退出依赖于信号链。默认 Docker 会先发 SIGTERM,等待 10 秒(--stop-timeout)再发 SIGKILL。如果应用没注册 SIGTERM 处理,或者 PID 1 被错误占用,这 10 秒就成了白等,最后被强杀。生产上我们把 --stop-timeout 调到 30 秒配合 Spring Boot 的 graceful shutdown,避免长连接被强切断导致用户请求 5xx。
理解了底层,上层那些花里胡哨的工具——Kubernetes、Docker Swarm、Nomad——都只是这三件套的调度与编排壳子。万变不离其宗。
