Skip to content

Docker 容器运行原理:从 docker run 到 Linux 进程

只会执行 docker run,但不知道 daemon、containerd、OCI runtime、namespace、cgroup 和 OverlayFS 在做什么,遇到容器 PID、信号、内存、文件层或权限问题时只能反复重建。本页从 Linux 进程开始解释容器完整运行过程。

学习目标

学完后应能:

  1. 解释容器为什么不是轻量虚拟机,而是受隔离和限制的宿主机进程。
  2. 画出 Docker CLI、daemon、containerd、shim、runc 与内核的调用链。
  3. 解释 PID、Mount、Network、UTS、IPC、User、Cgroup namespace 分别隔离什么。
  4. 解释 cgroup 如何限制和统计 CPU、内存、PID、IO。
  5. 解释镜像层、容器可写层、Copy-on-Write、OverlayFS whiteout。
  6. 解释 docker createstartrunstopkillpauserm 的状态变化。
  7. 解释 PID 1、信号转发、僵尸进程和优雅关闭。
  8. 说明容器安全边界、Capabilities、seccomp、rootless 和特权容器风险。

一、容器到底是什么

Docker 容器不是启动了一个自己的 Linux 内核。Linux 容器本质上是宿主机上的普通进程,只是:

text
namespace让它看到隔离后的系统视图
cgroup限制和统计资源
能力/安全策略限制系统调用和权限
分层文件系统提供独立根文件系统
Docker管理镜像、配置、网络、日志和生命周期
mermaid
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组件调用链

mermaid
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:

text
/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 完整过程

命令:

bash
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.2

docker run 可以理解为 docker createdocker start,其核心过程:

mermaid
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:

text
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
UTShostname/domain name容器可有独立hostname
IPCSystem V IPC、POSIX消息队列等容器间默认不共享IPC对象
UserUID/GID映射容器内root可映射为宿主机非root
Cgroupcgroup视图容器看到受限的资源层次
Time部分时钟视图,新内核能力时间命名空间支持受环境影响

PID namespace

同一个进程在不同 PID namespace 中可以有不同 PID。宿主机能看到容器进程,容器通常看不到宿主机所有进程。

bash
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:像虚拟网线的两端。

text
容器eth0
    ↕ veth pair
宿主机veth端

Linux bridge / docker0

宿主机路由与外部网络

User namespace

没有 User Namespace 映射时,容器内 UID 0 在宿主机内核权限判断中仍可能是宿主机 UID 0,只是受到其他隔离和能力限制。User namespace/rootless 能降低风险,但文件权限、低端口、网络驱动和兼容性需要验证。

五、cgroup如何限制资源

namespace回答“看见什么”,cgroup回答“能用多少、用了多少”。

mermaid
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。

判断:

bash
stat -fc %T /sys/fs/cgroup

常见 v2 文件:

text
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。

text
容器总内存
= Heap
+ Metaspace
+ Direct Memory
+ 线程栈
+ Code Cache
+ JVM/GC native
+ page cache等按cgroup口径计入部分

PIDs限制

Linux task 口径通常把线程计入。Java 线程过多可能先触发 pids.max 或本地资源不足,表现为:

text
unable to create new native thread

检查:

bash
docker inspect order-api --format '{{.HostConfig.PidsLimit}}'
docker stats --no-stream order-api

六、镜像分层和OverlayFS

镜像层为什么只读

镜像由内容寻址层组成,多个镜像和容器可以复用同一层,减少磁盘和传输。修改镜像层会破坏内容摘要和共享,因此运行时只读。

text
容器可写层 upperdir
----------------------
应用层 app.jar
JRE层
基础系统层 lowerdir
----------------------
Overlay挂载 merged

进程看到 merged 的统一文件树。

Copy-on-Write

读取镜像文件直接来自 lower 层。第一次修改只读层文件时,OverlayFS 先 copy-up 到可写 upper 层,再修改副本。大文件小改动也可能复制整个文件,数据库高频写不适合依赖容器可写层。

删除为什么不修改下层

删除 lower 层文件时,upper 层创建 whiteout 标记,在 merged 中隐藏下层文件,原下层数据仍存在镜像层。所以 Dockerfile:

dockerfile
RUN echo secret > /secret
RUN rm /secret

秘密可能仍在前一镜像层。必须避免写入构建上下文/层,使用 BuildKit Secret 等受控机制。

容器删除为什么数据丢失

容器业务写入默认进入可写层。docker rm 删除容器元数据和可写层,数据随之丢失。Volume 有独立生命周期,不因普通容器删除自动消失。

查看存储驱动

bash
docker info --format '{{.Driver}}'
docker inspect order-api --format '{{json .GraphDriver}}'
docker diff order-api

docker diff 展示相对镜像的文件变化,A/C/D 分别表示新增、修改、删除,有助于发现应用把日志、上传或临时文件写入可写层。

七、容器生命周期和命令区别

mermaid
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 restart stop 后再 start,容器可写层和配置仍是同一容器。
  • docker pause 通常通过 freezer/cgroup 暂停进程,不是优雅停止。
  • docker rm 删除容器对象和可写层,默认不删命名Volume。

容器状态由主进程决定。如果 ENTRYPOINT 脚本把 Java 放后台后自己退出:

sh
java -jar app.jar &

shell 立即结束,容器也退出,即使后台子进程短暂存在。正确通常是:

sh
exec java -jar app.jar

八、PID 1的特殊性

容器主进程在容器 PID namespace 中通常是 PID 1。PID 1:

  • 是孤儿进程收养者,需要 reap 已退出子进程,避免 zombies。
  • 对某些默认信号处理与普通进程有差异。
  • 若入口是 shell,信号可能不到真实应用。

Shell form问题

dockerfile
ENTRYPOINT java -jar app.jar

实际可能是:

text
/bin/sh -c "java -jar app.jar"   PID 1
└── java                         子进程

Docker stop 发 SIGTERM 给 PID 1 shell,shell 不一定正确转发,超时后 Docker SIGKILL,Java 来不及优雅关闭。

Exec form:

dockerfile
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Java 直接作为 PID 1 接收信号。复杂脚本最后使用:

sh
exec java $JAVA_OPTS -jar /app/app.jar

init进程

应用会创建大量子进程且自身不会回收时,可使用 --init,让轻量 init 作为 PID 1 负责信号转发和回收:

bash
docker run --init ...

不是每个 Java 容器都必须加 init,但要理解子进程模型和退出行为。

九、Spring Boot优雅停机

正确链路:

text
docker stop
→ SIGTERM到Java PID 1
→ Spring Boot开始graceful shutdown
→ 停止接收新请求
→ 等待在途请求和任务
→ 关闭线程池、连接池、MQ消费者
→ JVM正常退出码0

如果停止超时时间小于应用最大请求/任务时间,Docker 会 SIGKILL。配置:

bash
docker stop --time 60 order-api

Compose:

yaml
services:
  order-api:
    stop_grace_period: 60s

业务仍要设计幂等和恢复,优雅关闭不能保证机器断电、OOM Kill 等强制故障一定执行 finally。

十、容器安全边界

Linux Capabilities

传统 root 权限被拆成多个能力,如 NET_BIND_SERVICE、NET_ADMIN、SYS_ADMIN。Docker 默认保留一组能力并移除危险能力。按需 drop:

bash
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE ...

SYS_ADMIN 范围很大,不应为了方便随意添加。

seccomp

seccomp 过滤系统调用,Docker 默认 profile 禁止部分高风险 syscall。--security-opt seccomp=unconfined 会扩大攻击面,只用于明确诊断且需审批。

no-new-privileges

bash
docker run --security-opt no-new-privileges:true ...

阻止进程通过 setuid 等方式获得额外权限,是纵深防御之一。

非root用户

Dockerfile:

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

挂载:

text
/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 识别:

bash
docker exec order-api java -version
docker exec order-api java -XshowSettings:system -version
docker exec order-api jcmd 1 VM.flags

JDK 8 常见历史参数包括 UseContainerSupportMaxRAMPercentage 等是否可用取决于版本。不能只看 Docker --memory 就假设 JVM 一定自动把 Xmx 设为安全值。

容器内核数感知会影响:

  • GC工作线程。
  • ForkJoinPool commonPool。
  • Web服务器线程默认值。
  • Runtime.availableProcessors()

生产应显式核算,而不是套用宿主机参数。

十二、动手观察namespace和cgroup

在测试环境启动:

bash
docker run -d --name internals-demo --memory 256m --cpus 0.5 nginx:1.25

查看宿主机PID:

bash
PID=$(docker inspect internals-demo --format '{{.State.Pid}}')
echo "$PID"

namespace链接:

bash
ls -l /proc/$PID/ns
cat /proc/$PID/cgroup

进入进程namespace(需宿主机权限,仅测试/诊断):

bash
nsenter -t "$PID" -p -m -n -u -i sh

对比:

bash
hostname
ip addr
mount
ps -ef

清理:

bash
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后出现137SIGTERM未处理/转发,超时后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。

关联知识点