APISIX架构分析:如何动态管理Nginx集群?

2023-10-18 11:18:34 浏览数 (2)

开源版Nginx最为人诟病的就是不具备动态配置、远程API及集群管理的能力,而APISIX作为CNCF毕业的开源七层网关,基于etcd、Lua实现了对Nginx集群的动态管理。

让Nginx具备动态、集群管理能力并不容易,因为这将面临以下问题:

  • 微服务架构使得上游服务种类多、数量大,这导致路由规则、上游Server的变更极为频率。而Nginx的路由匹配是基于静态的Trie前缀树、哈希表、正则数组实现的,一旦server_name、location变动,不执行reload就无法实现配置的动态变更;
  • Nginx将自己定位于ADC边缘负载均衡,因此它对上游并不支持HTTP2协议。这增大了OpenResty生态实现etcd gRPC接口的难度,因此通过watch机制接收配置变更必然效率低下;
  • 多进程架构增大了Worker进程间的数据同步难度,必须选择1个低成本的实现机制,保证每个Nginx节点、Worker进程都持有最新的配置;

等等。

APISIX基于Lua定时器及lua-resty-etcd模块实现了配置的动态管理,本文将基于APISIX2.8、OpenResty1.19.3.2、Nginx1.19.3分析APISIX实现REST API远程控制Nginx集群的原理。

接下来我将分析APISIX的解决方案。

基于etcd watch机制的配置同步方案

管理集群必须依赖中心化的配置,etcd就是这样一个数据库。APISIX没有选择关系型数据库作为配置中心,是因为etcd具有以下2个优点:

  1. etcd采用类Paxos的Raft协议保障了数据一致性,它是去中心化的分布式数据库,可靠性高于关系数据库;
  2. etcd的watch机制允许客户端监控某个key的变动,即,若类似/nginx/http/upstream这种key的value值发生变动,watch的客户端会立刻收到通知,如下图所示:

因此,不同于Orange采用MySQL、Kong采用PostgreSQL作为配置中心(这二者同样是基于OpenResty实现的API Gateway),APISIX采用了etcd作为中心化的配置组件。

因此,你可以在生产环境的APISIX中通过etcdctl看到如下的类似配置:

代码语言:javascript复制
# etcdctl get  "/apisix/upstreams/1"
/apisix/upstreams/1
{"hash_on":"vars","nodes":{"httpbin.org:80":1},"create_time":1627982128,"update_time":1627982128,"scheme":"http","type":"roundrobin","pass_host":"pass","id":"1"}

其中,/apisix这个前缀可以在conf/config.yaml中修改,比如:

代码语言:javascript复制
etcd:
  host:  
    - "http://127.0.0.1:2379"   
  prefix: /apisix                 # apisix configurations prefix

而upstreams/1就等价于nginx.conf中的http { upstream 1 {} }配置。类似关键字还有/apisix/services/、/apisix/routes/等,不一而足。

那么,Nginx是怎样通过watch机制获取到etcd配置数据变化的呢?有没有新启动一个agent进程?它通过HTTP/1.1还是gRPC与etcd通讯的?

ngx.timer.at定时器

APISIX并没有启动Nginx以外的进程与etcd通讯。它实际上是通过ngx.timer.at这个定时器实现了watch机制。为了方便对OpenResty不太了解的同学,我们先来看看Nginx中的定时器是如何实现的,它是watch机制实现的基础。

Nginx的红黑树定时器

Nginx采用了epoll nonblock socket这种多路复用机制实现事件处理模型,其中每个worker进程会循环处理网络IO及定时器事件:

代码语言:javascript复制
//参见Nginx的src/os/unix/ngx_process_cycle.c文件
static void
ngx_worker_process_cycle(ngx_cycle_t *cycle, void *data)
{
    for ( ;; ) {
        ngx_process_events_and_timers(cycle);
    }
}

// 参见ngx_proc.c文件
void
ngx_process_events_and_timers(ngx_cycle_t *cycle)
{
    timer = ngx_event_find_timer();
    (void) ngx_process_events(cycle, timer, flags);
    ngx_event_process_posted(cycle, &ngx_posted_accept_events);
    ngx_event_expire_timers();
    ngx_event_process_posted(cycle, &ngx_posted_events);
}

ngx_event_expire_timers函数会调用所有超时事件的handler方法。事实上,定时器是由红黑树(一种平衡有序二叉树)实现的,其中key是每个事件的绝对过期时间。这样,只要将最小节点与当前时间做比较,就能快速找到过期事件。

OpenResty的Lua定时器

当然,以上C函数开发效率很低。因此,OpenResty封装了Lua接口,通过ngx.timer.at将ngx_timer_add这个C函数暴露给了Lua语言:

代码语言:javascript复制
//参见OpenResty /ngx_lua-0.10.19/src/ngx_http_lua_timer.c文件
void
ngx_http_lua_inject_timer_api(lua_State *L)
{
    lua_createtable(L, 0 /* narr */, 4 /* nrec */);    /* ngx.timer. */

    lua_pushcfunction(L, ngx_http_lua_ngx_timer_at);
    lua_setfield(L, -2, "at");

    lua_setfield(L, -2, "timer");
}
static int
ngx_http_lua_ngx_timer_at(lua_State *L)
{
    return ngx_http_lua_ngx_timer_helper(L, 0);
}
static int
ngx_http_lua_ngx_timer_helper(lua_State *L, int every)
{
    ngx_event_t             *ev = NULL;
    ev->handler = ngx_http_lua_timer_handler;
    ngx_add_timer(ev, delay);
}

因此,当我们调用ngx.timer.at这个Lua定时器时,就是在Nginx的红黑树定时器里加入了ngx_http_lua_timer_handler回调函数,这个函数不会阻塞Nginx。

下面我们来看看APISIX是怎样使用ngx.timer.at的。

APISIX基于定时器实现的watch机制

Nginx框架为C模块开发提供了许多钩子,而OpenResty将部分钩子以Lua语言形式暴露了出来,如下图所示:

APISIX仅使用了其中8个钩子(注意,APISIX没有使用set_by_lua和rewrite_by_lua,rewrite阶段的plugin其实是APISIX自定义的,与Nginx无关),包括:

  • init_by_lua:Master进程启动时的初始化;
  • init_worker_by_lua:每个Worker进程启动时的初始化(包括privileged agent进程的初始化,这是实现java等多语言plugin远程RPC调用的关键);
  • ssl_certificate_by_lua:在处理TLS握手时,openssl提供了一个钩子,OpenResty通过修改Nginx源码以Lua方式暴露了该钩子;
  • access_by_lua:接收到下游的HTTP请求头部后,在此匹配Host域名、URI、Method等路由规则,并选择Service、Upstream中的Plugin及上游Server;
  • balancer_by_lua:在content阶段执行的所有反向代理模块,在选择上游Server时都会回调init_upstream钩子函数,OpenResty将其命名为 balancer_by_lua;
  • header_filter_by_lua:将HTTP响应头部发送给下游前执行的钩子;
  • body_filter_by_lua:将HTTP响应包体发送给下游前执行的钩子;
  • log_by_lua:记录access日志时的钩子。 准备好上述知识后,我们就可以回答APISIX是怎样接收etcd数据的更新了。
nginx.conf的生成方式

每个Nginx Worker进程都会在init_worker_by_lua阶段通过http_init_worker函数启动定时器:

代码语言:javascript复制
init_worker_by_lua_block {
    apisix.http_init_worker()
}

关于nginx.conf配置语法,你可以参考我的这篇文章《从通用规则中学习nginx模块的定制指令》。你可能很好奇,下载APISIX源码后没有看到nginx.conf,这段配置是哪来的?

这里的nginx.conf实际是由APISIX的启动命令实时生成的。当你执行make run时,它会基于Lua模板apisix/cli/ngx_tpl.lua文件生成nginx.conf。请注意,这里的模板规则是OpenResty自实现的,语法细节参见lua-resty-template。生成nginx.conf的具体代码参见apisix/cli/ops.lua文件:

代码语言:javascript复制
local template = require("resty.template")
local ngx_tpl = require("apisix.cli.ngx_tpl")
local function init(env)
    local yaml_conf, err = file.read_yaml_conf(env.apisix_home)
    local conf_render = template.compile(ngx_tpl)
    local ngxconf = conf_render(sys_conf)

    local ok, err = util.write_file(env.apisix_home .. "/conf/nginx.conf",
                                    ngxconf)

当然,APISIX允许用户修改nginx.conf模板中的部分数据,具体方法是模仿conf/config-default.yaml的语法修改conf/config.yaml配置。其实现原理参见read_yaml_conf函数:

代码语言:javascript复制
function _M.read_yaml_conf(apisix_home)
    local local_conf_path = profile:yaml_path("config-default")
    local default_conf_yaml, err = util.read_file(local_conf_path)

    local_conf_path = profile:yaml_path("config")
    local user_conf_yaml, err = util.read_file(local_conf_path)
    ok, err = merge_conf(default_conf, user_conf)
end

可见,ngx_tpl.lua模板中仅部分数据可由yaml配置中替换,其中conf/config-default.yaml是官方提供的默认配置,而conf/config.yaml则是由用户自行覆盖的自定义配置。如果你觉得仅替换模板数据还不够,大可以直接修改ngx_tpl模板。

APISIX获取etcd通知的方式

APISIX将需要监控的配置以不同的前缀存入了etcd,目前包括以下11种:

  • /apisix/consumers/:APISIX支持以consumer抽象上游种类;
  • /apisix/global_rules/:全局通用的规则;
  • /apisix/plugin_configs/:可以在不同Router间复用的Plugin;
  • /apisix/plugin_metadata/:部分插件的元数据;
  • /apisix/plugins/:所有Plugin插件的列表;
  • /apisix/proto/:当透传gRPC协议时,部分插件需要转换协议内容,该配置存储protobuf消息定义;
  • /apisix/routes/:路由信息,是HTTP请求匹配的入口,可以直接指定上游Server,也可以挂载services或者upstream;
  • /apisix/services/:可以将相似的router中的共性部分抽象为services,再挂载plugin;
  • /apisix/ssl/:SSL证书公、私钥及相关匹配规则;
  • /apisix/stream_routes/:OSI四层网关的路由匹配规则;
  • /apisix/upstreams/:对一组上游Server主机的抽象;

这里每类配置对应的处理逻辑都不相同,因此APISIX抽象出apisix/core/config_etcd.lua文件,专注etcd上各类配置的更新维护。在http_init_worker函数中每类配置都会生成1个config_etcd对象:

代码语言:javascript复制
function _M.init_worker()
    local err
    plugin_configs, err = core.config.new("/plugin_configs", {
        automatic = true,
        item_schema = core.schema.plugin_config,
        checker = plugin_checker,
    })
end

而在config_etcd的new函数中,则会循环注册_automatic_fetch定时器:

代码语言:javascript复制
function _M.new(key, opts)
    ngx_timer_at(0, _automatic_fetch, obj)
end

_automatic_fetch函数会反复执行sync_data函数(包装到xpcall之下是为了捕获异常):

代码语言:javascript复制
local function _automatic_fetch(premature, self)
    local ok, err = xpcall(function()
        local ok, err = sync_data(self)
    end, debug.traceback)
    ngx_timer_at(0, _automatic_fetch, self)
end

sync_data函数将通过etcd的watch机制获取更新,它的实现机制我们接下来会详细分析。

总结下:

APISIX在每个Nginx Worker进程的启动过程中,通过ngx.timer.at函数将_automatic_fetch插入定时器。_automatic_fetch函数执行时会通过sync_data函数,基于watch机制接收etcd中的配置变更通知,这样,每个Nginx节点、每个Worker进程都将保持最新的配置。如此设计还有1个明显的优点:etcd中的配置直接写入Nginx Worker进程中,这样处理请求时就能直接使用新配置,无须在进程间同步配置,这要比启动1个agent进程更简单!

lua-resty-etcd库的HTTP/1.1协议

sync_data函数到底是怎样获取etcd的配置变更消息的呢?先看下sync_data源码:

代码语言:javascript复制
local etcd         = require("resty.etcd")
etcd_cli, err = etcd.new(etcd_conf)

local function sync_data(self)
    local dir_res, err = waitdir(self.etcd_cli, self.key, self.prev_index   1, self.timeout)
end

local function waitdir(etcd_cli, key, modified_index, timeout)
    local res_func, func_err, http_cli = etcd_cli:watchdir(key, opts)
    if http_cli then
        local res_cancel, err_cancel = etcd_cli:watchcancel(http_cli)
    end
end

这里实际与etcd通讯的是lua-resty-etcd库。它提供的watchdir函数用于接收etcd发现key目录对应value变更后发出的通知。

watchcancel函数又是做什么的呢?这其实是OpenResty生态的缺憾导致的。etcd v3已经支持高效的gRPC协议(底层为HTTP2协议)。你可能听说过,HTTP2不但具备多路复用的能力,还支持服务器直接推送消息,关于HTTP2的细节可以参照我的这篇文章《深入剖析HTTP3协议》,从HTTP3协议对照理解HTTP2:

然而,Lua生态目前并不支持HTTP2协议!所以lua-resty-etcd库实际是通过低效的HTTP/1.1协议与etcd通讯的,因此接收/watch通知也是通过带有超时的/v3/watch请求完成的。这个现象其实是由2个原因造成的:

  1. Nginx将自己定位为边缘负载均衡,因此上游必然是企业内网,时延低、带宽大,所以对上游协议不必支持HTTP2协议!
  2. 当Nginx的upstream不能提供HTTP2机制给Lua时,Lua只能基于cosocket自己实现了。HTTP2协议非常复杂,目前还没有生产环境可用的HTTP2 cosocket库。

使用HTTP/1.1的lua-resty-etcd库其实很低效,如果你在APISIX上抓包,会看到频繁的POST报文,其中URI为/v3/watch,而Body是Base64编码的watch目录:

我们可以验证下watchdir函数的实现细节:

代码语言:javascript复制
-- lib/resty/etcd/v3.lua文件
function _M.watchdir(self, key, opts)
    return watch(self, key, attr)
end

local function watch(self, key, attr)
    callback_fun, err, http_cli = request_chunk(self, 'POST', '/watch',
                                                opts, attr.timeout or self.timeout)
    return callback_fun
end

local function request_chunk(self, method, path, opts, timeout)
    http_cli, err = utils.http.new()
    -- 发起TCP连接
    endpoint, err = http_request_chunk(self, http_cli)
    -- 发送HTTP请求
    res, err = http_cli:request({
        method  = method,
        path    = endpoint.api_prefix .. path,
        body    = body,
        query   = query,
        headers = headers,
    })
end

local function http_request_chunk(self, http_cli)
    local endpoint, err = choose_endpoint(self)
    ok, err = http_cli:connect({
        scheme = endpoint.scheme,
        host = endpoint.host,
        port = endpoint.port,
        ssl_verify = self.ssl_verify,
        ssl_cert_path = self.ssl_cert_path,
        ssl_key_path = self.ssl_key_path,
    })

    return endpoint, err
end

可见,APISIX在每个worker进程中,通过ngx.timer.at和lua-resty-etcd库反复请求etcd,以此保证每个Worker进程中都含有最新的配置。

APISIX配置与插件的远程变更

接下来,我们看看怎样远程修改etcd中的配置。

我们当然可以直接通过gRPC接口修改etcd中相应key的内容,再基于上述的watch机制使得Nginx集群自动更新配置。然而,这样做的风险很大,因为配置请求没有经过校验,进面导致配置数据与Nginx集群不匹配!

通过Nginx的/apisix/admin/接口修改配置

APISIX提供了这么一种机制:访问任意1个Nginx节点,通过其Worker进程中的Lua代码校验请求成功后,再由/v3/dv/put接口写入etcd中。下面我们来看看APISIX是怎么实现的。

首先,make run生成的nginx.conf会自动监听9080端口(可通过config.yaml中apisix.node_listen配置修改),当apisix.enable_admin设置为true时,nginx.conf就会生成以下配置:

代码语言:javascript复制
server {
    listen 9080 default_server reuseport;

    location /apisix/admin { 
        content_by_lua_block {
            apisix.http_admin()
        }
    }
}

这样,Nginx接收到的/apisix/admin请求将被http_admin函数处理:

代码语言:javascript复制
-- /apisix/init.lua文件
function _M.http_admin()
    local ok = router:dispatch(get_var("uri"), {method = get_method()})
end

admin接口能够处理的API参见github文档,其中,当method方法与URI不同时,dispatch会执行不同的处理函数,其依据如下:

代码语言:javascript复制
-- /apisix/admin/init.lua文件
local uri_route = {
    {
        paths = [[/apisix/admin/*]],
        methods = {"GET", "PUT", "POST", "DELETE", "PATCH"},
        handler = run,
    },
    {
        paths = [[/apisix/admin/stream_routes/*]],
        methods = {"GET", "PUT", "POST", "DELETE", "PATCH"},
        handler = run_stream,
    },
    {
        paths = [[/apisix/admin/plugins/list]],
        methods = {"GET"},
        handler = get_plugins_list,
    },
    {
        paths = reload_event,
        methods = {"PUT"},
        handler = post_reload_plugins,
    },
}

比如,当通过/apisix/admin/upstreams/1和PUT方法创建1个Upstream上游时:

代码语言:javascript复制
# curl "http://127.0.0.1:9080/apisix/admin/upstreams/1" -H "X-API-KEY: edd1c9f034335f136f87ad84b625c8f1" -X PUT -d '
> {
>   "type": "roundrobin",
>   "nodes": {
>     "httpbin.org:80": 1
>   }
> }'
{"action":"set","node":{"key":"/apisix/upstreams/1","value":{"hash_on":"vars","nodes":{"httpbin.org:80":1},"create_time":1627982128,"update_time":1627982128,"scheme":"http","type":"roundrobin","pass_host":"pass","id":"1"}}}

你会在error.log中会看到如下日志(想看到这行日志,必须将config.yaml中的nginx_config.error_log_level设为INFO):

代码语言:javascript复制
2021/08/03 17:15:28 [info] 16437#16437: *23572 [lua] init.lua:130: handler(): uri: ["","apisix","admin","upstreams","1"], client: 127.0.0.1, server: _, request: "PUT /apisix/admin/upstreams/1 HTTP/1.1", host: "127.0.0.1:9080"

这行日志实际是由/apisix/admin/init.lua中的run函数打印的,它的执行依据是上面的uri_route字典。我们看下run函数的内容:

代码语言:javascript复制
-- /apisix/admin/init.lua文件
local function run()
    local uri_segs = core.utils.split_uri(ngx.var.uri)
    core.log.info("uri: ", core.json.delay_encode(uri_segs))

    local seg_res, seg_id = uri_segs[4], uri_segs[5]
    local seg_sub_path = core.table.concat(uri_segs, "/", 6)

    local resource = resources[seg_res]
    local code, data = resource[method](seg_id, req_body, seg_sub_path,
                                        uri_args)
end

这里resource[method]函数又被做了1次抽象,它是由resources字典决定的:

代码语言:javascript复制
-- /apisix/admin/init.lua文件
local resources = {
    routes          = require("apisix.admin.routes"),
    services        = require("apisix.admin.services"),
    upstreams       = require("apisix.admin.upstreams"),
    consumers       = require("apisix.admin.consumers"),
    schema          = require("apisix.admin.schema"),
    ssl             = require("apisix.admin.ssl"),
    plugins         = require("apisix.admin.plugins"),
    proto           = require("apisix.admin.proto"),
    global_rules    = require("apisix.admin.global_rules"),
    stream_routes   = require("apisix.admin.stream_routes"),
    plugin_metadata = require("apisix.admin.plugin_metadata"),
    plugin_configs  = require("apisix.admin.plugin_config"),
}

因此,上面的curl请求将被/apisix/admin/upstreams.lua文件的put函数处理,看下put函数的实现:

代码语言:javascript复制
-- /apisix/admin/upstreams.lua文件
function _M.put(id, conf)
    -- 校验请求数据的合法性
    local id, err = check_conf(id, conf, true)
    local key = "/upstreams/" .. id
    core.log.info("key: ", key)
    -- 生成etcd中的配置数据
    local ok, err = utils.inject_conf_with_prev_conf("upstream", key, conf)
    -- 写入etcd
    local res, err = core.etcd.set(key, conf)
end

-- /apisix/core/etcd.lua
local function set(key, value, ttl)
    local res, err = etcd_cli:set(prefix .. key, value, {prev_kv = true, lease = data.body.ID})
end

最终新配置被写入etcd中。可见,Nginx会校验数据再写入etcd,这样其他Worker进程、Nginx节点都将通过watch机制接收到正确的配置。上述流程你可以通过error.log中的日志验证:

代码语言:javascript复制
2021/08/03 17:15:28 [info] 16437#16437: *23572 [lua] upstreams.lua:72: key: /upstreams/1, client: 127.0.0.1, server: _, request: "PUT /apisix/admin/upstreams/1 HTTP/1.1", host: "127.0.0.1:9080"

为什么新配置不reload就可以生效?

我们再来看admin请求执行完Nginx Worker进程可以立刻生效的原理。

开源版Nginx的请求匹配是基于3种不同的容器进行的:

  1. 将静态哈希表中的server_name配置与请求的Host域名匹配,详见《HTTP请求是如何关联Nginx server{}块的?》;
  2. 其次将静态Trie前缀树中的location配置与请求的URI匹配,详见《URL是如何关联Nginx location配置块的?》;
  1. 在上述2个过程中,如果含有正则表达式,则基于数组顺序(在nginx.conf中出现的次序)依次匹配。

上述过程虽然执行效率极高,却是写死在find_config阶段及Nginx HTTP框架中的,一旦变更必须在nginx -s reload后才能生效!因此,APISIX索性完全抛弃了上述流程!

从nginx.conf中可以看到,访问任意域名、URI的请求都会匹配到http_access_phase这个lua函数:

代码语言:javascript复制
server {
    server_name _;
    location / {
        access_by_lua_block {
            apisix.http_access_phase()
        }
        proxy_pass      $upstream_scheme://apisix_backend$upstream_uri;
    }
}

而在http_access_phase函数中,将会基于1个用C语言实现的基数前缀树匹配Method、域名和URI(仅支持通配符,不支持正则表达式),这个库就是lua-resty-radixtree。每当路由规则发生变化,Lua代码就会重建这棵基数树:

代码语言:javascript复制
function _M.match(api_ctx)
    if not cached_version or cached_version ~= user_routes.conf_version then
        uri_router = base_router.create_radixtree_uri_router(user_routes.values,
                                                             uri_routes, false)
        cached_version = user_routes.conf_version
    end
end

这样,路由变化后就可以不reload而使其生效。Plugin启用、参数及顺序调整的规则与此类似。

最后再提下Script,它与Plugin是互斥的。之前的动态调整改的只是配置,事实上Lua JIT的及时编译还提供了另外一个杀手锏loadstring,它可以将字符串转换为Lua代码。因此,在etcd中存储Lua代码并设置为Script后,就可以将其传送到Nginx上处理请求了。

小结

Nginx集群的管理必须依赖中心化配置组件,而高可靠又具备watch推送机制的etcd无疑是最合适的选择!虽然当下Resty生态没有gRPC客户端,但每个Worker进程直接通过HTTP/1.1协议同步etcd配置仍不失为一个好的方案。

动态修改Nginx配置的关键在于2点:Lua语言的灵活度远高于nginx.conf语法,而且Lua代码可以通过loadstring从外部数据中导入!当然,为了保障路由匹配的执行效率,APISIX通过C语言实现了前缀基数树,基于Host、Method、URI进行请求匹配,在保障动态性的基础上提升了性能。

APISIX拥有许多优秀的设计,本文仅讨论了Nginx集群的动态管理,下篇文章再来分析Lua Plugin的设计。

0 人点赞