容器与编排

Dockerfile 安全加固:收窄攻击面,非 root 运行与只读文件系统

为维护基础镜像与把服务容器化的开发者提供一套可落地的加固清单:非 root、只读根文件系统、免特权、分层与镜像扫描。

作者:巧匠团队·8 分钟阅读·更新于 2026-09-06

为什么要纠结容器里的用户:root 越权即宿主机风险

容器进程默认以 root 运行,uid 0 对应宿主机 root。若通过 Docker 漏洞或错误挂载逃逸,攻击者直接获取宿主机高权限。把进程切到非 root 用户是整个加固里性价比最高的一步。

非 root 不只是 RUN useradd 一句:它还要与文件所有权、端口绑定、系统目录权限配合,否则进程起不来反而退化成新坑。

本节先从“让进程非 root 运行”落地,后面再配合只读与降权把整体防线补齐。

FROM node:20-alpine

# 创建非 root 用户并给到主目录
RUN addgroup -S appgrp && adduser -S appuser -G appgrp

# 拷贝后修正属主,避免启动时无权写缓存目录
COPY --chown=appuser:appgrp . /app

# 切换到非 root
USER appuser

CMD ["node", "server.js"]

少即是多:把攻击面压扁的依赖与层技巧

镜像是二进制面积的放大。基础镜像只装运行所需:向 Alpine/distroless 精简、合并 RUN 并随手删包、不把源码/密钥/调试符号一起打进来。

构建顺序把易变层往后面放,把依赖安装层(npm ci、pip)放前面,缓存复用同时让最后的代码层最小。

.dockerignore 拒绝把 node_modules、.git、.env 灌进构建上下文;多阶段构建里最终镜像只保留运行时产物,配以二进制拷贝与动态依赖路径。

FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN npm ci --omit=dev

# .dockerignore 至少包含:node_modules, .git, .env

错误示范:镜像里有密钥或跑特权容器

把 DATABASE_PASSWORD 等写进 Dockerfile 的 ENV 或配置文件里,会被任何能拉镜像的人(含协作仓库、公共仓库)读到,等于裸奔密钥。

正确姿势:运行时把环境变量传给 run(-e 或 compose environment),或挂载 Secret(Docker Swarm secret / K8s secret 或云厂商 KMS 注入)。构建时需要的密钥用 BuildKit 的 --secret mount,不进层。

容器运行也别图省事加 --privileged。始终按“最小能力”给,能用默认 SECCOMP 即默认。

# 错误示范:密钥打包进镜像
# ENV DATABASE_PASSWORD=supersecret123

# 修复:运行时注入
# docker run -e DATABASE_PASSWORD=$DB_PASS -e DATABASE_USER=$DB_USER image:tag

# 或在 compose 中
#   api:
#     env_file:
#       - .env.production   # 不入库

# 构建期 secret 用 BuildKit
# RUN --mount=type=secret,id=npm_token \
#     TOKEN=$(cat /run/secrets/npm_token) npm ci

只读根文件系统与降权:收编运行期改动

把根文件系统设为只读(--read-only 或 K8s securityContext readOnlyRootFilesystem)能让“容器里跑起来的攻击者”无从写盘篡改安装东西,临时路径交给 tmpfs/emptyDir。

降权是另一道锁:正常运行其实不需要 CAP_SYS_ADMIN、CAP_NET_ADMIN 等内核特权,K8s 里通过 securityContext 的 capabilities drop 掉它们,或在 docker run 里声明。

两者配合把一个有漏洞的容器从“可写可提权”变成“不可写、越狱受限”,即使代码有洞,能造成的破坏也窄得多。

# docker run 只读 + 降权
# docker run --read-only \
#   --tmpfs /tmp --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
#   -p 8080:3000 image:tag

# K8s 风格
# securityContext:
#   readOnlyRootFilesystem: true
#   runAsNonRoot: true
#   capabilities:
#     drop: ["ALL"]
#     add: ["NET_BIND_SERVICE"]

验证硬化的效果:扫描、渗透与已修复清单

加固值不值钱看它挡不挡得住真实攻击。至少跑一次镜像扫描(trivy、grype、docker scan)确认没有已知高危 CVE 躺在新打的层里。

主动验证只读与降权:临时起一个容器试装包或用 chmod 写系统路径,期望失败——写不进去才能证明 read-only 生效。

最后核对一份“已完成加固项”清单:非 root、可读根、能力最小化、无明文密钥、构建缓存分层、镜像扫描通过。逐项勾掉,编号留痕,便于审计。

# 镜像扫描
trivy image --severity HIGH,CRITICAL myapp:1.2.3

# 验证只读生效:期望写入失败
# docker run --rm --read-only --tmpfs /tmp image:tag \
#   sh -c "touch /etc/foo && echo wrote"  # 应报 read-only file system

# 验证非 root 生效
# docker run --rm image:tag id
# -> uid=1000(appuser) gid=1000(appgrp)

官方参考来源

下方为命令对应的官方权威文档,供你核对最新用法与深入查阅。