Why the Container User Matters: Root Escape Becomes Host Risk
Container processes default to root, and uid 0 maps to host root. Via a Docker bug or a wrong mount, an escape hands the attacker full host privileges. Moving the process off root is the best value-per-effort hardening step.
Non-root is not just a RUN useradd; it must pair with ownership, port binding, and start/sbin directory permissions, or the process refuses to launch and you gain a new problem.
Start this section by getting the process to run as a real non-root user; read-only and capability-dropping will complete the defenses.
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"]Less Is More: Flattening Dependencies and Layers of Attack
An image magnifies binary area. Slim the base to the running need: Alpine/distroless, combine RUN layers that also purge packages, and never bake in source, secrets, or debug symbols.
Order the build so volatile layers sit late and dependency-install layers (npm ci, pip) sit early, reusing cache while keeping the final code layer tiny.
.dockerignore keeps node_modules, .git, and .env out of the build context; multi-stage keeps only runtime artifacts in the final image, with binary copies and a resolved dynamic-dependency path.
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, .envMistake: Secrets in the Image or a Privileged Runtime
Putting DATABASE_PASSWORD into a Dockerfile ENV or a config file means anyone who can pull the image (collaborators, public registries) reads it — a bare secret.
Right approach: pass env at runtime via -e or compose environment, or mount a secret (Docker Swarm secret / K8s secret / cloud KMS). Build-time secrets flow through BuildKit --secret, never into a layer.
Also, do not reach for --privileged out of convenience; grant the least capability and let the default SECCOMP profile do its job.
# 错误示范:密钥打包进镜像
# 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 ciRead-Only Root FS and Downscoped Caps: Reining In Runtime Changes
A read-only root filesystem (--read-only, or securityContext readOnlyRootFilesystem in K8s) makes it impossible for an attacker running in your container to write, tamper, or install; scratch paths park on tmpfs/emptyDir.
Capability-drop is the second lock: normal work never needs CAP_SYS_ADMIN or CAP_NET_ADMIN. Drop them in K8s via securityContext capabilities, or declare them on docker run.
Together they turn a vulnerable container from "writable and raisable" into "immutable and escape-limited", so even a hole in the code causes far less damage.
# 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"]Verifying Hardening: Scanning, Probing, and the Fixed List
Hardening is only worth what it stops in a real attack. Run at least one image scan (trivy, grype, docker scan) to confirm no known high-severity CVE rides the newly built layer.
Probe read-only and dropped caps actively: launch a throwaway container and try to install a package or chmod a system path — failing to write proves read-only is on.
Finish with a "hardened items" checklist: non-root, readable-root, least capabilities, no plaintext secrets, cached build layers, scan clean. Tick each and leave a trace for audit.
# 镜像扫描
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)