开源版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个优点:
- etcd采用类Paxos的Raft协议保障了数据一致性,它是去中心化的分布式数据库,可靠性高于关系数据库;
- etcd的watch机制允许客户端监控某个key的变动,即,若类似/nginx/http/upstream这种key的value值发生变动,watch的客户端会立刻收到通知,如下图所示:
因此,不同于Orange采用MySQL、Kong采用PostgreSQL作为配置中心(这二者同样是基于OpenResty实现的API Gateway),APISIX采用了etcd作为中心化的配置组件。
因此,你可以在生产环境的APISIX中通过etcdctl看到如下的类似配置:
1 | # etcdctl get "/apisix/upstreams/1" |
其中,/apisix这个前缀可以在conf/config.yaml中修改,比如:
1 | etcd: |
而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 | //参见Nginx的src/os/unix/ngx_process_cycle.c文件 |
ngx_event_expire_timers函数会调用所有超时事件的handler方法。事实上,定时器是由红黑树(一种平衡有序二叉树)实现的,其中key是每个事件的绝对过期时间。这样,只要将最小节点与当前时间做比较,就能快速找到过期事件。
OpenResty的Lua定时器
当然,以上C函数开发效率很低。因此,OpenResty封装了Lua接口,通过ngx.timer.at将ngx_timer_add这个C函数暴露给了Lua语言:
1 | //参见OpenResty /ngx_lua-0.10.19/src/ngx_http_lua_timer.c文件 |
因此,当我们调用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函数启动定时器:
1 | init_worker_by_lua_block { |
关于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 | local template = require("resty.template") |
当然,APISIX允许用户修改nginx.conf模板中的部分数据,具体方法是模仿conf/config-default.yaml的语法修改conf/config.yaml配置。其实现原理参见read_yaml_conf函数:
1 | function _M.read_yaml_conf(apisix_home) |
可见,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 | function _M.init_worker() |
而在config_etcd的new函数中,则会循环注册_automatic_fetch定时器:
1 | function _M.new(key, opts) |
_automatic_fetch函数会反复执行sync_data函数(包装到xpcall之下是为了捕获异常):
1 | local function _automatic_fetch(premature, self) |
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 | local etcd = require("resty.etcd") |
这里实际与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个原因造成的:
- Nginx将自己定位为边缘负载均衡,因此上游必然是企业内网,时延低、带宽大,所以对上游协议不必支持HTTP2协议!
- 当Nginx的upstream不能提供HTTP2机制给Lua时,Lua只能基于cosocket自己实现了。HTTP2协议非常复杂,目前还没有生产环境可用的HTTP2 cosocket库。
使用HTTP/1.1的lua-resty-etcd库其实很低效,如果你在APISIX上抓包,会看到频繁的POST报文,其中URI为/v3/watch,而Body是Base64编码的watch目录:
我们可以验证下watchdir函数的实现细节:
1 | -- lib/resty/etcd/v3.lua文件 |
可见,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 | server { |
这样,Nginx接收到的/apisix/admin请求将被http_admin函数处理:
1 | -- /apisix/init.lua文件 |
admin接口能够处理的API参见github文档,其中,当method方法与URI不同时,dispatch会执行不同的处理函数,其依据如下:
1 | -- /apisix/admin/init.lua文件 |
比如,当通过/apisix/admin/upstreams/1和PUT方法创建1个Upstream上游时:
1 | # curl "http://127.0.0.1:9080/apisix/admin/upstreams/1" -H "X-API-KEY: edd1c9f034335f136f87ad84b625c8f1" -X PUT -d ' |
你会在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 | -- /apisix/admin/init.lua文件 |
这里resource[method]函数又被做了1次抽象,它是由resources字典决定的:
1 | -- /apisix/admin/init.lua文件 |
因此,上面的curl请求将被/apisix/admin/upstreams.lua文件的put函数处理,看下put函数的实现:
1 | -- /apisix/admin/upstreams.lua文件 |
最终新配置被写入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种不同的容器进行的:
- 将静态哈希表中的server_name配置与请求的Host域名匹配,详见《HTTP请求是如何关联Nginx server{}块的?》;
- 其次将静态Trie前缀树中的location配置与请求的URI匹配,详见《URL是如何关联Nginx location配置块的?》;
- 在上述2个过程中,如果含有正则表达式,则基于数组顺序(在nginx.conf中出现的次序)依次匹配。
上述过程虽然执行效率极高,却是写死在find_config阶段及Nginx HTTP框架中的,一旦变更必须在nginx -s reload后才能生效!因此,APISIX索性完全抛弃了上述流程!
从nginx.conf中可以看到,访问任意域名、URI的请求都会匹配到http_access_phase这个lua函数:
1 | server { |
而在http_access_phase函数中,将会基于1个用C语言实现的基数前缀树匹配Method、域名和URI(仅支持通配符,不支持正则表达式),这个库就是lua-resty-radixtree。每当路由规则发生变化,Lua代码就会重建这棵基数树:
1 | function _M.match(api_ctx) |
这样,路由变化后就可以不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的设计。