最近在做网关开发时遇到了一个场景:业务在做多实例部署时需要通过 Ingress 将服务注册给 APISIX,由于每个实例属于一个单独的 namespace,导致在 APISIX 和 Ingress 上出现流量覆盖的问题:

  1. Ingress 通过 Host 和 URI 匹配入向流量,同一个服务的 Host 和 URI 是完全一致的(Host 用 ip:port 表示),因此注册多个 Ingress 流量只会引流到一个实例上。
  2. APISIX 侧在 balance 阶段对于相同 ip:port 的 upstream node 会做去重,导致跨集群多实例时流量分配并不符合预期。

举个例子,有 A,B 两个集群,A 集群部署了 2 个实例,B 集群部署了一个实例。预期这 3 个实例应该平均分配流量(假设采用 rr),A 集群和 B 集群流量比例应该是 2:1,但实际上 A 集群和 B 集群在 APISIX 分配时流量是 1:1,而在 A 集群内一个实例占据了全部流量。

从业务上来说这个可以通过重新规划 namespace 和实例的关系来解决。同一个集群同一个服务的所有实例分布在同一个 namespace 中。这样注册的 Ingress 和 APISIX upstream node 就只会有一个。

抛开这个点,我们来看看从网关的角度如何解决这两个问题。

第一个问题比较好解决,每个注册的实例采用独立的 Domain,Ingress 通过 Host Header 来区分流量。在 APISIX 采用域名注册,域名解析到 Ingress 地址。如果域名没法解析(我们遇到的场景),也可以将 Domain 信息放到 node 的 metadata 内,写一个插件将其注入到 node.domain 内来完成。

第二个问题涉及到 APISIX balance 的主流程,相对来说麻烦一点。这也引出了我们今天的话题。

Upstream Node 的选择流程#

我们先来看看当前 APISIX balancer 是如何选择 upstream node 的。

入口点 - Nginx 阶段调用#

HTTP 请求流程:

Nginx HTTP 请求 → access_by_lua_block → balancer_by_lua_block

Stream 请求流程:

Nginx Stream 请求 → preread_by_lua_block → balancer_by_lua_block

主要调用链路#

Access/Preread 阶段(预选择 upstream node)#

-- apisix/init.lua
function _M.http_access_phase()
    -- 路由匹配和插件处理
    router.router_http.match(api_ctx)
    plugin.run_plugin("access", plugins, api_ctx)
    
    -- 处理 upstream 配置
    _M.handle_upstream(api_ctx, route, enable_websocket)
end

function _M.handle_upstream(api_ctx, route, enable_websocket)
    -- 设置 upstream 配置
    local code, err = set_upstream(route, api_ctx)
    
    -- 预选择服务器
    local server, err = load_balancer.pick_server(route, api_ctx)
    api_ctx.picked_server = server  -- 保存到上下文
end

Balancer 阶段(实际负载均衡)#

-- apisix/init.lua
function _M.http_balancer_phase()
    local api_ctx = ngx.ctx.api_ctx
    load_balancer.run(api_ctx.matched_route, api_ctx, common_phase)
end

-- apisix/balancer.lua
function _M.run(route, ctx, plugin_funcs)
    if ctx.picked_server then
        -- 使用 access 阶段预选择的服务器
        server = ctx.picked_server
        ctx.picked_server = nil
        set_balancer_opts(route, ctx)
    else
        -- 重试场景:重新选择服务器
        server, err = pick_server(route, ctx)
    end
    
    -- 设置当前 peer
    local ok, err = set_current_peer(server, ctx)
end

核心选择逻辑#

服务器选择器创建#

-- apisix/balancer.lua
function pick_server(route, ctx)
    -- 1. 获取健康节点
    local up_nodes = fetch_health_nodes(upstream, checker)
    
    -- 2. 创建服务器选择器
    local server_picker = lrucache_server_picker(key, version, 
                                                create_server_picker, up_conf, checker)
    
    -- 3. 选择服务器
    local server, err = server_picker.get(ctx)
end

function create_server_picker(upstream, checker)
    -- 1. 获取对应的 balancer 类型
    local picker = pickers[upstream.type]  -- roundrobin, chash, least_conn, ewma
    
    -- 2. 处理优先级节点
    if #up_nodes._priority_index > 1 then
        return priority_balancer.new(up_nodes, upstream, picker)
    end
    
    -- 3. 创建具体的选择器
    return picker.new(up_nodes[up_nodes._priority_index[1]], upstream)
end

健康检查#

function fetch_health_nodes(upstream, checker)
    if not checker then
        -- 无健康检查:返回所有节点
        return transform_all_nodes(upstream.nodes)
    end
    
    -- 有健康检查:只返回健康节点
    local up_nodes = {}
    for _, node in ipairs(upstream.nodes) do
        local ok, err = healthcheck_manager.fetch_node_status(checker, 
                                                             node.host, port, host)
        if ok then
            up_nodes = transform_node(up_nodes, node)
        end
    end
    
    -- 如果所有节点都不健康,使用默认节点
    if core.table.nkeys(up_nodes) == 0 then
        return transform_all_nodes(upstream.nodes)
    end
    
    return up_nodes
end

问题点#

问题在于 transform_all_nodes 时,nodes 是以 host:port 作为 key 的(如果原始是域名,host 已经在 handle_upstream 阶段转换成 ip)。所以,如果存在多个相同的后端,只有最后一个后端会生效。

local function transform_node(new_nodes, node)
    if not new_nodes._priority_index then
        new_nodes._priority_index = {}
    end

    if not new_nodes[node.priority] then
        new_nodes[node.priority] = {}
        core.table.insert(new_nodes._priority_index, node.priority)
    end

	--- host:port format key
    new_nodes[node.priority][node.host .. ":" .. node.port] = node.weight
    return new_nodes
end

local function create_server_picker(upstream, checker)

        local nodes = upstream.nodes
        local addr_to_domain = {}
        for _, node in ipairs(nodes) do
            if node.domain then
	            -- 存放 domain 的 table 也是用 host:port 作为 key 的
                local addr = node.host .. ":" .. node.port
                addr_to_domain[addr] = node.domain
            end
        end
end

解决思路#

这应该并不是一个很常见的场景。要解决这个问题,主要还是得从 key 的唯一性出发。有几个思路:

  1. key 添加 node 的 index。但是会存在 upstream 或者健康检查更新后 index 变化的问题;
  2. key 直接添加 domain。这种无法解决所有的 ip:port 重复的问题(对于 domain 也想同或者无 domain 的场景)。 相比之下,key 直接添加 domain 对于稳定性的影响是较小的。相关的代码修改放到了 https://github.com/chengzhycn/apisix/tree/node-domain 分支中。

由于对 APISIX 并不熟悉,这个问题也已经给社区提问:https://github.com/apache/apisix/issues/12664,看看社区的大佬如何看待这个问题。