2019 年 3 月 23 日,OpenResty 社区联合又拍云,举办 OpenResty × Open Talk 全国巡回沙龙·北京站,又拍云平台开发部负责人叶靖在活动上做了《OpenResty 在又拍云容器平台中的应用》的分享。

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

叶靖,又拍云平台开发部负责人,目前主要负责又拍云弹性云处理平台以及内部私有云的设计和开发工作,兼部分文件上传接口相关的工作。对 Python / Lua / Go 等语言有较深入的研究,在 ngx_lua 和 OpenResty 模块开发方面有丰富经验,专注于高并发、高可用服务架构设计,对 Docker 容器有较多的实践。平时热衷于参与开源社区分享开源经验。

以下是分享全文:

大家好,我是又拍云叶靖,今天主要分享 OpenResty 在又拍云容器平台中的应用。目前又拍云有很多产品,其中很多都使用了 OpenResty 技术,比如又拍云的 CDN 、网关都是基于 OpenResty,还有内部很多服务都是依赖于 OpenResty 进行运营。又拍云最近几年在 OpenResty 上的贡献都在这个地址:https://github.com/upyun/upyun-resty,包括一些开源的库、又拍云为社区做的工作、活动上的技术分享等。

又拍云容器云平台

image.png

又拍云检索界面

上图是又拍云私有云检索界面,可以帮助应用开发者来管理应用,开发者可以通过这个平台去创建、部署、更新应用,也可以做一些备份文件的管理等。那么我们为什么需要做这样的平台?

业务特点

首先要从又拍云的业务特点说起,主要有五点:

第一,域名多,又拍云有上千个域名,很多域名都需要 SSL 支持,所以我们要做动态的 SSL 调度;

第二,服务多,因为域名多必然会带来服务多,又拍云又是一个微服务的架构,很多大的服务都会被做成很小的服务;

第三,服务间的调用关系非常复杂,这些服务都是微服务,所以它的关系就构成了一个非常复杂的调度关系网,我们必须找到一个好的办法去处理这样的关系;

第四,流量大,这和一般的 Web 站点不一样,Web 的 API 都是非常轻量级的 API,请求包非常小,而我们刚好相反,比如上传、图片处理、视频处理,请求包非常大,都是一个图片文件或者视频文件的上传下载,所以必须找到一种好的办法去处理大流量;

第五,高可用,这是所有公司需要做的,服务不能存在单点。

解决办法

对应着业务的特点,又拍云做了对应的解决办法:

第一,针对域名多,需要一个 API 网关,让网关来帮我们管理上千个域名;

第二,针对服务多,我们想到了容器,由于服务非常多,它的依赖会非常多,如果不用容器,可能机器就会比较乱,所以我们就选了容器;

第三,针对调用关系复杂,需要容器间的网络可以互通,也就是说在 A 容器里可以直接通过 B 容器的容器地址,不需要经过物理机再绕一层;

第四,针对流量大,需要一个高性能的负载网关,并且非常重要的一点是必须要避免产生基本流量,我们的流量会经过一层代理,它都会产生新的流量,会新建一个到上游的链接,然后再把流量转过去,这时候就会产生新的流量;

第五,针对高可用,我们希望用 VIP 或内部域名解析的方式取代直接的 IP 访问。

image.png

kubernetes+OpenResty

上述五个解决办法让我们想到非常熟悉的 kubernetes(K8S),它可以帮助我们解决很多问题,比如服务多、调用关系复杂、高可用等,K8S 有 VIP 的概念,它可以把多个服务的 IP 映射到 VIP 上。

有了 K8S 还不够,还要解决网关的问题,所以我们使用了组件 OpenResty ,OpenResty 除了可以做外层的网关,还可以做内部的负载均衡。

image.png

业务拓扑图

选定这两个组件后,再看数据中心的业务拓扑图,虚线以上是公网,有一个 API 网关直接对公网访问,所有域名的解析全都会找到 API 网关。API 网关以下是内网,有一个很大的容器集群,大概占了又拍云所有业务的 90% 以上。一部分是基于虚拟机,基于 K8S 做的容器集群,另一部分的物理机和虚拟机需要通过负载均衡才能够访问到容器集群,通过内部的 Load Balancer 来负载内网的流量。

第一个 OpenResty 应用:Kong

image.png

Kong

OpenResty 在又拍云容器云平台的第一个应用是 Kong,Kong 对于大家来说应该非常熟悉了,在 API 网关方面应用非常多。又拍云做网关的时候,毫不犹豫地选择了 Kong。Kong 的功能非常多,这里举了几个我们用到的 Kong 的功能:首先就是域名管理,它帮我们管理上千个域名;对于 SSL 的域名它可以做证书的调度;还有一些访问控制,主要是 IP 黑白名单;权限认证,做一些基本的权限校验;还有速率控制、流量整形;最后一个非常有用的功能是 Kong 可以支持 API 的管理,又拍云内部的容器平台就可以通过 API 的形式,把一些新的配置直接推送到 Kong ,不需要人工在 Kong 上做配置。

image.png

又拍云使用 Kong 的插件

上图是又拍云使用的 Kong 的插件,我们用 Kong 还算是比较轻量级,在 Kong 上走的都是轻量级的 API,比如 REST 等请求 body 比较小的可以走 Kong ;如果请求 body 比较大,比如上传,我们会有自己的单独实现的基于 ngx-lua 的网关,专门做上传流量的处理。Kong 的插件基本上用的是权限控制、流量整形方面的插件,没有用特殊的插件,这些插件可以在 Kong 的库里找到。

第二个 OpenResty 应用:Nginx Ingress Controller

OpenResty 在又拍云容器云平台的第二个应用是 Nginx Ingress Controller。有了 Kong 之后,我们解决了 API 网关对公网的 API 网关的管理问题,我们还有一层网关是内网的负载均衡,它后面承担的是所有容器上的业务流量,还有内网的物理机、虚拟机、请求容器的流量全部在这上面。所以我们选型的时候,选择了官方的 Nginx Ingress Controller ,这是谷歌官方的负载网关的实践,也是应用最广泛的负载网关。

又拍云用 Nginx Ingress Controller 做多 SSL 证书调度,因为内网的域名很多也是用 SSL 证书的;它支持动态 upstream 管理和灰度更新,灰度更新是它的亮点,非常好用的功能;另外还支持 TCP 负载均衡,当然我们没有用这个功能,因我们觉得用 TCP 负载均衡,还不如直接在 K8S上来实现;此外还支持 WAF、链路追踪,这虽然是非常必要的,但是我们目前还没有在内网的负载均衡上加上 WAF 和链路追踪的功能。所有的这些功能都是可以在 Nginx Ingress Controller 动态更新的,它不需要更新 Nginx 本身,只要 reload 它的配置文件就可以生效,这是Nginx Ingress Controller 可以自动帮我们做的,我们需要做的就是修改配置。

Nginx Ingress Controller 原理

Nginx Ingress Controller 是基于 ngx-lua 的项目,虽然不是直接由 OpenResty 直接来做的,但是用到了 Nginx 和 ngx-lua 模块。它主要分成两个部分,第一部分是 Controller ,是由 Golang 写的程序,第二部分是官方原生的 Nginx 打了 ngx-lua 模块。

Controller 在启动的时候会把 Nginx 启动,在 Nginx Ingress Controller 里面,Nginx 的生命周期是 Controller 管理的,包括启动和关闭。

在 Nginx 启动之后,Controller 会去监听 K8S 的 API Server,去获取一些事件,这些事件包括的域名添加、删除、upstream 的更改等,然后 Controller 会生成新的配置文件 nginx.conf,然后它会发一个 reload 的指令给 Nginx。如果这个更新不需要去 reload Nginx,比如它只更新了upstream ,可以通过 Lua 来做,把新的 upstream 直接推到 Lua 的代码里面,Lua 代码主要是负责动态 upstream、灰度更新、证书调度,在它的实现里面,只有这 3 个功能是通过 Lua 来做,其他的功能是通过 nginx.conf 来做的。

动态更新 upstream 原理

image.png

动态更新 upstream 原理

下面介绍一下动态更新 upstream 的原理,上图的这个过程是基于 ngx-lua 的动态 upstream 管理的通用的做法。首先是 Controller 监听 K8S 事件,获取新的 upstream 列表,它会决定是否需要 reload Nginx。如果我们添加了新的域名或删除域名,需要 reload nginx.conf,生成一个新的 nginx.conf;如果不需要 reload,比如这次更改只有 upstream 地址的更新,没有其他的任何更改,此时会发一个 POST 请求给本地的地址,这个地址是 Nginx 的地址,然后在 Lua 代码里把新的 upstream 写到共享内存。

另外在 Nginx 里面起了一个定时器,每个 woker 里都有一个定时器,定时器会定时去读取共享内存,把共享内存里的 upstream 信息读到自己进程里面的 Lua table。此时当一个新的请求通过 proxy_pass 进来,就可以在 balancer_by_lua 进程内部拿到更新后的 upstream 列表,选一组地址带过去,这个就是动态 upstream 的原理。

灰度更新原理

image.png

灰度更新原理

灰度管理非常简单,也是在 lua 代码里面做的,主要是在 balancer_by_lua 生成了随机数,weight 是灰度的百分比,如果随机数小于 weight 会把它带到灰度版本,如果大于 weight 就会继续使用稳定版本,总共的代码不会超过 30 行。

SSL 证书调度原理

image.png

SSL 证书调度原理

SSL 证书调度也是通用的证书调度流程,主要是通过 ssl_certificate_by_lua_block 的指令来完成,配置文件只有上图中的四行代码,这四行代码主要做以下四件事情:SSL证书调度原理,主要是四件事情,一个是拿到当前请求的 hostname,然后拿 hostname 对应的证书和 key ,把旧的 key 清掉,把新的 key set 进去,就完成了一个证书的调度。

看完这个原理图,我们会发现其实 Nginx Ingress Controller 它的 Lua 代码并不复杂,每个功能实现都非常简单,我们要做移植其实也很简单的。

部署与更新

image.png

Nginx Ingress Controller 更新方法

上图是 Nginx Ingress Controller 的更新方法,更新其实就一句话,由于它是基于 K8S ,所以它的更新就是把 kubectl apply 一下,把 yaml 的文件应用起来,当然 yaml 文件需要事先写好。更新只需要修改版本号就可以,如果我们发现了 BUG ,把新的版本号写进去就可以生效。

image.png

部署拓扑

上图是 Nginx Ingress Controller 的部署拓扑图,一个大的容器集群里有四个 node ,分别是四台物理机,物理机里跑了很多的容器,Nginx Ingress Controller 其实也是一个容器,跟其他容器平级,完全对等的关系。当请求进来,它作为负载均衡,请求会发到 Nginx Ingress Controller,会把流量负载到其他的容器。

因为 Nginx Ingress Controller 是基于容器部署的,所以这里会有一个问题,我们应该用 Docker 的 Host 模式还是 Bridge 模式。

image.png

Host 和 Bridge 模式对比

上图是 Host 和 Bridge 模式的对比,Bridge 模式是在物理机里建立了虚拟的网桥,这里做了一个 NET,即网络地址的转换,如果是 Host 模式,就不需要经过内部的网桥,它的网络直接是宿主机的网络。所以当我们在部署负载网关的时候,就选择了 Host 模式,因为又拍云内部有很多大流量的请求,基于 Host 模式部署,就少了网桥的转发,性能是最好的,我们希望能够尽量把请求损耗降到最低,所以选用了 Host 模式部署。

由此,引出了以下的问题。首先,因为 Nginx Ingress Controller 是用容器的方式部署的,这就避免不了如何热更新负载网关的问题?虽然刚才讲到更新很简单,只是改个配置文件的版本号,但是这个过程,它会把旧的容器杀掉,再起一个新的容器,过程中会产生 502 ,业务会中断,当请求流量很高的时候,5XX 也会非常多。此外,是业务分散在多个部门,有很多的业务部门都会使用到负载均衡,有一些是 IP 写死的,这时切流量,为了不产生 5XX ,需要一台一台更新,过程会非常痛苦,我们曾经做过几次,基本上每一次大概需要一周的时间,才能把所有的负载网关更新完成。第三,灰度更新网关本身不方便,比如一个新的 Nginx 的版本需要更新,不可能一次性全部更新,需要一台一台更新,这样就不能用改下版本号的方式来更新,一旦改版本号所有的都会更改。这个问题在没有 bug 出现的时候,不需要更新 Nginx 是没有问题的,但是一旦 bug 出现,就会非常痛苦,我们在经历过几次之后就不能忍受了。

第二个问题是负载网关能否不加入 K8S 集群?负载网关其实是一个容器,必须跑到 K8S 集群里面。在又拍云,我们使用 OpenResty 已经比较多了,有很多历史的项目也是基于 OpenResty 的,这些项目也是作为内部服务的负载均衡或者网关的角色存在,这些网关肯定是不能加入 K8S 集群的,有没有办法让它们直接访问到容器,这也是我们考虑的一个点。另外,作为网关的机器,它的硬件配置也不一样,比如网卡会特别大、CPU 会好一些、硬盘可能很小,而且地址通常不会变动, 负载网关机器专机专用。

为了解决上面提到的问题,我们需要一个能独立部署在物理机上的 Nginx,并且能直接访问到 K8S 集群中的容器,不要在 K8S 集群当中,最好是单独的 Nginx,这样就可以用 Nginx 的热更新机制,非常方便地动态更新 Nginx 的二进制。需要做的事情就两步,首先把 Ingress Nginx 移到容器外,然后打通 Nginx 所在机器与容器集群的网络,这样物理机负载的网关机器就不需要加入到 K8S 集群里面了。

第三个 OpenResty 应用:Ingress OpenResty

 Ingress OpenResty 的两个特点

OpenResty 在又拍云容器云平台的第三个应用是 Ingress OpenResty,这是又拍云自己做的内部的负载网关。

它的两个特点,首先是分离了 Controller 与 Nginx,它们是相互独立启动和维护;Nginx 可独立 reload, hot update;Controller 可以直接重启,Controller 做的事情就是监听 K8S 、获取事件、更新 nginx.conf 、reload,它是无状态的,遇到问题可以直接重启。

第二个特点是支持 K8S 集群外部署,可以在 CentOS7 物理机上直接部署 K8S,负载网关的机器不需要加入 K8S 集群,需要负载网关的机器与容器在同一个二层网络中。

分离 Controller 与 Nginx 实现方式

首先是分离 Controller 与 Nginx,我们所有的这些工作都是基于官方的 ingress-nginx 做的,多数工作都是代码的拷贝,没有做太多额外的开发。第一部分需要一个 Nginx 配置的模板,可以去官方的 nginx.conf 的配置模版拷贝出来,去掉一些不必要的配置,比如我们用不到 WAF、链路追踪等,这样可以少编译很多 Nginx 模块;第二部分是 Lua 代码,前面介绍过 Lua 代码里面实现了三个功能是怎么做的,可以直接把 Lua 代码拷过来,基本不用修改;第三部分是 Controller 去除启动与停止 Nginx 的代码,因为在官方的实现里,Controller 会控制 Nginx 的生命周期,因为在官方的实现里,Controller 会控制 Nginx 的生命周期,可以启动与停止 Nginx ,但是我们希望两者互相独立,所以要把这些相关的代码删掉,修改 nginx.go 中相关代码;第四部分是需要编译一个新的 Nginx,官方的 ingress-nginx 是从官方的 Nginx 编译的,打了 lua_nginx_module,又装了很多 lua_resty 库,也写了 Lua 代码,但是我们觉得没有必要,因为 OpenResty 已经集成了所有常用的 lua_resty 库,所以我们直接从 OpenResty 编译。

做了上面四步之后,我们发现一个比较严重的问题:Nginx 重启后,upstream 丢失。在原先的 ingress-nginx 实现中,它的 upstream 是放在共享内存中,reload 是没有关系,但是如果 stop 后再开启,或者做二进制的热更新,这时共享内存会丢掉,所以 upstream 就找不到了,相当于所有请求没法做代理。我们想了一个比较简单粗暴的方式,把所有 upstream 信息持久化保存到磁盘,每次 Controller 收到新的 upstream 信息后,会把这个信息先写到磁盘上,然后再去动态地更新 Lua 里面的共享内存。当 Nginx 重启的时候,在必经的 init_by_lua 阶段从磁盘加载回共享内存,这步里是需要写一些 Lua 代码,大概 20 行左右,从你指定的位置去加载保存 upstream 信息。

上面这个过程,我们写了一个简单的 Dockerfile ,主要包含四个步骤,第一,基于 CentOS 7.3,因为我们线上的系统是基于CentOS 7.3;第二,静态安装 Pcre、OpenSSL,我们不希望它依赖系统的库;第三,需要安装 OpenResty,没有用官方 Nginx ,直接用 OpenResty 来编译;第四,安装额外的 Lua Resty 库,比如用 lua_resty_upload 做表单的上传等,整个过程我们放到 gist 上,地址:https://gist.github.com/yejingx/bb36cf78a149635ccd0581b311bcc403

支持 K8S 集群外部署实现

支持 K8S 集群外部署需要打通物理机与 K8S 集群的网络,这需要先了解 K8S 的网络互通原理。此外,又拍云流量都非常大,我们要避免产生新的流量,在做整个方案的时候,我们要考虑如何做才能让我们的流量代理转发次数尽量地少。

image.png

K8S 的网络原理图

上图是 K8S 的网络原理图,这里有左右两个 node,即两台物理机,物理机上面的容器都是用 Bridge 模式。左边的容器的数据可以直接通过物理机的网络,转发的右边的容器,访问右边的容器,这是 K8S CNI 接口要求的,所有 K8S 上的容器必须互通。

它的实现方式是用 K8S 的网络插件我这里介绍三种,第一种是 Flannel,第二种是 Calico,第三种也是我们使用的叫 Kube-Router。网络插件有很多,但是从原理上就两种,第一种叫 Overlay,第二种是 route。Overlay 顾名思义,是网络上面的网络,就是把 IP 包(三层网络的数据包)封装在另外一个主机的网络上,发到对方主机,再由对方主机把网络数据包解码,送到上层;route 是没有数据包的封装和解封装的过程,它直接在主机的路由表上面把路由信息写进去。

image.png

Kube-Router 网络原理图

我们选用的是网络方案是 Kube-Router。上图 Kube-Router 的原理,通过本地的路由表转发数据包,这里有一条路由规则:10.1.1.0/24,这是左边机器的容器的 IP ,它的 via 的下一个地址是192.168.1.100,也是左边的这台机器,有这条路由规则以后, 上面下来的数据包可以直接通过路由的方式转转到左边的 node 上。有路由表就必然要交换路由信息,路由信息在 Kube-Router 里面是通过 BGP 协议进行交换的。

熟悉了 K8S 网络互通的原理之后,就可以想办法打通负载网关的机器和 Docker 容器集群,有以下几种办法。第一,在负载网关机器上部署 Kube-Router,当然负载网关的机器不是 K8S 集群中的机器,这个方案其实是不可行的,因为 Kube-Router 是不支持这样的部署方式的;第二,部署 Kube-Router 的目的就是让它把路由表同步过来,如果只要做这件事情就不需要用 Kube-Router,可以直接部署能够同步路由表的软件,比如 Quagga ;第三,把负载网关机器上也加入到 K8S 集群里面,当然就互通了,这是最简单的方法,这个负载网关的节点上不要部署任何其他的服务,要只部署一个负载网关。

image.png

部署拓扑图

上图是我们改造的 Ingress OpenResty 的部署拓扑图,底下是容器集群,部署是在集群之外,它的 Nginx 和 Controller 是互相独立的,Controller 会监听 K8S 的 API Server,然后把更新信息通过 reload、动态 host 的方式更新的 Nginx,底下的数据包的转发是通过路由来实现,需要一个 Roure Table 转发到容器集群。

image.png

Request Time V.S.

我们也做了一个性能对比,上图是我们改造后的 Ingress OpenResty 和原生的 Ingress Nginx 在请求时间的对比,会发现没有任何差别。为什么会有这样的结果呢?因为原来的 Ingress Nginx 是 host 模式部署,这种模式部署的机器和直接跑在物理机上的部署模式在网络上是一模一样的,所以它的请求时间也是一样的。

优缺点

这样部署有什么优点呢?这就回到了我们的初衷,我们做这个并不是为了提高性能,而是我们嫌更新太麻烦,不够灵活,所以它的第一个优点是平滑升级,不管怎么升级都没有问题,更新 Lua 代码、配置文件、Nginx 二进制都是非常方便的;第二个优点是性能损耗小,理论上直接物理机上部署性能损耗是最小的;此外,它可以支持集群外的部署,但要保证网络是互通的。

当然它也有缺点,第一个缺点是需要自己维护项目,我们做了改动后,虽然改动的代码不多,但也需要自己维护,比如我们新加的一些 Lua 代码、改动了 Controller 代码、删了一些不必要的代码。不过,维护成本也不是很高。第二个缺点是需要与容器在同一个二层网络或者打通 IPIP 隧道,我们用的 Kube-Router 方案是基于路由的,网络需要在同一个二层网络,不能跨路由器,跨路由器是不会转发 Docker 内网的数据包的,所以缺点就是必须在同一个路由下面。当然,解决办法就是打一个跨路由的机器,打一个 IPIP 隧道,它也是可以网络互通的。

这是我今天的分享内容,谢谢大家。