又一个周三的下午,我们团队的 Sprint 评审会刚刚结束。这个迭代交付了几个关键业务功能,但整个团队的情绪并不高涨。在紧接着的回顾会议上,问题很快浮出水面:我们的 CI/CD 流水线太慢了。对于一个追求快速迭代的 Scrum 团队来说,每次提交后需要等待超过15分钟才能获得反馈,这几乎是灾难性的。这不仅打断了开发者的心流,也严重影响了我们进行小步快跑、快速验证的能力。
问题的根源指向了我们的单体仓库(Monorepo)结构。这个仓库里包含了我们的 Java 后端服务和一个基于 React 与 Chakra UI 构建的内部管理前端。
这是我们当时在 GitLab CI 上的流水线配置片段,问题显而易见:
# .gitlab-ci.yml (Old Version)
stages:
- build
- test
- deploy
build_frontend:
stage: build
image: node:18-alpine
script:
- cd frontend
- npm install
- npm run build
- docker build -t my-registry/frontend:latest .
- docker push my-registry/frontend:latest
# 每次都重新下载所有依赖,构建 Docker 镜像缓慢
build_backend:
stage: build
image: maven:3.8.5-openjdk-17
script:
- cd backend
- mvn package
# Spring Boot 打包出的 Fat JAR 巨大
- docker build -t my-registry/backend:latest .
- docker push my-registry/backend:latest
# 每次代码微小变更,都需要重新构建整个 Fat JAR 并上传整个镜像层
后端 Dockerfile
也非常典型,几乎是网上教程的翻版,这也是问题的核心之一:
# backend/Dockerfile (Old Version)
FROM openjdk:17-slim
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
每次后端哪怕只修改一行代码,mvn package
都会生成一个全新的、巨大的 Fat JAR。在 docker build
过程中,COPY
指令会因为这个新 JAR 文件的 hash 值改变而失效,导致 Docker 镜像缓存完全失效。一个超过 150MB 的层被重新构建并推送到镜像仓库,这个过程占据了流水线的大部分时间。前端的情况稍好,但 npm install
依然是耗时大户。
在回顾会上,我们把“CI/CD 时间缩短到5分钟以内”定为了下一个 Sprint 的最高优先级技术故事。这不仅仅是工程效率问题,它直接关系到我们 Scrum 流程的健康度。
初步构想与技术选型
我们的初步构想是分头行动,优化前端和后端的容器化策略。
前端:多阶段构建的深化
对于使用 Chakra UI 的 React 前端,问题相对明确。标准的 Docker 多阶段构建(Multi-stage build)是解决 npm install
缓存问题的有效手段。思路是将依赖安装和代码构建分离开,只要 package.json
和 package-lock.json
不变,依赖层就可以被完美缓存。
我们设计的 Dockerfile.frontend
如下:
# frontend/Dockerfile (Optimized)
# --- Build Stage ---
# 使用一个包含完整构建工具链的镜像
FROM node:18-alpine AS builder
WORKDIR /app
# 1. 优先复制 package.json 和 lock 文件
# 这一步是缓存的关键。只要依赖不变,后续的 npm ci 将会使用缓存层。
COPY package.json package-lock.json ./
# 2. 使用 npm ci 而非 install
# ci 会严格按照 lock 文件安装,保证构建环境的一致性,且速度通常更快。
RUN npm ci
# 3. 复制所有源代码
COPY . .
# 4. 执行构建
# 生成静态文件到 /app/build 目录
RUN npm run build
# --- Production Stage ---
# 使用一个极简的 Nginx 镜像来提供静态文件服务
FROM nginx:1.23-alpine
# 从 builder 阶段拷贝构建好的静态文件
COPY /app/build /usr/share/nginx/html
# 拷贝自定义的 Nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 暴露 80 端口
EXPOSE 80
# 启动 Nginx
CMD ["nginx", "-g", "daemon off;"]
这个方案在真实项目中是久经考验的。关键点在于 COPY package.json package-lock.json ./
这一步,它利用了 Docker 的分层缓存机制。只要依赖不更新,后续的 npm ci
就不会重复执行,大大节省了时间。
后端:告别 Dockerfile,拥抱 Jib
后端的优化才是这次技术攻坚的重头戏。传统的 Fat JAR + Dockerfile
模式的根本缺陷在于,它将所有东西——应用代码、依赖库、资源文件——混在一个巨大的层里。
我们评估了几个方案:
- 优化 Dockerfile:手动将依赖和应用代码分层。这需要解压 Fat JAR,手动
COPY
,过程繁琐且容易出错,可维护性极差。 - 使用 Spring Boot 的分层功能:Spring Boot 2.3+ 支持将 JAR 包分层,可以配合 Dockerfile 实现更好的缓存。这是一个不错的备选方案,但仍需要维护 Dockerfile。
- 采用 Jib:Google 开源的 Java 容器化工具。它的理念彻底颠覆了传统流程。
我们最终选择了 Jib,原因有三:
- 无需 Dockerfile 和 Docker 守护进程:Jib 直接与 Maven 或 Gradle 插件集成,在构建过程中直接生成符合 OCI 规范的镜像并推送到远端仓库。CI Runner 上不再需要安装和运行 Docker Daemon,这在安全性(告别 Docker-in-Docker)和资源消耗上都是一个巨大的胜利。
- 智能的分层策略:这是 Jib 的核心优势。它能自动将一个 Java 应用拆分成多个逻辑层:依赖(Dependencies)、快照依赖(Snapshot Dependencies)、资源(Resources)、类文件(Classes)。在真实项目中,第三方依赖是最稳定、最庞大的部分,而应用代码(特别是类文件)是变化最频繁、最小的部分。Jib 的分层策略意味着,当我们只修改业务代码时,CI 流程只需要重新构建并推送几 MB 甚至几 KB 的类文件层,而不是整个 150MB 的镜像。
- **可复现的构建 (Reproducible Builds)**:Jib 默认会将容器的创建时间戳固定,确保只要输入(源代码和配置)不变,生成的镜像 digest 就完全一致。这对于版本控制和部署的确定性至关重要。
步骤化实现与深度配置
确定方案后,我们便在新的 Sprint 中开始实施。
1. 改造后端 pom.xml 集成 Jib
首先,我们在 backend/pom.xml
中移除所有和 dockerfile-maven-plugin
相关的配置,然后添加 jib-maven-plugin
。配置过程远比想象中要细致,一个生产级的配置需要考虑很多细节。
<!-- backend/pom.xml -->
<project>
...
<build>
<plugins>
...
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.3.2</version>
<configuration>
<!-- 基础镜像配置:选择一个精简且安全的镜像 -->
<from>
<image>eclipse-temurin:17-jre-focal</image>
</from>
<!-- 目标镜像配置 -->
<to>
<!-- 镜像名称从 CI 变量中动态获取,保证灵活性 -->
<image>${env.CI_REGISTRY_IMAGE}/backend</image>
<!-- 使用 Git Commit SHA 作为标签,保证可追溯性 -->
<tags>
<tag>${env.CI_COMMIT_SHORT_SHA}</tag>
<tag>latest</tag>
</tags>
</to>
<!-- 认证配置:这是在 CI 环境中成功的关键 -->
<auth>
<!-- GitLab CI 提供的预定义变量 -->
<username>${env.CI_REGISTRY_USER}</username>
<password>${env.CI_REGISTRY_PASSWORD}</password>
</auth>
<container>
<!-- JVM 参数:为生产环境设置合理的 JVM 参数 -->
<jvmFlags>
<jvmFlag>-XX:+UseG1GC</jvmFlag>
<jvmFlag>-XX:MaxRAMPercentage=80.0</jvmFlag>
<jvmFlag>-Djava.security.egd=file:/dev/./urandom</jvmFlag>
<!-- 应用特定的配置 -->
<jvmFlag>-Dspring.profiles.active=production</jvmFlag>
</jvmFlags>
<!-- 设置容器启动时间为固定的纪元原点,实现可复现构建 -->
<creationTime>USE_CURRENT_TIMESTAMP</creationTime>
<!-- 在真实CI中,我们通常设置为一个固定值如 "1970-01-01T00:00:00Z" -->
<!-- USE_CURRENT_TIMESTAMP 用于本地调试 -->
<!-- 端口暴露 -->
<ports>
<port>8080</port>
</ports>
<!-- 使用 exploded 模式,以获得最细粒度的分层 -->
<!-- 对于 Spring Boot,这可以进一步将 fat jar 拆分 -->
<containerizingMode>exploded</containerizingMode>
</container>
<extraDirectories>
<!-- 如果有需要挂载的额外文件或目录,例如APM Agent -->
<!-- <paths>
<path>
<from>/path/to/agent</from>
<into>/opt/agent</into>
</path>
</paths> -->
</extraDirectories>
<!-- 允许构建不安全的镜像仓库(例如使用 HTTP 的内部仓库) -->
<allowInsecureRegistries>true</allowInsecureRegistries>
</configuration>
</plugin>
</plugins>
</build>
...
</project>
这里的每一个配置项都有其考量:
-
<from>
: 我们没有选择distroless
镜像,因为它虽然极小,但不包含 shell,这给调试(kubectl exec
)带来了困难。eclipse-temurin
的jre-focal
版本是一个很好的平衡点,既官方又相对精简。 -
<to>
: 镜像的名称和标签完全通过 GitLab CI 的预定义变量动态生成,这是 GitOps 实践的基础。硬编码镜像名是一个常见的错误。 -
<auth>
: Jib 需要认证才能推送到私有仓库。直接利用CI_REGISTRY_USER
和CI_REGISTRY_PASSWORD
是最安全、最便捷的方式,避免了在代码库中暴露凭证。 -
<jvmFlags>
: 直接在构建时固化生产环境的 JVM 参数,避免了在 Kubernetes YAML 中通过args
或JAVA_OPTS
环境变量注入的复杂性,保证了镜像的自包含性。 -
<containerizingMode>exploded</containerizingMode>
: 默认是packaged
,它会处理整个 JAR。而exploded
模式会将应用解压,然后 Jib 可以更智能地识别 class 文件、资源文件并把它们放到独立的层中,这对于频繁的代码变更尤其有效。
2. 全面革新 .gitlab-ci.yml
有了优化的 Dockerfile.frontend
和 Jib 的 pom.xml
,我们的 CI 配置文件也变得焕然一新。它更简洁,逻辑更清晰。
# .gitlab-ci.yml (New and Improved Version)
stages:
- build
- test
- deploy
variables:
# Maven 缓存,加速依赖下载
MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository"
# Jib 缓存目录
JIB_CACHE: ".jib-cache"
cache:
paths:
# 缓存 Maven 依赖和 Jib 的构建缓存
- backend/.m2/repository
- backend/.jib-cache
build_frontend:
stage: build
image: docker:20.10.16
services:
- docker:20.10.16-dind
variables:
# 启用 Docker Layer Caching
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs"
before_script:
# 登录 GitLab Registry
- echo -n $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
script:
- cd frontend
# --cache-from 尝试从之前的镜像拉取缓存
- docker build --cache-from $CI_REGISTRY_IMAGE/frontend:latest -t $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_SHORT_SHA -t $CI_REGISTRY_IMAGE/frontend:latest .
- docker push $CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_SHORT_SHA
- docker push $CI_REGISTRY_IMAGE/frontend:latest
rules:
# 只有 frontend 目录变更时才运行此作业
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- frontend/**/*
- if: '$CI_COMMIT_BRANCH == "main"'
build_backend:
stage: build
image: maven:3.8.5-openjdk-17
script:
- cd backend
# 核心命令:编译并使用 Jib 构建和推送镜像
# 无需 docker build, docker push
# JIB_CACHE 环境变量被映射到了 GitLab Cache
- mvn compile jib:build -Djib.cache.baseImage=${JIB_CACHE}/base-image -Djib.cache.application=${JIB_CACHE}/application
rules:
# 只有 backend 目录变更时才运行此作业
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- backend/**/*
- if: '$CI_COMMIT_BRANCH == "main"'
# ... 后续的 test 和 deploy stages
新配置的亮点:
- Monorepo 优化 (
rules:changes
): 我们为每个构建作业添加了rules:changes
,这样只有当对应目录(frontend/
或backend/
)下的文件发生变化时,作业才会被触发。这是 Monorepo CI 优化的关键一步,避免了不必要的重复构建。 - 前端缓存策略 (
--cache-from
): 前端构建作业中,我们明确使用--cache-from
指令,让 Docker 在构建新镜像前,尝试从latest
标签的镜像中拉取可用的缓存层。 - 后端命令的极致简化: 后端构建作业的核心脚本只有一行
mvn compile jib:build
。这背后,Jib 自动完成了认证、构建、分层、推送的所有工作。 - Jib 缓存 (
-Djib.cache.*
): 我们通过 Maven 参数将 Jib 的本地缓存目录重定向到我们为 GitLab CI 定义的缓存路径下。这意味着在不同的流水线运行之间,Jib 可以复用已经下载的基础镜像层和之前构建的应用层,进一步压榨构建时间。
流程图
整个优化的 CI/CD 流程可以用下面的图来表示:
graph TD A[Git Push to Monorepo] --> B{Changes in /frontend ?}; B -- Yes --> C[Run 'build_frontend' Job]; B -- No --> D[Skip Frontend Build]; A --> E{Changes in /backend ?}; E -- Yes --> F[Run 'build_backend' Job]; E -- No --> G[Skip Backend Build]; subgraph 'Frontend Build Job' C1[Restore npm cache if possible] --> C2[Docker Build w/ Multi-stage & --cache-from] --> C3[Push Image to Registry]; end subgraph 'Backend Build Job' F1[Restore Maven/.jib-cache] --> F2[mvn compile jib:build] --> F3[Jib pushes layers to Registry]; end C --> H[Run Tests]; F --> H; H --> I[Deploy to Staging];
最终成果与分析
Sprint 结束时,我们进行了成果演示。当流水线在屏幕上以 3分47秒 的时间完成时,团队响起了掌声。
我们拉取了一次后端代码微小变更后的 CI 日志进行分析,Jib 的强大之处展露无遗:
...
[INFO] --- jib-maven-plugin:3.3.2:build (default-cli) @ my-backend ---
[INFO]
[INFO] Containerizing application to my-registry.com/my-project/backend...
[INFO] Base image 'eclipse-temurin:17-jre-focal' is cached.
[INFO]
[INFO] The base image requires auth. Trying again for retrieving manifest...
[INFO] Layers:
[INFO] - Dependencies: 27 layers, 152.4 MiB (UNCHANGED)
[INFO] - Snapshot Dependencies: 1 layer, 1.2 MiB (UNCHANGED)
[INFO] - Resources: 1 layer, 256.1 KiB (UNCHANGED)
[INFO] - Classes: 1 layer, 2.7 MiB (CHANGED)
[INFO]
[INFO] Pushing 1 layer (2.7 MiB)...
...
[INFO] BUILD SUCCESS
日志清晰地显示,Jib 识别出只有 Classes
层发生了变化,因此只推送了区区 2.7 MiB
的数据。而那 152.4 MiB
的依赖层稳如泰山,完全无需重新上传。这与之前动辄上传整个 150MB+ Fat JAR 镜像形成了鲜明对比。
前端的优化效果同样显著,利用 Docker 的层缓存,npm ci
步骤从几分钟缩短到了十几秒。
这次优化不仅是技术上的成功,它对我们整个 Scrum 流程产生了正向的化学反应:
- 反馈循环缩短:开发者可以更快地得到 Merge Request 的构建结果,减少了上下文切换。
- 迭代节奏加快:我们更有信心进行小批量、高频率的合并,完全契合 Scrum 的精神。
- 会议更有价值:回顾会议上,团队不再抱怨工具链,而是能更专注于产品价值和流程改进。
遗留问题与未来迭代
尽管取得了显著成效,但当前的方案并非银弹,它也存在一些局限性和值得继续探索的方向。
首先,Jib 的便利性建立在牺牲部分灵活性之上。如果我们的应用需要复杂的操作系统级依赖(比如安装特定的 C++ 库或 Python 工具),单纯的 Jib 配置就无能为力了。在这种场景下,我们可能需要回退到 Jib 的 dockerBuild
模式,即让 Jib 驱动一个传统的 Dockerfile
来构建,但这会丧失其 daemonless 的部分优势。
其次,我们的 Monorepo 依赖管理还很初级。虽然通过 rules:changes
实现了作业级别的按需触发,但如果 backend
内部有多个 Maven 模块,一次变更仍然会触发所有模块的构建。未来的迭代中,引入像 Bazel 这样的构建工具,实现更细粒度的依赖关系分析和增量构建,将是进一步提升效率的方向。
最后,我们计划将 Jib 生成 SBoM (Software Bill of Materials) 的功能集成到 CI 流程中。这可以自动生成应用依赖的完整清单,并提交给我们的安全扫描工具(如Trivy或Snyk),从而在CI阶段就发现潜在的供应链漏洞,将 DevSecOps 的理念更深入地融入到我们的 Scrum 开发周期里。