2019 年 5 月 11 日,OpenResty 社区联合又拍云,举办 OpenResty × Open Talk 全国巡回沙龙武汉站,又拍云首席布道师在活动上做了《 基于 OpenResty 的动态服务路由方案 》的分享。

OpenResty x Open Talk 全国巡回沙龙是由 OpenResty 社区、又拍云发起,邀请业内资深的 OpenResty 技术专家,分享 OpenResty 实战经验,增进 OpenResty 使用者的交流与学习,推动 OpenResty 开源项目的发展。活动已先后在深圳、北京、武汉举办,后续还将陆续在上海、广州、杭州等城市巡回举办。

1.jpg

邵海杨,又拍云首席布道师,运维总监,资深系统运维架构师,多年 CDN 行业架构设计、运维开发、团队管理相关经验,精通 Linux 系统及嵌入式系统,互联网高性能架构设计、CDN 加速、KVM 虚拟化及 OpenStack 云平台的研究,目前专注于容器及虚拟化技术在又拍云的私有云实践。


以下是分享全文:

今天和大家介绍一个基于 ngx_lua 的动态服务路由解决方案,它是整个容器化过程中的组件,容器化在服务路由上有很大的挑战,又拍云通过自己的方案来实现了,并且已经稳定运行了三年左右。目前这个方案已经开源,如果大家后续也碰到一样的问题,可以直接使用这个方案。


服务 zero down-time 更新

在更新服务时,如何能做到让服务不断掉呢?又拍云做服务更新的时候,是不允许有失败的,如果因为我们的更新失败导致请求失败,即使请求非常少,口碑上也会不好,而且如果造成了事故,是要赔钱的。这也是我们做动态服务路由的重要原因。

服务路由主要包括以下几个部分:

  • 服务注册是指服务提供者在起来时,去服务发现注册,以表明提它提供的服务、端口、IP是多少,服务名是什么等;

  • 服务发现是集中管理服务的地方,记录了有哪些服务,它们在哪些地方;

  • 负载均衡,由于有很多同样的容器提供了同样的服务,需要考虑怎么在这些容器里做负载均衡。

服务发现有很多方案,但是它们的应用场景和语言都不太一样。Zookeeper 是一个比较老牌的开源项目,相对比较成熟,但对资源的要求比较高,是我们最早使用的一个方案,包括我们现在的 kafka、消息队列都是依赖 Zookeeper;etcd 和 Consul 是后起之秀,K8S 是依赖 etcd 的,etcd 在容器编排里面是依赖的;又拍云在服务注册和发现环节用了 Consul ,它是一站式的技术站,部署、可视化、维护等环节都比较方便,它不但支持 KV 存储,还有原生的服务监控、多数据中心、DNS 功能等。

负载均衡也有很多方案, LVS 有一个优势是在做完前面两层后,如果性能不好可以再加一个 LVS,因为它在四层,更加底层,不会破坏原来的网络结构,但是它的扩展非常难。HA_PROXY 和 Nginx 各有千秋,HA_PROXY 对 HTTP 头部解析消耗的 CPU 更少,如果做纯转发,如 WAF 可以使用 HA_PROXY,HA_PROXY 大概占 CPU 10% 左右 ,而 Nginx 做纯头部转发基本上是占 CPU  20%-25%,但是 Nginx 可扩展性更强,Nginx 可以做 TCP、UDP、HTTP 三种协议的转发和负载均衡,但是 HA_PROXY 只支持 TCP、HTTP。 HA_PROXY 最大的变化是它已经用 lua 重构,后续的发展也会与 lua 紧密结合,这相当于是又多了一种能力,它们也在拥抱 K8S 的生态圈。我们的方案是选择了 Nginx ,因为它专注于做 HTTP ,扩展性好,支持 TCP。       

2.jpg

如上图,我们把 Nginx 和 Consul 放在一张图里。为了突出服务,这里把一些跟服务不太相关的都省略掉了。我们基于 Mesos、Docker、 Marathon 做了服务管理。其中有一个特殊的服务是 Registrator,它会通过 Docker API 在每个物理机上起一个容器,通过 Docker API,把容器的状态定时的汇报给 Consul。上面的 Nginx 做负载均衡,因为我们的服务目前都是基于 Nginx 直接到容器里面。


Consul 里的服务如何更新到 Nginx

在前面的图里,Nginx 到容器、服务注册到配置文件都没有问题,但是从 Consul 到 Nginx 会出现问题,因为 Consul 有所有的信息,但是这些信息如何通知给 Nginx 呢?一个新的服务起来,或者是一个服务挂掉,这些信息 Consul 知道后怎么让 Nginx 把这些有问题的服务删掉,再把一些新写的服务加进去,这就是我们要解决的问题。

这里的问题就是 Consul 里的服务如何更新到 Nginx,如果解决了这个问题,Nginx +Consul+Registrator 的模式就圆满了。目前也有很多方案可以来解决这个问题:

1、方案一:Consul_template

3.jpg

监听 Consul 里的 key,触发执行一个脚本,利用这个特性的服务,服务发生变动,会根据预先配置好的模板重新生成配置,这个就是最后要执行的一个脚本。   

4.jpg

上图是一个例子,有模板生成 upstream.conf,中间都是将来要被渲染的一些变量,如果 K/v 发生变动,模板化生成一份真实的配置文件,然后再执行一个本地的命令,Nginx -s reload,重新生成配置文件,Reload 一下,这样新的服务就生效了。

当然 Reload 也会有一些缺点:

  • 第一,如果频繁 Reload 会有性能损耗;

  • 第二,旧进程长时间处于 shutting down 状态,如果连接里有长连接,旧的进程会一直处于中间进程,这个时间是不定的,你不知道到底什么时候Reload真正完成;

  • 第三,进程内缓存失效,我们会把数据库的一些信息,一些代码全部缓存进本地,这样缓存就全部失效了;

  • 最重要的一点是与设计初衷不符,它设计的初衷是方便运维不影响当前的请求,就相当于拿 Docker 做虚拟机用一样走歪了,走歪了之后很可能会碰到很多奇怪的坑,所以当时没有用这个方案。

2、方案二:内部 NDS 方案       

5.jpg

DNS 的方案也是比较常用的,比如把之前是一个 IP 地址的 Server,现在改成一个域名,只要把它解析掉一批 IP 就好了,这个听起来已经很完美了,而且 Consul 本身支持DNS,我们也不用维护另外的 DNS 了,只要把这个 ID 换成域名就好了。

但是我们感觉使用 DNS 方案还不如做 Reload,原因是

  • 第一,多了一层 DNS 解析时间,增加了额外的处理时间;

  • 第二,DNS 缓存,这是最主要的原因,因为缓存的存在没办法立即把一台有问题的机器切掉,如果需要缓解这个问题,就要把缓存设得短一点,但这样解析次数就多了。

  • 第三,端口号会改变,物理机一般会配置同一个端口,在 Docker 里也可以这么做,但对于一些对网络不是很敏感的应用,比如一些强 CPU 的应用,我们会直接把容器的网络用桥接的方式连接起来,而这时候端口是随机分配的,可能每个容器分配的都不一样,所以不可行。

我们想要的是通过 HTTP 接口,动态修改 Nginx 的上游服务列表,我们找到了现成的方案,叫 ngx_http_dyups_module。

3、方案三:ngx_http_dyups_module       

6.jpg

ngx_http_dyups_module 可以通过 GET 接口查询当前的一些信息;POST 可以更新上游;也能通过 Delete 删除上游。       

7.jpg

上图是一个例子,这个例子有三个请求:

  • 第一个,给 8080 这个服务端口发了请求之后,发现后面根本就没有任何的上游服务,所以它就 502 了;

  • 第二个,通过一个 Curl 的请求把两个服务地址给加进来;

  • 第三个,重新访问,第三条指令跟第一条指令是一模一样,因为第二条已经把服务加进来了,所以这是一个正常的输出。

在这个过程里没有任何 Reload 的操作,也没有改配置,它就完成了一个功能。

这个模块写得非常好,但是我们用了一段时间后把它下掉了,主要原因不是因为它不好,而是我们结合了一些自身的情况,发现了一些问题:

  • 第一,导致依赖 Nginx 本身的负载均衡算法。如果我们内部用 Ngx_lua 写得比较多,用了这个模块之后,会导致我们非常依赖 C 模块,也就是自身的一些负载均衡算法,我们有自己特有的需求,比如“本机优先”,优先访问本机的服务,这样听起来比较奇怪的负载均衡,如果要做这些事情,我们就要改 C 代码;

  • 第二,二次开发效率低,C 的开发效率远不及 Lua;

  • 第三,纯 lua 的方案无法使用,我们做这样一个方案并不是有一个项目能用就行了,而最好是其他项目都可以用。


动态负载均衡 Slardar 特性

基于以上这些原因,我们开始造自己的轮子。

8.jpg

这个轮子有四个部分:

  • 第一个部分,是最基础的 Nginx,我们希望用一些原生的指令和重试的策略;

  • 第二部分,是 lua 的模块;

  • 第三部分,是 lua_resty_checkups,这是我们 lua 版的管理模块,实现了动态的upstream 管理,这个模块实现了大概 30% 的功能,而且还有一些主动的健康检查功能,它的代码量大概是 1500 行左右,如果是 C 模块估计至少有 1 万行;

  • 第四部分,是 luasocket,千万不能在 Nginx 在处理请求的时候用。

1、lua-resty-checkups

简单介绍下 lua_resty_checkups 这个模板,它有几个功能:

  • 第一,是动态 upstream 管理,基于共享内存实现 worker 间同步;

  • 第二,是被动健康检查,这个是 Nginx 自身的一个特性;

  • 第三,是主动健康检查,这个模块会主动给后端发心跳包,可以定时,15 秒发一次,检查后端的服务是不是存活。我们还可以有一些个性化的检查,比如 heratbeat 定时给上游发送心跳包检测服务是否存活;

  • 第四,是负载均衡算法,本地优先可节约内网流量等。

2、服务区分        

9.jpg

以 Host 区分服务:比如上图两个 curl 往同一个地址去发,这两者之间是不一样的。

3、请求流程       

10.jpg

简单介绍下请求的流程,它可以分为三个部分,最上面是接收请求,会加载一个 worker 代码,worker 代码执行完根据 host 找对应的列表,然后把这个请求代理给服务端。

4、动态 upstream 更新       

11.jpg

这个跟 dyups 的 C 模块一样,也是通过 HTTP 接口来动态更新 upstream 列表,加完后可以在管理页面看到刚加进去的两个服务,这里会有 server 地址、一些健康检查的消息、状态变更的时间,以及它失败的次数,下图是一次主动健康检查的一个记录。

12.jpg

为什么会有主动健康检查呢?大家平时用的就是一些被动的健康检查,也就是请求发出去之后失败了才知道失败了,主动的检查是发心跳包,在请求之前就可以知道服务是不是出问题了。

5、动态 lua 加载

动态 lua 加载在做游戏的时候会经常用到。一开始程序里面跑了一些 lua 的代码,给后端的程序做参数转化和做兼容,比如有一个小调整不乐意去改,就拿前面的路由去做,首先可以对请求做改写,因为我可以拿到整个请求,它的请求体可以做任意的事情。

此外,我们还可以跟一些权限控制结合,做一些简单的参数检查。据我们的统计,我们至少有 10% 是重复请求,如果这些重复请求都去执行就是无谓的消耗,我们会返 304,表示结果跟之前的一样,可以直接用之前的结果。在返 304 的同时,如果我们需要后端的服务去判断,会把整个请求收下来,然后再往后面发,相当于内网带宽要增加一些,这样其实已经节省了带宽,可以不往后面发了。

13.jpg

这是一个动态负载加载的例子,如果把这段代码推到 Slardar 里面,它会执行,如果进行一个删除操作,它会返 403,即可以立即通过这个代码禁掉这个操作,那还有什么功能呢?你可以想象到的功能都可以做,而且这个过程是动态的,如果代码加载,也可以从状态页里看到它的信息。


动态负载均衡 Slardar 实现

前面介绍都是 Slardar 的特性,接下来简单介绍一下实现过程,一共分为三个部分: 动态 upstream 管理、负载均衡和动态 lua 代码加载。

1、动态 upstream 管理

启动时通过 luasocket 从 consul 加载配置文件,服务如果没有任何理由的挂了,挂了之后你刚起来时,你怎么知道刚刚怎么了呢?所以得有一个方式去固化这些东西,而我们选的是 consul,所以它启动的时候必须从 consul 加载,启动之后要监听管理的端口,接收 upstream 更新指令,还要启动一个定时器,这个定时器做 worker 间的同步,定时从共享内存看一下有没有更新,有更新就可以同步在自己的 worker 里。

这是一个简单的流程图,最开始的时候从 consul 加载,在完成 fork 后到了 worker 进程,也就是刚刚初始化加载的那些 worker 都有了,另外一部分启动定时器,一旦有更新就会进入到这个里面。

14.jpg

2、负载均衡

15.jpg

负载均衡我们主要用到了 balance_by_lua_*,一个请求过来,通过 upstream 的 C 模块把这个请求往这里发,如图是配置文件,刚刚也有一个类似的,就是在这里写了地址。通过 balance_by_lua_* 指令,我们会把它拦到这个文件里,就可以在这个 lua 文件里用 lua 代码选一个,这就是自身的一个 checkups 的选择的过程。

16.jpg

上图是大概的流程,可以先看下边部分,一开始的时候,checkups.select_peer 是我们的模块,然后根据这个 host 再到当前的 peer 就跳出去了,这就实现了用 lua 控制。上面部分是要知道它是成功还是失败的,如果它失败了,要对这个状态进行反馈。

3、动态 lua 加载

这个主要是用到 lua 的三个函数,分别是 loadfile、loadstring 和 setfenv。loadfile 是加载本地的 lua 代码,loadstring 是从 consul 或 HTTP 请求 body 加载代码,setfenv 设置代码的执行环境,通过这三个函数就可以加载,具体的实践细节这里就不再介绍。

4、动态负载均衡 Slardar 的优势

这就是我们造的轮子,主要用到 lua-resty-checkups 的模块和 balance_by_lua_* ,它有以下的优势:

  • 纯 lua 实现,不依赖第三方 C 模块,因此二次开发非常高效,减少维护负担;

  • 可以用 Nginx 原生的 proxy_*,因为我们只在请求的选 peer 的那个阶段做,peer 选完之后,发数据的那个阶段是直接走 Nginx 自己的指令,所以它可以用到 Nginx 原生的 proxy_* 指令;

  • 它适用于几乎任何的 ngx_lua 项目,可同时满足纯 lua 方案与 C 方案。


在微服务架构里,Slardar 能做什么

我们目前也在把之前的一些服务改造成微服务模式。微服务其实就是源于一个比较大的服务,把它拆分成一些小的服务,它的扩容跟迁移也不一样,微服务的扩容可以只扩容其中一部分,扩容多少可以根据需求。

17.jpg

我们现在正在尝试一个方案,这个方案背景是我们有做图的需求,做图这个功能有很多,比如说美化、缩略、水印等,如果要对做图的服务进行优化是非常困难的,因为它功能太多了,如果我们把它拆成微服务就不一样了,比如上图虚线上面的是我们现在的服务,这个是微服务的一个网关,下面是一些小的服务。比如说美化,它的运算比较复杂,耗 CPU 比较多,我们肯定选择一些 CPU 比较好的机器;用 GPU 来做缩略图,这个性能可能提高几十倍;最后是一个中规中矩的做图,那就普通的一些就够了。

还有一些比较偏门的,比如说梯度,可能只要保证服务可以用就行了,通过这个微服务的路由,我们根据后面的区分把之前的一个服务,以及它的参数拆成三个小的服务,这样通过三个步骤可以完成一个做图的服务。

当然我们在尝试这个方案其实也有很多的问题,比如一个服务原来用一个程序就可以做了,现在变成了三个,势必内网的带宽要增加了,中间的图片要被导来导去,这个怎么办呢?我们现在想到的办法就是做一些本地优先的调度策略,即做完之后,本地有一些水印的,那就优先用本地的。

最后套用大师的一句话:Talk is cheap,Show me the code目前我们已经将 Sladar 项目开源,项目地址是:https://github.com/upyun/slardar 。