10、发布系统-路由管理与灰度发布

前言

本章介绍发布系统的路由管理机制:部署前先摘除流量、部署后验证通过再恢复流量,以及通过间隔发布实现灰度发布的雏形。核心实现位于 AsyncDeploy.java 的路由管理模块和 DeployService.java 的 iptables 操作。

10.1 问题背景与解决思路

发布系统面临的一个经典问题:如何在不停服的前提下安全部署?

假设一台服务器正在处理 100 个 HTTP 请求,此时 rsync 同步新 jar 包 + kill 旧进程 + 启动新进程,这 100 个请求会被直接丢弃。用户体验就是"刷新了一下页面,报错了"。

正确的做法分三步:

  1. 摘除流量:部署前,将目标机器从负载均衡中摘除,让它不再接收新请求。
  2. 等待排干:等待已有请求处理完毕(通常是 10-30 秒)。
  3. 安全部署:rsync 同步 + 重启服务。
  4. 恢复流量:部署完成后,将机器加回负载均衡。

发布系统通过策略模式支持两种流量摘除方式: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 间隔发布机制结合路由管理,构成了手动灰度的雏形:逐台摘除、部署、恢复,每台之间留出观察窗口,最小化单次变更的影响面。

相关推荐
唐青枫1 小时前
Java WebLogic 实战指南:从 Domain、数据源到 WAR 部署和集群管理
java
2501_937860942 小时前
Java 集合底层深度剖析:Map 与 Set、二叉搜索树、哈希表全解
java·数据结构·散列表
cyforkk2 小时前
并发控制与状态安全:Single-Flight 与幂等性的本质区别
java·安全·spring
豆角焖肉2 小时前
MyBatis延迟加载、缓存机制与注解开发
java·spring·mybatis
所愿ღ2 小时前
SSM框架-Spring3
java·开发语言·笔记·spring
java1234_小锋2 小时前
【免费】基于Spark实时交通流量分析与拥堵预测系统(Java版本+可视化大屏+Kafka+SpringBoot+Vue3) 锋哥原创出品,必属精品
java·大数据·spark·kafka·实时交通流量分析与拥堵预测
组合缺一2 小时前
Solon 的 10 种 HTTP 服务器:改一行依赖,换一个引擎
java·服务器·网络协议·http·solon
LSL666_3 小时前
SpringBoot静态资源映射
java·spring boot·spring
520拼好饭被践踏3 小时前
JAVA+Agent学习day26
java·开发语言·数据结构·学习·agent