你的镜像里可能带着一堆已知漏洞就上线了
用 Docker 部署网站的站长大多有一个习惯:拉一个基础镜像,或者直接拿别人写好的镜像改一改,docker build 一下,跑起来能用就上线了。整个过程关注的是"能不能跑",几乎没人问过一句:这个镜像里装的操作系统包、依赖库、语言运行时,有没有已知的安全漏洞?
问题在于,镜像一旦构建完成,它里面的软件版本就被冻结了。基础镜像里的 openssl、zlib、libc、Python 依赖,全都停留在构建那一天的状态。之后上游发了安全补丁,你的镜像不会自动跟着更新——除非你重新构建。时间一长,一个"当初很干净"的镜像就会积累出一串 CVE(Common Vulnerabilities and Exposures,公开的漏洞编号)。
这篇文章讲的是用 Trivy 给镜像做漏洞扫描,并把扫描接进构建流程,做到"带高危漏洞的镜像不许上线"。它是目前最省事的开源镜像扫描工具:单个二进制、无需数据库服务、几秒钟出结果。
Trivy 到底扫的是什么
理解这一点,才能看懂它的报告。Trivy 拿到一个镜像后,会做三件事:
① 拆解镜像层,列出所有软件包。它把镜像当成一个只读的文件系统,读取各发行版的包数据库(Debian/Ubuntu 的 dpkg 数据库、Alpine 的 apk 数据库、RHEL 的 rpm 数据库),得到"这个镜像里装了什么、版本是多少"的完整清单。这一步叫 SBOM(Software Bill of Materials,软件物料清单)生成。
② 跟你所用的发行版的安全公告比对。注意这里的关键:Trivy 不是拿版本号去查一个"全局漏洞库",而是按发行版各自的安全跟踪数据来判断。因为 Debian、Ubuntu、Alpine 对同一个上游漏洞各自打补丁的节奏和版本号规则都不一样。这一点决定了"升级基础镜像"为什么往往是最有效的修复手段。
③ 还要扫应用依赖。如果镜像里跑了 Node 的 node_modules、Python 的 site-packages、Java 的 jar 包,Trivy 会解析 package-lock.json、requirements.txt、Gemfile.lock、pom.xml 等,把应用层依赖的漏洞也一并列出。这非常关键——很多真实事故不是系统包出事,而是某个 npm 依赖被爆了漏洞。
第一步:安装与首次扫描
Trivy 的安装非常轻量,官方脚本一行搞定:
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
trivy --version
第一次扫描会先下载漏洞数据库(约几十 MB),之后每天自动更新。对本机已有的镜像做扫描:
trivy image nginx:1.24
如果镜像里只有系统包,输出会很简短;如果含应用依赖,报告会长一些。你会看到类似这样的行:
nginx:1.24 (debian 11.7)
Total: 42 (UNKNOWN: 2, LOW: 20, MEDIUM: 14, HIGH: 5, CRITICAL: 1)
这个汇总行是判断的第一个抓手:先看 HIGH 和 CRITICAL 有多少。MEDIUM 和 LOW 通常可以容忍,但 CRITICAL 原则上必须有说法——要么立刻修,要么证明这个漏洞在你这个使用场景下不可利用。
第二步:按严重级别过滤,让报告可用
默认输出会把所有级别一起列出,信息量很大但抓不住重点。实际运维里应该只看高危及以上:
trivy image --severity HIGH,CRITICAL nginx:1.24
逐条报告会给出:漏洞 CVE 编号、所在软件包、当前版本、修复版本、漏洞描述链接。比如:
CVE-2023-xxxxx CRITICAL openssl 1.1.1n-0+deb11u1 fixed: 1.1.1n-0+deb11u5
看到 fixed 那一列有版本号,说明发行版已经发了补丁,你只要升级就能修。看到修复版本是空的(显示 -- 或没有 fixed),说明发行版还没修,属于"已知但暂无补丁",需要评估风险或换依赖。
第三步:分三种情况处理漏洞
拿到报告后,修复思路分三类,对应三种不同的动作:
① 系统包漏洞——升级基础镜像或 apt upgrade。这是最高频的情况。如果你用 nginx:1.24,而报告说 Debian 基础层里的 zlib 有漏洞,最简单的解法是换一个更新的基础镜像 tag。很多人以为 tag 固定(比如 node:18)就永远不变,其实不是——node:18 是"滚动 tag",上游会不断把新版本推到同一个 tag 下,你 docker pull 到的可能已经是修过补丁的层。所以定期重新拉取基础镜像并重建,本身就能消掉一部分漏洞。
更严谨的做法是在 Dockerfile 的安装阶段显式打补丁:
RUN apt-get update && apt-get upgrade -y \
&& rm -rf /var/lib/apt/lists/*
注意一定要跟 rm -rf /var/lib/apt/lists/*,否则 apt 缓存会把镜像撑大几十 MB。
② 应用依赖漏洞——升级依赖版本。如果是 node_modules 里的某个包,就用 npm audit fix 或手动升到安全版本,然后重新构建镜像。这里要强调:依赖漏洞必须在构建期修,运行时改容器文件系统是无效的,容器一重启就打回原形。
③ 没有修复版本的——评估而非硬修。有些 CVE 在发行版里长期无补丁(比如某个只影响特定功能、上游认为低危的库)。这时要判断:这个功能你用到了吗?比如漏洞只在处理某种特定格式文件时触发,而你的服务根本不受理用户上传,那实际风险就很低。把这种判断记下来形成"已知风险清单",比盲目升级更负责任。
第四步:用 .trivyignore 管理"已接受的例外"
总有一些漏洞是你评估后决定暂时不修的。如果每次扫描都报出来,很快就没人看报告了。Trivy 支持用 .trivyignore 文件把这些 CVE 显式豁免:
# .trivyignore — 每行一个 CVE,可带注释
CVE-2023-12345 # 本地开发工具链,不处理外部输入
CVE-2024-67890 # 发行版暂无补丁,已评估不可利用
扫描时它会自动读取。这个文件的价值不只是"让报告干净",更重要的是它把一个隐性的、口头的决定变成了显式的、可审计的记录——下次有人问"这个漏洞怎么没修",答案就在文件里。
第五步:把扫描接进 CI,挡住带漏洞的镜像
手动扫描容易忘,真正有效的是让 CI 在构建阶段就拦下来。Trivy 的 --exit-code 参数可以让"发现漏洞"变成"构建失败":
# 有 HIGH 或 CRITICAL 就以非 0 退出,整个构建失败
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest
放在 GitHub Actions 里大概长这样:
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: 'HIGH,CRITICAL'
exit-code: '1'
ignore-unfixed: true
ignore-unfixed: true 是个很实用的选项:它只报"已有修复版本"的漏洞,也就是"你能立刻修但没修"的那些。对于还没有补丁的漏洞,不阻塞构建,避免卡死发布节奏。
顺手把 SBOM 一起生成
SBOM 是"这个镜像里到底装了什么"的完整清单,现在越来越被视为软件供应链的基本要求。Trivy 生成 SBOM 只需一条命令:
trivy image --format cyclonedx --output sbom.json myapp:latest
生成的 CycloneDX 格式文件可以:
- 存档,将来某个依赖被爆出漏洞时,一查 SBOM 就知道哪些镜像受影响,而不用一个个重新扫;
- 随镜像一起发布,让使用者知道里面装了什么。
对于个人站的镜像其实也有用:半年后你早就忘了某个镜像是基于哪个发行版构建的,SBOM 会替你记着。
几个真正的实践要点
扫描对象是镜像,不是运行中的容器。虽然 trivy image 也能接受容器 ID,但要清楚:容器运行时若装了什么临时东西,扫描会和镜像不一致。以镜像为准,才能保证"扫过的东西"和"部署的东西"是同一个。
数据库要定期更新。Trivy 的漏洞库每天更新一次,但如果你的 CI 环境长期不联网或缓存了旧库,扫出来的结果会过期。CI 里最好确认数据库拉取成功(Trivy 会打印数据库的时间戳)。
不要追求"零漏洞"。一个真实项目,尤其是带大量依赖的,永远会有一些 LOW/MEDIUM。设定一个可执行的门槛(比如"HIGH 和 CRITICAL 必须为零,且只统计有修复版本的"),比追求完美更可持续。
扫描只是发现,构建才是修复。这一点最容易误解:改运行中的容器文件系统、手动 apt install 补丁,都不算数。所有修复必须回到 Dockerfile,重新构建、重新扫描、通过后才部署。把"扫描—修复—重建—复扫"当成一条闭环,而不是一次性的检查。
速查:常用命令
# 快速扫描,只报高危及以上
trivy image --severity HIGH,CRITICAL nginx:1.24
# 只报有修复版本的漏洞(CI 友好)
trivy image --severity HIGH,CRITICAL --ignore-unfixed myapp:latest
# 有高危则构建失败
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest
# 生成 SBOM
trivy image --format cyclonedx --output sbom.json myapp:latest
# 扫文件系统(构建目录,不依赖镜像)
trivy fs --severity HIGH,CRITICAL ./project
写在最后
镜像漏洞扫描这件事,最大的价值不在于"发现漏洞"本身,而在于它强制你回答一个问题:我部署的这个东西里,到底装了什么?大多数人对自己的镜像其实是模糊的——基础镜像哪来的、里面有哪些依赖、多久没重建过,都说不太清。Trivy 就是那个把模糊变清晰、并且能在 CI 里硬性把关的工具。
一个实际可行的落地节奏是:这周先手动扫一遍手头的所有镜像,把 CRITICAL 处理掉;下周把它加进构建流程,设一个 HIGH/CRITICAL 的准入线。花不了多少时间,但从此以后,你的每一次上线都自带一道"不许带已知高危漏洞上线"的检查。