前言
本章介绍发布系统的路由管理机制:部署前先摘除流量、部署后验证通过再恢复流量,以及通过间隔发布实现灰度发布的雏形。核心实现位于 AsyncDeploy.java 的路由管理模块和 DeployService.java 的 iptables 操作。
10.1 问题背景与解决思路
发布系统面临的一个经典问题:如何在不停服的前提下安全部署?
假设一台服务器正在处理 100 个 HTTP 请求,此时 rsync 同步新 jar 包 + kill 旧进程 + 启动新进程,这 100 个请求会被直接丢弃。用户体验就是"刷新了一下页面,报错了"。
正确的做法分三步:
- 摘除流量:部署前,将目标机器从负载均衡中摘除,让它不再接收新请求。
- 等待排干:等待已有请求处理完毕(通常是 10-30 秒)。
- 安全部署:rsync 同步 + 重启服务。
- 恢复流量:部署完成后,将机器加回负载均衡。
发布系统通过策略模式支持两种流量摘除方式:iptables 和 HTTP 路由中心。
10.2 策略模式设计
路由管理的配置集中在 application.yml(或 Nacos)的 publish.route-center 命名空间下:
yaml
publish:
route-center:
strategy: iptables # none | iptables | http
break-request-sleep: 20000 # 摘除后等待 20 秒排干流量
http:
offline-url-pattern: http://{ip}:{port}/offline_service?token={token}&name=publish-service
online-url-pattern: http://{ip}:{port}/online_service?token={token}&name=publish-service
token: ${ROUTE_CENTER_TOKEN}
success-keyword: succESS
对应源码中的配置注入:
java
// AsyncDeploy.java
@Value("${publish.route-center.strategy:none}")
private String routeStrategy; // none | iptables | http
@Value("${publish.route-center.http.offline-url-pattern:...}")
private String routeOfflineUrlPattern;
@Value("${publish.route-center.http.online-url-pattern:...}")
private String routeOnlineUrlPattern;
@Value("${publish.route-center.http.success-keyword:succESS}")
private String routeSuccessKeyword;
@Value("${publish.route-center.break-request-sleep:20000}")
private int breakRequestSleep;
private boolean routeEnabled() {
return !"none".equals(routeStrategy);
}
三种策略的含义:
| 策略 | 说明 | 适用场景 |
|---|---|---|
none |
不启用路由管理(默认值) | 开发环境、单机部署 |
iptables |
SSH 到目标机器操作 iptables 规则 | 无独立路由中心的场景,直接防火墙挡流量 |
http |
调用外部路由中心 API 摘除/恢复 | 有独立负载均衡或 API 网关的场景 |
10.3 部署流程中的路由管理
路由摘除和恢复的调用嵌入在 deployApp() 方法中,该方法处理单台机器的完整部署:
java
private boolean deployApp(PublishTask task, FlowFactory factory,
PublishApp app, Path contentPath) {
// ... 获取分布式锁 ...
try {
// ── 1. 路由摘除:部署前 ──
if (routeEnabled() && app.getPort() > 0) {
notifyRouteOffline(factory, app);
}
// ── 2. rsync + 重启:核心部署 ──
syncAndRestart(factory, app, contentPath, biz.getType());
// ── 3. 路由恢复:部署后 ──
if (routeEnabled() && app.getPort() > 0) {
notifyRouteOnline(factory, app);
}
// ── 4. 预热 + 后置脚本 ──
// ...
success = true;
} 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);
// ...
}
return success;
}
这里有两个重要的设计细节:
失败保护 :catch 块中有一行 notifyRouteOnline(factory, app)。如果部署过程中(比如 rsync 失败或重启命令报错)抛出异常,catch 块会尝试恢复路由。这是防御性设计------部署失败已经够糟了,不能再让机器一直处于"被摘除"状态,否则这台机器上的所有服务都不可用。
port > 0 的守卫条件:只有配置了端口的 App 才参与路由管理。有些非 HTTP 服务(如定时任务 worker、消息队列消费者)不需要摘除流量。
10.4 notifyRouteOffline / notifyRouteOnline --- 通知与记录
这两个方法不仅执行路由操作,还会创建 PublishFlow 记录,方便在发布历史中追踪路由操作的成败:
java
private void notifyRouteOffline(FlowFactory factory, PublishApp app) {
String label;
switch (routeStrategy) {
case "iptables":
label = "iptables 禁用端口(" + app.getIp() + ":" + app.getPort() + ")";
break;
case "http":
label = "HTTP 摘除节点(" + app.getIp() + ":" + app.getPort()
+ ", 等待" + (breakRequestSleep / 1000) + "s)";
break;
default:
return;
}
PublishFlow flow = factory.build(label);
try {
flow.setStatus(TaskStatus.running.getName());
flowService.insertAndSetObjectId(flow);
boolean ok = doRouteOffline(app);
if (ok && "http".equals(routeStrategy)) {
Thread.sleep(breakRequestSleep); // 等待流量排干
}
if (ok) {
flow.setStatus(TaskStatus.succ.getName());
} else {
flow.setStatus(TaskStatus.fail.getName());
flow.setErr("摘除节点失败: " + app.getIp() + ":" + app.getPort());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
flow.setStatus(TaskStatus.killed.getName());
} finally {
flow.stop();
flowService.save(flow);
}
}
摘除成功后,HTTP 策略会额外 Thread.sleep(breakRequestSleep) 等待已有请求排干。这个等待时间默认为 20 秒,远大于典型 HTTP 请求的处理时间(一般 < 1 秒),确保排干后再执行 rsync 和重启。
10.5 iptables 策略实现
iptables 策略直接在目标机器上操作防火墙规则。源码在 DeployService.java 中:
java
// DeployService.java
public void disablePort(PublishFlow flow, String ip, int port) {
RemoteServer svr = resolveServer(ip);
// 添加 DROP 规则
String rule = String.format(
"iptables -A INPUT -p tcp --dport %d -j DROP", port);
Command cmd = new Command(rule);
JSchUtil.sudo(svr, cmd);
if (cmd.getCode() == 0) {
log.info("disabled port {} on {}", port, ip);
}
// 持久化
Command persist = new Command(
"iptables-save > /etc/sysconfig/iptables 2>/dev/null "
+ "|| iptables-save > /etc/iptables/rules.v4 2>/dev/null "
+ "|| true");
JSchUtil.sudo(svr, persist);
}
public void enablePort(PublishFlow flow, String ip, int port) {
RemoteServer svr = resolveServer(ip);
// 删除 DROP 规则(-A 添加 → -D 删除)
String rule = String.format(
"iptables -D INPUT -p tcp --dport %d -j DROP", port);
Command cmd = new Command(rule);
JSchUtil.sudo(svr, cmd);
if (cmd.getCode() == 0) {
log.info("enabled port {} on {}", port, ip);
}
// 持久化(同 disablePort)
Command persist = new Command(
"iptables-save > /etc/sysconfig/iptables 2>/dev/null "
+ "|| iptables-save > /etc/iptables/rules.v4 2>/dev/null "
+ "|| true");
JSchUtil.sudo(svr, persist);
}
几个实现要点:
sudo 执行 :iptables 规则修改需要 root 权限。发布系统通过 JSchUtil.sudo() 以 SSH + sudo 方式执行命令。sudo() 方法内部会打开 PTY、通过 OutputStream 写入密码、读取 stdout/stderr 和 exitCode。
规则持久化 :iptables 规则默认只在内存中,重启后丢失。每次修改规则后执行 iptables-save 写入文件,确保服务器重启后流量摘除状态不会意外恢复。2>/dev/null || true 的写法兼容了 CentOS(/etc/sysconfig/iptables)和 Debian/Ubuntu(/etc/iptables/rules.v4)两种路径。
规则精确性 :--dport {port} 只 DROP 指定端口的 TCP 流量,不影响同一台机器上其他端口的服务(比如 SSH 22 端口、监控 agent 端口)。
10.6 HTTP 策略实现
当发布系统对接外部负载均衡或 API 网关时,使用 HTTP 策略。源码中 callRouteHttp() 方法实现 URL 模板替换和请求:
java
private boolean callRouteHttp(PublishApp app, String urlPattern) {
try {
String appName = extractAppName(app.getPath());
String url = urlPattern
.replace("{ip}", app.getIp())
.replace("{port}", String.valueOf(app.getPort()))
.replace("{token}", routeHttpToken)
.replace("{appName}", appName != null ? appName : "");
String response = HttpClientUtils.sendRequest(
url, HttpClientUtils.Method.GET, null);
log.info("routeCenter HTTP {}:{} → {}",
app.getIp(), app.getPort(),
response != null
? response.substring(0, Math.min(200, response.length()))
: "null");
return response != null
&& response.contains(routeSuccessKeyword);
} catch (Exception e) {
log.error("routeCenter HTTP failed for {}:{}",
app.getIp(), app.getPort(), e);
return false;
}
}
URL 模板支持四个变量:
| 变量 | 来源 | 示例 |
|---|---|---|
{ip} |
app.getIp() |
192.168.1.101 |
{port} |
app.getPort() |
8080 |
{token} |
配置文件 publish.route-center.http.token |
abc123 |
{appName} |
从 app.getPath() 中提取最后一层目录名 |
hotel-api |
成功判断:HTTP 响应的 body 中包含 successKeyword(默认 "succESS")。这里用 contains 而不是 equals,因为路由中心的响应可能带有额外字符(如换行符),用包含匹配更鲁棒。
摘除后的 breakRequestSleep 在 HTTP 策略中尤其重要------即使路由中心从负载均衡列表里移除了节点,已经建立的长连接(如 WebSocket、gRPC stream)不会立即断开。等待 20 秒给了这些连接自然结束的时间窗口。
10.7 灰度发布雏形 --- interval 间隔发布
灰度发布的核心思想是:不是一次性更新所有机器,而是一台一台地更新,每台之间留出观察时间。发布系统通过 PublishTask.interval 字段实现了这个机制。
在 AsyncDeploy.deploy() 的多机循环中:
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++;
log.error("cannot deploy {}", app.getFullPath(), e);
}
}
parentId++;
}
结合路由管理的效果:
时间线(3 台机器,interval = 60 秒):
├─ T+0s: 机器A 摘除路由 → 等待排干 → rsync+重启 → 恢复路由
├─ T+60s: 机器B 摘除路由 → 等待排干 → rsync+重启 → 恢复路由
├─ T+120s: 机器C 摘除路由 → 等待排干 → rsync+重启 → 恢复路由
这实际上就是一个手动灰度发布:任何时候只有一台机器处于"摘除-部署-恢复"的状态,其他两台继续服务。如果 A 部署后出现问题,运维可以手动停止任务,B 和 C 不受影响。
interval 设置为 0 时,所有机器并行部署(跳过 sleep),适用于开发/测试环境。
10.8 灰度发布的演进方向
当前实现是灰度发布的雏形,具备以下能力:
- 逐台摘除 + 部署 + 恢复,最小化影响面
- 间隔时间可配置,给验证留出窗口
- PublishFlow 记录了每一步操作,出问题可回溯
未来可扩展的方向:
百分比摘除 :不摘除整台机器,而是仅摘除一定比例的流量(如 20%)。这需要路由中心支持权重调整,而非简单的 on/off 开关。对应配置可以是 publish.route-center.http.offline-url-pattern 中增加 {weight} 模板变量。
自动观察与回滚:部署后自动监控新机器的错误率、响应时间,如果超过阈值自动触发回滚。这需要对接监控系统(如 Prometheus + Grafana)的 API。
金丝雀发布:先部署 1 台作为"金丝雀",让少量真实流量验证,观察一段时间(如 10 分钟)后,通过自动化决策(而非人工判断)决定是否继续全量部署。
总结
本章从"部署过程不能影响服务"这个实际问题出发,分析了发布系统的路由管理机制。
策略模式是核心设计模式:none / iptables / http 三种策略通过配置切换,策略的选择和路由操作的执行在 AsyncDeploy 中完成,具体命令执行在 DeployService 中实现,职责清晰。
iptables 策略通过 SSH + sudo 直接操作目标机器防火墙,数据结构简洁------添加一条 DROP 规则摘除流量,删除同一条规则恢复流量,事后持久化防止重启丢失。HTTP 策略通过 URL 模板 + 关键词匹配对接外部路由中心,灵活性更高。
deployApp() 方法的 finally 块中确保恢复路由是防御性设计的典型案例------部署可以失败,但不能让机器"卡"在摘除状态。
最后,interval 间隔发布机制结合路由管理,构成了手动灰度的雏形:逐台摘除、部署、恢复,每台之间留出观察窗口,最小化单次变更的影响面。