21

如何在OpenResty里实现代码热更新

 4 years ago
source link: https://blog.huoding.com/2020/03/25/807
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

所谓「代码热更新」,是指代码发生变化后,不用 reload 或者 graceful restart 进程就能生效。比如有一个聊天服务,连接着一百万个用户的长连接,所谓代码热更新就是在长连接不断的前提下完成代码更新。通过关闭 lua_code_cache 可以实现此目标,但那样的话性能无疑将大打折扣!实际上因为所有的 require 操作都是通过 package.loaded 来加载模块的,所以通过 package.loaded 就可以实现代码热更新,并且基本不影响性能。

下面让我们做个实验来说明一下如何实现代码热更新的,首先设置如下配置:

lua_code_cache on;
worker_processes 1;

location /run {
    content_by_lua '
        ngx.say(require("test").run())
    ';
}

location /unload {
    allow 127.0.0.1;
    deny all;

    content_by_lua '
        package.loaded[ngx.var.arg_m] = nil
    ';
}

需要说明的是,之所以把 worker_processes 设置为 1,是因为每个 worker 进程都有一个独立的 lua vm,设置成 1 更方便测试,稍后我会说明大于 1 的时候怎么办。此外,有两个 location,其中 run 是用来运行模块的,unload 的是用来卸载模块的。

接着在 package.path 所包含的某个路径上创建 test 模块:

local _M = {}

function _M.run()
    return 1
end

return _M

逻辑很简单,就是返回一个数字。一切准备就绪后,reload 一下 ngx,让我们开始实验:

  1. 请求 http://localhost/run,显示 1
  2. 修改模块 test.lua,把 1 改成 100
  3. 请求 http://localhost/unload?m=test,卸载 package.loaded 中的 test
  4. 请求 http://localhost/run,显示 100

由此可见,在模块内容被修改后,我们没有 reload 进程,只是通过卸载 package.loaded 中对应的模块,就实现了代码热更新。

看起来实现代码热更新非常简单。打住!有例外,让我们修改一下 test 模块:

local ffi = require("ffi")

ffi.cdef[[
struct test { int v; };
]]

local _M = {}

function _M.run()
    local test = ffi.new("struct test", {1})

    return test.v
end

return _M

还是打印一个数字,只是用 ffi 实现的,让我们再来重复一下实验步骤,结果报错了:

attempt to redefine …

究其原因,是因为当我们通过 package.loaded 卸载模块的时候,如果用到了 ffi.cdef 之类的 ffi 操作,那么其中的 C 语言类型声明是无法卸载的。

好在我们可以通过条件判断来决定是否要执行 ffi.cdef 语句:

if not pcall(ffi.typeof, "struct test") then
    ffi.cdef[[
    struct test { int v; };
    ]]
end

说明:如果我们要修改原始定义的话,那么就只能 reload 进程了。

最后,让我来说一说多进程的问题,在测试过程中,我只使用了一个进程,并且通过一个特定的 location 来实现卸载 package.loaded 中指定模块的功能,但是在实际情况中, worker_processes 多半是大于 1 的,也就说有多个 worker 进程,此时,如果再使用特定 location 来操作的话,你是无法确定到底是操作在哪个 worker 上的。

比较直观的解决方案是:

  1. 把需要动态加载的代码放在一个模块文件中,并标记版本号。
  2. 暴露一个 location,允许从外部写最新的版本号到共享内存字典。
  3. 检查模块的版本号与共享内存字典中的最新版本号是否一致,如果不一致的话,则通过 package.loaded 卸载模块,然后再调用 require 重新加载模块。

如此可以解决问题,但是不爽的是每个请求都要检查版本号。看看另一个方案:

  1. 在 init_worker 设置每个 worker 都定时启动 timer,扫描自己的共享内存队列。
  2. 暴露一个 location,允许从外部写模块名字到每一个 worker 的共享内存队列。
  3. 如果 timer 发现新数据,就说明有模块变化了,然后通过 package.loaded 卸载。

完美!

ieey22E.png!web


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK