NanoClaw 的容器镜像做了一件很值得拆解的事:一次性剔除 1,400 个 CVE。这个数字在镜像安全扫描报告里并不少见,很多镜像一扫描就是几十、几百甚至上千个漏洞条目,但真正愿意翻到底、逐个修复、再把流程固化下来的团队不多。CVE 全称是 Common Vulnerabilities and Exposures,也就是公开的漏洞编号;镜像里的 CVE 说明对应软件包存在已知安全公告,不代表机器一定已经被攻击,但它直接反映了供应链的暴露面。把 1,400 个 CVE 清掉,等于把攻击者最常用的“已知漏洞利用”路径大部分关掉。
这篇文章不打算只复述一条新闻,而是从 NanoClaw 这个案例出发,拆解容器镜像 CVE 治理的完整路径:先扫描拿到基线,再换基础镜像、做多阶段构建、生成 SBOM,最后把扫描接入 CI/CD 强制卡点。文章里会给出可以直接抄的 Dockerfile、扫描命令和 CI 配置示例,也会列出最常见的“删不完 CVE”的原因。适合正在做容器化改造、镜像瘦身、供应链安全审计,或者被镜像扫描报告追着改漏洞的读者。
1. 核心能力速览:NanoClaw 镜像 CVE 治理的关键指标
先说结论。从公开信息看,NanoClaw 这次做的事情本质上不是“修了 1,400 个漏洞”,而是通过镜像重构,让扫描器在镜像里找不到那么多可匹配的漏洞条目。这两种说法有区别:前者是补丁思维,后者是供应链精简思维。补丁思维是每个漏洞对应一个修复版本,非常被动;精简思维是去掉不需要的软件包、更换更小的基础镜像、锁定依赖版本,让漏洞根本没有机会进入最终镜像。
| 项目 | 说明 |
|---|---|
| 治理对象 | NanoClaw 的容器镜像 |
| 公开目标 | 镜像内已知 CVE 数量从 1,400 级别压到接近 0 |
| 核心手段 | 基础镜像替换、多阶段构建、依赖裁剪、SBOM 追踪 |
| 主要收益 | 漏洞暴露面下降、镜像体积变小、上线审计更简单 |
| 适用对象 | 任何基于 Linux 的 Docker 镜像 |
| 扫描验证工具 | Trivy、Grype、Syft、Docker Scout 等 |
| 自动化接入 | CI/CD 构建后自动扫描,漏洞数超阈值即失败 |
| 不适用范围 | 应用业务漏洞、运行时攻击防护、K8s 配置风险 |
注意一点:1,400 这个数字需要结合扫描工具和扫描时间来看。不同扫描器的漏洞库、严重级别过滤规则不同,扫描结果会有差异。更稳妥的判断是,NanoClaw 的真实收益在于把“每次扫描都有大量高危漏洞”变成了“高危漏洞清零或接近清零”,这个过程比具体数字更有参考价值。
2. 这 1400 个 CVE 到底是从哪来的
一个镜像里出现一千多个 CVE,通常不是“代码写错了”,而是组成镜像的材料本身带病。最常见的来源有四类。
2.1 基础镜像里带的历史漏洞
很多镜像以ubuntu、debian、centos这类完整发行版镜像为基础。完整发行版自带系统库、包管理器、shell 和一堆你用不到的软件包,这些包的版本一旦滞后,就会在扫描结果里对应到大量 CVE。比如某个镜像的基础版本还是几年前的,那么libc、openssl、zlib这些底层库的几十个漏洞会全部被扫出来。这是 1400 个 CVE 的大头。
2.2 包管理器安装的依赖
项目用apt-get install、pip install、npm install等方式安装依赖时,如果没固定版本,构建时会拉到当时的 latest,下一次构建可能版本就变了。依赖树上任何一个传递依赖存在漏洞,扫描结果都会增加一条记录。尤其 Python 和 Node.js 项目,依赖动辄几十上百个,传递依赖的 CVE 数量很容易膨胀。
2.3 构建阶段留下的缓存和调试工具
为了构建方便,很多 Dockerfile 会在构建阶段安装curl、wget、vim、gcc、build-essential,甚至把包管理器缓存一起留在最终镜像里。这些工具和缓存文件不是运行时需要的,但它们都在镜像文件系统里,扫描器会逐一匹配,结果就是又增加一批 CVE。还有编译产物里的旧动态库,比如某个二进制文件自带旧版libssl.so,也会被扫出来。
2.4 多版本残留和未清理的临时文件
有些镜像在构建过程中下载源码包、安装多个版本的工具链,最后没有rm -rf清理。这些残留文件既占空间,又贡献漏洞记录。镜像层是只读的,任何一层里留下的文件都会保留到最后,所以“先安装、后删除”并不能真正清理干净,正确做法是用多阶段构建把最终产物单独拷贝出来。
3. 镜像 CVE 治理的适用场景与使用边界
3.1 适合谁
如果你正在做以下工作,NanoClaw 这个案例的参考价值很大:
- 容器镜像要推送到公网镜像仓库,安全团队要求先过扫描。
- 政企、金融、医疗等场景交付镜像时,甲方要求提供漏洞扫描报告。
- 镜像体积已经很大,想顺便做精简。
- 团队有 CI/CD,想把镜像安全卡点自动化。
这个思路适合任何基于 Linux 的应用镜像,不管语言是 Go、Java、Python 还是 Node.js,核心路径基本一致。
3.2 不适合什么
镜像 CVE 治理不能覆盖所有安全问题。它只解决“已知漏洞条目”和“镜像内容精简”,不解决应用自身的业务逻辑漏洞,不代替 Web 应用防火墙,也不能防止攻击者利用未公开的 0day。Kubernetes 集群的 RBAC 配置、Seccomp、网络策略等风险,需要另外一套工具和流程。换句话说,CVE 清零是安全基线之一,不是安全全部。
3.3 版权、隐私与合规边界
在做镜像精简和漏洞修复时,要注意软件许可证和授权边界。基础镜像换到distroless或scratch并不意味着所有组件都可以随意分发,部分静态编译的二进制、动态库和字体文件仍然有各自的开源许可证。如果团队从互联网上拉取第三方基础镜像或二进制文件,要确认来源可信、许可证允许再分发。涉及用户数据、模型文件、密钥证书的镜像,一律不能推到公开仓库。所有安全测试应该在本地或隔离环境完成,避免把内部信息混入镜像。
4. 环境准备与前置条件
在开始复现 NanoClaw 的镜像精简流程之前,需要准备一套最小环境。下面给的是通用清单,具体版本按实际环境调整。
4.1 安装 Docker
镜像治理的第一步是能构建和运行镜像。Docker 的安装方式很多,Linux 下可以用包管理器,macOS 和 Windows 可以用 Docker Desktop。安装完成后执行:
docker --version docker compose version能输出版本号说明 Docker 客户端和引擎可用了。
4.2 安装镜像扫描工具:Trivy
Trivy 是目前使用最广的镜像扫描器之一,支持容器镜像、文件系统、SBOM 和 Git 仓库。macOS 安装:
brew install aquasecurity/trivy/trivyLinux 安装:
sudo apt-get install wget apt-transport-https gnupg lsb-release wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add - echo deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main | sudo tee -a /etc/apt/sources.list.d/trivy.list sudo apt-get update sudo apt-get install trivy安装后确认版本:
trivy --version4.3 安装 SBOM 工具:Syft
Syft 是 Anchore 开源的软件物料清单生成工具,用来生成镜像的 SBOM。安装方式:
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin验证:
syft version4.4 准备一个测试镜像
建议不要直接拿生产镜像做实验,先构建一个最小的测试镜像。比如下面这个 Go 程序镜像,故意使用旧基础镜像并安装一些不必要的包,用来观察 CVE 扫描结果。实验完成后可以再执行正式的精简流程。
docker pull ubuntu:20.04 docker images | grep ubuntu5. 从 1400 到接近 0:镜像精简实战拆解
NanoClaw 的案例可以拆成六步,每一步都对应镜像扫描报告里的一个漏洞来源。
5.1 先扫描,拿到基线
不要凭感觉判断镜像哪里有问题,先用扫描器生成一份基线报告。以 Trivy 为例:
trivy image --severity HIGH,CRITICAL --format table ubuntu:20.04这个命令会列出ubuntu:20.04里高危和严重漏洞。如果机器没法访问漏洞库,可以先更新:
trivy image --download-db-only扫描结果出来后,把漏洞总数按严重级别记录到一个文件里。后续每次修改 Dockerfile,都用同一份命令和同一个漏洞库版本重新扫描,才能对比出真实的修复效果。
5.2 更换基础镜像
这是削减 CVE 数量最有效的一步。从完整发行版ubuntu:20.04换到alpine:3.20,漏洞数量往往会下降一个数量级。如果进一步换到scratch或distroless,系统包几乎被清空,CVE 数量会非常低。
scratch是 Docker 保留的空镜像,最终镜像里只有你自己拷贝进去的文件,没有 shell、没有包管理器、没有系统库。gcr.io/distroless是 Google 提供的一组精简镜像,只包含运行应用需要的最小运行时,没有 shell 和包管理器。Go 应用非常适合直接基于scratch构建:
FROM golang:1.22 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o app . FROM scratch COPY --from=builder /app/app /app ENTRYPOINT ["/app"]这个 Dockerfile 里,最终镜像只有编译好的二进制文件,不包含编译器、源码、包缓存和系统库。镜像体积从几百 MB 降到几十 MB,CVE 数量也大幅下降。
5.3 多阶段构建
多阶段构建是镜像精简的基础。以前常见的做法是同一个阶段里先编译、再运行,结果最终镜像里残留了大量构建工具。正确做法是用AS builder阶段完成编译、下载、打包,然后在最终阶段只拷贝产物。
Python 项目也可以用多阶段构建,但要注意 Python 运行时依赖。如果应用的依赖里有原生扩展库,并不是所有情况下都能直接上scratch,更稳妥的选择是使用python:3.12-slim或distroless。下面是一个多阶段 Python 镜像示例:
FROM python:3.12-slim AS builder WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --prefix=/install -r requirements.txt FROM python:3.12-slim WORKDIR /app COPY --from=builder /install /usr/local COPY . . ENV PYTHONUNBUFFERED=1 CMD ["python", "app.py"]--prefix=/install可以把依赖安装到指定目录,最终阶段只拷贝这个目录,避免把pip缓存和中间构建文件带进来。
5.4 精简系统依赖
如果镜像必须安装系统包,务必使用最小化参数并及时清理缓存。比如基于 Debian 系的镜像:
FROM debian:bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/*这里的两个关键点:
--no-install-recommends禁止安装推荐包,减少无关包进入镜像。rm -rf /var/lib/apt/lists/*删除 apt 索引缓存,避免扫描器在缓存里匹配到大量包记录。
5.5 锁定依赖版本并生成 SBOM
CVE 数量反弹最常见的场景是依赖没有锁定。pip install flask和pip install flask==3.0.3在安全审计上是完全不同的状态。前者每次构建都可能拉到新版本,容易无意中引入带漏洞的版本;后者是确定性的。
在锁定依赖的基础上,用 Syft 生成镜像的软件物料清单:
syft packages docker:ubuntu:20.04 --scope all-layers -o spdx-json > ubuntu.spdx.json生成 SBOM 后,可以随时知道镜像里有哪些组件、版本多少、来自哪个层。后续做漏洞修复时,SBOM 也能帮助定位哪个组件对应哪个 CVE。
5.6 最终阶段删除多余文件和敏感信息
构建时复制到镜像里的.env、密钥、证书、源码文件,一旦进入镜像层就难以彻底删除。正确做法是只复制运行必需的文件,敏感信息通过 Kubernetes Secret、环境变量或外部配置中心注入。针对会读取文件的场景,注意设置文件权限:
FROM scratch COPY --from=builder /app/app /app COPY --from=builder /app/config /config RUN chmod 755 /app镜像里没有敏感文件,扫描器扫不到,审计也干净。
6. 镜像 CVE 扫描与效果验证
完成 Dockerfile 修改后,需要重新构建并扫描,验证“从 1400 到接近 0”的效果。
6.1 重新构建
docker build -t naclaw-demo:v2 .6.2 扫描新镜像
trivy image --severity HIGH,CRITICAL --ignore-unfixed --format table naclaw-demo:v2--ignore-unfixed只显示有修复版本的漏洞,这个参数在评估“能不能尽快修”时非常有用。如果只关心镜像里实际存在的已知漏洞,不加这个参数更合适。
6.3 输出 JSON 结果给审计
人工看表格不方便归档,可以把扫描结果导出成 JSON:
trivy image --severity HIGH,CRITICAL --format json -o trivy-report.json naclaw-demo:v2把 JSON 文件存档,配合 SBOM 文件,可以形成一份可追溯的镜像安全报告。
6.4 判断成功的标准
- 高危和严重漏洞数量从几百降到 0,或者降到团队设定的阈值以下。
- 镜像体积明显下降,
docker images可以看到实际大小。 - 运行测试通过,应用功能没有因为裁剪依赖而报错。
- 重复扫描两次结果一致,排除漏洞库缓存不一致导致的误判。
6.5 失败时先查什么
如果扫描结果仍然很高,先查是否在最终镜像里遗留了包管理器缓存、旧基础镜像层、未清理的下载文件。可以执行:
docker history naclaw-demo:v2看镜像层的指令,哪一层引入了大文件,哪一层安装了包,都能看出来。再用docker run --entrypoint /bin/sh进入镜像查看文件系统,但注意scratch镜像没有 shell,这时可以用docker export或dive工具分析层内容。
7. CI/CD 自动化与批量镜像治理
NanoClaw 的“消除 1,400 个 CVE”如果只做一次,价值有限。真正有价值的做法是把扫描变成自动化卡点,每次构建镜像后自动执行扫描,超过阈值就中断流水线。
7.1 镜像扫描作为构建后置检查
以下是一个 GitHub Actions 示例,把 Trivy 扫描插入到镜像构建之后:
name: image-scan on: push: paths: - 'Dockerfile' - 'src/**' jobs: scan: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Build image run: docker build -t my-app:${{ github.sha }} . - name: Run Trivy scanner uses: aquasecurity/trivy-action@master with: image-ref: my-app:${{ github.sha }} format: table exit-code: '1' ignore-unfixed: true severity: HIGH,CRITICALexit-code: '1'表示扫描到高危或严重漏洞时,当前任务失败,流水线中断。这个卡点能阻止带漏洞镜像进入仓库。
7.2 批量扫描已有镜像
如果团队有很多存量镜像,可以写一个循环批量扫描:
for image in $(cat images.txt); do echo "=== Scanning $image ===" trivy image --severity HIGH,CRITICAL --ignore-unfixed "$image" done批量扫描时建议加超时和日志输出,避免某个镜像卡住整个流程:
timeout 600 trivy image --severity HIGH,CRITICAL "$image" > /tmp/san_$(basename $image).log 2>&17.3 不涉及业务 API 服务的说明
NanoClaw 这次镜像 CVE 治理处理的是容器镜像制品,不是对外业务 API 服务,所以没有标准的 REST API 可以单独开放给外部调用。团队如果要把扫描能力暴露给内部其他系统,通常是调用 Trivy 的 Server 模式,或者直接封装命令行工具。部署在 Kubernetes 里的应用,更关注的是镜像仓库和 CI 流水线之间的联动,而不是业务接口的鉴权设计。
8. 资源占用与性能观察
镜像精简不只是安全收益,也会直接影响构建和运行效率。
8.1 镜像体积
用docker images可以对比精简前后的体积。从完整 Ubuntu 基础镜像换成distroless或scratch,体积通常下降 70% 到 90%。体积变小之后,镜像拉取时间、磁盘占用、节点冷启动时间都会变好。
8.2 构建时间
多阶段构建首次可能需要重新拉取基础镜像,后续利用 Docker 层缓存会变快。但要注意,基础镜像变化越频繁,缓存失效概率越高。把依赖安装和代码复制拆成不同层,能在源码变更时避免重新下载所有依赖。
8.3 运行时性能和调试成本
scratch或distroless镜像没有 shell,也没有包管理器,运行时的系统资源占用通常更低。但调试成本会上升:容器内无法直接执行curl、vim、bash,排查问题往往需要依赖 Kubernetes 的kubectl logs、临时挂载调试工具,或者使用带 shell 的开发镜像。生产镜像用最精简版本,开发调试镜像用完整版本,是常见做法。
8.4 如何观察资源占用
运行时可以用docker stats观察容器 CPU 和内存:
docker stats nclaw-demo构建时可以用/usr/bin/time记录构建耗时:
/usr/bin/time -v docker build -t naclaw-demo:v2 .如果担心构建期间磁盘占用,可以定时执行df -h观察 Docker 存储目录变化。
9. 常见问题与排查方法
镜像 CVE 治理过程中,下面这些问题最容易出现。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 换掉基础镜像后 CVE 数量变化不大 | 最终镜像里仍有编译产物或静态库 | 用docker history查镜像层 | 改成多阶段构建,只拷贝最终产物 |
镜像从ubuntu换成alpine后应用启动失败 | 应用依赖 glibc,而 Alpine 使用 musl | 看容器启动日志和 ldd 输出 | 使用distroless或兼容 glibc 的镜像 |
| 扫描结果里 CVE 显示 no fix | 漏洞在漏洞库中还没有修复版本 | 查 NVD、发行版安全公告 | 换组件版本或加 ignore 记录并评估风险 |
| 依赖版本锁定了,但扫描出旧版依赖 | 传递依赖没有锁定 | 用npm ls或pip freeze查依赖树 | 生成 lock 文件,使用全量依赖锁定 |
| 删除包后扫描仍然报 CVE | 包管理器缓存或旧层保留文件 | docker history和docker export查看 | 删除缓存,重新构建,不保留中间层 |
| 扫描器在 CI 中下载漏洞库很慢 | 网络受限或漏洞库过大 | 查看 CI 日志 | 预先缓存漏洞库,或使用内网镜像漏洞库 |
| 镜像体积没明显下降 | 最终阶段复制了完整依赖目录 | dive查看层体积 | 精简 COPY 目录,去掉调试文件和源码 |
改用scratch后容器内无法抓包排查 | 镜像没有 shell 和网络工具 | 观察外部日志和监控 | 临时采用调试镜像运行同一程序 |
| SBOM 生成不了 | 镜像格式或平台不兼容 | syft version检查版本 | 更新 Syft,改用--platform参数 |
10. 最佳实践与使用建议
从 NanoClaw 的镜像 CVE 治理经验里,可以沉淀出下面这些工程实践。
第一,把“漏洞扫描基线”固化到项目里。每次镜像变更前先生成一份基线报告,变更后用同一命令重新扫描。没有基线,就没法判断 1,400 到接近 0 是真实改善,还是扫描器的漏洞库变了、忽略规则变了导致的结果。
第二,最终镜像越小越好,但不能牺牲可观测性。生产镜像用distroless或scratch完全可以,但一定要保留标准输出和标准错误日志。云原生场景下,日志全部输出到 stdout,不要写本地文件,不然镜像精简后连日志文件都很难找到。
第三,依赖锁定要自动化。手工维护版本号很容易遗漏。Go 用go.sum,Python 用requirements.txt+pip-tools或uv,Node.js 用package-lock.json。lock 文件进入版本库,CI 构建时严格使用 lock 文件。
第四,CVE 扫描结果要分级处理。不是所有 CVE 都必须立刻修。优先处理高危和严重漏洞,再看有没有--ignore-unfixed项。无修复版本的漏洞需要做风险评估,记录到安全评审表里,而不是一直挂起。
第五,镜像仓库要设置不可变 tag。每次构建生成新的镜像 digest,不能覆盖同一个 tag。这样扫描结果和镜像版本能一一对应,回滚也安全。
第六,涉及人脸、声音、版权素材、用户数据的业务,一旦把这些内容打进镜像,就必须非常谨慎。生产镜像里禁止出现真实用户数据和未授权素材,所有数据处理任务都应该通过挂载卷或外部对象存储完成,镜像只负责程序逻辑。合规和授权问题比 CVE 数量更敏感,不能因为追求镜像精简而把敏感信息一起带进制品。
11. 总结与下一步
NanoClaw 这次消除 1,400 个 CVE,本质上是一个典型的镜像供应链治理案例。它告诉我们,镜像安全不是靠补丁堆出来的,而是靠去掉不必要的组件、缩小攻击面、锁住依赖、生成 SBOM,再把扫描变成 CI 卡点来实现的。值得先验证的功能是:拿一个现有镜像跑一遍 Trivy 扫描,看基线结果;然后把 Dockerfile 改成多阶段构建,换一个更小的基础镜像,再扫描一次。两次结果对比,就能直观感受到 CVE 数量下降的速度。
最容易踩的坑有两个:一是只改基础镜像,不清理最终阶段里残留的文件,导致 CVE 数量下降不明显;二是依赖锁定不完整,扫描结果清零后过几天又反弹。建议在项目里一次性配好三件事:trivy 扫描脚本、Syft SBOM 生成、CI 失败阈值。这三件事跑通后,镜像 CVE 治理就算进入可维护状态了。
下一步可以继续做的是:把 Trivy 扫描结果接入监控大盘,每两周自动生成一份安全报告;对无修复版本的高危漏洞建立跟踪表,定期检查上游是否发布了新版本;如果镜像里有自研二进制,考虑增加二进制层面的依赖分析,进一步缩小盲区。镜像 CVE 治理不是一次性的活动,而是一套需要长期维护的流程。建议把这篇文章里的扫描脚本和 CI 配置保存下来,下次给镜像做安全审计时直接用。