elizax opened a new pull request, #12968: URL: https://github.com/apache/apisix/pull/12968
# ai-proxy-multi 健康检查过滤解决方案 ## 问题描述 ### 现象 - ai-proxy-multi 插件配置了多个 LLM 实例 - 健康检查检测到某些实例返回 404,标记为 unhealthy - 但负载均衡器仍然选择这些不健康的实例处理请求 ### 预期行为 不健康的实例应该被自动剔除,只将健康的实例加入负载均衡池。 ### 测试场景 - **路由 ID**: 604799084062049420 - **URI**: /test/* - **实例 1** (故障): http://192.168.132.162:9080/chat/completions (返回 404) - **实例 2** (健康): http://192.168.132.162:9080/apisix/health (返回 200) --- ## 根本原因分析 ### 核心问题:Worker 间健康状态不同步 #### 1. Upstream vs ai-proxy-multi 的架构差异 | 特性 | Upstream | ai-proxy-multi | | ---------------------- | --------------------------- | --------------------------------------------------- | | **配置来源** | `res_conf.value.upstream` | 动态构建 (`plugin.construct_upstream`) | | **Checker 创建** | 内置机制,直接使用 | 需要通过 `healthcheck_manager.fetch_checker` 获取 | | **Worker 同步** | 通过 balance 模块保证 | **无同步机制** | | **健康状态读取** | 统一接口 | 各 worker 独立读取 | #### 2. 问题一:Nil Checker 导致健康检查失效 **原始代码**: ```lua local checker = healthcheck_manager.fetch_checker(resource_path, resource_version) checkers = checkers or {} checkers[instance.name] = checker -- ❌ checker 是 nil 也会加入 ``` **问题**: - 重启后,`fetch_checker` 可能返回 nil(checker 尚未创建) - `checkers = {["instance1"] = nil, ["instance2"] = nil}` - `next(checkers)` 返回 `("instance1", nil)` - `get_shm_info` 中 `checker_ref` 为 nil,无法获取 `.shm` - 所有实例被默认加入 picker **解决方案**: ```lua local checker = healthcheck_manager.fetch_checker(resource_path, resource_version) if checker then -- ✅ 只有非 nil 的 checker 才加入 checkers = checkers or {} checkers[instance.name] = checker end ``` #### 3. 问题二:`_dns_value` 运行时字段缺失 **原始代码**: ```lua function _M.construct_upstream(instance) local node = instance._dns_value -- ❌ 直接读取运行时字段 if not node then return nil, "failed to resolve endpoint for instance: " .. instance.name end -- ... end ``` **问题**: - `_dns_value` 是在请求处理过程中通过 `resolve_endpoint()` 动态生成的运行时字段 - 定时器从配置中心(etcd/Admin API)读取的是原始配置,不包含 `_dns_value` 字段 - 定时器创建 checker 时调用 `construct_upstream`,检查 `_dns_value` 不存在时返回 nil - 导致 checker 创建失败,从 waiting_pool 删除,后续请求永远获取不到 checker **定时器创建 checker 流程**: ``` 1. resource.fetch_latest_conf(resource_path) → 从 etcd 读取原始配置(没有 _dns_value) 2. jp.value(res_conf.value, json_path) → 提取 instance 配置(仍没有 _dns_value) 3. plugin.construct_upstream(instance_config) → 检查 instance._dns_value → 不存在 → 返回 nil ❌ 4. create_checker(upstream) → upstream 为 nil → 创建失败 ❌ ``` **解决方案**: ```lua // 新增函数:从配置计算 DNS node local function calculate_dns_node(instance_conf) local scheme, host, port local endpoint = core.table.try_read_attr(instance_conf, "override", "endpoint") if endpoint then scheme, host, port = endpoint:match(endpoint_regex) if port == "" then port = (scheme == "https") and "443" or "80" end port = tonumber(port) else local ai_driver = require("apisix.plugins.ai-drivers." .. instance_conf.provider) scheme = "https" host = ai_driver.host port = ai_driver.port end local node = { host = host, port = tonumber(port), scheme = scheme, } parse_domain_for_node(node) return node end // 修改 construct_upstream function _M.construct_upstream(instance) local upstream = {} local node = instance._dns_value // ✅ 如果 _dns_value 不存在,从配置自动计算 if not node then core.log.info("instance._dns_value not found, calculating from config for instance: ", instance.name) node = calculate_dns_node(instance) if not node then return nil, "failed to calculate endpoint for instance: " .. instance.name end end -- ... end ``` **效果**: - 定时器创建 checker 时,即使 `_dns_value` 不存在也能自动计算 - 不再依赖运行时生成的字段 - Checker 可以正常创建 #### 4. 问题三:LRU 缓存导致过期 Picker 被复用 **原始机制**: ```lua local version = plugin.conf_version(conf) -- 配置不变则 version 不变 local server_picker = lrucache_server_picker(ctx.matched_route.key, version, ...) ``` **问题**: - `conf_version` 只有配置变更时才变化 - LRU 缓存 TTL = 10 秒 - 健康状态变化时,缓存 key 不变,过期 picker 继续被使用 **解决方案**: ```lua local version = plugin.conf_version(conf) if checkers then local status_ver = get_checkers_status_ver(checkers) -- 健康状态变化时递增 version = version .. "#s" .. status_ver end ``` **效果**: - 健康状态变化 → `status_ver` 递增 → `version` 变化 → LRU 缓存失效 → 创建新 picker --- ## 解决方案详解 ### 方案一:通过 SHM 同步 Worker 间健康状态 #### 问题 不同 worker 的 `checker` 对象是独立的,本地缓存通过 worker events 异步同步,导致状态不一致。 #### 解决 直接从 SHM(共享内存)读取权威健康状态,绕过本地缓存: ```lua local function fetch_health_status_from_shm(shm, checker_name, ip, port, hostname, instance_name) local lookup_hostname = hostname or ip local state_key = string.format("lua-resty-healthcheck:%s:state:%s:%s:%s", checker_name, ip, port, lookup_hostname) local state = shm:get(state_key) if state then -- State: 1=healthy, 2=unhealthy, 3=mostly_healthy, 4=mostly_unhealthy local ok = (state == 1 or state == 3) return ok, state end -- 状态未找到,默认为健康 return true, nil end ``` **关键点**: - SHM 是所有 worker 共享的内存区域 - 健康检查状态写入 SHM,所有 worker 都能读取到一致的状态 - 绕过了本地缓存可能存在的延迟 --- ## 完整代码修改 ### 文件:`ai-proxy-multi.lua` #### 修改 1:只添加有效的 Checker ```lua // 第 381-385 行 local checker = healthcheck_manager.fetch_checker(resource_path, resource_version) if checker then -- ✅ 关键修改 checkers = checkers or {} checkers[instance.name] = checker end ``` #### 修改 2:添加 SHM 状态读取函数 ```lua // 第 228-250 行 local function fetch_health_status_from_shm(shm, checker_name, ip, port, hostname, instance_name) local lookup_hostname = hostname or ip local state_key = string.format("lua-resty-healthcheck:%s:state:%s:%s:%s", checker_name, ip, port, lookup_hostname) core.log.info("[SHM-DIRECT] instance=", instance_name, " key=", state_key) local state = shm:get(state_key) if state then local ok = (state == 1 or state == 3) core.log.info("[SHM-DIRECT] instance=", instance_name, " state=", state, " ok=", ok) return ok, state end core.log.warn("[SHM-DIRECT] state not found for instance=", instance_name, ", defaulting to healthy") return true, nil end ``` #### 修改 3:获取 SHM 和 Checker 信息 ```lua // 第 253-273 行 local function get_shm_info(checkers, conf, i, ins) local checker = checkers and checkers[ins.name] -- 优先使用实例自己的 checker if checker and checker.shm then core.log.info("[SHM-DEBUG] instance=", ins.name, " using own checker") return checker.shm, checker.name end -- 回退:使用另一个 checker 的 SHM local checker_ref = checkers and next(checkers) if checker_ref and checker_ref.shm then local checker_name = "upstream#" .. conf._meta.parent.resource_key .. "#plugins['ai-proxy-multi'].instances[" .. (i - 1) .. "]" core.log.info("[SHM-DEBUG] instance=", ins.name, " using fallback checker_ref, checker_name=", checker_name) return checker_ref.shm, checker_name end core.log.warn("[SHM-DEBUG] instance=", ins.name, " checkers=", checkers and "exists" or "nil", " checker_ref=", checker_ref and "exists" or "nil") return nil, nil end ``` #### 修改 4:使用 Status Ver 作为缓存 Key ```lua // 第 389-405 行 -- 使用 status_ver 作为缓存 key,健康状态变化时立即刷新 local version = plugin.conf_version(conf) if checkers then local status_ver = get_checkers_status_ver(checkers) version = version .. "#s" .. status_ver end -- 使用 LRU 缓存减少 SHM 访问 local server_picker = ctx.server_picker if not server_picker then server_picker = lrucache_server_picker(ctx.matched_route.key, version, create_server_picker, conf, ups_tab, checkers) end if not server_picker then return nil, nil, "failed to create server picker" end ctx.server_picker = server_picker ``` --- ## 测试结果 ### 测试 1:重启后立即测试 ``` 等待时间: 2 秒 结果: 88/100 成功 (88%) 说明: 前14个请求中7个404,之后全部成功 原因: 健康检查由请求触发,首次请求时 checker 尚未完成第一次检查 ``` ### 测试 2:健康检查完成后 ``` 结果: 100/100 成功 (100%) 说明: 所有请求都正确选择了健康的实例 ``` ### 日志证据 ``` # Picker 正确创建为只包含健康实例 [info] fetch health instances: {"_priority_index":[0],"0":{"deepseek-instance2":1}} # SHM 读取显示正确的健康状态 [info] [SHM-DIRECT] instance=deepseek-instance1 state=2 ok=false (unhealthy) [info] [SHM-DIRECT] instance=deepseek-instance2 state=1 ok=true (healthy) # 实例选择 [info] picked instance: deepseek-instance2 ✓ (只选择健康实例) ``` --- ## 关键要点总结 ### 1. Worker 间状态同步是核心问题 **Upstream 的工作方式**: - 内置 balance 模块统一管理 - Checker 创建和状态更新有统一机制 - 各 worker 自动同步状态 **ai-proxy-multi 的挑战**: - 插件动态构建 upstream - Checker 通过 `healthcheck_manager` 异步创建 - **没有内置的 worker 间同步机制** **解决方案**: - 通过直接读取 SHM 获取权威健康状态 - 所有 worker 从同一数据源读取,确保一致性 ### 2. LRU 缓存失效机制决定 Picker 更新频率 **缓存 Key 的演进**: | 方案 | 缓存 Key | 问题 | 解决 | | ------------------ | -------------------------------------- | ------------------------------ | --------------- | | 原始 | `conf_version` | 配置不变则 key 不变 | 添加 status_ver | | **最终方案** | `conf_version .. "#s" .. status_ver` | **健康状态变化立即刷新** | ✅ | **效果**: - 健康状态变化 → `status_ver` 递增 → 缓存 key 变化 → LRU 失效 → 创建新 picker - LRU TTL = 10 秒作为兜底,防止 status_ver 漏网 ### 3. Nil Checker 处理是细节关键 **问题**: ```lua checkers[instance.name] = nil -- next(checkers) 返回 nil 值 ``` **后果**: - `next(checkers)` 返回 `("key", nil)` - `checker_ref` 为 nil - 无法访问 `checker_ref.shm` - 健康检查失效 **解决**: ```lua if checker then checkers[instance.name] = checker -- 只添加有效值 end -- next(checkers) 要么返回有效 checker,要么返回 nil ``` --- ## 重启后前几秒的 404 问题 ### 现象 重启后前几秒(约 3-5 秒)仍会有少量 404 响应。 ### 原因 这是**预期行为**,原因: 1. 健康检查由**请求触发**,非定时运行 2. 目标初始状态为 `unhealthy`(修复后) 3. 第一次健康检查完成后才会标记为 healthy/unhealthy 4. 在健康检查完成前,picker 可能包含所有实例 ### 优化建议(可选) 如果需要完全消除重启后的 404,可以考虑: 1. **在 `healthcheck_manager.lua` 中添加启动时预热逻辑** 2. **调整健康检查参数**: - 增加 `healthy.successes`(需要更多成功才标记为 healthy) - 降低 `unhealthy.http_failures`(更快标记为 unhealthy) --- ## 相关文件 | 文件路径 | 作用 | 是否修改 | | -------------------------------------------- | -------------- | -------- | | `apisix/plugins/ai-proxy-multi.lua` | 多实例路由插件 | ✅ 是 | | `apisix/healthcheck_manager.lua` | Checker 管理 | ❌ 否 | | `deps/share/lua/5.1/resty/healthcheck.lua` | 健康检查库 | ❌ 否 | --- ## 总结 通过四个层面的组合修复,成功解决了 ai-proxy-multi 健康检查过滤的问题: ### 1. Worker 间状态同步(核心问题) - **问题**:ai-proxy-multi 没有 Upstream 那样的内置同步机制 - **解决**:直接从 SHM 读取权威健康状态 ### 2. 定时器创建 Checker 失败(基础问题) - **问题**:定时器从配置中心读取配置时,`_dns_value` 运行时字段不存在 - **解决**:添加 `calculate_dns_node()` 函数,从配置自动计算 endpoint ### 3. LRU 缓存失效机制(效率保证) - **问题**:缓存 key 不变导致过期 picker 被复用 - **解决**:使用 status_ver 作为缓存 key,健康状态变化立即刷新 ### 4. Nil Checker 处理(细节关键) - **问题**:nil 值被加入 checkers 表导致健康检查失效 - **解决**:只有有效的 checker 才加入表 ### 3. Nil Checker 处理(细节关键) - **问题**:nil 值加入 checkers 表导致健康检查失效 - **解决**:只有有效的 checker 才加入表 **核心改进**:从依赖运行时生成的字段和本地缓存,改为支持从 SHM 读取权威健康状态,并通过 status_ver 实现缓存的即时失效。 这使得系统在健康检查完成后能够达到 **100% 的成功率**,正确剔除不健康的实例。 -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
