精选文章|初探OpenResty
初探OpenResty
简介
Nginx的高性能是业界公认的,近年来在全球服务器市场上的占比份额也在逐年增加,在国内知名互联网公司也有广泛的应用,阿里还基于Nginx进行扩展打造了著名的Tengine。
而OpenResty是由国人章亦春基于Nginx和LuaJIT打造的动态web平台,LuaJIT是Lua编程语言的即时编译器。
Lua是一种强大、动态、轻量级的编程语言。该语言的设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能,OpenResty就是通过使用Lua来扩展Nginx来实现的可扩展Web平台。目前OpenResty 大多用在API网关的开发中,当然也可以用来替代Nginx,用于反向代理和负载均衡的场景。
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代码何时运行以及运行结果如何使用等。
下图显示了不同指令的执行顺序,这张图可以帮助理清我们编写的脚本是按照怎样的逻辑运行的。
其中,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代码
这个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.log
server {
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-alive
Welcome 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讲
https://time.geekbang.org/course/intro/100020301
OpenResty从入门到实战
https://time.geekbang.org/column/intro/186
OpenResty 官网
https://openresty.org/cn/
关注得物技术,携手走向技术的云端
文|言甚