tarantool / cartridge

Out-of-the-box cluster manager for Tarantool with a modern web UI
https://www.tarantool.io/en/cartridge/
BSD 2-Clause "Simplified" License
94 stars 29 forks source link

[2pt] Support roles hot-reload #1100

Closed rosik closed 3 years ago

rosik commented 3 years ago

В init.lua пользователь обычно пишет что-то типа

local initially_loaded = table.copy(package.loaded)
cartrudge.cfg({advertise_uri = ..., roles = {'myrole'}})

Тарантул делает dofile('init.lua') и так стартует инстанс.

Суть хотрелоада заключается в том, что мы разрешаем пользователю сделать

for m, _ in pairs(package.loaded) do
    if not initially_loaded[m] then package.loaded[m] = nil end
end

dofile('init.lua') -- ещё раз, но код уже мог немного измениться.

Что может измениться, и что мы вообще можем разрешить менять:

  1. Дефолты для аргпарса (advertise_uri, http_port, console_sock). Они "проблемные" до жути: даже если бы они были динамическими, приоритет у переменных окружения всё равно выше.
  2. Те же самые параметры, заданные через конфиг instances.yml. Что делать в этой ситуации? падать с ошибкой? Писать ворнинг и скипать?
  3. Параметры, которые влияют только на незабутстрапленный инстанс, а потом они персистятся в кластерном конфиге. Их всего три: vshard_groups, bucket_count, auth_enabled
  4. Параметры box_opts.
  5. Параметры, которые не аргпарсятся, и которые вполне могут быть признаны "динамическими" (как в box.cfg) - roles, auth_backend_name, webui_blacklist

Я думаю в первую очередь надо сфокусироваться на релоаде ролей. В ролях в свою очередь тоже могут быть серьезные изменения:

Таким образом, список проблем следующий:

rosik commented 3 years ago

Первым шагом мы решили реализовать что-то типа require('cartridge.reload').reloade_roles. Она будет релоадитиь только роли

rosik commented 3 years ago

This is the first iteration of hot-reload support development. At this point we'll make only cartridge roles reloadable. Later we'll come to the full dofile('init.lua').

Cartride will provide a new API:

require('cartridge').reload_roles({rollback_on_error = true/false})

What it will do:

  1. Unload all roles. As you may know, cartridge roles are loaded recursively: if your init.lua calls cartridge.cfg({roles = 'role-a'}) and role-a.lua specifies dependencies = {'role-b'} then both role-a and role-b will be unloaded. To "unload a role" means package.loaded[role] = nil.

  2. Require all roles specified in cartridge.cfg. Acoording to the example above cartridge will require role-a, and if its dependencies have changed e.g. to role-c, it'll require role-c (but not role-b) anymore.

  3. Cartridge will update service registry that you usually access with service_get/set functions. For every package name it'll check if the old role_name was initialized and, if so, nullify an old one and set a new one.

Note. Some roles keep state in Tarantool spaces and can't be stopped. For example vshard-storage. Unregistering them is a bad idea.

  1. Cartridge will call collectgarbage()

It may sound complicated and I'm sure it is -- hot-reload is a really difficult task. But if you're looking for simplicity here are 3 simple rules for you:

Reloading roles is possible in RolesConfigured state only. During hot-reload cartridge will enter ReloadingRoles state temporarily, and return to the RolesConfigured state upon success. Cartridge won't call any roles callbacks, handling success and errors is up to the application developer.

Upon an error cartridge enters ReloadError state and there are two options - leave everything as is (doomed), or try rolling back (doomed too). I think the rollback_on_error option is enough to implement everything. All other actions (like os.exit in case of an error) should be implemented by application developer basing on the apllication needs. We suggest implementing something like

_G.reload = function()
    local cartridge = require('cartridge')
    local ok, err = cartridge.reload_roles()
    if not ok then
        require('log').error('Hot-reload failed, exiting')
        os.exit()
    end

    cartridge.apply_config()
    return true
end

Note. User can always do something terrible that'll curse the state and cartridge can't even detect it. Below you'll find guidelines on several popular tasks.

Managing Lua closures

The most important thing is to update closures properly. Suppose your role exports some stored procedures in _G like this:

local function update_balance() end
_G.update_balance = update_balance()
-- bad: polluting global namespace

It's slightly better to group all functions in a single table

local M = {}
function M.update_balance() end
_G.__mystorage = M
rosik commented 3 years ago

Чего мы хотим добиться

Пользователь изменяет код роли на фс и выполняет релоад. Состояние инстанса должно быть максимально похоже на то, как если бы вместо хот релоада перезапустили весь инстанс.

Что может измениться в коде роли:

Менеджмент хранимок в _G

Ручная очистка

local function foo() end

_G.__mymodule_foo = foo
package.reload:register(function() _G.__mymodule_foo = nil end)

return {foo = foo}

Mons style

local M = {}

function M.foo() end

_G.mymodule = M
return M

Нетбокс вызывает conn:call('mymodule.foo'). О старых хранимках переживать не приходится. Если название модуля не использует точек, то можно вообще избежать вмешательства в _G и делать conn:call('package.loaded.mymodule.foo').

Менеджмент хттп роутов

Процесс сильно зависит от того, как хттп роуты изначально настраиваются. Это могут быть условно перманентные роуты или же роуты, настроенные в конфиге и применяемые на apply_config. Картриджу придётся поддерживать оба варианта сразу потому что уже сейчас есть роли и с тем и с другим подходом. И хранимок в _G это тоже касается.

Ручная очистка

Всё как с _G, только кода больше. Но его можно втащить на борт картриджа.

-- Delete a route
local index = httpd.iroutes[name]
table.remove(httpd.routes, index)
httpd.iroutes[name] = nil

-- Renumber remaining routes
for i = index, #httpd.routes do
    local route = httpd.routes[i]
    if route.name ~= nil then
        httpd.iroutes[route.name] = i
    end
end

Пересоздать хттп сервер с нуля

Со стороны пользователя ничего лишнего делать не нужно, но:

  1. Apply_config придется сделать частью релоада
  2. Релоад переживут только те роуты, которые добавлялись во время require либо в apply_config. Все остальные внезапно пропадут.
  3. Этот подход не применим к _G.