前言:这篇文章解决什么问题?
如果你是一个正在成长的开发者,大概率有过这样的困惑:
- 看了无数「Spring Boot 入门教程」,学会了写 Controller、Service、Mapper,但没人教你怎么把服务部署到生产环境。
- 用过 Jenkins、GitLab CI,但它们只管到「构建出一个制品」,后面怎么推到服务器、怎么重启、怎么验证------还是靠手工。
- 团队规模到了 5-10 人,服务有了七八个,机器有十几台,你开始隐约觉得「需要一个东西来管发布」,但不知道这个东西该长什么样。
这篇文章不会给你一份可运行的完整代码。它要做的是更前置的事情:带着你,像一个产品技术负责人一样,从零推演一个发布系统的设计。
你会看到:
- 发布这件事,拆开来看究竟包含哪些步骤
- 每一步面临什么设计选择,为什么选 A 不选 B
- 一个「发布系统」的数据模型怎样从模糊变清晰
- 哪些地方最容易踩坑,以及如何避免
不需要先看任何代码,只需要你对「发布上线」这件事有基本认知。
预计阅读时间:35 分钟。
第一章:先定义问题 ------「发布」到底是一个什么过程?
1.1 别急着写代码,先把流程画出来
任何一个系统设计的起点,都不是数据库表结构,更不是技术选型。而是把你要自动化的事情,先用自然语言描述清楚。
我们试着描述一次典型的「手工发布」:
工程师Terry要把 xxx-service 的 v2.3.1 版本发布到 5 台生产机器上。
1. 打开终端,cd 到项目目录
2. git checkout v2.3.1
3. mvn clean package -DskipTests(等了 3 分钟)
4. 打开另一个终端
5. scp target/x x x-service.war user@10.0.1.12:/data/tomcat/webapps/
6. ssh user@10.0.1.12 "/data/tomcat/service.sh restart"
7. curl http://10.0.1.12:8080/health(等返回 200)
8. 回到第 5 步,换 10.0.1.13
9. ...重复 5 次
10. 发消息到群里:「xxx-service v2.3.1 已上线」
这个过程隐含了大量信息。我们把它结构化一下:
| 阶段 | 做什么 | 涉及什么 | 可能出什么问题 |
|---|---|---|---|
| 获取代码 | git checkout 指定版本 | Git 仓库、网络 | 仓库不存在/分支输错/网络超时 |
| 构建 | mvn/npm 打包 | 构建工具、本地环境 | 编译失败/依赖下载失败/构建超时 |
| 传输 | 把制品弄到目标机器 | SSH、网络、磁盘 | 磁盘满/网络断/传错目录 |
| 重启 | 停旧服务、启新服务 | 进程管理、端口 | 启动失败/端口冲突/没关干净 |
| 验证 | 确认服务可用 | HTTP 接口 | 启动慢/返回 500/根本没起来 |
| 记录 | 告知团队 | 沟通渠道 | 忘了通知/通知写错版本号 |
好,现在你已经有了一张「发布流程六阶段」的草图。接下来我们要问自己一个问题------
1.2 这个系统要给谁用?解决什么场景?
用户是谁? 团队的开发者(不是运维,不是 SRE)。
核心场景是什么?
- 场景 A(手动发布):开发者打开一个 Web 页面,选择一个版本号,勾选几台机器,点击「发布」按钮。系统自动完成后续所有步骤,并在页面上实时展示每步进度。
- 场景 B(自动发布) :开发者 push 代码到 GitLab 的
master分支,系统自动触发构建 + 部署到测试环境。不需要登录任何页面。 - 场景 C(回滚):发现刚才的版本有 bug,点击「回滚」,系统把上一次部署的版本重新部署上去。
不做什么?
- 不做容器编排(不是 Kubernetes)
- 不做 CI 流水线编排(不是 Jenkins pipeline)
- 不做审批流程引擎(不是发布工单系统)
明确边界,是「产品思维」的第一步。
1.3 整体架构草图
有了流程和场景,我们可以画一个概念级架构:
┌────────────────────────────────────────────────┐
│ Publish 服务 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
│ │ Web 页面 │ │ 任务调度 │ │ SSH 执行引擎 │ │
│ │(手动触发) │ │(队列消费)│ │(远程命令) │ │
│ └──────────┘ └──────────┘ └─────────────┘ │
│ │ │ │ │
│ ┌─────┴─────┐ ┌─────┴──────┐ ┌─────┴──────┐ │
│ │GitLab API │ │ 构建引擎 │ │ 状态追踪 │ │
│ │(拉版本列表)│ │(Maven/npm) │ │(每一步记录) │ │
│ └───────────┘ └────────────┘ └────────────┘ │
│ │
└────────────────────┬───────────────────────────┘
│ SSH / rsync
▼
┌────────────────────────┐
│ 目标服务器集群(1~N) │
│ Tomcat / JAR / Python │
└────────────────────────┘
这个架构的关键词有三个:触发 (人 or Webhook)、执行 (构建 + 传输 + 重启)、追踪(每一步可见)。
第二章:数据模型 ------ 把「发布」翻译成结构化的信息
2.1 先回答五个问题
在画数据库表之前,用自然语言回答这五个问题。每个问题对应一张核心表:
| 问题 | 答案 | 对应概念 |
|---|---|---|
| 发什么? | 一个 Git 仓库 + 一个版本号 | 业务(Biz) |
| 发到哪? | 一台机器的一个目录 | 实例(App) |
| 谁发的? | 一次操作,包含目标版本和目标机器 | 任务(Task) |
| 过程怎样? | 每一步(拉代码→构建→传输→重启→验证) | 步骤(Flow) |
| 结果如何? | 构建产物的路径 + 版本号 | 制品(Pack) |
这五个概念的直觉关系是:
一个业务(Biz)
├── 有多个部署实例(App)
├── 有多次发布任务(Task)
│ └── 每次任务有多条步骤记录(Flow)
└── 有多个构建制品(Pack)
不需要急着写 SQL。先让这个关系在脑子里跑通,确保能回答「用户从选择版本到看到部署结果」全链路上的一切问题。
2.2 核心概念的属性拆解
业务(Biz)------定义一个「可发布的东西」
一个业务对应一个 Git 仓库。它的核心属性不是技术字段,而是差异性配置:
- 类型(type) :这个业务是什么技术栈?决定了构建命令和重启方式。可能的值:
java-war、java-jar、angular、python、custom。这是整个系统的策略选择器。 - 模块名(module):如果 Git 仓库是 monorepo,具体要构建哪个子模块?空值表示构建根项目。
- 预热 URL(warmUp):部署完成后用哪些 URL 验证服务?同一个业务的所有机器共享相同的预热路径。
- 构建命令(preScript):如果不为空,覆盖默认构建命令。为空则根据 type 自动推导。
思考点 :type 字段看起来简单,但它是整个系统分支逻辑 的入口。新增一种技术栈支持的代价只是一系列
switch(type)的新增分支。要考量的是:你的系统真的需要支持那么多类型吗?每加一种,测试矩阵翻一倍。
实例(App)------定义「代码最终跑到哪里」
- IP + 端口 + 路径 :唯一确定一个部署目标。路径格式如
/data/tomcat/webapps/ROOT。 - 环境标识(profile) :
dev/test/prod。决定哪些机器在同一个环境池里。 - 占用锁(locker):当前谁正在部署这台机器?空值表示空闲。
- 当前版本(code):这台机器上正在运行的是哪个版本(tag/branch name)。
思考点 :
locker字段做的是乐观锁思路------不是禁止操作,而是让后来者看到「有人正在部署这台机器」。但如果部署进程崩溃了,锁没释放怎么办?答案是超时机制------超过一定时间(比如 10 分钟)自动释放。
任务(Task)------定义「一次发布操作」
- 版本号(code):要发布哪个 git tag/branch
- 类型(type) :
pkg(只打包不部署)、deploy(只部署不打包)、onekey(一键打包+部署)、rollback(回滚到上一个版本) - 状态(status) :
create → running → succ/fail/killed/cancelled/timeout - 目标机器列表(processes) :
ip:path, ip:path, ... - 间隔时间(interval):多机器部署时,两台之间的等待秒数
思考点 :为什么要把
processes存成一个逗号分隔的字符串而不是一张关联表?因为发布任务创建后,目标机器一般不会变。反范式化在这里换取的是查询和展示的便利------不需要 JOIN。
步骤(Flow)------定义「任务执行的每一步」
这是整个系统最有设计价值的一张表。每个 Task 下挂 N 条 Flow 记录:
Task #42: 把 v1.5.0 部署到 3 台机器
├── Flow #1: "git checkout v1.5.0" [succ, 耗时 3.2s]
├── Flow #2: "mvn clean package" [succ, 耗时 127.5s]
├── Flow #3: "rsync 10.0.1.12" [succ, 耗时 8.1s]
├── Flow #4: "restart 10.0.1.12" [succ, 耗时 12.3s]
├── Flow #5: "预热 10.0.1.12:8080/health" [fail, 耗时 30.0s] ← 这里挂了
└── ...
为什么这很重要?因为发布出问题的时候,你需要的不是「失败」两个字,而是「哪台机器的哪一步」失败了。
核心设计决策:很多人会问「为什么不直接记日志,非要建一张表?」------日志是给人 grep 的,结构化数据是给人分析和展示的。当你的「发布进度条」需要一个 UI 来渲染时,建表比解析日志文件高效得多。
2.3 状态机的设计
任何一个有状态的对象,你都要画出它的状态流转图:
┌───────────┐
┌───────►│ cancelled │ (被并发锁拒绝)
│ └───────────┘
│
┌────┴────┐ ┌─────────┐ ┌──────┐
│ create │────►│ running │────►│ succ │
└──────────┘ └────┬────┘ └──────┘
│
├──► fail (任何步骤出错)
├──► killed (用户手动终止)
└──► timeout (超过最大执行时间)
需要特别注意 create 状态。一个任务在进入线程池之前是 create,它应该被视为「进行中」------因为用户已经提交了发布请求,期望它被执行。如果把 create 当成「未开始」,就会有竞态问题:用户在等结果的时间里又提交了第二个同名任务。
用伪代码表达:
java
// 状态枚举的核心判断
enum TaskStatus {
create, running, succ, fail, killed, cancelled, timeout
// create 也被视为进行中
boolean isActive() {
return this == create || this == running
}
}
第三章:从 Git 到制品 ------ 代码怎么变成可部署的东西
3.1 你要对接 GitLab,但不要耦合
发布系统需要和 Git 仓库交互:列出 tags、列出 branches、获取项目信息、接收 push 事件的 webhook 回调。
这里面有一个关键设计原则:把 GitLab 当成一个「外部数据源」,而不是系统的一部分。
怎么做到?定义一个抽象的 Git API 层,所有 Git 操作都通过它:
java
// 不是真实代码,是接口设计的思维雏形
interface GitRepositoryAPI {
// 获取可以发布的版本列表(tags + branches)
List<VersionRef> listDeployableVersions(projectId)
// 搜索项目
Project findProject(gitUrl)
// 验证用户是否有权限操作该项目
boolean checkAccess(userToken, projectId)
}
具体实现用 GitLab4J SDK,但调用方只依赖这个接口。好处是:如果哪天换到 GitHub / Gitee,只需要换一个实现类。
3.2 双 Token 体系:解决「谁有权看到什么」的问题
Git 操作涉及两个权限维度:
| 维度 | Token 类型 | 用途 |
|---|---|---|
| 用户可见范围 | 个人 Token | 页面展示 tags/branches 时,只显示这个用户有权限看到的 |
| 系统操作范围 | Admin Token | 自动部署时,可能操作用户没有直接权限的项目 |
两个 Token 分开管理,上层调用时根据场景选择。伪代码思路:
java
class GitService {
// 用户 Token:用于展示列表
List<Tag> listTags(userToken, projectId) { ... }
// Admin Token:用于自动部署
void createTag(projectId, tagName) { ... }
}
3.3 版本号的自动递增
发布时经常要从上一个 tag 自动生成下一个 tag:v1.2.3 → v1.2.4。与其引入完整的 SemVer 库,不如写一个 15 行的版本递增工具:
java
// 伪代码:版本号递增逻辑
String nextVersion(currentTag) {
// 解析 v{major}.{minor}.{patch}
// patch++,patch >= 100 则进 minor,minor >= 100 则进 major
// 边界保护:万一没匹配到格式,从 v1.0.0 开始
}
取舍 :不实现完整的 SemVer(pre-release、build metadata 等),因为团队内部的 tag 格式就是
vX.Y.Z。覆盖 99% 场景的简单方案,比覆盖 100% 场景的复杂方案,更好维护。
3.4 Webhook:push 事件驱动的自动部署
GitLab 在你 push 代码后会向一个 URL 发 HTTP POST。收到后怎么处理?
设计决策:不要在处理 Webhook 的线程里做部署。
为什么?Webhook 的 HTTP 请求有超时限制(通常 10 秒)。如果直接在回调线程里 clone → build → deploy,三五分钟过去了,GitLab 那边早就超时重试了。
正确做法:回调线程只负责「验证 + 创建任务 + 入队」,然后立刻响应 200。真正的部署由后台消费者慢慢做。
Webhook 线程(<10s) 后台消费线程(可几分钟)
───────────────── ────────────────────
收到 push 事件 从队列取任务
│ │
验证项目+分支 执行构建
│ │
创建 Task 执行部署
│ │
Task 入队 更新 Task 状态
│
返回 200 OK
这个「生产者-消费者」模式用 Java 内置的 LinkedBlockingQueue 加一个 @Scheduled 定时消费就够了,不需要引入 RabbitMQ。场景简单,内存队列足矣。
3.5 Git 本地缓存:做还是不做?
构建需要 clone 代码。每次都 clone 太慢(几十 MB 到几 GB),但不 clone 又没法构建。
折中方案:在 Publish 服务器上维护一个 Git 本地缓存。
- 首次:
git clone - 后续:
git fetch origin && git checkout <tag> - 保留
target/目录和 Maven 本地仓库(~/.m2),利用增量编译加速
潜在问题:随着业务增多,磁盘占用会持续增长。未来可能需要加一个 LRU 清理策略(超过 30 天未发布的业务的缓存自动删除)。
第四章:构建引擎 ------ 把源代码变成可部署的制品
4.1 构建命令不能写死在代码里
不同的项目用不同的构建工具:Maven、Gradle、npm、yarn、甚至自定义脚本。
设计原则:提供一个默认推导 + 允许自定义覆盖。
伪代码思维:
java
String resolveBuildCommand(Biz biz) {
// 1. 用户明确指定了构建命令 → 直接用
if (biz.preScript != null) return biz.preScript
// 2. 根据技术栈类型推导
switch (biz.type) {
case "java-war":
case "java-jar":
return "mvn clean package -DskipTests"
case "angular":
return "npm install && npm run build"
default:
throw error("未知类型,请配置 preScript")
}
}
4.2 Monorepo 的构建策略
如果 Git 仓库是一个多模块的 Maven 项目(一个仓库十几二十个子模块),每次构建不需要全部打包。Maven 提供了 -pl <模块> -am(also-make,顺带编译依赖模块)。
这个参数拼在哪里?拼在 Biz.module 字段不为空的时候。
module 为空 → mvn clean package -DskipTests
module = "hotel-service" → mvn clean package -pl hotel-service -am -DskipTests
4.3 构建进程管理的三个坑
调用外部构建进程时,有三个新手容易踩的坑:
坑 1:输出缓冲区死锁
Java 的 Process 的 stdout/stderr 是有固定大小缓冲区的。如果你不读它,缓冲区满了之后,构建进程会卡住(等你去读,但它又在等你 waitFor,典型的死锁)。
解法:用一个独立线程去消费输出流,主线程只负责 waitFor 等待进程结束。
java
// 思维雏形,不是真实代码
execute(command, workDir) {
process = startProcess(command, workDir)
// 独立线程读取输出,避免缓冲区阻塞
startThread(() -> {
while (line = process.stdout.readLine()) {
output.append(line)
}
})
// 主线程等待进程结束(带超时)
if (!process.waitFor(30, MINUTES)) {
process.kill()
throw TimeoutException
}
}
坑 2:超时时间设置
构建超时设多长时间?太短了误杀,太长了浪费线程。实践中 30 分钟是一个合理的上限------正常构建 2-5 分钟,如果超过 30 分钟,大概率是卡死了(依赖下载不动、编译死循环)。
坑 3:PATH 环境变量传递
启动 Java 进程时,mvn 命令可能找不到(PATH 不包含 Maven 的安装目录)。
解法:构建前先探测 mvn 的完整路径,后续直接用绝对路径:
java
resolveMavenPath() {
// 优先级:配置指定 > 系统 PATH 探测 > 硬编码默认路径
for (candidate in ["/opt/homebrew/bin/mvn", "/usr/local/bin/mvn", "/usr/bin/mvn"]) {
if (fileExists(candidate)) return candidate
}
throw "未找到 mvn,请安装或配置 publish.build.mvn"
}
4.4 打包不能并行------多加一层分布式锁
同一个业务不能有两个构建任务同时跑。原因:它们共用同一个 Git 缓存目录(/data/publish/repos/{bizName}),同时操作会冲突。
用 Redis 分布式锁来解决:
锁的 key = "build-lock:{bizId}"
锁的持有时间 = 最长构建时间(30 分钟)
注意:锁的粒度是 bizId(业务级别),不是全局锁。A 业务构建时,B 业务可以并行构建------互不影响。
第五章:部署编排 ------ 把代码推到服务器并让它跑起来
5.1 部署流水线的状态机
单台机器的部署可以抽象为 7 个步骤:
获得锁 → 摘除路由 → rsync 同步 → 重启服务 → 恢复路由 → 预热验证 → 释放锁
其中每一步都是一个 Flow 记录。任何一步失败,都有明确的「在哪一步挂了」的信息。
5.2 代码同步:为什么选 rsync 而不是 scp?
场景:一个 Java Web 项目,war 包解压后有几百个文件,但你只改了其中三个。
- scp 整个 war 包:每次传 200MB,网络和磁盘 I/O 都是浪费
- rsync 增量同步:只传变化的那 3 个文件,几 KB 就搞定
额外好处:rsync 的 --delete 参数可以自动清理远程已删除的文件,保持远程目录和本地完全一致。不需要额外写清理脚本。
rsync 通过 SSH 隧道传输,复用已有的 SSH 认证体系。
5.3 重启策略:不同类型,不同方式
不要试图统一所有服务的重启方式。不同的技术栈,重启命令就是不一样:
| 类型 | 重启方式 | 说明 |
|---|---|---|
| Java WAR (Tomcat) | {tomcat_home}/service.sh restart |
约定:每个 Tomcat 根目录下放 service.sh |
| Java JAR | cd {path} && {app.env} |
app.env 里存完整启动命令 |
| Python | supervisorctl restart {appName} |
依靠 supervisor 管理进程 |
用策略模式(伪代码):
java
restart(app, type) {
strategy = switch (type) {
case "java-war" -> new TomcatRestart()
case "java-jar" -> new JarRestart(app.env)
case "python" -> new SupervisorRestart(app.name)
default -> new TomcatRestart() // 兜底
}
strategy.execute(server)
}
设计取舍 :JAR 类型为什么让用户手写启动命令而不是自动生成?因为每个 Spring Boot 应用的 JVM 参数、Profile、端口都不同,一行
java -jar xxx.jar覆盖不了。与其让系统猜测,不如交给最了解这个服务的人(开发者自己)来配置。
5.4 路由摘除:流量切换的两种策略
部署过程中需要先把目标机器从负载均衡中摘掉,不然用户请求打到正在重启的机器上就会报错。
摘除方式取决于你的网络架构。设计成策略可配置:
| 策略 | 实现 | 适用场景 |
|---|---|---|
none |
什么都不做 | 开发环境,没有负载均衡 |
iptables |
SSH 到目标机器执行 iptables -A INPUT -p tcp --dport {port} -j DROP |
简单的 Linux 防火墙管理 |
http |
向路由中心发送 HTTP 请求通知摘除/恢复 | 有独立网关或服务发现中间件 |
三种策略共享同一个调用接口,配置决定行为。伪代码:
java
offlineRoute(app) {
switch (config.routeStrategy) {
case "iptables":
ssh.exec("iptables -A INPUT -p tcp --dport " + app.port + " -j DROP")
case "http":
httpClient.get(config.offlineUrlPattern.replace("{ip}", app.ip))
case "none":
// skip
}
}
5.5 预热:别让第一个用户当小白鼠
服务重启后,JVM 还在 JIT 编译、连接池还没建立、缓存还没加载。直接放流量上去,第一个用户会感受到明显的延迟甚至超时。
预热就是在新服务接流量前,先自己请求几个关键 URL,把服务「跑热」:
预热 URL 来自 Biz.warmUp(逗号分隔,如 "health,/api/search?keyword=test")
每个 URL 最多重试 3 次
每个 URL 之间可以间隔几秒
全部成功 → 预热通过 → 恢复路由
有失败 → 标记 Flow 为 fail → 可以选择中断发布或继续
第六章:远程执行引擎 ------ 你总要有人去操作那些服务器
6.1 远程命令执行的抽象
发布系统最终要对远程服务器做三件事:执行命令、上传文件、sudo 操作。
设计一个执行命令的数据对象,而不是一个到处传字符串的工具类:
java
// 命令的抽象
class Command {
String script // 要执行的 shell 命令
String workDir // 工作目录(可选)
int exitCode // 执行后的退出码
String stdout // 标准输出
String stderr // 错误输出
}
然后一个 RemoteExecutor 负责执行:
java
interface RemoteExecutor {
void exec(Server server, Command cmd)
void sudo(Server server, Command cmd) // 以 root 权限执行
void upload(Server server, Command cmd, File localFile)
}
6.2 本地/远程透明切换
一个实用的小设计:如果目标 IP 是 127.0.0.1 或 localhost,不走 SSH,直接用 ProcessBuilder 本地执行。
为什么需要?因为开发调试时 Publish 服务和目标服务可能都在同一台机器上。走 SSH 会有认证配置的麻烦。
java
exec(server, cmd) {
if (server.ip == "127.0.0.1" || server.ip == "localhost") {
// 本地执行:直接用 ProcessBuilder
localExec(cmd)
} else {
// 远程执行:JSch SSH
remoteExec(server, cmd)
}
}
6.3 SSH 认证链
建立 SSH 连接时,认证应该按优先级尝试:
1. 本地 SSH 私钥(~/.ssh/id_rsa → id_ed25519 → id_ecdsa)
2. keyboard-interactive(交互式认证)
3. 密码(配置中的兜底方案)
和 OpenSSH 客户端的默认行为保持一致即可,不需要发明新东西。
6.4 Sudo 的密码怎么传?
需要 root 权限时,执行 sudo {command}。但 sudo 要求输入密码,怎么在无人值守的场景下传?
sudo -S 选项表示从标准输入读取密码。利用 SSH Channel 的 InputStream/OutputStream:
1. 打开 SSH Channel,执行 "sudo -S -p '' {command}"
2. 向 Channel 的输出流写入 "密码\n"
3. 正常读取命令的输出
-S 表示 stdin 读密码,-p '' 表示不要提示符(避免干扰输出解析)。
6.5 运维工具箱
除了部署本身,还需要一些辅助检查能力。它们本质都是「远程执行命令 + 解析输出」:
| 工具 | 命令 | 目的 |
|---|---|---|
| 磁盘检查 | df -h / |
部署前确认目标机器磁盘使用率 < 95% |
| 环境检查 | java -version && mvn -version |
确认目标机器安装了必要的运行时 |
| 组件安装 | wget {script} && bash {script} |
首次部署时初始化机器(JDK、Tomcat、Supervisor 等) |
所有这些工具的脚本统一由 Publish 服务自身的 /scripts/ 路径托管,远端机器通过 wget 拉取。集中管理比每台机器手动拷贝脚本强。
第七章:Web 层 ------ 让发布有一个可操作的界面
7.1 要不要前后端分离?
这是一个经常被技术团队纠结的问题。答案是:看场景。
Publish 是一个内部运维工具。页面复杂度低------几张表单、几个列表、一个进度展示。不需要 CDN、不需要 SEO、不需要 SSR、不需要多端适配。
在这种情况下,前后端分离的收益几乎为零,但代价明确:
- 多一个前端项目的构建/部署流程
- 多一套 nginx 配置和 CORS 配置
- 多人开发时多一套本地调试环境
决策:用服务端 MVC 模板渲染,一笔写完。
原则:技术选型不追潮流,追场景匹配。如果你以后要把这个系统商业化(多租户、复杂交互、移动端),分离就是值得的。但目前它就是个内部工具,怎么简单怎么来。
7.2 Controller 的职责切分
按功能域切分 Controller,每个 Controller 只负责一类页面或一类操作:
BizController → 业务管理(增删改查 + 查看 GitLab 标签列表)
AppController → 机器实例管理(增删改查)
DeployController → 发布操作(选择版本 → 勾选机器 → 点击发布)
HistoryController → 发布历史列表 + 任务详情(含 Flow 流程展示)
PackController → 打包操作(可单独触发打包,不一定部署)
TriggerController → 接收 GitLab Webhook 回调(不需要页面渲染,仅处理数据)
LoginController → 登录
AjaxController → 统一的 Ajax JSON 接口(前端轮询任务状态等)
7.3 发布操作的交互流程
从用户视角,一次手动发布经过以下页面流转:
/deploy 发布首页
│ (展示我负责的业务列表 + 最近访问的)
│
/deploy/go?bizId=42 业务发布页
│ (展示该业务的 Git tags/branches 列表 + 机器列表)
│ 用户选择版本号 → 勾选目标机器 → 点击「发布」
│
/history/task/go?id=789 任务详情页
(实时展示 Task 下各 Flow 步骤的状态和输出)
create → running → succ/fail
任务详情页是整个系统交互价值最高 的部分。它不是静态展示,而是实时反馈------用户看到进度条在动、每一步在变色、失败时有明确的错误信息。
7.4 权限设计:够用就好
内部工具的权限不需要 RBAC 那套。两个维度就够了:
- 时间限制(timeRestrict) :部分业务限制只能在工作时间发布(如工作日 10:00-18:00)。管理员角色(
x-man)可以突破限制。 - 可见性控制:用户只能看到自己负责的业务列表。管理员的「全部业务」视图是额外的。
额外需要注意的是并发控制:如果某业务正在被发布,页面按钮应该置灰,提示「正在进行其他发布任务,请稍等几分钟」。
第八章:设计复盘 ------ 如果重来,哪些决策会不同?
8.1 「够用就好」不是偷懒,是资源意识
你用 LinkedBlockingQueue 而不是 RabbitMQ。
你用服务端模板渲染而不是 Vue + Element Plus。
你用 15 行的 SemVer 而不是完整的三方库。
这些选择背后是同一个原则:每引入一个新技术组件,就要有人去学、去维护、去排查问题。你省下的「开发时间」,最终会变成团队的「维护负债」。
8.2 谁受益于步骤记录表?
设计步骤记录表(Flow)这个决策,在写第一行代码时就做了。当时只觉得「出问题时要能查到是哪步挂了」。回头来看,它的收益远不止排查问题:
| 受益方 | 受益方式 |
|---|---|
| 开发者(发布人) | 实时看到进度,不用盯着终端 |
| 开发者(排查人) | 精准定位失败步骤,直接看到 stdout/stderr |
| 非技术人员(PM/QA) | 在页面上看懂「为什么卡住了」 |
| 技术 Leader | 分析发布数据:哪类步骤最常失败?平均发布时间?最慢的机器? |
一个结构化的步骤记录表,比一千行 grep 日志更有分析价值。
8.3 三层锁的粒度进化
最初的版本只有一层锁------全局锁(同时只能有一个发布任务在跑)。后来发现太粗了:A 业务和 B 业务不应该互相阻塞。
于是拆成两层:
业务级锁:build-lock:{bizId} → 同一业务不能同时打包
机器级锁:deploy-lock:{ip}:{path} → 同一台机器不能同时被部署
设计规律 :锁的粒度不是越细越好,也不是越粗越好。粒度应该和共享资源的边界对齐------共享什么就锁什么。
8.4 状态机:少用 boolean,多用枚举
最早的代码用 boolean isRunning 表示任务是否进行中。后来加了 isFailed、isCancelled。三个 boolean 有 8 种组合,其中 3 种是非法的(如 running=true && failed=true)。
枚举把非法状态从运行时 bug 变成了编译时不可能。这是状态机设计最核心的原则。
8.5 如果重来,我会考虑的改进
① Git 缓存策略
现在是全量 clone,未来可以改为 git clone --depth=1 --single-branch(浅克隆),减少磁盘占用。配合 LRU 策略自动清理不活跃业务的缓存。
② 分批并行部署
现在是严格串行(一台一台来)。可以支持分批并行:比如 20 台机器分 4 批,每批 5 台同时部署,批间有等待。这在滚动发布场景下能大幅缩短总时长。
③ 制品仓库
现在制品在 Publish 服务器本地暂存。好处是简单,坏处是磁盘压力和缺少版本管理。可以引入制品仓库(如 Nexus),构建完上传,部署时下载。同时支持制品版本回溯。
④ 部署步骤的可编程性
现在的部署步骤(rsync → restart → warmUp)是硬编码的。可以考虑支持简单的部署脚本------用户在 Web UI 上定义步骤序列,系统按序执行。这比加一个 switch case 灵活得多。
总结
这篇文章没有给你一行可以跑起来的代码。但它给了你一个「发布系统」的完整推演过程:
- 先定义问题:发布不只是部署,是获取→构建→传输→重启→验证→记录
- 再建模:用 Biz/App/Task/Flow/Pack 五个概念把发布行为结构化
- 然后细化:Git 对接、构建引擎、部署编排、SSH 执行、Web 层,每一步都问「为什么」
- 最后复盘:哪些决策是对的,哪些可以改进,把经验提炼为下次设计的起点
核心收获 :一个好用的发布系统,本质是把运维经验固化为自动化流程,把「人知道怎么做」变成「系统帮你做并对每一步负责」。你不是在写一个工具,你是在把团队里最资深那个开发者的发布经验,编码成所有人都能复用的能力。
如果这篇文章帮到了你,欢迎点赞收藏,转发给同样在思考「发布怎么搞」的同事。