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 等机制限制其视野与权限。
一个简化的攻击路径可以表示为:
其中:
- :攻击者获得了容器内某个进程的执行能力;
- :该进程拥有完成攻击所需的 Capabilities 或其他权限;
- :所需系统调用没有被 Seccomp 拦截;
- :攻击路径中存在可写入的文件、设备或内核接口;
- :Namespace、挂载、Docker socket 或宿主机接口提供了可达目标。
这个表达式不是安全评分公式,而是用于说明防御关系:只要其中一个必要条件被移除,某条攻击路径就会失败;但攻击者可能寻找另一条路径。
例如:
- 容器内服务被远程命令执行漏洞攻陷;
- 服务以 UID 0 运行;
- 进程尝试加载内核模块或操作网络设备;
CAP_SYS_MODULE、CAP_NET_ADMIN等权限被删除;- 该路径失败。
如果进程虽然不是 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
这里有三个重要细节:
USER 10001:10001使镜像默认启动进程使用数值 UID/GID,而不是依赖运行时是否存在同名用户;COPY --chown=app:app避免复制的文件归 root 所有,导致非 root 进程无法读取或执行;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_SETUID、CAP_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'
这里的因果关系是:
--user限制初始 UID/GID;--cap-drop=ALL删除容器运行时提供的全部能力;--cap-add=NET_BIND_SERVICE只恢复绑定低端口所需的能力;no-new-privileges阻止进程通过 set-user-ID、set-group-ID 或文件 Capability 等方式获得更多权限;- 该配置仍不允许应用配置路由、创建任意网络设备或加载内核模块。
但即使应用需要绑定 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:打开文件;read、write:读写文件描述符;socket、connect:创建和连接网络;clone、fork、execve:创建进程和执行程序;mount、umount2:挂载和卸载文件系统;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"
}
]
}
它只允许少量系统调用,动态链接程序、需要文件读取或网络连接的程序很可能立即失败。因此不能把这个文件直接套用于通用应用。
正确的调试流程是:
- 先用 Docker 默认 Seccomp 运行;
- 使用测试流量覆盖启动、配置加载、DNS、网络、日志、优雅退出等路径;
- 记录实际系统调用;
- 逐步收紧规则;
- 在生产前验证异常路径和滚动升级路径;
- 保留回滚配置,但不要用
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;
- 缓存;
- 临时上传文件;
- 数据目录;
- 日志文件;
- 运行时生成的配置。
将这些位置分为三类:
- 不需要持久化:使用 tmpfs;
- 需要跨重启保留:使用专用 volume;
- 本应只读:修改应用配置,避免写入。
例如:
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
每个配置的作用如下:
USER与user共同保证镜像默认值和编排运行时都使用非 root;cap_drop: ALL不依赖 Docker 默认保留哪些 Capabilities;no-new-privileges:true防止启动后通过 setuid 等路径增加权限;read_only: true让根文件系统不可写;tmpfs为临时状态提供明确的可写位置;network_mode: none移除不需要的网络路径;pids_limit限制进程和线程数量,降低 fork bomb 影响;- 没有挂载 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 保护的是运行时;它们不能证明镜像内容可信。镜像供应链至少涉及:
- 可信基础镜像来源;
- 版本或 digest 固定;
- SBOM,即软件物料清单;
- 漏洞扫描;
- 镜像签名与验证;
- 策略门禁;
- 构建过程中的依赖和 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
表现也可能是 EPERM、EACCES 或进程直接被终止。排查时可在测试环境临时进行对照:
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 服务,可以按以下顺序建立约束:
- 在镜像中声明固定的非 root UID/GID;
- 删除全部 Capabilities,再按确切需求添加;
- 启用
no-new-privileges; - 保留 Docker 默认 Seccomp,确认没有使用
unconfined; - 开启只读根文件系统;
- 为临时目录提供受限 tmpfs;
- 只挂载必要的持久化目录;
- 不挂载 Docker socket、宿主机根目录和不必要设备;
- 根据需求关闭网络或使用独立网络;
- 设置内存、CPU 和 PID 数量限制;
- 结合 AppArmor 或 SELinux;
- 固定镜像 digest,执行 SBOM、签名、扫描和策略门禁;
- 用实际启动、业务请求、故障恢复和升级流程验证配置。
最后一步尤其重要。安全配置必须通过行为验证:
- 应用是否能正常启动;
- 是否能读取必要配置和 Secret;
- 是否能写入唯一允许的位置;
- 是否能完成健康检查;
- 是否能优雅退出;
- 是否能在资源耗尽时失败而不是拖垮节点;
- 升级后是否引入了新的系统调用、文件写入或网络依赖。
容器安全的目标不是让进程“什么都不能做”,而是让它只能完成被明确声明和验证过的工作,并把身份、系统调用、可写状态、可见对象、资源和镜像来源分别限制在最小范围内。
系列导航与关联阅读
- 系列入口:Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:Docker 配置与 Secret:环境变量、文件注入、轮换和泄漏防护
- 下一篇:Docker 软件供应链:SBOM、签名、扫描、可信基础镜像和策略门禁
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论