#DevOps

Docker 安全加固:镜像扫描与最小权限

默认 Docker 容器权限过大(root + 无 seccomp)。本文讲清 Docker 安全加固:非 root 运行、最小镜像(distroless/scratch)、镜像扫描(Trivy/Grype)、seccomp/AppArmor、只读文件系统、资源限制,给一份生产容器安全 checklist。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年2月17日·更新于 2026年7月23日

Docker 安全加固:镜像扫描与最小权限

我审过一个线上事故:一个跑着 Python 服务的容器被植入了挖矿程序,top 一看 CPU 跑满,进程叫 kdevtmpfsi。排查下来是依赖里一个老 Flask 扩展有 RCE 漏洞,攻击者通过它拿到容器内的 shell,然后 curl 下载了挖矿脚本。真正让我后背发凉的是下一行命令——攻击者在容器里 cat /proc/1/status | grep Cap,发现自己居然是 root,且 capabilities 里挂着 CAP_SYS_ADMIN。

容器不是沙箱,这是绝大多数人没建立的认知。默认一个容器跑起来:进程是 root、capabilities 剪了一半但还留着一堆危险权限、seccomp 默认 profile 只挡了最离谱的系统调用、文件系统可写、资源没限制。一旦应用层被打穿(一个 RCE、一个 SSRF、一个反序列化),攻击者在容器内几乎等于 root,离宿主机只有一次逃逸的距离。

这篇文章讲清怎么把这条链一根根剪断:非 root 运行、最小镜像、CVE 扫描、seccomp/AppArmor、只读 rootfs、资源限制。没有一条是新东西,但生产里能把这六件事都做到的团队,我见过的不到三成。安全加固不是炫技,是基本功。

一、容器不是沙箱:默认权限到底有多大

先破除一个幻觉。很多人觉得"容器隔离了,应用被打穿也就出不去"。这个想法在两个层面是错的。

第一,容器内的 root 还是 root。 容器的 namespace 隔离的是视图(进程号、网络、挂载点),不是权限。一个容器内的 root,对容器内的文件系统、对挂进来的 volume、对 capabilities 允许的一切操作,拥有完整权限。更危险的是:Linux 内核里有相当一批 syscall 不做 namespace 隔离——比如某些对 /proc 的操作、对内核 keyring 的操作。容器内 root 利用这些 + 一个内核漏洞,就能跳到宿主机 namespace,这就是所谓的"容器逃逸"。历史上 CVE-2019-5736(runc)、CVE-2022-0185(cgroups)、Dirty COW,都是这条路子。

第二,默认的 capabilities 和 seccomp 比你以为的宽松。 Docker 默认给容器约 14 个 capabilities,号称"删了危险的",但 CAP_NET_RAW 还在——意味着攻击者能在容器网络里发任意原始包,做 ARP 欺骗、内网横向扫描。seccomp 默认 profile 只黑名单了大约 60 个 syscall(比如 reboot、kexec_load),Linux 有 400 多个 syscall,剩下的全放行。

给你一张表,直观感受默认容器 vs 加固后容器的权限差异:

维度默认容器加固后
运行用户root (uid 0)非 root,固定 uid
Capabilities~14 个(含 NET_RAW)全 drop,按需 --cap-add
seccomp默认 profile(挡 ~60 个)自定义 profile(默认拒绝)
文件系统可写--read-only + tmpfs
资源限制无memory/cpu/pids 限制
镜像1GB+,带 shell/aptdistroless,无 shell

安全加固的本质,就是把每一格从"默认宽松"挪到"默认拒绝"。下面每一节对应一格。

二、非 root 运行:USER 指令与 UID 的讲究

这是 ROI 最高的一条:在 Dockerfile 里加一行 USER。攻击者就算 RCE 了,拿到的是一个低权限用户,后面所有"提权到 root 再逃逸"的路径直接作废。

dockerfile
FROM node:22-slim AS runner
WORKDIR /app

# 创建专用用户和组,固定 uid/gid
RUN groupadd --system --gid 1001 nodejs \
 && useradd  --system --uid 1001 --gid nodejs --no-create-home --shell /usr/sbin/nologin nextjs

COPY --chown=nextjs:nodejs ./app ./
USER nextjs
CMD ["node", "server.js"]

几个实战要点:

1. USER 必须放在 CMD/ENTRYPOINT 之前。 放后面没用,进程已经起来了。放在 COPY 之后是为了能 --chown 把文件所有权交给这个用户——否则 root 拷贝的文件,非 root 进程可能读不了或写不了(比如要写 tmp 目录的场景)。

2. 固定 UID,别用动态分配。 useradd 不带 --uid 时,UID 是动态分配的(1000、1001 往后排)。这在 K8s 里会埋坑:runAsUser: 1000 这种 SecurityContext 要求 Dockerfile 里的 UID 对得上,固定 UID 才能让编排层和镜像层一致。惯例是选 1000 以上、避开系统保留段。

3. 用 distroless 时,UID 要和基础镜像里已有的用户对齐。 gcr.io/distroless/nodejs22-debian12 已经内置了一个 nonroot 用户(uid 65532),直接 USER nonroot 即可,不用自己建。

4. K8s 里用 SecurityContext 兜底,别只信 Dockerfile。 运行时强制覆盖,防止有人用 docker run -u root 把你的加固绕过掉:

yaml
securityContext:
  runAsNonRoot: true        # 强制非 root,uid 0 直接拒绝启动
  runAsUser: 1001
  fsGroup: 1001

runAsNonRoot: true 是个狠开关——如果镜像以 root 启动,Kubelet 直接拒绝拉起 Pod,比事后审计强多了。

一个常见疑问:"我的应用要绑 80 端口,非 root 没法绑啊?" 正解不是放开 root,而是用特权端口绑定能力(setcap cap_net_bind_service=+ep 给二进制),或者干脆用 Linux 5.7+ 的 sysctl net.ipv4.ip_unprivileged_port_start=80,或者别绑 80——前面挡一层 Service / Ingress,容器内老老实实听 8080。

三、最小镜像:distroless / scratch / alpine,攻击面是体积的函数

镜像里每多一个二进制,就多一条攻击路径。 一个带 curl、bash、apt-get 的镜像,攻击者拿到 RCE 后能现成的下载后门、横向探测、清理痕迹。一个连 /bin/sh 都没有的镜像,攻击者就算 exec 成功也只能对着 EPERM 发呆。

三种"小镜像"路线,适用场景不同:

镜像体积有 shell有包管理器适用
alpine~5–50MB有(busybox sh)有(apk)需要调试、原生模块兼容
distroless~20–150MB无无运行时,Java/Go/Node/Python
scratch0(空)无无静态编译 Go/Rust 二进制

我的实战建议:

默认上 distroless 运行时。 Google 维护,基于 Debian,有和你应用 runtime 匹配的版本(nodejs22-debian12、python3-debian12、gcc-debian12)。它有 CA 证书、有 NSS、有 tzdata,但没有 shell、没有 apt、没有 ls。调试是不方便——docker exec -it <c> sh 直接 not found——但生产环境要的就是这个"不方便"。真要调试用 :debug 变体(带 busybox shell),只在排障时临时用。

Go/Rust 这种编译成单一静态二进制的,直接用 scratch:

dockerfile
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app -ldflags="-s -w" .

FROM scratch
COPY --from=builder /app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 65532:65532
ENTRYPOINT ["/app"]

最终镜像就是一个二进制 + CA 证书包,几十 MB,连 libc 都没有。CGO_ENABLED=0 必须开,否则二进制动态链接 glibc,在 scratch 里跑不起来。CA 证书别忘了拷,否则 HTTPS 请求全报 x509: certificate signed by unknown authority。

alpine 是折中。 musl libc 极小,但要警惕:依赖原生编译的包(bcrypt、sharp、cryptography、better-sqlite3)在 musl 上可能装不上或行为有微妙差异。我的原则是builder 阶段用 alpine(快、工具链全),runner 阶段优先 distroless。两者不冲突,跨阶段 COPY --from 就行。

记住一个心智模型:镜像体积和攻击面是同一个数字的两副面孔。 一个 50MB 无 shell 的镜像,和一个 900MB 带 apt 的镜像,在 CVE 扫描器眼里是两个世界——前者可能零高危,后者常常一扫几十个。这就接上了下一节。

四、镜像扫描:把 CVE 卡在 CI,别带到生产

镜像做小了,不代表没有漏洞——openssl、glibc、node 本身的版本都可能带 CVE。镜像扫描的价值不是"扫出来",而是"扫出来之后不让它上线"。 扫描器不卡 CI,等于没扫。

两个主流开源工具,任选其一:

Trivy(Aqua Security,首选)。 快、覆盖广(OS 包、语言依赖、IaC、密钥泄露),报告清晰。一条命令卡高危:

bash
# 扫镜像,发现 HIGH/CRITICAL 直接退出非零,CI 失败
trivy image \
  --severity HIGH,CRITICAL \
  --ignore-unfixed \
  --exit-code 1 \
  --scanners vuln \
  myapp:$(git rev-parse --short HEAD)

--ignore-unfixed 很关键——上游还没出补丁的 CVE 扫出来你也修不了,只会制造噪音和倦怠。只卡有补丁的,把精力集中在能修的。

Grype(Anchore,适合 CI 集成)。 输出更结构化,SARIF 格式能直接喂给 GitHub Security tab:

bash
grype myapp:latest \
  --fail-on high \
  --only-fixed \
  -o sarif > grype.sarif

几个实战策略:

1. 扫描要在 push 之后、部署之前。 docker build 完成后立即扫,扫过才 push 到 registry。更好的姿势是开 registry 侧的持续扫描(Trivy Server / GCR 的 Vulnerability Scanning / ECR Inspector),因为新 CVE 每天都在发布——今天干净的镜像,下周可能就因为新披露的漏洞变红。生产镜像要被持续重扫,不是 build 时扫一次就万事大吉。

2. 用 .trivyignore 管理已知豁免,但要带过期时间。 有些 CVE 是误报、或上游不修、或业务不受影响。豁免可以,但每一项都要写理由 + 复查日期,否则 .trivyignore 会变成一个永远不清的垃圾桶:

text
# CVE-2024-xxxxx: 业务未使用受影响的 XXX 功能,2025-10-01 复查
CVE-2024-xxxxx

3. 漏洞修不了的根本办法是升基础镜像。 Trivy 报 node:18-slim 一堆 CVE,你 sed 一下改 node:22-slim,大半就绿了。基础镜像版本是漏洞数的最大变量,每年至少跟进一次大版本升级。

4. 别忘了扫依赖,不只是 OS 包。 Trivy / Grype 都支持 --scanners vuln 同时扫 npm/pip/maven 依赖。Node 应用的漏洞往往在 npm audit 那一层,而不是 Debian 包层。

一句话:扫描器的价值在 --exit-code 1,不在报告本身。报告没人看,红线必须自动化。

五、seccomp 与 AppArmor:系统调用级的第二道防线

如果非 root 是"剪掉用户权限",那 seccomp 和 AppArmor 就是"剪掉内核能做的事"。应用被打穿后,攻击者想提权、想逃逸,绝大多数靠的是某个鲜为人知的系统调用 + 内核漏洞组合。seccomp 把这些系统调用直接堵死,把"利用一个内核漏洞"从"难"变成"不可能"。

seccomp:系统调用过滤。 Docker 默认就启用了 seccomp,用的是 /etc/docker/seccomp/default.json,黑名单挡掉约 60 个危险 syscall(如 keyctl、kexec_load、unshare 在某些场景)。这个默认 profile 提供了基础保护,但真正的加固是切换到自定义白名单 profile——默认允许 300+ syscall 改成默认拒绝,只放开应用实际用到的几十个。

生成 seccomp profile 有工具链辅助:oci-seccomp-bpf-hook 能在干净环境跑一遍应用,trace 它实际调用了哪些 syscall,自动生成最小 profile。这是最稳的做法——实测而非猜测,避免"我以为这个 syscall 没用,结果启动就挂"。

启用自己的 profile:

bash
docker run --security-opt seccomp=/etc/docker/seccomp/myapp.json myapp:latest

K8s 里(1.19+,GA)在 SecurityContext 指定:

yaml
securityContext:
  seccompProfile:
    type: Localhost
    localhostProfile: profiles/myapp-seccomp.json

AppArmor:路径与能力的强制访问控制。 seccomp 管 syscall,AppArmor 管得更细——哪个路径能读、哪个能写、哪个二进制能 exec。它是 Ubuntu/Debian 默认带的 LSM(Linux Security Module),CentOS 系用 SELinux(更复杂但更强)。

AppArmor profile 长这样(简化):

text
#include <tunables/global>
profile myapp flags=(attach_disconnected) {
  #include <abstractions/base>
  # 只允许读自己的配置和 CA
  /app/** r,
  /etc/ssl/certs/** r,
  # 只允许写数据目录
  /data/** rw,
  # 禁止 exec 任何东西(挡反弹 shell)
  deny /** x,
  network inet stream,
}

这条 deny /** x 是我最推荐的一行——应用进程再也无法 exec 任何二进制,攻击者拿到 RCE 也起不来 shell、跑不了 curl、下载不了后门。配合 distroless 的"无 shell",这是双重保险。

bash
docker run --security-opt apparmor=docker-myapp myapp:latest

seccomp 和 AppArmor 不是二选一,是叠加的。 seccomp 在 syscall 层挡内核利用,AppArmor 在文件/能力层挡横向移动,两者正交。生产关键服务我建议两个都开。

一个务实提醒:两个都别在生产第一次启用。先在测试环境跑一周,seccomp profile 不全会直接 SIGSYS 杀进程,AppArmor 太严会 EACCES 报错——两者都要在非生产环境把 profile 调到刚好够用。

六、只读 rootfs 与资源限制:挡住落盘与资源耗尽

前五节挡的是"权限",这一节挡的是"后果"。攻击者即便进来了,你能让他写不了文件、占不了资源,影响半径也能压到极小。

只读 rootfs:--read-only。 默认容器文件系统可写,攻击者 curl 下载的挖矿程序、植入的 cron、篡改的二进制,重启前都赖在文件系统里。开只读后,根文件系统挂载为 read-only,任何写操作直接 EROFS:

bash
docker run --read-only \
  --tmpfs /tmp:rw,size=10m \
  --tmpfs /var/cache:rw,size=50m \
  myapp:latest

--tmpfs 是关键配套。应用总有些目录必须写——/tmp、缓存目录、session 存储——用 tmpfs 显式放开,挂载在内存里,容器一停即清。只读根 + 持久数据走 volume + 临时数据走 tmpfs,这是干净的写模型。

K8s 对应:

yaml
securityContext:
  readOnlyRootFilesystem: true

Pod spec 里再配 emptyDir 或 emptyDir.medium: Memory(即 tmpfs)给需要写的挂载点。

只读 rootfs 还有一个隐性收益:强制开发者把"有状态"和"无状态"分清楚。能写 rootfs 时,开发者随手往 /app/data 里塞文件也能跑;开了只读,所有需要持久化的数据必须显式走 volume,这本身就是更好的架构习惯。

资源限制:memory / cpu / pids。 默认容器没有资源限制,一个容器 OOM 或 CPU 跑满能拖垮整台宿主机——这是"吵闹的邻居(noisy neighbor)"问题,也是挖矿/ fork bomb 攻击最直接的破坏方式。docker run 三个限制:

bash
docker run \
  --memory="512m" \
  --memory-swap="512m" \
  --memory-reservation="256m" \
  --cpus="1.0" \
  --cpu-shares=512 \
  --pids-limit="100" \
  myapp:latest

逐条解释:

  • --memory + --memory-swap 设成相同值:关掉 swap 兜底,OOM 时进程直接被杀而不是无限吃 swap。这是避免"慢死"的关键。
  • --memory-reservation 是软限:平时允许突发,内存紧张时按这个值回收。
  • --cpus="1.0" 限核数。注意这是相对限制不是绝对绑定,--cpu-shares 是权重(默认 1024),只在 CPU 紧张时生效。
  • --pids-limit 最容易被忽略,但防 fork bomb 最有效。攻击者或 bug 一旦 fork() 失控,没有 pids 限制会瞬间吃满进程表,影响宿主机所有容器。

K8s 对应在 resources 字段:

yaml
resources:
  requests:
    cpu: "250m"
    memory: "256Mi"
  limits:
    cpu: "1000m"
    memory: "512Mi"

一句反直觉的告诫:不要只设 limits 不设 requests。 K8s 调度靠 requests 算节点能不能装下,limits 是运行时 cgroup 上限。只设 limits,Pod 可能被调度到一个根本没那么多资源的节点,引发频繁 throttle 和 OOM。requests 是承诺,limits 是上限,两者都要有,且 limits 一般是 requests 的 2–4 倍。

特别提醒:务必设 memory limit,谨慎设 cpu limit。CPU 是可压缩资源(compressible),超限只是 throttle,不会杀进程;内存是不可压缩资源,超限直接 OOMKill。所以 memory limit 是安全网,必须设。CPU limit 则要看场景——延迟敏感型服务设太严会被 throttle 拖慢,我见过团队给 API 设了 cpu: 100m(0.1 核),p99 直接飙到几秒。

七、生产容器安全 checklist:上线前过一遍

把前六节落到一份能贴到 PR 模板里的清单。每一项都是"做没做"的二元判断,没有"大概做了"。

镜像层

  • 多阶段构建,最终镜像只含运行产物(无源码、无 devDependencies、无构建工具链)
  • 基础镜像用 distroless / scratch / alpine-slim,非完整 Debian
  • 镜像 < 200MB(超出要给理由)
  • CI 里 Trivy/Grype 扫描,--exit-code 1 卡 HIGH/CRITICAL
  • 基础镜像版本每季度跟进升级

运行权限层

  • Dockerfile 有 USER <非root> 指令,放在 CMD 之前
  • UID 固定(≥1000),与 K8s SecurityContext 一致
  • K8s runAsNonRoot: true 兜底
  • --cap-drop=ALL,按需 --cap-add(绝大多数服务一条都不需要加)
  • privileged 模式永远不开(securityContext.privileged: false)

系统调用与 MAC 层

  • seccomp 启用(至少用 Docker 默认 profile,关键服务用自定义白名单)
  • AppArmor/SELinux 启用,profile 包含 deny /** x 挡 exec

文件系统层

  • --read-only / readOnlyRootFilesystem: true
  • 需要写的目录显式 tmpfs / emptyDir,持久数据走 volume
  • 不挂载宿主机敏感路径(/var/run/docker.sock、/、/proc)

资源层

  • memory limit + memory request 都设
  • CPU request 设,limit 按场景
  • pids limit 设(K8s 用 podPidsLimit 准入策略)

秘钥层(经常被忘但极重要)

  • 秘钥不写进镜像(ENV 硬编码 / COPY .env 都是红线)
  • 用 K8s Secret / Vault / 云 KMS 运行时注入
  • Trivy --scanners secret 扫镜像内泄漏的 token

编排层(K8s 额外)

  • NetworkPolicy 限制东西向流量(默认 deny-all,按需放开)
  • PodSecurity Admission 启用(restricted 级别)
  • admission controller 拦截 privileged / hostNetwork / hostPID
  • namespace 隔离生产/预发/测试

这份清单不是一次性过的——每个新服务上线过一遍,每个老服务季度审计一遍。安全是持续动作,不是一次性达标。

小结

  • 容器不是沙箱,默认权限大得吓人:root 用户、十余个 capabilities、宽松 seccomp、可写文件系统、无资源限制。应用被打穿后,默认容器几乎等于一个 root 权限的跳板,离宿主机只差一个内核漏洞。
  • 非 root 运行是 ROI 最高的加固:一行 USER 指令 + 固定 UID + K8s runAsNonRoot 兜底,直接废掉绝大多数提权逃逸路径。UID 要固定、要跨镜像和编排层一致。
  • 最小镜像 + 扫描闭环是攻防的基本盘:distroless/scratch 把攻击面降到物理最小,Trivy/Grype --exit-code 1 把 CVE 卡在 CI。扫描的价值在红线自动化,不在报告。
  • seccomp/AppArmor + 只读 rootf + 资源限制是纵深防御的第二层:挡不住应用被打穿,但能把爆炸半径压到极小——exec 不了 shell、写不了盘、吃不满资源。
  • 安全是 checklist 化的持续动作,不是一次性达标:镜像层、权限层、syscall 层、文件系统层、资源层、秘钥层,每层都有"做没做"的二元判断,新服务过一遍,老服务季度审一遍。

一句话收尾:默认的容器是为"方便"优化的,生产容器要为"假设已经被打穿"优化——攻击者进来之后,还有什么能做、还有什么做不了,这才是加固的标尺。 把每一格从"默认宽松"挪到"默认拒绝",这件事没有终点,只有持续的 checklist。

Related

相关推荐