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,主要定义了容器生态中两个相互关联但不同的对象:
- Image Specification:描述如何表示镜像。
- 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 ...
这条链路中有几个重要的因果关系:
- CLI 通常不直接创建 Linux namespace。
dockerd通常不直接执行每个容器的用户进程。containerd管理容器任务和镜像/快照,但不提供 Docker CLI 的全部语义。runc负责一次低层运行时操作,通常不是长期驻留的容器管理守护进程。- 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 的工具,例如 ctr 或 nerdctl,看到的命名空间、镜像标签和容器对象,也不一定与 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:内容大小。
设内容字节序列为 ,其内容地址为:
如果两个对象的字节完全相同,则它们得到相同的 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/amd64和linux/arm64。
镜像 config 中可能包含:
- 默认环境变量;
- 默认工作目录;
- 默认用户;
Entrypoint;Cmd;- rootfs 的 diff IDs;
- 历史构建信息。
这些配置是镜像默认值,不等于宿主机已经实施的隔离配置。运行容器时,Docker 还要把命令行参数、网络设置、挂载、资源限制等合并为一个具体运行实例。
3.3 Layer 不是最终 rootfs
设基础层为 ,后续变更层为 。逻辑文件系统可以表示为:
其中 不是普通集合相加,而是带有覆盖和删除语义的分层合并:
- 后层的同名文件覆盖前层文件;
- whiteout 标记表示删除低层文件;
- 不同层中的相同内容可以被内容寻址存储复用。
容器启动时,运行时通常需要一个可写层:
只读镜像层
lower layers
+
容器可写层
=
容器 rootfs 视图
在 Linux 上,常见实现是 OverlayFS 或其他 snapshotter,但 OCI Image Specification 并不要求必须使用 OverlayFS。OCI 规定镜像内容和层的表示方式,不规定宿主机必须采用哪种存储驱动。
镜像层大小也不能直接等于最终磁盘占用。因为:
- 多个镜像可能共享同一层;
- 压缩传输大小和解压后大小不同;
- snapshotter 可能使用 copy-on-write;
- 容器可写层的修改不回写镜像;
- 删除大文件只会在新层中记录删除标记,旧层中的数据仍存在。
因此,在另一个镜像关联主题中,必须区分:
镜像层的压缩传输大小
镜像内容的解压大小
本机共享后的实际占用
容器可写层的额外占用
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 需要进行类似以下的语义合并:
Entrypoint保持为/bin/app;- CLI 在镜像
Cmd位置提供新的参数--port 9090; - CLI 的
MODE=debug覆盖镜像中的MODE=prod; - 工作目录仍为
/app,除非用户另行指定; - Docker 额外加入网络、挂载、资源限制、主机名、用户和安全配置;
- 最终生成该容器实例对应的 OCI runtime 配置。
所以,容器启动命令不是简单地“从镜像里执行一个字符串”,而是多个来源配置经过规则合并后的结果。
4.2 runc 做什么
runc 是一个低层 OCI runtime。它读取 runtime bundle,并通过 Linux 系统调用完成例如:
- 创建或加入 namespaces;
- 配置 root filesystem;
- 执行
pivot_root或相关根目录切换逻辑; - 设置挂载;
- 加入或创建 cgroup;
- 设置用户和组;
- 丢弃 capabilities;
- 配置 seccomp;
- 应用 Linux 安全模块相关配置;
execve容器进程。
可以把 runc 的主要任务写成:
runc 本身通常不是像 dockerd 那样长期保存所有容器业务状态的守护进程。它更接近“把 OCI 描述翻译成一次内核进程创建操作”的工具和库。
4.3 Runtime Spec 的状态操作
OCI Runtime Specification 定义了运行时生命周期中的典型操作:
create → start → running → stopped → delete
更细地说:
create:准备容器状态,但不让用户进程真正运行;start:启动容器进程;running:容器进程处于运行状态;stopped:容器进程退出;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/amd64和linux/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
诊断逻辑:
- 如果
docker context show指向错误目标,先修正 context; - 如果 socket 不存在或 Daemon 未运行,检查 Daemon;
- 如果 socket 权限不足,检查当前用户和 Docker socket 权限;
- 如果远程连接失败,检查 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
实际权限由多个因素共同决定:
只看 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
↓
容器主进程
对应地:
- 命令是否被 CLI 正确解析?
- CLI 是否连接到了正确的 Daemon?
- Daemon 是否接受了请求?
- 镜像 manifest、平台和层是否可用?
- rootfs、挂载和 snapshot 是否准备成功?
- runtime 配置是否被 runc 接受?
- capability、seccomp、LSM 或 cgroup 是否拒绝?
- 应用入口程序是否存在、可执行并正确处理信号?
- 主进程是否因为 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 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 下一篇:Docker 镜像与分层:内容寻址、OverlayFS、缓存和镜像体积
- 延伸:Docker 容器生命周期:创建、启动、信号、退出、重启与清理
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论