背景#

homelab 有两个 K3s 集群:家里的 homelab(一台 Ryzen 5600H 笔记本当控制面,加一台跑在 NAS 上的 worker VM),和 Oracle Cloud 上的单节点 oracle-k3s。两边在跑的东西,Trivy 一共出了 142 份漏洞报告,每份对应一个工作负载里的一个容器。这些镜像大多是别人构建的:grafana、litellm、zitadel、browserless/chrome 等等。能决定的是用哪个版本,决定不了它的基础镜像里装了什么。

镜像不动,漏洞也会变多。按 Jerry Gamblin 的年度统计,2025 年发布了 48,185 个 CVE,比 2024 年的 39,962 个多 20.6%。Trivy 的漏洞库也天天在变:trivy-db 的 README 写的是每 6 小时构建一次,客户端默认每 24 小时拉一次新库。一个几个月没更新的镜像,今天扫是干净的,下周再扫就可能多出一个 Critical。所以只在 CI 构建时扫一次不够,还要对集群里「正在跑的镜像」定期重扫。

选 Trivy 主要因为两点。一是一个工具覆盖了要查的几类问题:镜像里的已知漏洞、Kubernetes 配置问题、镜像里打包进去的密钥,Operator 版本还多一个 RBAC 评估。二是它有 Operator:在集群里自动发现工作负载、自动重扫,结果存成 Kubernetes 自定义资源(CR),还暴露 Prometheus 指标,正好接上已有的 Prometheus、Alertmanager 和 Telegram 通知。

约束只有一条:homelab 控制面是一台笔记本,空闲时 CPU 就在 60 度出头,所有安全组件都要控 CPU。后面不少配置是为它定的。

Trivy 扫一个镜像时做了什么#

先不管 Operator,直接用 Trivy CLI 扫一个镜像,看它做了哪几步。下面用 Docker 跑 Trivy 0.72.0(集群里也是这个版本;本机这里按 digest 引用,原因见后面的供应链事件),扫 curlimages/curl:8.21.0,只看有修复版本的 HIGH 和 CRITICAL:

docker run --rm -v "$PWD/trivy-cache:/root/.cache/trivy" aquasec/trivy:0.72.0@sha256:cffe3f5161a47a6823fbd23d985795b3ed72a4c806da4c4df16266c02accdd6f \
  image --severity HIGH,CRITICAL --ignore-unfixed curlimages/curl:8.21.0

日志里和扫描有关的几行:

INFO	[vulndb] Downloading artifact...	repo="mirror.gcr.io/aquasec/trivy-db:2"
INFO	[vulndb] Artifact successfully downloaded	repo="mirror.gcr.io/aquasec/trivy-db:2"
INFO	[vuln] Vulnerability scanning is enabled
INFO	[secret] Secret scanning is enabled
INFO	Detected OS	family="alpine" version="3.24.1"
INFO	[alpine] Detecting vulnerabilities...	os_version="3.24" repository="3.24" pkg_num=36
INFO	Number of language-specific files	num=0

这几行对应扫描的四步:

  1. 下载漏洞库。第一次运行会拉一个 131.44 MiB 的压缩包,解开后本地缓存是 1.6G。下载地址是 mirror.gcr.io,这是 2024 年底改的默认值:当时全球 Trivy 用户从 GHCR 拉漏洞库的请求超过了 GHCR 每个 namespace 每分钟 44000 次的上限,大量扫描报 TOOMANYREQUESTS,Aqua 因此把默认地址换成了 Google 的镜像,GHCR 留作后备(trivy discussion #8009)。
  2. 认出操作系统:alpine 3.24.1。
  3. 列出镜像里装了哪些包。Alpine 的包数据库里有 36 个包;pip、npm 装的语言依赖,或者 Go 二进制里编译进去的模块,这个镜像里没有。
  4. 拿每个包的版本去漏洞库里查。系统包查的是发行版自己的安全公告(Alpine secdb、Debian Security Tracker 等),语言包查的是 GitHub Advisory Database 这类生态公告(Trivy 文档)。

整个过程不启动容器,只读镜像里的文件。结果的开头两行:

curlimages/curl:8.21.0 (alpine 3.24.1)
======================================
Total: 8 (HIGH: 7, CRITICAL: 1)

┌─────────────┬─────────────────┬──────────┬────────┬───────────────────┬───────────────┬──────────────────────────────────────────────────────────────┐
│   Library   │  Vulnerability  │ Severity │ Status │ Installed Version │ Fixed Version │                            Title                             │
├─────────────┼─────────────────┼──────────┼────────┼───────────────────┼───────────────┼──────────────────────────────────────────────────────────────┤
│ brotli-libs │ CVE-2026-111497 │ CRITICAL │ fixed  │ 1.2.0-r1          │ 1.2.1-r0      │ brotli: Out-of-Bounds Write in Google Brotli Decoder         │
│             │                 │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2026-111497                  │
├─────────────┼─────────────────┼──────────┤        ├───────────────────┼───────────────┼──────────────────────────────────────────────────────────────┤
│ c-ares      │ CVE-2026-33630  │ HIGH     │        │ 1.34.6-r0         │ 1.34.8-r0     │ c-ares: c-ares: Use-after-free / double-free in              │
│             │                 │          │        │                   │               │ query-completion handling                                    │

Fixed Version 是 Alpine 的修复包版本:Alpine 已经发布了 brotli-libs 1.2.1-r0,所以 Status 是 fixed。按 filtering 文档,--ignore-unfixed 只显示状态为 fixed 的漏洞,发行版还没出修复包的不报。这里的「已修复」指发行版有了新包,curl 的镜像有没有用新包重建是另一回事,后面分诊时这个差别很要紧。

集群里同一个镜像的报告也是 1 个 CRITICAL、7 个 HIGH,和本机扫的一致。

Trivy Operator 怎么在集群里自动扫#

CLI 要人去跑。Trivy Operator 把这件事自动化:发现要扫的工作负载,为它创建扫描 Job,把结果写成 CR,再通过 /metrics 交给 Prometheus。

flowchart TB W["工作负载
Deployment / StatefulSet
DaemonSet / CronJob"] -->|"新建、换镜像、报告过期"| O["trivy-operator"] O -->|"1. 创建扫描 Job"| J["扫描 Job
每个容器起一个 Trivy"] J -->|"2. 查漏洞库"| S["trivy-server
漏洞库存在 PVC 上"] J -->|"3. 扫描结果"| O O -->|"4. 写入"| R["VulnerabilityReport
等 4 类 CR"] O -->|"5. 指标"| P["Prometheus"] P -->|"6. 告警规则"| A["Alertmanager
Telegram"]

operator 什么时候扫一个工作负载,看报告上的几个字段就知道。下面是集群里 krr 这个 CronJob 的报告,只留了相关字段:

{
  "labels": {
    "resource-spec-hash": "7459485f47",
    "trivy-operator.container.name": "notify",
    "trivy-operator.resource.kind": "CronJob",
    "trivy-operator.resource.name": "krr"
  },
  "annotations": {
    "trivy-operator.aquasecurity.github.io/report-ttl": "24h0m0s"
  },
  "owner": [{ "kind": "CronJob", "name": "krr", "controller": true }],
  "scanner": { "name": "Trivy", "version": "0.72.0" },
  "os": { "family": "alpine", "name": "3.24.1" },
  "summary": { "criticalCount": 1, "highCount": 7 }
}
  • resource-spec-hash 是工作负载 pod 模板的哈希。换了镜像或改了 pod spec,哈希对不上,operator 就重扫。
  • report-ttl: 24h0m0s 来自 operator 的环境变量 OPERATOR_SCANNER_REPORT_TTL=24h。报告满 24 小时就被删掉,operator 发现这个工作负载没有报告,再扫一次。镜像不变的工作负载就是靠它每天按最新的漏洞库重扫。
  • owner 指向 CronJob(Deployment 的话指向 ReplicaSet)。工作负载删了,报告跟着被垃圾回收。

扫描 Job 去哪里取漏洞库,看 trivy.mode。chart 默认是 Standalone,按 Operator 文档的说法,这时每个扫描 pod 都有一个 init 容器先下载漏洞库。上面看到漏洞库压缩后有 131 MiB,一天上百次重扫就是上百次下载和解压。homelab 开了 builtInTrivyServer: true:operator 额外跑一个 trivy-server StatefulSet,漏洞库存在它的 PVC 上,扫描 Job 用 client 模式连 http://trivy-service.trivy-system:4954 查询,自己不再下载。

扫描结果分 4 类 CR,homelab 上的数量:

CR 内容 数量
VulnerabilityReport 镜像里的已知漏洞,每个容器一份 65
ConfigAuditReport 工作负载的配置问题,比如特权容器、没设资源限制 111
ExposedSecretReport 镜像里打包进去的私钥、token 71
RbacAssessmentReport / ClusterRbacAssessmentReport Role 和 ClusterRole 权限过大 12 / 117

平时用 kubectl 直接看:

kubectl -n monitoring get vulnerabilityreports -o wide
NAME                                                          REPOSITORY                                       TAG                                    SCANNER   AGE     CRITICAL   HIGH   MEDIUM   LOW   UNKNOWN
cronjob-blog-stats-rollup-rollup                              library/postgres                                 docker.io/library/postgres:17-alpine   Trivy     23h     0          37     0        0     0
cronjob-krr-notify                                            curlimages/curl                                  8.21.0                                 Trivy     21h     1          7      0        0     0
daemonset-6c9bb4f54f                                          prometheus/node-exporter                         v1.11.1-distroless                     Trivy     14h     0          42     0        0     0

部署配置:控 CPU、共用漏洞库、只看可修复的高危#

Trivy Operator 用官方 Helm chart(aqua/trivy-operator 0.33.1,operator 0.31.1)装,由 ArgoCD 同步。values 里和取舍有关的部分:

excludeNamespaces: "kube-system,kube-public,kube-node-lease,trivy-system"
operator:
  scanJobsConcurrentLimit: 1
  scanJobTTL: "5m"
  scanJobTimeout: "20m"
  metricsFindingsEnabled: true
  metricsVulnIdEnabled: true
  vulnerabilityScannerScanOnlyCurrentRevisions: true
  configAuditScannerEnabled: true
  rbacAssessmentScannerEnabled: true
  exposedSecretScannerEnabled: true
  clusterComplianceEnabled: false
  builtInTrivyServer: true
trivy:
  mode: ClientServer
  ignoreUnfixed: true
  severity: "HIGH,CRITICAL"
  timeout: "15m0s"
  image:
    tag: "0.72.0"
trivyOperator:
  scanJobNodeSelector:
    kubernetes.io/hostname: k8s-worker-106

和 chart 0.33.1 的默认值对比,改动集中在这几项:

配置 chart 默认 homelab 原因
operator.scanJobsConcurrentLimit 10 1 串行扫描,控 CPU
operator.builtInTrivyServer / trivy.mode false / Standalone true / ClientServer 漏洞库只存一份
trivy.severity 全部 5 个级别 HIGH,CRITICAL 只看高危
trivy.ignoreUnfixed false true 没有修复包的先不报
operator.clusterComplianceEnabled true false CIS 基线交给 kube-bench 每周跑
operator.metricsVulnIdEnabled false true 按 CVE ID 出指标,告警要用

表里这几个默认值,到 chart 0.38.1(operator 0.36.1,2026-10-29 发布)都没变(values.yaml)。

扫描 Job 还用 scanJobNodeSelector 钉到了 worker 节点 k8s-worker-106(NAS 上一台 2 核 4G 的 VM),拉镜像、解层的 CPU 和磁盘开销都不落在笔记本上。oracle 没有散热问题,并发是 2。scanJobTTL 和两个超时决定扫描会不会静默失败,放到最后一节讲。

ClientServer、只留 HIGH 和 CRITICAL、报告 24 小时过期,这三项当初是为了省 CPU 和下载量定的。Aqua 在 2026-07-01 发过一篇文章,讲 Trivy Operator 的报告把 etcd 撑满的问题,给的缓解办法里也有这三项,另外还有把报告挪到 PVC、etcd 压缩和碎片整理、监控存储大小(Aqua 博客)。文中的理由是:报告存成 CR,单份报告可能超过 Kubernetes API server 1.5 MiB 的硬上限;把 trivy.severity 改成 HIGH,CRITICAL 在很多环境里能让报告对象的数量和体积减少最多 80%;ClientServer 则是为了让扫描 pod 不必各自下载漏洞库。homelab 最大的一份报告是 xiaogpt 的,只含 HIGH 和 CRITICAL 已经有 1.12 MiB(kubectl get -o json 的长度),离这个上限不远。

还有一项还没做到位:扫描器镜像现在按 tag 引用,mirror.gcr.io/aquasec/trivy:0.72.0 和 trivy-operator:0.31.1。2026 年 3 月 Trivy 自己出过一次供应链事件(CVE-2026-33634):攻击者用一次没轮换干净的凭据,发布了带窃密代码的 v0.69.4,在 2026-03-19 18:24 UTC 到 03-23 01:36 UTC 之间从 Docker Hub 拉过 0.69.4、0.69.5、0.69.6 或 latest 的,都可能拿到恶意镜像,trivy-action 仓库 77 个版本 tag 里有 76 个被改指向了恶意提交(Aqua 的事件说明、Docker 的说明)。homelab 部署时已经过了受影响窗口,用的 0.72.0 不在受影响范围内。但这件事说明 tag 是可以被改指向的,Docker 的建议是按 digest 钉镜像,有签名的 provenance 再验一遍。扫描器跑在 PSA 为 privileged 的 trivy-system 里,能读全集群的工作负载定义,比一般应用更值得钉 digest。

告警按 CVE ID 发,先给厂商 7 天#

最早的告警规则很直接:

- alert: TrivyImageCriticalVulnerabilities
  expr: sum by (cluster) (trivy_image_vulnerabilities{severity="Critical"}) > 0
  for: 30m

只要任一镜像有 Critical 就响,每 4 小时重发一次。用了几个月,问题有三个。

第一,一个 CVE 会点亮一片镜像。发行版底层包出一个漏洞,所有用这个基础镜像的镜像同时变红。2026-11-05 两个集群共 35 份报告带 Critical,按 CVE ID 合并后只有 3 个,上面 curl 里那个 brotli-libs 的 CVE-2026-111497 一个就命中 19 个镜像。同一个镜像内部也会重复:xiaogpt 补上报告以后,里面有 158 条 Critical,按 CVE ID 合并后是 28 个,因为 ImageMagick 这一个源码包在 Debian 里拆成了 13 个二进制包,同一个 CVE 在每个包上各记一条。

第二,Critical 多数不需要马上处理。前面说过,ignoreUnfixed 的 fixed 指发行版出了新包,厂商镜像通常要过几天才重建,这几天里能做的只有等。从风险看也是这样:Datadog 的 State of DevSecOps 2025 把运行时上下文算进去以后(服务是不是在生产环境、EPSS 是否低于 1%、有没有公开 exploit、是否暴露在公网),CVSS 评分为 Critical 的漏洞只有 18% 仍算 critical。

第三,剩一个就一直响,而且新来的看不见。homelab 和 oracle 曾经连续 9 天每 4 小时一条。告警在 firing 时,新冒出来的 CVE 也不会再发通知,因为告警的标签只有 cluster,Alertmanager 认为还是同一条。

改法是把告警拆到 CVE ID 上,并给厂商一个重建的宽限期。打开 operator.metricsVulnIdEnabled 后,operator 为每条漏洞出一条 trivy_vulnerability_id 序列,标签里有 vuln_id、image_repository、severity。这个指标量很大(homelab 约 1.7k 条、oracle 约 5k 条),抓取时用 relabel 只留 Critical。然后用 recording rule 收成每个(集群,CVE ID)一条,值是受影响的镜像仓库数,再写两条告警:

- record: trivy:critical_cve:images
  expr: |
    count by (cluster, vuln_id) (
      count by (cluster, vuln_id, image_repository) (
        trivy_vulnerability_id{severity="Critical"}
      )
    )    

- alert: TrivyCriticalCVE
  expr: max_over_time(trivy:critical_cve:images[6h])
  for: 30m

- alert: TrivyCriticalCVEStale
  expr: |
    max_over_time(trivy:critical_cve:images[6h])
      and on (cluster, vuln_id)
    max_over_time(trivy:critical_cve:images[1d] offset 6d)    

TrivyCriticalCVE 是知会,一个新 CVE ID 出现时发一次。TrivyCriticalCVEStale 要求这个 CVE 现在还在、6 到 7 天前也在,说明厂商一周都没重建,这时才需要人动手。它每天提醒一次,并在 Alertmanager 里抑制同一个 ID 的 TrivyCriticalCVE。

规则里有两个细节:

  • [6h] 平滑。强制重扫、报告到期删除、operator 重启,都会让序列断开一段时间。不平滑的话,每次重扫都会先发一条 RESOLVED 再发一条新告警。代价是修好以后 RESOLVED 最多晚 6 小时。
  • Stale 用 offset 6d 只比较两头,没用 for: 7d。序列中间只要断一次,for 的计时就清零,7 天永远攒不满。反过来,规则上线后的头 6 天没有历史数据可比,Stale 不会响。

收到告警之后:升级镜像,或者带证据豁免#

Stale 响了以后分三步处理:能升级镜像就升级;升不了,先确认这个漏洞在这两个集群里触发不了,再写豁免;两种改法都要强制重扫,才能马上看到效果。

升级前先确认新镜像真的修了#

上游发了新版本,不代表有漏洞的包也升了。判断时要把候选镜像拉下来,进去看包的实际版本,不看 release notes。一个例子:Next.js 在 2026-09-08 发过两个 Critical 公告(GHSA-2xp9-vwfh-vxw4、GHSA-p293-qw3h-jr36),修复版本是 16.3.3,集群里有 4 个镜像中招。逐个拉最新版看 node_modules 里的 next 版本:

镜像 升级路径 next 版本 结果
homepage v1.13.2 → v2.3.0 16.2.6 → 16.3.3 修好,已升级
zitadel-login v4.13.1 → v4.17.3 16.1.6 → 16.2.11 仍有漏洞
multica-web v0.4.28 → v0.4.42 16.2.6 → 16.2.6 没变
open_notebook 1.14.0 已是最新 stable 无 无可升

4 个里只有 1 个能靠升级修掉。这里的「修好」只对这两个公告成立:9-30 和 10-22 又出了 3 个 Critical(比如 GHSA-vcvr-r3jv-pc5j),修复版本是 16.3.6 和 16.4.1,16.3.3 也在受影响范围内。

升级时还要看两件事。oracle 是 arm64、homelab 是 amd64,用 docker buildx imagetools inspect 确认新版本两个架构都有,并钉多架构 index 的 digest。新版本可能改了配置项或行为,比如 Homepage v2 把 Host 头检查从 /api 扩到了所有页面(middleware.js),kubelet 探针用 pod IP 访问会拿到 400。

升不了就判断可达性,再写豁免#

没有已修复的镜像时,看漏洞公告的触发前提,对照这个服务实际怎么用。这一步就是 Datadog 那组上下文里的「暴露面」和「能不能被利用」,只是靠人来判断。一条真实的豁免:

trivy:
  ignoreFileName: ".trivyignore.yaml"
  ignoreFile:
    vulnerabilities:
      - id: CVE-2026-56854
        statement: "x/crypto/ssh 源地址限制未强制(GO-2026-6303); 触发需本进程作 SSH 服务端且在非公钥认证回调里设 source-address, 17 个受影响镜像无一跑 SSH 服务端(argocd 只作 git 客户端); CVE 2026-08-28 发布, 各上游最新版均早于该日无已修镜像, 2026-10 复审"

这个漏洞在 Go 的 x/crypto/ssh 里,两个集群 17 个镜像同时中招,凡是依赖了 x/crypto 的 Go 程序都算。它只有在程序自己当 SSH 服务端、并且在非公钥认证的回调里设了源地址限制时才能被利用。这 17 个镜像没有一个跑 SSH 服务端,ArgoCD 只是作为客户端用 SSH 拉 git 仓库。CVE 发布时各上游的最新版都比它早,也没有能升级的镜像。

写豁免有几条规矩:

  • statement 写清楚是哪个组件、为什么触发不了、什么条件下删掉这条。
  • 豁免按 CVE ID 对整个集群生效,限定不到某个镜像。所以 statement 要写明它依赖的前提,上面那条的前提是「集群里没有 Go 写的 SSH 服务端」,哪天前提不成立就要删。
  • 两个格式坑。ignoreFileName 要用 .trivyignore.yaml:用纯文本格式时,chart 渲染出的 - CVE-xxx 行会被当成 ID 为 - 的条目,整份豁免静默失效。也不能写 expired_at:Helm 把 values 转成 JSON 再转回来时,日期变成了带引号的字符串,Trivy 解析失败,所有扫描 Job 都会 FATAL,所以复审日期只能写进 statement。
  • 镜像里扫出的私钥按文件路径豁免,不按规则 ID。按规则 ID 豁免会把以后真泄漏的私钥也一起挡掉。

这种「某个 CVE 在某个产品里不可利用」的声明有标准格式,叫 VEX(Vulnerability Exploitability eXchange),Trivy CLI 能用 --vex 读取(Trivy VEX 文档,标为 experimental),但 Trivy Operator 的 chart 到 0.38.1 都没有 VEX 相关的配置项,集群里只能用 ignoreFile。statement 按 VEX 的思路写(状态、理由、适用范围),以后要迁过去也方便。

两份 values 里现在一共 66 条漏洞豁免。在实际运维中,真正靠升级镜像修掉的只有十来次,其余都是等上游、判可达性、写豁免这样处理的。

改完要强制重扫#

改豁免不会触发重扫。operator 只在 resource-spec-hash 变了或报告满 24 小时时重新生成报告,报告上没有记录豁免配置的哈希。所以新加的豁免,对镜像没变的工作负载要等最多 24 小时才体现出来,这期间告警照响。

想马上生效,就删掉还带 Critical 的报告,再重启 operator 触发一次全量 reconcile(只删报告不能可靠地触发重扫)。这几步做成了 just helm trivy-rescan,核心是:

CTX=oracle-k3s   # 两个集群各跑一次

# 只删还带 Critical 的报告;全删的话并发只有 1~2,要排很久
kubectl --context "$CTX" get vulnerabilityreports -A -o json \
  | jq -r '.items[] | select((.report.summary.criticalCount // 0) > 0)
           | "\(.metadata.namespace) \(.metadata.name)"' \
  | while read -r ns n; do kubectl --context "$CTX" -n "$ns" delete vulnerabilityreport "$n"; done

# 重启 operator,触发全量 reconcile;报告是 CR,不随 pod 丢失
kubectl --context "$CTX" -n trivy-system rollout restart deploy/trivy-operator

# 等报告回来
kubectl --context "$CTX" get vulnerabilityreports -A --no-headers | wc -l

最后一步不能省。删了报告却没重建,比不删更糟:缺的报告在指标里按 0 算,看起来就像漏洞已经清零了。

扫描失败通常不报错#

扫描失败会留下同样的缺口:告警只数现存报告里的漏洞,「漏扫了」和「修干净了」在告警和看板上完全一样。到现在遇到过六种让扫描静默失败的情况:

成因 表现 处理
scanJobTTL 太长 完成的 Job 在被回收前一直占着并发名额,队列停住,没有日志 TTL 从 1h 改成 5m
改了豁免 旧报告最多再留 24 小时 just helm trivy-rescan
只有 arm64 的镜像 扫描 Job 不带 --platform,默认找 linux/amd64,找不到就 FATAL 自建镜像同时出 amd64
Docker Hub 匿名拉取限流 扫描报 TOOMANYREQUESTS,失败的扫描不会自动重试 重启 operator 重排;给扫描 Job 配认证还没做
滚动更新留下的 0 副本 ReplicaSet 旧 ReplicaSet 上的报告不再重扫,配置审计和密钥报告永不过期 删旧 ReplicaSet,给 Deployment 设 revisionHistoryLimit
扫描 Job 超时 Job 被 DeadlineExceeded 杀掉,operator 一行日志都不打 放宽时限,或降低扫描 pod 的 requests

Docker Hub 那一项和扫描方式有关:扫描 Job 每次都从镜像仓库拉镜像,不用节点上已有的缓存。按 Docker 文档,匿名拉取的上限是每个 IPv4 地址(或 IPv6 /64)每 6 小时 100 次,登录的免费账号是 200 次。一个集群的出口通常只有一个 IP,批量强制重扫时很容易超过。

只有 arm64 的镜像会直接报错,多架构镜像则不会,但同样受默认平台影响。Trivy 只在指定了 --platform 时才把平台传给镜像拉取(remote.go),不指定就用 go-containerregistry 的默认值 linux/amd64(options.go)。trivy-operator 也按这个默认值处理,扫描因为找不到对应平台而失败时,它打的说明是「By default, only Linux amd64 images are supported for scanning.」(scanjob.go,到 0.36.1 还是这一句)。所以在 oracle 这个 arm64 集群上,多架构镜像的报告扫的是 amd64 变体,报告里的包版本和实际在跑的 arm64 版本可能有出入。

下面展开其中两种,它们对应 values 里两个看起来无关紧要的数字,最后讲怎么发现这类缺口。

scanJobTTL 决定扫描吞吐#

scanJobTTL 是扫描 Job 完成后多久被删掉。operator 算并发时,已完成但还没删的 Job 也占一个名额,所以吞吐上限是「并发数 ÷ TTL」。oracle 第一次部署时 TTL 是 1h、并发是 2:46 个工作负载只出了 7 份报告,队列静止了 33 分钟,没有一行报错。按这个公式算 homelab,当时并发 1,每小时最多扫 1 个镜像,一天 24 个,而报告 24 小时就过期,工作负载再多几个就会出现「报告先过期、轮不到重扫」。改成 5m 以后,每小时上限是 12 个,并发数还是 1,散热不受影响。

扫描 Job 的两层超时#

扫描有两层时限,chart 默认都是 5 分钟:

  • 外层是 Job 的 activeDeadlineSeconds,对应 operator.scanJobTimeout。它从 Job 创建开始算,pod Pending 和拉镜像的时间都算在内。超时后 Job 控制器直接杀 pod,operator 不打日志。
  • 里层是 Trivy 自己的 --timeout,对应 trivy.timeout。超时后 Trivy 打一行 context deadline exceeded 的 FATAL 日志再退出。

homelab 有两个镜像一直没有报告,原因各占一种。grafana 是调度不上:扫描 pod 给被扫工作负载的每个容器各起一个 Trivy 容器,requests 按容器数相加。grafana 有主容器、2 个 sidecar 和 1 个 init,当时扫描容器的 memory request 设的是 256Mi(chart 默认 100M),4 × 256Mi = 1024Mi,而 worker 按 requests 算只剩 1004Mi。扫描 pod 每次 Pending 5 分钟后被外层时限杀掉,14 天里被重排了大约 100 次,一次都没跑起来。扫描的 requests 降到 128Mi 以后恢复,grafana 扫一次 13 秒。

xiaogpt 是扫得慢:镜像里有一个很大的 Python 虚拟环境,Trivy 的密钥扫描要逐个读文件。外层放到 20m、里层放到 15m,让里层先超时,至少能留下一行日志。放宽后第一次扫完用了 6 分 20 秒。

用报告数对工作负载数确认扫描在工作#

判断 Trivy 有没有在工作,要看报告数和工作负载数,告警绿不绿说明不了问题。手工核对:

CTX=k3s-homelab
kubectl --context "$CTX" get vulnerabilityreports -A --no-headers | wc -l    # 已有报告
kubectl --context "$CTX" get deploy,sts,ds,cronjob -A --no-headers \
  | grep -vE '^(kube-system|kube-public|kube-node-lease|trivy-system)' | wc -l # 应该扫的工作负载
kubectl --context "$CTX" -n trivy-system get jobs                             # 滞留的 Job

homelab 上是 65 份报告对 47 个工作负载。一个工作负载有几个容器就有几份报告,所以报告数比工作负载多是正常的;报告数明显少于工作负载数,就是队列堵了或扫描在失败。还有一点:别按报告名去 grep 某个工作负载有没有被扫,名字超过 63 个字符会被压成哈希,比如上面的 daemonset-6c9bb4f54f,要按 label trivy-operator.resource.name 认。

just helm trivy-critical 把这件事做成了按容器比对,输出末尾直接列出「在跑但没有报告」的容器:

═══ k3s-homelab:报告 65 份,含 Critical 12 份,去重后 30 个 CVE ID;trivy-system 里 Job 0 个(已完成 0)
  覆盖率:运行中容器都有报告
═══ oracle-k3s:报告 77 份,含 Critical 26 份,去重后 80 个 CVE ID;trivy-system 里 Job 2 个(已完成 2)
  覆盖率:运行中容器都有报告

和前面 11-05 的「两个集群合计 3 个 CVE ID」比,这里多出来的主要是两个镜像:homelab 的 30 个 CVE ID 里有 28 个来自 xiaogpt,oracle 的 80 个里有 78 个来自 browserless/chrome。这两个镜像之前一个卡在超时上,一个卡在 Docker Hub 限流和超时上,一直没有报告,它们的 100 多个 CVE ID 在告警里都按 0 算。

告警这边补了三条兜底:

  • TrivyScanJobFailed:kube_job_status_failed{namespace="trivy-system"} > 0,不写 for。失败的 Job 5 分钟后就被 TTL 删掉,序列跟着消失,写了 for: 15m 的通用 KubeJobFailed 因此永远不响。用 keep_firing_for: 1h 防止它 5 分钟后就发 RESOLVED。
  • TrivyOperatorMetricsAbsent:operator 挂了或抓取断了,指标整个消失。两个集群各写一条,用一条 absent() 的话,只要还有一个集群在上报就不会响。
  • TrivyVulnIdMetricsMissing:报告里有 Critical,按 CVE ID 的指标却没有,说明前面两条 CVE 告警失效了,多半是抓取侧的过滤正则写错了。

小结#

Trivy 做的事很简单:读镜像里的包清单,和漏洞库对版本。Trivy Operator 把它变成集群里的常驻任务,工作负载一变或报告满 24 小时就重扫,结果存成 CR、出成指标。homelab 为了散热把扫描串行化并挪到 worker,用 trivy-server 共用一份漏洞库,只看可修复的高危;这几项也在 Aqua 给大集群的建议里。

日常运维落在两件事上。一是按 CVE ID 分诊:新 CVE 先等厂商重建,挂满 7 天再处理,能升级就升级,升不了要有可达性证据才写豁免,改完强制重扫。二是确认扫描真的在跑:缺报告按 0 算,判断依据是报告数对工作负载数。

还没做完的有三件:Docker Hub 拉取还是匿名的;扫描器镜像还按 tag 引用,没钉 digest;分诊时还没用上 EPSS 和 CISA KEV 这类「是否已被利用」的数据。

参考资料#

相关文章#