跳到正文 →

UPYUN基于ngx_lua的动态服务路由方案

嘉宾介绍

叶靖:UPYUN 平台开发部系统开发工程师,主要负责 UPYUN 云处理平台的设计和开发工作,在 Nginx 和 ngx_lua 模块的开发上有较多经验。热衷于参与开源社区分享开源经验。

Nginx 以其出色的性能和稳定性,被广泛应用于提供反向代理或负载均衡服务。但是,由于 Nginx 开源版本并未提供动态的 upstream 更新接口,当上游服务器集群需要调整时,只能通过修改 Nginx 的配置文件,再对 Nginx 进行 reload 操作来使新的配置文件生效。

Nginx 以其出色的性能和稳定性,被广泛应用于提供反向代理或负载均衡服务。但是,由于 Nginx 开源版本并未提供动态的 upstream 更新接口,当上游服务器集群需要调整时,只能通过修改 Nginx 的配置文件,再对 Nginx 进行 reload 操作来使新的配置文件生效。

Reload 操作虽然不会影响请求,但本质上是一个方便运维的特性,对业务并不是很友好。UPYUN 通过 ngx_lua 在 Nginx 内部实现了一个动态 upstream 更新接口,只需要通过 HTTP 给 Nginx 发送一个更新指令,就能动态地对 upstream 进行调整,并不需要修改配置文件或进行 reload 操作,大大减小了 upstream 的维护成本和更新时间,为集群动态扩容奠定了基础。

01/更新 Nginx upstream 的流程
在开发过种中,肯定碰到过要更新 nginx upstream 的情况。常规的更新 Nginx upstream 的流程是:

  • 修改 Nginx 配置文件,增加或删除相关 upstream 中的 server。
  • 通过 kill -s HUP 发送 reload 信号给 nginx 对配置文件重新进行加载。

Nginx 在收到 reload 信号后会新起一批 worker 进程并加载新的配置文件处理新的请求,旧的 worker 进程在处理完当前连接的请求后退出,这样,新的配置文件就生效了。

Nginx 的 reload 是不会对请求造成影响的,因为旧的 worker 进程并没有立即退出,仍然在为旧的连接服务。

这个特性对于有计划的扩容或升级是非常方便的,但同时也会带来以下几个问题:

  • 旧进程完全退出时间不确定。

旧的 worker 进程在收到 reload 信号后会处于 shutting down 状态,但是受长连接的影响,无法确定旧进程的准确退出时间。

这会带来什么问题呢?比如上游某台服务挂了,需要立即从集群摘掉,那么当你修改完配置并 reload 之后,旧进程还是会产生部分 502。

  •  进程内的缓存会失效。

UPYUN 大量采用 ngx_lua 在 Nginx 内部完成一些通用的操作,比如权限认证、频率限制、参数检查等。

这些操作需要的帐号、元数据等信息会以 LRU 的方式缓存在进程内部。如果进程被重启,这些缓存将会失效,对于数据库将会是一个冲击。

当然,这个问题是容易解决的,比如将缓存设计为两层,进程内 LRU 一层,共享内存里一层。

这样当 worker 进程重启后还可以从共享内存取到缓存的数据(共享内存在 reload 后数据并不会清除),此时只需要做一些反序列化(共享内存不能存数结构对象,只能存字节码)操作就可以了。

  • 更新脚本复杂,容易出错。

如果要求更新时服务没有 downtime, 那么就需要先在 Nginx 中移除一台待更新的服务,等这台服务更新完成后,再把刚刚移掉的那台加回到 Nginx upstream 列表。如此循环直到所有上游服务更新完毕。这种需求通常需要运维脚本来完成,但是脚本对文件格式要求很高,很容易出错。

对于一个可以由程序自动控制的动态扩容集群,Nginx 的 reload 是无法满足要求的。所以,很多公司采用域名替换 upstream 里的 ip 地址(Nginx 支持 upstream 域名解析),然后再搭建一个内部的 DNS 服务来把 upstream 里的域名解析到服务地址。

这样,当集群调整时,只需要更改 DNS 解析就可以了,不需要对 Nginx 进行 reload 操作,也就不存在 reload 带来的问题。

UPYUN 并没有采用 DNS 的方案,主要原因不是因为多了一层解析时间,也不是因为内部 DNS 的维护成本,而是因为 DNS 无法改变 upstream 的端口号。UPYUN 的服务都是基于 Docker 容器封装的,服务运行的宿主机 IP 和端口都不确定,所以,DNS 的方案无法满足。

02/UPYUN基于 ngx_lua 的动态服务路由方案
UPYUN 针对以上原因并结合自身需求,开发了基于 ngx_lua 的动态服务路由方案,以下是系统架构图:
UPYUN基于ngx_lua的动态服务路由方案UPYUN基于ngx_lua的动态服务路由方案
上图中的 Slardar 即 UPYUN 动态服务路由项目的内部代号,是一个基于 Nginx 和 ngx_lua 模块开发的项目。Consul 是一个开源的分布式配置管理或服务发现数据库(类似于 etcd、zookeeper)。NSQ 是一个开源的消息队列。image, audio, video, zip 等都是基于 Docker 的服务或消费者。从图中可以看到,Slardar 的路由分为同步请求异步请求两部分。异步请求的处理相对简单,Slardar 将会在通过参数检查之后将处理任务放入 NSQ 队列,接下去的工作就完全交给各种消费都来完成了。异步请求的扩容也非常简单,只需要起新的消费者监听到 NSQ 队列就行了,在消费者异常时也可以直接 kill 掉,NSQ 会将没有 commit 的任务发送给其它消费者重新消费。可以看到,在异步处理时,Slardar 的 upstream 只要填写 NSQ 的地址就可以了,而 NSQ 拥有很好的性能,一般不需要进行 NSQ 的扩容操作。

接下去我们来介绍 Slardar 对同步请求的处理。

UPYUN基于ngx_lua的动态服务路由方案
(图为 Slardar 处理同步请求的流程图。)

Slardar 对于同步请求的动态服务路由主要体现在两部分:

  • 支持新的服务类型不需要更新 Slardar。
  • 更新 upstream 列表不需要更新 Slardar。

Slardar 会根据 HTTP 请求头的 Host 域来区分不同的服务,比如 Host 是 image 就会路由到图片处理集群,是 audio 就会路由到音频处理集群。

在拿到 Host 之后,Slardar 会从 Consul 加载与 Host 对应的参数检查代码。

如果参数检查失败则会直接给下游返回相应的错误码;如果参数检查正确则根据 Host 找到相应的 upstream 列表,把请求转发给上游服务并等待处理完成返回响应。

这样,当需要添加一种新的服务类型时,只需要指定一个新的 Host 名字,并动态地加上这个 Host 对应的 upstream 列表和参数检查代码(可选),Slardar 就会把请求路由到新的服务集群中去。

与其它负载均衡器不同的是,Slardar 会在接到请求后从外部存储动态加载 Lua 代码做一些参数检查和改写等操作。

这样做的好处是可以对请求做出非常灵活的控制(例如临时禁掉某个恶意空间或对参数进行 rewrite 等),并且能够将很多非法请求挡在外面,节省内网流量。

同时也正因为这个特性,Slardar 不适合非常频繁的 reload 操作,因为当 reload 导致 worker 进程内编译好的 lua 代码失效时,重新从共享内存甚至外部的 Consul 加载 lua 代码并编译是非常耗时的操作。

Lua 代码的动态加载主要是通过 Lua 的 loadstring 和 setfenv 函数来完成的,有兴趣的同学可以参考 Lua 的官方文档。

下面我们来看一下 Slardar 是如何实现动态的 upstream 更新的。以下是 upstream 管理相关的流程图:

UPYUN基于ngx_lua的动态服务路由方案
首先,Slardar 会监听一个管理端口(比如 8080)用于接收指令、查看一些 Nginx 内部状态等信息。当接收到 upstream 更新指令时,Slardar 会读取更新指令参数中的 upstream 名字和新的服务列表,把这些信息写在共享内存里。

为什么要存在共享内存而不直接更新呢?因为收到更新指令的只是 nginx 进程的其中一个 worker,worker 只能更新自身进程的 upstream 列表而其它 worker 进程的列表是无法改变的。

为了解决这个问题,Slardar 在 Nginx 初始化 worker 进程的时候(对应 ngx_lua 的 init_worker_by_lua* 指令)为每个 worker 启动了一个定时器,该定时器每隔一秒钟会检查共享内存里的 upstream 列表是否有变动,如果有则同步到自身进程内。这种做法其实也是 Nginx 各 worker 间常用的通信方法。

至于 worker 进程如何更新自身的 upstream 列表,有很多种做法。

UPYUN 采用内部的 lua-resty-checkups 模块(即将开源),可以在共享内存中维护 upstream 列表和健康状况等信息。淘宝开源的 Tengine 也有类似的 C 模块实现方案,这里不再展开。

至此,我们已经完成了 upstream 的动态更新,但还有一个问题没有解决:假如 Nginx 被 reload 了,之前通过 HTTP 接口动态更新的列表不是都消失了吗?

UPYUN 通过两步解决这个问题:

  • 发送更新指令给 Slardar 之后,再把新的 upstream 列表保存到 Consul 进行持久化。这个操作可以由指令发起者调用 Consul 提供的 API 来完成。
  • Slardar 初始化(对应 ngx_lua 的 init_by_lua* 指令)的时候从 Consul 载入 upstream 列表,而不是从 Nginx 配置文件。

熟悉 ngx_lua 的同学可能会发现,在 init_by_lua* 阶段(也就是 Nginx master 进程初始化的阶段)是没办法调用 cosocket 的,也就是没办法发起非阻塞的 HTTP 请求从 Consul 读取数据了。

因为那时候连 Nginx 自身的 connection 都没有初始化完毕。

那么我们只能退而求其次,通过 luasocket 来发起阻塞的 HTTP 请求来从 Consul 加载 upstream 列表,幸好是在 init 阶段,阻塞的调用也不会对之后的请求造成任何影响。

值得一提的是,利用 Consul 的 watch 特性,可以监听 Consul 中某个 upstream 列表,如果有更新,可以触发一个脚本自动将新的 upstream 列表更新到 Slardar。这样我们只要维护 Consul 中的 upstream 列表就可以了。

以上就是 UPYUN 基于 ngx_lua 的动态服务路由方案的实现流程。

归类 技术分享