镜像签名和漏洞扫描插进 CI 流水线后,我们的构建慢了 8 分钟,最后靠这三项优化压回 2 分钟以内
把镜像签名和漏洞扫描塞进 CI 之后,流水线从 6 分半涨到 14 分半,最后是靠缓存分层、并行化扫描和签名策略调整这三件事,把额外开销压回了 2 分钟以内。下面按实际改造顺序说。
先定位时间到底花在哪
一开始我们以为慢在 Trivy 下载漏洞库,结果 gitlab-ci.yml 里加了 time 打点之后发现,真正吃时间的是三个地方:docker build 因为 --no-cache 导致基础层反复拉取、Trivy 每次全量扫所有层、以及 Cosign 签名时等待镜像 manifest 上传完成后的串行步骤。
原始流水线大概是这样的:
build:
script:
- docker build --no-cache -t registry.example.com/app:$CI_COMMIT_SHA .
- trivy image --severity HIGH,CRITICAL --exit-code 1 registry.example.com/app:$CI_COMMIT_SHA
- cosign sign --key cosign.key registry.example.com/app:$CI_COMMIT_SHA
- docker push registry.example.com/app:$CI_COMMIT_SHA
单看每一步都合理,但串起来之后,构建 6 分钟、扫描 4 分钟、签名加推送 4 分钟,总时长直奔 14 分钟以上。问题不在工具本身,而在流程设计把可以重叠和复用的部分全做成了串行。
优化一:构建缓存从层级别下手,而不是简单开 BuildKit 缓存
第一反应是开 BuildKit 的 --cache-from 和 --cache-to,但实测只省了 40 秒左右。原因在于我们的基础镜像层没变,但 COPY . . 那层因为代码变动每次都失效,后面 pip install / go build 全跟着重跑。
真正有效的做法是把依赖安装和代码复制拆成两个阶段,让依赖层能被缓存命中:
# 阶段一:依赖层,变化频率低
FROM python:3.11-slim AS deps
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 阶段二:代码层,变化频率高
FROM python:3.11-slim
WORKDIR /app
COPY --from=deps /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY . .
配合 BuildKit 的内联缓存:
build:
script:
- docker buildx build
--cache-from type=registry,ref=registry.example.com/app:buildcache
--cache-to type=registry,ref=registry.example.com/app:buildcache,mode=max
-t registry.example.com/app:$CI_COMMIT_SHA
--push .
这里有个容易忽略的点:--push 直接把镜像推到仓库,不需要单独 docker push,省掉了本地导出和再次上传的时间。这一步把构建从 6 分钟压到 3 分 20 秒左右。
但注意,mode=max 会把所有中间层都推到缓存镜像里,如果仓库带宽有限,反而会拖慢。我们后来改成 mode=min,只缓存最终镜像层,构建时间没变,但缓存推送时间从 50 秒降到 10 秒以内。
优化二:漏洞扫描只扫新增层,不全量扫镜像
Trivy 默认对镜像全量扫描,包括基础镜像层。我们的基础镜像是 python:3.11-slim,每次扫描都要重新解析一遍 Debian 的包列表和漏洞库,这部分占了扫描时间的 70% 以上。
改法是把扫描拆成两个动作:基础镜像的扫描结果缓存起来,只对业务代码层做增量扫描。Trivy 本身不直接支持「只扫新增层」,但可以通过 --skip-files 和 --skip-dirs 排除基础镜像已经覆盖的路径,同时用 --cache-dir 把漏洞数据库缓存在 CI runner 本地:
scan:
script:
- trivy image
--cache-dir /cache/trivy
--severity HIGH,CRITICAL
--exit-code 1
--skip-dirs /usr/local/lib/python3.11/site-packages
--skip-files /usr/local/lib/python3.11/site-packages/*
registry.example.com/app:$CI_COMMIT_SHA
漏洞库缓存在 runner 本地之后,Trivy 首次运行要下载约 300MB 的数据库,之后增量更新只需几秒。配合跳过高频不变的系统路径,扫描时间从 4 分钟降到 50 秒以内。
如果你的 runner 是无状态的(比如 GitLab SaaS runner 或每次跑在全新容器里),--cache-dir 不生效。这时候可以把 Trivy 的缓存目录挂载到持久卷,或者用 aquasec/trivy-action 这类封装好的 Action,它们内部处理了缓存逻辑。
另一个值得考虑的方案是换用 Grype 或 Snyk,它们对依赖清单的扫描方式不同。但实测下来,在同样的缓存条件下,Trivy 的速度差距不大,换工具的迁移成本不划算。
优化三:签名和推送并行,用 keyless 签名省去密钥管理
原流程里 Cosign 签名是在镜像推送完成之后才执行的,因为签名需要拿到镜像的 digest。但 Cosign 支持直接对 manifest digest 签名,不需要等待镜像完全上传。
实际上,docker buildx build --push 完成时,manifest 已经在仓库里了,这时候可以立刻并行触发签名和漏洞扫描:
build-and-push:
script:
- docker buildx build --push -t registry.example.com/app:$CI_COMMIT_SHA .
sign:
needs: [build-and-push]
script:
- cosign sign registry.example.com/app@$DIGEST
scan:
needs: [build-and-push]
script:
- trivy image registry.example.com/app:$CI_COMMIT_SHA
needs 关键字让签名和扫描在构建推送完成后同时启动,而不是等扫描完再签名。这一步把原本串行的 4 分钟压缩到并行后的 1 分 10 秒左右(取两者较慢的那个)。
更大的优化点是改用 keyless 签名。原来用 cosign.key 私钥文件,每次 CI 都要从变量里取出、写文件、签名、删除,还有密钥泄露风险。切换到 OIDC 身份认证之后:
sign:
script:
- cosign sign --yes
--oidc-issuer https://gitlab.example.com
--oidc-client-id $CI_PROJECT_ID
registry.example.com/app@$DIGEST
不需要管理密钥文件,签名步骤从 30 秒降到 5 秒以内。而且 GitLab 的 OIDC token 本身就绑定了项目、分支、commit,溯源信息比手动管理密钥更可靠。
最终流水线长这样
合并三项优化之后,完整的 CI 配置大概是:
stages:
- build-and-push
- verify
build-and-push:
stage: build-and-push
script:
- docker buildx build
--cache-from type=registry,ref=registry.example.com/app:buildcache
--cache-to type=registry,ref=registry.example.com/app:buildcache,mode=min
--push
-t registry.example.com/app:$CI_COMMIT_SHA .
- echo "DIGEST=$(docker buildx imagetools inspect registry.example.com/app:$CI_COMMIT_SHA --format '{{json .Manifest.Digest}}')" >> build.env
artifacts:
reports:
dotenv: build.env
scan:
stage: verify
needs: [build-and-push]
script:
- trivy image
--cache-dir /cache/trivy
--severity HIGH,CRITICAL
--exit-code 1
--skip-dirs /usr/local/lib/python3.11/site-packages
registry.example.com/app:$CI_COMMIT_SHA
sign:
stage: verify
needs: [build-and-push]
script:
- cosign sign --yes
--oidc-issuer https://gitlab.example.com
--oidc-client-id $CI_PROJECT_ID
registry.example.com/app@$DIGEST
最终耗时:构建加推送 3 分 30 秒,扫描和签名并行约 1 分钟,总流水线时间稳定在 4 分半到 5 分钟。相比最初的 14 分半,额外开销从 8 分钟压到了 2 分钟以内,并且这个数字在代码变更频繁的情况下也能保持稳定。
常见问题
为什么不用 --no-cache 保证构建可复现?
--no-cache 和缓存策略并不冲突。依赖层通过 requirements.txt 或 go.mod 的 hash 来控制失效,只要依赖文件没变,缓存的依赖层就是可复现的。代码层每次都是全新构建,因为 COPY . . 的内容变了。如果担心缓存污染,可以在 weekly 流水线里跑一次全量无缓存构建作为验证。
Trivy 漏洞库缓存在本地 runner 上,多个项目共用会不会有冲突?
Trivy 的 --cache-dir 是并发安全的,内部用文件锁处理多进程访问。但如果不同项目用了不同版本的 Trivy,缓存格式可能不兼容。建议按 Trivy 版本划分缓存目录,或者干脆给每个 runner 分配独立的缓存卷。
keyless 签名在私有仓库里能用吗?
能。OIDC 签发的是短时身份 token,Cosign 用这个 token 向透明日志(Rekor)提交签名记录。私有仓库的镜像 digest 一样可以签,只是签名验证时需要配置信任的 OIDC issuer。如果完全不想让签名记录出现在公共 Rekor 日志里,可以自建 Rekor 实例,但维护成本不低。
扫描跳过了 site-packages 目录,漏掉依赖漏洞怎么办?
基础镜像的依赖漏洞在镜像构建时就已经存在,应该由基础镜像的维护方负责修复并更新 tag。我们每周跑一次全量扫描作为兜底,日常流水线只扫业务代码引入的依赖。如果基础镜像出现新的高危漏洞,全量扫描会捕捉到,然后我们升级基础镜像 tag。