3. 镜像构建过程的安全风险
镜像构建是容器供应链的“生产车间”。漏洞、密钥、调试工具、构建残留往往不是运行时才出现的,而是在 docker build 执行的那一刻就被固化进镜像层。理解构建过程的风险,关键要抓住四条主线:Dockerfile 指令如何改变文件系统与元数据、基础镜像决定了多少“继承风险”、构建上下文把什么送进了守护进程、多阶段构建是否真正切断了构建期与运行期的联系。
一、Dockerfile 指令:每一层都是可被检视的“证据”
Dockerfile 的每条指令都会生成一个镜像层(部分元数据指令除外),层的内容可以被 docker history、docker save 或镜像扫描工具还原。常见风险集中在以下几类指令:
RUN:在层中留下包管理器缓存、临时文件、下载的源码包。例如RUN apt-get update && apt-get install -y curl会把/var/lib/apt/lists缓存写入层,既增大体积,也可能包含过期的软件源元数据。COPY/ADD:把宿主机文件带入镜像。ADD还会自动解压 tar 包、支持远程 URL,容易在不知情的情况下引入外部内容;COPY . .则可能把.env、.git、私钥一并带入。ENV/ARG:ENV会持久写入镜像配置,若用于存放令牌、数据库口令,任何能拉取镜像的人都能通过docker inspect看到;ARG虽不进入最终镜像配置,但其值会出现在构建历史中,同样不适合传密钥。USER:缺省为 root。若 Dockerfile 未显式切换用户,容器内进程以 root 运行,一旦被突破,逃逸与横向移动的成本显著降低。
一个典型的“反面示例”是:
FROM ubuntu:20.04
ENV DB_PASSWORD=Prod@2024
COPY . /app
RUN apt-get update && apt-get install -y python3 python3-pip
RUN pip install -r /app/requirements.txt
CMD ["python3", "/app/main.py"]
这段 Dockerfile 至少存在四个问题:基础镜像标签不固定、口令写入 ENV、COPY . 把整个上下文(可能含 .git 与密钥)带入、未切换非 root 用户。修正方向是:用固定 digest 的基础镜像、用构建期 secret 或运行时注入替代 ENV 口令、用 .dockerignore 收窄上下文、显式 USER appuser,并把 apt-get 缓存清理合并到同一层。
二、基础镜像选择:继承的漏洞与信任边界
基础镜像的风险具有“继承性”:上层镜像的漏洞扫描结果,绝大部分来自基础镜像中的 OS 包与语言运行时。选择基础镜像时,至少要评估四个维度:
- 来源与维护方:优先选择官方镜像或组织内部维护的镜像,避免来源不明的第三方镜像。
- 标签策略:
latest会随上游更新而漂移,导致同一 Dockerfile 在不同时间构建出不同结果。生产环境应使用固定版本标签,必要时固定到 digest,例如python:3.11.9-slim-bookworm@sha256:...。 - 体积与攻击面:
ubuntu、debian完整版包含大量非必需包;-slim、-alpine、distroless依次减小体积与包数量,但也可能缺少 shell、包管理器,影响调试与合规工具注入。 - 许可证与合规:某些基础镜像的许可证条款会限制再分发,需要在选型阶段确认。
实践中可以用一个简单的对比来判断:同一应用分别基于 ubuntu:20.04、python:3.11-slim、gcr.io/distroless/python3 构建,扫描出的 CVE 数量通常呈明显下降趋势,但可调试性也同步下降。选择不是“越小越好”,而是与运行环境、排障能力、合规要求匹配。
三、构建上下文:被忽视的数据泄露通道
执行 docker build 时,Docker 客户端会把指定目录(构建上下文)打包发送给守护进程。即使 Dockerfile 中没有 COPY 某个文件,只要它在上下文目录内,就可能被发送。常见隐患包括:
- 上下文目录包含
.git,历史提交中的密钥、内部地址被一并带入; - 包含
.env、id_rsa、credentials.json等敏感文件; - 包含
node_modules、target、dist等构建产物,导致上下文体积巨大、构建缓慢,并可能把本地调试配置带入镜像。
控制手段是 .dockerignore。它应在项目根目录显式声明,至少覆盖版本控制目录、密钥文件、本地环境文件、构建产物与日志:
.git
.gitignore
.env
*.pem
id_rsa
node_modules
dist
build
*.log
需要强调的是,.dockerignore 只影响上下文传输与 COPY 可见范围,不能替代对镜像层的清理。若某个文件已被 COPY 进层,即使后续 RUN rm 删除,它仍保留在历史层中,可被还原。
四、多阶段构建:切断构建期与运行期
多阶段构建的核心价值,是让编译工具链、源码、测试依赖只存在于构建阶段,最终镜像只保留运行所需产物。典型结构如下:
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
这里最终镜像只包含一个静态二进制与 distroless 基础层,编译器和源码不会进入运行镜像。但多阶段构建也有隐患:
- 构建阶段仍可能泄露:如果构建阶段使用了
ARG传入令牌,或把密钥COPY进 builder 层,虽然最终镜像不含这些层,但构建缓存与中间镜像仍可能被访问。 - 误把构建阶段作为最终阶段:Dockerfile 中最后一个
FROM才是最终镜像。若顺序写错,可能把 builder 阶段直接发布。 - 跨阶段复制了不该复制的内容:
COPY --from=builder /src /app会把源码、测试文件一并带入运行镜像,抵消多阶段构建的意义。 - 缓存复用带来的“陈旧依赖”:
RUN go mod download层可能长期命中缓存,导致依赖版本未随go.sum更新而更新。需要合理安排COPY顺序,让依赖清单变化时缓存失效。
RUN --mount=type=secret,id=npmrc),而不是 ARG 或 ENV。secret 不会写入镜像层,也不会出现在 docker history 中。五、把风险控制落到构建流程中
综合来看,构建阶段的安全治理可以归纳为一张“输入—处理—输出”的关系图:基础镜像与上下文是输入,Dockerfile 指令与多阶段划分是处理过程,最终镜像层与元数据是输出。下图把这一关系与对应的控制点放在一起,便于在评审 Dockerfile 时逐项对照。
把这张图当作 Dockerfile 评审清单使用:先看输入是否可控(基础镜像是否固定、上下文是否收窄),再看处理是否最小化(指令是否合并清理、是否使用 secret、是否切换用户),最后看输出是否可验证(镜像层是否含敏感文件、扫描结果是否可追溯)。构建阶段的安全不是一次性的检查,而是每次提交 Dockerfile 时都要重新确认的工程习惯。