Java 平台应用持续集成:自底向上实践指南
不同的开发和交付场景需要不同的持续集成方案,在这一问题上没有银弹。
基础平台
对于团队,Jenkins 可能是通用项目的不二之选,它提供了对 Gitlab Merge 请求、发版等场景的自动测试和存档、部署支持。基于分布式 Worker 的架构提升了 Jenkins 的灵活性,但倘若你真的面临在不同架构和平台下进行测试和构建的需求,就会发现 Jenkins 过于笨重,环境配置动不动就出现问题,烦心事一堆。
在一些情况下,Github Actions 可能也是一个不错的选择:基于 .yaml 描述文件,定义所需的环境,平台会自动拉取所需镜像,为代码执行测试、构建并将结果存档。除了调试极其困难以外,几乎免费的网络、计算和存储资源很难说不是一个好的选择 —— 除了要考虑如何集成外。我开发了一个小工具 ci-transfer 就是配合这种场景设计的,它允许将构建好的产物发送到虚拟主机并执行部署脚本,也支持发送到阿里云 OSS 对象存储,实现完整的 CI/CD 闭环。
./ci-transfer
-s deploy/report_latest.zip \
-d "user:password@192.168.1.100:/root/report_latest.zip" \
--precommands "rm -f /root/report_latest.zip" \
-c "/root/deploy.sh"
deploy.sh
脚本大概如下所示,停止旧进程,然后基于新的构建产物拉起新进程运行。
#!/bin/bash
LATEST_ZIP=$(ls -t report_*.zip | head -n1)
unzip -o "$LATEST_ZIP"
PID=$(pgrep -f "java -jar report-system.jar")
if [ ! -z "$PID" ]; then
kill $PID
fi
nohup java -jar report-system.jar >/dev/null 2>&1 &
sleep 1
tail -f report-system.log
对于复杂场景,可以使用构建脚本配合宿主环境实现复杂逻辑处理。比如前后端分离的应用,前后端需要在不同环境中执行不同构建,并将产物合并。以一个 Spring Boot 应用和 Vite 项目的 Gradle DSL 构建为例,构建需要执行前端 tsc 和 pnpm 编译,然后将产物同步到 Spring Boot 的 resources 目录,然后调用 Plugin 打包为 FatJar 并压缩为 zip 包以得到产物。
tasks.register("releaseFrontEnd") {
exec {
workingDir(viteProjectDir)
commandLine("pnpm", "install")
}
exec {
workingDir(viteProjectDir)
commandLine("pnpm", "run", "release")
}
sync {
from(viteOutputDir)
into(springResourcesDir)
}
}
tasks.register<Copy>("releaseBackend") {
dependsOn("bootJar")
val deploymentDir = Paths.get(deployDir).toFile()
val reportDir = File(deploymentDir, "report")
into(deploymentDir)
doFirst {
reportDir.mkdirs()
reportDir.listFiles()?.forEach { it.deleteRecursively() }
}
from(project.sourceSets["main"].resources.srcDirs) {
include(prodConfigFile)
rename(prodConfigFile, "application.yaml")
into("report")
}
from(layout.buildDirectory.dir("libs")) {
include("${project.name}.jar")
into("$deployDir/report")
}
finalizedBy("zipReport")
}
云原生平台
云原生平台的持续集成和部署方案很多,且标准化程度较高,一方面,容器这一抽象抹平了环境的差异,允许以通用的方式基于描述文件执行构建和测试。另一方面,云原生是在 DevOps 理念基础上发展起来的,它强调开发与运维的紧密协作:代码提交即测试、构建和自动集成。最后,云原生平台具有“基于 Kubernetes 数据中心抽象和 Istio 等服务治理工具,服务允许被动态路由到不同部署软件版本流量”的运维特点。这些因素共同促成了云原生应用的持续集成和部署的繁荣生态。
从问题解决的视角看,云原生只是提供了标准的运行环境,但并没有解决持续集成场景“千人千面”的问题:对于部署,一些项目(比如 Clojure)不需要编译,只需要 Git 仓库拉取最新代码,基于带有 Maven 依赖的 Leiningen 和 JRE 运行时的镜像即可运行。而另一些项目(比如 Spring 应用)则可能需要先编译并打包为 FatJar,然后使用标准 JRE 镜像运行。此外,大部分情况下,可执行文件直接打好了镜像,需要拉取指定 Tag 标签并运行。对于测试,一些情况下自动化测试是必要的,测试后会立刻部署,但测试平台不一定和部署平台衔接紧密,在另一些情况下,可能依赖测试人员进行集成测试,部署的时间点并不明确。
考虑一套完成的 CI/CD 流程:向 Gitea 提交并由 Github 同步的代码应该先在 Github Action 等 CI 平台执行测试,并构建存档,之后触发 Rancher API 以重新部署 Kubernetes 工作负载,并在其准备就绪后对流量进行切换。在这一过程中,测试平台的产物上传以及运行时获取镜像是个问题,通常需要搭建一套容器镜像仓库 Container Registry,在构建结束后通过 docker push 将镜像上传到仓库,之后 Rancher 拉取对应 Tag 的镜像并创建容器。
这对于大型项目来说很合适,但对于个人或小团队而言则太“笨重” —— 一方面,维护公网可用的网络传输的容器仓库成本极高,另一方面,很多项目可能是混合部署,即不一定有标准的 Kubernetes 环境,或者 Docker 运行时,可能只有老旧的 JDK 1.6 平台和早已淘汰的 CentOS 7。
Java 平台本身就非常标准化,因此自底向上的解决方案是:以代码和构建产物为先,镜像仅提供运行时和工具,构建产物既可以部署在裸金属服务器的 JRE 虚拟机中,也可以部署在云原生平台的容器中运行。基于这种思路,我使用 Rust 开发了几个小工具,以替代持续集成过程中上传镜像到仓库以及云原生平台从仓库拉取镜像的操作。
简单来说,webhook-git-updater 用于作为初始化容器从 Git 仓库拉取代码并共享给容器使用,这适合不需要编译的 Clojure Web 项目,而 oss-res 则用于作为初始化容器从阿里云对象存储 OSS 下载构建产物 —— FatJar 包,然后挂载到 JRE 镜像中运行。对于单元测试后的产物上传,上述介绍过的 ci-transfer
工具可将构建的产物上传到阿里云对象存储 OSS 中。对于不需要单元测试的场景,手动运行 Gradle 脚本,直接调用本地 ossutil
上传构建产物到阿里云对象存储 OSS 即可。
自动更新代码
webhook-git-updater 的使用很简单,其可以作为 Sidecar 服务运行,当请求其 /git/sync 端点时,其会自动从远程的 HOOK_GIT_URL 仓库拉取 HOOK_GIT_BRANCH 分支到和主服务共享的 emptyDir 容器,以刷新代码。在 Kubernetes 集群中作为初始化容器,当重新部署 Deployment 时自动从远端仓库拉取最新代码并交给主容器运行。
docker run -it --rm \
-p 8080:8080 \
-v ../repo:/app/repo \
-e HOOK_LOCAL_DIR=repo \
-e HOOK_USER=admin \
-e HOOK_PASSWORD=123 \
-e HOOK_GIT_URL=https://git.abc.com/user/cyberMe \
-e HOOK_GIT_BRANCH=cyber-me \
-e HOOK_GIT_USER=corkine \
-e HOOK_GIT_PASSWORD=5fb07a7416462 \
corkine/webhook-git-updater:latest
自动更新构建产物
oss-res 作为初始化容器访问 OSS 并下载压缩包,然后和 JRE 运行时共享 emptyDir 容器,oss-res 会自动解压压缩包,也可以根据在线 MD5 验证并跳过未变更内容的重复下载,之后 JRE 运行时镜像会自动根据命令启动 Java 进程。在 Kubernetes 集群中,作为初始化容器获取资源并配合 JRE 镜像主容器运行。
docker run --rm -i -v .:/app corkine/oss-res:0.0.1 \
--oss-config=eyJvc3NfYnVja2V0IjoUIn0= \
--file=/project-report/report-system.zip \
--output=/app \
--unzip \
--cache
基于这些工具,当不需要单元测试的时候,可直接由 Gitea 事件触发 Webhook 并调用 Rancher API 重新部署 Deployment,其通过初始化容器,要么使用 webhook-git-updater 更新仓库代码,要么使用 oss-res 下载构建产物,然后交给容器运行。当需要单元测试的时候,Github Action 测试并构建完毕后通过 ci-transfer 上传产物,并通过同样方法调用 Rancher API 以触发 Deployment 的重新部署。
总的来说,兼顾云和非云平台的全自动持续测试、集成和部署操作并不麻烦:其核心在于通过 Git 事件(和 Github Action 脚本)触发 Rancher API 以进行 Deployment 重新部署。这一过程的具体实现有两种:使用 ossutil、ci-transfer 和 oss-res 实现构建产物上传下载、使用 webhook-git-updater 实现 Git 仓库代码下载,或者使用容器镜像仓库通过 docker push 和 docker pull :tag 传递镜像(不支持容器直接基于产物运行,必须经过 CI 平台构建镜像),前者以损失回退到特定版本镜像的代价满足了覆盖场景更广的集成需求(基于代码部署),且很好的兼顾了原生和非云原生平台(基于构建产物部署)。