Prhub

#27542 [EPD] Dynamic encoder registration cleanup

原始 PR 作者 ShangmingCai 合并时间 2026-06-08 19:08 文件变更 2 提交数 8 评论 5 代码增减 +42 / -2

执行摘要

修复 EPD 动态编码器注册中的 URL 环回和竞态 bug

PR描述明确指出修复三个问题:1)server_args.url()将wildcard host解析为127.0.0.1/::1,导致注册的URL在跨机器解耦部署中无法访问;2)编码器重启时,前一个实例的失败计数器持续存在,导致健康检查循环立即驱逐新注册的URL;3)编码器关闭未注销URL,导致URL在健康检查驱逐之前持久存在(最多max_consecutive_failures × health_check_interval秒的浪费调度尝试)。关联Issue #22253实现了动态编码器注册的基础设施,此PR是其清理和修复。

推荐精读,特别是encode_receiver.py中_consecutive_failures清理位置的调整,是一个典型的微调修复经典竞态案例。值得关注的还有atexit.register在uvicorn.run前的使用模式,可作为服务优雅关闭的参考。建议作者考虑gemini-code-assist的review建议,增强IP检测失败时的错误处理,并补充单元测试。

讨论亮点

gemini-code-assist[bot]在review评论中指出:当server_args.host为0.0.0.0或::时,将其作为fallback传给get_local_ip_auto()存在问题——如果自动IP检测失败,会fallback到wildcard地址,导致注册不可达的encoder_url。建议调用get_local_ip_auto()时不传递fallback,以便失败时能大声报错或适当处理。作者未回复该评论,但实际提交的代码保留了server_args.host作为fallback,存在潜在风险。

实现拆解

  1. 修复URL构造(encode_server.py):将_register_encoder_url_with_bootstrap中的encoder_url = server_args.url()替换为手动host/scheme解析:使用get_local_ip_auto()将0.0.0.0/::映射到实际本地IP,并正确处理https模式。
  2. 新增优雅注销函数(encode_server.py):新增_unregister_encoder_url_from_bootstrap函数,遍历所有bootstrap URL发送DELETE /unregister_encoder_url请求,超时2秒,异常仅debug日志。
  3. 注册atexit回调(encode_server.py):在launch_server和_launch_server_dp两个入口中,在注册url后通过atexit.register注册注销函数,确保进程退出时自动清理。
  4. 修复_consecutive_failures计数器逻辑(encode_receiver.py):将register和unregister中的清理操作移到对应方法的开始处,确保无论url是否已注册都能清除失败计数。
  5. 仅修改源码文件:无测试、配置或部署配套改动。
文件 模块 状态 重要度
python/sglang/srt/disaggregation/encode_server.py 编码器注册 modified 7.06
python/sglang/srt/disaggregation/encode_receiver.py 编码器注册 modified 4.82

关键符号

_unregister_encoder_url_from_bootstrap _register_encoder_url_with_bootstrap EncoderBootstrapServer.register EncoderBootstrapServer.unregister launch_server _launch_server_dp

关键源码片段

python/sglang/srt/disaggregation/encode_server.py core-logic

核心变更文件,修复 URL 构造 bug、新增 _unregister_encoder_url_from_bootstrap 函数、注册 atexit 回调。

def _register_encoder_url_with_bootstrap(server_args: ServerArgs):
    # 修复前:直接使用 server_args.url(),若 host 为 0.0.0.0 则返回 127.0.0.1,跨机不可达
    host = server_args.host
    if not host or host in ("0.0.0.0", "::"):
        host = get_local_ip_auto(server_args.host) # 安全:将通配符替换为实际本地 IP
    scheme = "https" if server_args.ssl_certfile else "http"
    # 使用正确的 host + port 构造 URL,而非 server_args.url()
    encoder_url = NetworkAddress(host, server_args.port).to_url(scheme)
    # ... 注册请求逻辑 ...
​
​
def _unregister_encoder_url_from_bootstrap(server_args: ServerArgs):
    """优雅注销:遍历所有 bootstrap 发送 DELETE 请求,超时 2 秒。"""
    host = server_args.host
    if not host or host in ("0.0.0.0", "::"):
        host = get_local_ip_auto(server_args.host)
    scheme = "https" if server_args.ssl_certfile else "http"
    encoder_url = NetworkAddress(host, server_args.port).to_url(scheme)
    payload = {"url": encoder_url}
    for bootstrap_url in server_args.encoder_register_urls:
        try:
            resp = http_requests.delete(
                f"{bootstrap_url}/unregister_encoder_url",
                json=payload,
                timeout=2.0, # 短超时,不阻塞进程退出
            )
            if resp.status_code == 200:
                logger.info(f"Unregistered encoder URL '{encoder_url}' from bootstrap at {bootstrap_url}")
            else:
                logger.warning(f"Bootstrap {bootstrap_url} returned {resp.status_code} on unregister: {resp.text}")
        except Exception as e:
            # 异常不抛出,确保注销不阻塞主进程退出
            logger.debug(f"Unregister from {bootstrap_url} failed: {e}")
​
​
def launch_server(server_args: ServerArgs):
    # ... 初始化 ...
    if server_args.encoder_register_urls:
        import atexit
        _register_encoder_url_with_bootstrap(server_args)
        # atexit 注册:进程退出时自动清理,避免 stale URL 持续存在
        atexit.register(_unregister_encoder_url_from_bootstrap, server_args)
    uvicorn.run(app, host=server_args.host, port=server_args.port)
python/sglang/srt/disaggregation/encode_receiver.py core-logic

修复 _consecutive_failures 清理时机,防止重启后立即驱逐新注册编码器。

def register(self, url: str) -> bool:
    """注册编码器 URL,如果已存在则跳过添加,但始终清除失败计数。"""
    with self._lock:
        # 修复前:此语句在 if 内部,仅新注册时清理,重启后旧计数残留导致立即驱逐
        self._consecutive_failures.pop(url, None) # 移至开头:无论 url 是否已存在都清理
        if url not in self._urls:
            self._urls.append(url)
            logger.info(f"Registered encoder URL: {url}")
            return True
        logger.debug(f"Encoder URL already registered: {url}")
        return Falsedef unregister(self, url: str) -> bool:
    """注销编码器 URL,同时清理失败计数。"""
    with self._lock:
        if url in self._urls:
            self._urls.remove(url)
            self._consecutive_failures.pop(url, None) # 新增:注销时同步清理失败计数
            logger.info(f"Unregistered encoder URL: {url}")
            return True
        return False

评论区精华

get_local_ip_auto fallback 风险 正确性

gemini-code-assist[bot] 指出当 host 为 wildcard 时,将原始 host 传给 get_local_ip_auto 作为 fallback 可能导致 IP 检测失败后注册不可达的 encoder_url,建议调用时不带 fallback 参数。

结论:作者未采纳建议,提交的代码保留了 server_args.host 作为 fallback。 · unresolved

风险与影响

  1. IP检测fallback风险:如果get_local_ip_auto()在从wildcard host解析实际IP时失败(例如网络接口异常),会fallback回原始wildcard地址(如0.0.0.0),导致注册一个不可达的URL,进而使prefill节点无法联系编码器。
  2. atexit线程安全问题:atexit回调在进程退出时执行,可能与其他线程(如uvicorn的event loop)存在竞态,虽然didn't appear in practice,但需注意。
  3. 缺少测试覆盖:本次PR没有修改或新增测试文件,对于此类核心bugfix,缺少回归测试。
  4. 单点注销失败不阻塞:注销循环中每个bootstrap失败仅日志,不重试,可能在网络瞬断时留下脏数据。

此PR影响所有使用EPD动态编码器注册的用户(集群/多机部署)。修复了URL环回问题使注册在多机环境正常工作、重启竞态提高了编码器故障恢复可靠性、优雅注销减少了调度浪费。影响程度为中等,因为涉及的是EPD可选功能,不影响单机部署。

IP 检测 fallback 风险 缺少测试覆盖 线程安全

关联 Issue

#22253 [EPD] Support dynamic encoder register

完整报告

参与讨论