2018 年 11 月 17 日,由 OpenResty 主办的 OpenResty Con 2018 在杭州举行。本次 OpenResty Con 的主题涉及 OpenResty 的新开源特性、业界实践、性能优化、Trace、 API 网关等方面。

又拍云受邀参加 OpenResty Con 2018,又拍云系统开发工程师张超在大会上做了《又拍云 OpenResty / Nginx 服务优化实践》的开场演讲。

又拍云在自身业务中大量使用了 Nginx、OpenResty,使用两者开发了云处理、云存储业务平台,并在又拍云 CDN 平台中使用 ngx_lua 作为反向代理服务, CDN 平台的 API、文件上传服务通过 ngx_lua 对大文件进行流式上传,利用 ngx_lua 对网络线路进行动态的调度,自动选择合适的线路进行上传加速。同时,又拍云在 Github 上也一直保存、更新着又拍云所报告和修复的关于 Nginx 和 OpenResty 的 bug,是 OpenResty 重要的贡献者。

image.png

△ 又拍云系统开发工程师张超

张超,又拍云系统开发工程师,负责又拍云 CDN 平台反向代理层组件的开发和维护。Nginx、OpenResty 等开源软件爱好者。本次分享着重介绍了又拍云 CDN 平台在不断的更新迭代中总结出的关于 OpenResty 和 Nginx 服务优化的经验,包括构建容器化的生产环境性能分析环境、集成 SSL 硬件加速至 OpenResty 及其陷阱等案例。

分享主要分三块展开:

  • 关于性能分析、 Tips 以及使用 Nginx 编码的注意点,使应用代码能够获得更好的运行性能;

  • 又拍云对 DNS 的解析管理;

  • SSL 硬件加速的实践,侧重介绍在使用过程中与 LuaJIT 的冲突及解决思路。


以下是分享实录:


又拍云和 OpenResty 的那些事

又拍云从 2013 年就开始关注并且使用 OpenResty ,是较早使用 OpenResty 的厂商之一。

image.png

△ 又拍云 CDN 架构与 API、云处理架构

上图左边是又拍云 CDN 的简单架构图,右边是又拍云的 API、云处理方面的架构图。图中除了 Cache Server,其他所有的服务全部是基于 OpenResty 实现的,包括又拍云存储的网关。去年,又拍云引入了 Kong,将它作为公司的 API 服务,日志上报、流量上报、监控数据上报等服务的统一入口网关,并做了统一的认证,给我们省了不少的事情。

upyun-resty 这个 github 仓库记录了又拍云所开源的 lua-resty 库、C 模块以及又拍云所报告和修复的关于 OpenResty 和 Nginx 的 bugs。

大家如果有兴趣的话可以浏览这个页面:https://github.com/upyun/upyun-resty

从 2013 到 2018 年,又拍云的业务发展愈加全面,不得不去做更深入的一些优化。业务发展到了一定阶段,必然会遇到性能瓶颈,而这些问题就交由我们这些工程师们来解决。


性能分析、Tips

我们经常会在线上或本地开发机器上执行 top、pidstat 等命令,这些命令会报告一些系统和进程的资源使用情况,这就是资源分析。资源分析主要聚焦于资源利用率、饱和度以及错误数,这就是经典的 USE 分析方法。这个分析方法由 Brendan Gregg 大神提出,非常有用。通过资源分析可以非常直观的看到业务应用在线上跑着,会吃掉多少的内存,占用多少的 CPU,产生多少磁盘吞吐等。

第二个是针对应用程序本身所展开的工作负载分析,工具也比较多,包括 perf,systemtap,bcc/ebpf 等工具。通过 systemtap、bcc/ebpf 可以将数据进行汇总,利用工具画出火焰图。负载分析主要是针对应用程序自身的表现,比如 OpenResty,在作为一个反向代理服务器或者网关存在时,大家比较关心它的指标就是请求延时和状态码,像 501、502、504 状态码的比例。

当我们需要展开分析时,会发现线上环境,工具等条件不足,甚至连基本的编译工具都没有,这时候像 systemtap 这样的工具就没法用了。又拍云也遇到了这样的问题,因此我们采用了容器化,把所有的工具全部扔到了容器里,包括一些基本的编译器,链接器,常用的 perf、systemtap、gdb、mozilla rr 等工具。大家可能会觉得容器的权限会不会有问题,当然首先这个容器必须是以特权模式去运行,另外还需要把宿主机的内核头文件进行映射,并且必须使用宿主机的 pid namespace,否则在容器里面没有办法去追踪宿主机上的进程。

image.png

△ 容器案例

上图例子中运行的容器使用的是 upyun_stap 的镜像,跑起来后可以在这个容器里面做任何的事情,但是它对内核是有一定的要求,如果想使用 systemtap,内核就必须把一些相关的选项打开。这是我们一个小的思路,如果遇到环境工具不足的情况,可以考虑采用容器化的方法。

ngx_lua 编程怎样挑选合适的 API 获得更好的编程体验

介绍一下 ngx.ctx 和 ngx.var,这两个 API 一般用于储存和请求相关的数据,它们的数据都是关联到某个请求。ngx.ctx 本质上就是一个 Lua table,后者 ngx.var 实际上是一个获取 Nginx 变量的接口,通过它可以设置和获取变量。相比较而言, ngx.ctx 是更好的选择,首先 Lua table 的实现非常快,云风曾在一篇文章里提过,Lua table 查询一次的耗时,几乎等于对 key 进行一次 Hash 的过程。其次 ngx.var 返回的是 Nginx 变量的值,它的返回值只能是字符串,类型比较单调,如果存一些结构化的数据,就不得不去进行序列化,从而会带来一定的开销,并且在 ngx.var 的接口内部还有一些 Hash 的计算、内存的分配等。

image.png

△ Tips

分享一个 Tips,我们可以适当的 cache 一下 ngx.ctx,去避免相对比较昂贵的 metamethod 元方法调用。比如上面这个代码片段,先把 ngx.ctx 进行 local,然后再对它进行一些复杂和取用,这样就避免了额外的 metamethod 调用。

ngx.ctx 也有一些缺陷,最大的缺陷就是生命周期只能局限在一个 Nginx location 当中。换言之,如果业务比较复杂,Nginx 发生了内部重定向,存储于 ngx.ctx 内的数据都会被丢失,所有模块的上下文都会被清掉,因为 ngx.ctx 实际上是存在 ngx_lua 模块的上下文当中。所以在发生一次内部调用之后,数据就再也获取不到了。这个问题去年我们在 github 也问过,后来用 Lua 实现了一个折中的方案,我们模拟了 ngx.ctx 的实现过程,将它保存在我们自己所创建的表里,然后将它的索引值保存到变量中,因为变量的生命周期是不受 location 影响,而是贯穿整个请求的,不管请求中间发生过多少次的内部跳转,变量都是一直存在的。

image.png

△ 方法案例

举一个例子,上图从 location / t1 跳转到了 t2,数据还是可以从 location /t 2 中获取出来。通过这个方法,就可以保证即使发生了内部跳转,依旧可以获取数据。

HTTP headers 操作

介绍大家在用 OpenResty 的时候,必然是离不开操作 HTTP 头部的,主要的接口就是 ngx.req.get_headers,这个接口会返回所有的请求头,如果说没有给它指定 raw 参数,所有的名字都是小写的形式存在的。如果你传入的请求头名称不是全小写的,第一次是获取不到的,它会把这个全部转换成小写,再去访问一次。因此在这个过程中,我们会访问表两次,而且中间有一次经过了元方法的调用,在针对这个事情上有一个比较好的办法,那就是手动的把它的元表去掉,然后在访问的时候全部使用小写的形式。这样就可以省掉元方法的调用,可能一次元方法、两次元方法,看不出什么效果,但是当一个请求里面可能操作比较多的请求头,同时 qps 比较高的时候,就能体现出一定的差距来。

然后是日志处理,我们大多都会采用 Nginx access log 模块去管理访问日志。它的弊端就是和磁盘、文件系统进行了交互,那么就有可能受到文件系统或磁盘的影响,如果磁盘抽风或磁盘利用率满了,反过来就会影响到 Nginx 的进程。因为 OpenResty 和 Nginx 在写磁盘的时候,是同步写的,如果 access_log buffer 的参数设置不合理,会产生多次的 write() 系统调用,对应用程序本身是非常不利的。建议大家可以使用 lua-resty-logger-socket 组件,又拍云也使用了这个工具,将日志发送到同一个机房同一个网段的其他服务,由其他的服务去上报日志,这样可以避免 CDN 服务和磁盘直接进行交互,最大程度保障服务不会收到磁盘本身的影响。因为一块磁盘有时候不仅一个服务在用,其他的服务也在用,如果其他的服务产生太多的磁盘写入,就会反过来影响到 CDN 服务。

养成良好的编程习惯

  1. 不要滥用全局变量:在 ngx_lua 编程中,全局变量是在一张表里,虽然 Lua table 设计的很好,每次访问都仅仅是一次表查询,虽然速度很快,但是和本地变量的读取还是有差距的,所以全局变量能不用就不用。

  2. 避免低效的字符串拼接操作:举一个臭名昭著的例子,在 for 循环里去拼接字符串,会产生大量的内存分配,大量的内存拷贝,带来 GC 开销。

  3. 避免太多的 table resize 操作:大家在编程时,需要一个表就创建出一个空表,往里面写数据,这个过程中会发生一些 table resize,表内部的 hash part 大小会发生改变。比较好的办法就是使用 table.new 创建预先定义好大小的表。

  4. 使用 LuaJIT 和 lua-resty-core 的搭配,避免使用官方的 Lua 解释器:LuaJIT 非常的强悍,所以也应该尽可能的去使用 JITable 的函数,同样 LuaJIT 的 ffi 实现也非常的优秀,比使用原生的 Lua C stack 的交互方式更加的方便、高效。

DNS 解析管理

大家可能对 Nginx 本身的域名解析的理解上存在一些偏差,这里会介绍一下 Nginx 运行时所使用的 DNS 解析器的一些缺点,以及分享又拍云基于 ngx_lua 所做的解决方案。

image.png

△ 案例

图中虚拟主机的后端是写在 upstream group 里面,只有一个 server 指向 upyun.com 域名,大家会觉得这样写完就没事了。某一天 upyun.com 指向的那台机器崩掉了,匆匆忙忙去改解析,却发现没有生效,然后服务还是出现一 堆 502。因为这样的一个域名,在 Nginx 或者 OpenResty 刚刚启动的过程中,就已经解析完了,worker 进程在处理请求的过程中不会对域名的解析发生任何改变,其实解析在处理 server  指令的时候就已经完成了,这第一个误区。这种情况下只能手动去重启服务,如果服务像 CDN 那么多,会比较耗时和麻烦。第二如果不用 upstream 直接带过去也是不行的,两者实际上没有太多的差别,Nginx 内部也会创造一个 upstream group,然后把域名解析好。所以这两种方法,总的来说只会解析一次,使用的是 getaddrinfo 或者 gethostbyname,调用系统配置的 nameserver 去进行配置。

image.png

分享一个比较好的方法,可以强迫你的服务进行解析,就是在 proxy_pass 指令里使用变量,把整个解析过程推迟到运行时。proxy_pass 后面的那一串是一个复杂值,会进行一个变量的解释,再对域名进行解析,这时候会使用到 Nginx 的 resolver,比如上面所定义的,定义了三个 nameserver。然而 Nginx 的运行时 DNS resolver 的问题也挺多的,DNS resolver 虽然支持轮询,也支持 cache,可以简单的缓存在内存中,但是不支持配置备用 name server,所有配置好的 nameserver 之后都会被使用。同样 DNS resolver 也没有实现故障转移,关键的是不支持在故障时使用陈旧数据。因为出现故障时,有数据肯定好过没数据,即使解析是旧的,也能挡住一定的危害。

image.png

△ DNS resolver的运行情况

我们对 DNS resolver 不是非常的满意,又拍云的业务也都是用 Ngx_lua 所编写的,索性就直接弄了一个 lua-resty-domain 的库,它主要承担的是域名的管理。它基于 OpenResty 官方的 lua-resty-dns (DNS 解析器的库) 同时结合 Cloudflare 开源的 lua-resty-shcache (一个缓存组件的库),也结合了又拍云所开源的 lua-resty-checkups(主要是做心跳和负载均衡的库)。全部结合起来以后,lua-resty-domain 能够做到复杂的 load balancing,包括简单的轮询、带权轮询和一致性 Hash,或者是其他的一些负载均衡的办法。lua-resty-domain 支持心跳功能,就是向目标的 nameserver 发起解析请求,看它的解析情况,以及是否超时等。 lua-resty-domain 也支持故障转移,如果所有主 nameserver 都挂掉了,会去使用备用的。因为结合了 lua-resty-shcache 所以也支持 cache 功能,可以把解析出来的数据存在共享内存当中。关键的是,lua-resty-domain 能在所有的 nameserver 挂掉的时候,提供一些陈旧数据,保证业务不会受到影响。综合来说,这是一个比较好的解决方案。

image.png

△ lua-resty-domain 配置

在 cluster 块中定义了一主一备的 nameserver,能在 DNS 解析方面提供一个高可用的解决方案。DNS 是每个服务里面不可获缺的一部分,比如使用了公共的 DNS 的时候,无法保证公共DNS 时刻没有问题, 比如某个时刻 DNS 服务器负载较高,解析延时上升反而影响到了业务,这就划不来了。所以万事还是要从自己做起,做好一个完美的解决方案,尽可能的避免这样的影响。

image.png

除了使用 lua-resty-dns 库,ngx_lua 的 Cosocket,内部也提供了简单的域名解析功能,使用的也是 Nginx 的 resolver,但我们不太想用。所以我们参考了一下 Kong,把 ngx.socket.tcp 的 connect 方法重写了一下,先调用 lua-resty-domain 进行解析,再将解析出来的地址传递给原来的方法,强迫它使用我们所定义好的域名解析功能。虽然这些代码没有特别容错,但是足够表述出这种思想,这是一个非常好的 idea。

SSL 硬件加速实践 BUG 解析

SSL Acceleration 会把应用中所涉及到的加解密、签名等占用 CPU 的任务全部 offload  到像 Intel QAT card 这种具备计算能力的硬件上去,从而降低应用程序的负载。 Acceleration 这个词比较有迷惑性,并不是说计算本身得到加速,而是说这种技术能够让应用程序的吞吐得到提升。尤其结合异步模式,采用异步的方式把任务全部都交付给硬件后,应用就可以处理其他请求了,这样使得应用程序的吞吐得到了一个质的提升。

CDN 厂商关注的就是 CDN 和带宽的比例,CDN 跑到 80%、90%,但是带宽一直上不去的话就完全利用不起来机房的带宽,这对成本来说是不利的。如果在同等的 CPU 消耗情况下,能产生的带宽和吞吐量上去了,对成本会比较有利。又拍云所采用的就是 OpenResty+Asynchronous OpenSSL+Intel QAT 的组合方案。

image.png

△ 硬件加速前后数据

这张对比图,上面是没有使用硬件加速的数据,下面是使用了硬件加速的数据,分别做了一次RSA 私钥签名以及公钥认证的过程,可以看到最后一行的对比,使用 QAT 卡之后私钥签名的过程至少提高了十倍,后面用公钥认证也有八到九倍的提升。不过这是理想的情况,是没有任何业务的时候的得到的一个理论值,业务当中肯定是达不到的。

image.png

△ 实际 CPU 效果

这是我们监控中采集到的 CPU 的变化曲线,当时的连接数大概是三四万个到 443 端口的连接。 CPU 的变化可能就没有那么理想,大约降低了10%,因为其他的业务也需要占据一定的 CPU。集成到又拍云这样复杂的 CDN 系统中也算是一个非常不错的提升了。

异步模式的 OpenSSL 和 LuaJIT 不能很好的共存,会出现应用程序崩溃的问题,这是非常不幸的。对此我们也排查了很久,究竟是什么导致的崩溃。

首先尝试过将 LuaJIT 的 JIT compiler 禁用,结果是还会出现崩溃。也尝试过关闭 OpenSSL 的异步模式,崩溃确实没有出现了,但是同步的去做硬件 offload 过程,没有比直接用软件计算快多少,那就没必要用这个方案了。另外我们在 ssl_certificate_by_lua* 阶段有 ocsp 校验过程,会有一些 Light Thread 的协程切换,我们也试过关闭 ocsp,但崩溃问题还是会出现。最后,我们甚至将服务中用到的其他一些组件全换成了原生组件,但问题依旧存在。

image.png

△ 流程图

后来,LuaJIT 社区有一个哥们说我们的程序是不是存在这么一个情况。

首先 Nginx 要进行 SSL handshake,运行在系统栈上。异步模式 Open SSL 内部使用了 OpenSSL fibre,其实就是协程,启用这个模式后,会涉及到 fibre 上下文切换的过程, 此时程序会运行到 OpenSSL fibre 的栈上,前面我提到了我们的程序在 ssl_certificate_by_lua 阶段也有业务介入,那么反过来程序又会跑到了 LuaJIT 的栈上,从而可以运行应用代码。应用代码又调用了 ssl.set_der_cert 这样的接口去设置证书,证书都是保存在 redis 这样的外部组件里的,拿出来就需要设置进去。设置进去以后,会涉及到一些摘要计算,此时程序运行到了 OpenSSL fibre 的栈上去了,在交付给硬件以后,OpenSSL fibre 可能会将自己切出,所以此时应用程序又回到了系统栈上,即握手之前的位置,然后 Niginx就非常愉快的去处理其他的请求,其他请求也是类似,所有的业务代码都是由 LuaJIT 运行的,某个时刻就又跑到了 LuaJIT 的协程栈上。

整个的过程简单来说,就是我们重入了 LuaJIT,但是并没有调用到 lua_yield() 函数。

Lua 是用 lua_resume() 和 lua_yield(),去进行协程切换的。我们相当于违反了这个规则,重入到了 LuaJIT,发生了不可预期的问题。

image.png△ 禁用代码

目前想到的好办法就是在 ngx_lua 和 ssl 相关的 API 里去禁掉上下文切换。所幸 OpenSSL 异步模式提供了相关接口,我们在相关的函数里加入了两段代码,重新编译完放上去后,应用程序就开始正常的工作了。

我的分享就到这里,希望大家遇到同样的问题能有一些启发。