如果把一个业务镜像拿去做一次完整的漏洞扫描,得到一份包含上千个 CVE 的报告,你会怎么处理?很多团队的第一反应是升级基础镜像、升级依赖、重新构建,然后再次扫描。但下一个季度再扫,报告里又会出现一批新漏洞。这种“扫描—修复—再扫描”的循环,本质上是在追着问题跑,而不是在治理问题。
最近看到一个项目的容器镜像安全改造案例,标题非常直接:We eliminated 1,400 CVEs in NanoClaw's container images。1,400 个 CVE,这个数字放在大多数团队面前,基本等同于“这个镜像已经不能要了”。但真正值得关注的,并不是这个数字本身,而是背后那条完整的治理链路:镜像构建方式、基础镜像选择、依赖锁定策略、CI 扫描门禁、运行时加固,每一步都在压缩漏洞的生存空间。
这篇文章不打算复述 NanoClaw 项目的内部实现细节,因为外部只能看到结论,看不到它每一步的取舍。更务实的做法,是把这类镜像 CVE 治理中最通用、最能直接复用的方法整理出来:镜像为什么会堆积大量 CVE、如何从构建方式上做减法、如何在 CI 中加一道漏洞门禁、如何做运行时加固,以及落地过程中最容易踩的坑。
无论你的服务是 Java、Go 还是 Python,使用 Docker、Podman 还是 Kubernetes,这套思路都可以迁移。下面进入正题。
1. 为什么 1,400 个 CVE 值得被认真对待
CVE(Common Vulnerabilities and Exposures,通用漏洞与披露)是安全社区为已知漏洞分配的编号。一个 CVE 并不等于一个可被直接利用的漏洞,它只是告诉我们:某个组件在某个版本上存在已知的安全问题。是否影响你的业务,取决于组件是否被加载、攻击路径是否存在、线上环境是否接触得到攻击面。
那为什么还要在意“1,400”这个数字?有三个现实原因。
第一,合规与审计压力。在很多团队的准入清单里,镜像扫描报告是上线前必须提交的材料。一份千级 CVE 的报告,无论其中的漏洞是否真的可利用,都会让安全评审很难通过,也会让每一次客户安全问卷、每一次外部审计都变得异常艰难。
第二,漏洞数量是镜像治理水平的直接信号。如果一个镜像长期使用旧版基础镜像、复制了构建阶段的全部工具、依赖也没有锁定版本,那么 CVE 数量自然会滚雪球。反过来,一个镜像经过良好治理,漏洞数往往能控制在个位数甚至为 0。数字本身的含金量,不如数字背后的流程含金量高。
第三,数量级决定了修复成本的量级。100 个 CVE 和 1,400 个 CVE 不是 14 倍的关系,而是完全不同的两种处境。前者可以逐个评估、分批升级;后者只能重建镜像、重新梳理依赖,甚至需要专门立项。与其等漏洞堆积到千级再集中处理,不如在每次构建时就把门禁立起来。
从 NanoClaw 这个案例得到的第一个结论是:消除 1,400 个 CVE,不是某一次“大扫除”的功劳,而是把漏洞治理从“上线前扫描”前置到了“构建全流程”。
2. 镜像里的 CVE 到底从哪里来
很多人以为镜像漏洞主要来自应用代码,其实大多数情况下,镜像里的 CVE 来自四个地方。
第一是基础镜像。基于 Debian、Ubuntu、CentOS 的官方镜像,本身就包含操作系统层面的软件包,这些包由发行版维护,会在安全公告发布后打补丁。但如果你的镜像长期停留在旧 tag,始终用某个大版本而不跟进该版本的安全更新,那么镜像里 OS 组件的已知漏洞就会不断累积。
不妨做一个简单实验。构建一个最小的 Ubuntu 镜像,不装任何业务代码,直接扫描:
docker pull ubuntu:22.04 trivy image --severity HIGH,CRITICAL ubuntu:22.04你会发现,即使不部署任何应用,一个基础镜像也可能被扫出几十个高危漏洞。这不是镜像“不干净”,而是发行版软件包仓库里存在未被修复或被标记的已知问题。基础镜像选什么、跟不跟安全更新,直接决定了 CVE 的基数。
第二是语言依赖。这是应用层 CVE 的主要来源。Java 的 Jar 包、Python 的 Wheel、Node.js 的 node_modules、Go 的二进制依赖,每一个依赖都可能引入 CVE。最典型的例子是 Log4j 的 CVE-2021-44228,一个出现在第三方库中的漏洞,可以让全球大量 Java 服务同时中招。依赖越多、版本越老、锁定越随意,镜像扫描出问题就越多。
第三是构建工具残留。很多 Dockerfile 会这样写:先安装 gcc、make、curl、wget、bash 等工具来编译代码,然后直接把这些工具留在最终镜像里。这些工具本身也会产生 CVE,而且它们扩大了攻击面——攻击者一旦进入容器,第一件事就是寻找 shell 和网络工具。
第四是缓存与临时文件。包管理器缓存(apt-get install后的/var/lib/apt/lists、pip install后的缓存)、编译中间产物、构建密钥、配置文件,都会被复制进镜像层。它们不一定产生 CVE,但会增加镜像体积、增加扫描噪音,甚至泄露敏感信息。
把这四类来源放到一起,就很容易理解为什么一个“功能正常”的镜像会有上千个 CVE:它可能叠加了旧版基础镜像、大量未锁定依赖、完整构建工具链和一堆临时文件。治理的第一步,不是一个个去修,而是先让镜像变得更“小”、更“干净”。
3. CVE 治理的路线选择:先做减法和锁定,再做扫描和修复
这里要给出一个明确的路线判断:镜像 CVE 治理的正确顺序,不是“扫描—修复—再扫描”,而是“减面—锁定—扫描—加固”。
先说为什么“扫描—修复”循环会失效。扫描器只会报告已知漏洞,不会告诉你漏洞为什么出现。如果一个镜像的基础镜像版本过旧,你修完扫描器列出的 50 个漏洞,重新拉取基础镜像更新层时又会引入新的变化,下一轮扫描还是会有新问题。更麻烦的是,有些 CVE 当前没有可用补丁,你只能换组件或换版本,而这种替换往往牵一发动全身。没有根因治理,修复就只能停留在表面。
我推荐的四阶段路线如下。
第一阶段,减面:缩小镜像的攻击面。用多阶段构建去掉构建工具,选择更精简的基础镜像,不安装用不到的软件包。这一阶段能把 CVE“基数”大幅降下来,而且是一劳永逸的架构调整,不是临时补丁。
第二阶段,锁定:把依赖版本精确固定下来。生成 lock 文件、固定基础镜像的 digest、精确到 patch 版本,让每次构建可复现。锁定不是不升级,而是让升级变成有意识的动作,而不是被动碰运气。
第三阶段,扫描:在 CI 中接入扫描工具,对指定严重级别的 CVE 设置阻断门禁。扫描发生在构建之后、发布之前,越早发现问题,修复成本越低。
第四阶段,加固:对运行时做安全配置,比如非 root 用户、只读文件系统、丢弃 Linux capabilities、启用 seccomp。这些配置不会直接消除 CVE,但会显著降低剩余漏洞被利用的概率。
这四阶段有严格的先后关系。如果你跳过减面和锁定,直接上扫描门禁,团队会每天被扫描结果淹没,最终要么关掉门禁,要么把“例外清单”写得比代码还长。反过来,先做减法和锁定,扫描结果会干净得多,门禁也能真正执行下去。
4. 基础镜像与镜像瘦身:从源头减少 CVE 数量
减面阶段最核心的操作,是多阶段构建(multi-stage build)和基础镜像替换。
多阶段构建的核心思想,是把“编译环境”和“运行环境”分开。编译阶段使用功能完整的镜像,安装构建工具、下载依赖、编译产物;运行阶段只复制编译产物和一个最小运行时。这样一来,gcc、make、curl、shell 这些工具都不会进入最终镜像。
4.1 Go 服务:多阶段构建到 distroless
Go 是静态编译语言,最终产物几乎不依赖系统运行库,因此可以直接使用 distroless 甚至 scratch:
# 文件路径:Dockerfile FROM golang:1.22 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server FROM gcr.io/distroless/static-debian12:nonroot COPY --from=builder /app/server /server USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["/server"]这个 Dockerfile 有几个关键点。第一,CGO_ENABLED=0关闭 CGO,生成纯静态二进制;第二,-ldflags="-s -w"去掉调试信息和符号表,减小体积;第三,最终阶段选择 distroless 的 nonroot 镜像,里面没有 shell、没有包管理器,只有运行所需的库和用户;第四,执行用户是nonroot,默认非 root 运行。
构建并扫描:
docker build -t demo/go-app:v1 . trivy image --severity HIGH,CRITICAL demo/go-app:v1由于最终阶段没有操作系统包管理器,Go 服务的扫描结果通常只包含应用二进制内嵌的依赖漏洞,数量会锐减。
4.2 Java 服务:构建与运行环境分离
Java 应用不能完全脱离运行时,但可以做到只保留 JRE,不带 Maven、GCC 等构建工具:
# 文件路径:Dockerfile.java FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests FROM eclipse-temurin:17-jre-jammy RUN useradd --create-home --shell /usr/sbin/nologin appuser USER appuser WORKDIR /app COPY --from=builder /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]这里需要留意的是,eclipse-temurin:17-jre-jammy本身仍是基于 Ubuntu 的镜像,OS 层 CVE 不可能完全消除,但持续跟进该镜像的安全更新 tag,可以把 OS 层漏洞控制在可接受范围内。Java 应用真正的漏洞大头往往在 Jar 依赖上,这部分需要依赖治理来解决,而不是换基础镜像能解决的。
4.3 Python 服务:用户级依赖与兼容性
Python 服务也类似,用python:3.12-slim作为运行阶段,并把依赖安装到用户目录:
# 文件路径:Dockerfile.python FROM python:3.12 AS builder WORKDIR /app COPY requirements.txt . RUN pip install --user --no-cache-dir -r requirements.txt FROM python:3.12-slim RUN useradd -m appuser