容器镜像漏洞扫描拖垮 CI:我把构建时间从 20 分钟压回 3 分钟的排查实录
容器镜像漏洞扫描拖垮 CI:我把构建时间从 20 分钟压回 3 分钟的排查实录
上周三下午,我正准备把一个新功能合并到主分支,CI 突然跑了一个多小时还没结束。平时 3 分钟的构建,硬生生拖到了 20 分钟。翻了一圈日志,罪魁祸首是两周前接入的容器镜像漏洞扫描。
说实话,安全团队的初衷是好的。但把扫描直接塞进构建流程,没做任何优化,这跟在高速公路入口设检查站检查每辆车的底盘没啥区别——堵是必然的。
问题是怎么发现的
事情要从安全合规说起。公司安全团队要求所有上线镜像必须通过漏洞扫描,Severity High 及以上必须清零。我花了一个下午把 Trivy 接进了 GitLab CI,配置看起来挺简单:
scan_image:stage:testimage:aquasec/trivy:latestscript:-trivy image--exit-code 1--severity HIGH,CRITICAL $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA第一遍跑通了,我还挺得意。但接下来的几天,每次推送代码都要等 20 分钟。更离谱的是,开发人员开始抱怨本地分支的 CI 也慢了,因为 merge request 触发的 pipeline 同样要跑完整扫描。
我拉了一下构建耗时数据,扫描阶段占了整个 pipeline 的 78%。Trivy 每次都要重新下载漏洞数据库,然后对镜像逐层扫描。我们的镜像基于一个 Ubuntu 基础镜像,一层叠一层,Trivy 把每一层都拆开来分析。再加上没有缓存,每次都是从零开始。
划重点:漏洞扫描不是不能慢,但慢在构建流程里就是致命伤。
排查时间线
我按时间线记录了整个排查过程,方便后续复盘。
T+0 分钟:确认瓶颈
先看了 GitLab CI 的 stage 耗时。build 阶段 2 分 30 秒,scan 阶段 17 分钟。问题明确:扫描是瓶颈。
# 快速查看各 stage 耗时gl-ci-trace|grep-E"(build|scan|test)"|awk'{print $1, $NF}'T+5 分钟:分析 Trivy 扫描日志
Trivy 的日志里反复出现Downloading DB...和Analyzing layer...。我注意到漏洞数据库下载占了 4 分钟,镜像分析占了 13 分钟。
# 本地复现,加上时间戳timetrivy image--severityHIGH,CRITICAL my-app:latest本地跑了一遍,结果一样:数据库下载慢,镜像分析慢。
T+15 分钟:定位两个根因
第一个根因:Trivy 默认从 GitHub 下载漏洞数据库,网络不稳定时经常超时重试。第二个根因:我们镜像层数太多,而且有很多重复文件,Trivy 的逐层分析策略在这种情况下效率极低。
T+25 分钟:制定三层修复方案
我画了一个简单的决策树:要么让扫描变快,要么把扫描从构建流程里挪出去,或者两者都做。最后决定三层一起上:缓存漏洞数据库、并行扫描、分层扫描策略。
解决方案:三层修复
第一层:缓存漏洞数据库
Trivy 的漏洞数据库可以缓存到本地或者 registry。我在 CI 里加了一个缓存 job,每天只更新一次数据库,其他 job 直接使用缓存。
.trivy_cache:cache:key:trivy-db-${CI_COMMIT_REF_SLUG}paths:-.cache/trivybefore_script:-mkdir-p .cache/trivy-export TRIVY_CACHE_DIR=.cache/trivy-|if [ ! -f .cache/trivy/db/trivy.db ]; then echo "Cache miss, downloading DB..." trivy image --download-db-only else echo "DB cache hit, skipping download" fi这一步把数据库下载时间从 4 分钟压到了 10 秒。
第二层:并行扫描 + 分层策略
原来我们扫描的是最终构建完成的镜像。但实际上,很多漏洞来自基础镜像,业务代码层很少有高危漏洞。我改成了先扫描基础镜像,再扫描业务层,两者并行。
scan_base_image:stage:testimage:aquasec/trivy:latestscript:-trivy image--severity HIGH,CRITICAL--skip-dirs /app ubuntu:22.04allow_failure:truescan_app_layer:stage:testimage:aquasec/trivy:latestscript:-trivy filesystem--severity HIGH,CRITICAL /apponly:-merge_requestsfilesystem模式扫描的是构建上下文,而不是完整镜像。对于业务代码变更,这通常就够了。只有基础镜像升级时,才触发完整镜像扫描。
第三层:优化镜像本身
扫描慢还有一个原因:镜像太大了。我花了一个小时把镜像从 Ubuntu 换成了 distroless,又砍掉了一些不必要的构建依赖。
# 优化前:Ubuntu 基础镜像,层数多 FROM ubuntu:22.04 RUN apt-get update && apt-get install -y python3 python3-pip COPY requirements.txt . RUN pip install -r requirements.txt COPY . /app # 优化后:多阶段构建 + distroless FROM python:3.11-slim as builder COPY requirements.txt . RUN pip install --user -r requirements.txt FROM gcr.io/distroless/python3 COPY --from=builder /root/.local /home/nonroot/.local COPY --chown=nonroot:nonroot . /app WORKDIR /app ENV PATH=/home/nonroot/.local/bin:$PATH镜像从 1.2GB 压到了 180MB。镜像小了,Trivy 扫描自然也少了。
踩坑记录
坑 1:Trivy cache 目录权限
GitLab runner 的 cache 目录默认权限是 root,但 Trivy 容器里跑的是非 root 用户。结果缓存写进去了,Trivy 读不出来。解决方式是统一用root用户跑 Trivy,或者调整 cache 目录权限。
scan_image:image:name:aquasec/trivy:latestentrypoint:[""]script:-trivy image--cache-dir /tmp/trivy-cache...坑 2:–skip-dirs 不生效
我一开始用--skip-dirs /app想跳过业务代码目录,结果发现 Trivy 的image模式里--skip-dirs只对filesystem模式有效。换成filesystem扫描构建上下文,才真正跳过不需要的目录。
坑 3:并行扫描的依赖顺序
scan_base_image和scan_app_layer放在同一个 stage,但scan_app_layer其实依赖 build 阶段的产物。我一开始把它们放错了 stage,导致scan_app_layer跑的时候构建产物还没生成。调整 stage 顺序后解决。
坑 4:distroless 的调试困难
distrioless 镜像里没有 shell,调试起来很费劲。我最后保留了一个debug版本的 Dockerfile,本地调试用debug标签,线上用distroless标签。
# 本地调试dockerbuild-tmy-app:debug-fDockerfile.debug.# 线上部署dockerbuild-tmy-app:latest-fDockerfile.最终效果
三层修复下来,CI 构建时间从 20 分钟压回了 3 分钟,甚至更短。
| 优化项 | 优化前 | 优化后 | 节省 |
|---|---|---|---|
| 漏洞数据库下载 | 4 分钟 | 10 秒 | ~3.8 分钟 |
| 镜像扫描 | 13 分钟 | 45 秒 | ~12 分钟 |
| 镜像构建 | 3 分钟 | 2 分钟 | 1 分钟 |
| 总计 | 20 分钟 | 3 分钟 | 17 分钟 |
更重要的是,开发人员不再抱怨 CI 慢了。安全合规和开发效率这两件事,终于可以和平共处了。
写在最后
这件事给我最大的教训是:安全工具接入不能简单粗暴。直接把 Trivy 塞进构建流程,不考虑缓存、不考虑分层、不考虑镜像体积,结果就是开发效率和安全合规两边都不讨好。
如果你是安全团队,建议把扫描放在独立的 stage,并且给开发团队留足缓存策略。如果你是开发团队,接入扫描工具的时候,先本地跑一遍time trivy image,看看瓶颈到底在哪。
说到底,DevSecOps 不是把安全工具堆进 CI 就完事了。安全左移没问题,但左移不等于把负担全扔给开发。找到一个让两边都能接受的方案,才是真正的工程实践。
如果你也在 CI 里踩过扫描的坑,欢迎评论区交流。踩坑的路上,你并不孤单。
