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

Docker 运行时隔离:namespaces、cgroups、Mount、PID 和网络

Docker 容器不是一种独立的虚拟机。现代 Linux 容器通常由 Docker Engine 接收请求,再交给 containerd 和 OCI runtime(常见实现为 runc)创建 Linux 进程;真正提供隔离的主体是 Linux 内核中的 namespaces、cgroups、挂载机制、Capabilities、seccomp 以及 LSM 等安全机制。

可以先把边界概括为:

  • namespace 决定进程“能看到什么”;
  • cgroup 决定进程“能使用多少资源”;
  • Mount namespace 决定进程“看到哪棵文件系统树”;
  • PID namespace 决定进程“看到哪些进程、如何编号和回收子进程”;
  • Network namespace 决定进程“拥有哪套网络设备、地址、路由和端口空间”。

这些机制互相配合,但不能互相替代。隐藏宿主机进程不会自动限制 CPU;限制内存也不会自动隐藏宿主机文件;隔离网络接口也不会自动阻止容器访问挂载进来的宿主目录。

一、从 Docker 请求到 Linux 隔离

以普通的 docker run 为例,调用链大致如下:

docker CLI
   │ Unix socket / API
   ▼
Docker daemon
   │
   ▼
containerd
   │ 创建容器元数据、准备 rootfs、管理生命周期
   ▼
OCI runtime(通常是 runc)
   │
   ├─ clone/unshare 创建 namespaces
   ├─ 设置 cgroup
   ├─ 设置 Mount namespace 和 rootfs
   ├─ 配置网络 namespace
   ├─ 设置 capabilities、seccomp、LSM 等限制
   └─ execve 容器进程

Docker CLI 本身不负责执行容器中的进程。它向 Docker daemon 发出请求;daemon 通常通过 containerd 管理容器生命周期,再由 OCI runtime 根据 OCI Runtime Specification 中的 Linux 配置创建进程环境。

OCI Image Specification 描述镜像内容,例如 manifest、config 和 filesystem layers;OCI Runtime Specification 描述运行时应如何创建一个容器,例如 root filesystem、namespace、mount、Linux resources 和 process 配置。镜像层本身不是隔离机制,隔离发生在运行阶段。

一个容器启动时,运行时需要完成两类工作:

  1. 构造进程可见的环境:namespace、rootfs、挂载、网络、环境变量、工作目录等;
  2. 构造进程可用的权限和资源边界:cgroup、Capabilities、seccomp、AppArmor/SELinux 等。

因此,执行:

docker run --rm alpine:3.20 sh

并不意味着 Docker 创建了一个“轻量虚拟机”。它通常是在宿主机内核上创建一个或多个隔离视图,然后启动一个普通 Linux 进程。这个进程仍然由宿主机内核调度、管理和计费。

二、Namespaces:隔离“视图”而不是复制内核

Linux namespace 是内核提供的隔离机制。进程属于某些 namespace 后,读取系统信息或调用相关系统调用时,看到的是该 namespace 对应的视图。

常见类型包括:

Namespace 隔离对象 容器中的典型效果
Mount 挂载树 容器看到自己的根文件系统和挂载点
PID 进程编号空间 容器内的 PID 1 与宿主机 PID 不同
Network 网络设备、地址、路由、端口 容器拥有独立的网络栈
UTS hostname、NIS domain name 容器可拥有独立 hostname
IPC System V IPC、POSIX message queue 隔离共享内存、信号量和消息队列
User UID/GID 映射和部分权限 容器 root 可映射为宿主机非 root
Cgroup cgroup 视图 进程看到的 cgroup 层级可被隔离

namespace 隔离的基本条件可以写成:

可见对象(p)=namespace 允许的对象集合\text{可见对象}(p)=\text{namespace 允许的对象集合}

其中 pp 是进程。这个条件只描述“可见性”,并不描述资源消耗或权限。例如,一个进程看不到宿主机的其他进程,不表示它不能把自己的 CPU 使用率提升到一个核心;一个进程处在独立网络 namespace 中,也不表示它不能通过默认路由访问外部网络。

Docker 常用的隔离并不是所有环境下完全相同:

  • rootful Docker 通常使用独立的 Mount、PID、Network、UTS 和 IPC namespace;
  • User namespace 默认是否启用取决于 Docker 配置。启用 userns-remap 或使用 rootless Docker 时,容器内 UID 0 可以映射为宿主机上的非特权 UID;
  • --pid=host--network=host--ipc=host 等选项会主动取消对应隔离;
  • --privileged 会显著放宽 capabilities、设备访问和安全限制,不能被理解为“只关闭某一个 namespace”。

检查一个容器的 namespace,可以使用:

docker run -d --name ns-demo alpine:3.20 sleep 300

docker inspect -f '{{.State.Pid}}' ns-demo

假设输出为:

23145

在 Linux 宿主机上查看该进程的 namespace:

sudo ls -l /proc/23145/ns

可能看到类似:

ipc  -> ipc:[4026533101]
mnt  -> mnt:[4026533104]
net  -> net:[4026533107]
pid  -> pid:[4026533106]
uts  -> uts:[4026533105]

方括号中的 inode 标识 namespace。将它与宿主机 Shell 比较:

readlink /proc/1/ns/net
sudo readlink /proc/23145/ns/net

如果两者不同,说明容器进程和宿主机 PID 1 不在同一个 Network namespace 中。这里查看的是 Linux 运行状态,不是 Docker 的抽象元数据,因此适合诊断“配置看似正确但内核状态不一致”的问题。

三、Mount namespace:每个进程看到的文件系统树

Mount namespace 隔离的是“挂载树”。它不复制磁盘,也不自动复制文件,而是让不同进程组对挂载点的解析结果不同。

容器启动后,运行时通常会:

  1. 准备镜像 rootfs;
  2. 创建新的 Mount namespace;
  3. 将 rootfs 设置为新的根目录;
  4. 挂载 /proc/dev/sys 等必要伪文件系统;
  5. 应用 bind mount、volume、tmpfs 等用户指定的挂载;
  6. 通过 pivot_root 或等价方式切换根目录;
  7. 启动容器进程。

镜像层与 Mount namespace 的关系需要区分:

  • 镜像层是只读内容层;
  • 容器可写层通常由存储驱动提供;
  • OverlayFS 等驱动可以把多个层合并成一个 rootfs 视图;
  • Mount namespace 决定这个合并后的 rootfs 以及其他挂载对容器进程如何呈现。

因此,“镜像文件系统”和“挂载隔离”不是同一个概念。没有 Mount namespace,即使准备好了独立 rootfs,进程也可能继续看到宿主机的挂载树。

1. Bind mount、volume 和 tmpfs

下面的命令创建三种不同性质的挂载:

mkdir -p /tmp/docker-mount-demo

docker run --rm \
  --mount type=bind,src=/tmp/docker-mount-demo,dst=/work \
  --mount type=tmpfs,dst=/run/cache,tmpfs-size=16m \
  alpine:3.20 \
  sh -c 'echo hello >/work/a; echo cache >/run/cache/b; df -h /work /run/cache'

其含义分别是:

  • bind:把宿主机已有目录映射到容器。容器写入 /work/a,宿主机的 /tmp/docker-mount-demo/a 会出现;
  • tmpfs:数据存放在内存或交换空间相关的 tmpfs 中,容器删除后挂载内容消失;
  • 未指定的 /:来自镜像 rootfs 和容器可写层。

--mount 通常比旧式 -v 更明确,因为源路径、目标路径和类型被显式写出。生产环境中,bind mount 的权限边界尤其重要:

docker run --rm \
  --mount type=bind,src=/etc,dst=/host-etc,readonly \
  alpine:3.20 \
  sh -c 'cat /host-etc/hostname'

readonly 只限制通过该挂载点进行写入,并不能把宿主机目录变成“完全不可见”。容器仍然可以读取其中所有它有权限读取的文件;如果同时存在另一个可写挂载、设备访问或宿主机高权限漏洞,整体安全边界仍可能被破坏。

2. 挂载传播

挂载传播决定一个挂载 namespace 中新发生的挂载事件是否传播到其他 namespace。常见传播属性包括:

  • private:不向其他 namespace 传播;
  • shared:挂载和卸载事件可以双向传播;
  • slave:接收上游传播,但不反向传播。

容器编排中使用 Docker-in-Docker、挂载设备管理目录或运行 kubelet 时,常会遇到传播属性问题。一个目录在宿主机上是 shared 还是 private,会影响容器内创建的子挂载是否能被宿主机或其他 namespace 观察到。

可以查看宿主机挂载传播属性:

findmnt -o TARGET,FSTYPE,OPTIONS,PROPAGATION /

如果一个需要向容器传播挂载事件的场景使用了 private,常见失败表现是:容器内命令显示挂载成功,但宿主机或上层管理程序看不到该挂载。反过来,过度使用 shared 会扩大挂载事件传播范围,增加意外影响其他工作负载的风险。

四、PID namespace:进程编号、PID 1 和回收责任

PID namespace 隔离进程编号空间。一个进程可以同时拥有多个 PID:

  • 在容器 PID namespace 中可能是 PID 1;
  • 在父级 namespace,也就是宿主机中,可能是 PID 23145。

容器内的 PID 1 不一定是宿主机的第一个进程。它只是该 PID namespace 中的第一个进程。

启动一个容器并检查进程:

docker run --rm -d --name pid-demo alpine:3.20 \
  sh -c 'while true; do sleep 60; done'

docker exec pid-demo ps -ef

典型输出类似:

PID   USER     TIME  COMMAND
    1 root     0:00  sh -c while true; do sleep 60; done
    7 root     0:00  sleep 60
   10 root     0:00  ps -ef

在宿主机上:

host_pid=$(docker inspect -f '{{.State.Pid}}' pid-demo)
sudo ps -o pid,ppid,stat,cmd -p "$host_pid"

容器内的 PID 1 与宿主机 PID 不同,但它们指向同一个实际进程,只是位于不同的 PID namespace 视图中。

1. PID 1 的特殊语义

PID 1 有两个重要责任:

  1. 处理或转发终止信号
  2. 回收孤儿进程,避免产生僵尸进程

普通进程退出后,其父进程需要调用 wait() 回收退出状态。如果父进程没有回收,子进程会短暂成为 zombie。若一个进程的父进程退出,内核会将其重新托管给该 PID namespace 的 PID 1;因此容器内 PID 1 不仅是“主程序”,还承担子进程回收责任。

错误示例:

docker run --rm alpine:3.20 sh -c '
  (sleep 1) &
  while true; do sleep 60; done
'

这里的 Shell 是否正确转发信号、是否回收所有子进程,取决于 Shell 实现和脚本写法。更可靠的入口脚本通常使用:

#!/bin/sh
set -eu
exec "$@"

exec 用目标程序替换 Shell,使目标程序直接成为 PID 1,而不是让 Shell 永久挡在信号路径中。

Docker 还提供了 --init

docker run --rm --init alpine:3.20 sh -c 'while true; do sleep 60; done'

它会在容器内加入一个轻量 init 进程,帮助处理子进程回收和部分信号转发问题。它不能修复应用自身的所有信号处理错误,也不能替代应用对优雅关闭的设计。

2. PID 可见性与 PID 限制是两件事

以下配置控制“最多允许创建多少个进程”:

docker run --rm --pids-limit=100 alpine:3.20 sh -c '
  i=0
  while [ "$i" -lt 200 ]; do
    sleep 100 &
    i=$((i+1))
  done
  wait
'

达到限制后,新的 forkclone 可能失败,应用通常看到类似:

fork: Resource temporarily unavailable

--pids-limit 依赖 Linux 的 pids cgroup controller。它与 PID namespace 的区别是:

  • PID namespace:限制进程的编号视图和可见范围;
  • pids cgroup:限制该 cgroup 中允许存在的进程数量。

一个容器即使只能看到自己的进程,也可能创建大量进程;反过来,设置了很小的 pids limit,也不代表它看不到宿主机进程。

--pid=host 会让容器加入宿主机 PID namespace:

docker run --rm --pid=host alpine:3.20 ps -ef

此时容器中的 ps 可能看到宿主机上的大量进程。该选项常用于诊断工具,但会显著降低进程视图隔离,不应与普通应用容器混用。

五、cgroups:限制和统计资源使用

cgroup,即 control group,是 Linux 内核对进程组进行资源控制和统计的机制。Docker 会把容器进程放入某个 cgroup,并通过控制器配置资源限制。

在 cgroup v2 中,资源控制通常通过统一层级下的文件完成,例如:

  • cpu.max:CPU 带宽;
  • cpu.weight:相对 CPU 权重;
  • memory.max:内存硬限制;
  • memory.high:内存压力阈值;
  • memory.current:当前内存使用;
  • pids.max:进程数上限;
  • io.max:I/O 限制;
  • cgroup.procs:属于该 cgroup 的进程。

Docker 命令行隐藏了这些内核文件,但语义仍然来自 cgroup controller。

1. CPU:配额和权重

例如:

docker run --rm --cpus=0.5 alpine:3.20 \
  sh -c 'yes >/dev/null'

--cpus=0.5 的常见实现等价于设置大约半个 CPU 的周期配额。以 100000 微秒调度周期为例,约对应:

quota=0.5×100000=50000 μs\text{quota}=0.5 \times 100000=50000\ \mu s

含义是:在每个 100000 微秒的周期内,该 cgroup 中的任务最多运行约 50000 微秒。它不是把进程绑定到某个 CPU,也不是保证每个时间片精确运行 50%;调度器会在周期内进行累计和节流。

与之不同,CPU weight 是竞争时的相对份额。例如两个繁忙 cgroup 权重分别为 100 和 200,在同一 CPU 饱和且没有其他限制时,理论上后者获得约两倍的竞争份额:

shareiwijwj\text{share}_i \approx \frac{w_i}{\sum_j w_j}

这只是调度竞争模型。当某个 cgroup 不忙、任务受 I/O 阻塞或系统存在其他控制器限制时,实际结果不会简单等于这个比例。

2. 内存:硬限制、压力和 OOM

运行内存限制:

docker run --rm --memory=128m --memory-swap=128m alpine:3.20 \
  sh -c 'dd if=/dev/zero of=/tmp/file bs=1M count=256'

--memory=128m 设置容器内存上限;同时指定 --memory-swap=128m 时,通常表示内存和 swap 的总上限也为 128 MiB,即不额外允许 swap。具体可用能力仍受宿主机是否启用 swap、Docker 版本和 cgroup 模式影响。

当 cgroup 达到 memory.max,内核会尝试回收内存;若回收不足,可能触发该 cgroup 内的 OOM 处理。容器表现可能是进程被 SIGKILL,Docker 状态中出现非零退出码,或者宿主机日志记录 cgroup OOM 事件。

诊断示例:

docker inspect -f '
status={{.State.Status}}
exit={{.State.ExitCode}}
oom={{.State.OOMKilled}}
error={{.State.Error}}
' <container>

其中 OOMKilled=true 是 Docker 对常见 cgroup OOM 结果的记录,但诊断时仍应检查内核日志和 cgroup 统计,因为应用主动调用 abort、宿主机全局 OOM 或外部 kill -9 可能产生不同表现。

在 cgroup v2 宿主机上,可以查看容器的 cgroup 路径:

docker inspect -f '{{.State.Pid}}' <container>
pid=$(docker inspect -f '{{.State.Pid}}' <container>)
sudo cat /proc/"$pid"/cgroup

常见 cgroup v2 输出类似:

0::/system.slice/docker-<id>.scope

再根据该路径查看:

sudo cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.current
sudo cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.max

实际路径受 systemd、Docker 配置和发行版影响,不能硬编码为某一个固定目录。

3. cgroups 不等于 namespace

下面三个命题彼此独立:

  1. 进程是否能看到宿主机进程:主要由 PID namespace 决定;
  2. 进程最多使用多少内存:主要由 memory cgroup 决定;
  3. 进程能否打开某个设备或系统调用:主要由 capabilities、设备策略和 seccomp 等决定。

因此,以下配置只做资源治理,并不自动完成安全隔离:

docker run --rm \
  --memory=256m \
  --cpus=1 \
  --pids-limit=200 \
  alpine:3.20

它不能阻止容器读取通过 bind mount 暴露的宿主机文件,也不能阻止容器访问其被授予的网络目标。

六、Network namespace:独立网络栈如何接入宿主机

Network namespace 隔离一组网络资源,包括:

  • 网络接口;
  • IPv4/IPv6 地址;
  • 路由表;
  • netfilter 规则;
  • socket 端口空间;
  • /proc/net 等网络状态视图。

默认 bridge 网络下,容器通常拥有自己的 Network namespace,其中有一个 eth0;宿主机上对应一端通常是 veth pair 的另一端,并接入 Docker 创建的 bridge。数据路径可抽象为:

容器进程
  │ socket
  ▼
容器 eth0
  │ veth pair
  ▼
Docker bridge(如 docker0 或用户定义 bridge)
  │
  ├─ 同一网络中的其他容器
  └─ 宿主机路由、iptables/nftables、NAT
        │
        ▼
      外部网络

检查容器网络:

docker run --rm alpine:3.20 ip addr
docker run --rm alpine:3.20 ip route
docker run --rm alpine:3.20 cat /etc/resolv.conf

典型结果会包含:

eth0@if...
    inet 172.17.x.y/16
default via 172.17.0.1 dev eth0

容器能访问外部网络,通常依赖以下链路都成立:

  1. 容器拥有地址和默认路由;
  2. bridge 和 veth 正常;
  3. 宿主机允许转发;
  4. Docker 配置的防火墙规则和 NAT 正常;
  5. DNS 配置可用;
  6. 上游网络允许该流量。

任何一个环节失败,都可能表现为“容器没有网络”,但修复方法不同。比如能访问 1.1.1.1 但不能解析域名,通常是 DNS 问题;能解析域名但连接超时,可能是路由、防火墙或上游策略问题。

1. 容器之间的端口与宿主机端口

在同一个 Docker bridge 网络中,容器端口首先属于各自的 Network namespace。两个容器都监听 0.0.0.0:8080 通常没有冲突,因为它们不在同一个网络 namespace。

宿主机端口发布改变的是外部可达路径:

docker run -d --name web \
  -p 127.0.0.1:8080:80 \
  nginx:1.27

它通常表示:

  • 容器内服务监听端口 80;
  • Docker 配置转发,将宿主机 127.0.0.1:8080 的连接导向容器;
  • 只有访问宿主机本地回环地址的客户端能直接访问该发布端口。

验证:

curl http://127.0.0.1:8080
docker port web

EXPOSE 80-p 不同。EXPOSE 是镜像元数据层面的声明,不会自动让宿主机端口可访问;-p 才是运行时端口发布。

2. hostnone 和自定义网络

docker run --rm --network=host alpine:3.20 ip addr
docker run --rm --network=none alpine:3.20 ip addr
  • --network=host:容器使用宿主机 Network namespace,不再拥有独立的网络接口和端口空间;
  • --network=none:除回环接口等基础内容外,不接入 Docker 网络;
  • 默认 bridge 或用户定义 bridge:提供独立网络 namespace,并通过虚拟设备连接。

host 模式减少了虚拟网络路径,但也消除了网络隔离;容器内监听宿主机端口可能直接与宿主机服务冲突。none 模式适合明确不需要网络的任务,但应用依赖 DNS、软件包下载或服务发现时会直接失败。

七、Mount、PID 和网络是如何同时生效的

运行时创建容器并不是依次创建三个互不相关的“功能开关”,而是构造一个进程启动时的整体上下文。

以:

docker run --rm \
  --read-only \
  --tmpfs /tmp:size=32m \
  --pids-limit=128 \
  --memory=256m \
  --network=bridge \
  alpine:3.20 sh

为例:

  1. Mount namespace 使容器获得独立的挂载树;
  2. --read-only 使容器根文件系统以只读方式呈现;
  3. /tmp 的 tmpfs 提供一个可写但短暂的目录;
  4. PID namespace 使 sh 通常成为容器内 PID 1;
  5. --pids-limit=128 通过 pids cgroup 限制进程总数;
  6. --memory=256m 通过 memory cgroup 限制内存;
  7. Network namespace 提供容器自己的接口、地址和路由;
  8. bridge 网络通过 veth 将该 namespace 接入宿主机网络;
  9. capabilities 和 seccomp 再决定进程能否执行某些特权操作。

其中任何一层都可能独立失败。例如:

  • rootfs 可见但 /tmp 不可写:Mount 配置或应用目录问题;
  • 能启动但创建线程失败:pids 或内存限制;
  • 能访问 IP 但不能解析域名:网络路径已通,DNS 配置失败;
  • 容器内 PID 1 收不到停止信号:入口程序或 init 处理问题;
  • 容器能看到挂载目录但读不到文件:Unix 权限、UID 映射或 LSM 拒绝。

八、一个可运行的综合诊断实验

下面的 Compose 文件同时使用只读根文件系统、tmpfs、内存限制、进程数限制和自定义网络:

services:
  app:
    image: alpine:3.20
    command:
      - sh
      - -c
      - |
        echo "PID: $$"
        echo "hostname: $$(hostname)"
        ip addr
        ip route
        sleep 3600
    read_only: true
    tmpfs:
      - /tmp:size=16m
    mem_limit: 128m
    pids_limit: 64
    networks:
      - isolated

networks:
  isolated:
    driver: bridge

启动:

docker compose up -d
docker compose exec app sh

进入容器后依次执行:

id
ps -ef
mount
ip addr
ip route
cat /proc/1/cgroup
echo test >/tmp/a
echo test >/etc/a

预期现象:

  • ps -ef 主要看到该容器 PID namespace 中的进程;
  • /tmp/a 写入成功,因为 /tmp 是 tmpfs;
  • /etc/a 通常失败,因为根文件系统是只读的;
  • ip addr 显示容器自己的接口;
  • /proc/1/cgroup 显示该进程所属 cgroup;
  • pids_limitmem_limit 分别影响进程数和内存,不影响 /tmp 的挂载语义。

如果 Compose 版本或 Docker 环境对某个资源字段支持方式不同,应以 docker compose config 展开的结果和 docker inspect 实际状态为准:

docker compose config
docker inspect <container>
docker stats <container>

配置文件是期望状态,inspect/proc/sys/fs/cgroup 才能帮助确认运行时实际状态。

九、常见误解与反例

误解一:容器 root 等于宿主机 root

容器内 UID 0 只是当前 user namespace 中的 root。rootful Docker 且未启用 user namespace 时,容器 root 在宿主机上通常仍对应 UID 0,因此风险较高;rootless 或 user namespace remapping 可以建立 UID 映射,使容器 UID 0 对应宿主机上的非零 UID。

但 UID 映射不是万能安全边界。挂载宿主机 Docker socket、授予过多 capabilities、使用特权设备或存在内核漏洞,都可能突破预期隔离。

误解二:--read-only 让容器完全不可写

--read-only 通常只读化容器根文件系统。应用仍可能向以下位置写入:

  • 显式挂载的可写 volume;
  • bind mount;
  • tmpfs;
  • 某些运行时提供的可写伪文件系统。

因此只读根文件系统必须与应用所需的可写目录一起设计,否则常见结果是应用启动时报“permission denied”,而不是更安全地自动运行。

误解三:停止容器就是杀掉所有进程

Docker 停止容器时通常先向容器主进程发送终止信号,等待宽限期后再强制杀死。若主进程是一个不会转发信号的 Shell,真正的应用进程可能迟迟不退出,最终收到 SIGKILL

诊断:

docker stop --time=10 <container>
docker inspect -f '{{.State.FinishedAt}} {{.State.ExitCode}}' <container>

应用应正确处理 SIGTERM,入口脚本应使用 exec,需要时可使用 --init。否则数据库、消息消费者等有状态程序可能无法完成 flush 或事务收尾。

误解四:网络 namespace 能阻止所有网络访问

Network namespace 只是提供独立网络栈。默认 bridge 网络仍允许容器通过宿主机路由和 NAT 访问外部网络。要限制访问目标,还需要防火墙策略、网络插件、代理或应用层认证。

同样,--network=none 只是不接入 Docker 网络;如果容器拥有宿主机设备、额外接口或其他共享机制,不能仅凭这一项推断整体网络安全。

误解五:cgroup 限制一定能保护宿主机

cgroup 能限制符合其控制器语义的资源使用,但存在边界:

  • 内核内存、页缓存、socket buffer 等统计方式受 cgroup 模式和内核版本影响;
  • memory.max 触发的是 cgroup 范围内的内存压力处理,不等于宿主机永不发生全局 OOM;
  • I/O 限制依赖存储设备、文件系统和对应 controller 的支持;
  • CPU 限制会导致 throttling,但不保证应用延迟保持不变。

资源限制应通过实际工作负载验证,不能只根据命令行参数推断性能。

十、故障诊断路径

1. 先确认容器主进程和状态

docker ps -a
docker inspect <container> \
  --format 'status={{.State.Status}} pid={{.State.Pid}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}}'
docker logs <container>

如果 Pid=0 且容器已退出,先看退出码和日志;不要直接把问题归因于 namespace 或 cgroup。

2. 检查 PID 和 Mount 视图

pid=$(docker inspect -f '{{.State.Pid}}' <container>)

sudo readlink /proc/"$pid"/ns/pid
sudo readlink /proc/"$pid"/ns/mnt
sudo nsenter -t "$pid" -p -m ps -ef
sudo nsenter -t "$pid" -m mount

nsenter 会进入目标进程的 namespace 视图。它需要宿主机 root 权限,错误使用可能在隔离环境中执行具有高权限的诊断命令。通过 nsenter 看到的结果可用于区分:

  • Docker 配置没有生效;
  • 应用本身修改了挂载或进程状态;
  • 容器内工具缺失导致的误判。

3. 检查网络路径

docker exec <container> ip addr
docker exec <container> ip route
docker exec <container> cat /etc/resolv.conf
docker exec <container> wget -qO- http://<target>:<port>

建议按顺序区分:

  1. 接口是否存在;
  2. 地址是否存在;
  3. 默认路由是否存在;
  4. 目标 IP 是否可达;
  5. DNS 是否能解析;
  6. 目标端口是否监听;
  7. 防火墙或策略是否丢弃。

“连接失败”不是一个足够具体的故障类别。

4. 检查 cgroup 实际值和事件

docker stats <container>

pid=$(docker inspect -f '{{.State.Pid}}' <container>)
sudo cat /proc/"$pid"/cgroup

在 cgroup v2 中,进入对应目录后可查看:

sudo cat memory.current
sudo cat memory.max
sudo cat memory.events
sudo cat pids.current
sudo cat pids.max

memory.events 中的 oomoom_kill 等计数有助于判断是否发生了 cgroup 内存事件。CPU 受限时可检查 cpu.stat 中的 throttling 相关计数;实际字段以宿主机内核和 cgroup 版本为准。

十一、Linux 容器的真实边界

这些机制建立在共享宿主机内核之上。容器内的 uname、系统调用实现、调度器、内存管理器和网络协议栈都来自宿主机内核,而不是镜像中的独立内核。镜像只提供用户态文件和程序。

因此:

  • 不能用容器隔离不同内核版本的内核行为;
  • 容器内的 root 权限应通过 capabilities、user namespace 和安全策略进一步收缩;
  • 运行不可信代码时,不能仅把普通容器等同于强隔离沙箱;
  • 需要更强边界时,应评估 rootless、虚拟机、微型虚拟机或专用 sandbox runtime;
  • Docker Desktop 等非 Linux 主机环境通常在一个 Linux VM 中运行容器,本文讨论的 namespace 和 cgroup 边界首先属于那个 Linux VM,而不一定直接等同于 macOS 或 Windows 宿主系统。

OCI 规范定义了运行时配置的标准接口,但不保证所有平台、内核版本和 runtime 对每个字段提供完全相同的效果。Docker、containerd、runc、内核、systemd、存储驱动和网络实现共同决定最终行为。

十二、隔离机制的组合关系

可以用下面的关系理解 Docker 运行时隔离:

镜像 layers ──► rootfs / mount
                     │
                     ▼
              Mount namespace
                     │
             ┌───────┼────────┐
             ▼       ▼        ▼
        PID namespace  Network namespace  UTS/IPC/User
             │       │        │
             └───────┼────────┘
                     ▼
              容器主进程及其子进程
                     │
                     ▼
              cgroup 资源控制
                     │
                     ▼
           CPU / memory / pids / I/O 统计与限制

Capabilities + seccomp + LSM + device policy
                     │
                     ▼
             系统调用和特权操作边界

最终,一个容器进程能做什么,取决于多个条件的交集:

实际能力=namespace 可见对象挂载树可见路径凭据与 capabilitiesseccomp/LSM 允许的操作cgroup 资源状态\text{实际能力} = \text{namespace 可见对象} \cap \text{挂载树可见路径} \cap \text{凭据与 capabilities} \cap \text{seccomp/LSM 允许的操作} \cap \text{cgroup 资源状态}

这个表达式不是内核 API,而是分析问题的模型。它解释了为什么“能看到”不等于“能访问”,“能访问”不等于“能修改”,“能修改”也不等于“资源无限”。

理解 Docker 运行时,关键不是把容器当成一个神秘的封装,而是沿着进程实际经历的路径检查:它属于哪些 namespace,看到哪棵 Mount 树,位于哪个 PID 和 Network 视图,进入了哪个 cgroup,又被授予了哪些 capabilities 与系统调用权限。这样才能把启动失败、端口冲突、进程泄漏、OOM、权限错误和网络超时还原为具体的 Linux 内核机制。


系列导航与关联阅读

官方资料

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