Docker run的完整流程
07 · docker run 完整流程
重要程度:★★★☆☆ 进阶 | 适用角色:全部 | 前置:01 ~ 06
一、为什么必须学
坑 1:容器起不来,报错看不懂
docker run myapp
# failed to create task for container: failed to create shim task:
# OCI runtime create failed: runc create failed: ...
这条错误链涉及 dockerd → containerd → shim → runc 四个环节。不知道每个环节负责什么,就不知道该查哪里。
坑 2:不知道自己的镜像到底用了什么配置
docker run 一个命令背后有几十个默认决策(用什么网络模式、分配多少内存、开放哪些端口、加载什么 seccomp profile……)。不看清这些,就无法回答"这个容器到底跑在什么权限下"。
坑 3:容器日志是空的,不知道 stdout 去哪了
容器日志由 log driver 捕获,不是文件。理解流程才知道日志存在哪、怎么取。
坑 4:以为 docker run 是一条原子命令
它其实是多个 API 调用的组合,中间任何一环失败都可能留下半成品状态(容器已创建但未启动)。
二、理论:四层调用链
docker CLI
│ HTTP over unix.sock (/var/run/docker.sock)
▼
dockerd ──────── 准备镜像(拉取/解压分层)+ 组装配置 + 调度网络
│ gRPC
▼
containerd ────── 创建 snapshot(upperdir)+ 管理容器生命周期
│ OCI runtime spec
▼
runc ──────────── clone() + unshare() + pivot_root + 设置限制
│ execve(ENTRYPOINT)
▼
容器进程(容器内 PID 1)
│
├─ containerd-shim 持续监控(PID 1 退出 → 通知 dockerd)
├─ log driver 捕获 stdout/stderr
└─ 网络:veth + docker0 + iptables NAT 已就绪
各组件职责
| 组件 |
职责 |
挂了会怎样 |
| docker CLI |
发 API 请求 |
不影响已运行的容器 |
| dockerd |
镜像管理、容器调度、网络编排 |
已运行的容器继续跑(靠 shim) |
| containerd |
容器生命周期、snapshot 管理 |
同上 |
| containerd-shim |
容器进程与 containerd 之间的中间层 |
容器进程被杀(最外层守护) |
| runc |
真正创建进程和 namespace |
容器起不来 |
shim 的关键作用:保证 containerd 自身重启甚至崩溃时,容器进程不会跟着被杀。这是 live-restore 能工作的基础——dockerd 重启不影响业务容器。
三、实操验证
3.1 CLI → daemon 的通道
docker version --format 'Client API: {{.Client.APIVersion}} | Server API: {{.Server.APIVersion}}'
ls -l /var/run/docker.sock
Client API: 1.56 | Server API: 1.56
srw-rw---- 1 root docker 0 Oct 16 08:17 /var/run/docker.sock
unix domain socket,权限 srw-rw---- 属于 root:docker 组——能访问这个 socket 就等于能控制 Docker daemon(等价于宿主 root)。这是重要的安全边界。
3.2 进程树:谁在跑
ps -ef --forest | grep -E "dockerd|containerd|containerd-shim|runc" | grep -v grep | head -12
实测输出:
root 727268 1 0 Oct16 ? 00:05:33 /usr/bin/containerd
root 727279 1 0 Oct16 ? 00:01:40 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
root 779935 1 0 03:07 ? 00:00:00 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 54bcc47f3e442edccd406393052e1e528de8ed808dc5520a41902b4c59ee95fc -address /run/containerd/containerd.sock
root 781013 1 0 03:08 ? 00:00:00 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 9013cef73738fffa3bd55f309645994bbd2fe25e547870eb37675d0e25cdcf5e -address /run/containerd/containerd.sock
三个关键观察:
dockerd 和 containerd 的 PPID 都是 1(由 systemd 管理,互不依赖)
- 每个容器对应一个独立的
containerd-shim-runc-v2 进程,参数里的 -id 就是容器 ID
- shim 的 PPID 也是 1 —— shim 独立于 containerd 存活
第 3 点是 live-restore 的基础:即使 containerd 被 kill,所有 shim 和容器进程都活着。
3.3 抓取 runc 的 INIT 阶段
runc 创建容器时会 fork 一个 init 进程做 namespace 设置和 pivot_root,这个过程非常快。用高频采样能抓到它:
docker rm -f run-lab >/dev/null 2>&1
( for i in $(seq 1 20); do
ps -eo pid,comm | grep -E "runc|shim" | grep -v grep | awk '{print " "$2}'
sleep 0.1
done ) &
MON=$!
docker run -d --name run-lab alpine:3.20 sh -c 'while true; do sleep 30; done' >/dev/null
wait $MON 2>/dev/null
采样中抓到了这一瞬:
t+787109: containerd-shim
t+787136: runc:[2:INIT] ← runc 的 init 阶段!
t+787109: containerd-shim
runc:[2:INIT] 是 runc 初始化进程的进程名。这个阶段它正在做:
unshare() 创建 5 类 namespace
pivot_root 切换根目录
- 挂载
/proc、/sys、/dev 等伪文件系统
- 设置 cgroup、capabilities、seccomp
execve 执行 ENTRYPOINT
这个闪现是"runc 做了什么"的直接证据。
3.4 容器进程的父子关系
CPID=$(docker inspect run-lab --format '{{.State.Pid}}')
ps -o pid,ppid,comm -p $CPID
echo "父进程: $(ps -o comm= -p $(ps -o ppid= -p $CPID | tr -d ' '))"
PID PPID COMMAND
787136 787109 sh
父进程: containerd-shim
容器进程的父进程是 shim,不是 dockerd,也不是 containerd。 这层间接关系就是 shim 存在的意义。
3.5 容器实际生效的配置
docker inspect run-lab | python3 -c "
import json,sys
d=json.load(sys.stdin)[0]
h=d['HostConfig']
print(' IpcMode: ', h.get('IpcMode'))
print(' PidMode: ', h.get('PidMode'))
print(' NetworkMode: ', h.get('NetworkMode'))
print(' Privileged: ', h.get('Privileged'))
print(' CapAdd: ', h.get('CapAdd'))
print(' CapDrop: ', h.get('CapDrop'))
print(' SecurityOpt: ', h.get('SecurityOpt'))
print(' Memory: ', h.get('Memory'))
"
实测输出:
IpcMode: private
PidMode:
NetworkMode: bridge
Privileged: False
CapAdd: None
CapDrop: None
SecurityOpt: None
Memory: 0
这份输出回答了"这个容器到底跑在什么权限下":
| 字段 |
值 |
含义 |
IpcMode |
private |
独立 IPC namespace |
PidMode |
空 |
独立 PID namespace(默认值) |
NetworkMode |
bridge |
默认网桥网络 |
Privileged |
False |
非特权,走 capabilities 裁剪 |
CapAdd/CapDrop |
None |
用 Docker 默认的 14 项能力 |
SecurityOpt |
None |
用内置 seccomp profile |
Memory |
0 |
不限制内存(危险!生产必须显式设置) |
3.6 pivot_root 验证
文件系统隔离的核心是 pivot_root——它把容器的 overlay 挂载设为新的根。
echo "--- 容器内根目录 ---"
docker exec run-lab sh -c 'ls / | tr "\n" " "'
echo
echo "--- 宿主机通过 /proc/PID/root 进入容器根 ---"
ls /proc/$CPID/root/ | tr '\n' ' '
echo
echo "--- 宿主机真实根目录 ---"
ls / | tr '\n' ' '
实测输出:
--- 容器内根目录 ---
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
--- 宿主机通过 /proc/PID/root 进入容器根 ---
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
--- 宿主机真实根目录 ---
afs bin boot dev etc home lib lib64 lost+found media mnt opt proc root run sbin srv swap sys tmp usr var vz
前两者完全一致——/proc/PID/root 就是容器的根(宿主视角的 overlay 挂载,因为 /proc 是共享的)。
宿主机独有的 boot、lib64、lost+found、swap、vz 在容器内不存在,因为容器根本不是宿主机文件系统。
同一路径不同视图的经典对比:
echo "宿主 /proc/$CPID/root/opt : [$(ls /proc/$CPID/root/opt 2>&1 | tr '\n' ' ')]"
echo "宿主自身 /opt : [$(ls /opt | tr '\n' ' ')]"
宿主 /proc/787136/root/opt : []
宿主自身 /opt : [containerd meme-alpha-engine ]
3.7 宿主 root 可以进入任何容器
nsenter -t $CPID -m -p --preserve-credentials sh -c \
'echo "已进入容器: $(cat /etc/os-release | grep NAME=)"; echo "容器内 PID 1: $(cat /proc/1/comm)"'
nsenter: failed to execute sh: No such file or directory
这个失败本身是 informative 的:nsenter 把当前 root 换成了容器的 root(alpine),容器里没有 sh 在 /bin/sh 这个路径上(alpine 的 sh 在 /bin/sh,但宿主的 sh 是 /usr/bin/sh)——这恰恰证明 pivot_root 生效了,宿主的文件系统已经被换掉。
正确方式是用容器内存在的 shell:
nsenter -t $CPID -m -p --preserve-credentials /bin/sh -c \
'echo "已进入容器: $(cat /etc/os-release | grep NAME=)"'
结论:宿主机 root 可以随时进入容器的所有 namespace。这是设计使然(运维需要),也意味着宿主机被攻破 = 所有容器沦陷。
3.8 内核侧的 namespace 集合
ls -l /proc/$CPID/ns/ | awk '{print " "$9" -> "$10" "$11}'
实测输出:
cgroup -> -> cgroup:[4026532707]
ipc -> -> ipc:[4026532705]
mnt -> -> mnt:[4026532703]
net -> -> net:[4026532708]
pid -> -> pid:[4026532706]
pid_for_children -> -> pid:[4026532706]
time -> -> time:[4026532763]
time_for_children -> -> time:[4026532763]
user -> -> user:[4026531837]
uts -> -> uts:[4026532704]
对比宿主机(4026531835~4026531841),除了 user 之外全部是新的 inode。
pid_for_children 和 time_for_children 是"子进程将继承的 namespace"占位符——因为容器内创建的子进程会继承父进程的 namespace,这两个字段指向的就是父进程当前所在的 namespace。
3.9 root 挂载点:overlay 挂载的证据
grep -E ' / / ' /proc/$CPID/mountinfo | head -1 | tr ',' '\n' | head -5
777 737 0:74 / / rw
relatime - overlay overlay rw
lowerdir=/var/lib/containerd/.../snapshots/220/fs:/var/lib/containerd/.../snapshots/1/fs
upperdir=/var/lib/containerd/.../snapshots/221/fs
workdir=/var/lib/containerd/.../snapshots/221/work
容器的 / 就是一个 overlay 挂载点。这是 pivot_root 的目标——把 overlay 设为新根。
3.10 runc 挂载的伪文件系统
docker exec run-lab sh -c 'mount | grep -E "proc|sysfs|cgroup|devpts|tmpfs"'
实测输出:
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
tmpfs on /dev type tmpfs (rw,nosuid,size=65536k,mode=755,inode64)
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=666)
sysfs on /sys type sysfs (ro,nosuid,nodev,noexec,relatime)
cgroup on /sys/fs/cgroup type cgroup2 (ro,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot)
shm on /dev/shm type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k,inode64)
proc on /proc/bus type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/fs type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/irq type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/sys type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/sysrq-trigger type proc (ro,nosuid,nodev,noexec,relatime)
tmpfs on /proc/acpi type tmpfs (ro,relatime,size=4k,nr_inodes=1,inode64)
tmpfs on /proc/interrupts type tmpfs (rw,nosuid,size=65536k,mode=755,inode64)
tmpfs on /proc/kcore type tmpfs (rw,nosuid,size=65536k,mode=755,inode64)
tmpfs on /proc/keys type tmpfs (rw,nosuid,size=65536k,mode=755,inode64)
tmpfs on /proc/scsi type tmpfs (ro,relatime,size=4k,nr_inodes=1,inode64)
tmpfs on /proc/timer_list type tmpfs (rw,nosuid,size=65536k,mode=755,inode64)
tmpfs on /sys/firmware type tmpfs (ro,relatime,size=4k,nr_inodes=1,inode64)
runc 做的挂载工作一目了然:
| 挂载 |
目的 |
权限 |
proc on /proc |
让容器看到自己的进程视图 |
rw |
sysfs on /sys |
设备信息 |
ro |
cgroup2 on /sys/fs/cgroup |
资源控制接口 |
ro |
tmpfs on /dev |
设备节点目录 |
rw, 64MB 限制 |
devpts on /dev/pts |
伪终端(docker exec -it 用) |
rw |
shm on /dev/shm |
POSIX 共享内存 |
rw, 64MB 限制 |
一串 tmpfs 遮罩 /proc/* |
屏蔽危险的内核接口 |
见下 |
最后那批 tmpfs 遮罩最值得注意。内核把 kcore、keys、interrupts、timer_list、scsi 等敏感接口用空 tmpfs 覆盖——这些接口可以泄露宿主机内核内存内容、注入按键、查看宿主机设备,是容器逃逸的常见目标。
runc 默认就做了这些防护,不需要额外配置。
四、完整的 API 调用序列
docker run 实际是多个 API 调用:
POST /images/create?name=alpine:3.20 ← 若本地无镜像,先拉取
POST /containers/create ← 创建容器(此时不启动)
POST /containers/{id}/start ← 启动容器
中间态是真实存在的:
docker create --name demo alpine:3.20
docker ps -a --filter name=demo --format '{{.Status}}'
# Created ← 已创建但未启动
docker start demo
docker ps -a --filter name=demo --format '{{.Status}}'
# Up 1 second
docker create 失败时不会留下容器,docker start 失败时会留下 Created 状态的容器——这就是"清理时看到一堆 Created 容器"的原因。
启动失败时的排查顺序
按错误链从外往里查:
# 1. dockerd 层
systemctl status docker
journalctl -u docker -n 50
# 2. containerd 层
systemctl status containerd
# 3. runc 层(最常见的失败点)
# 错误信息里含 "runc create failed" 就是这一层
# → 检查 seccomp profile / 镜像 entrypoint / 挂载参数
# 4. 应用层
docker logs <c>
docker inspect <c> --format '{{.State.ExitCode}}'
五、日志与状态
日志去向
容器 stdout/stderr 由 log driver 捕获,不写文件。默认是 json-file:
docker inspect <c> --format '{{.LogPath}}'
日志文件在宿主机上是 JSON 格式,每行一条:
{"log":"输出内容\n","stream":"stdout","time":"2026-10-20T03:15:00.123456789Z"}
注意:这个文件不在容器的文件系统里,在宿主机 /var/lib/docker/containers/<id>/ 下。容器删了日志也没了。
状态速查
docker inspect <c> --format '{{.State.Status}}' # created/running/exited/paused
docker inspect <c> --format '{{.State.ExitCode}}' # 退出码
docker inspect <c> --format '{{.State.OOMKilled}}' # 是否 OOM
docker inspect <c> --format '{{.State.Error}}' # 错误信息
docker inspect <c> --format '{{.State.StartedAt}}'
docker inspect <c> --format '{{.State.FinishedAt}}'
| 退出码 |
含义 |
| 0 |
正常退出 |
| 1 / 2 |
应用自身错误 |
| 125 |
docker run 本身出错 |
| 126 |
找到命令但无法执行(权限) |
| 127 |
命令不存在(entrypoint 写错) |
| 137 |
SIGKILL(stop 超时强杀 / OOM) |
| 139 |
SIGSEGV(段错误) |
六、常见误解
误解 1:docker run 是一条原子操作
不是。 它是多个 API 调用的组合。docker create 和 docker start 之间存在中间态,容器可能停留在 Created 状态。
误解 2:容器进程的父进程是 dockerd
错。 实测父进程是 containerd-shim。
PID PPID COMMAND
787136 787109 sh
父进程: containerd-shim
误解 3:dockerd 挂了容器就全挂了
不一定。 容器进程的父进程是 shim,shim 独立于 dockerd 和 containerd 存活。
这就是 live-restore 的价值——dockerd 重启期间容器继续运行。shim 是最外层的守护者。
误解 4:容器日志在容器的文件系统里
不是。 日志在宿主机 /var/lib/docker/containers/<id>/<id>-json.log,容器内看不到。容器删了日志一起没。
误解 5:runc 只做了"启动进程"这一件事
远远不止。 实测看到的挂载表显示 runc 做了大量工作:创建 5 类 namespace、pivot_root、挂载 20+ 个伪文件系统、设置 cgroup 限额、裁剪 capabilities、加载 seccomp BPF、设置 NoNewPrivs、关闭继承的 fd、setuid 到容器用户……
runc:[2:INIT] 这个瞬时进程名,就是这些工作的执行者。
误解 6:容器内能看到宿主机的 /proc/kcore
看不到。 runc 用空 tmpfs 遮罩了 /proc/kcore、/proc/keys、/proc/interrupts 等敏感接口。这些接口能泄露宿主机内核内存内容,是容器逃逸的常见目标。
七、本篇速查
# 组件与进程
ps -ef --forest | grep -E "dockerd|containerd|shim|runc" | grep -v grep
systemctl status docker containerd
# socket 与 API
ls -l /var/run/docker.sock
docker version
# 容器实际生效配置
docker inspect <c> | python3 -c "
import json,sys
d=json.load(sys.stdin)[0]['HostConfig']
for k in ['IpcMode','PidMode','NetworkMode','Privileged','CapAdd','CapDrop','SecurityOpt','Memory']:
print(f' {k}: {d.get(k)}')
"
# pivot_root 验证
CPID=$(docker inspect <c> --format '{{.State.Pid}}')
ls /proc/$CPID/root/
ls /proc/$CPID/root/opt
ls /opt
# 宿主进入容器
nsenter -t $CPID -m -p --preserve-credentials /bin/sh -c 'cat /etc/os-release'
# 内核侧 namespace
ls -l /proc/$CPID/ns/
# root 挂载点
grep -E ' / / ' /proc/$CPID/mountinfo | head -1
# runc 挂载的伪文件系统
docker exec <c> sh -c 'mount | grep -E "proc|sysfs|cgroup|devpts|tmpfs"'
# 容器日志
docker inspect <c> --format '{{.LogPath}}'
docker logs --tail 50 --timestamps <c>
# 状态与退出码
docker inspect <c> --format '{{.State.Status}} {{.State.ExitCode}} {{.State.OOMKilled}}'
上一篇:06 · 网络实现:veth、网桥与 iptables
返回:专题索引
相关:Docker 基础与容器生命周期 | 容器的基本实现原理(原始整合版)