Что делает OpenResty таким особенным

API7.ai

October 14, 2022

OpenResty (NGINX + Lua)

В предыдущих статьях вы узнали о двух краеугольных камнях OpenResty: NGINX и LuaJIT, и я уверен, что вы готовы начать изучение API, предоставляемых OpenResty.

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

Принципы

Diagram1

Процессы Master и Worker в OpenResty содержат виртуальную машину LuaJIT, которая используется всеми корутинами в рамках одного процесса, и в которой выполняется Lua-код.

В каждый момент времени каждый процесс Worker может обрабатывать запросы только от одного пользователя, что означает, что выполняется только одна корутина. У вас может возникнуть вопрос: раз NGINX поддерживает C10K (десятки тысяч одновременных соединений), разве ему не нужно обрабатывать 10 000 запросов одновременно?

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

На уровне OpenResty корутины Lua работают совместно с механизмом событий NGINX. Если в Lua-коде происходит операция ввода-вывода, например, запрос к базе данных MySQL, она сначала вызовет yield корутины Lua, чтобы приостановить себя, а затем зарегистрирует обратный вызов в NGINX; после завершения операции ввода-вывода (это может быть тайм-аут или ошибка), обратный вызов NGINX resume разбудит корутину Lua. Таким образом завершается взаимодействие между конкурентностью Lua и событийным механизмом NGINX, избегая написания обратных вызовов в Lua-коде.

Мы можем посмотреть на следующую диаграмму, которая описывает весь процесс. И lua_yield, и lua_resume являются частью lua_CFunction, предоставляемой Lua.

Diagram2

С другой стороны, если в Lua-коде нет операций ввода-вывода или sleep, например, все интенсивные операции шифрования и дешифрования, то виртуальная машина LuaJIT будет занята корутиной Lua до тех пор, пока весь запрос не будет обработан.

Я предоставил фрагмент исходного кода для ngx.sleep ниже, чтобы помочь вам лучше понять это. Этот код находится в ngx_http_lua_sleep.c, который вы можете найти в каталоге src проекта lua-nginx-module.

В ngx_http_lua_sleep.c мы можем увидеть конкретную реализацию функции sleep. Сначала необходимо зарегистрировать Lua API ngx.sleep с помощью C-функции ngx_http_lua_ngx_sleep.

void ngx_http_lua_inject_sleep_api(lua_State *L) { lua_pushcfunction(L, ngx_http_lua_ngx_sleep); lua_setfield(L, -2, "sleep"); }

Ниже приведена основная функция sleep, и я выделил только несколько строк основного кода.

static int ngx_http_lua_ngx_sleep(lua_State *L) { coctx->sleep.handler = ngx_http_lua_sleep_handler; ngx_add_timer(&coctx->sleep, (ngx_msec_t) delay); return lua_yield(L, 0); }

Как видите:

  • Здесь сначала добавляется функция обратного вызова ngx_http_lua_sleep_handler.
  • Затем вызывается ngx_add_timer, интерфейс, предоставляемый NGINX, чтобы добавить таймер в цикл событий NGINX.
  • Наконец, используется lua_yield для приостановки конкурентности Lua, передавая управление циклу событий NGINX.

Функция обратного вызова ngx_http_lua_sleep_handler срабатывает, когда операция sleep завершена. Она вызывает ngx_http_lua_sleep_resume и в конечном итоге пробуждает корутину Lua с помощью lua_resume. Вы можете самостоятельно изучить детали вызова в коде, поэтому я не буду вдаваться в подробности здесь.

ngx.sleep — это всего лишь простейший пример, но, разобрав его, вы можете увидеть основные принципы модуля lua-nginx-module.

Основные концепции

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

OpenResty, как и NGINX, имеет концепцию этапов, и каждый этап имеет свою уникальную роль:

  • set_by_lua, который используется для установки переменных.
  • rewrite_by_lua, для переадресации, перенаправления и т.д.
  • access_by_lua, для доступа, разрешений и т.д.
  • content_by_lua, для генерации возвращаемого содержимого.
  • header_filter_by_lua, для обработки фильтров заголовков ответа.
  • body_filter_by_lua, для фильтрации тела ответа.
  • log_by_lua, для логирования.

Конечно, если логика вашего кода не слишком сложна, можно выполнить её все на этапе rewrite или content.

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

В качестве примера я использую ngx.sleep. Из документации я знаю, что он может использоваться только в следующих контекстах и не включает этап log.

context: rewrite_by_lua*, access_by_lua*, content_by_lua*, ngx.timer.*, ssl_certificate_by_lua*, ssl_session_fetch_by_lua*_

И если вы не знаете этого и используете sleep на этапе log, который он не поддерживает:

location / { log_by_lua_block { ngx.sleep(1) } }

В журнале ошибок NGINX будет указание уровня error.

[error] 62666#0: *6 failed to run log_by_lua*: log_by_lua(nginx.conf:14):2: API disabled in the context of log_by_lua* stack traceback: [C]: in function 'sleep'

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

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

Я продолжу пример с задержкой на 1 секунду. Если вы хотите реализовать это в Lua, вы должны сделать это так.

function sleep(s) local ntime = os.time() + s repeat until os.time() > ntime end

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

Однако, если мы переключимся на ngx.sleep(1), согласно анализу исходного кода выше, OpenResty может обрабатывать другие запросы (например, request B) в течение этой секунды. Контекст текущего запроса (назовём его request A) будет сохранён и пробуждён механизмом событий NGINX, а затем вернётся к request A, так что CPU всегда находится в естественном рабочем состоянии.

Переменные и жизненный цикл

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

Как я уже говорил, в OpenResty я рекомендую объявлять все переменные как локальные и использовать инструменты, такие как luacheck и lua-releng, для обнаружения глобальных переменных. Это касается и модулей, например, как показано ниже.

local ngx_re = require "ngx.re"

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

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

local _M = {} _M.color = { red = 1, blue = 2, green = 3 } return _M

Я определил модуль в файле под названием hello.lua, который содержит таблицу color, а затем добавил следующую конфигурацию в nginx.conf.

location / { content_by_lua_block { local hello = require "hello" ngx.say(hello.color.green) } }

Эта конфигурация загрузит модуль на этапе content и выведет значение green в качестве тела HTTP-ответа.

Вы можете задаться вопросом, почему переменные модуля так удивительны?

Модуль будет загружен только один раз в одном процессе Worker; после этого все запросы, обрабатываемые этим Worker, будут совместно использовать данные в модуле. Мы говорим, что "глобальные" данные подходят для инкапсуляции в модулях, потому что Worker-ы OpenResty полностью изолированы друг от друга, поэтому каждый Worker загружает модуль независимо, и данные модуля не могут пересекать Worker-ы.

Что касается обработки данных, которые нужно разделять между Worker-ами, я оставлю это для следующей главы, так что вам не нужно углубляться в это здесь.

Однако есть одна вещь, которая может пойти не так: при доступе к переменным модуля лучше держать их только для чтения и не пытаться их изменять, иначе вы получите состояние гонки (race) в случае высокой конкуренции, ошибку, которую невозможно обнаружить с помощью модульного тестирования, которая иногда возникает в продакшене и трудно локализуема.

Например, текущее значение переменной модуля green равно 3, и вы выполняете операцию плюс 1 в своём коде, так что теперь значение green равно 4? Не обязательно; это может быть 4, 5 или 6, потому что OpenResty не блокирует запись в переменную модуля. Тогда возникает конкуренция, и значение переменной модуля обновляется несколькими запросами одновременно.

Сказав это о глобальных, локальных и модульных переменных, давайте обсудим переменные, пересекающие этапы.

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

location /foo { set $my_var ; # нужно сначала создать переменную $my_var content_by_lua_block { ngx.var.my_var = 123 } }

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

location /test { rewrite_by_lua_block { ngx.ctx.foo = 76 } access_by_lua_block { ngx.ctx.foo = ngx.ctx.foo + 3 } content_by_lua_block { ngx.say(ngx.ctx.foo) } }

Вы можете видеть, что мы определили переменную foo, которая хранится в ngx.ctx. Эта переменная пересекает этапы rewrite, access и content и в конечном итоге выводит значение на этапе content, которое, как мы ожидали, равно 79.

Конечно, у ngx.ctx есть свои ограничения.

Например, дочерние запросы, созданные с помощью ngx.location.capture, будут иметь свои отдельные данные ngx.ctx, независимые от ngx.ctx родительского запроса.

Кроме того, внутренние перенаправления, созданные с помощью ngx.exec, уничтожают ngx.ctx исходного запроса и создают его заново с пустым ngx.ctx.

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

Заключение

Наконец, я скажу ещё несколько слов. Мы изучаем принципы OpenResty и несколько важных концепций, но вам не нужно их запоминать. В конце концов, они всегда имеют смысл и оживают в сочетании с реальными требованиями и кодом.

Интересно, как вы это понимаете? Добро пожаловать оставить комментарий и обсудить со мной, а также поделиться этой статьёй с вашими коллегами и друзьями. Мы общаемся вместе, вместе с прогрессом.