2019 年 8 月 31 日,OpenResty 社区联合又拍云,举办 OpenResty × Open Talk 全国巡回沙龙·成都站,奇安信工程师艾菲在活动上做了《OpenResty 关键特性概览》的分享。
OpenResty x Open Talk 全国巡回沙龙是由 OpenResty 中国社区、又拍云发起,邀请业内资深的 OpenResty 技术专家,分享 OpenResty 实战经验,增进 OpenResty 使用者的交流与学习,推动 OpenResty 开源项目的发展。活动将陆续在深圳、北京、武汉、上海、成都、广州、杭州等城市巡回举办。
艾菲,奇安信工程师,前 OpenResty 软件基金会成员。2013 年加入奇虎 360,擅长使用 OpenResty 完成各种网关和缓存行为。
以下是分享全文:
大家下午好,我是艾菲,目前在奇安信做服务端开发。前段时间 OpenResty 发布了 1.5 版本,里面有很多激动人心的改动,比如 Lua 的内存限制突破了 2G,ngx.pipe, ngx.semaphore 等特性,我们可以利用这些特性将 web application 打造成为一个更加强大的 web 平台。激动之余我顺着 OpenResty 的 release 列表浏览了所有的大版本,发现 OpenResty 的每一个版本都将其推向更加全面方向,感慨之余决定写篇关于 OpenResty 特性的文章发布到博客上。再后来收到又拍云邀请做一次 OpenTalk 分享,觉得这个适合分享给大家,也是一个不错的选题,于是就有了今天的这个主题。
OpenResty 阶段
如果你写过 OpenResty 的代码,对这个应该不陌生。如上图,外面的大框是 Nginx 原生的特性,里面每一个小的方框是 OpenResty 在模块里嵌入的一些阶段性的概念。OpenResty 的官方文档上每个 API 的描述下面都有一个 context,它描述了这个 API 所能作用域,而这个作用域就是所谓的阶段。
通常我们会根据域的关系来构建业务代码,而这种阶段的顺序关系是内生的,跟配置的顺序没有关系。举个例子:在 location 里,如果优先定义的是 Access,再去定义Rewrite,这是没有任何影响的,还是会按照阶段图的顺序来执行代码。我们会根据 Rewrite、Access 的特性来构建业务代码,比如:
在 init 阶段把常用的变量加载起来,就全局变量它是不可变的;
在 Access 阶段做一些准入拦截,授权验证,防爬虫等逻辑;
在 content 阶段专注于内容的生成或者数据转发等操作,比如这个内容应该是我们处理,还是转到上游去?是应该用 Nginx 原生的 Upstream,还是应该用 balancer_by_lua* 动态选择上游来转发;
除此外还有 header_filter_by_lua* 和 body_filter_by_lua*,这两个我们后面也会提到。
基础的 HTTP 处理
OpenResty 的第一个 release 版本是大约是在 2011 年 5 月发布的,在之前 Nginx 一直是作为一款优秀的反向代理服务器而被大家所熟知,如果有一些业务逻辑需要定制化开发,只能通过编写 nginx c module 的方式来实现,这实际上是比较麻烦的。
OpenResty 的第一个 release 版本提供了丰富的 Lua API,包括 ngx.say、ngx.resp.get_headers 等,它让我们可以通过 Lua 语言来完成对 httprequest 和 response 的一些基本元素的控制,灵活的控制整个 HTTP 的请求和响应体,这显然在开发效率上比编写 ngx c module 要高效很多。
另外如果配合 resty 库、cosocket、share dict 等,我们可以做缓存、第三方服务的访问等,可以把 Nginx 变成一个强大的应用服务器,实现复杂的应用。
控制 error 的输出内容
我们在开发时可能会遇到一些 500、400 的错误,这些错误在浏览器上非常难看。如果我们希望定制一些可爱的图片,像 bilibili 出现 500 误时,就会出现一个可爱的小人在跑,那么可以通过下面这种方式去实现它:
优先设定 status,接着用 ngx.print 的方式去定制一个想要的页面返回给浏览器,然后再用 return ngx.exit(200),那么浏览器所展示的就是我们定制的页面。
访问 redis 和 postgres
我们也可以用 redis 和 pgmoon 库做 redis 或者数据库访问,pgmoon 是一个访问 postgres 的库。
另外借助 resty http 模块可以访问其他的服务器,京东商城大量地使用这个库做页面聚合。在一些大型的、复杂的 web 应用里,通常不是由单一的服务器来做响应的,它可能会拆分成多个功能独立、服务器独立的 web 服务,由前面的服务器来做数据的聚合返回给浏览器。这样的好处是在“618”大促时,业务需要保证购买按钮是一定能被点到的,而例如商品详情和右侧栏的推荐,优先级不是那么高。用这种方法,可以通过关掉一个 redis 的变量,在不做任何代码改动的情况下把可能消耗比较大的、有延迟的、无关紧要的服务暂停掉,并且把它们的服务器给集中到前面,去响应优先级更高的比如“购买”服务,这种我们都称之为服务降级。
body_filter_by_lua*
HTTP 元素控制的后面一个是 body_filter_by_lua*,本质上它属于 Nginx output filters 的一种。在一次请求中,它可能被调用多次,调用次数跟数据量无关,跟 Nginx 后面响应体有关系,取决于响应次数。
在 subrequest 时body_filter_by_lua* 也是会被调用到,如果希望它只应用于响应终端的请求时,需要用到 is_subrequest变量去做一些判断,那么它所在 location 的区域就只会响应于对终端输出的处理。
有些时候离不开 header_filter_by_lua* 的辅佐。当代码运行到 body_filter_by_lua* 时,HTTP 报头(header)已经发送出去了。如果在之前设置了跟响应体相关的报头,而又在 body_filter_by_lua* 中修改了响应体,会导致响应报头和实际响应的不一致。举个简单的例子:假设上游的服务器返回了 Content-Length 报头,而 body_filter_by_lua* 又修改了响应体的实际大小。客户端收到这个报头后,按其中的 Content-Length 去处理请求,它可能会收取不到全量的数据,也有可能会等某些数据等到超时,这种情况是经常发生的。所以我们需要在 body_filter_by_lua* 之前,header_filter_by_lua*阶段去给 content 做一个设置。设置 content_lenth 为空 ,强迫 ngx 采用 chunk 的形式发送返回数据,就能避免这种问题。
ngx.timer.at
ngx.timer.at 即定时器,定时器应用的比较多,经常用来做一次延迟处理或者通过内嵌的方式来构建定时任务,是一个使用起来相对简单的 API。这其中有一些细节的点,从 Nginx 的角度来看每个 timer 都是一个 fake request,与真实的 request 一样会占据一个连接。所以在配置 worker_connections 的时候要需要考虑到 timer 的消耗,并不会真的建立一个网络连接,需要有一定的冗余量。在 OpenResty 里,timer 的总数不仅受限于 worker connections,它同时还受限于lua_max_pending_timers 和 lua_max_running_timers 两个变量的控制。
举个例子,我们通过上图的方式去构建一个循环响应的定时任务,即使我们保证了 worker_connections 有一定冗余量,如果启动 timer 时没有足够的内存,也会导致 timer 创建失败。如果可以尽可能用 ngx.timer.every 来启动定期的 timer。如果用 ngx.timer.at 反复启动 timer ,一旦每次启动失败,那就真的失败了。
timer 其实是一个 fake request,它的创建是有开销的。我们也可以通过复用一个 timer 来减少开销,但是有两个挑战:
连接要在 request 退出的时候才会释放内存;
当前 entry thread 会把它所创建的每个协程,记录到一个链表里。而各种协程 API,大都需要访问这个链表。如果 timer 或者长连接持续大量地创建协程,会导致协程 API 变得越来越慢。就目前的情况,要想解决这个问题,需要对协程进行复用,避免无限制地创建协程。
ngx.worker.id()
通常我们不会用一个 worker 的方式去做服务响应,会有多个 worker 去做请求处理。如果我们又希望有定时任务在里面,会用到 API:ngx.worker.id(),它的顺序是 12345 然后到 N,N 是 worker 的数量。但是在 Nginx 版本里有一个问题,所有的 worker 都是 1。除此之外,Nginx 在reload 的时候,会有两组 worker 进程。新的worker 会接替老的 worker,但直到老 worker 退出之前,这两组 worker 是同时运行的。
跨作用域
OpenResty 的各个 API 都有其“生命周期”即作用域,比如其中非常重要的 cosocket,它的作用域如下:rewrite_by_lua,access_by_lua, content_by_lua,ngx.timer.,ssl_certificate_by_lua, ssl_session_fetch_by_lua,也就是说我们只能在上述阶段使用 cosocket,由于很多第三方组件的实现都是基于它,比如 resty-redis,resty-memcached,resty-mysql 等。
ngx.timer.at 有一个比较偏门的用法,就是用来做跨域处理。ngx.timer 的作用域要稍微广一些,比如它可以在 init_by_worker 这个阶段使用,而 cosocket 是不能在这个阶段使用的。
如果我们希望在 init_by_worker 这个阶段去做一些配置加载,可以用这样的方式来做一个桥接。这其实是比较偏门的用法,它可以起到跨域的作用。当然我们更希望的是cosocket 能够早日的在 init_worker 和 init 阶段去支持。
init 阶段怎么解?
如果一定要在 init 阶段去做数据的访问,可以借助 pgmoon。这里拾取了它创建 socket 的一段代码:
上图中在第四行做了一个 get phase(),如果 getphase() 不等于 init,那么它的 sockettype 其实是 Nginx。如果是其他阶段,那么采用的是 lua socket。
当所处阶段是 cosocket 不能支持的时候,他用的是 ngx.socket.tcp。这里并不是只有 Nginx,socket tcp 可以使用,还有 Lua 原生提供的 tcp 可以使用,而且不受域的限制。
跨进度共享
通常来说,我们不会只用一个 worker 来处理请求,甚至都不会用一个物理主机来处理请求, 我们通常采用以下方法:
- SD+轮询是其他的 worker 可以通过创建 timer 的方式去读一个 share dict;
- 如果是要跨主机,而不是跨 worker ,可以用一些外援,比如 Redis 和 Mencached 的方式去做处理;
- 如果大家用过 lua_resty_dkjson 就会知道它在解析一些非常大的 Json 主体时效率非常低下,可能不到 C-Json 效率的 1/52;
- ngx_lua_ipc 是一个第三方 Nginx C 模块,提供了一些 Lua API,可供在 OpenResty 代码里完成进程间通讯(IPC)的操作。它会在 Nginx 的 init 阶段创建 worker process + helper process 对 pipe fd。每对 fd 有一个作为 read fd,负责接收数据,另一个作为 write fd,用于发送数据。当 Nginx 创建 worker 进程时,每个 worker 进程都会继承这些 pipe fd,于是就能通过它们来实现进程间通讯。
- 上图中第四、五两点,这个 datavisor 是一个同行提出来的,他说一个好方法去做 worker 之间的通信,思路就是去修改 Nginx 源码,对每一个 listen unix:socket flag_a 加标记,然后在每个 worker 里面去识别标记。
HTTPS
HTTPS 其实跟 OpenResty 没有太大关系,提到这点是因为我偶然翻到一个小说网站,发现它居然是用的 let's encrypt 的 SSL 证书来认证。然后我就去查了一下,发现了这是一个全新的加密方式,并且是免费的,所以我就决定把 HTTPS 纳入来介绍下。
ssl_certificate_by_lua*
OpenResty 的 bundle 包是支持 SNI 的,所以我们可以在一台主机上为不同域名的“租户”绑定不同的证书,我们只需要在 Nginx的配置中设置多个 server block, 并为每个block 配置server_name 以及指定其证书和私钥即可
虽然上述方式可行,但是在更新证书的时候就会显得非常麻烦,特别是当你拥有多个主机且每个主机上不止一个证书的时候,证书的逐个替换,Nginx 服务的重启都是非常麻烦且容易出错的步骤,即便是通过自动化的代码完成上述步骤,但是多个主机的更换证书的协调性问题依然不容忽视。
使用 ssl_certificate_by_lua* 可以帮助我们更好地来实现:
上图是整个代码流程,我做了中文的注释。我们可以在这个阶段去做一个 SNI server_name 的获取,然后以此作为 key 来访问域名所对应的证书,然后将它格式化设置出来。这意味着我们可以把证书存放在以server_name 为 key,然后证书的内容为value,存放在 Redis,这样就可以一键实现全网的证书更新了。
HTTPS 性能问题
HTTPS 的安全性和性能开销是不可调和的矛盾。性能问题主要来源于 tcp 握手会有密钥的协商过程。协商过程会要求服务端去做非对称加密的解密。这个是非常耗费 CPU 的。同时由于多了 3 次密钥交换的握手过程,如果在网络延迟较大的情况下,它会加长页面的响应时间。
简单介绍一下 HTTPS 加解密的过程:
第一步,客户端给出协议版本号、一个客户端生成的随机数(Client random),以及客户端支持的加密方法;
第二步,服务端确认双方使用的加密方法,并给出数字证书、以及一个服务器生成的随机数(Server random);
第三步,客户端确认数字证书有效,然后生成一个新的随机数(Premaster secret),并使用数字证书中的公钥,加密这个随机数,发给服务端;
第四步,服务端使用自己的私钥,获取客户端发来的随机数(即 Premaster secret), CPU 开销主要就在这个解密上;
第五步,客户端和服务端根据约定的加密方法,使用前面的三个随机数,生成"对话密钥"(session key),用来加密接下来的整个对话过程。
其实 HTTPS 非对称加密来保护的就是 session key,如果可以复用 session key,那就避免了 CPU 的开销,因为它不用再解密。有两种方式避免 CPU 的开销:session cookie 和 session ticket。
session cookie 是 Nginx 原生的,但它要求客户端的 Open SSL 版本要是大于某个版本的(具体什么版本我忘了),并且它要求客户端所对应的服务器是固定的,这种场景难以满足,因为客户端所连接的通常都是负载均衡的服务器,并不知道后端是哪个服务器。
session ticket 不存在上述问题,OpenResty 提供了两个命令来做 session ticket 的功能。第一个就是在 TCP 握手完成后,可以用去收取 session_key,并用 ssl_session_store_by_lua_block的方式把它保存到某个存储介质上,比如 Redis、数据库里。然后下次请求再以同样 ID 来访问时,就可以依照这个 ID 所存储的地方,把 session_key 拉出来。如此,通过抓包你可以看到流程少了两步,它在终端第一个随机数发送以后,服务端不会再发送证书,它直接会 cipher change 的包,用 session_key 来做对称加密。这样做 CPU 开销会小很多。
压力测试我找了一个性能非常差的主机,然后用单 worker 的方式去做性能测试。上面的是单 work 用 wrk 来做性能测试,是普通的 HTTP,970 多;下面我是用的session ticket 的方式去做一个性能测试,是 930 多。可以看到降幅其实并不大。
这次分享还有很多没有提到的,比如 ffi、resty core、test nginx 等,框架如 lapis、香草。周边还有一个非常好用的 Lua 库叫 penlignt,内部会有很多丰富 Lua 的功能,比如我们去判断一符串是不是在一个 area 里或者是不是在 table 里,这些功能它都包含了,Kong 里面大量用到 penlignt 里面的一个启动命令,都是非常好的东西, 希望大家能用起来,提高开发效率。
以上是我今天的全部分享,谢谢大家!