0%

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

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

让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的客户端会立刻收到通知,如下图所示:

基于etcd同步nginx配置

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

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

1
2
3
# 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中修改,比如:

1
2
3
4
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及定时器事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//参见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语言:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//参见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语言形式暴露了出来,如下图所示:
openresty钩子

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函数启动定时器:

1
2
3
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文件:

1
2
3
4
5
6
7
8
9
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函数:

1
2
3
4
5
6
7
8
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对象:

1
2
3
4
5
6
7
8
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定时器:

1
2
3
function _M.new(key, opts)
ngx_timer_at(0, _automatic_fetch, obj)
end

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

1
2
3
4
5
6
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源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
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:
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目录:

APISIX与etcd通过HTTP1通讯

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

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
-- 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就会生成以下配置:

1
2
3
4
5
6
7
8
9
server {
listen 9080 default_server reuseport;

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

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

1
2
3
4
-- /apisix/init.lua文件
function _M.http_admin()
local ok = router:dispatch(get_var("uri"), {method = get_method()})
end

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- /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上游时:

1
2
3
4
5
6
7
8
# 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):

1
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函数的内容:

1
2
3
4
5
6
7
8
9
10
11
12
-- /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字典决定的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- /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函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- /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中的日志验证:

1
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配置块的?》
  3. 在上述2个过程中,如果含有正则表达式,则基于数组顺序(在nginx.conf中出现的次序)依次匹配。

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

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

1
2
3
4
5
6
7
8
9
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代码就会重建这棵基数树:

1
2
3
4
5
6
7
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的设计。