OpenResty Best Practice

目录

OpenResty 发展起源

OpenResty(也称为 ngx_openresty)是一个全功能的 Web 应用服务器。它打包了标准的 nginx 核心,很多的常用的第三方模块,以及它们的大多数依赖项。
通过揉和众多设计良好的 nginx 模块,OpenResty 有效地把 nginx 服务器转变为一个强大的 Web 应用服务器,基于它开发人员可以使用 lua 编程语言对 nginx 核心以及现有的各种 nginx C 模块进行脚本编程,构建出可以处理一万以上并发请求的极端高性能的 Web 应用。

OpenResty 致力于将你的服务器端应用完全运行于 nginx 服务器中,充分利用 nginx 的事件模型来进行非阻塞 I/O 通信。不仅仅是和 HTTP 客户端间的网络通信是非阻塞的,与 MySQL、PostgreSQL、Memcached 以及 Redis 等众多后端之间的网络通信也是非阻塞的。
因为 OpenResty 软件包的维护者也是其中打包的许多 nginx 模块的作者,所以 OpenResty 可以确保所包含的所有组件可以可靠地协同工作。

OpenResty 最早是雅虎中国的一个公司项目,起步于 2007 年 10 月。当时兴起了 OpenAPI 的热潮,用于满足各种 Web Service 的需求,基于 Perl 和 Haskell 实现;
2009 章亦春在加入淘宝数据部门的量子团队,决定对 OpenResty 进行重新设计和彻底重写,并把应用重点放在支持像量子统计这样的 Web 产品上面,这是第二代的 OpenResty,基于 nginx 和 lua 进行开发。

为什么要取 OpenResty 这个名字呢?OpenResty 最早是顺应 OpenAPI 的潮流做的,所以 Open 取自“开放”之意,而 Resty 便是 REST 风格的意思。虽然后来也可以基于 ngx_openresty 实现任何形式的 Web service 或者传统的 Web 应用。

也就是说 nginx 不再是一个简单的静态网页服务器,也不再是一个简单的反向代理了,OpenResty 致力于通过一系列 nginx 模块,把 nginx 扩展为全功能的 Web 应用服务器,目前有两大应用目标:

  1. 通用目的的 Web 应用服务器。在这个目标下,现有的 Web 应用技术都可以算是和 OpenResty 或多或少有些类似,比如 Nodejs,PHP 等等,但 OpenResty 的性能更加出色。
  2. nginx 的脚本扩展编程,为构建灵活的 Web 应用网关和 Web 应用防火墙等功能提供了极大的便利性。

OpenResty 特性概括如下:

  • 基于 nginx 的 Web 服务器
  • 打包 nginx 核心、常用的第三方模块及依赖项
  • 使用 lua 对 nginx 进行脚本编程
  • 充分利用 nginx 的事件模型进行非阻塞 I/O 通信
  • 使用 lua 以同步方式进行异步编程
  • 拓展后端通信方式

综合 OpenResty 的特性,它不仅具备 nginx 的负载均衡、反向代理及传统 http server 等功能,还可以利用 lua 脚本编程实现路由网关,实现访问认证、流量控制、路由控制及日志处理等多种功能;同时利用 cosocket 拓展和后端(mysql、redis、kafaka)通信后,更可开发通用的 restful api 程序。

OpenResty 之 lua 编程

lua 简介

1993 年在巴西里约热内卢天主教大学诞生了一门编程语言,他们给这门语言取了个浪漫的名字 — lua,在葡萄牙语里代表美丽的月亮。事实证明他们没有糟蹋这个优美的单词,lua 语言正如它名字所预示的那样成长为一门简洁、优雅且富有乐趣的语言。

lua 从一开始就是作为一门方便嵌入(其它应用程序)并可扩展的轻量级脚本语言来设计,因此她一直遵从着简单、小巧、可移植、快速的原则,官方实现完全采用 ANSI C 编写,能以 C 程序库的形式嵌入到宿主程序中。luaJIT 2 和标准 lua 5.1 解释器采用的是著名的 MIT 许可协议。正由于上述特点,所以 lua 在游戏开发、机器人控制、分布式应用、图像处理、生物信息学等各种各样的领域中得到了越来越广泛的应用。其中尤以游戏开发为最,许多著名的游戏,比如 World of Warcraft、大话西游,都采用了 lua 来配合引擎完成数据描述、配置管理和逻辑控制等任务。即使像 Redis 这样中性的内存键值数据库也提供了内嵌用户 lua 脚本的官方支持。

作为一门过程型动态语言,lua 有着如下的特性:

  1. 变量名没有类型,值才有类型,变量名在运行时可与任何类型的值绑定;
  2. 语言只提供唯一一种数据结构,称为表(table),它混合了数组、哈希,可以用任何类型的值作为 key 和 value。提供了一致且富有表达力的表构造语法,使得 lua 很适合描述复杂的数据;
  3. 函数是一等类型,支持匿名函数和正则尾递归(proper tail recursion);
  4. 支持词法定界(lexical scoping)和闭包(closure);
  5. 提供 thread 类型和结构化的协程(coroutine)机制,在此基础上可方便实现协作式多任务;
  6. 运行期能编译字符串形式的程序文本并载入虚拟机执行;
  7. 通过元表(metatable)和元方法(metamethod)提供动态元机制(dynamic meta-mechanism),从而允许程序运行时根据需要改变或扩充语法设施的内定语义;
  8. 能方便地利用表和动态元机制实现基于原型(prototype-based)的面向对象模型;
  9. 从 5.1 版开始提供了完善的模块机制,从而更好地支持开发大型的应用程序;

lua 基础数据类型

1
2
3
4
5
print(type("hello world")) --> output:string
print(type(print)) --> output:function
print(type(true)) --> output:boolean
print(type(360.0)) --> output:number
print(type(nil)) --> output:nil

nil

nil 是一种类型,lua 将 nil 用于表示“无效值”。一个变量在第一次赋值前的默认值是 nil,将 nil 赋予给一个全局变量就等同于删除它。

1
2
3
4
5
local num
print(num) --> output:nil

num = 100
print(num) --> output:100

boolean (true/false)

布尔类型,可选值 true/false;lua 中 nil 和 false 为“假”,其它所有值均为“真”,比如 0 和空字符串就是“真”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
local a = true
local b = 0
local c = nil

if a then
print("a") --> output:a
else
print("not a") -- 这个没有执行
end

if b then
print("b") --> output:b
else
print("not b") -- 这个没有执行
end

if c then
print("c") -- 这个没有执行
else
print("not c") --> output:not c
end

number

Number 类型用于表示实数,和 C/C++ 里面的 double 类型很类似。可以使用数学函数 math.floor(向下取整)和 math.ceil(向上取整)进行取整操作。

1
2
3
4
local order = 3.99
local score = 98.01
print(math.floor(order)) --> output:3
print(math.ceil(score)) --> output:99

string

和其他语言 string 大同小异

1
2
3
4
5
6
7
8
9
local str1 = 'hello world'
local str2 = "hello lua"
local str3 = [["add\name",'hello']]
local str4 = [=[string have a [[]].]=]

print(str1) --> output:hello world
print(str2) --> output:hello lua
print(str3) --> output:"add\name",'hello'
print(str4) --> output:string have a [[]].

table (数组、字典)

Table 类型实现了一种抽象的“关联数组”。“关联数组”是一种具有特殊索引方式的数组,索引通常是字符串(string)或者 number 类型,但也可以是除 nil 以外的任意类型的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
local corp = {
web = "www.google.com", -- 索引为字符串,key = "web",
-- value = "www.google.com"
telephone = "12345678", -- 索引为字符串
staff = {"Jack", "Scott", "Gary"}, -- 索引为字符串,值也是一个表
100876, -- 相当于 [1] = 100876,此时索引为数字
-- key = 1, value = 100876
100191, -- 相当于 [2] = 100191,此时索引为数字
[10] = 360, -- 直接把数字索引给出
["city"] = "Beijing" -- 索引为字符串
}

print(corp.web) --> output:www.google.com
print(corp["telephone"]) --> output:12345678
print(corp[2]) --> output:100191
print(corp["city"]) --> output:"Beijing"
print(corp.staff[1]) --> output:Jack
print(corp[10]) --> output:360

在内部实现上,table 通常实现为一个哈希表、一个数组、或者两者的混合。具体的实现为何种形式,动态依赖于具体的 table 的键分布特点。

function

在 lua 中,函数也是一种数据类型,函数可以存储在变量中,可以通过参数传递给其他函数,还可以作为其他函数的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
local function foo()
print("in the function")
-- dosomething()
local x = 10
local y = 20
return x + y
end

local a = foo -- 把函数赋给变量

print(a())

-- output:
in the function
30

lua 表达式

算术运算符 说明 关系运算符 说明 逻辑运算符 说明
+ 加法 < 小于 and 逻辑与
- 减法 > 大于 or 逻辑或
* 乘法 <= 小于等于 not 逻辑非
/ 除法 >= 大于等于 - -
^ 指数 ~= 不等于 - -
% 取模 - - - -

note: lua 中的不等于~= 表示, 和其他语言的 != 不一致

lua 流程控制

lua 的流程控制结构和 python 类似,有几个特例:

  • lua 中的 elseif 需要连写,中间不能有空行;python 中写法是 elif
  • lua 中没有 continue 流控

if/else/elseif

1
2
3
4
5
6
7
if a = 1 then
print("1")
elseif a == 2 then
print("2")
else
print("3")
end

while

1
2
3
4
5
6
while a > 1 do
if a == 5 then
break
end
a = a + 1
end

repeat

1
2
3
4
5
6
7
local i = 0
repeat
print(i)
if i == 5 then
break
end
until true

for/break

1
2
3
4
5
6
7
8
9
10
11
12
13
local t = { a = 1, b = 2}
for k, v in pairs(t) do -- 遍历字典
print(k, v)
end

local t = {1, 2}
for k, v in ipairs(t) do -- 遍历整型数组
print(k, v)
end

for i = 1, 10 do -- range 循环
print(i)
end

return

1
2
3
4
5
6
7
local function foo(arg)
if arg == "" then
return nil
end

return "bar"
end

OpenResty 模块编写

编写一个 access.lua 模块,源码如下:

1
2
3
4
5
6
7
8
9
local _M = {}

_M.check = function()
if ngx.var.http_host == "foo.bar.com" then
ngx.exit(403)
end
end

return _M -- 注意 return _M,返回 table 表示的模块

access_by_lua 的 nginx hook 中调用 access 模块:

1
2
3
4
access_by_lua_block {
local rule = require "access" -- require 中不需要加 `.lua` 后缀
rule.check()
}

OpenResty 核心原理

nginx 进程模型

nginx 是一个 master + 多个 worker 进程模型;master 进程负责管理和监控 worker 进程,如加载和解析配置文件,重启 worker 进程,更新二进制文件等。 worker 进程负责处理请求,每个 worker 地位和功能相同,内部按照 epoll + callback 方式实现并发连接处理;整体架构图如下:
nginx 架构模型

nginx 请求处理流程

每个 worker 进程都分阶段处理 http 请求,简单概括为初始化请求 -> 处理请求行 -> 后端交互 -> 响应头处理 -> 响应包体处理 -> 打印日志等几个阶段。其中处理响应体阶段又可以挂载多个不同的 filter。具体的请求阶段可以参见
nginx Phase, nginx 请求处理流程如下图:
nginx请求处理流程

nginx 事件机制

nginx 的事件驱动机制是对 epoll 驱动的封装,但其本质还是 epoll + callback 方式:
nginx事件机制

lua 协程

函数 描述
coroutine.create() 创建 coroutine,返回 coroutine,参数是一个函数,当和 resume 配合使用的时候就唤醒函数调用
coroutine.resume() 重启 coroutine,和 create 配合使用
coroutine.yield() 挂起 coroutine,将 coroutine 设置为挂起状态,这个和 resume 配合使用能有很多有用的效果
coroutine.status() 查看 coroutine 的状态。注:coroutine 的状态有四种:dead,suspend,running,normal

coroutine.create(f)

创建一个主体函数为 f 的新协程。f 必须是一个 lua 的函数。返回这个新协程,它是一个类型为 “thread” 的对象,创建后并不会启动该协程。

coroutine.resume(co, [, val1, …])

开始或继续协程 co 的运行。当第一次执行一个协程时,他会从主函数处开始运行。val1, … 这些值会以参数形式传入主体函数。如果该协程被挂起,resume 会重新启动它;val1, … 这些参数会作为挂起点的返回值。如果协程运行起来没有错误,resume 返回 true 加上传给 yield 的所有值 (当协程挂起),或是主体函数的所有返回值(当协程中止)。

coroutine.yield(…)

挂起正在调用的协程的执行。 传递给 yield 的参数都会转为 resume 的额外返回值。

coroutine.status(co)

以字符串形式返回协程 co 的状态:

  • 当协程正在运行(它就是调用 status 的那个) ,返回 “running”;
  • 如果协程调用 yield 挂起或是还没有开始运行,返回 “suspended”;
  • 如果协程是活动的,都并不在运行(即它正在延续其它协程),返回 “normal”;
  • 如果协程运行完主体函数或因错误停止,返回 “dead”。

协程实例(生产者消费者)

使用协程实现生产者消费者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

local function produce()
while true do
local x = io.read()
coroutine.yield(x) -- 挂起协程
end
end

local producer = coroutine.create(produce) -- 创建协程

local function receive()
local status, value = coroutine.resume(producer) -- 执行协程
return value
end

local function consumer()
while true do
local x = receive()
io.write(x, "\n")
end
end

consumer() -- loop

lua 与 c 堆栈交互

lua 虚拟机常嵌入 C 程序中运行,对于 C 程序来说,lua 虚拟机就是一个子进程。lua 将所有状态都保存在 lua_State 类型中,所有的 C API 都要求传入一个指向该结构的指针。我们根据这个指针来获取 lua 虚拟机(也就是子进程)的状态。

虚拟机内部与外部的 C 程序发生数据交换主要是通过一个公用栈实现的,也就是说 lua 虚拟机和 C 程序公用一个栈,双方都可以压栈或读取数据。一方压入,另一方弹出就能实现数据的交换。

在 c 中,lua 堆栈就是一个 struct,堆栈索引方式可能是正数也可能是负数,区别是:正数索引 1 永远表示栈底,负数索引 -1 永远表示栈顶。
堆栈的默认大小是 20,可以用 lua_checkstack 修改,用 lua_gettop 则可以获得栈里的元素数目。

C 调用 lua

  • 在 C 中创建 lua 虚拟机

    1
    lua_State *luaL_newstate (void)
  • 加载 lua 的库函数

    1
    void luaL_openlibs (lua_State *L);
  • 加载 lua 文件,使用接口

    1
    int luaL_dofile (lua_State *L, const char *filename);
  • 开始交互,lua 定义一个函数

    1
    function test_func_add(a, b) return a + b end
  • 如果你的 lua_State 是全局变量,那么每次对堆栈有新操作时务必使用lua_settop(lua_State, -1)将偏移重新置到栈顶

  • 去lua文件中取得test_func_add方法

    1
    void lua_getglobal (lua_State *L, const char *name);
  • 参数压栈

    1
    lua_pushnumber
  • 通过 pcall 调用

    1
    int lua_pcall (lua_State *L, int nargs, int nresults, int msg);

完整示例,先编写一个 foo.lua 文件,在文件中实现 test_func_add 方法

1
2
3
function test_func_add(a, b)
return a + b
end

接下来在 C 代码中调用 foo.lua:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
lua_State* init_lua()
{
lua_State* s_lua = luaL_newstate();
if (!s_lua) {
printf("luaL_newstate failed!\n");
exit(-1);
}
luaL_openlibs(s_lua);

return s_lua;
}

bool load_lua_file(lua_State* s_lua, const char* lua_file)
{
if (luaL_dofile(s_lua, lua_file) != 0) {
printf("LOAD LUA %s %s\n", lua_file, BOOT_FAIL);
return false;
}
printf("LOAD LUA %s %s\n", lua_file, BOOT_OK);
return true;
}


int proc_add_operation(lua_State* s_lua, int a, int b)
{
lua_settop(s_lua, -1);
lua_getglobal(s_lua, "test_func_add");
lua_pushnumber(s_lua, a);
lua_pushnumber(s_lua, b);

int val = lua_pcall(s_lua, 2, 1, 0);
if (val) {
printf("lua_pcall_error %d\n", val);
}

return (int)lua_tonumber(s_lua, -1);
}

int main() {
lua_State* s_lua =init_lua();
if (!load_lua_file(s_lua, "foo")) {
return -1;
}

proc_add_operation(s_lua, 1, 2);
}

lua 调用 c

  • 定义谁先实现 C 接口

    1
    2
    3
    4
    5
    6
    7
    8
    #define target 300
    static int l_test_check_value(lua_State * l)
    {
    int num = lua_tointeger(l, -1);
    bool check = (num == target);
    lua_pushboolean(l, check);
    return 1;
    }
  • lua 虚拟启动时候,注册加载 C 接口

    1
    lua_register(s_lua, "test_check_value", l_test_check_value);
  • 在 lua 代码中调用注册的 C 接口

    1
    2
    3
    4
    function test_func_check(a)
    local val = test_check_value(a)
    return val
    end

lua 协程与 nginx 事件机制结合

文章前部分用大量篇幅阐述了 lua 和 nginx 的相关知识,包括 nginx 的进程架构,nginx 的事件循环机制,lua 协程,lua 协程如何与 C 实现交互;在了解这些知识之后,本节阐述 lua 协程是如何和 nginx 的事件机制协同工作。

从 nginx 的架构和事件驱动机制来看, nginx 的并发处理模型概括为:单 worker + 多连接 + epoll + callback。
即每个 nginx worker 同时处理了大量连接,每个连接对应一个 http 请求,一个 http 请求对应 nignx 中的一个结构体(ngx_http_request_t):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct ngx_http_request_s {
uint32_t signature; /* "HTTP" */

ngx_connection_t *connection;

void **ctx;
void **main_conf;
void **srv_conf;
void **loc_conf;

ngx_http_event_handler_pt read_event_handler;
ngx_http_event_handler_pt write_event_handler;

....
}

结构体中的核心成员为 ngx_connection_t *connection,其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct ngx_connection_s {
void *data;
ngx_event_t *read; // epoll 读事件对应的结构体成员
ngx_event_t *write; // epoll 写事件对应的结构体成员

ngx_socket_t fd; // tcp 对应的 socket fd

ngx_recv_pt recv;
ngx_send_pt send;
ngx_recv_chain_pt recv_chain;
ngx_send_chain_pt send_chain;

ngx_listening_t *listening;

...
}

从如上结构体可知,每个请求中对应的 ngx_connection_t 中的读写事件和 epoll 关联;nginx epoll 的事件处理核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
   ...

events = epoll_wait(ep, event_list, (int) nevents, timer);
for (i = 0; i < events; i++) {
c = event_list[i].data.ptr;

instance = (uintptr_t) c & 1;
c = (ngx_connection_t *) ((uintptr_t) c & (uintptr_t) ~1); // epoll 获取激活事件,将事件转换成 ngx_connection_t

...

rev = c->read;
rev->handler(rev);

...

wev = c->write;
wev->handler(ev);

...
}

nginx epoll loop 中调用 epoll_wait 获取 epoll 接管的激活事件,并通过 c 的指针强转,得到 ngx_connection_t 获取对应的连接和连接上对应的读写事件的回调函数,即通过 C 结构体变量成员之间的相关关联来串联请求和事件驱动,实现请求的并发处理;这里其实和高级语言的面向对象的写法如出一辙,只是模块和成员变量之间的获取方式的差异。

如果引入 lua 的协程机制,在 lua 代码中出现阻塞的时候,主动调用 coroutine.yield 将自身挂起,待阻塞操作恢复时,再将挂起的协程调用 coroutine.resume 恢复则可以避免在 lua 代码中写回调;而何时恢复协程可以交由 c 层面的 epoll 机制来实现,则可以实现事件驱动和协程之间的关联。现在我们只需要考虑,如何将 lua_State 封装的 lua land 和 C land 中的 epoll 机制融合在一起。

事实上 lua-nginx-module 确实是按照这种方式来处理协程与 nginx 事件驱动之间的关系,lua-nginx-module 为每个 nginx worker 生成了一个 lua_state 虚拟机,即每个 worker 绑定一个 lua 虚拟机,当需要 lua 脚本介入请求处理流程时,基于 worker 绑定的虚拟机创建 lua_coroutine 来处理逻辑,当阻塞发生、需要挂起时或者处理逻辑完成时挂起自己,等待下次 epoll 调度时再次唤醒协程执行。如下是 rewrite_by_lua 核心代码部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
tatic ngx_int_t
ngx_http_lua_rewrite_by_chunk(lua_State *L, ngx_http_request_t *r)
{
co = ngx_http_lua_new_thread(r, L, &co_ref);

lua_xmove(L, co, 1);
ngx_http_lua_get_globals_table(co);
lua_setfenv(co, -2);

ngx_http_lua_set_req(co, r); // 此处设置协程与 ngx_http_request_t 之间的关系

...

rc = ngx_http_lua_run_thread(L, r, ctx, 0); // 运行 lua 脚本处理 rewrite 逻辑

if (rc == NGX_ERROR || rc > NGX_OK) {
return rc;
}

...
}

从上述代码片段中我们看到了协程与 ngx 请求之间的绑定关系,那么只要在 ngx_http_lua_run_thread 函数中(实际上是在 lua 脚本中)处理何时挂起 lua 的执行即可。大部分时候我们在 lua 中的脚本工作类型分两种,一种是基于请求信息的逻辑改写,一种是基于 tcp 连接的后端交互。逻辑改写往往不会发生 io 阻塞,即当前脚本很快执行完成后回到 C land,不需要挂起再唤醒的流程。而对于方式二,lua-nginx-module 提供了 cosocket api,
它封装了 tcp api,并且会在合适的时候(coroutine.yield 的调用发生在 IO 异常,读取包体完毕,或者 proxy_buffers 已满等情形,具体的实现读者可以参考 ngx_http_lua_socket_tcp.c 源码)调用 coroutine.yield 方法 。
lua-corotine

综上所述,结合lua 协程和 nginx 事件驱动机制,使用 OpenResty 可以使用 lua 脚本方便的扩展 nignx 的功能。

OpenResty hooks (编程钩子)

lua-resty-phase

init_by_lua

该阶段主要用于预加载一些 lua 模块, 如加载全局 json 模块:require 'cjson.safe';设置全局的 lua_share_dict 等,并且可以利用操作系统的 copy-on-write 机制;reload nginx 会重新加载该阶段的代码。

init_worker_by_lua

该阶段可用于为每个 worker 设置独立的定时器,设置心跳检查等。

rewrite_by_lua

实际场景中应用最多的一个 hooks 之一,可用于请求重定向相关的逻辑,如改写 host 头,改写请求参数和请求路径等

access_by_lua

该阶段可用于实现访问控制相关的逻辑,如动态限流、限速,防盗链等

content_by_lua

该阶段用于生成 http 请求的内容,和 proxy_pass 指令冲突;二者在同一个阶段只能用一个。该阶段可用于动态的后端交互,如 mysql、redis、kafaka 等;也可用于动态的 http 内容生成,如使用 lua 实现 c 的 slice 功能,完成大文件的分片切割。

banalce_by_lua

该阶段可用于动态的设置 proxy_pass 的上游地址,例如用 lua 实现一个带监控检测机制的一致性 hash 轮序后端算法,根据上游的响应动态设置该地址是否可用。

body_filter_by_lua

用于过滤和加工响应包体,如对 chunk 模式的包体进行 gzip; 也可以根据包体的大小来动态设置 ngx.var.limit_rate.

header_filter_by_lua

调整发送给 client 端的响应头,也是最常用的 hooks 之一;比如设置响应的 server 头,修缓存头 cache-control 等。

log_by_lua

一方面可以设置 nginx 日志输出的字段值,另一方面我们也可以用 cosocket 将日志信息发送到指定的 http server;因响应头和响应体已发送给客户端,该阶段的操作不会影响到客户端的响应速度。

OpenResty 之 lua 编写常见陷阱

  • elseif,区别于 else if;
  • and & or,不支持问号表达式;lua 中 0 表示 true
  • no continue,lua 中不支持 continue 语法;需要用 if 和 else 语句实现;
  • . & :,lua 中 object.method 和 object:method 行为不同,object:method 为语法糖,会扩展成第一个参数为 self
  • forgot return _M,在编写模块的时候如果最后忘记 return _M, 调用时会提示尝试对 string 调用方法的异常

OpenResty 编程优化

  • do local statement,尽量使用 local 化的变量声明,加速变量索引速度的同时避免全局命名空间的污染;
  • do not use blocked api,不要调用会阻塞 lua 协程的 api,比如 lua 原生的 socket,会造成 nginx worker block;
  • use ngx.ctx instead of ngx.var,ngx.var 会调用 ngx.var 的变量索引系统,比 ngx.ctx 低效很多;
  • decrease table resize,避免 lua table 表的 resize 操作,可以用 luajit 事先声明指定大小的 table。比如频繁的 lua 字符串相加的 .. 操作,当 lua 预分配内存不够时,会重新动态扩容(和 c++ vector 类型),会造成低效;
  • use lua-resty-core,使用 lua-resty-core api,该部分 api 用 luajit 的 ffi 实现比直接的 C 和 lua 交互高效;
  • use jit support function,少用不可 jit 加速的函数,那些函数不能 jit 支持,可以参看 luajit 文档。
  • ffi,对自己实现的 C 接口,也建议用 ffi 暴露出接口给 lua 调用。

nginx 易混易错配置说明

so_keepalive

用于 listen 中,探测连接保活; 采用TCP连接的C/S模式软件,连接的双方在连接空闲状态时,如果任意一方意外崩溃、当机、网线断开或路由器故障,另一方无法得知TCP连接已经失效,除非继续在此连接上发送数据导致错误返回。很多时候,这不是我们需要的。我们希望服务器端和客户端都能及时有效地检测到连接失效,然后优雅地完成一些清理工作并把错误报告给用户。

如何及时有效地检测到一方的非正常断开,一直有两种技术可以运用。一种是由TCP协议层实现的Keepalive,另一种是由应用层自己实现的心跳包。

TCP默认并不开启Keepalive功能,因为开启 Keepalive 功能需要消耗额外的宽带和流量,尽管这微不足道,但在按流量计费的环境下增加了费用,另一方面,Keepalive设置不合理时可能会因为短暂的网络波动而断开健康的TCP连接。并且,默认的Keepalive超时需要7,200,000 milliseconds,即2小时,探测次数为 5 次。系统默认的 keepalive 配置如下:

1
2
3
net.ipv4.tcpkeepaliveintvl = 75
net.ipv4.tcpkeepaliveprobes = 5
net.ipv4.tcpkeepalivetime = 7200

如果在 listen 的时候不设置 so_keepalive 则使用了系统默认的 keepalive 探测保活机制,需要 2 小时才能清理掉这种异常连接;如果在 listen 指令中加入

1
so_keepalive=30m::10

可设置如果连接空闲了半个小时后每 75s 探测一次,如果超过 10 次 探测失败,则释放该连接。

sendfile/directio

sendfile

copies data between one file descriptor and another. Because this copying is done within the kernel, sendfile() is more efficient than the combination of read(2) and write(2), which would require transferring data to and from user space.

Linux 的文档中可以看出,当 nginx 有磁盘缓存文件时候,可以利用 sendfile 特性将磁盘内容直接发送到网卡避免了用户态的读写操作。

directio

Enables the use of the O_DIRECT flag (FreeBSD, Linux), the F_NOCACHE flag (macOS), or the directio() function (Solaris), when reading files that are larger than or equal to the specified size. The directive automatically disables (0.7.15) the use of sendfile for a given request

写文件时不经过 Linux 的文件缓存系统,不写 pagecache, 直接写磁盘扇区。启用aio时会自动启用directio, 小于directio定义的大小的文件则采用 sendfile 进行发送,超过或等于 directio 定义的大小的文件,将采用 aio 线程池进行发送,也就是说 aio 和 directio 适合大文件下载。因为大文件不适合进入操作系统的 buffers/cache,这样会浪费内存,而且 Linux AIO(异步磁盘IO) 也要求使用directio的形式。

proxy_request_buffering

控制处理客户端包体的行为,如果设置为 on, 则 nginx 会接收完 client 的整个包体后处理。如 nginx 作为反向代理服务处理客户端的上传操作,则先接收完包体再转发给上游,这样上游异常的时候,nginx 可以多次重试上传,但有个问题是如果包体过大,nginx 端如果负载较重话,会有大量的写磁盘操作,同时对磁盘的容量也有较高要求。如果设置为 off, 则传输变成流式处理,一个 chunk 一个 chunk
传输,传输出错更多需要 client 端重试。

proxy_buffer_size

Sets the size of the buffer used for reading the first part of the response received from the proxied server. This part usually contains a small response header. By default, the buffer size is equal to one memory page. This is either 4K or 8K, depending on a platform.

proxy_buffers

Sets the number and size of the buffers used for reading a response from the proxied server, for a single connection. By default, the buffer size is equal to one memory page. This is either 4K or 8K, depending on a platform.

proxy_buffering

Enables or disables buffering of responses from the proxied server.

When buffering is enabled, nginx receives a response from the proxied server as soon as possible, saving it into the buffers set by the proxy_buffer_size and proxy_buffers directives. If the whole response does not fit into memory, a part of it can be saved to a temporary file on the disk. Writing to temporary files is controlled by the proxy_max_temp_file_size and proxy_temp_file_write_size directives.

When buffering is disabled, the response is passed to a client synchronously, immediately as it is received. nginx will not try to read the whole response from the proxied server. The maximum size of the data that nginx can receive from the server at a time is set by the proxy_buffer_size directive.

proxy_buffering on 时处理上游的响应可以使用 proxy_buffer_sizeproxy_buffers 两个缓冲区;而设置 proxy_buffering off 时,只能使用proxy_buffer_size 一个缓冲区。

proxy_busy_size

When buffering of responses from the proxied server is enabled, limits the total size of buffers that can be busy sending a response to the client while the response is not yet fully read. In the meantime, the rest of the buffers can be used for reading the response and, if needed, buffering part of the response to a temporary file. By default, size is limited by the size of two buffers set by the proxy_buffer_size and proxy_buffers directives.

当接收上游的响应发送给 client 端时,也需要一个缓存区,即发送给客户端而未确认的部分,这个 buffer 也是从 proxy_buffers 中分配,该指令限定能从 proxy_buffers 中分配的大小。

keepalive

该指令可作用于 nginx.conf 和 upstream 的 server 中;当作用于 nginx.conf 中时,表示作为 http server 端回复客户端响应后,不关闭该连接,让该连接保持 ESTAB 状态,即 keepalive。 当该指令作用于 upstrem 块中时,表示发送给上游的 http 请求加入 connection: keepalive, 让服务端保活该连接。值得注意的是服务端和客户端均需要设置 keepalive 才能实现长连接。 同时 keepalive指令需要和 如下两个指令配合使用:

1
2
keepalive_requests 100;
keepalive_timeout 65;

keepalive_requests 表示一个长连接可以复用的次数,keepalive_timeout 表示长连接在空闲多久后可以关闭。
keepalive_timeout 如果设置过大会造成 nginx 服务端 ESTAB 状态的连接数增多。

nginx 维护与更新

nginx 信号集和 nginx 操作之间的对应关系如下:

nginx operation signal
reload SIGHUP
reload SIGUSR1
stop SIGTERM
quit SIGQUIT
hot update SIGUSR2 & SIGWINCH & SIGQUIT

stop vs quit

stop 发送 SIGTERM 信号,表示要求强制退出,quit 发送 SIGQUIT,表示优雅地退出。 具体区别在于,worker 进程在收到 SIGQUIT 消息(注意不是直接发送信号,所以这里用消息替代)后,会关闭监听的套接字,关闭当前空闲的连接(可以被抢占的连接),然后提前处理所有的定时器事件,最后退出。没有特殊情况,都应该使用 quit 而不是 stop。

reload

master 进程收到 SIGHUP 后,会重新进行配置文件解析、共享内存申请,等一系列其他的工作,然后产生一批新的 worker 进程,最后向旧的 worker 进程发送 SIGQUIT 对应的消息,最终无缝实现了重启操作。 再 master 进程重新解析配置文件过程中,如果解析失败则会回滚使用原来的配置文件,即 reload 失败,此时工作的还是老的 worker。

reopen

master 进程收到 SIGUSR1 后,会重新打开所有已经打开的文件(比如日志),然后向每个 worker 进程发送 SIGUSR1 信息,worker 进程收到信号后,会执行同样的操作。reopen 可用于日志切割,比如 nginx 官方就提供了一个方案:

1
2
3
4
$ mv access.log access.log.0
$ kill -USR1 `cat master.nginx.pid`
$ sleep 1
$ gzip access.log.0 # do something with access.log.0

这里 sleep 1 是必须的,因为在 master 进程向 worker 进程发送 SIGUSR1 消息到 worker 进程真正重新打开 access.log 之间,有一段时间窗口,此时 worker 进程还是向文件 access.log.0 里写入日志的。通过 sleep 1s,保证了 access.log.0 日志信息的完整性(如果没有 sleep 而直接进行压缩,很有可能出现日志丢失的情况)。

hot update

某些时候我们需要进行二进制热更新,nginx 在设计的时候就包含了这种功能,不过无法通过 nginx 提供的命令行完成,我们需要手动发送信号。

首先需要给当前的 master 进程发送 SIGUSR2,之后 master 会重命名 nginx.pid 到 nginx.pid.oldbin,然后 fork 一个新的进程,新进程会通过 execve 这个系统调用,使用新的 nginx ELF 文件替换当前的进程映像,成为新的 master 进程。新 master 进程起来之后,就会进行配置文件解析等操作,然后 fork 出新的 worker 进程开始工作。

接着我们向旧的 master 发送 SIGWINCH 信号,然后旧的 master 进程则会向它的 worker 进程发送 SIGQUIT 信息,从而使得 worker 进程退出。向 master 进程发送 SIGWINCH 和 SIGQUIT 都会使得 worker 进程退出,但是前者不会使得 master 进程也退出。

最后,如果我们觉得旧的 master 进程使命完成,就可以向它发送 SIGQUIT 信号,让其退出了。

引用