得物技术初探OpenResty

简介 Nginx 的高性能是业界公认的,近年来在全球服务器市场上的占比份额也在逐年增加,在国内知名互联网公司也有广泛的应用,阿里还基于Nginx进行扩展打造了著名的Tengine。而OpenResty是由国人章亦春基于Nginx和LuaJIT打造的动态web平台,LuaJIT是Lua编程语言的即时编译器。Lua是一种强大、动态、轻量级的编程语言。该语言的设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能,OpenResty就是通过使用Lua来扩展Nginx来实现的可扩展Web平台。目前OpenResty 大多用在 API 网关的开发中,当然也可以用来替代Nginx,用于反向代理和负载均衡的场景。
OpenResty 的架构组成 得物技术初探OpenResty
文章图片

如前所述,OpenResty 底层是基于Nginx 和 LuaJIT 的,所以 OpenResty 继承了 Nginx 的多进程架构, 每一个 Worker 进程都是 fork Master 进程而得到的, 其实, Master 进程中的 LuaJIT 虚拟机也会一起 fork 过来。在同一个 Worker 内的所有协程,都会共享这个 LuaJIT 虚拟机,Lua 代码的执行也是在这个虚拟机中完成的。而在同一个时间点上,每个 Worker 进程只能处理一个用户的请求,也就是只有一个协程在运行。
Nginx 由于 Nginx 处理请求采用的是事件驱动模型,所以每一个 Worker进程最好独占一个CPU。实践中我们往往把 Worker 进程的数量配置成与CPU核数相同,此外把每一个 Worker 进程与某一个CPU核绑定在一起,这样可以更好的使用每一个CPU核上的CPU缓存,减少缓存失效的命中率,进而提高请求处理的性能。
LuaJIT 其实 OpenResty 最初默认使用的是标准Lua,从 1.5.8.1 版本开始才默认使用 LuaJIT,背后的原因是因为 LuaJIT 相比标准Lua有很大的性能优势。
首先,LuaJIT 的运行时环境除了一个汇编实现的 Lua 解释器外,还有一个可以直接生成机器代码的 JIT 编译器。开始的时候,LuaJIT 和标准 Lua 一样,Lua 代码被编译为字节码,字节码被 LuaJIT 的解释器解释执行。但不同的是,LuaJIT 的解释器会在执行字节码的同时,记录一些运行时的统计信息,比如每个 Lua 函数调用入口的实际运行次数,还有每个 Lua 循环的实际执行次数。当这些次数超过某个随机的阈值时,便认为对应的 Lua 函数入口或者对应的 Lua 循环足够热,这时便会触发 JIT 编译器开始工作。JIT 编译器会从热函数的入口或者热循环的某个位置开始,尝试编译对应的 Lua 代码路径。编译的过程,是把 LuaJIT 字节码先转换成 LuaJIT 自己定义的中间码(IR),然后再生成目标机器的机器码。这个过程跟Java中JIT编译器工作原理类似,其实它们都是为了提高程序运行效率而采取的同一类优化手段,正所谓底层技术都是相通的,可以类比学习。
其次,LuaJIT 还紧密结合了 FFI(Foreign Function Interface,它不能作为单独的模块使用),可以让你直接在 Lua 代码中调用外部的 C 函数和使用 C 的数据结构。FFI 通过解析普通的C声明,就完成 Lua/C 的绑定工作。JIT 编译器从Lua代码访问C数据结构而生成的代码与C编译器生成的代码相同。与通过经典Lua/C API绑定的函数调用不同,对C函数的调用可以内联在 JIT 编译的代码中,所以FFI 方式不仅简单,而且比传统的 Lua/C API 方式的性能更优。
下面是一个简单的调用示例:

local ffi = require("ffi") ffi.cdef[[ int printf(const char *fmt, ...); ]] ffi.C.printf("Hello %s!", "world")

短短这几行代码,就可以直接在 Lua 中调用 C 的 printf 函数,打印出 Hello world!。类似的,我们可以用 FFI 来调用 NGINX、OpenSSL 的 C 函数,来完成更多的功能。
OpenResty 的工作原理 OpenResty 是基于Nginx的高性能Web平台,所以其高效运行与Nginx密不可分。
Nginx 处理HTTP请求有11个执行阶段,我们可以从 ngx_http_core_module.h 的源码中看到:
typedef enum { NGX_HTTP_POST_READ_PHASE = 0,NGX_HTTP_SERVER_REWRITE_PHASE,NGX_HTTP_FIND_CONFIG_PHASE, NGX_HTTP_REWRITE_PHASE, NGX_HTTP_POST_REWRITE_PHASE,NGX_HTTP_PREACCESS_PHASE,NGX_HTTP_ACCESS_PHASE, NGX_HTTP_POST_ACCESS_PHASE,NGX_HTTP_PRECONTENT_PHASE,NGX_HTTP_CONTENT_PHASE,NGX_HTTP_LOG_PHASE } ngx_http_phases;

巧合的是,OpenResty 也有 11 个 *_by_lua 指令,它们和 NGINX 的11个执行阶段有很大的关联性。指令是使用Lua编写Nginx脚本的基本构建块,用于指定用户编写的Lua代码何时运行以及运行结果如何使用等。下图显示了不同指令的执行顺序,这张图可以帮助理清我们编写的脚本是按照怎样的逻辑运行的。
得物技术初探OpenResty
文章图片

其中, init_by_lua 只会在 Master 进程被创建时执行,init_worker_by_lua 只会在每个 Worker 进程被创建时执行。其他的 *_by_lua 指令则是由终端请求触发,会被反复执行。
下面对每一个OpenResty 指令的执行时机和使用进行说明。
在 Nginx 启动过程中嵌入Lua 代码 init_by_lua :在 Nginx 解析配置文件(Master进程)时在 Lua VM 层面立即调用的 Lua 代码。一般在 init_by_lua 阶段,我们可以预先加载 Lua 模块和公共的只读数据,这样可以利用操作系统的 COW(copy on write)特性,来节省一些内存。不过,init_by_lua 阶段无法执行http请求获取远程配置信息,对初始化工作多少有些不便。
init_worker_by_lua :在 Nginx Worker 进程启动时调用,一般在init_worker_by_lua阶段,我们会执行一些定时任务,比如上游服务节点扩所容动态感知和健康检查等,对于init_by_lua*阶段无法执行http请求的问题,也可以在此阶段的定时任务中进行。
在 OpenSSL 处理 SSL 协议时嵌入Lua代码 ssl_certificate_by_lua* :利用 OpenSSL 库(要求1.0.2e版本以上)的SSL_CTX_set_cert_cb特性,将 Lua代码添加到验证下游客户端SSL证书的代码前,可用于为每个请求设置 SSL 证书链和相应的私钥以及在这种上下文中无阻塞地进行SSL握手流量控制。
在11个HTTP阶段中嵌入Lua代码 set_by_lua* :将Lua代码添加到Nginx官方 ngx_http_rewrite_module 模块中的脚本指令中执行,因为 ngx_http_rewrite_module在它的指令中不支持非阻塞I/O,所以需要生成当前Lua "light threads" 的Lua API不能在这个阶段中工作。由于Nginx事件循环在此阶段代码执行过程中将被阻塞,故需要避免在此阶段中执行耗时操作,一般用于执行比较快和少的代码来设置变量。
rewrite_by_lua* :将Lua代码添加到11个阶段中的 rewrite阶段中,作为独立模块为每个请求执行相应的 Lua代码。此阶段的Lua代码可以进行API调用,并在独立的全局环境(即沙箱)中作为一个新生成的协程执行。此阶段可以实现很多功能,比如调用外部服务、转发和重定向处理等。
access_by_lua :将Lua代码添加到11个阶段中的 access 阶段中执行,与rewrite_by_lua类似,也是作为独立模块为每个请求执行相应的 Lua代码。此阶段的Lua代码可以进行API调用,并在独立的全局环境(即沙箱)中作为一个新生成的协程执行。一般用于访问控制、权限校验等。
content_by_lua* :在 11 个阶段的 content 阶段以独占方式为每个请求执行相应的 Lua 代码,用于生成返回内容。需要注意的是,不要在同一 location 中使用此指令和其他内容处理指令。例如,这个指令和 proxy_pass 指令不应该在同一个 location 中使用。
log_by_lua* :将Lua代码添加到11个阶段中的log阶段中执行,它不会替换当前请求的access日志,但会在其之前运行,一般用于请求的统计及日志记录。
在负载均衡时嵌入Lua代码 balance_by_lua :将Lua代码添加到反向代理模块、生成上游服务地址的 init_upstream 回调方法中,用于 upstream 负载均衡控制。这个Lua代码执行上下文不支持 yield,因此在这个上下文中禁用可能 yield 的 Lua API (比如 cosockets 和 "light threads")。不过我们一般可以通过在早期的处理阶段(如 access_by_lua )中执行这样的操作,并通过 ngx.ctx 将结果传递到这个上下文中来绕过这个限制。
在过滤响应时嵌入Lua代码 header_filter_by_lua* :将Lua代码嵌入到响应头部过滤阶段中,用于应答头过滤处理。
body_filter_by_lua* :将Lua代码嵌入到响应包体过滤阶段中,用于应答体过滤处理。需要注意的是,此阶段可能在一个请求中被调用多次,因为响应体可能以块的形式传递。因此,该指令中指定的Lua代码也可以在单个HTTP请求的生命周期内运行多次。
OpenResty 快速体验 在了解了OpenResty 的架构组成和基本工作原理后,我们通过一个简单的例子来上手OpenResty,以我们工作用的Mac系统来进行。
安装OpenResty
$ brew tap openresty/brew $ brew install openresty

创建工作目录
$ mkdir ordemo $ cd ordemo $ mkdir logs/ conf/

创建nginx配置文件 在 conf 工作目录下,创建 nginx配置文件 nginx.conf ,配置内容如下:
error_log logs/error.log debug; pid logs/nginx.pid; events { worker_connections 1024; }http { access_log logs/access.logserver { listen 8080; location / { content_by_lua ' ngx.say("Welcome to OpenResty!") '; } } }

启动服务
$ cd ordemo $ openresty -p `pwd` -c conf/nginx.conf# 停止服务 $ openresty -p `pwd` -c conf/nginx.conf -s stop

没有报错的话,说明 OpenResty 已经启动成功了。可以通过浏览器或者 curl 命令发起请求:
$ curl -i 127.0.0.1:8080 HTTP/1.1 200 OK Server: openresty/1.19.3.1 Date: Tue, 29 Jun 2021 08:55:51 GMT Content-Type: text/plain Transfer-Encoding: chunked Connection: keep-aliveWelcome to OpenResty!

这就是一个最简单的基于 OpenResty 的服务开发过程,只在 Nginx HTTP 请求的11个阶段中的 content 阶段嵌入了 Lua 代码,直接生成了请求响应体。
OpenResty 在得物的应用 当前基础架构团队基于 OpenResty 开发了流量路由组件(API-ROUTE)用于异地多活和小得物项目,该组件主要通过识别请求中的用户ID,根据路由规则进行动态路由,也实现了基于客户端IP和用户ID的灰度导流,后续根据规划将承担更多角色。
上面那个简单的Demo是不是挺简单,有没有想起编程语言入门Demo Hello World?Hello World 看似简单,但其隐藏在背后的执行过程可没那么简单!同样的,OpenResty 也没我们看到的那么单纯!它的背后隐藏了非常多的文化和技术细节。。懂得都懂。。
最后欢迎对OpenResty有兴趣的同学一起交流学习进步。
参考及学习列表 Nginx核心知识150讲
OpenResty从入门到实战
OpenResty 官网
OpenResty API
awesome-resty
文/郭先生
【得物技术初探OpenResty】关注得物技术,携手走向技术的云端

    推荐阅读