8、发布系统-完整流水线的核心

第8章:异步发布流程 --- 完整流水线的核心

上一章构建了远程部署引擎的各个零件------SSH 执行、rsync 同步、服务重启。本章将这些零件组装成一条完整的异步发布流水线。AsyncDeploy.deploy() 方法是整个发布系统的心脏,它把 Git 同步、Maven 构建、制品解压、依赖检查、配置校验、路由摘除、代码同步、服务重启、HTTP 预热、部署后脚本这十多个步骤串成一个有序的流程。


8.1 AsyncDeploy 整体架构

AsyncDeploy 是一个 Spring @Service,约 870 行,包含以下结构性设计:

异步执行

java 复制代码
@Async("taskExecutor")
public void deploy(PublishTask task) { ... }

使用专用的线程池 taskExecutor(核心 4 线程,最大 8 线程,队列 100),确保发布任务不阻塞 Tomcat 的 HTTP 线程。方法返回 void 而不是 Future,因为发布结果是写入数据库而非通过返回值传递------调用方(Controller)只需触发任务,然后通过轮询 PublishTask.status 获知进度。

12 个依赖注入

PublishTaskServiceFlowServicePublishAppServiceLockServicePublishPackServiceWarServiceGitServiceDeployServicePublishDependencyServicePublishBizService,以及外部 HTTP 工具和钉钉 webhook 配置。

可配置的行为开关

配置项 默认值 作用
dingtalk.webhook 空(不通知) 钉钉机器人 webhook URL
publish.route-center.strategy none 路由摘除策略:noneiptableshttp
publish.dependency-check.enabled false 是否启用依赖版本校验
publish.dependency-check.break-on-error false 版本不匹配时是否中断发布

下面重点剖析最核心的 deploy() 方法。


8.2 deploy() 核心流程(逐步骤解析)

deploy() 方法约 140 行,可以分为 10 个步骤。下面按照源码执行顺序逐一拆解。

Step 0:设置状态 + 钉钉通知

java 复制代码
task.setStatus(TaskStatus.running.getName());
taskService.updateById(task);

if ("yinke".equals(currentEnv)) {
    sendDingTask(task);
}

任务从 create 进入 running,前端就能看到"发布中"的状态。钉钉通知仅在特定环境(yinke)触发,消息内容包含发布原因、修改模块、涉及 IP、上线版本、验证人------这是一个轻量级的发布审批记录。

Step 1:校验 biz 和 appList

java 复制代码
PublishBiz biz = task.getBiz();
List<PublishApp> appList = task.getAppList();
if (biz == null || appList == null || appList.isEmpty()) {
    task.setStatus(TaskStatus.fail.getName());
    task.setErr("业务不存在或未选择目标机器");
    task.stop();
    taskService.updateById(task);
    return;
}

这是一个快速失败(fail-fast)检查。没有业务信息或没有目标机器,后续步骤毫无意义,直接标记失败并返回。

Step 2:查找/创建 PublishPack

java 复制代码
PublishPack pack = packService.findByBizIdAndCode(task.getBizId(), task.getCode());
if (pack == null) {
    gitService.syncRemote(biz);
    pack = packService.findByBizIdAndCode(task.getBizId(), task.getCode());
}

PublishPack 是构建产物的元数据记录。如果之前已经为相同的 biz + code(分支/标签)打包过,直接复用缓存。如果找不到,触发一次 gitService.syncRemote() 再查询------这说明"打包"这个动作可能由外部系统(如 CI)完成,发布系统只在必要时触发。

Step 3:GitRepository ensurePath

java 复制代码
GitRepository repo = new GitRepository(biz.getName(), biz.getGit(), task.getCode());
gitService.ensurePath(repo);
if (biz.getModule() != null && !biz.getModule().isEmpty()) {
    repo.setSyncPath(repo.getSyncPath().resolve(biz.getModule()));
}

ensurePath() 确保本地存在指定分支/标签的代码副本。如果 PublishBiz 指定了子模块(如 my-service-gateway),则 syncPath 指向子模块目录而非仓库根目录。

Step 4:如果需要打包 → warService.packWar2()

java 复制代码
if (task.isPack() || pack == null || pack.getPath() == null || pack.getPath().isEmpty()) {
    warService.packWar2(task);
    pack = packService.findByBizIdAndCode(task.getBizId(), task.getCode());
}

task.isPack() 是用户创建任务时勾选的"重新打包"选项。三个条件任一满足就触发重建:用户明确要求重新打包、缓存不存在、缓存的制品路径无效。

Step 5:查找制品文件

这是整个流程中最复杂的分支逻辑------因为不同项目类型的制品形式完全不同:

java 复制代码
File warFile;
if ("angular".equals(biz.getType())) {
    warFile = findWar(repo.getSyncPath().toString(), "dist.tar.gz");
} else if ("jar".equals(biz.getType())) {
    File targetDir = repo.getSyncPath().resolve("target").toFile();
    File[] jars = targetDir.listFiles((d, n) ->
        n.endsWith(".jar") && !n.contains("-sources")
        && !n.contains("-javadoc") && !n.startsWith("original-"));
    warFile = (jars != null && jars.length > 0) ? jars[0] : null;
} else {
    File targetDir = repo.getSyncPath().resolve("target").toFile();
    File[] jars = targetDir.listFiles((d, n) ->
        (n.endsWith(".jar") || n.endsWith(".war"))
        && !n.contains("-sources") && !n.contains("-javadoc"));
    warFile = (jars != null && jars.length > 0) ? jars[0] : null;
}
  • angular :前端项目,制品是 dist.tar.gz
  • jar :Spring Boot 项目,在 target/ 下找 .jar,用文件名校验排除 sourcesjavadocoriginal- 前缀的 jar
  • 其他 (兼容旧 java 类型):在 target/ 下同时找 .jar.war,同样排除源码和文档包

findWar() 方法有一个值得我们关注的安全校验:

java 复制代码
private File findWar(String path, String warName) {
    if (warName.contains("..") || warName.contains("/") || warName.contains("\\")) {
        log.error("unsafe warName: {}", warName);
        return null;
    }
    return new File(path + "/" + warName);
}

拒绝包含 ../\ 的文件名,这是最基本的路径穿越防御。

Step 6:解压到 content 目录

java 复制代码
switch (biz.getType()) {
    case "java":
    case "jar":
        contentPath = unzipJar(repo, warFile);
        break;
    case "angular":
        contentPath = unzipTar(repo, warFile);
        break;
    default:
        contentPath = unzipNone(repo);
        break;
}

三种解压策略:

  • unzipJar() :直接把 jar 文件 cp 到 content 目录。注意它保留了原始 jar 文件名,原因是同一台机器可能部署多个服务,文件名是唯一区分标志。
  • unzipTar()tar xvf 解压到 content 目录。前端项目的 dist.tar.gz 包含静态文件目录结构。
  • unzipNone():对于非标准类型,直接把 workspace 作为 content 目录------这意味着 rsync 会同步整个工作区。

所有解压方法都包含路径安全校验:

java 复制代码
String canonical = contentFile.getCanonicalPath();
if (!canonical.startsWith(repo.getSyncPath().toFile().getCanonicalPath())) {
    throw new SecurityException("unsafe path: " + canonical);
}

通过比较规范化路径(解析符号链接后的真实路径)来防御符号链接逃逸攻击。

Step 7:依赖版本检查

java 复制代码
if (dependencyCheckEnabled && ("java".equals(biz.getType()) || "jar".equals(biz.getType()))) {
    FlowFactory depFactory = new FlowFactory(task.getId(), parentId);
    if (!checkDependencies(contentPath, task, depFactory, dependencies)) {
        return; // break-on-error 时中断发布
    }
    reportDependencies(biz.getId(), dependencies);
    parentId++;
}

依赖检查是可选的(通过 dependencyCheckEnabled 控制)。它的工作原理是从 BOOT-INF/libWEB-INF/lib 目录扫描所有 jar 文件名,用正则解析出 {name: version} 的键值对,然后与 necessaryDependencies 中配置的期望版本对比。

parseJarDependency() 是这个解析过程的核心:

java 复制代码
// 例: spring-boot-3.3.13.jar → {spring-boot: 3.3.13}
String[] parts = fileName.split("-");
for (int i = 0; i < parts.length; i++) {
    if (parts[i].matches("^\\d.*")) {
        String name = fileName.substring(0, fileName.indexOf(parts[i]) - 1);
        // 拼接后续数字段作为完整版本号
        for (int j = i + 1; j < parts.length; j++) {
            if (seg.matches("^\\d+$") || seg.equals("SNAPSHOT") || seg.matches("^[A-Z]+$")) {
                version += "." + seg;
            } else break;
        }
        // 检测 SNAPSHOT
        if (fileName.contains("SNAPSHOT")) version += "-SNAPSHOT";
        dependencies.put(name, version.replace(".jar", ""));
        return;
    }
}

这个朴素的分割算法对标准 Maven 命名规则足够有效,但对某些非常规命名(如 my-lib-1.0-FINAL.jar)可能存在边界问题。不过作为发布流程中的一个"安全网"(而非唯一的版本控制手段),容错性是足够的。

检查结果会写入 publish_dependency 表(先删后插),方便后续追溯每次发布时的依赖快照。

Step 8:应用配置校验

java 复制代码
checkApplicationProperties(contentPath, biz.getType(), propFactory);

这一步递归扫描 content 目录,检查是否存在 application-*.propertiesapplication-*.yml 文件。如果找到,标记为失败并提示"不建议使用本地配置文件,请迁移到 Nacos"。这是项目规范落地的一种温和方式------不阻塞发布,但会在发布记录中留下警告。

Step 9:循环 appList 逐台部署

java 复制代码
long interval = task.getInterval() * 1000L;
int failCount = 0;
int count = 0;
for (PublishApp app : appList) {
    FlowFactory factory = new FlowFactory(task.getId(), parentId);
    if (TaskStatus.valueOf(task.getStatus()).isRunning()) {
        if (count++ > 0 && interval > 0) {
            sleep(interval, factory);
        }
        try {
            if (!deployApp(task, factory, app, contentPath)) {
                failCount++;
            }
        } catch (Exception e) {
            failCount++;
        }
    }
    parentId++;
}

逐台部署的滚动策略靠两个机制:

  1. interval 间隔 :第一个目标机器之后,每台机器之间暂停 task.interval 秒。这个等待值由用户在前端指定,目的是给新部署的服务留出启动和预热时间,避免同时重启多台机器导致服务全部不可用。
  2. failCount 计数 :单台失败不中断整个部署(除非是路由层面的不可恢复错误),而是继续发布下一台。最终 failCount == 0 则整体成功,否则标记失败------前台可以看到具体哪些机器部署成功、哪些失败。

sleep() 方法也创建了 PublishFlow 记录,方便在发布历史中看到等待过程:

java 复制代码
PublishFlow flow = factory.build("sleep " + (ms / 1000) + " seconds, waiting for startup");
flow.setStatus(TaskStatus.running.getName());
flowService.insertAndSetObjectId(flow);
Thread.sleep(ms);
flow.setStatus(TaskStatus.succ.getName());
flow.stop();
flowService.save(flow);

Step 10:设置最终状态

java 复制代码
task.setStatus(failCount == 0 ? TaskStatus.succ.getName() : TaskStatus.fail.getName());
task.stop();
taskService.updateById(task);

task.stop() 计算从 createTime 到当前时间的毫秒差,存入 cost 字段。最终无论是成功还是失败,状态都会落库。


8.3 deployApp() --- 单台部署流程

deployApp() 是单台机器的部署核心,约 70 行,流程为:

复制代码
分布式锁 → 路由摘除 → rsync + 重启 → 路由恢复 → 预热 → postScript → 释放锁

8.3.1 分布式锁

java 复制代码
String lockName = "deploy-" + app.getFullPath();
if (!lockService.tryLock(lockName)) {
    flow.setStatus(TaskStatus.cancelled.getName());
    flow.setErr(app.getFullPath() + "被另外一个发布进程锁定, 忽略它的发布, 请等待1分钟后再重试");
    // ...
}

锁的粒度是 IP:路径。这意味着同一个发布任务的不同机器可以并行部署(不同锁),但同一个任务对同一台机器的两次部署会被串行化。锁的实现是 Redis 分布式锁(通过 LockService),finally 块中保证释放。

8.3.2 路由摘除与恢复

在部署前把流量从目标节点摘除,部署完成后恢复,这是灰度发布的基本操作。系统支持三种策略:

java 复制代码
switch (routeStrategy) {
    case "iptables":
        deployService.disablePort(null, app.getIp(), app.getPort());
        // 部署完成后:
        deployService.enablePort(null, app.getIp(), app.getPort());
        break;
    case "http":
        callRouteHttp(app, routeOfflineUrlPattern);
        Thread.sleep(breakRequestSleep);  // 等待存量请求排空
        // 部署完成后:
        callRouteHttp(app, routeOnlineUrlPattern);
        break;
    case "none":
    default:
        // 不摘除,直接部署
}

iptables 策略直接 DROP 目标端口的入站流量,粗暴但有效。http 策略调用外部路由中心(如 Nginx、Envoy)的 API,并在摘除后等待 breakRequestSleep(默认 20 秒)让存量请求处理完毕。

关键的防御性代码在 finally 块中:

java 复制代码
} catch (Exception e) {
    flow.setStatus(TaskStatus.fail.getName());
    success = false;
    // 即使失败也尝试恢复路由------否则节点永久不可用
    if (routeEnabled() && app.getPort() > 0) {
        try { notifyRouteOnline(factory, app); } catch (Exception ignored) {}
    }
} finally {
    lockService.unlock(lockName);
    flow.stop();
    flowService.save(flow);
}

如果部署失败(比如 rsync 中途网络中断、重启命令异常),catch 块强制调用 notifyRouteOnline() 恢复路由。这个逻辑保护了线上服务不被"摘除了但没恢复"这种情况毁掉。注意它又包了一层 try-catch------恢复路由也可能失败(比如外部路由中心宕机),但至少做了最大努力。

8.3.3 syncAndRestart

java 复制代码
private void syncAndRestart(FlowFactory factory, PublishApp app, Path contentPath, String type) {
    PublishFlow flow = factory.build("rsync " + app.getFullPath());
    deployService.syncCode(flow, app, contentPath);

    flow = factory.build("restart " + app.getFullPath());
    deployService.restart(flow, app, type);
}

rsync 和 restart 是两个独立的 PublishFlow 步骤,各自记录输出和耗时。注意 rsync 的 src 路径末尾带 /------这是 rsync 的关键约定:带 / 表示同步目录内容,不带 / 表示同步目录本身。

8.3.4 HTTP 预热

预热逻辑比 DeployService.warmUpService() 更丰富------它支持多个 URL 路径:

java 复制代码
List<String> warmUps = Splitter.on(',').omitEmptyStrings().trimResults().splitToList(warmUp);
for (String warm : warmUps) {
    String ping;
    if (warm.charAt(0) != '/') {
        ping = urlBuilder.toString() + "/" + warm;
    } else {
        ping = urlBuilder.toString() + warm;
    }
    // 每个 URL 重试 3 次
    while (tries++ < 3) {
        HttpClientUtils.sendRequest(ping, HttpClientUtils.Method.GET, null);
        // ...
    }
}

URL 拼接规则:如果 warmUp/ 开头,视为绝对路径,直接追加到 http://IP:PORT 后面;否则视为相对于应用上下文的路径。这个设计兼容了"不同应用部署在不同 context path"的场景。

8.3.5 PostScript 执行

java 复制代码
if (success && biz.getPostScript() != null && !biz.getPostScript().isBlank()) {
    if (!executePostScript(factory, app, biz.getPostScript())) {
        log.warn("postScript failed for {}, continuing deployment", app.getFullPath());
    }
}

部署后脚本(postScript)在目标机器的应用目录下执行,失败不会阻塞后续部署,只记录日志。这是一种务实的策略------postScript 通常用于清理临时文件、触发监控上报等辅助操作,不应该因为辅助操作的失败而回滚整个部署。


8.4 FlowFactory --- 步骤追踪工厂

FlowFactory 是一个轻量级的工厂类,解决的是"如何让每步操作的 PublishFlow 记录有序"这个问题:

java 复制代码
public class FlowFactory {
    private final long taskId;
    private int parentId;

    public FlowFactory(long taskId, int parentId) {
        this.taskId = taskId;
        this.parentId = parentId;
    }

    public PublishFlow build(String name) {
        parentId++;
        PublishFlow flow = new PublishFlow();
        flow.setTaskId(taskId);
        flow.setName(name);
        flow.setStep(String.valueOf(parentId));
        return flow;
    }
}

每次调用 build()parentId 自增,生成的 step 字段形成严格有序的序列。例如一次典型部署的 step 序列可能是:

复制代码
2: 检查依赖版本
3: 校验应用配置
4: sleep 30 seconds
5: 192.168.1.10:/opt/app
  5.1: iptables 禁用端口
  5.2: rsync 192.168.1.10:/opt/app
  5.3: restart 192.168.1.10:/opt/app
  5.4: iptables 恢复端口
  5.5: access : http://192.168.1.10:8080/health
6: 192.168.1.11:/opt/app
  ...

注意 deployApp() 内部创建了新的 FlowFactory(每个 app 一个),这样每台机器的部署步骤都在自己的命名空间下,互不干扰。


8.5 状态机

发布任务的生命周期通过 TaskStatus 枚举定义:

复制代码
create → running → succ / fail / killed / cancelled / timeout

关键的辅助方法:

  • isRunning()runningcreate 都视为"进行中"。这允许在任务创建后、异步线程启动前的短暂窗口内,前端仍能正确显示状态。
  • stop():计算 System.currentTimeMillis() - createTime.getTime(),将耗时存入 cost 字段。getCost() 针对 runningcreate 状态实时计算,已结束的任务从 DB 字段读取------因为 createTime 是瞬态字段,服务重启后会丢失。

PublishFlow 的输出清理

PublishFlowouterr 字段在 setter 中自动做了清理:

java 复制代码
private static String sanitizeField(String s) {
    if (s == null) return null;
    String cleaned = s.replaceAll("\\e\\[[\\d;]*[a-zA-Z]", "");  // ANSI 转义码
    cleaned = cleaned.replace('\n', ' ').replace('\r', ' ');       // 换行→空格
    cleaned = cleaned.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]", ""); // 控制字符
    if (cleaned.length() > 2000) {
        cleaned = cleaned.substring(cleaned.length() - 2000);     // 保留最后 2000 字符
    }
    return cleaned;
}

这个清理主要解决 MySQL Connector/J 9.x 对特殊字符的兼容性问题------rsync 或 Maven 输出中常包含 ANSI 颜色码和换行符,直接写入可能导致 PreparedStatement 参数绑定异常。


8.6 制品查找与解压

findWar() --- 多类型制品扫描

制品查找的核心逻辑已在上文 Step 5 中详述。补充一点:jar 类型使用的文件过滤器 !n.startsWith("original-") 是为了排除 Spring Boot Maven Plugin 生成的 original-*.jar------这是没有 repackage 的原始 jar,不包含依赖,无法直接运行。

unzipJar() --- JAR 制品

对于 Spring Boot fat jar,不需要解压,直接 cp 到 content 目录。注意保留原始文件名------同一台机器可能部署 gateway.jarorder.jar,如果统一重命名为 app.jar 就会互相覆盖。

unzipTar() --- 前端制品

前端构建产物(dist.tar.gz)用 tar xvf 解压。这些静态文件最终通过 rsync 同步到目标机器的 Nginx 静态文件目录。

unzipNone() --- 非标准类型

直接返回 repo.getWorkspace(),rsync 会把整个工作区同步到目标机器。这种模式适用于脚本类项目(如 Python、Shell),它们不需要构建,代码即制品。


8.7 失败重试

系统通过 /history/task/retry 接口支持失败重试。其实现逻辑是根据旧任务的 bizIdcodeappList 等字段创建一条新的 PublishTask(状态为 create),然后重新调用 deploy()。这意味着重试本质上是一次全新的发布,会重新走完整的打包、同步、重启流程------而不是"从上次失败的步骤接着执行"。这个设计更简单也更容易保证幂等性。


8.8 小结

本章深入剖析了 AsyncDeploy.deploy() 方法的完整执行流程------从设置运行状态开始,经过 Git 同步、制品构建、依赖校验、配置审查,到逐台机器的路由摘除、代码同步、服务重启、HTTP 预热、部署后脚本,最终汇总状态并计算耗时。这是一条高度工程化的流水线,体现在:

  • 全流程可追溯 :每一步都有 PublishFlow 记录,带耗时和输出
  • 可配置的行为开关:路由策略、依赖检查、钉钉通知均可按环境配置
  • 防御性设计:finally 块中确保路由恢复和锁释放,避免"摘除了但没恢复"的灾难
  • 滚动发布:interval 间隔 + 分布式锁保证逐台部署的安全性和可观测性
  • 安全防护:路径穿越校验、符号链接逃逸防御、文件名校验贯穿制品处理全流程

如果用一个词来概括这套发布系统的哲学,那就是"每步可追溯,失败可恢复"。这八个字也是所有自动化运维系统在设计时应遵循的核心原则。

相关推荐
Alexalbb1 小时前
从角色过滤到可审计授权:单体系统数据权限设计复盘与演进
java
万亿少女的梦1682 小时前
基于Spring Boot的游戏交易管理系统设计与实现
java·spring boot·mysql·系统设计·交易管理
闲猫2 小时前
Agent工程实践:从WorkFlow到多Agent协作的落地笔记
java·服务器·前端
笨蛋不要掉眼泪2 小时前
Java虚拟机:常用参数
java·开发语言·python
亦暖筑序3 小时前
AgentScope-Java 入门:用 Middleware 审计 Agent 调用
java·ai编程·agentscope
你驴我3 小时前
WhatsApp 消息撤回与编辑的幂等性设计实践
java·服务器·前端·后端·python
青山木4 小时前
Hot 100 ---腐烂的橘子
java·数据结构·后端·算法·leetcode·广度优先
小龙报4 小时前
【优选算法】1. 水果成蓝 2.找到字符串中所有字母的异位词
java·c语言·数据结构·数据库·c++·redis·算法
ruleslol4 小时前
SpringBoot26-@Configuration + @Component
spring boot