0x0 背景
最近在配合自动化pipeline上线,需要websocket长连接给服务增加prestop机制,大致原理是通过k8s的prestop hook触发更新前优雅退出的逻辑。
通过prestop hook执行我们的检测脚本, 检测脚本会定期请求服务暴露的/prestop接口,该接口会做两件事情:
- 检测当前实例中活跃连接的数量,如果没有活跃连接的则返回ok, 表示可以关闭服务了
- 设置prestop标识,让服务进入prestop状态,开启连接检查
此后每条连接会进入连接关闭逻辑检查,分为两类:
- 空闲连接检测,这种是被动检测,由客户端报文触发,每次客户端发送业务报文时重置连接空闲时间,每次客户端发送ping报文时检测连接空闲时长,根据空闲连接判断算法决定是否需要关闭连接。
- 业务session检测,这种属于主动检测,由服务主动触发,每次服务返回业务报文给客户端之后,根据发送的报文是否符合关闭的条件决定是否需要关闭连接。
流程如下:
同时,为了支持reload, 我们在exit_worker_by_lua* 阶段增加了清理逻辑,触发reload/quit之后,会对prestop设置过的数据进行清理。
0x1 问题
代码开发完了prestop模块后,集成到服务中进行测试的时候发现了一个诡异的问题,在prestop api和 exit_worker_by_lua*阶段,我们设置和清理的数据在业务代码中嵌入的检测方法处获取不到。
由于服务肯定是开启了lua_code_cache
的,因此require同一个文件cache的module应该一样才对,但是打印了两处module的table,得到的是不一样的地址:
2020/12/11 18:13:53 [info] 133#133: *26584428 [lua] prestop.lua:154: check_close(): ###table: 0x7f95715817d8
2020/12/11 18:15:49 [info] 133#133: [lua] exit_worker_by_lua:4: ###DEBUG:table: 0x7f95716028b8
0x2 分析
为了查看都有哪些模块缓存起来了,我们增加一些了日志,打印package.loaded这个表中value是table的这些key和value的地址
local t = {}
for k, v in pairs(package.loaded) do
if type(v) == "table" then
table.insert(t, {key = k, value = tostring(v)})
end
end
ngx.log(ngx.INFO, "###DEBUG: ", cjson.encode(t))
得到的列表如下:
[
{
"key":"resty.websocket.protocol",
"value":"table: 0x7f1335d7c3a0"
},
...
{
"key":"app.models.prestop",
"value":"table: 0x7f1335c42be8"
},
{
"key":"resty.core.ndk",
"value":"table: 0x7f1381882788"
},
...
{
"key":"prestop",
"value":"table: 0x7f1335d86938"
},
...
{
"key":"jit",
"value":"table: 0x7f13818a1bd0"
}
]
可以看到出现了两个prestop的module,key分别是"app.src.prestop"和"prestop", 结合不同文件里引用的require语句:
local prestop = require("prestop")
local prestop = require('app.models.prestop')
可见缓存用的name是require中的全路径,那为什么两个路径不一样,也都能找到这个模块呢? 通过查看lua_package_path的配置:
lua_package_path '/usr/local/openresty/lualib/?.lua;?.lua;./app/?.lua;./app/lib/?.lua;./app/models/?.lua;$prefix/deps/share/lua/5.1/?.lua;$prefix/deps/share/lua/5.1/?/init.lua;;';
原因是在我们的nginx.conf中的lua_package_path有这样一条path: ./app/models/?.lua; 通过它我们,直接require(“prestop”)也可以找到模块下的文件。
带着疑问看了一下luajit的代码,可以看到在src/lib_package.c
的lj_cf_package_require
函数里,会尝试在_LOADED表中查找模块名name是否有缓存,如果有则说明已经加载过了,如果没有查到,会进入require逻辑加载一次模块并缓存起来:
lua_settop(L, 1); /* _LOADED table will be at index 2 */
lua_getfield(L, LUA_REGISTRYINDEX, "_LOADED");
lua_getfield(L, 2, name);
if (lua_toboolean(L, -1)) { /* is it there? */
if ((L->top-1)->u64 == KEY_SENTINEL) /* check loops */
luaL_error(L, "loop or previous error loading module " LUA_QS, name);
return 1; /* package is already loaded */
}
lua_getfield(L, LUA_ENVIRONINDEX, "loaders");
if (!lua_istable(L, -1))
luaL_error(L, LUA_QL("package.loaders") " must be a table");
lua_pushliteral(L, ""); /* error message accumulator */
for (i = 1; ; i++) {
lua_rawgeti(L, -2, i); /* get a loader */
if (lua_isnil(L, -1))
luaL_error(L, "module " LUA_QS " not found:%s",
name, lua_tostring(L, -2));
lua_pushstring(L, name);
lua_call(L, 1, 1); /* call it */
if (lua_isfunction(L, -1)) /* did it find module? */
break; /* module loaded successfully */
else if (lua_isstring(L, -1)) /* loader returned error message? */
lua_concat(L, 2); /* accumulate it */
else
lua_pop(L, 1);
}
(L->top++)->u64 = KEY_SENTINEL;
lua_setfield(L, 2, name); /* _LOADED[name] = sentinel */
lua_pushstring(L, name);
lua_call(L, 1, 1); /* run loaded module */
if (!lua_isnil(L, -1)) /* non-nil return? */
lua_setfield(L, 2, name); /* _LOADED[name] = returned value */
lua_getfield(L, 2, name);
if ((L->top-1)->u64 == KEY_SENTINEL) { /* module did not set a value? */
lua_pushboolean(L, 1); /* use true as result */
lua_pushvalue(L, -1); /* extra copy to be returned */
lua_setfield(L, 2, name); /* _LOADED[name] = true */
在第4行前加入如下打印验证一下我们的猜想
printf("#####DEBUG: %s, %s\n", name, (lua_toboolean(L, -1) ? "true" : "false"));
dumpstack(L);
测试代码
local t = require("t")
print(tostring(t))
t = require("t")
print(tostring(t))
得到如下打印
#####DEBUG: t, false
dumpstack:
1 string t
2 table 0x7fb443393598
3 nil nil
table: 0x7fb443391d48
#####DEBUG: t, true
dumpstack:
1 string t
2 table 0x7fb443393598
3 table 0x7fb443391d48
table: 0x7fb443391d48
可以看到第二次require “t"的时候,lua_getfield返回值已经不是nil了,并且table已经在栈里面,说明有cache了,接下来修改测试代码, 更改require的模块名
local t = require("t")
print(tostring(t))
t = require("./t")
print(tostring(t))
#####DEBUG: t, false
dumpstack:
1 string t
2 table 0x7f8446981598
3 nil nil
table: 0x7f844697fd48
#####DEBUG: ./t, false
dumpstack:
1 string ./t
2 table 0x7f8446981598
3 nil nil
table: 0x7f8446980810
可以看到第二次 require的模块name为”./t",并且没有cache
0x3 修复
了解清楚原因后就好办了,把require处引用的模块改成一致后,通过打印几个地方require得到的module的table地址确认都一样了,再次查看package.loaded中加载的模块只有一个app.models.prestop的key了。
2020/12/14 13:05:44 [info] 126#126: *96292006 [lua] prestop.lua:159: check_close(): ###DEBUG:table: 0x7f1335c0f3c8
2020/12/14 13:07:24 [info] 126#126: [lua] exit_worker_by_lua:4: ###DEBUG:table: 0x7f1335c0f3c8
[
{
"key":"ngx.re",
"value":"table: 0x7f13819133c0"
},
...
{
"key":"app.models.prestop",
"value":"table: 0x7f1335c0f3c8"
},
...
{
"key":"jit",
"value":"table: 0x7f13818a1bd0"
}
]
0x4 反思
- 如果require同一个文件,但是使用的路径不一样,很可能在package.loaded中缓存了两份,需要检查一遍代码
- 开了lua_code_cache就以为操作了同一个module了,人类认为的同一个文件对计算机来说可能并不是,写代码的时候切记不能想当然。