概述

本文是对上文的一些原理补充,上文连接:https://blog.csdn.net/weixin_52748928/article/details/162829807?spm=1011.2124.3001.6209

jenkins 有一个关键的问题,就是提高构建速度,以下很多步骤大多是用来解决这一问题的。

因为上文中jenkins是在docker中部署的,并在构建时需要执行docker 命令,因此就有了将宿主机的 Docker CLI 挂载进容器这个步骤。

由于每次都要构建maven下载依赖,我们把maven 容器的下载路经挂载到宿主机上,这样第二次下载时只下载增量文件,用于提高速度。

由于每次打镜像后镜像比较大,上传时间比较长。为了解决这个问题,一是增加网络带宽,二是减小镜像大小。本文针对减小镜像大小做了一些操作。使用了分层 JAR + Dockerfile 分层 COPY的方案。该方案具具体实现如下:

在pom 中开启分层,并在dockerfile 中复制分层

      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
        <version>3.5.0</version>
<!--        启用分层功能-->
        <configuration>
          <layers>
            <enabled>true</enabled>
          </layers>
        </configuration>
      </plugin>


# 【核心优化】按依赖顺序分层复制文件,充分利用 Docker 的层缓存机制。
# 第 1 层:复制第三方依赖(除非修改 pom.xml,否则此层命中缓存,无需重建)
COPY --from=builder /app/dependencies/ ./
# 第 2 层:复制 Spring Boot 加载器类(除非升级 Spring Boot 版本,否则命中缓存)
COPY --from=builder /app/spring-boot-loader/ ./
# 第 3 层:复制 SNAPSHOT 依赖(变更频率中等)
COPY --from=builder /app/snapshot-dependencies/ ./
# 第 4 层:复制业务代码和资源(每次代码提交都会变化,仅这一小层需要重新构建和推送)
COPY --from=builder /app/application/ ./

 原理详解

 为什么需要 Docker Socket 挂载?

宿主机
  ┌─────────────────────────────────────────────────┐
  │  ┌──────────────┐    ┌──────────────────────┐   │
  │  │ Docker 守护进程 │◄───│ /var/run/docker.sock│   │
  │  │ (dockerd)    │    └──────────┬───────────┘   │
  │  └──────┬───────┘               │                │
  │         │                     挂载               │
  │  ┌──────▼───────────────────────▼──────────┐    │
  │  │            Jenkins 容器                  │    │
  │  │    docker build / docker push 等命令     │    │
  │  └─────────────────────────────────────────┘    │
  └─────────────────────────────────────────────────┘

Docker 采用 C/S 架构:

  • dockerd(守护进程):真正执行容器管理、镜像构建等操作
  • docker CLI(客户端):解析命令行参数并通过 REST API 与 dockerd 通信

默认情况下,dockerd 监听 /var/run/docker.sock(Unix Socket)。将宿主机的 Docker Socket 挂载到 Jenkins 容器内,本质上是让容器内的 docker CLI 能直接与宿主机的 dockerd 通信。

好处: Jenkins 容器内执行的 docker build / docker push 实际上是在宿主机上执行的,容器本身不需要运行 dockerd。

注意: 这被称为 "Docker in Docker" 的 socket 方案(DooD),与完整的 DinD(在容器内运行完整 dockerd)不同,socket 方案更轻量、更安全。

 BuildKit 与传统 Docker Build 的区别

传统 Build(DOCKER_BUILDKIT=0):
┌──────────────┐   顺序执行步骤    ┌──────────────┐
│ Dockerfile   │ ───────────────►  │   镜像输出    │
│ Step 1-11    │                  │              │
└──────────────┘                  └──────────────┘

BuildKit 模式(DOCKER_BUILDKIT=1):
┌──────────────┐   并发执行       ┌──────────────┐
│ Dockerfile   │ ──► Step 1 ──►  │   镜像输出    │
│ Step 1-11    │ ──► Step 2 ──►  │              │
│              │ ──► Step 3 ──►  │              │
└──────────────┘                  └──────────────┘

BuildKit 是 Docker 的下一代构建引擎:

  • 并行构建: 无依赖的步骤可以并发执行
  • 缓存优化: 更好的层缓存机制
  • 安全性: 支持 seccomp、AppArmor 等安全配置
  • 多平台: 支持构建多架构镜像(arm64/amd64 等)

本项目的选择: 显式禁用 BuildKit(DOCKER_BUILDKIT=0),因为服务器上没有安装 docker-buildx 组件。对于简单的 Spring Boot 镜像构建,传统引擎完全够用。

 Maven 缓存挂载链(两层,非三层)

宿主机                                         Maven 编译容器
/opt/maven-cache/.m2  ────────────────────────────►  /root/.m2
     持久化                                             依赖下载目录

原理:

  1. 宿主机层: /opt/maven-cache/.m2 是真实的物理存储目录,所有依赖最终保存在这里
  2. Maven 编译容器层: Maven 容器是由 Jenkins 通过 docker run -v /opt/maven-cache/.m2:/root/.m2 启动的,将宿主机目录直接挂载到 Maven 容器内
    • 关键:Jenkins 容器通过挂载 /var/run/docker.sock 来调用宿主机的 Docker daemon,所以 -v 中的路径是由宿主机文件系统解析的,不会经过 Jenkins 容器

为什么这样设计?

  • Jenkins 容器与 Maven 编译容器是两个独立的容器
  • Maven 编译容器是临时创建的(--rm),每次构建结束后自动删除
  • 如果不做持久化,Maven 容器删除后所有已下载的依赖也会消失
  • 通过两层挂载,Maven 容器的 /root/.m2 最终指向宿主机 /opt/maven-cache/.m2,依赖被持久保存

dockerignore 的设计考量

**/target/           # Maven 编译输出(构建镜像时不需要)
.git/                # Git 元数据
*.md                 # 文档
Jenkinsfile          # CI 配置
Dockerfile           # Docker 构建配置

为什么排除 /target/?**

  • docker build 会将 Dockerfile 所在目录(及子目录)作为构建上下文发送给 dockerd
  • target/ 目录包含编译后的 class 文件和 JAR,体积大(一个 Spring Boot fat jar 约 50-100MB)
  • 排除后可以显著减少上下文大小,加速 Docker build 的 "Sending build context" 阶段

为何需要 build-output/ 避开排除?

  • 因为 */target/ 被排除了,所以 Dockerfile 不能直接 COPY MODULE/target/.jar
  • 解决方案:在 Jenkins 的 Maven build 阶段,先将 JAR 复制到 build-output/(不在排除规则内)
  • Dockerfile 再从 build-output/ 复制

 为什么一次构建打两个镜像标签?

docker build -t registry/rag:abc123-33 -t registry/rag:latest .

标签

用途

特点

rag:abc123-33

固定版本标签

git短SHA-构建序号,每次构建唯一,可回滚

rag:latest

当前最新标签

每次构建覆盖,部署时省去改版本号

两个标签指向完全相同的镜像 ID(相同的文件系统层),所以不占用额外空间。这是标准的 CI/CD 做法。


分层 JAR 节省时间的原理

核心就一句话:不变的数据只上传一次,后续构建只上传变化的部分。

Docker 镜像的层结构

没有分层时,一个 Spring Boot JAR 在 Docker 镜像里就是一个整体块:

Spring Boot 镜像(~180MB)
├── 基础 JDK 层(~70MB)          ← 拉取,固定不变
└── fat JAR 层(~100MB)          ← 每次代码变动都得重新推送整个 JAR

用了分层 JAR + Dockerfile 分层 COPY 后:

Spring Boot 镜像(~180MB)
├── 基础 JDK 层(~70MB)          ← 拉取,固定不变
├── dependencies/(~50MB)       ← 第三方依赖,pom.xml 不变就不变
├── spring-boot-loader/(~1MB)   ← Spring Boot 加载器,几乎不变
├── snapshot-dependencies/(~5MB)← SNAPSHOT 依赖,偶尔变
└── application/(~2MB)         ← 你自己的代码,**每次构建唯一变化的部分**
Docker 推送的工作原理

Docker 镜像由一组**内容寻址的层(content-addressable layers)**组成,每层都有一个基于内容的 SHA256 digest:

layer digest = SHA256(layer content)

推送时,Docker 向仓库逐个查询:

"我这里有层 sha256:xxxx,仓库你有吗?"

如果仓库返回 "已有"(Layer already exists),Docker 直接跳过上传,只传一个引用。这就是在构建时你看到的 Layer already exists 的那些行。这里的仓库指的是远程仓库。

前后对比

无分层(fat JAR)

有分层(layered JAR)

首次构建

推 ~100MB

推 ~58MB(依赖 + loader + snapshot + application)

后续构建

推 ~100MB(整个新 JAR)

推 ~2MB(仅 application 层)

构建时间

13.5 min

2.2 min

所以分层架构的关键优势是:dependencies/ 层作为最重的部分,只要 pom.xml 不变化,它的 digest 就完全相同,后续构建只需上传几 MB 的 application 层。 网络传输瓶颈从 "每次都推 100MB" 变成了 "每次都推 2MB",十几分钟就变成了两分钟。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐