上一篇 《如何在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 源:
sudo apt-get install -y wget gnupgwget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/nullecho "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.listsudo apt-get updatesudo apt-get install -y trivy装完验证:
trivy --version方式二:跑容器版
不想装,就用官方镜像跑一次性容器。扫本机 Docker 里的镜像需要挂载 docker.sock,漏洞库缓存挂个卷出来,不然每次都要重新下数据库:
docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ -v $HOME/.cache/trivy:/root/.cache \ aquasec/trivy:latest image nginx:latestWARNING挂载 docker.sock 约等于交出宿主机的 root 权限,这一点在安全篇里专门讲过。容器版 Trivy 只应该在你自己的机器上、扫你自己的镜像时这样用;生产服务器上更推荐装二进制,或者只扫远程镜像(见下文),不要为了图省事长期挂着 sock 跑来路不明的扫描容器。
扫远程镜像:不用先 pull
trivy image 后面直接跟镜像名就行。本地 Docker 里没有这个镜像时,Trivy 会通过仓库 API 直接读取镜像的层来分析,不需要先 docker pull,也不会把镜像真正下载进本机 Docker:
# 扫 Docker Hub 上的官方镜像trivy image nginx:1.27
# 扫 GHCR 上的镜像trivy image ghcr.io/owner/myapp:v1.2.0
# 扫本地镜像文件(离线搬运过来的 tar 包)trivy image --input myapp.tar这意味着你可以在镜像真正部署之前,在任何一台机器上先把它扫一遍,扫完再决定要不要拉到服务器上。
第一次扫描实战:输出逐段解读
直接对最常见的官方镜像开扫:
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 限定:
# 只看 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 默认以发行版安全公告为准,这通常更贴近「这个包在当前系统上到底修没修」。
降噪三件套:把报告缩到能看的尺寸
全量报告动辄一两百条,没人看得下去。实战中靠三个手段把它缩到「一屏以内、条条要处理」:
第一,只看高危。
trivy image --severity HIGH,CRITICAL nginx:latest第二,只看有修复版的。 没修复版的你看了也修不了,先从行动清单里拿掉,单独定期回看:
trivy image --severity HIGH,CRITICAL --ignore-unfixed nginx:latest这两条组合下来,156 条通常就剩个位数到十几条,这才是能真正动手处理的清单。
第三,.trivyignore 忽略已评估项。 有些高危漏洞经过评估确实与自己无关(组件在,但相关功能没启用、接口不对外),或者暂时只能接受风险。在项目目录放一个 .trivyignore 文件,Trivy 运行时会自动读取:
# 示例编号,实际填写真实 CVE 编号# 评估结论:该组件的漏洞路径需要本地文件访问,容器内无此场景,风险接受# 评估人:Anexns,复查日期:2026-02-15CVE-2025-12345写法上三条规范:一个编号一行;每条必须写注释说明为什么忽略;注释里带上复查日期。没有注释的忽略项,三个月后没人敢动、也没人记得为什么忽略,它就会变成永久盲区。忽略是「评估后接受」,不是「眼不见为净」,这两者的区别全在注释里。
另外两个常用参数一并记住:--scanners vuln 明确只扫漏洞(关掉密钥、配置等其他扫描器,出报告更快更干净);--format json 输出 JSON,适合把结果存档或喂给其他脚本做对比。
不止扫漏洞:配置扫描与依赖扫描
Trivy 还有两个顺手的功能,和镜像扫描配合着用效果更好。
trivy config 扫配置问题。 它检查 Dockerfile、docker-compose.yaml、Kubernetes 清单等文件里的错误配置,比如以 root 用户运行、特权模式、敏感端口暴露等:
# 扫当前目录下所有配置文件trivy config .
# 只扫某个 Dockerfiletrivy config ./Dockerfile输出会给出检查项编号、问题描述和修改建议。它和 《Docker 安全与资源限制实战》 里讲的那些加固项正好互补:那篇讲「应该怎么做」,这个工具帮你自动查「有没有做到」。注意它扫的是文件里的写法,不代表运行时真实状态,两边都看才完整。
trivy fs 扫项目依赖。 它直接扫项目目录里的锁文件(package-lock.json、requirements.txt、go.mod 等),不用先把镜像构建出来:
trivy fs --scanners vuln .适合在写代码阶段就发现某个依赖版本有问题,把修复点提前到构建之前。镜像扫描和 fs 扫描的关系是:fs 扫的是「源头清单」,镜像扫的是「最终成品」,成品为准,源头用来提前预警。
门禁策略:只拦高危,别把门禁做成摆设
扫描真正的价值,是接进流程里当一道门:镜像不合格就不许上线。Trivy 用 --exit-code 实现这一点——默认扫描永远返回 0(扫到漏洞也算「成功执行」),加上这个参数后,发现符合条件的漏洞就返回非 0,脚本和 CI 就能拦住:
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 myapp:latestecho $? # 0 表示通过,非 0 表示被拦下接进发布脚本,就是这么一段:
#!/bin/bashset -edocker build -t myapp:latest .# 门禁:存在「有修复版的高危漏洞」就中止发布trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 myapp:latestdocker 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 拉到什么全凭上游,你控制不了版本。务实的办法是加一个定期巡检:定时对正在跑的镜像扫一遍高危,把结果推给你自己看,而不是拦更新:
# 每周巡检:只报告,不拦截,看完人工决定要不要处理trivy image --severity HIGH,CRITICAL --ignore-unfixed \ --format table $(docker ps --format '{{.Image}}' | sort -u)发现某个在跑的镜像爆了可利用的 CRITICAL,再人工介入:要么等上游出修复版后手动拉一次,要么临时加防护。自动更新管「保持新」,巡检管「新进来的干不干净」,两者不冲突。
扫出漏洞之后:修复的决策顺序
修复不是把报告里每个包挨个升一遍,按这个顺序来,先修基础镜像,再修应用依赖,大多数条目会在第一步就消失:
- 升基础镜像。报告里七成以上的系统包漏洞,是因为基础镜像旧。把 Dockerfile 里的
FROM debian:12对应的小版本更新、或者直接重新拉最新基础镜像重新构建,很多条目立刻没了——上游发行版早就修好了,只是你的镜像还停在旧快照上。 - 定点升系统包。基础镜像已经是最新、个别包还没跟上时,在 Dockerfile 里对那个包做定点升级。一般不建议无差别
apt-get upgrade把镜像层搅乱,但对明确有漏洞的包定点升,是可接受的临时手段,并把原因写在注释里。 - 升应用依赖。Library 列里是语言依赖的,改锁文件升到修复版本,重新构建。这类修复通常最快,因为版本跨度小。
- 换更小的基础镜像。如果某个服务总是被一堆用不上的系统包拖着报漏洞,考虑换 slim 甚至 distroless:包越少,攻击面越小,以后报告也越短。这是结构性修复,放在最后做,要回归测试。
- 复扫验证。修完重新扫一遍,对比 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 扫,把版本钉死:
# 先看在跑的镜像 digestdocker 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 对账:没有历史对比,每次都要对着全量报告从头判断一遍,工具迟早会因为太费神被跳过。
把这篇和自动更新、安全加固两篇连起来看,就是一条完整的链:更新让镜像保持新,扫描让新进来的东西先过检,加固让跑起来的容器就算出事也掀不了桌子。