Docker 容器运行原理:从 docker run 到 Linux 进程
只会执行 docker run,但不知道 daemon、containerd、OCI runtime、namespace、cgroup 和 OverlayFS 在做什么,遇到容器 PID、信号、内存、文件层或权限问题时只能反复重建。本页从 Linux 进程开始解释容器完整运行过程。
学习目标
学完后应能:
- 解释容器为什么不是轻量虚拟机,而是受隔离和限制的宿主机进程。
- 画出 Docker CLI、daemon、containerd、shim、runc 与内核的调用链。
- 解释 PID、Mount、Network、UTS、IPC、User、Cgroup namespace 分别隔离什么。
- 解释 cgroup 如何限制和统计 CPU、内存、PID、IO。
- 解释镜像层、容器可写层、Copy-on-Write、OverlayFS whiteout。
- 解释
docker create、start、run、stop、kill、pause、rm的状态变化。 - 解释 PID 1、信号转发、僵尸进程和优雅关闭。
- 说明容器安全边界、Capabilities、seccomp、rootless 和特权容器风险。
一、容器到底是什么
Docker 容器不是启动了一个自己的 Linux 内核。Linux 容器本质上是宿主机上的普通进程,只是:
namespace让它看到隔离后的系统视图
cgroup限制和统计资源
能力/安全策略限制系统调用和权限
分层文件系统提供独立根文件系统
Docker管理镜像、配置、网络、日志和生命周期flowchart TD
A["宿主机Linux内核"] --> B["宿主机普通进程"]
A --> C["容器A进程"]
A --> D["容器B进程"]
C --> E["A的namespace视图"]
C --> F["A的cgroup限制"]
C --> G["A的rootfs与可写层"]
D --> H["B的namespace视图"]
D --> I["B的cgroup限制"]
D --> J["B的rootfs与可写层"]所以:
- 容器启动快,因为主要是创建进程、namespace、cgroup 和挂载,不是启动完整 Guest OS。
- 容器共享宿主机内核,因此 Linux 容器不能直接运行需要不同内核的系统。
- 容器隔离不是绝对安全边界,内核漏洞可能影响多个容器。
- 容器内
uname看到的是宿主机内核,不是镜像发行版自己的内核。
镜像里所谓 Ubuntu、Alpine,主要是用户态文件:shell、libc、包管理器、动态库和配置,不包含独立运行中的 Linux 内核。
二、Docker组件调用链
sequenceDiagram
participant CLI as "docker CLI"
participant D as "dockerd"
participant C as "containerd"
participant S as "containerd-shim"
participant R as "OCI runtime / runc"
participant K as "Linux kernel"
CLI->>D: "Engine API: create/start container"
D->>D: "处理镜像、网络、Volume和配置"
D->>C: "创建并启动Task"
C->>S: "创建容器shim"
S->>R: "按OCI bundle创建容器"
R->>K: "namespace、cgroup、mount、capability、exec"
K-->>R: "容器主进程已启动"
R-->>S: "runc创建阶段退出"
S-->>C: "持续管理进程、IO和退出状态"
C-->>D: "状态和事件"
D-->>CLI: "返回容器ID"Docker CLI
docker 命令是客户端,通过 Unix Socket、Named Pipe 或 TCP 调用 Docker Engine API。它不直接调用内核创建 namespace。
Linux 常见 Socket:
/var/run/docker.sock能访问 Docker Socket 的用户通常拥有接近宿主机 root 的控制能力,因为可启动特权容器、挂载宿主机目录。因此不能把 Docker Socket 随意挂入普通业务容器。
dockerd
Docker daemon 负责高层对象:镜像、容器配置、网络、Volume、Build、日志驱动、API 和授权。它协调 containerd,不需要自己长期作为每个容器的父进程执行应用。
containerd
containerd 管理镜像内容、快照和容器 Task 生命周期,是更底层的容器运行管理组件。Kubernetes 节点也可以通过 CRI 使用 containerd,而不一定使用 dockerd。
containerd-shim
shim 作为容器进程与 containerd 之间的中间层,管理标准IO和退出状态,使 daemon/containerd 重启时容器不必随之全部退出。不同 containerd 版本和 runtime 实现细节可能不同,但关键理解是 runc 不是一直常驻管理所有容器。
OCI runtime / runc
OCI Runtime Specification 定义如何根据 bundle/config 创建容器。runc 读取 rootfs、进程参数、namespace、mount、capability、seccomp 和资源配置,调用内核完成创建,随后创建命令本身可以退出。
三、docker run 完整过程
命令:
docker run -d \
--name order-api \
--memory 1g \
--cpus 1.5 \
--network app-net \
-p 18080:8080 \
-v order-logs:/app/logs \
order-api:1.4.2docker run 可以理解为 docker create 加 docker start,其核心过程:
flowchart TD
A["CLI解析run参数"] --> B["调用daemon创建容器"]
B --> C{"本地是否有镜像"}
C -- "否" --> D["从Registry拉取Manifest和Layers"]
C -- "是" --> E["校验镜像与平台"]
D --> E
E --> F["准备镜像只读层和容器可写层"]
F --> G["创建Volume/Bind挂载"]
G --> H["创建网络端点、veth、IP和DNS"]
H --> I["生成OCI运行配置"]
I --> J["containerd和runtime创建namespace/cgroup"]
J --> K["挂载rootfs并应用安全策略"]
K --> L["执行Entrypoint和Cmd"]
L --> M["接管stdout/stderr和状态"]
M --> N["容器进入running"]3.1 镜像解析
order-api:1.4.2 是镜像引用。daemon 在本地查找,不存在时访问 Registry:获取 Manifest、配置对象和缺失 Layers。Tag 可变,生产更可靠的是 digest:
registry.example.com/order-api@sha256:...3.2 准备rootfs
镜像只读层按顺序叠加,再增加容器可写层。Volume/Bind Mount 在之后挂到目标路径,可能遮住镜像内容。
3.3 网络
在 bridge 模式下,Docker 为容器创建 Network namespace、veth pair,将一端放容器、一端接入宿主机网桥,为容器分配 IP 和路由。-p 配置宿主机端口转发规则。
3.4 OCI配置
包含进程参数、环境、cwd、用户、rootfs、mount、namespace、cgroup资源、Capabilities、seccomp 等。runtime 根据配置调用内核。
3.5 执行主进程
Entrypoint 与 Cmd 组合为容器主进程。该进程退出,容器状态就变为 exited。容器不是靠里面是否还有“后台服务”判断存活;主进程结束即容器结束。
四、Linux namespace分别隔离什么
namespace 不是资源限制,而是让一组进程看到独立视图。
| Namespace | 隔离对象 | 容器中的表现 |
|---|---|---|
| PID | 进程ID和进程树 | 主进程在容器内可见为PID 1 |
| Mount | 挂载点和文件系统视图 | 容器有自己的rootfs和挂载表 |
| Network | 网卡、IP、路由、端口、网络栈 | 容器有eth0和独立localhost |
| UTS | hostname/domain name | 容器可有独立hostname |
| IPC | System V IPC、POSIX消息队列等 | 容器间默认不共享IPC对象 |
| User | UID/GID映射 | 容器内root可映射为宿主机非root |
| Cgroup | cgroup视图 | 容器看到受限的资源层次 |
| Time | 部分时钟视图,新内核能力 | 时间命名空间支持受环境影响 |
PID namespace
同一个进程在不同 PID namespace 中可以有不同 PID。宿主机能看到容器进程,容器通常看不到宿主机所有进程。
docker inspect order-api --format '{{.State.Pid}}'
docker exec order-api sh -c 'echo $$; ps -ef'宿主机 PID 可能是 24561,容器内主进程是 1。
Mount namespace
它隔离挂载表,不是复制所有文件。容器 rootfs 由分层文件系统挂载;bind/volume 作为额外挂载。宿主机同一文件可通过 bind 暴露给容器,因此错误挂载敏感路径会突破预期文件隔离。
Network namespace
容器的 localhost 只回到自己 Network namespace。bridge 连接通常用 veth pair:像虚拟网线的两端。
容器eth0
↕ veth pair
宿主机veth端
↓
Linux bridge / docker0
↓
宿主机路由与外部网络User namespace
没有 User Namespace 映射时,容器内 UID 0 在宿主机内核权限判断中仍可能是宿主机 UID 0,只是受到其他隔离和能力限制。User namespace/rootless 能降低风险,但文件权限、低端口、网络驱动和兼容性需要验证。
五、cgroup如何限制资源
namespace回答“看见什么”,cgroup回答“能用多少、用了多少”。
flowchart TD
A["容器进程加入cgroup"] --> B["CPU控制器"]
A --> C["Memory控制器"]
A --> D["PIDs控制器"]
A --> E["IO控制器"]
B --> F["配额、权重、节流统计"]
C --> G["内存上限、当前量、OOM事件"]
D --> H["进程/线程数量上限"]
E --> I["块设备IO权重或上限"]cgroup v1与v2
cgroup v1 各控制器可能拥有不同层次;v2 使用统一层次和更一致的接口。现代发行版越来越多使用 v2。不要把网上固定 /sys/fs/cgroup/memory/... 路径无条件套到 v2。
判断:
stat -fc %T /sys/fs/cgroup常见 v2 文件:
memory.current
memory.max
memory.events
cpu.max
cpu.stat
pids.current
pids.max路径由 systemd、Docker、rootless 模式等决定,应先通过进程 /proc/<pid>/cgroup 确认归属。
CPU配额不是独占CPU
--cpus 1.5 通常转换为 quota/period,使一个周期内最多使用约 1.5 CPU 时间。进程仍可能在多个核调度,但超过配额会被 throttling。表现可能是宿主机总体CPU不高,容器应用延迟却抖动。
--cpu-shares/权重主要在竞争时决定相对份额,不是硬上限。--cpuset-cpus 才限制可运行CPU集合。
Memory上限与OOM
容器内存统计不只 Java Heap。达到 cgroup memory.max 后,内核回收不足时可能在该 cgroup 内选择进程杀死,Docker记录 OOMKilled。Java 还未来得及抛 OOM,所以可能没有 Heap Dump。
容器总内存
= Heap
+ Metaspace
+ Direct Memory
+ 线程栈
+ Code Cache
+ JVM/GC native
+ page cache等按cgroup口径计入部分PIDs限制
Linux task 口径通常把线程计入。Java 线程过多可能先触发 pids.max 或本地资源不足,表现为:
unable to create new native thread检查:
docker inspect order-api --format '{{.HostConfig.PidsLimit}}'
docker stats --no-stream order-api六、镜像分层和OverlayFS
镜像层为什么只读
镜像由内容寻址层组成,多个镜像和容器可以复用同一层,减少磁盘和传输。修改镜像层会破坏内容摘要和共享,因此运行时只读。
容器可写层 upperdir
----------------------
应用层 app.jar
JRE层
基础系统层 lowerdir
----------------------
Overlay挂载 merged进程看到 merged 的统一文件树。
Copy-on-Write
读取镜像文件直接来自 lower 层。第一次修改只读层文件时,OverlayFS 先 copy-up 到可写 upper 层,再修改副本。大文件小改动也可能复制整个文件,数据库高频写不适合依赖容器可写层。
删除为什么不修改下层
删除 lower 层文件时,upper 层创建 whiteout 标记,在 merged 中隐藏下层文件,原下层数据仍存在镜像层。所以 Dockerfile:
RUN echo secret > /secret
RUN rm /secret秘密可能仍在前一镜像层。必须避免写入构建上下文/层,使用 BuildKit Secret 等受控机制。
容器删除为什么数据丢失
容器业务写入默认进入可写层。docker rm 删除容器元数据和可写层,数据随之丢失。Volume 有独立生命周期,不因普通容器删除自动消失。
查看存储驱动
docker info --format '{{.Driver}}'
docker inspect order-api --format '{{json .GraphDriver}}'
docker diff order-apidocker diff 展示相对镜像的文件变化,A/C/D 分别表示新增、修改、删除,有助于发现应用把日志、上传或临时文件写入可写层。
七、容器生命周期和命令区别
stateDiagram-v2
[*] --> Created: "docker create"
Created --> Running: "docker start"
Running --> Paused: "docker pause"
Paused --> Running: "docker unpause"
Running --> Exited: "主进程退出 / stop / kill"
Exited --> Running: "docker start / restart策略"
Created --> Removed: "docker rm"
Exited --> Removed: "docker rm"
Removed --> [*]docker run= create + start,并可附带。docker stop先发送停止信号,等待超时,再 SIGKILL。docker kill默认直接 SIGKILL,也可--signal指定。docker restartstop 后再 start,容器可写层和配置仍是同一容器。docker pause通常通过 freezer/cgroup 暂停进程,不是优雅停止。docker rm删除容器对象和可写层,默认不删命名Volume。
容器状态由主进程决定。如果 ENTRYPOINT 脚本把 Java 放后台后自己退出:
java -jar app.jar &shell 立即结束,容器也退出,即使后台子进程短暂存在。正确通常是:
exec java -jar app.jar八、PID 1的特殊性
容器主进程在容器 PID namespace 中通常是 PID 1。PID 1:
- 是孤儿进程收养者,需要 reap 已退出子进程,避免 zombies。
- 对某些默认信号处理与普通进程有差异。
- 若入口是 shell,信号可能不到真实应用。
Shell form问题
ENTRYPOINT java -jar app.jar实际可能是:
/bin/sh -c "java -jar app.jar" PID 1
└── java 子进程Docker stop 发 SIGTERM 给 PID 1 shell,shell 不一定正确转发,超时后 Docker SIGKILL,Java 来不及优雅关闭。
Exec form:
ENTRYPOINT ["java", "-jar", "/app/app.jar"]Java 直接作为 PID 1 接收信号。复杂脚本最后使用:
exec java $JAVA_OPTS -jar /app/app.jarinit进程
应用会创建大量子进程且自身不会回收时,可使用 --init,让轻量 init 作为 PID 1 负责信号转发和回收:
docker run --init ...不是每个 Java 容器都必须加 init,但要理解子进程模型和退出行为。
九、Spring Boot优雅停机
正确链路:
docker stop
→ SIGTERM到Java PID 1
→ Spring Boot开始graceful shutdown
→ 停止接收新请求
→ 等待在途请求和任务
→ 关闭线程池、连接池、MQ消费者
→ JVM正常退出码0如果停止超时时间小于应用最大请求/任务时间,Docker 会 SIGKILL。配置:
docker stop --time 60 order-apiCompose:
services:
order-api:
stop_grace_period: 60s业务仍要设计幂等和恢复,优雅关闭不能保证机器断电、OOM Kill 等强制故障一定执行 finally。
十、容器安全边界
Linux Capabilities
传统 root 权限被拆成多个能力,如 NET_BIND_SERVICE、NET_ADMIN、SYS_ADMIN。Docker 默认保留一组能力并移除危险能力。按需 drop:
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE ...SYS_ADMIN 范围很大,不应为了方便随意添加。
seccomp
seccomp 过滤系统调用,Docker 默认 profile 禁止部分高风险 syscall。--security-opt seccomp=unconfined 会扩大攻击面,只用于明确诊断且需审批。
no-new-privileges
docker run --security-opt no-new-privileges:true ...阻止进程通过 setuid 等方式获得额外权限,是纵深防御之一。
非root用户
Dockerfile:
RUN addgroup --system app && adduser --system --ingroup app app
USER app非root降低容器逃逸或挂载误用后的影响,但需正确设置文件、端口和Volume权限。容器内非root不等于宿主机绝对安全。
privileged为什么危险
--privileged 给予几乎所有Capabilities并放宽设备/安全限制,容器对宿主机控制能力显著增加。普通 Spring Boot、MySQL、Redis 不应因为权限报错就直接 privileged,应定位目录权限、设备和具体能力。
Docker Socket
挂载:
/var/run/docker.sock:/var/run/docker.sock相当于把 Docker daemon 控制权交给容器。CI Agent 使用也要隔离和授权,不能给不可信业务代码。
十一、Java版本与容器资源感知
老版本 JDK 8 对 cgroup CPU/内存感知不完善,可能按宿主机资源计算 Heap、GC线程和 ForkJoinPool并行度,导致容器限制内配置过大。后续 JDK 8 更新和 JDK 10+ 改善容器支持,但具体行为取决于厂商与更新版本。
检查实际 JVM 识别:
docker exec order-api java -version
docker exec order-api java -XshowSettings:system -version
docker exec order-api jcmd 1 VM.flagsJDK 8 常见历史参数包括 UseContainerSupport、MaxRAMPercentage 等是否可用取决于版本。不能只看 Docker --memory 就假设 JVM 一定自动把 Xmx 设为安全值。
容器内核数感知会影响:
- GC工作线程。
- ForkJoinPool commonPool。
- Web服务器线程默认值。
Runtime.availableProcessors()。
生产应显式核算,而不是套用宿主机参数。
十二、动手观察namespace和cgroup
在测试环境启动:
docker run -d --name internals-demo --memory 256m --cpus 0.5 nginx:1.25查看宿主机PID:
PID=$(docker inspect internals-demo --format '{{.State.Pid}}')
echo "$PID"namespace链接:
ls -l /proc/$PID/ns
cat /proc/$PID/cgroup进入进程namespace(需宿主机权限,仅测试/诊断):
nsenter -t "$PID" -p -m -n -u -i sh对比:
hostname
ip addr
mount
ps -ef清理:
docker rm -f internals-demo不要在生产随意 nsenter 和修改 namespace;该实验目的是验证“容器是宿主机进程的隔离视图”。
十三、常见问题从原理推导
| 现象 | 底层解释 |
|---|---|
| 容器启动很快 | 创建进程/namespace/cgroup/rootfs,不启动Guest Kernel |
| 容器内localhost连不到MySQL容器 | Network namespace独立,localhost指当前容器 |
| bind空目录后默认配置消失 | Mount覆盖镜像merged路径 |
| 删除容器后业务文件丢失 | 数据写在容器可写层,rm时删除 |
| 修改镜像文件占空间突然增加 | Copy-on-Write把文件copy-up到upper层 |
| 宿主机CPU不满但容器延迟高 | cgroup CPU quota发生throttling |
| 没有Java OOM但容器被杀 | cgroup总内存达到上限,内核直接OOM Kill |
| stop后出现137 | SIGTERM未处理/转发,超时后SIGKILL |
| 热部署后旧进程残留 | PID 1/脚本/子进程和信号管理错误 |
| privileged后问题“好了” | 绕过安全限制但扩大风险,未解决最小权限根因 |
十四、面试标准回答
Docker容器底层原理
容器本质是宿主机进程。Linux namespace隔离PID、Mount、Network、UTS、IPC、User等视图,cgroup限制和统计CPU、内存、PID和IO,OverlayFS把镜像只读层与容器可写层合并,Capabilities、seccomp等限制权限。Docker daemon管理高层对象,通过containerd、shim和OCI runtime调用内核创建进程,因此容器共享宿主机内核、启动快,但不是完整虚拟机。
docker run背后发生什么
CLI调用Docker Engine API,daemon解析镜像和运行配置,缺镜像时拉取manifest和layers,准备只读镜像层与可写层、Volume和网络端点,生成OCI配置。containerd通过shim调用runc创建namespace、cgroup和rootfs,应用Entrypoint成为容器主进程,daemon接收日志、状态和事件。主进程退出后容器进入Exited。
namespace和cgroup区别
namespace解决“进程能看到什么”,如独立PID、网络、挂载和hostname;cgroup解决“进程能用多少以及用了多少”,如CPU quota、memory max、pids max和IO控制。只有namespace没有cgroup,容器仍可能耗尽宿主机资源;只有cgroup没有namespace,进程仍看到宿主机全局视图。
为什么容器删除后数据丢失
镜像层只读,容器运行时写入独立可写层;删除容器会删除这层。Volume和Bind Mount有独立生命周期,关键数据库、上传和需要保留的诊断文件必须挂载并设计备份。数据库高频写也不适合长期依赖Overlay可写层的Copy-on-Write。
PID 1有什么问题
容器主进程通常是PID 1,它需要接收停止信号并回收孤儿子进程。Shell form入口可能让
/bin/sh成为PID 1,不正确转发SIGTERM,Docker stop超时后SIGKILL,应用无法优雅关闭。生产优先exec form或脚本最后exec,必要时使用轻量init。
