OpenResty FAQ | Динамическая загрузка, NYI и кэширование Shared Dict

API7.ai

January 19, 2023

OpenResty (NGINX + Lua)

Серия статей о OpenResty была обновлена до сих пор, и часть, посвященная оптимизации производительности, — это все, что мы изучили. Поздравляем вас с тем, что вы не отстаете, продолжаете активно учиться и практиковаться, а также с энтузиазмом оставляете свои мысли.

Мы собрали множество более типичных и интересных вопросов, и вот взгляд на пять из них.

Вопрос 1: Как реализовать динамическую загрузку модулей Lua?

Описание: У меня есть вопрос о динамической загрузке, реализованной в OpenResty. Как я могу использовать функцию loadstring для завершения загрузки нового файла после его замены? Я понимаю, что loadstring может загружать только строки, поэтому если я хочу перезагрузить файл/модуль Lua, как я могу это сделать в OpenResty?

Как мы знаем, loadstring используется для загрузки строки, а loadfile может загружать указанный файл, например: loadfile("foo.lua"). Эти две команды достигают одинакового результата. Что касается того, как загружать модули Lua, вот пример:

resty -e 'local s = [[ local ngx = ngx local _M = {} function _M.f() ngx.say("hello world") end return _M ]] local lua = loadstring(s) local ret, func = pcall(lua) func.f()'

Содержимое строки s — это полный модуль Lua. Поэтому, когда вы обнаружите изменение в коде этого модуля, вы можете перезапустить загрузку с помощью loadstring или loadfile. Таким образом, функции и переменные в нем будут обновлены вместе с ним.

Чтобы пойти дальше, вы также можете обернуть получение изменений и перезагрузку в функцию, называемую code_loader.

local func = code_loader(name)

Это делает обновление кода гораздо более лаконичным. В то же время code_loader обычно использует lru cache для кэширования s, чтобы избежать вызова loadstring каждый раз.

Вопрос 2: Почему OpenResty не запрещает блокирующие операции?

Описание: На протяжении многих лет я всегда задавался вопросом, почему, если эти блокирующие вызовы официально не рекомендуются, их просто не отключить? Или добавить флаг, чтобы пользователь мог выбрать их отключение?

Вот мое личное мнение. Во-первых, потому что экосистема вокруг OpenResty не идеальна, иногда нам приходится вызывать блокирующие библиотеки для реализации некоторых функций. Например, до версии 1.15.8 вам приходилось использовать библиотеку Lua os.execute вместо lua-resty-shell для вызова внешних команд. Например, в OpenResty чтение и запись файлов все еще возможны только с помощью библиотеки Lua I/O, и нет неблокирующей альтернативы.

Во-вторых, OpenResty очень осторожен в таких оптимизациях. Например, lua-resty-core разрабатывался долгое время, но никогда не включался по умолчанию, требуя ручного вызова require 'resty.core'. Он был включен только в последнем выпуске 1.15.8.

Наконец, разработчики OpenResty предпочитают стандартизировать блокирующие вызовы, автоматически генерируя высокооптимизированный код Lua через компилятор и DSL. Поэтому на платформе OpenResty нет усилий для реализации чего-то вроде флаговых опций. Конечно, я не уверен, может ли это направление решить проблему.

С точки зрения внешнего разработчика, более практичной проблемой является то, как избежать таких блокировок. Мы можем расширить инструменты обнаружения кода Lua, такие как luacheck, чтобы находить и предупреждать о распространенных блокирующих операциях, или мы можем интрузивно отключить или переписать определенные функции, напрямую переопределяя _G, например:

resty -e '_G.ngx.print = function() ngx.say("hello") end ngx.print()' # hello

С этим примером кода вы можете напрямую переписать функцию ngx.print.

Вопрос 3: Влияет ли операция NYI в LuaJIT на производительность?

Описание: loadstring показывает never в списке NYI LuaJIT. Будет ли это сильно влиять на производительность?

Что касается NYI в LuaJIT, нам не нужно быть слишком строгими. Для операций, которые могут быть JIT, подход JIT, естественно, лучший; но для операций, которые пока не могут быть JIT, мы можем продолжать их использовать.

Для оптимизации производительности нам нужно использовать научный подход, основанный на статистике, что и делает выборка flame graph. Преждевременная оптимизация — корень всех зол. Нам нужно оптимизировать только горячий код, который делает много вызовов и потребляет много CPU.

Возвращаясь к loadstring, мы будем вызывать его для перезагрузки только при изменении кода, а не при каждом запросе, поэтому это не частая операция. В этом случае нам не нужно беспокоиться о его влиянии на общую производительность системы.

В связи со второй проблемой блокировок, в OpenResty мы иногда также вызываем блокирующие операции файлового ввода-вывода во время фаз init и init worker. Эта операция более затратна по производительности, чем NYI, но поскольку она выполняется только один раз при запуске сервиса, это приемлемо.

Как всегда, оптимизацию производительности следует рассматривать с макро-перспективы, на что вам нужно обратить особое внимание. В противном случае, зацикливаясь на конкретной детали, вы, скорее всего, будете оптимизировать долгое время, но не получите хорошего эффекта.

Вопрос 4: Могу ли я самостоятельно реализовать динамический upstream?

Описание: Для динамического upstream мой подход заключается в настройке 2 upstream для сервиса, выборе разных upstream в зависимости от условий маршрутизации и прямом изменении IP в upstream при изменении IP машины. Есть ли какие-то недостатки или подводные камни в этом подходе по сравнению с использованием balancer_by_lua напрямую?

Преимущество balancer_by_lua заключается в том, что оно позволяет пользователю выбирать алгоритм балансировки нагрузки, например, использовать ли roundrobin или chash, или любой другой алгоритм, реализованный пользователем, что гибко и высокопроизводительно.

Если вы делаете это через правила маршрутизации, результат будет таким же. Но проверка работоспособности upstream должна быть реализована вами, что добавляет много дополнительной работы.

Мы также можем расширить этот вопрос, спросив, как мы должны реализовать этот сценарий для abtest, который требует другого upstream.

Вы можете решить, какой upstream использовать на этапе balancer_by_lua, основываясь на uri, host, parameters и т.д. Вы также можете использовать API-шлюзы, чтобы превратить эти решения в правила маршрутизации, решая, какой маршрут использовать на начальном этапе access, а затем находить указанный upstream через связь между маршрутом и upstream. Это распространенный подход в API-шлюзах, и мы поговорим об этом более конкретно позже в практической части.

Вопрос 5: Обязательно ли кэширование в shared dict?

Описание:

В реальных производственных приложениях я думаю, что уровень кэша shared dict обязателен. Кажется, все помнят только преимущества lru cache: отсутствие ограничений на формат данных, отсутствие необходимости в десериализации, отсутствие необходимости рассчитывать объем памяти на основе объема k/v, отсутствие конкуренции между воркерами, отсутствие блокировок чтения/записи и высокая производительность.

Однако не стоит игнорировать, что один из самых фатальных недостатков lru cache заключается в том, что его жизненный цикл зависит от Worker. Каждый раз, когда NGINX перезагружается, эта часть кэша полностью теряется, и в этот момент, если нет shared dict, источник данных L3 будет перегружен за минуты.

Конечно, это случай с высокой конкуренцией, но если используется кэширование, объем бизнеса, безусловно, не мал, что означает, что приведенный анализ все еще применим. Если я прав в этом мнении?

В некоторых случаях, действительно, как вы сказали, shared dict не теряется при перезагрузке, поэтому он необходим. Но есть особый случай, когда только lru cache приемлем, если все данные активно доступны из источника данных L3 на этапе init или init_worker.

Например, если открытый API-шлюз APISIX имеет источник данных в etcd, он только получает данные из etcd. Он кэширует их в lru cache на этапе init_worker, а последующие обновления кэша активно получаются через механизм watch etcd. Таким образом, даже если NGINX перезагружается, не будет лавинообразного запроса к кэшу.

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