第1章:为什么要自己造发布系统
1.1 场景分析:微服务架构下的发布痛点
以一个典型的 Spring Cloud Alibaba 微服务集群为例:系统包含 10 余个微服务,部署在 20 余台物理机和云服务器上。每个服务有独立的 Git 仓库、独立的构建流程、独立的部署路径。
在引入自研发布系统之前,团队的日常发布流程如下:
git pull origin main # 拉取最新代码
mvn clean package -DskipTests # 构建打包
scp target/*.jar root@10.0.1.x:/data/app/ # 上传到目标机器
ssh root@10.0.1.x "cd /data/app && ./restart.sh" # 远程重启
curl http://10.0.1.x:8080/actuator/health # 验证服务是否启动
这个流程存在几个核心痛点:
第一,手动操作链路长,易出错。 每次发布需要手动执行 5-6 个命令,涉及多台机器时需要在不同终端之间切换。假设一次发布涉及 5 台机器,就需要重复执行 20-30 次命令。人的注意力是有限的,重复操作中稍有不慎就可能 scp 到错误的机器,或遗漏某台机器的重启步骤。
第二,多环境配置管理混乱。 项目有 local、dev、test、prod 四套环境,每套环境的数据库连接、Redis 地址、Nacos 地址、第三方 API 密钥都不同。Jenkins 和 GitLab CI 虽然能管理环境变量,但配置分散在 Jenkins 的 Global Properties、项目的 .gitlab-ci.yml、服务器的 application-prod.yml 等多个位置。当环境变量需要变更时,很难一次性确认所有位置都已更新。
第三,发布过程不可视。 手动发布时,只能通过终端输出判断每一步是否成功。如果某台机器重启失败,需要人工检查日志才能发现。发布历史没有统一记录,一周后想回溯"上周三谁发布了 user-service,发布的是哪个版本",只能翻聊天记录和 Shell history。
第四,多服务依赖发布顺序难管理。 微服务之间有明确的调用依赖关系。例如 B 服务依赖 A 服务的接口,C 服务又依赖 B 服务。如果先发布了上游服务而后端接口尚未就绪,就会导致线上调用失败。理想情况是能定义发布依赖图,按顺序自动发布。
第五,回滚困难。 手动发布时,想要回滚到上一个版本,需要记住上一个版本的 jar 包路径、手动停止当前服务、替换 jar、重新启动。整个过程耗时且容易出错。在紧急情况下,每一秒的延迟都意味着用户请求的失败。
1.2 现有方案评估
在决定自研之前,团队对市面上主流的发布工具做了评估。
Jenkins
Jenkins 是目前最成熟的 CI/CD 工具,拥有丰富的插件生态。配置一个典型的 Maven 构建 + SSH 部署流水线,需要安装 Git Plugin、Maven Integration Plugin、Publish Over SSH Plugin 等插件,然后编写类似下面的 Pipeline 脚本:
groovy
pipeline {
agent any
stages {
stage('Checkout') {
steps {
git branch: 'main', url: 'https://gitlab.justgotrip.cn/justgotrip/hotel.git'
}
}
stage('Build') {
steps {
sh 'mvn clean package -DskipTests'
}
}
stage('Deploy') {
steps {
sshPublisher(publishers: [sshPublisherDesc(configName: 'server1',
transfers: [sshTransfer(sourceFiles: 'target/*.jar', remoteDirectory: '/data/app')])])
}
}
}
}
Jenkins 的问题在于:
- 配置繁琐。 每个服务需要单独编写 Pipeline 脚本,10 个服务就是 10 份配置。Pipeline 语法(Groovy)需要学习成本,团队成员不一定都熟悉。
- 界面不够直观。 Jenkins 的 Blue Ocean 界面虽然有所改善,但步骤间的状态展示仍然比较原始。团队成员想要快速了解"user-service 发布到哪一步了",需要点进 Job 页面逐层查看。
- 与 GitLab 集成不深。 Jenkins 通过 Webhook 或轮询感知代码变化,无法直接利用 GitLab 的 Tag、Branch、Merge Request 等版本管理能力。
GitLab CI
GitLab CI 与代码仓库天然集成,.gitlab-ci.yml 随代码一起版本管理。但对于多服务场景,存在问题:
- 配置分散。 每个服务的
.gitlab-ci.yml各自维护,10 个服务就有 10 份配置。当 SSH 密钥或部署路径变更时,需要逐一修改。 - Runner 管理成本。 需要为每个环境(dev/test/prod)维护独立的 GitLab Runner,Runner 本身也需要监控和维护。
- 发布视角受限。 GitLab CI 以"代码仓库"为中心,发布的视角是"某个仓库触发了一次 Pipeline"。而我们需要的视角是"本次发布涉及哪些服务、部署到哪些机器、每一步的状态如何"------这是以"发布任务"为中心的视角。
自研方案
基于以上评估,团队决定自研一套轻量级的发布系统。目标定位明确:
- 一键发布。 选择 Git 版本(Tag/Branch)和目标机器,点击发布按钮,后续的拉代码、构建、上传、重启、验证全部由系统自动完成。
- 步骤可视化。 每个发布任务拆解为多个步骤(Git Clone、Maven Build、rsync、Restart、HTTP WarmUp),每一步的状态(运行中/成功/失败/超时)实时展示在前端页面。
- 失败可追溯。 每一步的标准输出和标准错误完整保存在数据库,发布失败时可以精确定位到哪一个步骤、哪一台机器、报了什么错误。
- 与公司技术栈深度集成。 对接公司内部的 GitLab 实例(通过 gitlab4j-api)、钉钉通知、Nacos 配置中心。
1.3 核心需求抽象
经过内部讨论,团队将发布系统的核心需求抽象为五个环节。
环节一:Git 版本管理
用户在前端选择要发布的服务、选择 Tag 或 Branch,系统自动从 GitLab 拉取对应版本的代码。这里的核心能力是:
- 列出 GitLab 上指定项目的所有 Tag 和 Branch(通过 gitlab4j-api 调用 GitLab REST API)
- 在发布服务器本地维护一个代码缓存目录(
/data/publish/repos),避免每次都全量 clone - 支持 OAuth2 Token 认证,确保私有仓库可以正常拉取
对应源码中的 GitService,核心逻辑在 ensurePath 方法中:
java
public void ensurePath(GitRepository repo) {
Path repoPath = Paths.get(repoBasePath, repo.getName());
repo.setWorkspace(repoPath);
File dir = repoPath.toFile();
if (dir.exists()) {
fetchAndCheckout(repoPath, repo.getCode()); // 增量 fetch
} else {
cloneRepo(repo.getGit(), repo.getCode(), repoPath); // 首次 clone
}
repo.setSyncPath(repoPath);
}
首次发布时执行 git clone -b <branch> 全量拉取,后续发布时执行 git fetch + git checkout 增量更新。这种"本地缓存"策略将第二次发布的代码拉取耗时从分钟级降到秒级。
环节二:构建打包
拉完代码后,根据项目类型(Java Maven 项目 / Angular 前端项目 / Python 项目)执行对应的构建命令:
- Java Maven 项目:
mvn clean package -DskipTests - Angular 前端项目:
npm install && npm run build,然后tar打包 - Python 项目:直接同步源码
构建产物的路径统一记录到 publish_pack 表,供后续部署步骤使用。对应源码中的 PublishPackService 和 WarService.packWar2 方法。
环节三:部署同步
将构建产物分发到目标机器。对于 Java 项目,系统先在本机将 jar 包解压到临时目录,然后通过 rsync 以增量方式同步到远程服务器的目标路径:
java
// DeployService.syncCode 方法中
CmdExecResult result = SyncUtils.rsync(src, dst, svr.getUser(), svr.getHost(), svr.getPass(), type);
使用 rsync 而非 scp 是一个重要设计决策。rsync 只传输差异部分,对于几百 MB 的 jar 包,实际修改的 class 文件可能只有几 KB,rsync 的增量传输能大幅减少网络传输时间。
环节四:远程重启
代码同步完成后,通过 SSH 在远程机器上执行重启命令。不同类型的项目有不同的重启方式:
java
// DeployService.restart 方法中
switch (type) {
case "jar":
doRestart(flow, app); // cd path && ./restart.sh
break;
case "python":
doRestartPython(flow, app); // supervisorctl restart
break;
}
对于 JAR 项目,重启命令从 PublishApp.env 字段读取,这意味着每台机器可以有不同的启动命令(例如不同的 JVM 参数、不同的 Spring Profile)。
环节五:验证与通知
部署完成后,系统自动对目标服务发起 HTTP 请求进行预热验证:
java
// AsyncDeploy.warmUpService 方法中
while (tries++ < 3) {
try {
HttpClientUtils.sendRequest(ping, HttpClientUtils.Method.GET, null);
flow.setStatus(TaskStatus.succ.getName());
warmed = true;
break;
} catch (Exception e) {
log.warn("warmUp {}, tries = {}", ping, tries, e);
}
}
最多重试 3 次,每次间隔 2 秒,验证服务是否正常响应。验证通过后,通过钉钉 Webhook 发送发布通知:
[数据中心prod环境发布记录]
1. 发布原因: 修复订单状态更新Bug
2. 修改模块: user-service
3. 涉及IP: 10.0.1.10, 10.0.1.11, 10.0.1.12
4. 上线版本: v2.0.1
5. 发布后验证人: 张三
1.4 设计哲学
在启动编码之前,团队确定了三条核心设计原则,它们贯穿了整个系统的架构和实现。
原则一:「一键发布」
用户只需要做两件事:选择版本(Tag/Branch)和选择目标机器,其余全部由系统自动完成。这条原则直接决定了系统的核心流程由异步任务驱动------用户提交发布请求后立即返回"任务已创建",后台线程异步执行发布流程,前端轮询任务状态。
对应源码中,PublishTask 实体记录了发布任务的完整信息:
java
@TableName("publish_task")
public class PublishTask extends BaseEntity {
private String type; // 任务类型:pkg / deploy / onekey / rollback
private long bizId; // 关联的业务
private String name; // 任务名称
private String profile; // 环境:local / dev / test / prod
private String editor; // 发布人
private String code; // Git 版本(Tag 或 Branch 名)
private String status; // 状态:create / running / succ / fail
// ...
}
AsyncDeploy.deploy 方法是核心编排入口,标注了 @Async("taskExecutor"),在专用线程池中执行:
java
@Async("taskExecutor")
public void deploy(PublishTask task) {
task.setStatus(TaskStatus.running.getName());
// 1. Git 拉取 → 2. Maven 构建 → 3. rsync 同步 → 4. SSH 重启 → 5. HTTP 预热
}
原则二:「步步可见」
每一步操作都创建一条 PublishFlow 记录,从前端可以看到发布进行到哪一步、每一步的输出是什么。这是通过 FlowFactory 实现的:
java
public class FlowFactory {
private final long taskId;
private int parentId;
public PublishFlow build(String name) {
parentId++;
PublishFlow flow = new PublishFlow();
flow.setTaskId(taskId);
flow.setName(name);
flow.setStep(String.valueOf(parentId));
return flow;
}
}
每执行一个步骤(git clone、maven build、rsync、restart、warmUp),就调用 factory.build(stepName) 创建一条 flow 记录。流程结束时,所有步骤的状态和耗时一目了然。
PublishFlow 实体还做了两项精心的工程处理:
java
public class PublishFlow extends BaseEntity {
private static final int MAX_TEXT_LEN = 2000;
public void setOut(String out) {
this.out = sanitizeField(out); // 自动截断到 2000 字符
}
private static String sanitizeField(String s) {
// 1. 去掉 ANSI 转义码(Maven 构建输出带有颜色控制字符)
// 2. 换行/回车 → 空格(兼容 MySQL Connector/J 9.x 的参数绑定)
// 3. 去掉其余 ASCII 控制字符
// 4. 截断到 2000 字符
}
}
Maven 构建的输出可能达到数万字符,全部存储既不必要也影响数据库性能。截断到 2000 字符足以保留关键的构建信息和错误堆栈。ANSI 转义码的过滤则确保了前端显示的整洁。
原则三:「失败可追溯」
每一步的 stdout 和 stderr 都保存在数据库。发布失败时,不需要登录服务器查看日志,直接在前端页面就能看到完整的错误信息。PublishFlow 的 out 和 err 字段分别存储标准输出和标准错误。
同时,TaskStatus 枚举定义了一套完整的状态机:
java
public enum TaskStatus {
create("create"), // 任务已创建,等待执行
running("running"), // 正在执行
succ("succ"), // 成功
fail("fail"), // 失败
killed("killed"), // 被终止
cancelled("cancelled"), // 被取消(如分布式锁冲突)
timeout("timeout"); // 超时
}
每个状态之间的转换都有明确的语义。一个任务被取消(cancelled)可能是因为同一台机器被另一个发布任务锁定,这与执行失败(fail)是不同的场景,需要向前端传递不同的提示信息。
以上是发布系统从"为什么做"到"做成什么样"的完整思考过程。从下一章开始,我们将进入具体的工程实现------从 Maven 多模块划分、技术选型到核心代码的逐层剖析。