为什么要纠结容器里的用户: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)