从零设计一个发布系统

前言:这篇文章解决什么问题?

如果你是一个正在成长的开发者,大概率有过这样的困惑:

  • 看了无数「Spring Boot 入门教程」,学会了写 Controller、Service、Mapper,但没人教你怎么把服务部署到生产环境
  • 用过 Jenkins、GitLab CI,但它们只管到「构建出一个制品」,后面怎么推到服务器、怎么重启、怎么验证------还是靠手工。
  • 团队规模到了 5-10 人,服务有了七八个,机器有十几台,你开始隐约觉得「需要一个东西来管发布」,但不知道这个东西该长什么样。

这篇文章不会给你一份可运行的完整代码。它要做的是更前置的事情:带着你,像一个产品技术负责人一样,从零推演一个发布系统的设计

你会看到:

  1. 发布这件事,拆开来看究竟包含哪些步骤
  2. 每一步面临什么设计选择,为什么选 A 不选 B
  3. 一个「发布系统」的数据模型怎样从模糊变清晰
  4. 哪些地方最容易踩坑,以及如何避免

不需要先看任何代码,只需要你对「发布上线」这件事有基本认知。

预计阅读时间: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-warjava-jarangularpythoncustom。这是整个系统的策略选择器
  • 模块名(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.3v1.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.1localhost,不走 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 表示任务是否进行中。后来加了 isFailedisCancelled。三个 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 灵活得多。


总结

这篇文章没有给你一行可以跑起来的代码。但它给了你一个「发布系统」的完整推演过程

  1. 先定义问题:发布不只是部署,是获取→构建→传输→重启→验证→记录
  2. 再建模:用 Biz/App/Task/Flow/Pack 五个概念把发布行为结构化
  3. 然后细化:Git 对接、构建引擎、部署编排、SSH 执行、Web 层,每一步都问「为什么」
  4. 最后复盘:哪些决策是对的,哪些可以改进,把经验提炼为下次设计的起点

核心收获 :一个好用的发布系统,本质是把运维经验固化为自动化流程,把「人知道怎么做」变成「系统帮你做并对每一步负责」。你不是在写一个工具,你是在把团队里最资深那个开发者的发布经验,编码成所有人都能复用的能力。

如果这篇文章帮到了你,欢迎点赞收藏,转发给同样在思考「发布怎么搞」的同事。