Docker 基础体系 · 第 2/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker 与 OCI 架构:CLI、Daemon、containerd、runc 和隔离边界

Docker 容器经常被简化为“执行一条 docker run 命令”。但这条命令实际上跨越了多个层次:

用户
 │
 ├─ docker CLI
 │      │ Docker Engine API
 ▼      ▼
Docker Daemon(dockerd)
 │
 ├─ 镜像、网络、卷、权限与生命周期编排
 │
 └─ containerd
        │
        ├─ 镜像元数据与内容
        ├─ snapshotter
        ├─ task 管理
        └─ containerd-shim-runc-v2
                 │
                 └─ runc
                        │
                        └─ Linux kernel
                           namespaces / cgroups / capabilities /
                           seccomp / LSM / mounts

这几个组件解决的是不同问题:

  • CLI:面向用户的命令行客户端,负责把命令转换为 Docker Engine API 请求。
  • Daemon(dockerd:Docker 的控制平面,负责认证请求、解析参数、管理镜像、网络、卷和容器生命周期。
  • containerd:通用容器运行时管理器,负责镜像内容、文件系统快照、容器任务以及运行时适配。
  • runc:符合 OCI Runtime Specification 的低层运行时,负责把一个 OCI runtime bundle 变成 Linux 进程。
  • OCI:开放容器标准集合,不是一个守护进程,也不是 Docker 的另一个实现名称。
  • Linux kernel:真正实施 namespace、cgroup、系统调用过滤和权限边界的部分。

理解这些边界,才能解释诸如“为什么 dockerd 重启后容器仍在运行”“为什么删除镜像不一定影响运行中的容器”“为什么容器不是虚拟机”“为什么 OCI 镜像可以被不同运行时使用”等问题。


1. OCI 是规范,不是一个进程

OCI,即 Open Container Initiative,主要定义了容器生态中两个相互关联但不同的对象:

  1. Image Specification:描述如何表示镜像。
  2. Runtime Specification:描述如何启动和管理一个容器运行时实例。

Docker 是 OCI 生态中的一个实现和使用者,但 Docker 命令、Docker Engine API、Compose 文件格式并不等于 OCI 规范。

例如,以下对象属于不同层次:

对象 作用
Dockerfile Docker/BuildKit 使用的构建输入
Compose 文件 Compose 工具用于描述应用和服务
OCI image manifest 描述镜像配置和层的内容寻址清单
OCI image config 描述默认命令、环境变量、工作目录等镜像配置
OCI runtime config.json 描述一次具体运行实例的 namespace、挂载、能力等
Linux 进程 内核实际调度和隔离的执行实体

Dockerfile 不会直接交给 runc,Compose 文件也不会直接成为 OCI runtime 配置。中间必须经过 Docker、BuildKit、containerd 等组件的转换和编排。


2. 从 docker run 到进程:完整数据流

执行:

docker run --rm \
  --name demo \
  -e GREETING=hello \
  alpine:3.20 \
  sh -c 'echo "$GREETING"; sleep 30'

表面上只有一条命令,实际流程可以抽象为:

sequenceDiagram
    participant U as 用户
    participant C as docker CLI
    participant D as dockerd
    participant CT as containerd
    participant S as snapshotter
    participant SH as containerd-shim-runc-v2
    participant R as runc
    participant K as Linux kernel
    participant P as 容器进程

    U->>C: docker run 参数
    C->>D: Docker Engine API 请求
    D->>CT: 拉取/准备镜像与容器任务
    CT->>S: 准备 rootfs 快照
    S-->>CT: 可写容器文件系统
    CT->>SH: 创建 task
    SH->>R: create/start OCI bundle
    R->>K: clone/setns/mount/cgroup/seccomp
    K-->>R: 创建并配置进程
    R-->>SH: 返回启动结果
    SH-->>CT: task 状态与退出码
    CT-->>D: 容器状态
    D-->>C: API 响应
    C-->>U: 输出 stdout/stderr
    K->>P: 执行 sh -c ...

这条链路中有几个重要的因果关系:

  1. CLI 通常不直接创建 Linux namespace。
  2. dockerd 通常不直接执行每个容器的用户进程。
  3. containerd 管理容器任务和镜像/快照,但不提供 Docker CLI 的全部语义。
  4. runc 负责一次低层运行时操作,通常不是长期驻留的容器管理守护进程。
  5. Linux kernel 才是实际执行隔离的地方。

2.1 CLI:客户端,不是容器运行时

docker CLI 负责:

  • 解析命令行参数;
  • 根据 Docker context 选择目标 Engine;
  • 通过 Unix socket、TCP 或其他配置的传输方式调用 Docker Engine API;
  • 处理交互式终端、日志显示和退出码。

本机常见的连接方式是:

docker context show
docker version
docker info

典型情况下,CLI 通过:

/var/run/docker.sock

访问本机 Docker Daemon。也可以通过远程 context 访问另一台主机上的 Docker Engine。

因此,下面两种情况必须区分:

docker ps

与:

docker -H ssh://user@host ps

第一条通常查询本机 Daemon,第二条查询远程主机上的 Daemon。CLI 所在的机器不一定是容器真正运行的机器。

如果 CLI 报错:

Cannot connect to the Docker daemon

这只说明 CLI 无法完成 Engine API 通信,不能直接推出“容器都停止了”。可能原因包括:

  • Daemon 未启动;
  • socket 权限不足;
  • context 指向错误;
  • 远程地址不可达;
  • TLS 配置不匹配。

2.2 Daemon:Docker 的控制平面

dockerd 是 Docker Engine 的长期运行进程。它向 CLI 提供 Docker API,并负责把 Docker 语义落实到下层组件。

它通常处理以下工作:

  • 镜像拉取、标记、删除和分发;
  • 容器创建、启动、停止、删除;
  • 网络和端口发布;
  • 卷的创建和挂载;
  • 日志驱动;
  • Docker API 的认证和授权边界;
  • 构建请求与 BuildKit 的协作;
  • 容器重启策略;
  • 对 containerd 的调用和状态同步。

“Daemon 管理容器”是控制平面意义上的说法。它并不意味着 dockerd 是每个容器进程的父进程,也不意味着容器退出时必须由 dockerd 直接收割。

现代 Docker Engine 通常通过 containerd 管理底层容器任务。具体进程树和组件参数会随 Docker Engine、containerd 和运行时版本变化,因此不应把某一台机器上的 ps 输出当成规范保证。

2.3 containerd:容器任务与内容管理层

containerd 是一个独立的容器运行时管理项目。它的职责比“执行一个容器进程”更宽,通常包括:

  • 镜像内容存储与拉取;
  • 内容寻址对象管理;
  • 镜像元数据;
  • snapshotter 管理文件系统快照;
  • 容器任务(task)生命周期;
  • shim 管理;
  • 与低层 OCI runtime 的连接。

containerd 的 API 和 Docker Engine API 不是同一个 API。直接使用 containerd 的工具,例如 ctrnerdctl,看到的命名空间、镜像标签和容器对象,也不一定与 Docker CLI 的对象完全等价。

例如,Docker 使用的 containerd namespace 通常与 Kubernetes 使用的 namespace 不同。一个 namespace 中可见的镜像,不必然在另一个 namespace 中可见。

因此:

docker images

和:

ctr images ls

不能简单理解为“用两个命令查看同一个镜像列表”。它们可能使用不同的元数据空间、标签体系和权限边界。

2.4 shim:让容器任务脱离 Daemon 直接存活

现代 containerd 通常使用 shim,常见实现为:

containerd-shim-runc-v2

shim 位于 containerd 与 runc/容器进程之间,承担任务监控、stdio、退出状态和父子进程关系等职责。

它带来的一个关键效果是:

dockerd 或 containerd 的控制面重启,不必然导致已经运行的容器进程退出。

原因是容器进程由 Linux kernel 调度,并由 shim 保持任务相关状态。控制面重新连接后,可以重新查询这些任务的状态。

但这不是“任何故障都不影响容器”的保证。以下情况仍可能导致容器退出:

  • 宿主机内核崩溃或重启;
  • 容器主进程自身退出;
  • OOM killer 杀死进程;
  • 关键 cgroup、挂载或设备配置失败;
  • shim 或运行时发生影响任务的故障;
  • 容器使用的宿主资源消失。

3. OCI Image Specification:镜像是内容寻址对象集合

OCI 镜像不是一个单一的压缩文件。它通常由以下对象组成:

Index(可选,多平台)
 └─ Manifest
     ├─ Config descriptor
     └─ Layer descriptors
          ├─ layer 1
          ├─ layer 2
          └─ ...

3.1 Descriptor 和 digest

OCI 使用 descriptor 引用内容。一个 descriptor 通常包含:

  • mediaType:内容类型;
  • digest:内容摘要,例如 sha256:...
  • size:内容大小。

设内容字节序列为 BB,其内容地址为:

d=SHA256(B)d = \operatorname{SHA256}(B)

如果两个对象的字节完全相同,则它们得到相同的 digest;如果 digest 不同,则不能把它们当作同一个内容对象。

这解释了镜像的内容寻址特性:

  • 标签是可变的人类可读名称;
  • digest 是针对具体内容的不可变引用;
  • latest 不是版本号,也不是不可变保证;
  • image@sha256:... 比单独使用标签更适合精确复现。

可以查看镜像摘要:

docker image inspect alpine:3.20 \
  --format '{{json .RepoDigests}}'

注意,仓库摘要与本地镜像 ID 可能不是同一个层次的标识。镜像配置、manifest、index 和各层都有各自的 digest。

3.2 Manifest、Config 与 Layer 的区别

一个简化的 OCI image manifest 类似:

{
  "schemaVersion": 2,
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:CONFIG...",
    "size": 1234
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:LAYER1...",
      "size": 5678
    }
  ]
}

这里:

  • manifest 说明有哪些对象组成镜像;
  • config 保存镜像配置和历史信息;
  • layer 保存文件系统变更;
  • index 可以按平台选择不同 manifest,例如 linux/amd64linux/arm64

镜像 config 中可能包含:

  • 默认环境变量;
  • 默认工作目录;
  • 默认用户;
  • Entrypoint
  • Cmd
  • rootfs 的 diff IDs;
  • 历史构建信息。

这些配置是镜像默认值,不等于宿主机已经实施的隔离配置。运行容器时,Docker 还要把命令行参数、网络设置、挂载、资源限制等合并为一个具体运行实例。

3.3 Layer 不是最终 rootfs

设基础层为 L1L_1,后续变更层为 L2,L3L_2, L_3。逻辑文件系统可以表示为:

R=L1L2L3R = L_1 \oplus L_2 \oplus L_3

其中 \oplus 不是普通集合相加,而是带有覆盖和删除语义的分层合并:

  • 后层的同名文件覆盖前层文件;
  • whiteout 标记表示删除低层文件;
  • 不同层中的相同内容可以被内容寻址存储复用。

容器启动时,运行时通常需要一个可写层:

只读镜像层
   lower layers
        +
容器可写层
        =
容器 rootfs 视图

在 Linux 上,常见实现是 OverlayFS 或其他 snapshotter,但 OCI Image Specification 并不要求必须使用 OverlayFS。OCI 规定镜像内容和层的表示方式,不规定宿主机必须采用哪种存储驱动。

镜像层大小也不能直接等于最终磁盘占用。因为:

  1. 多个镜像可能共享同一层;
  2. 压缩传输大小和解压后大小不同;
  3. snapshotter 可能使用 copy-on-write;
  4. 容器可写层的修改不回写镜像;
  5. 删除大文件只会在新层中记录删除标记,旧层中的数据仍存在。

因此,在另一个镜像关联主题中,必须区分:

镜像层的压缩传输大小
镜像内容的解压大小
本机共享后的实际占用
容器可写层的额外占用

4. OCI Runtime Specification:一次运行实例的契约

OCI Runtime Specification 定义的是一个运行时 bundle。最小概念上,它包含:

bundle/
├── config.json
└── rootfs/

其中:

  • rootfs/ 是容器看到的根文件系统;
  • config.json 是该运行实例的配置。

这个 config.json 与镜像 config 不是同一个文件。

4.1 镜像配置如何变成运行时配置

假设镜像默认配置为:

{
  "config": {
    "Entrypoint": ["/bin/app"],
    "Cmd": ["--port", "8080"],
    "Env": ["MODE=prod"],
    "WorkingDir": "/app"
  }
}

执行:

docker run -e MODE=debug image --port 9090

Docker 需要进行类似以下的语义合并:

  1. Entrypoint 保持为 /bin/app
  2. CLI 在镜像 Cmd 位置提供新的参数 --port 9090
  3. CLI 的 MODE=debug 覆盖镜像中的 MODE=prod
  4. 工作目录仍为 /app,除非用户另行指定;
  5. Docker 额外加入网络、挂载、资源限制、主机名、用户和安全配置;
  6. 最终生成该容器实例对应的 OCI runtime 配置。

所以,容器启动命令不是简单地“从镜像里执行一个字符串”,而是多个来源配置经过规则合并后的结果。

4.2 runc 做什么

runc 是一个低层 OCI runtime。它读取 runtime bundle,并通过 Linux 系统调用完成例如:

  • 创建或加入 namespaces;
  • 配置 root filesystem;
  • 执行 pivot_root 或相关根目录切换逻辑;
  • 设置挂载;
  • 加入或创建 cgroup;
  • 设置用户和组;
  • 丢弃 capabilities;
  • 配置 seccomp;
  • 应用 Linux 安全模块相关配置;
  • execve 容器进程。

可以把 runc 的主要任务写成:

(rootfs,config.json)configured Linux process(\text{rootfs}, \text{config.json}) \longrightarrow \text{configured Linux process}

runc 本身通常不是像 dockerd 那样长期保存所有容器业务状态的守护进程。它更接近“把 OCI 描述翻译成一次内核进程创建操作”的工具和库。

4.3 Runtime Spec 的状态操作

OCI Runtime Specification 定义了运行时生命周期中的典型操作:

create → start → running → stopped → delete

更细地说:

  1. create:准备容器状态,但不让用户进程真正运行;
  2. start:启动容器进程;
  3. running:容器进程处于运行状态;
  4. stopped:容器进程退出;
  5. delete:清理运行时状态和资源引用。

在实际 Docker 中,用户通常不会直接看到这套操作,因为 Docker 和 containerd 会把它包装成更高层的容器 API。

一个重要区别是:

  • container:配置和元数据对象;
  • task/process:正在运行的进程实体;
  • image:用于构造 rootfs 和默认配置的内容对象。

容器对象可以存在但没有运行 task:

created / exited container

也可以有运行中的 task:

running container + running task

删除容器时,必须先处理其 task 和相关挂载,否则无法安全释放运行时资源。


5. runc 与 Linux kernel:隔离真正发生在哪里

容器隔离不是由 runc 单独“模拟”出来的。runc 只是调用 Linux kernel 提供的机制。核心边界包括 namespaces、cgroups、capabilities、seccomp、LSM 和 mount 配置。

5.1 Namespaces:隔离“看见什么”

Linux namespace 为进程提供不同的系统视图。常见 namespace 包括:

Namespace 主要隔离对象
PID 进程编号视图
Mount 挂载点视图
Network 网络设备、路由、端口空间
UTS hostname 和 domain name
IPC System V IPC、POSIX message queue 等
User UID/GID 映射和权限视图
Cgroup cgroup 路径视图

例如,容器内的首个进程通常在自己的 PID namespace 中看到 PID 1:

docker run --rm alpine ps

可能输出:

PID   USER     TIME  COMMAND
    1 root     0:00  ps

这里的 PID 1 只是该容器 namespace 内的编号。宿主机上同一个进程会有另一个 PID。

这产生了一个常见边界:

namespace 隔离的是视图和命名空间,不等于隔离了整个内核。

容器进程仍然使用宿主机内核。它不能运行与宿主机不兼容的另一个内核,也不能自动获得虚拟机级别的硬件和内核隔离。

5.2 cgroups:限制“能用多少”

cgroup 主要负责对进程组进行资源组织和限制,例如:

  • CPU;
  • 内存;
  • PIDs 数量;
  • 块设备 I/O;
  • 部分设备访问控制。

例如:

docker run --rm --memory=128m --cpus=0.5 alpine sh -c '
  echo "running with resource limits"
'

这并不保证进程一定只消耗某个精确的 CPU 百分比或恰好 128 MiB 物理内存。它表示 Docker 将请求转换为相应 cgroup 配置,由内核按 cgroup 规则执行。

若进程超过内存限制,可能出现 OOM kill。此时容器的退出状态、Docker 显示的状态和内核日志需要结合判断:

docker inspect <container> \
  --format 'Exit={{.State.ExitCode}} OOM={{.State.OOMKilled}} Error={{.State.Error}}'

如果内存不足发生在宿主机全局,而不是容器 cgroup 内,受影响的进程可能不只来自某一个容器。

5.3 Capabilities:把 root 拆成权限集合

容器内显示 UID 0,不代表拥有宿主机上完整的 root 权限。Linux capabilities 将传统 root 权限拆分成多个能力,例如:

  • CAP_NET_ADMIN:网络配置相关权限;
  • CAP_SYS_ADMIN:范围很广的系统管理权限;
  • CAP_SYS_PTRACE:调试和跟踪其他进程的相关权限;
  • CAP_MKNOD:创建设备节点的相关权限。

Docker 默认会使用一组受限 capabilities,而不是把所有 capability 都交给容器。用户也可以进一步删除:

docker run --rm \
  --cap-drop=ALL \
  alpine sh -c 'id; ip link'

此命令即使显示 UID 0,也可能因缺少 capability 而无法执行需要特权的操作。

反例是:

docker run --rm --privileged alpine sh

--privileged 会显著扩大设备、capability 和安全配置权限,具体效果与 Docker Engine 和宿主机配置有关。它不是“普通容器稍微快一点的模式”,而是明显削弱隔离边界的选项。

5.4 Seccomp:限制“能调用哪些系统调用”

seccomp 可以限制进程能够调用的系统调用。Docker 常见配置会使用默认 seccomp profile,阻止一部分高风险或不适合容器的 syscall。

如果应用需要被 profile 禁止的 syscall,表现可能是:

  • Operation not permitted
  • 进程收到 SIGSYS
  • 应用初始化失败;
  • 容器立即退出。

诊断时不能只看“容器是否 root”,还要检查:

  • capability;
  • seccomp;
  • AppArmor 或 SELinux;
  • user namespace;
  • 设备和挂载;
  • cgroup 限制。

5.5 Mount、rootfs 与设备

容器的 rootfs 通常来自镜像层和可写快照,但 Docker 还会注入或挂载其他对象:

  • /proc
  • /sys 的受限视图;
  • /dev
  • /etc/hosts
  • /etc/hostname
  • /etc/resolv.conf
  • 用户指定的 bind mount 或 volume。

例如:

docker run --rm \
  -v "$PWD/data:/data" \
  alpine sh -c 'echo inside > /data/file'

这里 /data 不是镜像层中的普通文件,而是宿主机路径的 bind mount。容器删除后,宿主机的 data/file 仍然存在。

这说明:

容器文件系统与挂载进来的宿主机路径不是同一存储边界。

如果误用:

-v /:/host

容器可能读取甚至修改宿主机的大量文件。再叠加高权限、设备访问或 docker.sock 挂载,隔离边界会进一步削弱。


6. 容器不是虚拟机:共享内核决定边界

虚拟机通常通过 hypervisor 为 guest 提供独立的虚拟硬件和 guest kernel。普通 Linux 容器则:

容器进程
   ↓ 系统调用
宿主机 Linux kernel

而不是:

容器进程
   ↓
容器自己的 Linux kernel

因此:

  • 容器内的 Linux 用户空间可以不同;
  • 容器内的发行版可以不同;
  • 容器不能独立升级或替换宿主机内核;
  • 宿主机内核漏洞可能影响所有容器;
  • 容器运行时和内核必须兼容;
  • 访问宿主机内核接口的能力是重要安全边界。

“容器里运行了一个完整 Ubuntu”通常只是指它携带 Ubuntu 用户空间文件和工具,不代表它运行了 Ubuntu 内核。

如果需要更强的内核隔离,可以使用虚拟机或带虚拟机隔离的容器方案,但那已经超出普通 runc Linux container 的边界。


7. 镜像、容器与运行时状态的生命周期

下面的三个对象经常被混淆:

镜像 image
  = 可复用的只读内容 + 默认配置

容器 container
  = 某个镜像的实例化配置 + 可写层 + 元数据

任务 task/process
  = 容器当前运行的 Linux 进程及其运行时状态

一个典型状态变化如下:

image
  │ docker create
  ▼
created container
  │ docker start
  ▼
running task
  │ 主进程退出 / docker stop
  ▼
exited container
  │ docker rm
  ▼
container metadata and writable layer removed

执行:

docker create --name demo alpine sleep 100
docker ps -a --filter name=demo

此时容器对象已经创建,但进程尚未运行。

执行:

docker start demo
docker ps --filter name=demo

容器才有运行中的 task。

执行:

docker stop demo

Docker 通常会先向容器主进程发送停止信号,等待一段时间,再根据配置采取强制终止动作。信号能否被正确处理,取决于容器内 PID 1 是否负责转发和回收子进程。

删除:

docker rm demo

删除的是容器对象和其可写层,不是镜像本身。若使用了 volume 或 bind mount,外部数据是否删除还取决于该卷的类型和删除参数。

--rm 只是请求容器退出后自动移除容器对象;它不会把镜像删除,也不会自动清理所有外部卷数据。


8. docker build 的路径:BuildKit 不等于 runc

现代 Docker Engine 通常使用 BuildKit 构建镜像:

DOCKER_BUILDKIT=1 docker build -t demo:latest .

BuildKit 主要负责:

  • 解析 Dockerfile;
  • 构建依赖图;
  • 并行执行独立步骤;
  • 管理构建缓存;
  • 产生镜像配置和层;
  • 导出 OCI 或 Docker 镜像格式。

构建步骤可能需要执行命令,但这不意味着最终镜像中的每个命令都在一个长期 Docker 容器里运行。BuildKit 使用自己的执行器、挂载和缓存机制;具体隔离方式可能随后端和配置变化。

构建结果与运行结果仍是两个阶段:

Dockerfile
   ↓ BuildKit
镜像 manifest/config/layers
   ↓ docker run
容器 rootfs + runtime config
   ↓ containerd/runc
Linux task

例如:

FROM alpine:3.20
RUN echo "built" > /built.txt
CMD ["cat", "/built.txt"]

构建时,RUN 产生一层镜像内容;运行时,CMD 才是默认的容器启动命令。把构建阶段的执行进程和运行阶段的服务进程混为一谈,会导致对缓存、层和生命周期的误判。


9. Compose 位于 Docker API 之上

Compose 是描述多容器应用的工具和规范。它可以表达:

  • services;
  • networks;
  • volumes;
  • 环境变量;
  • 依赖关系;
  • 端口和挂载;
  • 构建配置。

例如:

services:
  web:
    image: nginx:1.27
    ports:
      - "8080:80"
    volumes:
      - web-data:/usr/share/nginx/html:ro

volumes:
  web-data:

执行:

docker compose up -d

Compose 工具会把服务模型转换为多个 Docker Engine API 操作,包括创建网络、拉取镜像、创建卷、创建容器、启动容器等。

因此 Compose 不是新的低层 runtime,也不会绕过 dockerd 直接调用 runc。它主要负责应用级编排;单个服务最终仍然经过 Docker Engine、containerd 和 OCI runtime。

“Compose 中的 service”也不等于“一个 OCI container”。一个 service 可以扩展为多个容器实例,Compose 还要处理项目名称、网络、依赖和配置合并。


10. Docker 如何选择 OCI 镜像平台

多架构镜像通常通过 OCI index 或 Docker manifest list 表达。其逻辑是:

同一个标签
 ├─ linux/amd64 manifest
 ├─ linux/arm64 manifest
 └─ 其他平台 manifest

拉取时,客户端或 Engine 根据目标平台选择对应 manifest。可以显式指定:

docker pull --platform=linux/amd64 alpine:3.20

查看本地镜像信息:

docker image inspect alpine:3.20

但要注意:

  • linux/amd64linux/arm64 不只是 CPU 名称,还影响镜像层和可执行文件;
  • 在 ARM 主机上运行 amd64 镜像可能需要仿真;
  • 仿真通常涉及 binfmt_misc 和用户态模拟器;
  • OCI 规范定义了平台描述,但不保证宿主机一定能执行目标架构。

如果出现:

exec format error

首先应检查镜像架构、宿主机架构和是否正确配置了仿真,而不是先把问题归因于 runc


11. 组件故障时,如何判断问题位于哪一层

11.1 CLI 到 Daemon 失败

表现:

Cannot connect to the Docker daemon

验证:

docker context show
docker version
systemctl status docker
ls -l /var/run/docker.sock

诊断逻辑:

  1. 如果 docker context show 指向错误目标,先修正 context;
  2. 如果 socket 不存在或 Daemon 未运行,检查 Daemon;
  3. 如果 socket 权限不足,检查当前用户和 Docker socket 权限;
  4. 如果远程连接失败,检查 TLS、SSH 和网络。

此时不要先执行 docker rm、重装 Docker 或清空 /var/lib/docker。控制面不可达不等于数据损坏。

11.2 Daemon 重启但容器仍运行

检查:

docker ps
docker inspect <container>

如果 Daemon 恢复后容器状态仍为 running,这通常符合 shim 和内核任务独立存活的架构。

但如果容器状态与实际进程不一致,可能涉及:

  • daemon 与 containerd 状态同步延迟;
  • shim 异常;
  • containerd socket 不可用;
  • 容器进程已退出但状态尚未刷新。

应结合日志检查:

journalctl -u docker
journalctl -u containerd

具体服务名称和日志来源可能因发行版安装方式不同而变化。

11.3 容器创建成功但启动失败

常见路径是:

镜像存在
  ↓
rootfs/snapshot 准备失败
  ↓
OCI bundle 或 mount 配置失败
  ↓
runc create/start 失败

可能原因包括:

  • rootfs 层损坏;
  • 挂载源不存在或权限不足;
  • 目标路径类型不匹配;
  • cgroup 配置不兼容;
  • seccomp、capability 或 LSM 拒绝;
  • 架构不匹配;
  • 入口程序不存在或不可执行。

可以逐层验证:

docker image inspect alpine:3.20
docker inspect <container>
docker logs <container>
docker events

如果进程根本没有成功启动,docker logs 可能为空;这时应重点看 docker events、Daemon 日志和内核日志,而不是把“无日志”解释成“应用没有输出”。

11.4 容器启动后立即退出

执行:

docker run --name once alpine sh -c 'echo start; exit 7'
docker ps -a --filter name=once
docker inspect once \
  --format 'status={{.State.Status}} exit={{.State.ExitCode}}'

预期状态类似:

status=exited exit=7

这是正常的生命周期结果:容器的生命周期默认与其主进程绑定。docker run -d 只改变 CLI 是否等待输出,不会让一个本来会退出的进程永久运行。

如果执行:

docker run -d alpine

容器可能很快退出,因为镜像默认命令完成后,主进程结束,容器也随之结束。


12. PID 1、信号与进程树边界

容器内第一个进程具有 PID 1 的特殊语义。它不仅是应用进程,还可能需要:

  • 接收 Docker 发送的停止信号;
  • 转发信号给子进程;
  • 回收孤儿子进程;
  • 正确传播退出码。

例如,shell 包装可能造成信号路径不同:

CMD ["sh", "-c", "my-server"]

与:

CMD ["my-server"]

并不等价。前者可能让 shell 成为 PID 1,信号是否转发取决于 shell 和命令写法。

可以使用 exec form,并让应用或专用 init 正确处理信号:

CMD ["my-server", "--foreground"]

这不是 OCI Image Specification 强制的 Dockerfile 规则,而是 Linux 进程模型和 Docker 生命周期交互产生的工程要求。

若应用收到 SIGTERM 后没有退出,Docker 在等待超时后可能发送 SIGKILL。被 SIGKILL 终止的进程没有机会执行清理逻辑,因此需要特别关注临时文件、连接关闭和数据一致性。


13. Rootless 与 rootful:隔离边界不同

传统 Docker Engine 常以 root 权限运行 Daemon。由于 Docker socket 可代表 Daemon 执行高权限操作:

ls -l /var/run/docker.sock

能够访问该 socket 的用户通常拥有接近宿主机 root 的能力。这是一个重要安全事实,而不是普通文件读写权限问题。

Rootless 模式则尝试让 Docker 和容器运行在非 root 用户上下文中,依赖 user namespace、subuid/subgid 以及相应的网络和存储方案。其边界和限制不同,例如:

  • 某些网络模式不可用或行为不同;
  • 某些挂载和设备操作受限;
  • 资源控制能力取决于宿主机 cgroup 配置;
  • 文件所有权映射可能与 rootful 模式不同;
  • 并非所有应用都能无修改运行。

不能把“容器内 UID 0”简单等同于“rootful 宿主机 root”,也不能把 rootless 误认为完全消除了所有内核漏洞和配置风险。


14. 常见误解与反例

误解一:Docker CLI 就是 Docker Engine

CLI 只是客户端。CLI 可以连接远程 Engine,因此:

命令输入位置 ≠ 容器运行位置

查看容器前,先确认:

docker context show
docker info

误解二:containerd 和 runc 是同一个东西

containerd 管理内容、快照和任务;runc 执行 OCI runtime 操作。两者之间通常还存在 shim。

containerd:管理“任务和资源”
runc:创建和配置“进程”

虽然实现细节可能变化,但职责层次不能混淆。

误解三:删除镜像会删除运行中的容器文件

容器创建后通常已经使用镜像层构造了自己的 rootfs 视图。镜像和容器是不同对象。删除一个没有被引用的镜像标签,通常不等于删除正在使用的容器 rootfs。

反过来,删除容器也不会删除其使用的基础镜像。

误解四:容器内 root 等于宿主机 root

实际权限由多个因素共同决定:

有效权限=f(UID/GID,user namespace,capabilities,seccomp,LSM,mount,devices)\text{有效权限} = f( \text{UID/GID}, \text{user namespace}, \text{capabilities}, \text{seccomp}, \text{LSM}, \text{mount}, \text{devices} )

只看 id 输出不足以判断安全边界。

误解五:容器隔离了所有内核资源

容器隔离的是部分视图、资源控制和权限,不是独立内核。宿主机内核、设备、挂载和 Docker socket 都可能成为跨容器或跨边界的通道。

误解六:OCI 规定了 Docker 的全部行为

OCI Image Specification 不规定:

  • docker run 的参数语义;
  • Docker 网络和卷模型;
  • Compose 的依赖关系;
  • Docker 的日志驱动;
  • Docker 的 restart policy;
  • Docker CLI 的输出格式。

OCI Runtime Specification 也不等于 Linux kernel 安全规范。它描述 runtime 如何表达和管理容器,但实际安全效果仍依赖内核、运行时实现和配置。


15. 一个可验证的端到端实验

下面的实验展示镜像、容器、进程、挂载和退出状态之间的关系。

步骤一:创建但不启动

docker create \
  --name arch-demo \
  -e DEMO=oci \
  alpine:3.20 \
  sh -c 'echo "$DEMO"; sleep 60'

预期输出是一个容器 ID。此时:

docker inspect arch-demo \
  --format 'status={{.State.Status}} pid={{.State.Pid}}'

可能得到:

status=created pid=0

这说明容器元数据存在,但尚无运行中的容器进程。

步骤二:启动并观察 PID

docker start arch-demo

docker inspect arch-demo \
  --format 'status={{.State.Status}} pid={{.State.Pid}}'

可能得到:

status=running pid=12345

这里的 12345 是宿主机视角下的 PID。容器内部通常会将同一进程视为 PID 1。

docker exec arch-demo ps

预期可能看到:

PID   USER     TIME  COMMAND
    1 root     0:00  sh
    7 root     0:00  sleep

docker exec 创建的是额外进程,不会替换容器主进程。

步骤三:观察输出和退出码

docker logs arch-demo

预期包含:

oci

等待 60 秒或主动停止:

docker stop arch-demo
docker inspect arch-demo \
  --format 'status={{.State.Status}} exit={{.State.ExitCode}}'

容器进入 exited 后仍保留元数据和可写层,直到:

docker rm arch-demo

步骤四:验证 bind mount 不属于容器可写层

mkdir -p ./demo-data

docker run --rm \
  -v "$PWD/demo-data:/data" \
  alpine sh -c 'echo persistent > /data/result'
cat ./demo-data/result

预期:

persistent

即使容器由于 --rm 自动删除,./demo-data/result 仍存在。这是因为文件写入了 bind mount 对应的宿主机路径,而不是容器可写层。


16. 规范保证、实现事实与工程建议

OCI 规范保证的范围

OCI 规范主要保证可互操作的数据和运行时描述形式,例如:

  • 镜像 manifest、config、layer 的结构;
  • descriptor 和 digest 的引用方式;
  • runtime bundle 和 config.json 的结构;
  • runtime 生命周期操作的抽象。

常见实现事实

现代 Docker Engine 中常见:

  • dockerd 作为 Docker API Daemon;
  • BuildKit 作为构建后端;
  • containerd 管理镜像、快照和任务;
  • containerd-shim-runc-v2 管理运行任务;
  • runc 作为 OCI runtime;
  • OverlayFS 作为某些 Linux 存储场景中的 snapshotter 或存储驱动。

这些是常见架构,不应误写成所有版本、所有平台、所有发行版都绝对相同。

工程上需要单独验证的内容

以下行为必须以实际版本和配置为准:

  • 默认 seccomp profile;
  • capabilities 默认集合;
  • cgroup v1 或 v2;
  • rootless 支持能力;
  • 默认 runtime;
  • snapshotter 和存储驱动;
  • Compose 规范版本和实现差异;
  • 多架构仿真是否已安装;
  • Docker Desktop 中的 Linux VM 边界。

特别是 Docker Desktop:用户在 macOS 或 Windows 上执行 Linux 容器时,容器通常运行在 Docker 管理的 Linux 虚拟机中。此时“宿主机”需要区分:

用户操作系统
   ↓
Docker Desktop 的 Linux VM
   ↓
dockerd/containerd/runc
   ↓
Linux 容器进程

文章中关于 Linux namespace、cgroup 和共享内核的分析,直接适用于容器所在的 Linux 环境;但在 Docker Desktop 场景下,这个 Linux 环境可能不是用户操作系统本身。


17. 诊断问题的正确顺序

当容器行为异常时,可以沿数据流反向定位:

用户命令
  ↓
CLI 与 context
  ↓
Docker API / dockerd
  ↓
containerd 与 shim
  ↓
镜像内容 / snapshotter
  ↓
OCI runtime / runc
  ↓
Linux kernel
  ↓
容器主进程

对应地:

  1. 命令是否被 CLI 正确解析?
  2. CLI 是否连接到了正确的 Daemon?
  3. Daemon 是否接受了请求?
  4. 镜像 manifest、平台和层是否可用?
  5. rootfs、挂载和 snapshot 是否准备成功?
  6. runtime 配置是否被 runc 接受?
  7. capability、seccomp、LSM 或 cgroup 是否拒绝?
  8. 应用入口程序是否存在、可执行并正确处理信号?
  9. 主进程是否因为 OOM、信号或自身错误退出?

这种顺序比直接执行:

docker system prune -a

更安全。清理命令可能删除未使用的镜像、容器和构建缓存,能够掩盖问题或增加恢复成本,但不能修复权限、架构、seccomp、内核或应用生命周期错误。

Docker 的“容器运行”最终不是一个单独组件的行为,而是多层契约的组合:

Docker API 语义
    → Docker 对象与策略
    → containerd 任务与内容管理
    → OCI image / runtime 描述
    → runc 的 Linux 配置动作
    → Linux kernel 的隔离与调度
    → 应用进程的实际行为

掌握这条边界后,CLI、Daemon、containerd 和 runc 不再是含糊的“Docker 内部组件”,OCI 镜像和运行时规范也不再与 Dockerfile、Compose 或 Linux 进程混为一谈。容器的能力、限制、故障表现和安全风险,都可以沿着这条链路被具体定位。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。