Docker 安全加固:镜像扫描与最小权限
默认 Docker 容器权限过大(root + 无 seccomp)。本文讲清 Docker 安全加固:非 root 运行、最小镜像(distroless/scratch)、镜像扫描(Trivy/Grype)、seccomp/AppArmor、只读文件系统、资源限制,给一份生产容器安全 checklist。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
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/apt | distroless,无 shell |
安全加固的本质,就是把每一格从"默认宽松"挪到"默认拒绝"。下面每一节对应一格。
二、非 root 运行:USER 指令与 UID 的讲究
这是 ROI 最高的一条:在 Dockerfile 里加一行 USER。攻击者就算 RCE 了,拿到的是一个低权限用户,后面所有"提权到 root 再逃逸"的路径直接作废。
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 把你的加固绕过掉:
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 |
scratch | 0(空) | 无 | 无 | 静态编译 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:
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、密钥泄露),报告清晰。一条命令卡高危:
# 扫镜像,发现 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:
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 会变成一个永远不清的垃圾桶:
# 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:
docker run --security-opt seccomp=/etc/docker/seccomp/myapp.json myapp:latest
K8s 里(1.19+,GA)在 SecurityContext 指定:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/myapp-seccomp.json
AppArmor:路径与能力的强制访问控制。 seccomp 管 syscall,AppArmor 管得更细——哪个路径能读、哪个能写、哪个二进制能 exec。它是 Ubuntu/Debian 默认带的 LSM(Linux Security Module),CentOS 系用 SELinux(更复杂但更强)。
AppArmor profile 长这样(简化):
#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",这是双重保险。
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:
docker run --read-only \
--tmpfs /tmp:rw,size=10m \
--tmpfs /var/cache:rw,size=50m \
myapp:latest
--tmpfs 是关键配套。应用总有些目录必须写——/tmp、缓存目录、session 存储——用 tmpfs 显式放开,挂载在内存里,容器一停即清。只读根 + 持久数据走 volume + 临时数据走 tmpfs,这是干净的写模型。
K8s 对应:
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 三个限制:
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 字段:
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 + K8srunAsNonRoot兜底,直接废掉绝大多数提权逃逸路径。UID 要固定、要跨镜像和编排层一致。 - 最小镜像 + 扫描闭环是攻防的基本盘:distroless/scratch 把攻击面降到物理最小,Trivy/Grype
--exit-code 1把 CVE 卡在 CI。扫描的价值在红线自动化,不在报告。 - seccomp/AppArmor + 只读 rootf + 资源限制是纵深防御的第二层:挡不住应用被打穿,但能把爆炸半径压到极小——exec 不了 shell、写不了盘、吃不满资源。
- 安全是 checklist 化的持续动作,不是一次性达标:镜像层、权限层、syscall 层、文件系统层、资源层、秘钥层,每层都有"做没做"的二元判断,新服务过一遍,老服务季度审一遍。
一句话收尾:默认的容器是为"方便"优化的,生产容器要为"假设已经被打穿"优化——攻击者进来之后,还有什么能做、还有什么做不了,这才是加固的标尺。 把每一格从"默认宽松"挪到"默认拒绝",这件事没有终点,只有持续的 checklist。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。