Docker 安全基础:非 root 用户运行、只读文件系统与最小权限原则
系统讲解 Docker 容器安全的基础实践:以非 root 用户运行容器(USER 指令、gosu/tini、UID 映射)、只读文件系统(--read-only + tmpfs 挂载临时目录)、最小权限原则(--cap-drop ALL + 按需 --cap-add、--no-new-privileges 防止提权)、安全选项配置,以及常见危险操作(--privileged/--pid=host/--network=host)的风险分析与替代方案。
容器共享宿主机内核,一旦容器内进程逃逸,后果比虚拟机逃逸更严重。但大多数生产环境中的容器仍以 root 用户运行、拥有完整权限——这不是必须的,而是配置疏忽。本文讲解容器安全加固的基础层:用户、文件系统、权限三个维度。
安全基线检查
# 快速检查当前容器的安全配置
docker inspect myapp --format '
User: {{.Config.User}}
ReadonlyRootfs: {{.HostConfig.ReadonlyRootfs}}
Privileged: {{.HostConfig.Privileged}}
CapAdd: {{.HostConfig.CapAdd}}
CapDrop: {{.HostConfig.CapDrop}}
SecurityOpt: {{.HostConfig.SecurityOpt}}
NoNewPrivileges: {{index .HostConfig.SecurityOpt 0}}
'
一、以非 root 用户运行容器
1.1 为什么 root 用户危险
容器内 root = 宿主机 root(未开启 User Namespace 时)
风险场景:
容器内进程以 root 运行
↓ 挂载了宿主机目录(-v /:/host 或 -v /etc:/etc)
↓ 容器内 root 可以修改 /host/etc/passwd
↓ 逃逸成功,宿主机被控制
实际案例:
很多 CI 系统(如挂载 docker.sock 的 runner)
以 root 运行 → 等同于拥有宿主机 root 权限
1.2 Dockerfile 配置非 root 用户
# Python 应用示例
FROM python:3.11-slim
WORKDIR /app
# 创建专用用户(UID 1000)
RUN groupadd -r app && useradd -r -g app -u 1000 app
# 安装依赖(以 root 安装,然后切换)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制应用文件并设置所有权
COPY --chown=app:app . .
# 创建必要的目录并设置权限
RUN mkdir -p /app/logs /app/tmp \
&& chown -R app:app /app/logs /app/tmp
# 切换到非 root 用户
USER app
EXPOSE 8000
CMD ["gunicorn", "app:application", "-b", "0.0.0.0:8000"]
# Go 应用(scratch 镜像)
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
FROM scratch
# scratch 镜像无法用 useradd,直接指定 UID
# 需要在构建时复制 /etc/passwd(或用数字 UID)
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /app /app
USER 1000:1000 # 直接使用 UID,不依赖 /etc/passwd
EXPOSE 8080
ENTRYPOINT ["/app"]
1.3 使用 tini 处理信号(替代 PID 1 问题)
# 容器内 PID 1 的问题:
# 普通应用不处理 SIGCHLD → 僵尸进程积累
# 不正确处理 SIGTERM → 容器停止时应用不优雅退出
# 方案1:使用 tini(轻量 init)
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y tini && rm -rf /var/lib/apt/lists/*
RUN useradd -r -u 1000 app
USER app
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/app/server"]
# 方案2:Docker 自带 --init 标志
docker run -d --init myapp
# 效果等同于使用 tini,不需要改 Dockerfile
1.4 处理需要特权操作的应用(gosu)
# 场景:entrypoint 需要 root 权限(修改配置),但应用本身不需要
# 方案:使用 gosu 降级(类似 su,但不产生新 shell)
# Dockerfile
RUN wget -O /usr/local/bin/gosu https://github.com/tianon/gosu/releases/download/1.17/gosu-amd64 \
&& chmod +x /usr/local/bin/gosu
# entrypoint.sh
#!/bin/sh
set -e
# root 操作:生成配置文件
envsubst < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf
chown nginx:nginx /var/log/nginx
# 降级到 nginx 用户运行应用
exec gosu nginx "$@"
二、只读文件系统
# 运行时开启只读文件系统
docker run -d \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=100m \ # 允许写 /tmp(但不能执行)
--tmpfs /var/run:rw,noexec,nosuid \ # 进程 PID 文件
--mount type=volume,source=app-logs,target=/app/logs \ # 日志用 volume
myapp
# 验证
docker exec myapp touch /test-file
# touch: /test-file: Read-only file system ← 正常
docker exec myapp touch /tmp/test-file
# 成功(tmpfs 允许写入)
# Dockerfile 配合只读文件系统
FROM python:3.11-slim
WORKDIR /app
# 提前创建应用会写入的目录(运行时这些目录将来自 volume 或 tmpfs)
VOLUME ["/app/logs", "/tmp"]
RUN useradd -r -u 1000 app
COPY --chown=app:app . .
USER app
# 告知使用者需要的可写目录
LABEL io.company.writable-dirs="/app/logs,/tmp"
# compose.yml 中配置只读
services:
api:
image: myapp:v1
read_only: true
tmpfs:
- /tmp:size=100m,mode=1777
- /var/run:size=10m
volumes:
- app-logs:/app/logs
volumes:
app-logs:
三、最小权限(Linux Capabilities)
3.1 Linux Capabilities 概述
root 的权限在 Linux 中被拆分为 40+ 个独立的 capability:
常见 capability:
CAP_NET_BIND_SERVICE 绑定 < 1024 的端口(如 80, 443)
CAP_NET_ADMIN 网络管理(设置路由、接口、iptables)
CAP_SYS_ADMIN 系统管理(挂载、chroot 等,几乎等同 root)
CAP_SYS_PTRACE 调试进程(strace)
CAP_CHOWN 修改文件所有者
CAP_SETUID 切换 UID
CAP_KILL 向任意进程发送信号
CAP_AUDIT_WRITE 写安全审计日志
Docker 默认给容器的 capabilities(约 14 个):
CHOWN, DAC_OVERRIDE, FSETID, FOWNER, MKNOD,
NET_RAW, SETGID, SETUID, SETFCAP, SETPCAP,
NET_BIND_SERVICE, SYS_CHROOT, KILL, AUDIT_WRITE
3.2 删除所有权限,按需添加
# 最安全的做法:先 drop all,再按需 add
docker run -d \
--cap-drop ALL \ # 删除所有 capabilities
--cap-add NET_BIND_SERVICE \ # 只添加绑定端口所需
--security-opt no-new-privileges \ # 禁止进程获取新权限
myapp
# 大多数 Web 应用只需要这些(如果以非 root 运行且端口 > 1024):
docker run -d \
--cap-drop ALL \
--security-opt no-new-privileges \
myapp # 完全无 capabilities(如果不需要任何特权操作)
# 查看容器实际拥有的 capabilities
docker exec myapp cat /proc/1/status | grep Cap
# CapInh: 0000000000000000
# CapPrm: 0000000000000000
# CapEff: 0000000000000000 ← 所有 cap 都是 0,即无特权
# 解码 capabilities(capsh 工具)
capsh --decode=00000000a80425fb # Docker 默认的 cap 集合
3.3 compose.yml 配置
services:
api:
image: myapp:v1
user: "1000:1000"
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # 如果需要绑定 80/443
tmpfs:
- /tmp:size=100m
nginx:
image: nginx:1.27-alpine
user: "nginx"
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN # 修改日志文件所有者
- SETUID # 降级到 nginx 用户
- SETGID
- DAC_OVERRIDE # 读取任意文件(配置文件权限)
- NET_BIND_SERVICE
ports:
- "80:80"
- "443:443"
tmpfs:
- /var/run:size=10m
- /var/cache/nginx:size=100m
四、危险配置的风险与替代方案
4.1 –privileged(特权模式)
# --privileged 的危险性
docker run -d --privileged myapp
# 等同于:给容器几乎所有 capabilities + 访问所有设备
# 相当于在容器内以宿主机 root 运行
# 常见滥用场景:
# "我的应用需要 mount"→ 只需 CAP_SYS_ADMIN
# "Docker-in-Docker"→ 挂载 docker.sock 替代
# "需要访问网络设备"→ 只需 CAP_NET_ADMIN
# 真正需要 --privileged 的场景(极少):
# 系统级工具(kvm、device mapper 操作)
# 完整系统测试
# 替代方案
docker run -d \
--cap-add SYS_ADMIN \ # 只给 mount 所需的 capability
--security-opt apparmor:unconfined \
myapp
4.2 挂载 /var/run/docker.sock
# 危险:等同于给容器 root 权限(可以管理所有容器)
docker run -d -v /var/run/docker.sock:/var/run/docker.sock myapp
# 相对安全的替代方案:Docker Socket Proxy
# 只暴露需要的 API(如只允许查询容器状态,不允许创建/删除)
docker run -d \
--name docker-proxy \
-v /var/run/docker.sock:/var/run/docker.sock \
-e CONTAINERS=1 \ # 允许 GET /containers/*
-e POST=0 \ # 禁止 POST(创建/修改)
tecnativa/docker-socket-proxy
4.3 其他危险标志
# --pid=host:共享宿主机进程命名空间
# 危险:容器内可看到并 kill 宿主机所有进程
# 替代:通常不需要,如果需要监控宿主机进程,使用 node-exporter
# --network=host:共享宿主机网络命名空间
# 危险:容器内监听的端口直接绑定到宿主机,可与宿主机进程冲突
# 适用场景:高性能服务(Nginx、Redis)且网络 NAT 是瓶颈时
# --userns=host:禁用 User Namespace
# 危险:容器 root = 宿主机 root(User NS 的保护被绕过)
# --ipc=host:共享宿主机 IPC 命名空间
# 危险:容器内可访问宿主机的共享内存
# 适用场景:需要高性能进程间通信(如 CUDA 应用)
五、daemon.json 安全加固
// /etc/docker/daemon.json
{
"userns-remap": "default",
"no-new-privileges": true,
"live-restore": true,
"icc": false,
"default-ulimits": {
"nofile": {
"Name": "nofile",
"Hard": 65535,
"Soft": 65535
}
},
"log-driver": "json-file",
"log-opts": {
"max-size": "100m",
"max-file": "5"
}
}
配置说明:
"userns-remap": "default"
→ 开启 User Namespace,容器 root 映射到宿主机非特权用户
→ 最重要的安全配置之一,但可能影响 volume 权限,需测试
"no-new-privileges": true
→ 所有容器默认禁止获取新权限(等同于 --security-opt no-new-privileges)
"icc": false
→ 禁用容器间通信(默认容器可以互相访问)
→ 开启后容器必须通过显式网络才能通信
⚠️ 注意:会影响同宿主机不同网络的容器通信
小结
容器安全的三个基础:非 root 用户、只读文件系统、最小 capabilities,单独任何一个都有价值,组合使用效果显著。
实际落地时,最容易做且收益最高的是:在 Dockerfile 中添加 USER 指令(改动成本低,效果立竿见影)。其次是 --cap-drop ALL --cap-add 按需,大多数 Web 应用实际上不需要任何 capability。只读文件系统需要梳理应用的写入路径,改造成本稍高,但能防止大量“在容器内下载和执行恶意代码”的攻击场景。
