从零构建 Java 17 S2I 镜像并接入 Jenkins 流水线 — 实战全记录

本文完整记录了制作 OpenShift 兼容的 Java 17 S2I(Source-to-Image)构建器镜像、解决国内网络与镜像体积两大痛点、最终接入 Jenkins CI/CD 流水线部署若依(RuoYi-Cloud)微服务项目的全过程。
镜像: docker pull registry.cn-hangzhou.aliyuncs.com/pkyit/s2i:java17
github地址: https://github.com/pkyit/s2i


为什么需要 S2I?

S2I(Source-to-Image)是 OpenShift 提出的"源码到镜像"标准化方案。它的核心理念很简单:开发者只需要提供源代码,构建器镜像负责编译打包并生成可运行的容器镜像,整个过程对开发者透明。

s2i build

assemble 脚本

注入 JAR

run 脚本

源代码

S2I Builder

编译 + 打包

运行时镜像

启动应用

但目前社区能找到的 S2I Java 镜像大多停留在 Java 11,而 Java 17 已经成为 LTS 主流版本。于是决定:自己造一个。


整体架构

整体方案分为三层:S2I 构建器镜像负责编译打包,Lean 运行时镜像负责在生产环境中运行 JAR,Jenkins 流水线串联全流程。
在这里插入图片描述

Jenkins Pipeline

Lean Runtime 镜像 (~160MB)

编译产物

S2I Runtime 基础镜像

S2I Builder 镜像 (466MB)

Alpine 3.21 + OpenJDK 17

Maven 3.9.6

Gradle 8.7

阿里云镜像源

assemble 构建脚本

Alpine 3.21 + OpenJDK 17

应用 JAR

run 启动脚本

Git Checkout

S2I Build 编译

Docker Build 打包

Push 镜像仓库

K8s 部署


第一步:构建 S2I Builder 镜像

基础镜像选择

基础镜像使用 registry.aliyuncs.com/pkyit/java:jdk17,基于 Alpine 3.21.5 + OpenJDK 17.0.17,体积小巧且适合国内网络环境。

Dockerfile 核心设计

Dockerfile 的设计有几个关键点值得关注。

首先是构建工具安装,Maven 和 Gradle 都使用了国内镜像源加海外回退的双保险策略,确保在国内外网络环境下都能稳定下载:

# Maven: 阿里云 Apache 镜像优先,archive.apache.org 回退
RUN curl -fsSL -o /tmp/maven.tar.gz \
       "https://mirrors.aliyun.com/apache/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" \
    || curl -fsSL -o /tmp/maven.tar.gz \
       "https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" \
    && tar xzf /tmp/maven.tar.gz -C /opt \
    && mv /opt/apache-maven-${MAVEN_VERSION} /opt/maven

# Gradle: 腾讯云镜像优先,官方源回退
RUN curl -fsSL -o /tmp/gradle.zip \
       "https://mirrors.cloud.tencent.com/gradle/gradle-${GRADLE_VERSION}-bin.zip" \
    || curl -fsSL -o /tmp/gradle.zip \
       "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
    && unzip -qo /tmp/gradle.zip -d /opt \
    && mv /opt/gradle-${GRADLE_VERSION} /opt/gradle

其次是 OpenShift 兼容性。OpenShift 会以随机 UID 运行容器(属于 root 组,GID=0),所以需要确保目录权限正确:

RUN mkdir -p /deployments /tmp/src /tmp/artifacts /home/s2i \
    && chmod -R g+rwX /deployments /tmp/src /tmp/artifacts /home/s2i

RUN adduser -D -u 1001 -h /home/s2i -s /bin/bash s2i 2>/dev/null || true \
    && addgroup s2i root 2>/dev/null || true

最后是 S2I 标准标签,这些标签告诉 OpenShift 如何找到构建脚本:

LABEL io.openshift.s2i.scripts-url="image:///usr/libexec/s2i" \
      io.openshift.s2i.destination="/tmp" \
      io.openshift.tags="builder,java,java17"
S2I 核心脚本

S2I 镜像的灵魂在于四个脚本:assemble(构建)、run(运行)、usage(帮助)、save-artifacts(增量构建)。

assemble — 自动检测 + 智能构建

assemble 脚本是整个 S2I 流程的核心。它会自动检测项目类型并按优先级选择构建策略:

pom.xml + mvnw

pom.xml

build.gradle + gradlew

build.gradle

无构建文件

源码输入

检测项目类型

Maven Wrapper 构建

Maven 构建

Gradle Wrapper 构建

Gradle 构建

二进制部署

复制 JAR/WAR 到 /deployments

清理源码 + 构建缓存

构建完成

关键代码片段展示了自动检测逻辑:

# 自动检测构建类型
[ -f "${S2I_SOURCE_DIR}/pom.xml" ] && HAS_POM=true
{ [ -f "${S2I_SOURCE_DIR}/build.gradle" ] || \
  [ -f "${S2I_SOURCE_DIR}/build.gradle.kts" ]; } && HAS_GRADLE=true
[ -f "${S2I_SOURCE_DIR}/mvnw" ] && HAS_MVNW=true
[ -f "${S2I_SOURCE_DIR}/gradlew" ] && HAS_GRADLEW=true

# 按优先级执行
if [ "${HAS_POM}" = "true" ]; then
    if [ "${HAS_MVNW}" = "true" ]; then
        build_maven_wrapper    # 优先使用项目自带的 Maven Wrapper
    else
        build_maven           # 使用内置 Maven
    fi
elif [ "${HAS_GRADLE}" = "true" ]; then
    # ... 同理
fi

run — 容器感知 JVM 调优

run 脚本负责在容器环境中智能启动 Java 应用。它会自动检测容器内存限制并计算 JVM 堆大小,同时支持 cgroup v1 和 v2 两种格式:

max_memory() {
    local ratio="${JAVA_MAX_MEM_RATIO:-80}"
    local max_mem=""
    # cgroup v2
    if [ -f /sys/fs/cgroup/memory.max ]; then
        max_mem=$(cat /sys/fs/cgroup/memory.max 2>/dev/null)
    # cgroup v1
    elif [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
        max_mem=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null)
    fi
    if [ -n "${max_mem}" ] && [ "${max_mem}" != "max" ]; then
        echo "-XX:MaxRAMPercentage=${ratio}.0"
    fi
}

此外还支持远程调试(JAVA_DEBUG)、堆转储(JAVA_DIAGNOSTICS)、自定义 GC 策略等高级功能。


第二步:解决国内网络痛点 — 阿里云镜像

在国内使用 Maven/Gradle 构建,最大的痛点就是从 Maven Central 下载依赖极慢。解决方案是在构建器中默认配置阿里云镜像。

Maven 镜像 — assemble 脚本在构建时自动生成 settings.xml

generate_maven_settings() {
    cat > "${settings_file}" <<SETTINGS_XML
<?xml version="1.0" encoding="UTF-8"?>
<settings>
  <mirrors>
    <mirror>
      <id>aliyun-mirror</id>
      <url>${MAVEN_MIRROR_URL}</url>
      <mirrorOf>${MAVEN_MIRROR_OF}</mirrorOf>
    </mirror>
  </mirrors>
</settings>
SETTINGS_XML
}

默认值通过环境变量设置,用户可以随时覆盖:

MAVEN_MIRROR_URL="${MAVEN_MIRROR_URL:-https://maven.aliyun.com/repository/public}"

Gradle 镜像 — 通过 init.d 全局初始化脚本实现:

// /opt/gradle/init.d/aliyun-mirror.gradle
allprojects {
    buildscript {
        repositories {
            maven { url 'https://maven.aliyun.com/repository/public' }
            maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
        }
    }
    repositories {
        maven { url 'https://maven.aliyun.com/repository/public' }
        maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
    }
}

这个脚本在 Dockerfile 构建阶段就预置到了 /opt/gradle/init.d/ 目录中,Gradle 启动时会自动加载,无需项目做任何修改。


第三步:解决镜像体积痛点 — 多阶段构建

初版构建出的 Spring Boot 应用镜像高达 534MB。问题出在哪里?S2I Builder 镜像本身包含 Maven、Gradle、构建缓存,这些东西全部被"继承"到了最终的应用镜像中。
在这里插入图片描述

优化后: 340MB (多阶段构建)

只复制 JAR

Builder 阶段: Maven + 编译

Runtime 阶段: JDK + JAR

初版: 534MB (全部塞进一个镜像)

Maven + Gradle 466MB

构建缓存

源码

JAR 100MB

解决方案是多阶段构建(Dockerfile.lean):第一阶段使用完整的 S2I Builder 编译源码,第二阶段只把 JAR 文件复制到一个纯净的 JDK 运行时镜像中。

# ========== Stage 1: 构建阶段 ==========
FROM registry.cn-hangzhou.aliyuncs.com/pkyit/s2i:java17 AS builder
USER root
COPY . /tmp/src/
RUN /usr/libexec/s2i/assemble

# ========== Stage 2: 运行阶段 ==========
FROM registry.aliyuncs.com/pkyit/java:jdk17
COPY --from=builder /deployments/ /deployments/
COPY --from=builder /usr/libexec/s2i/run /usr/libexec/s2i/run
USER 1001
CMD ["/usr/libexec/s2i/run"]

同时,assemble 脚本中加入了激进的清理逻辑,确保构建阶段不留下任何多余文件:

cleanup_maven_build_artifacts() {
    # 删除 target 目录(编译产物)
    rm -rf "${build_dir}/target" 2>/dev/null || true
}

cleanup_build_cache() {
    # 清理 Maven 本地仓库
    rm -rf "${MAVEN_LOCAL_REPO}" 2>/dev/null || true
    # 清理 Gradle 缓存
    rm -rf "${GRADLE_USER_HOME}/caches" 2>/dev/null || true
    rm -rf "${GRADLE_USER_HOME}/daemon" 2>/dev/null || true
}

cleanup_source() {
    # 删除源代码
    rm -rf "${S2I_SOURCE_DIR:?}"/* 2>/dev/null || true
}

效果对比非常显著:

构建方式 镜像体积 包含内容
标准模式 534 MB Maven + Gradle + 缓存 + 源码 + JAR
Lean 多阶段 340 MB JDK + JAR(生产运行所需的最小集)
缩减幅度 36% 构建工具完全隔离在 Builder 阶段

第四步:多架构构建(amd64 + arm64)

现代基础设施越来越多地混合使用 x86 和 ARM 服务器。为了让 S2I 镜像同时支持两种架构,使用了 Docker buildx + QEMU 方案。

# 注册 QEMU 用户态模拟(支持 arm64 构建)
docker run --privileged --rm tonistiigi/binfmt --install arm64

# 创建多架构 builder
docker buildx create --name s2i-builder --use

# 构建并推送双架构镜像
docker buildx build \
    --platform linux/amd64,linux/arm64 \
    -t registry.cn-hangzhou.aliyuncs.com/pkyit/s2i:java17 \
    --push .

这里踩了一个坑:arm64 构建在 QEMU 模拟环境下从 archive.apache.org 下载 Maven 超时(连接耗时超过 128 秒)。解决方案就是前面提到的国内镜像优先策略,让 QEMU 环境也能快速下载构建工具。


第五步:接入 Jenkins 流水线 — 若依微服务实战

为了验证 S2I 镜像的实际可用性,选择了一个真实的微服务项目来实战检验:若依(RuoYi-Cloud),一个基于 Spring Cloud Alibaba 的微服务框架,包含 7 个后端服务和 1 个前端。

改造前的流水线

原始 Jenkins 流水线的 Java 构建阶段直接使用一个包含 Maven 的 JDK 镜像编译,然后为每个服务单独 docker build,每个服务都有自己的 Dockerfile(基于 eclipse-temurin:17-jre):

// 改造前
stage("Build Java") {
    steps {
        sh "docker run --rm -v ${HOST_BUILD_DIR}:/workspace ... mvn clean package ..."
    }
}
stage("Build Images") {
    steps {
        sh '''
            cp ruoyi-gateway/target/ruoyi-gateway.jar docker/ruoyi/gateway/
            docker build -t ${REGISTRY}/ruoyi-gateway:test docker/ruoyi/gateway/
            # ... 每个服务都要手动复制 JAR + docker build
        '''
    }
}

每个服务还需要维护一份独立的 Dockerfile,增加了维护成本。

改造后的流水线

改造后的流水线使用 S2I 镜像作为统一的构建和运行时基础:

pipeline {
    agent any
    environment {
        REGISTRY    = "registry.cn-hangzhou.aliyuncs.com/pkydocker"
        S2I_BUILDER = "registry.cn-hangzhou.aliyuncs.com/pkyit/s2i:java17"
        S2I_RUNTIME = "registry.cn-hangzhou.aliyuncs.com/pkyit/s2i:java17"
    }
    stages {
        stage("Build Backend (S2I)") {
            steps {
                sh '''
                    docker run --rm --user root \
                        -v ${HOST_BUILD_DIR}:/workspace -w /workspace \
                        -v ${HOST_M2}:/root/.m2 \
                        ${S2I_BUILDER} \
                        sh -c '
                            cat > /tmp/settings.xml << SETXML
<?xml version="1.0" encoding="UTF-8"?>
<settings><mirrors><mirror><id>aliyun</id>
  <url>https://maven.aliyun.com/repository/public</url>
  <mirrorOf>central</mirrorOf></mirror></mirrors></settings>
SETXML
                            mvn clean package -P test -DskipTests -T 1C \
                                -Dmaven.repo.local=/root/.m2 \
                                -s /tmp/settings.xml -e --batch-mode
                        '
                '''
            }
        }

        stage("Build Backend Images (S2I Runtime)") {
            steps {
                sh '''
                    for pair in \
                        "ruoyi-gateway:ruoyi-gateway/target/ruoyi-gateway.jar" \
                        "ruoyi-auth:ruoyi-auth/target/ruoyi-auth.jar" \
                        "ruoyi-system:ruoyi-modules/ruoyi-system/target/ruoyi-modules-system.jar" \
                        "ruoyi-gen:ruoyi-modules/ruoyi-gen/target/ruoyi-modules-gen.jar" \
                        "ruoyi-job:ruoyi-modules/ruoyi-job/target/ruoyi-modules-job.jar" \
                        "ruoyi-file:ruoyi-modules/ruoyi-file/target/ruoyi-modules-file.jar" \
                        "ruoyi-monitor:ruoyi-visual/ruoyi-monitor/target/ruoyi-visual-monitor.jar"; do

                        name="${pair%%:*}"
                        jarfile="${pair##*:}"
                        dir=$(dirname "${jarfile}")

                        # 内联生成 Dockerfile,无需为每个服务维护单独文件
                        cat > ${dir}/Dockerfile.s2i << DEOF
FROM ${S2I_RUNTIME}
USER root
COPY $(basename ${jarfile}) /deployments/$(basename ${jarfile})
RUN chown 1001:0 /deployments/$(basename ${jarfile}) && chmod g+rw /deployments/$(basename ${jarfile})
USER 1001
ENV SPRING_PROFILES_ACTIVE=test \\
    JAVA_MAX_MEM_RATIO=75
CMD ["/usr/libexec/s2i/run"]
DEOF

                        docker build -t ${REGISTRY}/${name}:test \
                            -f ${dir}/Dockerfile.s2i ${dir}/
                    done
                '''
            }
        }
    }
}

改造后的核心变化有三点:

第一,构建阶段统一使用 S2I Builder 镜像,Maven/Gradle 版本、阿里云镜像配置都内置在镜像中,实现了构建环境的标准化。

第二,运行时镜像统一基于 S2I Runtime,每个服务不再需要维护独立的 Dockerfile,只需要内联生成一个简单的 Dockerfile(只有 8 行),把 JAR 复制到 /deployments/ 即可。

第三,JVM 调优由 S2I 的 run 脚本自动处理,容器内存感知、GC 策略、堆转储等功能开箱即用,无需在每个服务的启动脚本中重复配置。


验证结果

Jenkins 构建 #14 执行成功,耗时 486 秒。7 个后端服务全部使用 S2I 镜像重新构建并部署到 Kubernetes 集群。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

K8s Pod 状态
NAME                             READY   STATUS    RESTARTS   AGE
ruoyi-gateway-6fcb6b45f8-zk5hj   1/1     Running   0          77s
ruoyi-auth-85bb4c6774-dbcnh      1/1     Running   0          5m
ruoyi-system-555dfbdc48-vhwrh    1/1     Running   0          5m
ruoyi-gen-7d9bc77559-fmxlr       1/1     Running   0          5m
ruoyi-job-57d7c659f8-w7kb4       1/1     Running   0          5m
ruoyi-file-84d9cb775d-55hvv      1/1     Running   0          5m
ruoyi-monitor-784ff4bb7b-rg2cf   1/1     Running   0          5m

所有服务 7/7 Ready,0 次重启。

端到端 API 验证

通过 Gateway NodePort(30080)验证微服务链路:

测试接口 预期结果 实际结果
GET /code(验证码) 200 + uuid + 图片 200 OK
POST /auth/login Gateway 路由到 Auth 返回"验证码不能为空"
GET /auth/logout Gateway 路由到 Auth 200 OK(提示需要 POST)
Monitor :30090 401 未认证 401 Unauthorized

各服务启动日志也确认了 Spring Boot 应用正常初始化:

(♥◠‿◠)ノ゙  认证授权中心启动成功   ლ(´ڡ`ლ)゙
(♥◠‿◠)ノ゙  系统模块启动成功   ლ(´ڡ`ლ)゙
(♥◠‿◠)ノ゙  若依网关启动成功   ლ(´ڡ`ლ)゙

踩坑记录

整个过程中遇到了几个典型问题,记录下来供参考。

坑 1:Bash 函数定义顺序

assemble 脚本最初版本中,函数定义在主执行逻辑之后,导致执行时报 command not found。Bash 不像 JavaScript 有函数提升(hoisting),所有函数必须在使用之前定义。解决方案是将所有函数定义移到脚本顶部的 # Functions 区域。

坑 2:CRLF 换行符

在 Windows 上创建的文件上传到 Linux 服务器后,/bin/bash^M 无法执行。解决方法是在服务器上用 sed -i 's/\r$//' file 转换。建议 CI/CD 项目中始终配置 .gitattributes 强制 LF 换行。

坑 3:Dockerfile 不支持 Shell heredoc

Docker 的 RUN 指令不支持 Shell 的 <<'EOF' heredoc 语法。最初尝试在 Dockerfile 中用 heredoc 内联生成 Gradle init 脚本,结果报 unknown instruction 错误。解决方案是将内容提取为独立文件,用 COPY 指令复制到镜像中。

坑 4:QEMU arm64 构建网络超时

在 QEMU 模拟的 arm64 环境中,curl 连接 archive.apache.org 超时(>128秒)。这是因为 QEMU 用户态模拟的网络性能远低于原生环境。解决方案是使用国内镜像源(阿里云、腾讯云)作为首选,海外源作为回退。

坑 5:Gateway Pod 未自动更新

由于所有服务都使用 :test 固定 tag,当新镜像推送后 K8s 的 Deployment spec 没有变化,不会触发滚动更新。对 ruoyi-gateway 手动执行了 kubectl rollout restart 解决。生产环境建议使用镜像 digest 或带时间戳的 tag。


项目文件清单

https://github.com/pkyit/s2i
https://gitee.com/pengkaiyan/RuoYi-Vue (jenkins脚本在这里,git提交记录第一次是用传统流水线实现的,第二次是用s2i构建容器)

s2i-build/
├── Dockerfile                    # S2I Builder 镜像定义
├── Dockerfile.lean               # 多阶段 Lean 构建模板
└── s2i/
    ├── aliyun-mirror.gradle      # Gradle 阿里云镜像初始化脚本
    └── bin/
        ├── assemble              # 构建脚本(核心,~350行)
        ├── run                   # 运行时启动脚本(~150行)
        ├── save-artifacts        # 增量构建支持
        ├── usage                 # 帮助入口
        └── usage.txt             # 帮助文档

总结与展望

通过这次实践,我们实现了一套完整的 Java 17 S2I 解决方案:

  • 构建器镜像:内置 Maven 3.9.6 + Gradle 8.7,默认阿里云镜像,支持 Maven/Gradle/Wrapper/二进制四种构建模式自动检测
  • 多架构支持:amd64 + arm64 双架构,适配 x86 服务器和 ARM 服务器
  • Lean 运行时:多阶段构建将镜像体积从 534MB 降至 340MB(减少 36%)
  • 容器化 JVM 调优:自动感知 cgroup 内存限制,开箱即用的 GC 策略、堆转储、远程调试
  • CI/CD 集成:成功接入 Jenkins 流水线,部署若依微服务 7 个后端服务全部正常运行

下一步可以考虑的方向包括:引入 Kaniko 实现 Jenkins 中无 Docker daemon 构建;集成 Trivy 进行镜像安全扫描;以及探索 distroless 运行时镜像进一步减小体积。

如果你也在寻找 Java 17 的 S2I 方案,或者希望统一团队的构建运行时标准,相信这篇文章能给你一些参考。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐