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

Docker 容器安全:非 root、Capabilities、Seccomp、只读根和隔离

容器安全的核心问题不是“容器里是否有一个 root 用户”,而是:

当容器中的进程被攻击者控制后,它还能调用哪些内核接口、访问哪些对象、写入哪些位置,以及能否影响宿主机或其他容器?

Docker 的安全控制通常分布在多个层次:

flowchart TD
    A[容器进程] --> B[用户与用户命名空间]
    A --> C[Linux Capabilities]
    A --> D[Seccomp 系统调用过滤]
    A --> E[只读根文件系统]
    A --> F[Namespaces 隔离]
    A --> G[cgroups 资源限制]
    A --> H[LSM: AppArmor/SELinux]
    A --> I[宿主机内核与 Docker daemon]

    B --> J[进程可获得的身份]
    C --> K[可执行的特权操作]
    D --> L[可调用的系统调用]
    E --> M[可修改的文件]
    F --> N[可见的进程/网络/挂载/IPC]
    G --> O[可消耗的资源]
    H --> P[额外的强制访问控制]

这些机制解决的是不同问题,不能相互替代。例如:

  • 非 root 限制进程身份,但不能自动限制所有系统调用;
  • 删除 Capabilities 限制部分特权操作,但不能阻止普通用户利用应用漏洞;
  • Seccomp 限制系统调用集合,但不理解文件路径和业务权限;
  • 只读根文件系统防止大部分持久化写入,但不阻止内存中的攻击;
  • Namespace 隔离可见对象,但容器仍然共享宿主机内核。

因此,容器安全通常是多个约束的交集,而不是某个单独参数的结果。


1. 先定义 Linux 容器的安全边界

Docker 容器不是一个完整的虚拟机。容器中的进程仍由宿主机 Linux 内核执行,只是通过 Namespace、Capabilities、Seccomp、LSM 和 cgroups 等机制限制其视野与权限。

一个简化的攻击路径可以表示为:

攻击成功ICSWR\text{攻击成功} \Rightarrow I \land C \land S \land W \land R

其中:

  • II:攻击者获得了容器内某个进程的执行能力;
  • CC:该进程拥有完成攻击所需的 Capabilities 或其他权限;
  • SS:所需系统调用没有被 Seccomp 拦截;
  • WW:攻击路径中存在可写入的文件、设备或内核接口;
  • RR:Namespace、挂载、Docker socket 或宿主机接口提供了可达目标。

这个表达式不是安全评分公式,而是用于说明防御关系:只要其中一个必要条件被移除,某条攻击路径就会失败;但攻击者可能寻找另一条路径。

例如:

  1. 容器内服务被远程命令执行漏洞攻陷;
  2. 服务以 UID 0 运行;
  3. 进程尝试加载内核模块或操作网络设备;
  4. CAP_SYS_MODULECAP_NET_ADMIN 等权限被删除;
  5. 该路径失败。

如果进程虽然不是 root,却能访问 /var/run/docker.sock,攻击者仍可能通过 Docker API 创建一个高权限容器。因此,身份控制必须和挂载、套接字、Capabilities、Namespace 一起检查。

1.1 rootful Docker 与 rootless Docker

常见的 Docker Engine 由宿主机上的 root 权限 daemon 管理。即使容器内进程是非 root,Docker daemon 仍可能代表客户端创建挂载、网络和设备。

因此:

docker -H unix:///var/run/docker.sock ps

中的 Docker socket 本身就是高权限控制面。把它挂载进容器:

-v /var/run/docker.sock:/var/run/docker.sock

通常等价于向容器授予“请求 Docker daemon 执行特权操作”的能力。它不是普通的日志或配置文件挂载,不应在没有明确威胁模型的情况下暴露给应用容器。

rootless Docker 则让 daemon 和容器进程都运行在普通用户上下文中,并使用用户命名空间映射 UID。它可以降低 daemon 被攻陷后直接获得宿主机 root 的风险,但存在能力边界,例如:

  • 低端口绑定、设备访问和部分网络功能可能受限;
  • 某些存储驱动和内核功能依赖宿主机配置;
  • rootless 不会修复应用漏洞,也不会替代容器内的身份、文件系统和系统调用限制。

rootless 与“容器内使用非 root”是两个不同概念:

  • rootless:Docker daemon 本身不以宿主机 root 运行;
  • 非 root 容器:容器内应用进程不以容器 UID 0 运行。

二者可以同时使用,也可以分别使用。


2. 非 root:限制进程身份,而不只是改变用户名

2.1 容器里的 root 到底是什么

默认情况下,容器内的 UID 0 通常对应容器的 root 用户。若未启用用户命名空间映射,该 UID 0 还可能对应宿主机的 UID 0;即使启用了映射,容器内 root 仍经常拥有容器文件系统内的完全访问能力,并可能利用其他错误配置扩大影响。

容器内的 root 不等于“宿主机一定被攻破”,但也绝不能理解为普通用户。它通常可以:

  • 修改容器内由 root 拥有的文件;
  • 改变进程 UID/GID,前提是拥有相应 Capability;
  • 使用容器内可见的设备和挂载;
  • 通过应用、Docker socket、宿主机目录或内核漏洞进一步扩大影响。

2.2 在镜像中声明非 root 用户

一个基本的 Dockerfile 可以写成:

FROM alpine:3.20

RUN addgroup -S app \
    && adduser -S -G app -u 10001 app

WORKDIR /app

COPY --chown=app:app run.sh /app/run.sh
RUN chmod 0555 /app/run.sh

USER 10001:10001

ENTRYPOINT ["/app/run.sh"]

run.sh

#!/bin/sh
set -eu

printf 'uid=%s gid=%s\n' "$(id -u)" "$(id -g)"
printf 'root filesystem test: '

if touch /etc/should-fail 2>/dev/null; then
    echo "unexpectedly writable"
    rm -f /etc/should-fail
else
    echo "write denied"
fi

mkdir -p /tmp/app
printf 'ok\n' >/tmp/app/status
cat /tmp/app/status

构建并运行:

docker build -t nonroot-demo .
docker run --rm nonroot-demo

预期输出类似:

uid=10001 gid=10001
root filesystem test: write denied
ok

这里有三个重要细节:

  1. USER 10001:10001 使镜像默认启动进程使用数值 UID/GID,而不是依赖运行时是否存在同名用户;
  2. COPY --chown=app:app 避免复制的文件归 root 所有,导致非 root 进程无法读取或执行;
  3. chmod 0555 只允许读取和执行,脚本本身不能被应用用户修改。

仅仅在 Dockerfile 最后写 USER app 并不能解决所有问题。还必须检查:

  • 应用需要写入哪些目录;
  • 日志是写 stdout/stderr,还是写文件;
  • 绑定挂载的宿主机文件归谁所有;
  • 启动脚本是否试图修改 /etc、切换用户或绑定低端口;
  • 包管理器和临时目录是否依赖 root。

2.3 运行时覆盖 USER

镜像声明了非 root 后,运行时仍可显式覆盖:

docker run --rm --user 20000:20000 nonroot-demo

这适合平台统一分配 UID,但可能导致镜像内已有目录不可读或不可写。反过来,以下命令会重新以 root 启动:

docker run --rm --user 0 nonroot-demo

所以安全检查不能只读取 Dockerfile,还要检查最终的 docker run、Compose、Helm 或平台编排配置。

2.4 非 root 的常见失败表现

应用切换为非 root 后,常见错误包括:

Permission denied: '/app/data'
bind: permission denied
cannot create /var/run/app.pid

诊断时先确认实际身份和目录权限:

docker run --rm --entrypoint sh nonroot-demo -c '
id
printf "\n"
find /app /tmp -maxdepth 2 -printf "%M %u:%g %p\n" 2>/dev/null
'

对于正在运行的容器:

docker exec <container> id
docker exec <container> sh -c 'cat /proc/1/status | grep -E "^(Uid|Gid|Cap|NoNewPrivs):"'

如果应用确实需要写入 /app/data,应明确创建并授权该目录,而不是把整个容器改回 root:

RUN mkdir -p /app/data \
    && chown -R 10001:10001 /app/data

如果使用宿主机绑定挂载,则宿主机上的 UID/GID 也必须匹配;容器内的用户名字符串不会自动改变宿主机文件所有权。


3. Linux Capabilities:把 root 拆分为多个特权集合

3.1 为什么需要 Capabilities

传统 Unix 权限模型把大量管理能力集中到 UID 0。Linux Capabilities 将其中一部分能力拆成独立权限,例如:

  • CAP_NET_BIND_SERVICE:绑定小于 1024 的端口;
  • CAP_NET_RAW:创建原始网络套接字,常与 ping、抓包或网络探测有关;
  • CAP_NET_ADMIN:配置网络接口、路由、规则等;
  • CAP_CHOWN:改变文件所有者;
  • CAP_DAC_OVERRIDE:绕过部分传统文件读写检查;
  • CAP_SETUIDCAP_SETGID:改变进程 UID/GID;
  • CAP_SYS_ADMIN:范围非常广,常被认为是高风险能力;
  • CAP_SYS_PTRACE:对其他进程执行部分调试和跟踪操作;
  • CAP_SYS_MODULE:加载和卸载内核模块。

Capability 不是一个布尔值,而是多个进程集合。Linux 进程通常涉及:

  • Permitted:进程允许使用的能力集合;
  • Effective:当前系统调用检查实际使用的集合;
  • Inheritable:可在执行新程序时继承的集合;
  • Ambient:普通程序执行时可保留的一部分能力;
  • Bounding:进程及其子进程能够获得的能力上限。

工程配置通常通过 Docker 的 --cap-drop--cap-add 操作能力集合,但内核实际判断仍基于进程的 Capability 集合和具体系统调用。

3.2 默认 Capabilities 不是稳定的安全契约

Docker 通常不会把宿主机 root 的全部 Capabilities 直接交给普通容器,而是根据 Engine 和运行时版本使用一个默认集合。默认集合可能包含 CAP_NET_RAW 等对某些应用并非必要的能力,也可能随着实现和平台变化。

因此,不应把“默认容器已经安全”理解为规范保证。更明确的做法是先全部删除,再按应用需要添加:

docker run --rm \
  --cap-drop=ALL \
  alpine:3.20 \
  sh -c 'id; grep "^Cap" /proc/self/status'

在许多 Linux 环境中,CapEff 会显示为全零,但具体输出是十六进制位图,不能直接把数值当作能力名称。可以在具备 libcap 工具的镜像中查看:

docker run --rm \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  alpine:3.20 \
  sh -c 'apk add --no-cache libcap >/dev/null && capsh --print'

apk add 需要网络访问和包仓库,不应放在生产运行容器中;这个命令只是用于实验和诊断。

3.3 能力最小化的端到端示例

假设一个服务只需要监听 80 端口,但不需要配置网卡、加载模块或访问原始网络套接字:

docker run --rm \
  --user 10001:10001 \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --security-opt=no-new-privileges:true \
  alpine:3.20 \
  sh -c 'grep "^Cap" /proc/self/status; id'

这里的因果关系是:

  1. --user 限制初始 UID/GID;
  2. --cap-drop=ALL 删除容器运行时提供的全部能力;
  3. --cap-add=NET_BIND_SERVICE 只恢复绑定低端口所需的能力;
  4. no-new-privileges 阻止进程通过 set-user-ID、set-group-ID 或文件 Capability 等方式获得更多权限;
  5. 该配置仍不允许应用配置路由、创建任意网络设备或加载内核模块。

但即使应用需要绑定 80 端口,也可以让它监听 8080,再由反向代理或宿主机端口映射到 80:

docker run --rm -p 80:8080 my-service

端口映射发生在 Docker 网络层,并不要求容器进程拥有 CAP_NET_BIND_SERVICE。这通常比为应用增加 Capability 更简单。

3.4 --privileged 会破坏多层限制

以下配置会显著扩大容器权限:

docker run --privileged ...

它不是“多给几个 Capability”,而是通常同时影响:

  • 容器可获得的 Capabilities;
  • 设备访问;
  • Seccomp;
  • AppArmor 或其他 LSM 配置;
  • Namespace 和内核接口的可用范围。

调试时临时使用 --privileged 可能帮助定位问题,但把它留在生产配置中,通常意味着放弃了大量隔离收益。若应用只需要访问某个设备,应优先使用精确的 --device,并仍然审查该设备暴露的内核接口。


4. no-new-privileges:防止权限在执行过程中增长

no-new-privileges 是 Linux 的进程属性。启用后,进程及其子进程不能通过执行 setuid/setgid 程序或其他受支持的路径获得新的特权。

Docker 运行示例:

docker run --rm \
  --user 10001:10001 \
  --security-opt=no-new-privileges:true \
  alpine:3.20 \
  sh -c 'grep "^NoNewPrivs:" /proc/self/status'

预期:

NoNewPrivs:  1

它的边界是:

  • 它不会删除已经拥有的 Capabilities;
  • 它不会限制普通系统调用;
  • 它不会让 root 自动变成非 root;
  • 它不会阻止应用利用已经拥有的文件、网络或设备权限。

因此,常见组合是:

非 root
+ cap-drop=ALL
+ no-new-privileges
+ Seccomp
+ 合理的 Namespace 和挂载

这些控制点分别限制身份、特权操作、权限提升、系统调用和对象可见性。


5. Seccomp:限制系统调用,而不是限制“命令”

5.1 系统调用处于哪一层

用户态程序不能直接操作进程、文件、网络和内存,它通过系统调用进入 Linux 内核,例如:

  • openat:打开文件;
  • readwrite:读写文件描述符;
  • socketconnect:创建和连接网络;
  • cloneforkexecve:创建进程和执行程序;
  • mountumount2:挂载和卸载文件系统;
  • ptrace:调试或跟踪进程;
  • bpf:加载 BPF 程序;
  • setns:加入其他 Namespace。

Seccomp,即 secure computing mode,允许内核在系统调用进入实际处理前,根据:

  • 系统调用编号;
  • CPU 架构;
  • 系统调用参数;

决定允许、拒绝、记录日志或杀死进程。

它控制的是“能否调用某类内核入口”,不是“这个进程能否执行某条 shell 命令”。

5.2 Docker 默认 Seccomp

Linux 容器通常使用 Docker 提供的默认 Seccomp 配置。其思路是允许普通应用常用系统调用,并拒绝一批风险较高或不适合容器的调用。默认配置不是一个面向所有应用的完整安全证明,具体行为取决于 Docker Engine、OCI runtime、内核和架构。

默认 Seccomp 常见的拒绝结果可能表现为:

Operation not permitted

但这个错误也可能来自:

  • 缺少 Capability;
  • 文件或目录权限不足;
  • LSM 拒绝;
  • Namespace 中不存在目标;
  • 只读挂载;
  • cgroup 限制。

所以不能只凭 EPERM 判断是 Seccomp。

查看容器的安全配置:

docker inspect <container> \
  --format '{{json .HostConfig.SecurityOpt}}'

查看完整配置:

docker inspect <container> | less

在 Linux 宿主机上,还可以检查进程的 Seccomp 状态:

docker exec <container> sh -c \
  'grep "^Seccomp:" /proc/1/status'

常见值包括:

  • 0:未启用 Seccomp;
  • 1:严格模式;
  • 2:过滤模式,通常是 BPF 过滤器。

具体容器内是否能读取该字段,取决于进程和文件系统可见性。

5.3 自定义 Seccomp 的正确思路

自定义 Seccomp 应从“应用实际需要的系统调用”出发,而不是看到几个危险名称就随意删除。一个静态链接、只读取配置并写 stdout 的程序,和一个需要 DNS、TLS、线程、动态加载、JIT 或数据库连接的程序,所需调用集合完全不同。

配置可以通过 Docker 运行参数加载:

docker run --rm \
  --security-opt seccomp=/path/to/profile.json \
  my-image

Compose 中:

services:
  app:
    image: my-image@sha256:<digest>
    security_opt:
      - seccomp:./seccomp-profile.json

下面是一个仅用于展示结构和实验的最小化示例:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": [
    "SCMP_ARCH_X86_64"
  ],
  "syscalls": [
    {
      "names": [
        "read",
        "write",
        "close",
        "exit",
        "exit_group",
        "futex",
        "mmap",
        "mprotect",
        "munmap",
        "brk",
        "rt_sigaction",
        "rt_sigprocmask",
        "rt_sigreturn",
        "clock_gettime",
        "nanosleep"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

它只允许少量系统调用,动态链接程序、需要文件读取或网络连接的程序很可能立即失败。因此不能把这个文件直接套用于通用应用。

正确的调试流程是:

  1. 先用 Docker 默认 Seccomp 运行;
  2. 使用测试流量覆盖启动、配置加载、DNS、网络、日志、优雅退出等路径;
  3. 记录实际系统调用;
  4. 逐步收紧规则;
  5. 在生产前验证异常路径和滚动升级路径;
  6. 保留回滚配置,但不要用 seccomp=unconfined 作为长期修复。

如果禁用 Seccomp:

docker run --rm \
  --security-opt seccomp=unconfined \
  my-image

相当于移除这一层系统调用过滤。它可以用于定位“是否由 Seccomp 导致启动失败”,但会增加内核攻击面,不能作为生产默认值。

5.4 Seccomp 不能替代 Capabilities

即使系统调用允许,内核仍可能在系统调用内部检查 UID、Capability、Namespace 和 LSM。例如:

  • 允许调用 mount,不代表没有 CAP_SYS_ADMIN 的进程可以成功挂载;
  • 允许调用 bpf,不代表没有相应权限的进程可以加载任意 BPF 程序;
  • 允许调用 ptrace,不代表进程可以跨 PID Namespace 调试宿主机进程。

反过来,如果 Seccomp 直接拒绝系统调用,即使进程拥有相关 Capability,也无法通过该调用完成操作。


6. 只读根文件系统:阻止写入,而不是提供加密

6.1 容器的可写层是什么

镜像由只读层组成。启动普通容器时,Docker 通常再叠加一个容器可写层,应用对 /etc/var/app 等路径的修改会写入这个层,而不会改变镜像本身。

--read-only 会把容器的根文件系统以只读方式挂载:

docker run --rm \
  --read-only \
  alpine:3.20 \
  sh -c '
    touch /etc/test 2>/dev/null || echo "/etc: read-only"
    mkdir -p /tmp/test
    echo ok >/tmp/test/result
    cat /tmp/test/result
  '

预期类似:

/etc: read-only
ok

/tmp 之所以可以写,是因为这个命令只验证了根文件系统只读,并没有显式阻止某些已有临时挂载。要可靠地为运行时临时数据提供写入位置,应显式挂载 tmpfs:

docker run --rm \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,nodev \
  alpine:3.20 \
  sh -c 'echo ok >/tmp/result && cat /tmp/result'

这里:

  • rw:允许写入;
  • noexec:禁止从该挂载点直接执行文件;
  • nosuid:忽略 set-user-ID/set-group-ID 位;
  • nodev:不把普通文件解释为设备。

这些挂载选项仍受内核、运行时和平台实现影响,应在目标 Linux 环境中验证。

6.2 只读根的真实效果

只读根文件系统可以减少:

  • 攻击者修改应用二进制;
  • 写入启动脚本或动态加载配置;
  • 写入 cron、shell 初始化文件等持久化位置;
  • 恶意程序在镜像根路径创建落地文件;
  • 应用意外污染镜像运行环境。

但它不能阻止:

  • 修改内存中的代码或数据;
  • 读取已有的 Secret;
  • 向网络发送数据;
  • 写入可写的 volume、bind mount 或 tmpfs;
  • 利用 Docker socket;
  • 利用宿主机内核漏洞;
  • 删除或覆盖某个单独可写挂载中的文件。

只读根也不是数据机密性机制。文件仍然可以被读取,挂载的 Secret 仍然可能被进程访问,日志仍然可能泄漏敏感信息。

6.3 应用写入失败的修复方法

如果应用启动时报:

Read-only file system

不要直接去掉 --read-only。先确认它写入了什么:

docker run --rm \
  --read-only \
  --tmpfs /tmp \
  my-image

然后检查应用的:

  • PID 文件;
  • Unix socket;
  • 缓存;
  • 临时上传文件;
  • 数据目录;
  • 日志文件;
  • 运行时生成的配置。

将这些位置分为三类:

  1. 不需要持久化:使用 tmpfs;
  2. 需要跨重启保留:使用专用 volume;
  3. 本应只读:修改应用配置,避免写入。

例如:

docker run --rm \
  --read-only \
  --tmpfs /run:rw,noexec,nosuid,nodev \
  --mount type=volume,src=app-data,dst=/var/lib/app \
  my-image

/run 中的临时 PID 或 socket 不应和持久化数据混在同一个大目录中。专用挂载可以让权限、备份和清理边界更清楚。


7. Namespace 隔离:限制容器“看得到什么”

Docker 使用 Linux Namespace 为容器提供隔离视图。常见 Namespace 包括:

Namespace 隔离对象 容器中的表现
PID 进程编号和进程树 容器通常只能看到自己的进程
Mount 挂载点和文件系统视图 容器看到独立的根目录
Network 网卡、路由、端口、iptables 视图 容器通常拥有自己的网络栈
UTS 主机名和域名 容器可有独立 hostname
IPC System V IPC、POSIX 消息队列等 容器间默认不共享 IPC
User UID/GID 映射 容器 UID 可映射为宿主机非特权 UID
Cgroup cgroup 视图 控制资源层级的可见性和归属

默认隔离并不代表所有 Namespace 都绝对独立。以下选项会主动削弱隔离:

--pid=host
--network=host
--ipc=host
--uts=host
--userns=host

例如 --pid=host 让容器可以看到宿主机 PID Namespace 中的进程;这会扩大进程信息暴露,并可能与 CAP_SYS_PTRACE 等能力组合形成更严重风险。

--network=host 让容器直接使用宿主机网络栈,容器内监听的端口就是宿主机端口,也绕过了部分 Docker 网络隔离。它可能用于特定高性能或网络代理场景,但不应作为普通服务的默认配置。

若服务不需要网络,可以使用:

docker run --rm --network=none alpine:3.20 ip addr

此时通常只能看到回环接口,不能访问外部网络。注意 --network=none 不会阻止已经挂载的宿主机文件或 Docker socket 访问,它只限制网络 Namespace。

7.1 容器之间的隔离不是绝对隔离

默认情况下,不同容器通常具有独立的 PID、Mount、Network 和 IPC Namespace,但它们仍可能共享:

  • 同一个宿主机内核;
  • 同一台机器的 CPU、内存和设备;
  • Docker daemon;
  • 显式挂载的 volume 或 bind mount;
  • 用户定义网络;
  • 某些共享的 Namespace。

例如:

docker run --rm \
  --pid=container:other-container \
  alpine:3.20 ps

会让新容器加入另一个容器的 PID Namespace。共享 Namespace 是显式的架构选择,而不是普通容器的默认状态,应和共享进程、信号、调试权限一起评估。

7.2 cgroups 是资源隔离,不是身份隔离

cgroups 限制 CPU、内存、进程数、块设备 IO 等资源。例如:

docker run --rm \
  --memory=256m \
  --cpus=1.0 \
  --pids-limit=128 \
  my-image

这些限制可以降低资源耗尽攻击的影响,但不改变进程 UID,也不限制系统调用。内存达到上限时,进程可能被 cgroup OOM 杀死;--pids-limit 达到上限时,创建新线程或进程可能失败。

因此,资源故障的诊断不能误判为应用权限问题:

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

8. 一个组合配置:非 root、Capabilities、Seccomp、只读根和隔离

下面给出一个可构建的 Compose 示例。它假设应用:

  • 不需要 root;
  • 不需要低端口特权;
  • 不需要网络;
  • 只向 stdout 输出日志;
  • 只需要在 /tmp 产生临时数据;
  • 不需要持久化文件。

Dockerfile

FROM alpine:3.20

RUN addgroup -S app \
    && adduser -S -D -H -u 10001 -G app app

WORKDIR /app

COPY --chown=10001:10001 run.sh /app/run.sh
RUN chmod 0555 /app/run.sh

USER 10001:10001

ENTRYPOINT ["/app/run.sh"]

run.sh

#!/bin/sh
set -eu

echo "uid=$(id -u) gid=$(id -g)"

mkdir -p /tmp/app
printf '%s\n' "temporary data" >/tmp/app/state
cat /tmp/app/state

sleep 3600

compose.yaml

services:
  app:
    build:
      context: .
    read_only: true
    user: "10001:10001"
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp:rw,noexec,nosuid,nodev
    network_mode: "none"
    pids_limit: 64

启动:

docker compose up --build

预期日志类似:

uid=10001 gid=10001
temporary data

每个配置的作用如下:

  1. USERuser 共同保证镜像默认值和编排运行时都使用非 root;
  2. cap_drop: ALL 不依赖 Docker 默认保留哪些 Capabilities;
  3. no-new-privileges:true 防止启动后通过 setuid 等路径增加权限;
  4. read_only: true 让根文件系统不可写;
  5. tmpfs 为临时状态提供明确的可写位置;
  6. network_mode: none 移除不需要的网络路径;
  7. pids_limit 限制进程和线程数量,降低 fork bomb 影响;
  8. 没有挂载 Docker socket、宿主机根目录或设备,因此减少了跨边界路径。

验证配置:

docker compose ps
docker inspect "$(docker compose ps -q app)" \
  --format '
User={{.Config.User}}
Readonly={{.HostConfig.ReadonlyRootfs}}
SecurityOpt={{json .HostConfig.SecurityOpt}}
CapDrop={{json .HostConfig.CapDrop}}
NetworkMode={{.HostConfig.NetworkMode}}
PidsLimit={{.HostConfig.PidsLimit}}
'

验证写入和网络:

docker compose exec app sh -c '
id
touch /etc/fail 2>/dev/null || echo rootfs-read-only
cat /proc/net/route
'

这里的测试结果不是永久证明。应用升级后可能新增 DNS、网络、证书、缓存或线程需求,必须重新验证。


9. Secret 与只读根的关系:写保护不等于秘密保护

Secret 的安全目标是降低明文配置在命令行、镜像层和环境变量中的暴露风险。常见注入方式包括:

  • 环境变量;
  • 绑定文件;
  • Compose Secret;
  • 平台的 Secret 存储。

环境变量容易被以下位置看到:

docker inspect <container>
docker exec <container> env

它们也可能被应用诊断接口、错误日志或子进程继承。Secret 文件通常更容易控制读取路径和权限,但只要进程拥有读取权限,攻击者控制该进程后仍然可以读取。

例如,以下配置让 Secret 以文件形式提供:

services:
  app:
    image: my-app@sha256:<digest>
    secrets:
      - db_password
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,nodev

secrets:
  db_password:
    file: ./secrets/db_password.txt

应用应从 /run/secrets/db_password 读取,而不是把内容复制到环境变量、日志或可写目录中。Secret 文件路径和具体挂载行为依赖 Compose 实现与平台,但“文件注入后进程仍可读取”这一点不变。

只读根的作用是防止应用把 Secret 复制到普通根文件系统中的可写路径;它不能:

  • 阻止应用读取 Secret;
  • 阻止应用把 Secret 打印到 stdout;
  • 阻止应用通过网络外传 Secret;
  • 自动完成 Secret 轮换。

轮换时,应用需要重新读取文件、接收信号、重启或通过平台特定机制刷新。若应用只在启动时读取一次,替换 Secret 文件不会自动改变内存中的密码。


10. 镜像供应链与运行时隔离是两条链

非 root、Capabilities 和 Seccomp 保护的是运行时;它们不能证明镜像内容可信。镜像供应链至少涉及:

  1. 可信基础镜像来源;
  2. 版本或 digest 固定;
  3. SBOM,即软件物料清单;
  4. 漏洞扫描;
  5. 镜像签名与验证;
  6. 策略门禁;
  7. 构建过程中的依赖和 Secret 泄漏防护。

例如:

FROM alpine:3.20

依赖可变标签。使用 digest 可以固定内容:

FROM alpine:3.20@sha256:<verified-digest>

digest 固定的是某个镜像内容,不等于该内容天然没有漏洞,也不等于来源已经可信。工程上通常需要同时验证来源、签名、SBOM 和漏洞策略。

运行时安全也不能代替扫描:

  • 一个存在远程执行漏洞的非 root 应用仍可能被攻陷;
  • 一个没有高危漏洞的镜像,如果以 --privileged 启动,仍可能拥有过大的运行时权限;
  • SBOM 能帮助知道软件组成,但不会限制系统调用;
  • 签名能帮助判断来源和完整性,但不能阻止应用访问挂载的 Secret。

更可靠的门禁逻辑是同时检查镜像和运行配置,例如:

镜像 digest 已固定
且来源/签名符合策略
且扫描结果满足风险门槛
且容器用户不是 root
且未使用 privileged
且未挂载 Docker socket
且 Capabilities 已最小化
且未关闭 Seccomp
且根文件系统按应用需求只读

这是策略系统中的组合条件,而不是某个单一扫描结果。


11. 诊断:先确定失败发生在哪一层

容器强化后,启动失败通常来自四类原因:

11.1 身份和文件权限

表现:

Permission denied

检查:

docker exec <container> id
docker exec <container> sh -c 'ls -ld /app /app/data /tmp'

修复方向是调整目录所有权、运行时 UID、volume 权限或应用路径,而不是直接恢复 root。

11.2 只读挂载

表现:

Read-only file system

检查:

docker inspect <container> \
  --format 'ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}}'

修复方向是为确实需要写入的目录增加 tmpfs 或专用 volume,并明确其生命周期。

11.3 缺少 Capability

表现可能是:

Operation not permitted

检查:

docker exec <container> sh -c '
grep -E "^(Uid|Gid|CapInh|CapPrm|CapEff|CapBnd|NoNewPrivs):" /proc/1/status
'

然后确认应用到底需要哪个特权操作。不要因为 EPERM 就添加 SYS_ADMIN;应优先确认具体系统调用和 Capability,必要时只添加一个窄能力。

11.4 Seccomp 或 LSM

表现也可能是 EPERMEACCES 或进程直接被终止。排查时可在测试环境临时进行对照:

docker run --rm \
  --security-opt seccomp=unconfined \
  my-image

如果仅在禁用 Seccomp 后恢复,需要针对实际系统调用调整配置,而不是永久关闭过滤。

同时检查宿主机的 AppArmor、SELinux 和内核日志。Docker 的 security_opt 只影响对应配置,不会把所有 LSM 统一成一个开关。


12. 常见误解与边界

误解一:非 root 就不会影响宿主机

错误。非 root 降低了直接获得特权的概率,但以下配置仍可能打开跨边界路径:

  • /var/run/docker.sock
  • 宿主机 //proc/sys 的危险挂载;
  • --privileged
  • --pid=host--network=host
  • 高风险设备;
  • 宿主机内核漏洞。

误解二:Capabilities 全删后就绝对安全

错误。普通用户仍可以:

  • 读取应用可访问的 Secret;
  • 利用应用逻辑漏洞;
  • 向允许的网络发送数据;
  • 消耗未限制的资源;
  • 利用内核漏洞;
  • 修改可写 volume 中的数据。

误解三:Seccomp 能阻止所有危险行为

错误。Seccomp 只过滤系统调用,不理解:

  • 路径是否属于某个业务目录;
  • 某个 HTTP 请求是否恶意;
  • 某个文件内容是否敏感;
  • 应用是否应该访问数据库。

它需要和身份、Capabilities、Mount Namespace、LSM、文件权限共同工作。

误解四:只读根文件系统能阻止数据泄漏

错误。只读根主要限制落地和持久化写入,不限制读取、内存、网络和其他可写挂载。

误解五:容器是虚拟机

错误。容器与宿主机共享内核。若威胁模型要求内核级强隔离、多租户不信任或需要运行不可信代码,应考虑虚拟机、微虚拟机或其他具有独立内核的沙箱技术,而不是只依赖 Docker 默认隔离。


13. 一条可执行的加固顺序

对普通 Linux 服务,可以按以下顺序建立约束:

  1. 在镜像中声明固定的非 root UID/GID;
  2. 删除全部 Capabilities,再按确切需求添加;
  3. 启用 no-new-privileges
  4. 保留 Docker 默认 Seccomp,确认没有使用 unconfined
  5. 开启只读根文件系统;
  6. 为临时目录提供受限 tmpfs;
  7. 只挂载必要的持久化目录;
  8. 不挂载 Docker socket、宿主机根目录和不必要设备;
  9. 根据需求关闭网络或使用独立网络;
  10. 设置内存、CPU 和 PID 数量限制;
  11. 结合 AppArmor 或 SELinux;
  12. 固定镜像 digest,执行 SBOM、签名、扫描和策略门禁;
  13. 用实际启动、业务请求、故障恢复和升级流程验证配置。

最后一步尤其重要。安全配置必须通过行为验证:

  • 应用是否能正常启动;
  • 是否能读取必要配置和 Secret;
  • 是否能写入唯一允许的位置;
  • 是否能完成健康检查;
  • 是否能优雅退出;
  • 是否能在资源耗尽时失败而不是拖垮节点;
  • 升级后是否引入了新的系统调用、文件写入或网络依赖。

容器安全的目标不是让进程“什么都不能做”,而是让它只能完成被明确声明和验证过的工作,并把身份、系统调用、可写状态、可见对象、资源和镜像来源分别限制在最小范围内。


系列导航与关联阅读

官方资料

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