6305 字
32 分钟
镜像安全扫描:用 Trivy 给镜像查漏洞
2026-11-15

上一篇 《如何在Linux自动更新Docker容器》 解决的是「镜像怎么保持新」,《Docker 安全与资源限制实战》 解决的是「容器运行时怎么关进笼子」。但还有一个问题夹在中间没人管:你拉下来的镜像,本身干不干净?

很多人有个错觉,觉得基础镜像用 latest、依赖锁文件不乱动,就万事大吉。实际上用着 latest,不等于用着安全:nginx:latest 这类官方镜像里躺着几十上百个系统包,其中任何一个爆了 CVE(公共漏洞披露),你的容器就原样继承。更麻烦的是应用依赖——一个两年没动的 package-lock.json,可能锁着早就上了通缉名单的库。

这篇讲的工具是 Trivy:上线前先给镜像做一次体检,把漏洞按等级列清楚。这篇的主线先说在前面:扫描的目的不是零漏洞,而是分级处置——高危的拦下来修,低危的知晓并排期。抱着「扫出一条都不许上线」的心态用扫描器,最后只会把扫描器关掉,这在后面讲门禁时还会再强调一次。

为什么镜像里会有漏洞#

先把账算清楚。一个镜像的漏洞来源就两处:

来源典型内容谁负责更新
基础镜像的系统包OpenSSL、curl、glibc、zlib 等 OS 层库基础镜像维护者,你重新构建或拉新版才生效
应用依赖npm / pip / Go modules 等语言级依赖你自己,改锁文件、升版本

说白了,你对一个镜像的信任不应该来自「它是官方发布的」,而应该来自「我扫过,我知道里面装了什么、带着哪些已知问题」。前者是盲信,后者才是底数。

这两处有个共同特点:漏洞是随时间「长」出来的。镜像构建那天干干净净,过三个月,里面的某个库被披露了高危漏洞,镜像文件一个字节没变,安全状态却已经从「干净」变成「带病」。这就是为什么扫描不是构建时做一次就完事,而是要反复做——尤其是用了自动更新之后,新镜像进来之前更应该过一眼。

应用依赖那一路出事起来更隐蔽。2021 年底的 Log4Shell(CVE-2021-44228)就是典型:一个几乎每个 Java 项目都在用的日志库爆出远程代码执行,无数服务镜像本身一行代码没改、基础镜像也没问题,仅仅因为依赖树里躺着这个库,一夜之间全成了高危。这类事件靠人工盯新闻根本盯不过来,只能靠工具把每个镜像的依赖清单和漏洞库逐条比对——这正是扫描器存在的意义:它不判断你的代码写得好不好,它只做一件苦力活,把「你用了哪些包、每个包现在有没有已知漏洞」这本账,随时算给你看。

还有个反直觉的事实:漏洞数量多不代表镜像差。Debian 这类全量基础镜像包多,扫出来上百条很常见,其中绝大多数是低危、或者根本没有修复版本、或者触发条件在你的场景里根本不成立。反过来,一个只扫出 3 条的镜像,如果其中一条是可远程利用的 CRITICAL,比 150 条低危严重得多。看扫描报告,永远先看等级和可修复性,再看数量。

Trivy 是什么,两种运行方式#

Trivy 是 Aqua Security 开源的扫描器,一个工具能扫好几样东西:镜像漏洞、IaC 配置问题、项目依赖、硬编码密钥。这篇以镜像漏洞扫描为主,后面会带到配置扫描和依赖扫描。它的漏洞数据来自各发行版安全公告和 NVD 等公开库,本地缓存一份数据库,扫描时不需要把镜像传到任何云端,全程在本机完成,这点对自托管场景很重要。

先把它的边界说清,免得用错期待:Trivy 查的是已知漏洞——已经被披露、编了号、进了数据库的那些。它查不出没人发现过的未知漏洞,也看不懂你的业务代码逻辑有没有问题。所以「扫描零高危」只能读作「没有已知的、可利用性明确的大问题」,不能读作「绝对安全」。它的定位是上线前的一道体检关卡,替代不了运行时加固和最小权限那些基本功,这也是为什么这篇要和安全篇配套着看。

运行方式有两种,按场景选:

方式适合场景代价
安装二进制长期使用、接 CI、定时任务要装一次、偶尔升级
跑容器版 aquasec/trivy临时扫一下、不想装东西每次要带一长串参数,漏洞库缓存要挂卷

方式一:安装二进制#

Debian / Ubuntu 用官方 apt 源:

Terminal window
sudo apt-get install -y wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt-get update
sudo apt-get install -y trivy

装完验证:

Terminal window
trivy --version

方式二:跑容器版#

不想装,就用官方镜像跑一次性容器。扫本机 Docker 里的镜像需要挂载 docker.sock,漏洞库缓存挂个卷出来,不然每次都要重新下数据库:

Terminal window
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v $HOME/.cache/trivy:/root/.cache \
aquasec/trivy:latest image nginx:latest
WARNING

挂载 docker.sock 约等于交出宿主机的 root 权限,这一点在安全篇里专门讲过。容器版 Trivy 只应该在你自己的机器上、扫你自己的镜像时这样用;生产服务器上更推荐装二进制,或者只扫远程镜像(见下文),不要为了图省事长期挂着 sock 跑来路不明的扫描容器。

扫远程镜像:不用先 pull#

trivy image 后面直接跟镜像名就行。本地 Docker 里没有这个镜像时,Trivy 会通过仓库 API 直接读取镜像的层来分析,不需要先 docker pull,也不会把镜像真正下载进本机 Docker:

Terminal window
# 扫 Docker Hub 上的官方镜像
trivy image nginx:1.27
# 扫 GHCR 上的镜像
trivy image ghcr.io/owner/myapp:v1.2.0
# 扫本地镜像文件(离线搬运过来的 tar 包)
trivy image --input myapp.tar

这意味着你可以在镜像真正部署之前,在任何一台机器上先把它扫一遍,扫完再决定要不要拉到服务器上。

第一次扫描实战:输出逐段解读#

直接对最常见的官方镜像开扫:

Terminal window
trivy image nginx:latest

第一次运行会先下载漏洞数据库(下一节专门讲),然后输出分两段。第一段是汇总头:

nginx:latest (debian 12.8)
==========================
Total: 156 (UNKNOWN: 0, LOW: 85, MEDIUM: 47, HIGH: 21, CRITICAL: 3)

这一行交代了四件事:扫的是哪个镜像、它的 OS 是什么版本、总共多少条、按等级各多少。先看这里的 CRITICAL 和 HIGH 数字,心里有个底,再往下看明细。

第二段是明细表(以下为节选示意):

┌────────────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┬──────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
├────────────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┼──────────────────────────────────────────┤
│ libssl3 │ CVE-2023-5363 │ MODERATE │ fixed │ 3.0.11-1~deb12u1 │ 3.0.11-1~ │ openssl: incorrect cipher key handling │
│ curl │ CVE-2023-38545 │ HIGH │ fixed │ 7.88.1-10 │ 7.88.1-10+ │ curl: SOCKS5 heap buffer overflow │
│ ... │ ... │ ... │ ... │ ... │ ... │ ... │
└────────────────┴────────────────┴──────────┴────────┬───────────────────┴───────────────┴──────────────────────────────────────────┘

每一列怎么看:

列含义怎么读
Library出问题的包名先看认不认识:系统库还是应用依赖,决定找谁修
Vulnerability漏洞编号,一般是 CVE 编号拿它去查详情、写忽略清单、跟同事沟通都用这个号
Severity严重等级处置优先级的第一依据,下一节细讲
Status修复状态fixed 表示已有修复版;affected 表示受影响但还没修复版;还有 will_not_fix(官方明确不修)等
Installed Version你镜像里装的版本和下一列对比,看差了多少
Fixed Version修复版本空的或者没有这一项,就是暂时无药可救,只能等或换
Title漏洞一句话说明快速判断跟自己有没有关系,比如一个打印组件的漏洞出现在纯 API 服务里,优先级自然靠后

读报告的顺序建议固定下来:先按 Severity 从高到低,只看 CRITICAL 和 HIGH;再在其中只看 Status 为 fixed、有 Fixed Version 的——这批是「现在就能修」的。剩下的高危没修复版的,单独记下来评估风险;中低危扫一眼数量,做到心里有数即可。

补一个范围问题:trivy image 默认把两类包一起扫——OS 系统包和语言级依赖(镜像里能找到的 package-lock.json、jar 包清单、Go 二进制的模块信息等),报告里如果条目特别多,先看 Library 列区分来源。如果只想看系统包、或者只想看应用依赖,用 --vuln-type 限定:

Terminal window
# 只看 OS 系统包
trivy image --vuln-type os nginx:latest
# 只看语言级依赖
trivy image --vuln-type library myapp:latest

分开看的好处是责任清楚:OS 包的问题归基础镜像,升基础镜像解决;依赖的问题归代码仓库,改锁文件解决。混在一起看,很容易对着一堆系统包漏洞去改应用代码,白忙活。

严重等级体系与处置策略#

Trivy 的等级来自各漏洞库的 CVSS 评分,共五档:

等级大致含义处置策略
CRITICAL可远程利用、影响大,可能已有公开利用立刻处理:有修复版马上升级上线;没修复版评估是否先下线或加防护
HIGH利用门槛不高或影响明显本轮就修:纳入最近一次发布,门禁拦截的下限一般设在这一档
MEDIUM有利用条件限制,或影响有限排期处理:跟着基础镜像的常规更新一起走,定期回看
LOW触发条件苛刻,影响小知晓即可,不单独为它发版
UNKNOWN来源库没给评分当 LOW 对待,但要留意数量异常增多

注意两个坑。第一,等级是漏洞库给的,不是结合你的场景评的。一个需要本地权限才能触发的 HIGH,在你的容器里可能根本够不着;反过来一个 MEDIUM 如果正好打在你暴露在公网的组件上,就得当 HIGH 处理。等级是起点,判断在你。第二,不同来源对同一 CVE 的评分可能不一致,Trivy 默认以发行版安全公告为准,这通常更贴近「这个包在当前系统上到底修没修」。

降噪三件套:把报告缩到能看的尺寸#

全量报告动辄一两百条,没人看得下去。实战中靠三个手段把它缩到「一屏以内、条条要处理」:

第一,只看高危。

Terminal window
trivy image --severity HIGH,CRITICAL nginx:latest

第二,只看有修复版的。 没修复版的你看了也修不了,先从行动清单里拿掉,单独定期回看:

Terminal window
trivy image --severity HIGH,CRITICAL --ignore-unfixed nginx:latest

这两条组合下来,156 条通常就剩个位数到十几条,这才是能真正动手处理的清单。

第三,.trivyignore 忽略已评估项。 有些高危漏洞经过评估确实与自己无关(组件在,但相关功能没启用、接口不对外),或者暂时只能接受风险。在项目目录放一个 .trivyignore 文件,Trivy 运行时会自动读取:

# 示例编号,实际填写真实 CVE 编号
# 评估结论:该组件的漏洞路径需要本地文件访问,容器内无此场景,风险接受
# 评估人:Anexns,复查日期:2026-02-15
CVE-2025-12345

写法上三条规范:一个编号一行;每条必须写注释说明为什么忽略;注释里带上复查日期。没有注释的忽略项,三个月后没人敢动、也没人记得为什么忽略,它就会变成永久盲区。忽略是「评估后接受」,不是「眼不见为净」,这两者的区别全在注释里。

另外两个常用参数一并记住:--scanners vuln 明确只扫漏洞(关掉密钥、配置等其他扫描器,出报告更快更干净);--format json 输出 JSON,适合把结果存档或喂给其他脚本做对比。

不止扫漏洞:配置扫描与依赖扫描#

Trivy 还有两个顺手的功能,和镜像扫描配合着用效果更好。

trivy config 扫配置问题。 它检查 Dockerfile、docker-compose.yaml、Kubernetes 清单等文件里的错误配置,比如以 root 用户运行、特权模式、敏感端口暴露等:

Terminal window
# 扫当前目录下所有配置文件
trivy config .
# 只扫某个 Dockerfile
trivy config ./Dockerfile

输出会给出检查项编号、问题描述和修改建议。它和 《Docker 安全与资源限制实战》 里讲的那些加固项正好互补:那篇讲「应该怎么做」,这个工具帮你自动查「有没有做到」。注意它扫的是文件里的写法,不代表运行时真实状态,两边都看才完整。

trivy fs 扫项目依赖。 它直接扫项目目录里的锁文件(package-lock.json、requirements.txt、go.mod 等),不用先把镜像构建出来:

Terminal window
trivy fs --scanners vuln .

适合在写代码阶段就发现某个依赖版本有问题,把修复点提前到构建之前。镜像扫描和 fs 扫描的关系是:fs 扫的是「源头清单」,镜像扫的是「最终成品」,成品为准,源头用来提前预警。

门禁策略:只拦高危,别把门禁做成摆设#

扫描真正的价值,是接进流程里当一道门:镜像不合格就不许上线。Trivy 用 --exit-code 实现这一点——默认扫描永远返回 0(扫到漏洞也算「成功执行」),加上这个参数后,发现符合条件的漏洞就返回非 0,脚本和 CI 就能拦住:

Terminal window
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 myapp:latest
echo $? # 0 表示通过,非 0 表示被拦下

接进发布脚本,就是这么一段:

#!/bin/bash
set -e
docker build -t myapp:latest .
# 门禁:存在「有修复版的高危漏洞」就中止发布
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 myapp:latest
docker compose up -d

接进 GitHub Actions 这类 CI 也一样,把构建和扫描放进同一个 job,扫描不过,后面的推送、部署步骤自然不会执行:

- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image \
--severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
myapp:${{ github.sha }}

门禁阈值怎么定,是这件事成败的关键。不要把标准定成「有任何漏洞就拦截」。基础镜像里常年躺着几十条中低危和无修复版的漏洞,全拦等于永远发不了版。后果不用猜:第一次大家改镜像,第二次开始申请例外,第三次门禁天天误报,迟早被人整体关掉——很多团队的安全扫描就是这么变成摆设的。

能长期跑下去的门禁长这样:

拦截(返回失败)只报告(不拦)
CRITICAL 且有修复版CRITICAL / HIGH 但无修复版(单独建清单定期回看)
HIGH 且有修复版MEDIUM / LOW / UNKNOWN 全部

逻辑很简单:只拦「你现在就能修」的问题。拦下来的每一条都有明确动作(升级),执行的人没怨气,门禁才活得久。

门禁之外,再养成一个存档习惯:每次发布用 --format json 把完整报告存一份,文件名带上日期和镜像 digest。存档不是为了应付检查,而是为了能做对比——这次比上次多出来的条目,才是真正需要关注的「新增风险」;长期不变的老条目,说明它们已经在你的接受清单里。没有历史对比,每次面对全量报告都要从头判断一遍,扫描很快就会因为「太费神」被跳过。

和自动更新怎么配合#

用了 《如何在Linux自动更新Docker容器》 里的 Watchtower 之后,更新是全自动的,扫描放哪一步?分两种情况:

镜像来自你自己的 CI:在 CI 里把关(构建 → 扫描门禁 → 推送仓库),Watchtower 只拉通过扫描的版本。服务器端什么都不用改,这是最干净的做法,前提是你的镜像不用 latest 乱飘,而是用明确版本号,让「通过扫描的那一版」和「上线的那一版」是同一个。

镜像来自第三方(官方镜像、开源项目):Watchtower 拉到什么全凭上游,你控制不了版本。务实的办法是加一个定期巡检:定时对正在跑的镜像扫一遍高危,把结果推给你自己看,而不是拦更新:

Terminal window
# 每周巡检:只报告,不拦截,看完人工决定要不要处理
trivy image --severity HIGH,CRITICAL --ignore-unfixed \
--format table $(docker ps --format '{{.Image}}' | sort -u)

发现某个在跑的镜像爆了可利用的 CRITICAL,再人工介入:要么等上游出修复版后手动拉一次,要么临时加防护。自动更新管「保持新」,巡检管「新进来的干不干净」,两者不冲突。

扫出漏洞之后:修复的决策顺序#

修复不是把报告里每个包挨个升一遍,按这个顺序来,先修基础镜像,再修应用依赖,大多数条目会在第一步就消失:

  1. 升基础镜像。报告里七成以上的系统包漏洞,是因为基础镜像旧。把 Dockerfile 里的 FROM debian:12 对应的小版本更新、或者直接重新拉最新基础镜像重新构建,很多条目立刻没了——上游发行版早就修好了,只是你的镜像还停在旧快照上。
  2. 定点升系统包。基础镜像已经是最新、个别包还没跟上时,在 Dockerfile 里对那个包做定点升级。一般不建议无差别 apt-get upgrade 把镜像层搅乱,但对明确有漏洞的包定点升,是可接受的临时手段,并把原因写在注释里。
  3. 升应用依赖。Library 列里是语言依赖的,改锁文件升到修复版本,重新构建。这类修复通常最快,因为版本跨度小。
  4. 换更小的基础镜像。如果某个服务总是被一堆用不上的系统包拖着报漏洞,考虑换 slim 甚至 distroless:包越少,攻击面越小,以后报告也越短。这是结构性修复,放在最后做,要回归测试。
  5. 复扫验证。修完重新扫一遍,对比 Total 和高危数量,确认目标条目真的消失、且没有引入新问题。没复扫的修复等于没修完,因为你不知道升级动作到底有没有落到最终镜像里。

实在修不了的(无修复版、或升级会牵动太多),回到 .trivyignore 的流程:写清评估结论和复查日期,接受风险并留痕,而不是假装没看见。

漏洞数据库更新与离线环境#

Trivy 的漏洞数据不在程序里,而是第一次运行时从官方仓库(GHCR 上的 trivy-db)下载一份数据库到本地缓存(默认在 ~/.cache/trivy),之后每次运行会检查并增量更新。几个必须知道的点:

  • 首次运行慢,主要是它在下数据库,不是扫描慢。容器版如果不挂缓存卷,每次都是「首次」,这是最常见的慢因,解决办法就是前面部署时把 $HOME/.cache 挂出来。
  • --skip-db-update 是双刃剑。CI 里为省时间、或网络受限时可以跳过数据库更新、用缓存里的旧库扫。但长期跳过数据库更新,扫出来的「干净」是假的——新披露的漏洞根本不在你的库里,报告再绿也不能说明问题。跳过可以,必须同时有机制保证数据库定期真的更新一次。
  • 纯离线环境(内网服务器不通外网)的做法:在一台能上网的机器上执行 trivy image --download-db-only 把数据库下到缓存目录,把整个缓存目录打包拷到离线机器的相同位置,再配合 --skip-db-update(有条件可再加 --offline-scan 禁止一切外连尝试)运行;或者用 --db-repository 把数据库来源指向内网自建的 OCI 镜像仓库。离线库的新鲜度靠你拷贝的频率,建议至少每周同步一次。
WARNING

数据库版本不同,同一镜像在不同时间扫出的结果可以不同:今天扫着干净,下周新 CVE 进库后同一镜像又报出高危,这不是工具不准,而是事实变了。对外汇报或留档时,养成习惯在报告旁记下扫描日期和镜像 digest,避免日后对不上账。

排障#

扫描特别慢。 先分清慢在哪一段:开头长时间没输出,多半在下数据库——检查缓存目录有没有挂对、网络到 GHCR 通不通;数据库就绪后还慢,通常是镜像层太多太大,或同时开了 config、secret 等其他扫描器。处理顺序:挂好缓存卷 → 加 --scanners vuln 只扫漏洞 → CI 里把 ~/.cache/trivy 做成缓存复用。

结果几百条,无从下手。 这是没降噪的必然结果,不是镜像没救了。直接套降噪三件套:--severity HIGH,CRITICAL --ignore-unfixed 先把清单缩到个位数;再按 Library 列看聚集在哪个基础层,优先升基础镜像,一步消掉一大批;剩下的逐条按修复顺序处理。永远不要从报告第一行开始逐条啃,那是按包名字母序排的,和重要程度无关。

扫描结果和镜像版本对不上。 最常见的原因是 tag 漂移:你以为扫的是服务器上跑的那一版 myapp:latest,但本地的 latest 是另一时间拉的另一个构建。tag 是会动的标签,不是一致性保证。处理办法:扫之前先 docker pull 确保本地就是最新;要严格对账就按 digest 扫,把版本钉死:

Terminal window
# 先看在跑的镜像 digest
docker inspect --format '{{index .RepoDigests 0}}' myapp:latest
# 按 digest 扫描,结果与线上严格对应
trivy image myapp@sha256:<digest>

另一种对不上是两台机器数据库新旧不同导致的条目差异,按上一节说的,核对扫描日期和数据库更新时间即可,别急着怀疑镜像被换了。

明明升级了,还报同一条。 先确认三件事:部署的容器是不是真的换成了新镜像(docker ps 看镜像 ID 和创建时间,老容器没重建是最常见的原因);你改的是不是最终进镜像的那一份文件(多阶段构建里改了构建阶段、运行阶段拷的还是旧产物,报告里会照报不误);同一个包在镜像里是不是有多份(比如基础层一份、应用又 vendor 了一份,Trivy 按实际文件逐份报告,修掉一份还剩一份)。按这三条顺序查,基本都能对上。

容器版报连不上 Docker。 检查 docker.sock 路径在不在、挂载参数有没有写错;有些环境 Docker 走了 rootless 或自定义 socket 路径,挂载源路径要跟着改。实在不行就装二进制版,少一层挂载少一类问题。

小结#

  • 镜像的漏洞来自基础镜像系统包和应用依赖两处,且会随时间「长」出来,所以扫描要反复做,不是构建时做一次就完事。
  • Trivy 装二进制或跑容器版都行;扫远程镜像不用先 pull;容器版挂 docker.sock 的权限风险心里要有数。
  • 读报告先看 CRITICAL / HIGH 且有修复版的那批;--severity、--ignore-unfixed、.trivyignore 三件套把报告缩到能行动的尺寸,忽略必须写注释和复查日期。
  • 门禁只拦「高危且有修复版」,用 --exit-code 1 接进发布脚本和 CI;全拦的门禁活不过一个月。
  • 修复顺序:升基础镜像 → 定点升系统包 → 升应用依赖 → 换更小的基础镜像,修完必须复扫。
  • 数据库是扫描的命根子:缓存要挂出来复用,--skip-db-update 不能长期用,离线环境靠定期拷贝缓存或自建数据库镜像源。
  • 扫描报告要存档、版本要按 digest 对账:没有历史对比,每次都要对着全量报告从头判断一遍,工具迟早会因为太费神被跳过。

把这篇和自动更新、安全加固两篇连起来看,就是一条完整的链:更新让镜像保持新,扫描让新进来的东西先过检,加固让跑起来的容器就算出事也掀不了桌子。

镜像安全扫描:用 Trivy 给镜像查漏洞
https://blog.anexns.com/posts/docker-trivy-scan/
作者
Anexns
发布于
2026-11-15
许可协议
CC BY-NC-SA 4.0