技术博客

Docker 安全基础:非 root 用户运行、只读文件系统与最小权限原则

系统讲解 Docker 容器安全的基础实践:以非 root 用户运行容器(USER 指令、gosu/tini、UID 映射)、只读文件系统(--read-only + tmpfs 挂载临时目录)、最小权限原则(--cap-drop ALL + 按需 --cap-add、--no-new-privileges 防止提权)、安全选项配置,以及常见危险操作(--privileged/--pid=host/--network=host)的风险分析与替代方案。

Docker安全非root只读文件系统最小权限capability容器安全

容器共享宿主机内核,一旦容器内进程逃逸,后果比虚拟机逃逸更严重。但大多数生产环境中的容器仍以 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。只读文件系统需要梳理应用的写入路径,改造成本稍高,但能防止大量“在容器内下载和执行恶意代码”的攻击场景。