Jib 驱动的 Java 容器化与 Chakra UI 前端在 Scrum 迭代中的 CI 效能提升实录


又一个周三的下午,我们团队的 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.jsonpackage-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 --from=builder /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 模式的根本缺陷在于,它将所有东西——应用代码、依赖库、资源文件——混在一个巨大的层里。

我们评估了几个方案:

  1. 优化 Dockerfile:手动将依赖和应用代码分层。这需要解压 Fat JAR,手动 COPY,过程繁琐且容易出错,可维护性极差。
  2. 使用 Spring Boot 的分层功能:Spring Boot 2.3+ 支持将 JAR 包分层,可以配合 Dockerfile 实现更好的缓存。这是一个不错的备选方案,但仍需要维护 Dockerfile。
  3. 采用 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-temurinjre-focal 版本是一个很好的平衡点,既官方又相对精简。
  • <to>: 镜像的名称和标签完全通过 GitLab CI 的预定义变量动态生成,这是 GitOps 实践的基础。硬编码镜像名是一个常见的错误。
  • <auth>: Jib 需要认证才能推送到私有仓库。直接利用 CI_REGISTRY_USERCI_REGISTRY_PASSWORD 是最安全、最便捷的方式,避免了在代码库中暴露凭证。
  • <jvmFlags>: 直接在构建时固化生产环境的 JVM 参数,避免了在 Kubernetes YAML 中通过 argsJAVA_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

新配置的亮点:

  1. Monorepo 优化 (rules:changes): 我们为每个构建作业添加了 rules:changes,这样只有当对应目录(frontend/backend/)下的文件发生变化时,作业才会被触发。这是 Monorepo CI 优化的关键一步,避免了不必要的重复构建。
  2. 前端缓存策略 (--cache-from): 前端构建作业中,我们明确使用 --cache-from 指令,让 Docker 在构建新镜像前,尝试从 latest 标签的镜像中拉取可用的缓存层。
  3. 后端命令的极致简化: 后端构建作业的核心脚本只有一行 mvn compile jib:build。这背后,Jib 自动完成了认证、构建、分层、推送的所有工作。
  4. 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 开发周期里。


  目录