第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 个依赖注入:
PublishTaskService、FlowService、PublishAppService、LockService、PublishPackService、WarService、GitService、DeployService、PublishDependencyService、PublishBizService,以及外部 HTTP 工具和钉钉 webhook 配置。
可配置的行为开关:
| 配置项 | 默认值 | 作用 |
|---|---|---|
dingtalk.webhook |
空(不通知) | 钉钉机器人 webhook URL |
publish.route-center.strategy |
none |
路由摘除策略:none、iptables、http |
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,用文件名校验排除sources、javadoc和original-前缀的 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/lib 或 WEB-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-*.properties 或 application-*.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++;
}
逐台部署的滚动策略靠两个机制:
- interval 间隔 :第一个目标机器之后,每台机器之间暂停
task.interval秒。这个等待值由用户在前端指定,目的是给新部署的服务留出启动和预热时间,避免同时重启多台机器导致服务全部不可用。 - 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():running和create都视为"进行中"。这允许在任务创建后、异步线程启动前的短暂窗口内,前端仍能正确显示状态。stop():计算System.currentTimeMillis() - createTime.getTime(),将耗时存入cost字段。getCost()针对running和create状态实时计算,已结束的任务从 DB 字段读取------因为createTime是瞬态字段,服务重启后会丢失。
PublishFlow 的输出清理
PublishFlow 的 out 和 err 字段在 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.jar 和 order.jar,如果统一重命名为 app.jar 就会互相覆盖。
unzipTar() --- 前端制品
前端构建产物(dist.tar.gz)用 tar xvf 解压。这些静态文件最终通过 rsync 同步到目标机器的 Nginx 静态文件目录。
unzipNone() --- 非标准类型
直接返回 repo.getWorkspace(),rsync 会把整个工作区同步到目标机器。这种模式适用于脚本类项目(如 Python、Shell),它们不需要构建,代码即制品。
8.7 失败重试
系统通过 /history/task/retry 接口支持失败重试。其实现逻辑是根据旧任务的 bizId、code、appList 等字段创建一条新的 PublishTask(状态为 create),然后重新调用 deploy()。这意味着重试本质上是一次全新的发布,会重新走完整的打包、同步、重启流程------而不是"从上次失败的步骤接着执行"。这个设计更简单也更容易保证幂等性。
8.8 小结
本章深入剖析了 AsyncDeploy.deploy() 方法的完整执行流程------从设置运行状态开始,经过 Git 同步、制品构建、依赖校验、配置审查,到逐台机器的路由摘除、代码同步、服务重启、HTTP 预热、部署后脚本,最终汇总状态并计算耗时。这是一条高度工程化的流水线,体现在:
- 全流程可追溯 :每一步都有
PublishFlow记录,带耗时和输出 - 可配置的行为开关:路由策略、依赖检查、钉钉通知均可按环境配置
- 防御性设计:finally 块中确保路由恢复和锁释放,避免"摘除了但没恢复"的灾难
- 滚动发布:interval 间隔 + 分布式锁保证逐台部署的安全性和可观测性
- 安全防护:路径穿越校验、符号链接逃逸防御、文件名校验贯穿制品处理全流程
如果用一个词来概括这套发布系统的哲学,那就是"每步可追溯,失败可恢复"。这八个字也是所有自动化运维系统在设计时应遵循的核心原则。