热更新-动态路由

前言

在传统微服务体系中,热更新是一个非常重要的功能,可以在网关运行,不重启的情况下,动态修改配置并生效。我们可以把路由分为三种模式

  1. nginx/openresty支持的静态路由,本质是修改nginx.conf,重启nginx生效
  2. 服务自动发现
  3. 热更新

热更新最重要的是可以动态的更新upstream,Tengine提供的第三方模块nginxngx_http_dyups_module允许在运行时通过 HTTP 接口添加、删除或修改 upstream 配置,无需重启 Nginx。

1、ngx_http_dyups_module模块

  • ngx_http_dyups_module 最初由阿里巴巴基于 Tengine 分支开发,目标是在不重启或重载 Nginx/Tengine 进程的情况下,实现对 upstream 集群的动态管理(增删、更新、查询)。
  • 传统 Nginx 要更新 upstream 配置,需要修改配置文件后执行 nginx -s reload,过程中会有瞬间连接中断风险,而 dyups 模块则提供了一个 “在线” 接口,能够在 HTTP 运行时通过 RESTful 或 Lua API 对 upstream 进行增删改查

1.1、主要数据结构与共享内存设计

全局上下文:ngx_dyups_global_ctx

  • 在初始化(init_module)阶段,模块通过 ngx_shared_memory_add("dyups_shm_zone", size, &ngx_http_dyups_module) 分配一块共享内存。该内存段映射到主进程和所有 Worker,共同维护以下重要字段:
    • shpool(类型为 ngx_slab_pool_t *):专用于分配共享内存中的消息节点和字符串副本;
    • shctx(类型为自定义结构,通常称 ngx_dyups_shctx_t *):包含消息队列头指针、尾指针、当前版本号 version、消息计数 msg_count、以及存储 Worker 状态的数组 status[] 等。

消息节点:ngx_dyups_msg_t

  • 每条对 upstream 的增删改查动作最终会被封装成一个 ngx_dyups_msg_t 节点,写入共享内存中的链表:

    1
    2
    3
    4
    5
    6
    7
    8
    typedef struct {
    ngx_queue_t queue; // 链表节点
    ngx_uint_t type; // 操作类型:ADD / DELETE / UPDATE
    ngx_str_t name; // 上游名称
    ngx_str_t content; // 包含实际 server 列表的字符串
    pid_t pid[NGX_MAX_WORKERS]; // 标记哪些 Worker 已处理
    ngx_uint_t count; // 已处理 Worker 数目
    } ngx_dyups_msg_t;
  • pid[] 用来记录各 Worker 进程对该消息的处理状态;当 count 达到当前 worker_processes 数量时,表示所有 Worker 均已读取该消息,无需再转发或保留。

Worker 状态数组:sh->status[]

  • shctx->status 是一个长度等于 worker_processes 的数组,每个元素为:

    1
    2
    3
    4
    typedef struct {
    pid_t pid; // Worker 进程号
    time_t time; // 最后一次心跳时间戳
    } ngx_dyups_status_t;
  • init_process 阶段,每个 Worker 会在共享内存中注册自己的 pid,并设置第一次的 time = now。这一信息用于:

    1. 标记哪些 Worker 仍处于活跃状态;
    2. 防止由于某个 Worker 异常退出导致共享消息长时间无法累积至 count == worker_processes 而悬挂。

** 共享内存分配粒度**

  • 共享内存池由 ngx_slab_pool_t 管理,所有对消息节点和动态存储 upstream 字符串的分配都来自该 Slab。这样可以避免不同进程使用各自的堆内存,确保跨进程可见。
  • 默认 dyups_shm_zone_size 2M 时,可能一次只能发送有限大小的 upstream 信息;对于包含大量 server 的集群,需要调大该参数以防止分配失败。

1.2、动态增删改查

本节梳理一次典型的 “更新(Add/Update)” 操作流程,删除操作类似,但在正式更新时会调用 ngx_dyups_delete_upstream

1、 外部 API 触发与初步检查

  1. 当客户端通过 curl -d "server X:Y;" http://host/dyups/upstream/<name> 发送更新请求时,进入 Nginx 的某个 Worker 并调用 ngx_http_dyups_update_upstream(ngx_str_t *name, ngx_buf_t *buf, ngx_str_t *rv)
  2. 函数开头会检查全局 ngx_http_dyups_api_enable 标志;若未启用,则直接返回 NGX_HTTP_NOT_ALLOWED (405),并在 rv 中设置 "API disabled" 提示。

2、 获取共享内存锁

  1. 根据主配置 dmcf->trylock 判断锁模式:
    • trylock == 0(阻塞模式),调用 ngx_shmtx_lock(&shpool->mutex),若有其它 Worker 持锁,则本 Worker 阻塞,直到锁可用;
    • trylock == 1(非阻塞模式),调用 ngx_shmtx_trylock(&shpool->mutex),若抢锁失败,则立即设置 rv="wait and try again" 并返回 NGX_HTTP_CONFLICT (409),告知客户端稍后重试。
  2. 上锁成功后,才能安全访问 shctx->msg_queue 以及共享 upstream 数据。

3、 读取并清理历史消息

  1. 调用 ngx_http_dyups_read_msg_locked(timer)(需在持锁状态下执行),先扫描共享内存中 shctx->msg_queue 列表:
    • 如果某个 msg->count == total_worker,则说明该消息已被所有 Worker 处理,可安全移出队列并调用 ngx_dyups_destroy_msg 释放对应共享内存;
    • 否则,对于尚未被本 Worker 处理的消息节点(通过检查 pid[] 数组判断),调用相应内部函数(如 ngx_dyups_do_updatengx_dyups_delete_upstream)在本地内存执行对应操作,并将本 Worker PID 加入 pid[],令 msg->count++
  2. 该步骤的目的是:先把其他 Worker 上次发送的更新都在本进程内同步完成,保证不会在后续操作中丢失或冲突。

4、 沙箱验证(ngx_dyups_sandbox_update

  1. 读取完历史消息后,函数调用 status = ngx_dyups_sandbox_update(buf, rv)
    • 该函数会在内存中临时创建一个 “伪” ngx_http_upstream_srv_conf_t 结构,解析并检查 buf 中包含的 server ...; 列表;
    • 如果语法错误(例如缺少分号、IP:端口格式不对)或参数非法(如 weight=0),则 status != NGX_HTTP_OK,并在 rv 中返回相应错误信息,跳转至 finish 标签,释放锁并返回错误。
  2. 通过沙箱验证意味着新配置符合 Nginx 语法要求且参数合法,但尚未正式写入到主内存结构。

5、 正式更新(ngx_dyups_do_update

  1. 执行 status = ngx_dyups_do_update(name, buf, rv)
    • 在本 Worker 进程的 upstream_conf_hash 中,查找是否已存在名为 nameupstream;若不存在则创建新的 ngx_http_upstream_srv_conf_t 对象;
    • 按照沙箱中的解析结果,构建或更新一个 ngx_http_upstream_server_t 链表,其中包含每个后端服务器节点的 IP、权重、最大连接数、失败重试等参数;
    • 对比新旧列表,执行 “新增”、“修改”、“删除” 三类操作,替换原有数据结构;
    • 调用原生 Nginx 的 ngx_http_upstream_init_round_robininit_chash_peer 等函数,重建负载均衡相关状态,保证后续请求调度能使用最新集群。
  2. 如果 ngx_dyups_do_update 返回非 NGX_HTTP_OK,说明更新过程中出现内存分配失败或其他异常,此时会跳到 finish,释放锁并返回错误码。

6、 消息广播到其他 Worker(ngx_http_dyups_send_msg

  1. 若正式更新成功(status == NGX_HTTP_OK),则调用 ngx_http_dyups_send_msg(name, buf, NGX_DYUPS_ADD)
    • 通过 ngx_slab_alloc_lockedshpool 分配一个 ngx_dyups_msg_t 大小的内存;
    • name、更新内容 buf(通常是以字符串形式保存)、type = NGX_DYUPS_ADDcount = 1(本 Worker 自己先算一次)以及 pid[0] = ngx_pid 填入消息结构;
    • 将新消息插入 shctx->msg_queue 链表尾部。
  2. 如果写入共享内存失败(如剩余空间不足),则 ngx_http_dyups_send_msg 返回非零,调用者将 rv="alert: update success but not sync to other process",并最终返回 NGX_HTTP_INTERNAL_SERVER_ERROR (500),提醒运维重启或人工介入。

7、 释放共享内存锁并返回

  1. 不论上述哪个环节出错或成功,都会执行 finish: 标签中的 ngx_shmtx_unlock(&shpool->mutex),释放独占锁。
  2. 最终返回值 status 可能是:
    • NGX_HTTP_OK (200):更新成功且消息已写入共享内存;
    • NGX_HTTP_BAD_REQUEST (400):沙箱测试失败;
    • NGX_HTTP_CONFLICT (409):非阻塞锁未能获得;
    • NGX_HTTP_INTERNAL_SERVER_ERROR (500):正式更新成功但无法写入共享内存;
    • NGX_HTTP_NOT_ALLOWED (405):未启用 dyups API。

1.3、进程间同步机制(消息队列与定时器)

1、定时器驱动:ngx_add_timer(&msg_timer, read_msg_timeout)

  • 在每个 Worker 的 init_process 阶段,模块会调用 ngx_add_timer(&ngx_dyups_global_ctx.msg_timer, read_msg_timeout),设置一个定时事件。
  • 定时器回调函数是 ngx_http_dyups_read_msg,其内部会先判断当前时间与 ev->timer.key 是否过期,若到期就调用 ngx_http_dyups_read_msg_locked
  • 该定时器默认以 dyups_read_msg_timeout 1s 周期触发,但可根据场景调整(如改为 500ms2s)。

2、持锁读取消息:ngx_http_dyups_read_msg_locked

  • 该函数会首先调用 ngx_shmtx_lock(&shpool->mutex);若有其它 Worker 正在更新,会阻塞直到锁空闲;若设置了 dyups_trylock on;,读取方仍是阻塞获取锁,无法立即跳过。
  • 遍历 shctx->msg_queue
    1. msg->count == total_worker,说明所有 Worker 都已处理完毕,调用 ngx_queue_remove 将该消息从链表中移除,并调用 ngx_dyups_destroy_msg 释放内存;
    2. 否则,检查 msg->pid[] 数组,如果当前 Worker PID 尚未出现,则根据 msg->typeADD/DELETE)执行对应操作(调用 ngx_dyups_do_updatengx_dyups_delete_upstream),然后将本 PID 写入 pid[],令 msg->count++;否则跳过。
  • 处理完所有消息后,调用 ngx_shmtx_unlock(&shpool->mutex),并重置或延迟下一次定时器(ngx_add_timer(ev, read_msg_timeout))。

3、Worker 状态更新:shctx->status[]

  • ngx_http_dyups_read_msg_locked 开头,Worker 会先尝试在 shctx->status[] 中找到或注册自己的 pid,并更新对应 time = now
  • 若某些 status[].pid 长时间无 “心跳” 更新(超时),模块会认为对应 Worker 已崩溃或退出,并在后续清理过程中剔除它对消息的未处理计数,以避免死消息永久挂起。

1.4、锁机制与并发控制(阻塞与非阻塞模式)

锁保护范围

  • 每次写入或读取共享内存时,都必须先调用 ngx_shmtx_lock(&shpool->mutex);在写操作(如 ngx_http_dyups_update_upstream)中,整个流程包括读取旧消息、沙箱验证、正式更新、消息写入共享内存,全部在同一把锁内完成,保证对共享内存的原子性与一致性。

阻塞锁(默认模式)

  • 当一个 Worker 开始更新时,其它尝试更新或读取消息的 Worker 都会在 ngx_shmtx_lock 处阻塞;在长时间更新(如包含大量节点的 upstream)期间,可能导致多个 Worker 长时间处于挂起状态,无法处理新来的业务请求。
  • 这种场景在后文性能章节还会详细分析。

非阻塞锁(dyups_trylock on

  • 若设置 dyups_trylock on;,写操作中改用 ngx_shmtx_trylock,若锁被占用则立即返回 NGX_HTTP_CONFLICT (409),上层接口告知客户端 “wait and try again”,无需 Worker 长时间阻塞。
  • 定时器读取消息 的路径仍是阻塞式 ngx_shmtx_lock,若锁被占用则轮询线程仍要等到锁空闲才能继续同步,可能短暂挂起业务。

锁粒度优化思考

  • 将锁粒度最小化:沙箱验证、实际更新、消息写入间尽量迅速;
  • 调整 dyups_read_msg_timeout 周期:过小会频繁抢锁,过大则延迟配置同步;
  • 在高并发场景下,优先使用 trylock on,让更新请求自行重试,而不是让 Worker 一直挂起。

1.5、源码分析

1
2
3
4
5
6
ngx_http_dyups_interface_handler()
└── ngx_http_dyups_interface_read_body()
└── ngx_http_dyups_body_handler()
└── ngx_dyups_update_upstream()
└── ngx_dyups_do_update()
└── ngx_dyups_add_server()

1、注册location的处理函数

1
2
3
4
5
6
{ ngx_string("dyups_interface"),
NGX_HTTP_LOC_CONF|NGX_CONF_NOARGS,
ngx_http_dyups_interface,
0,
0,
NULL },
1
2
3
4
5
6
7
8
9
10
11
12
13
static char *
ngx_http_dyups_interface(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
{
ngx_http_core_loc_conf_t *clcf;
ngx_http_dyups_main_conf_t *dmcf;

dmcf = ngx_http_conf_get_module_main_conf(cf, ngx_http_dyups_module);
clcf = ngx_http_conf_get_module_loc_conf(cf, ngx_http_core_module);
clcf->handler = ngx_http_dyups_interface_handler;
dmcf->enable = 1;

return NGX_CONF_OK;
}

2、更新upstream

这是最主要处理函数,主要做了5步

  • 获取共享内存锁(可选阻塞或非阻塞模式),以保证多个进程不会并发修改
  • 从共享内存中读取之前未处理的消息,并根据需要在共享内存中进行更新或删除操作;
  • 在“沙箱”中(sandbox)先行尝试本次上游配置更新,以验证新配置语法和结构是否正确;
  • 如果沙箱测试通过,则按正式流程调用 ngx_dyups_do_update 实际更新上游链表;
  • 在更新成功后,将本次更新操作写入共享内存,使其他 Worker/进程能够同步到新的配置;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
ngx_int_t
ngx_dyups_update_upstream(ngx_str_t *name, ngx_buf_t *buf, ngx_str_t *rv)
{
ngx_int_t status;
ngx_event_t *timer;
ngx_slab_pool_t *shpool;
ngx_http_dyups_main_conf_t *dmcf;

dmcf = ngx_http_cycle_get_module_main_conf(ngx_cycle,
ngx_http_dyups_module);


.......................
// 获取共享内存锁
if (!dmcf->trylock) {
ngx_shmtx_lock(&shpool->mutex); // 阻塞方式获取锁
} else {
if (!ngx_shmtx_trylock(&shpool->mutex)) { // 非阻塞方式尝试获取锁
ngx_str_set(rv, "wait and try again\n");
return NGX_HTTP_CONFLICT;
}
}
// 读取共享内存中的消息
ngx_http_dyups_read_msg_locked(timer);

// 在沙箱中测试更新
status = ngx_dyups_sandbox_update(buf, rv);
.......................

// 执行实际的上游更新操作
status = ngx_dyups_do_update(name, buf, rv);
if (status == NGX_HTTP_OK) {
// 同步更新消息到其他进程
if (ngx_http_dyups_send_msg(name, buf, NGX_DYUPS_ADD)) {
.......................
}
}
.......................
}

2、ingress-nginx的热更新实现

对于一个功能,一定要多学习借鉴同层次产品的实现。ingress-nginx也实现了热更新,但是是通过lua层面实现的,有一篇文章总结的非常好 https://xiaorui.cc/archives/7342

  1. Lua 负载均衡:在 balancer_by_lua_block 中使用 lua-resty-balancer,在共享内存中根据算法选取可用后端。
  2. 主动推送:当 Kubernetes 集群中 Ingress/Service/Endpoint 变更时,Control-Plane 通过 HTTP POST 将新的后端 JSON 发送到 OpenResty 内部接口 /configuration/backends
  3. 写入共享内存:该 Lua 接口读取请求体,使用 cjson.decode() 解析 JSON,并将后端列表序列化后写入 ngx.shared.dynamic_upstreamslua_shared_dict
  4. Worker 周期性拉取:尽管 Control-Plane 会主动推送,仍需在每个 Worker 的 init_worker_by_lua_block 中注册定时器,每隔 1 秒调用 sync_backendssync_backends_with_external_name,从 Controller HTTP 接口拉取最新后端配置,保证所有新启动或错过推送的 Worker 都能补偿性地同步到共享内存 。
  5. 请求时读取共享内存:在每个请求的 balancer_by_lua_block 阶段,Lua 脚本通过 ngx.shared.dynamic_upstreams:get(service_name) 读取最新后端列表,执行加权/轮询等算法后调用 ngx.balancer.set_current_peer(host, port),完成动态路由决策,而无需重载 NGINX。
  6. 这样,Ingress-NGINX 能在高可用场景下实现“推送+拉取+读取”三步闭环,既能及时响应变更,又能弥补短暂的推送失效,最大化减少重载带来的抖动和中断。

2.1、前置三步确认

  1. Lua 负载均衡(lua-resty-balancer)
  • Ingress-NGINX 在 nginx.tmpl 中定义了若干 lua_shared_dict,例如:

    1
    lua_shared_dict dynamic_upstreams 5m;

    用于存储后端服务地址与权重等信息,供 Lua 层访问

  • 在具体的 upstream 块中,使用 balancer_by_lua_block 指令调用 Lua 脚本(如 balancer.lua)进行动态负载均衡:

    1
    2
    3
    4
    5
    6
    upstream dynamic_upstream {
    balancer_by_lua_block {
    local balancer = require("lua_ingress").balancer
    balancer("default-my-service") -- 传入服务名
    }
    }

    lua-resty-balancer 或自研脚本会读取 ngx.shared.dynamic_upstreams 中同名键对应的 JSON 字符串,cjson.decode() 转为 Lua 表后,根据配置选择后端

  1. 路由变更时主动推送
  • 当 Kubernetes 集群中 Ingress 或 Service/Endpoint 发生变更时,Ingress-NGINX Controller 会构建一个类似如下格式的 JSON:

    1
    2
    3
    {
    "default-my-service": ["10.0.0.5:80", "10.0.0.6:80"]
    }

    然后向本地 OpenResty 暴露的 /configuration/backends 接口发起 HTTP POST 请求 。

  • 该接口在 Lua 层

    1
    2
    3
    4
    if ngx.var.request_uri == "/configuration/backends" then
    handle_backends()
    return
    end

    成功后返回 HTTP 200,完成一次“主动推送+写入共享内存”的操作 。

  1. 接口解析与存储
  • /configuration/backends 接口对应的 Lua 实现在 configuration.lua 中,核心为 handle_backends() 函数:

  • 这段代码主要实现以下功能:

    1. 支持两种HTTP方法:
      • GET:获取当前后端配置
      • POST:更新后端配置
    2. 关键操作流程:
      • 读取请求体数据
      • 将新配置存入共享内存
      • 记录同步时间戳
      • 处理各种错误情况
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    local function handle_backends()
    -- 处理GET请求:返回当前后端配置数据
    if ngx.var.request_method == "GET" then
    ngx.status = ngx.HTTP_OK
    ngx.print(_M.get_backends_data())
    return
    end

    -- 读取请求体中的后端配置数据
    local backends = fetch_request_body()
    if not backends then
    ngx.log(ngx.ERR, "dynamic-configuration: unable to read valid request body")
    ngx.status = ngx.HTTP_BAD_REQUEST
    return
    end

    -- 将新的后端配置存入共享内存
    local success, err = configuration_data:set("backends", backends)
    if not success then
    ngx.log(ngx.ERR, "dynamic-configuration: error updating configuration: " .. tostring(err))
    ngx.status = ngx.HTTP_BAD_REQUEST
    return
    end

    -- 更新同步时间戳
    ngx.update_time() -- 确保获取当前精确时间
    local raw_backends_last_synced_at = ngx.time()

    -- 记录最后同步时间到共享内存
    success, err = configuration_data:set("raw_backends_last_synced_at", raw_backends_last_synced_at)
    if not success then
    ngx.log(ngx.ERR, "dynamic-configuration: error updating when backends sync, " ..
    "new upstream peers waiting for force syncing: " .. tostring(err))
    ngx.status = ngx.HTTP_BAD_REQUEST
    return
    end

    -- 返回创建成功的状态码
    ngx.status = ngx.HTTP_CREATED
    end

    如果需要同步 ExternalName 服务,则 Controller 还会调用 /configuration/backends/external 接口,Lua 在 sync_backends_with_external_name() 中提取域名并进行 DNS 解析,写入同一共享内存 。


2.2、第4步:Worker 周期性拉取

sync_backends & sync_backends_with_external_name

尽管 Controller 会主动推送,但需要在每个 Worker 内注册定时器,周期性(一般为 1 秒)拉取最新配置,原因如下:

4.1、 设计初衷

  1. Worker 冷启动补偿
    • 新创建的 Worker 并未能经历之前 Controller 的推送;如果不主动拉取,就只能在下一次有变更时才有机会拿到配置,造成“冷启动白屏”或“脏数据路由”现象 。
  2. 网络或推送失败补偿
    • Controller 在推送时可能因网络抖动、Lua 解析错误等意外导致部分 Worker 未能及时更新共享内存;定时拉取能在网络恢复后及时获取正确数据,保证“最终一致性” 。
  3. 并发推送竞态场景
    • 当多个 Controller 同时推送不同版本的后端列表时,某些 Worker 可能接收 A 版本但错过 B 版本推送;定时拉取确保 Worker 能以“最新可用版本”为准,避免短暂竞态导致长时间漂移 。

4.2、 定时器注册

1、初始化时启动定时器,ngx.timer.at只会执行一次,ngx.timer.every会重复执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function _M.init_worker()
-- 当worker进程启动时,立即同步非ExternalName类型的后端服务
-- 这些后端不需要DNS解析,可以直接处理
sync_backends()

-- 对于需要DNS解析的ExternalName类型后端服务,
-- 需要通过定时器异步处理(因为init_worker阶段无法使用socket)
local ok, err = ngx.timer.at(0, sync_backends_with_external_name)
if not ok then
ngx.log(ngx.ERR, "failed to create timer: ", err)
end

-- 设置定期同步普通后端服务的定时器
-- 同步间隔为BACKENDS_SYNC_INTERVAL(默认为1秒)
ok, err = ngx.timer.every(BACKENDS_SYNC_INTERVAL, sync_backends)
if not ok then
ngx.log(ngx.ERR, "error when setting up timer.every for sync_backends: ", err)
end

-- 设置定期同步ExternalName后端服务的定时器
-- 使用相同的同步间隔
ok, err = ngx.timer.every(BACKENDS_SYNC_INTERVAL, sync_backends_with_external_name)
if not ok then
ngx.log(ngx.ERR, "error when setting up timer.every for sync_backends_with_external_name: ",
err)
end
end

2、sync_backends_with_external_name

  • 作用:专门同步类型为 ExternalName 的 Kubernetes Service,先读取接口返回的数据,获取域名列表,再进行 DNS 解析后更新共享内存。
  • 流程
    1. HTTP GET /configuration/backends/external 获取映射。(Controller 提供的接口)
    2. 对每个域名调用 ngx.socket.dns.toip(domain) 获取 IP 列表。
    3. 调用 sync_backend()(详见下文)将解析后的 IP 地址写入共享内存。
  • 使用频率:定时器注册后每秒执行一次 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
local function sync_backends_with_external_name()
for _, backend_with_external_name in pairs(backends_with_external_name) do
sync_backend(backend_with_external_name)
end
end

local function sync_backend(backend)
-- 检查是否为ExternalName类型的后端服务
-- 如果是则解析外部名称获取实际IP地址
if is_backend_with_external_name(backend) then
backend = resolve_external_names(backend)
end

-- 检查后端端点是否为空
-- 如果为空则清除该后端的负载均衡器缓存
if not backend.endpoints or #backend.endpoints == 0 then
balancers[backend.name] = nil
return
end

-- 格式化IPv6地址,确保被方括号包围
backend.endpoints = format_ipv6_endpoints(backend.endpoints)

-- 根据后端配置获取对应的负载均衡算法实现
local implementation = get_implementation(backend)
-- 从缓存中获取该后端的负载均衡器实例
local balancer = balancers[backend.name]

-- 如果负载均衡器不存在,则创建新实例
if not balancer then
balancers[backend.name] = implementation:new(backend)
return
end

-- 检查负载均衡算法是否发生变化
-- 通过比较元表判断当前实例是否属于新的实现类
if getmetatable(balancer) ~= implementation then
ngx.log(ngx.INFO,
string.format("LB algorithm changed from %s to %s, resetting the instance",
balancer.name, implementation.name))
-- 算法变化时创建新的负载均衡器实例
balancers[backend.name] = implementation:new(backend)
return
end

-- 如果负载均衡器已存在且算法未变化,则同步更新后端配置
balancer:sync(backend)
end

3、sync_backends

  • 作用:获取 Controller 暴露的普通 Service 后端配置(Endpoints),同步到共享内存(lua_shared_dict dynamic_upstreams)。
  • 数据来源:HTTP GET 请求 /configuration/backends 接口。(Controller 提供的接口)
  • 使用频率:由 init_worker_by_lua_block 注册的定时器每秒执行一次,确保实时同步。
  • 不处理:对于类型为 ExternalName 的服务不会同步(将由 sync_backends_with_external_name 处理)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
local function sync_backends()
-- 获取后端配置最后同步时间
local raw_backends_last_synced_at = configuration.get_raw_backends_last_synced_at()
-- 如果配置未更新则直接返回
if raw_backends_last_synced_at <= backends_last_synced_at then
return
end

-- 获取后端配置数据
local backends_data = configuration.get_backends_data()
-- 如果没有配置数据则清空balancers缓存
if not backends_data then
balancers = {}
return
end

-- 解析JSON格式的后端配置
local new_backends, err = cjson.decode(backends_data)
if not new_backends then
ngx.log(ngx.ERR, "could not parse backends data: ", err)
return
end

-- 创建需要保留的后端名称集合
local balancers_to_keep = {}
-- 遍历所有新后端配置
for _, new_backend in ipairs(new_backends) do
-- 如果是ExternalName类型的服务
if is_backend_with_external_name(new_backend) then
-- 深拷贝配置并存入外部名称后端集合
local backend_with_external_name = util.deepcopy(new_backend)
backends_with_external_name[backend_with_external_name.name] = backend_with_external_name
else
-- 同步普通后端配置
sync_backend(new_backend)
end
-- 标记该后端需要保留
balancers_to_keep[new_backend.name] = true
end

-- 清理不再存在的后端配置
for backend_name, _ in pairs(balancers) do
if not balancers_to_keep[backend_name] then
balancers[backend_name] = nil
backends_with_external_name[backend_name] = nil
end
end
-- 更新最后同步时间
backends_last_synced_at = raw_backends_last_synced_at
end
  • ngx.timer.at(0, _M.sync_backends) 表示在 Worker 启动后立即执行一次 sync_backends
  • sync_backendssync_backends_with_external_name 的末尾,重新调用 ngx.timer.at(1, ...) 注册下一次 1 秒延迟的定时任务;
  • 这样每个 Worker 都会每秒主动拉取一次后端配置,无论 Controller 推送是否成功,均能在最多 1 秒内同步到最新状态 。

2.3、第5步:请求阶段读取共享内存并路由

在完成“主动推送”与“定时拉取”后,共享内存 ngx.shared.dynamic_upstreams 中始终保存最新的后端列表。下一步,在 HTTP 请求到达时,Lua 负责从共享内存读取并选取后端。完整请求流程

  1. DNS/HTTP 解析:客户端请求到达 NodePort/LoadBalancer,转发到 NGINX Worker 进程;
  2. **匹配 location**:Nginx 根据配置匹配到使用 proxy_pass http://dynamic_upstream;location /
  3. **执行 balancer_by_lua_block**:Nginx 在进入 upstream 阶段前执行 Lua 脚本,Lua 从 ngx.shared.dynamic_upstreams 中读取当前后端列表并选取一个后端;
  4. 设置上游 Peer:Lua 调用 ngx.balancer.set_current_peer(host, port),指定本次请求的上游地址;
  5. 转发给上游:Nginx 将请求转发至以上步骤得出的 IP:Port,完成路由。

这一路径实现了“读取-选取-转发”的动态更新,无需 nginx -s reload,大幅提升了路由变更后的可用性与稳定性。


2.4、总结

  1. 主动推送:Control-Plane 在后端变更时将 JSON 配置推送至 Lua 接口,写入共享内存。
  2. 定时拉取:为了保证新 Worker 与部分推送失败的补偿,每个 Worker 在 init_worker_by_lua_block 中注册定时器,每秒调用 sync_backendssync_backends_with_external_name,从 Controller 接口再次拉取配置并更新共享内存。
  3. 请求时读取:在 balancer_by_lua_block 阶段,Lua 脚本从共享内存读取最新后端列表,执行负载均衡算法(lua-resty-balancer)并调用 ngx.balancer.set_current_peer(),实现动态路由。

通过上述 “主动推送 + 周期性拉取 + 请求时读取” 的三步闭环,Ingress-NGINX 能在不重启 NGINX 的前提下,实现后端服务的动态热更新,兼顾了 低延迟高可用一致性

3、扩展实现

对于自己实现网关,由于业务的复杂性,lua层面实现明显不符合需求。之前分析过服务自动发现的设计,2个功能都是在运行期更新路由信息,实际很多逻辑是重叠的。我们从一个经典的部署场景切入

1个网关集群,包含2台网关

1个zookeeper集群

3.1、整体架构

很明显,2台为网关的配置需要一致,整体需要考虑集群和多进程的更新

1、客户端调用A网关,变更dyups的配置,A网关更新后,同步到zk。 然后B网关定时感知到变更,然后进行更新。

2、单独抽一个0号进程,专注于配置更新,而work专注于业务请求的处理。

3、通过共享内存进行数据的共享,进行变更

3.2、具体设计

主要的链路分为2种

  • 客户端调用 -> 解析客户端body -> 修改路由信息(内存+配置文件) -> 同步到zookeeper
  • 定时器感知zookeeper路由信息变更 -> 获取变更的部分 -> 修改路由信息(内存+配置文件)

网关集群对数据的一致性保证,与nginx的多进程对数据的一致性保证,其实非常相似。

1、与服务自动发现一致,复用0号进程,使用0号进程监听特定端口,客户端可以调用该端口,进程配置更新

2、0号进程处理客户端的变更请求,更新本地文件、共享内存的版本号、配置数据写入共享内存

3、0号进程更新完数据后,将数据更新到zk,同时也要更新zk的版本号

4、0号进程开启定时器,比对zk的版本号与共享内存的版本号是否一致,不一致则进行变更

5、0号进程获取zk最新的数据,更新本地文件、共享内存的版本号、配置数据写入共享内存

6、work进程开启定时器,比对共享内存的版本号与本地内存中的版本号,不一致则进程更新

3.3、需要考虑的问题

涉及到多台网关就需要考虑竞争关系,还好nginx是多进程,考虑过很多类似的问题,2者的情况类似

1、2台网关同时主动式更新

假设配置的版本号为10,A网关被主动式调用,版本号为11,配置写到zk,此时修改zk的版本号为11

接着,B网关也被主动式调用,版本号也改为11,此时也要写到zk,这时就会存在问题,配置被覆盖了

所以实际网关在写配置到zk时,需要先获取一下版本号,是否相等,如果相等说明没有其他网关更新,然后再加一,更新到zk

2、多进程更新

事实上,只有0号进程会修改配置,work只是读而已,所以不存在竞争关系。但是0号进程也会有问题,因为他不仅要从zk读取数据(其他网关的变更),他也会被主动式调用,修改数据,所以存在竞争关系,其实又回到了第1个问题。

其实就是模仿ngx_dyups_update_upstream的逻辑,修改数据之前,先获取一下zk的版本号,确认当前拿到的是最新的数据,然后再进行主动式的更新。

如果zk的数据已经变更,那本次主动式调用,需要返回失败。同时要提示客户端,需要先拿到最新的数据,才能进程主动式调用更新。

4、主动式调用

upstream只是一个很基础的路由属性,实际上复杂的业务需要很多属性,比如uri,uri+upstream,可以组成一个路由的基本单元,即客户端请求到业务服务端的映射。当然还有权重、负载策略、失败重试等等属性,有些属性是nginx本身具有的,有些属性需要嵌入ngixn模块开发,或者重新实现模块实现。但总的思想是,虚拟一个路由对象,然后进行动态路由更新。

相比于ngx_dyups_update_upstream,也只是属性的不一样而已,所以按照同样的方式实现,其实就可以。当然实际实现起来,是有一些难度的。

编写一个完整的http模块,接收post请求,解析json的body,组装为特定数据格式,在检查过配置的正确性后,分别调用多个路由模块的更新,实现共享内存内存更新,然后更新配置文件。而实际的work通过定时器从共享内存更新最新的路由信息。

5、资料

参考:

官方文档:https://www.bookstack.cn/read/nginx-official-doc/5.md

GitHub仓库:https://github.com/yzprofile/ngx_http_dyups_module

openresty的balancer库:**lua-resty-balancer**


热更新-动态路由
https://zjfans.github.io/2025/06/08/热更新-动态路由/
作者
张三疯
发布于
2025年6月8日
许可协议