mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1107 字
3 分钟
容器完整流程:docker run 背后
2021-09-16

docker run -d -p 80:80 nginx,一条命令,0.3 秒,一个运行中的 Nginx 容器。但在这 0.3 秒内,发生了什么?

Docker CLI 解析参数,通过 HTTP API 调用 dockerd;dockerd 检查本地镜像,不存在则从 Registry 拉取;拉取完成后,containerd 解压镜像层到 OverlayFS;containerd 启动 containerd-shim;shim 调用 runc,runc 经 clone 启动 runc init 子进程,在新 Namespace 中挂载 rootfs、配置 Cgroup、设置 seccomp、执行容器进程;shim 收集容器 PID 并返回给 containerd;containerd 通知 dockerd;dockerd 配置端口映射;Docker CLI 返回容器 ID。

下面完整追踪这条调用链的每一步,把前 8 章的知识串起来。

一、docker run 的完整调用链#

1.1 调用链总览#

sequenceDiagram participant CLI as Docker CLI participant DOCKERD as dockerd participant CTNRD as containerd participant SHIM as containerd-shim participant RUNC as runc participant KERNEL as Linux 内核 CLI->>DOCKERD: 1. POST /containers/create DOCKERD->>CTNRD: 2. Pull image (if needed) CTNRD->>DOCKERD: 3. Image ready DOCKERD->>CTNRD: 4. Create container CTNRD->>CTNRD: 5. Unpack image → OverlayFS CTNRD->>SHIM: 6. Start shim process SHIM->>RUNC: 7. runc create RUNC->>KERNEL: 8. clone(CLONE_NEWPID|CLONE_NEWNS|...) KERNEL-->>RUNC: 新 Namespace 中的进程 RUNC->>KERNEL: 9. mount overlayfs RUNC->>KERNEL: 10. pivot_root RUNC->>KERNEL: 11. write cgroup files RUNC->>KERNEL: 12. prctl(SECCOMP_SET_MODE_FILTER) RUNC-->>SHIM: 容器已创建 SHIM-->>CTNRD: PID + 状态 CTNRD-->>DOCKERD: 容器已创建 CLI->>DOCKERD: 13. POST /containers/{id}/start DOCKERD->>CTNRD: 14. Start task CTNRD->>SHIM: 15. runc start SHIM->>RUNC: 16. runc start RUNC->>KERNEL: 17. 执行用户命令 KERNEL-->>SHIM: 容器运行中 SHIM-->>CTNRD: 容器运行中 CTNRD-->>DOCKERD: 容器运行中 DOCKERD->>DOCKERD: 18. 配置端口映射 (iptables) DOCKERD-->>CLI: 容器 ID

1.2 涉及的进程#

进程PID 示例角色生命周期
docker CLI10000命令行客户端命令执行期间
dockerd1000Docker daemon持久运行
containerd2000容器运行时管理持久运行
containerd-shim3000容器进程监督容器运行期间
runc (create)4000OCI 运行时创建后退出
runc init4001容器初始化初始化后 exec 为用户进程
nginx4001→容器进程容器运行期间

二、阶段一:镜像拉取#

2.1 检查本地镜像#

// dockerd 检查本地镜像(简化)
func (daemon *Daemon) CreateContainer(params CreateParams) (*Container, error) {
// 1. 解析镜像名称
ref, err := reference.ParseNormalizedNamed(params.Image)
// 2. 查找本地镜像
image, err := daemon.imageStore.Get(ref)
if err != nil {
// 本地不存在,拉取镜像
image, err = daemon.PullImage(ref)
}
// 3. 创建容器配置
container, err := daemon.newContainer(image, params)
return container, nil
}

2.2 镜像拉取流程#

# docker pull nginx:latest 的底层操作
# 1. 解析镜像名称
# nginx:latest → docker.io/library/nginx:latest
# 2. 获取 Manifest
# GET https://registry-1.docker.io/v2/library/nginx/manifests/latest
# 3. 下载 Config blob
# GET https://registry-1.docker.io/v2/library/nginx/blobs/sha256:abc123...
# 4. 下载 Layer blob(按需,跳过已有层)
# GET https://registry-1.docker.io/v2/library/nginx/blobs/sha256:def456...
# 5. 解压 Layer 到 OverlayFS
# tar -xzf layer1.tar.gz -C /var/lib/docker/overlay2/l1/diff
# tar -xzf layer2.tar.gz -C /var/lib/docker/overlay2/l2/diff

2.3 镜像层解压与 OverlayFS#

# 查看 nginx 镜像的层
docker inspect nginx --format '{{json .RootFS.Layers}}' | python3 -m json.tool
# 输出示例(6 层):
# "sha256:a1b2c3d4..."
# "sha256:e5f6g7h8..."
# "sha256:i9j0k1l2..."
# "sha256:m3n4o5p6..."
# "sha256:q7r8s9t0..."
# "sha256:u1v2w3x4..."
# 每层对应 OverlayFS 的一个 lowerdir
ls /var/lib/docker/overlay2/l/
# 每个短链接指向一个层的 diff 目录

三、阶段二:容器创建#

3.1 dockerd 生成容器配置#

dockerd 将 Docker 参数转换为 OCI Runtime Spec 格式的 config.json:

// dockerd 生成容器配置(简化)
func (daemon *Daemon) createContainerSpec(container *Container) (*specs.Spec, error) {
spec := &specs.Spec{
Version: specs.Version,
Process: &specs.Process{
Args: container.Config.Entrypoint + container.Config.Cmd,
Env: container.Config.Env,
Cwd: container.Config.WorkingDir,
User: container.Config.User,
Terminal: container.Config.Tty,
},
Root: &specs.Root{
Path: "rootfs",
Readonly: container.HostConfig.ReadonlyRootfs,
},
Hostname: container.Config.Hostname,
Mounts: daemon.generateMounts(container),
Linux: &specs.Linux{
Namespaces: daemon.generateNamespaces(container),
Resources: daemon.generateResources(container),
Seccomp: daemon.generateSeccomp(container),
},
}
return spec, nil
}

3.2 Docker 参数到 OCI Spec 的映射#

Docker 参数OCI Spec 字段说明
-e FOO=barprocess.env环境变量
-u nginxprocess.user运行用户
-h myhosthostname主机名
--memory=512mlinux.resources.memory.limit内存限制
--cpus=2linux.resources.cpu.quota/periodCPU 限制
--cap-add=NET_ADMINprocess.capabilitiesLinux Capabilities
--security-opt seccomp=...linux.seccompseccomp 规则
--pid=hostlinux.namespaces (无 PID NS)共享宿主 PID NS
--network=hostlinux.namespaces (无 Network NS)共享宿主网络
--read-onlyroot.readonly只读根文件系统
-v /host:/containermounts挂载点

3.3 containerd 创建容器#

// containerd 创建容器(简化)
func (m *TaskManager) Create(ctx context.Context, id string, spec *specs.Spec) error {
// 1. 准备 rootfs 快照
mounts, err := m.snapshotter.Prepare(ctx, id, parentSnapshot)
// 这一步创建 OverlayFS 的 upperdir
// 2. 写入 config.json
if err := writeConfig(bundlePath, spec); err != nil {
return err
}
// 3. 启动 shim
shim, err := m.startShim(ctx, bundle, id)
// shim 进程启动后,通过 RPC 与 containerd 通信(shim v1 走 gRPC,v2 走 ttrpc)
// 4. 通过 shim 创建容器
_, err = shim.Create(ctx, &task.CreateRequest{
ID: id,
Bundle: bundlePath,
Rootfs: mounts,
Terminal: spec.Process.Terminal,
})
return err
}

四、阶段三:runc 创建容器#

4.1 runc create 的系统调用#

# 用 strace 追踪 runc create 的关键系统调用
sudo strace -f -e trace=clone,unshare,mount,pivot_root,prctl,capset,write \
-o /tmp/runc-create.log runc create --bundle /tmp/bundle mycontainer
# 关键系统调用序列(简化):
# 1. clone(CLONE_NEWPID|CLONE_NEWNS|CLONE_NEWUTS|CLONE_NEWIPC|CLONE_NEWNET)
# → 创建新 Namespace 中的子进程
# 2. mount("overlay", "/var/lib/docker/overlay2/.../merged", "overlay", ...)
# → 挂载 OverlayFS
# 3. mount("proc", "/proc", "proc", ...)
# → 挂载 procfs
# 4. pivot_root("/var/lib/docker/overlay2/.../merged", ...)
# → 切换根文件系统
# 5. write(5, "200000 100000", ..., "cpu.max")
# → 设置 CPU Cgroup
# 6. write(5, "536870912", ..., "memory.max")
# → 设置内存 Cgroup
# 7. capset(CAP_SETPCAP, {CAP_AUDIT_WRITE, CAP_KILL, ...})
# → 设置 Capabilities
# 8. prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...)
# → 设置 seccomp 过滤
# 9. execve("/docker-entrypoint.sh", ["nginx", "-g", "daemon off;"], ...)
# → 执行用户命令

4.2 容器创建的完整时序#

sequenceDiagram participant SHIM as containerd-shim participant RUNC as runc participant INIT as runc init participant KERNEL as Linux 内核 Note over SHIM,INIT: 容器创建+启动的完整时序(runc create 只落状态文件,真正的 clone/挂载/限流在 runc init 里完成) SHIM->>RUNC: exec: runc run <id>(含 create + start) RUNC->>KERNEL: 1. clone(CLONE_NEWPID|CLONE_NEWNS|...) KERNEL->>INIT: 在新 Namespace 中启动 Note over INIT: 以下操作在新 Namespace 中 INIT->>KERNEL: 2. mount("overlay", merged, "overlay", lowerdir=...:upperdir=...) INIT->>KERNEL: 3. mount("proc", "/proc", "proc", 0, "") INIT->>KERNEL: 4. mount("sysfs", "/sys", "sysfs", MS_RDONLY, "") INIT->>KERNEL: 5. mount("devtmpfs", "/dev", "devtmpfs", 0, "") INIT->>KERNEL: 6. mount("tmpfs", "/dev/shm", "tmpfs", 0, "") INIT->>KERNEL: 7. mount("tmpfs", "/run", "tmpfs", 0, "") INIT->>KERNEL: 8. mount("cgroup2", "/sys/fs/cgroup", "cgroup2", 0, "") INIT->>KERNEL: 9. pivot_root(merged, put_old) Note over INIT: 根文件系统切换完成 INIT->>KERNEL: 10. sethostname("mycontainer") INIT->>KERNEL: 11. write("200000 100000" → cpu.max) INIT->>KERNEL: 12. write("536870912" → memory.max) INIT->>KERNEL: 13. write(PID → cgroup.procs) Note over INIT: 这里为叙述方便把 cgroup 限额写入画在 pivot_root 后;runc libcontainer 的 Init() 实际在 prepareRootfs 之前就调用 setupCgroup(详见 [runc 源码](./06-runc源码.md) 3.2 节),两者顺序以 06 篇代码为准 INIT->>KERNEL: 14. capset(bounding={CAP_AUDIT_WRITE, CAP_KILL, ...}) INIT->>KERNEL: 15. prctl(PR_SET_NO_NEW_PRIVS, 1) INIT->>KERNEL: 16. prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, bpf_prog) INIT->>KERNEL: 17. setuid(101) / setgid(101) INIT->>KERNEL: 18. execve("/docker-entrypoint.sh", args, env) Note over KERNEL: 容器进程开始运行 RUNC-->>SHIM: 容器已创建,PID = 4001

五、阶段四:容器启动#

5.1 runc start#

# runc start 发送信号让 runc init 执行用户命令
runc start mycontainer
# 底层操作:
# 1. 通过管道通知 runc init 执行用户命令
# 2. runc init 调用 execve() 替换为用户进程
# 3. 容器进程开始运行

5.2 端口映射#

# dockerd 配置端口映射(-p 80:80)
# 以下命令只是模拟 dockerd 的行为,实际由 dockerd 的 iptables wrapper 自动管理,
# 请勿手动添加,否则会和 dockerd 自己维护的规则冲突
# 1. 创建 iptables DNAT 规则
sudo iptables -t nat -A DOCKER -p tcp --dport 80 -j DNAT --to-destination 172.17.0.2:80
# 2. 创建 iptables MASQUERADE 规则
sudo iptables -t nat -A POSTROUTING -s 172.17.0.2 -j MASQUERADE
# 3. 创建 iptables ALLOW 规则
sudo iptables -A DOCKER -d 172.17.0.2 -p tcp --dport 80 -j ACCEPT
# 查看 Docker 的 iptables 规则
sudo iptables -t nat -L DOCKER -n -v

5.3 网络配置#

# Docker 的网络配置流程
# 1. 创建 veth pair
sudo ip link add veth123 type veth peer name veth456
# 2. 将一端放入容器 Network Namespace
sudo ip link set veth456 netns <container-pid>
# 3. 将另一端连接到 docker0 bridge
sudo ip link set veth123 master docker0
# 4. 在容器内配置 IP 地址
sudo nsenter -t <container-pid> -n ip addr add 172.17.0.2/16 dev eth0
# 5. 在容器内设置默认路由
sudo nsenter -t <container-pid> -n ip route add default via 172.17.0.1

六、阶段五:容器运行#

6.1 容器运行时的进程与 Namespace 关系#

容器启动后,宿主机上可以看到完整的进程和 Namespace 映射:

graph TB subgraph 宿主机["宿主机视角"] SYSTEMD["systemd<br/>PID 1"] DOCKERD["dockerd<br/>PID 1000"] CTNRD["containerd<br/>PID 2000"] SHIM["containerd-shim<br/>PID 3000"] NGINX["nginx<br/>PID 4001 (宿主 PID)"] end subgraph 容器内["容器内视角"] PID1["PID 1 (nginx master)"] PID2["PID 10 (nginx worker)"] end SYSTEMD --> DOCKERD --> CTNRD --> SHIM --> NGINX NGINX -.->|"PID Namespace 映射"| PID1 NGINX -.->|"PID Namespace 映射"| PID2 subgraph NS隔离["Namespace 隔离"] NET["Network NS: 172.17.0.2"] MNT["Mount NS: OverlayFS merged"] UTS["UTS NS: hostname=mycontainer"] IPC["IPC NS: 独立消息队列"] CG["Cgroup NS: /docker/abc123"] end style 宿主机 fill:#e8eaf6,stroke:#283593 style 容器内 fill:#e0f2f1,stroke:#00695c style NS隔离 fill:#fff3e0,stroke:#e65100

6.2 容器运行时的状态#

# 查看容器的完整状态
docker inspect mynginx | python3 -m json.tool
# 关键信息:
# - State.Pid: 容器进程在宿主机上的 PID
# - State.Running: 是否运行中
# - NetworkSettings.IPAddress: 容器 IP
# - HostConfig.PortBindings: 端口映射
# - HostConfig.Memory: 内存限制
# - HostConfig.NanoCpus: CPU 限制

6.3 容器进程的 Namespace#

# 查看容器进程的所有 Namespace
PID=$(docker inspect -f '{{.State.Pid}}' mynginx)
ls -la /proc/$PID/ns/
# 输出:
# cgroup -> 'cgroup:[4026532700]' ← 独立 Cgroup NS
# ipc -> 'ipc:[4026532698]' ← 独立 IPC NS
# mnt -> 'mnt:[4026532696]' ← 独立 Mount NS
# net -> 'net:[4026532699]' ← 独立 Network NS
# pid -> 'pid:[4026532697]' ← 独立 PID NS
# user -> 'user:[4026531837]' ← 共享宿主 User NS
# uts -> 'uts:[4026532695]' ← 独立 UTS NS

6.4 容器进程的 Cgroup#

# 查看容器的 Cgroup 路径
cat /proc/$PID/cgroup
# 输出(Cgroup v2):
# 0::/system.slice/docker-abc123.scope
# 查看容器的资源限制
cat /sys/fs/cgroup/system.slice/docker-abc123.scope/cpu.max
cat /sys/fs/cgroup/system.slice/docker-abc123.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-abc123.scope/pids.max

七、完整流程的代码追踪#

用 containerd 的 Go 客户端可以模拟 docker run 的核心调用链。下面只保留关键步骤,完整程序见 containerd 官方 client 示例。

package main
import (
"context"
"log"
"os"
containerd "github.com/containerd/containerd/v2/client"
"github.com/containerd/containerd/v2/pkg/namespaces"
specs "github.com/opencontainers/runtime-spec/specs-go"
)
func main() {
ctx := namespaces.WithNamespace(context.Background(), "default")
client, err := containerd.New("/run/containerd/containerd.sock")
if err != nil {
log.Fatal(err)
}
defer client.Close()
// 1. 拉取镜像(WithPullUnpack 会自动解压层到 OverlayFS)
image, err := client.Pull(ctx, "docker.io/library/nginx:alpine",
containerd.WithPullUnpack)
// 2. 创建容器(生成 OCI Bundle + 快照)
container, err := client.NewContainer(ctx, "my-nginx",
containerd.WithImage(image),
containerd.WithNewSnapshot("nginx-snapshot", image),
containerd.WithNewSpec(containerd.WithImageConfig(image), withResourceLimits()))
defer container.Delete(ctx, containerd.WithSnapshotCleanup)
// 3. 创建并启动任务(shim 调用 runc create + start)
task, err := container.NewTask(ctx, containerd.NewIO(os.Stdin, os.Stdout, os.Stderr))
defer task.Delete(ctx)
task.Start(ctx)
// 4. 等待退出
statusC, _ := task.Wait(ctx)
status := <-statusC
log.Printf("exited with code %d", status.ExitCode())
}
func withResourceLimits() containerd.SpecOpts {
return func(_ context.Context, _ *containerd.Client, c *specs.Spec) error {
c.Linux.Resources = &specs.LinuxResources{
Memory: &specs.LinuxMemory{Limit: pInt64(512 * 1024 * 1024)},
CPU: &specs.LinuxCPU{Quota: pInt64(200000), Period: pUint64(100000)},
}
return nil
}
}
func pInt64(v int64) *int64 { return &v }
func pUint64(v uint64) *uint64 { return &v }

八、性能分析#

8.1 docker run 各阶段耗时#

gantt title docker run 各阶段耗时(镜像已存在) dateFormat X axisFormat %Lms section OCI Bundle 生成 config.json :0, 5 section 运行时 shim 启动 (fork+exec) :5, 25 runc create (NS+Cgroup+mount) :25, 125 runc start (execve) :125, 130 section 网络 veth/bridge/iptables :130, 180 section 总计 容器就绪 :milestone, 180
阶段典型耗时说明
镜像拉取1-60s取决于网络和镜像大小
镜像解压0.1-5s取决于层数和大小
OCI Bundle 生成1-5ms生成 config.json
shim 启动5-20msfork + exec
runc create1-5ms落状态文件、构造容器对象
runc start(runc init)20-100msclone 进新 Namespace、挂载 rootfs、写 Cgroup
网络配置5-50msveth/bridge/iptables
总计(镜像已存在)50-200ms

上面的耗时是 4 核 8G VM、SSD、nginx:alpine 镜像上的量级参考,实际数值随宿主配置和镜像大小浮动。开篇说的”0.3 秒”包含了 CLI 启动、socket 往返等 I/O 等待,比纯内核侧的 50-200ms 略高。

8.2 优化建议#

# 1. 使用更小的基础镜像
docker run -d alpine-based-image # 比 Ubuntu 镜像快 5-10x
# 2. 预拉取镜像
docker pull nginx:latest # 提前拉取,避免运行时等待
# 3. 使用 --init 避免 PID 1 问题
docker run --init nginx # 使用 tini 作为 PID 1
# 4. 使用 host 网络模式(跳过网络配置)
docker run --network=host nginx # 省去 veth/bridge 配置
# 5. 限制日志大小
docker run --log-driver=json-file --log-opt max-size=10m nginx

九、本章小结#

上一章深入解读了 containerd-shim 的解耦机制的内部机制。

阶段关键操作涉及组件
镜像拉取Registry API → Content Store → Snapshotcontainerd
容器创建生成 OCI Bundle → 启动 shimcontainerd
runc createclone → mount → pivot_root → cgroup → seccomprunc + 内核
runc startexecve 用户命令runc
网络配置veth pair → bridge → iptablesdockerd
容器运行shim 监督 → 事件通知shim + containerd
Note

容器启动失败时,按调用链逐段定位:镜像拉取失败看 docker pull 报错;runc create 失败看 journalctl -u containerd(常见原因:OverlayFS 挂载失败、Namespace 创建被 seccomp 拒绝、Cgroup 路径不存在);容器一启动就退出,先用 docker run -it 交互模式确认入口命令是否正确。

Tip

排查容器启动失败的常用方法:docker logs 查看容器日志,docker inspect 查看容器状态和退出码,journalctl -u containerd 查看 containerd 日志,dmesg 查看内核日志(OOM、seccomp 拒绝等)。如果容器一启动就退出,先用 docker run -it 交互模式运行,确认入口命令是否正确。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

容器完整流程:docker run 背后
https://blog.souloss.cn/posts/container-runtime/container-complete-workflow/
作者
Souloss
发布于
2021-09-16
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时