随着供应链攻击持续增加、AI 生成代码进入更多生产系统,容器安全不能再等到发布前才集中清理。Docker 最新更新所指向的方向很明确:增加从源码构建的软件,在软件生命周期结束后继续提供安全覆盖,让定制镜像继承基础镜像的安全保证,并把策略检查前移到每台开发机。
这里的“零 CVE”更适合作为默认工作方式,而不是永久不变的承诺。漏洞数据库会更新,昨天通过扫描的镜像今天可能出现新记录。真正重要的是让构建、扫描、修复和重新发布成为持续运行的闭环。
从源码构建,减少无法解释的二进制
传统基础镜像经常包含发行版软件包、包管理器、Shell 和调试工具。它们方便排障,却也扩大了镜像的软件清单和攻击面。增加从源码构建的软件,价值不只是“版本更新”,还包括更清楚地回答几个问题:
- 源码来自哪里,固定在哪个提交或标签?
- 构建过程使用了什么编译器和依赖?
- 最终镜像中究竟留下了哪些运行时文件?
- 某个 CVE 是否真的影响镜像中的代码路径?
从源码构建并不会自动消除漏洞。编译器、模块依赖和构建脚本本身仍属于供应链。它带来的核心收益,是可追溯性以及删除非必要运行时组件的机会。
可以这样实践:用多阶段构建编译一个静态 Go 服务,最终镜像只保留应用二进制和证书。下面示例假设本机已经安装 Docker,并且构建时可以访问 Go 模块仓库。
go.mod:
module example.com/zero-cve-demo
go 1.23
main.go:
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintln(w, "ok")
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Dockerfile:
# 实际项目应把镜像固定到经过验证的 digest。
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY main.go ./
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w" \
-o /out/server ./main.go
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/server /server
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/server"]
构建并验证服务:
docker build -t local/zero-cve-demo:dev .
docker run --rm -d --name zero-cve-demo -p 8080:8080 local/zero-cve-demo:dev
curl --fail http://127.0.0.1:8080/healthz
docker stop zero-cve-demo
这个例子展示的是减小运行时镜像的方法,不代表镜像天然没有漏洞。生产环境还应固定基础镜像 digest、校验源码来源、生成 SBOM,并保存构建证明。
生命周期结束后,安全责任并不会结束
很多团队仍在运行已进入生命周期末期的软件:升级可能涉及兼容性验证、数据迁移或硬件认证,无法在停止支持的当天完成。来源摘要强调,新的安全覆盖会延伸到生命周期结束之后,这能缩短“业务仍要运行,但上游已经不再修复”的暴露窗口。
不过,延长覆盖不等于取消升级计划。它更像一座过渡桥:团队获得修复关键漏洞的时间,但仍需明确最终迁移日期。采用此类覆盖时,应确认支持范围至少包含以下信息:
- 哪些基础镜像、软件版本和 CPU 架构受到支持;
- 修复的是所有已知 CVE,还是特定严重级别和可利用漏洞;
- 修复如何发布,镜像 digest 和 SBOM 是否同步更新;
- 支持结束后,工作负载必须迁移到哪个版本。
定制镜像不能成为保证失效的分界线
企业很少直接运行原始基础镜像。实际镜像通常会加入应用、配置、证书和代理组件。如果安全保证只覆盖未经修改的上游镜像,那么大部分生产工作负载仍然需要团队自行拼接证据。
Docker 更新所强调的“让保证延续到定制镜像”,关键在于保留来源关系:能够识别定制镜像继承了哪个受支持基础镜像,同时把应用层新增的软件与基础层分开评估。这也意味着团队必须避免破坏可追踪性的构建方式,例如从不受控地址下载未校验的二进制,或者反复覆盖系统库。
可以在 Dockerfile 中为远程制品加入显式校验:
ARG TOOL_VERSION=1.2.3
ARG TOOL_SHA256
ADD https://downloads.example.com/tool-${TOOL_VERSION}-linux-amd64 /usr/local/bin/tool
RUN echo "${TOOL_SHA256} /usr/local/bin/tool" | sha256sum -c - \
&& chmod 0755 /usr/local/bin/tool
运行前需要把示例域名替换为真实制品地址,并通过构建参数传入发布方公布的 SHA-256:
docker build \
--build-arg TOOL_SHA256='<published-sha256>' \
-t registry.example.com/team/app:1.0.0 .
校验和只能证明下载内容与预期一致,不能证明发布方本身可信。对关键组件还应验证签名、来源证明和构建身份。
把策略放到开发机,而不是只放在 CI 末端
如果漏洞策略只在合并后的 CI 流水线执行,开发者会在提交代码很久之后才收到反馈。策略下沉到开发机后,基础镜像过期、出现高危漏洞或缺少必要元数据等问题,可以在本地构建阶段直接暴露。
如果团队使用 Docker Scout,可以这样实践一个最小本地检查脚本。具体可用参数取决于已安装的 Docker Scout 版本,扫描也可能需要登录并更新漏洞数据库。
scripts/check-image.sh:
#!/usr/bin/env bash
set -euo pipefail
image="${1:?usage: scripts/check-image.sh IMAGE}"
docker scout cves "$image" \
--only-severity critical,high \
--exit-code
执行方式:
chmod +x scripts/check-image.sh
docker build -t local/zero-cve-demo:dev .
./scripts/check-image.sh local/zero-cve-demo:dev
同一个脚本应同时用于开发机和 CI,避免两套规则逐渐分叉。真实项目还需要定义例外机制:每个豁免都应包含负责人、原因、到期时间和补偿措施,而不是永久维护一个没有上下文的忽略列表。
落地时关注四个边界
把零 CVE 设为默认值,不应简化成“扫描结果必须显示 0”。更稳妥的采用顺序是:
- 固定基础镜像和构建依赖,生成 SBOM 与来源证明。
- 优先阻断可利用的严重漏洞,并为误报和无修复版本建立限时豁免。
- 确认定制镜像仍能继承基础镜像的支持与安全元数据。
- 在开发机、拉取请求和镜像仓库中执行同一套策略,并持续重新扫描已发布镜像。
零 CVE 是一个会随漏洞情报变化的状态,而不是一次性认证。真正成熟的目标,是让每个镜像都有可解释的来源、明确的维护期限、可重复执行的策略,以及在新漏洞出现后快速重建和替换的路径。