¿Qué hace que OpenResty sea tan especial?

API7.ai

October 14, 2022

OpenResty (NGINX + Lua)

En artículos anteriores, has aprendido sobre los dos pilares de OpenResty: NGINX y LuaJIT, y estoy seguro de que estás listo para comenzar a aprender sobre las APIs que OpenResty proporciona.

Pero no te apresures. Antes de hacerlo, necesitas dedicar un poco más de tiempo a familiarizarte con los principios y conceptos básicos de OpenResty.

Principios

Diagram1

Los procesos Master y Worker de OpenResty contienen ambos una máquina virtual LuaJIT, que es compartida por todas las corrutinas dentro del mismo proceso, y en la cual se ejecuta el código Lua.

Y en un mismo momento, cada proceso Worker solo puede manejar solicitudes de un usuario, lo que significa que solo una corrutina está en ejecución. Puede que te preguntes: Dado que NGINX puede soportar C10K (decenas de miles de concurrencias), ¿no necesita manejar 10,000 solicitudes simultáneamente?

Por supuesto que no. NGINX utiliza epoll para impulsar eventos y reducir la espera y el tiempo de inactividad, de modo que se puedan utilizar tantos recursos de CPU como sea posible para procesar las solicitudes de los usuarios. Después de todo, todo el sistema logra un alto rendimiento solo cuando las solicitudes individuales se procesan lo suficientemente rápido. Si se utiliza un modo multi-hilo de manera que una solicitud corresponda a un hilo, entonces con C10K, los recursos pueden agotarse fácilmente.

En el nivel de OpenResty, las corrutinas de Lua trabajan en conjunto con el mecanismo de eventos de NGINX. Si ocurre una operación de I/O como consultar una base de datos MySQL en el código Lua, primero llamará a yield de la corrutina Lua para suspenderse y luego registrará un callback en NGINX; después de que la operación de I/O se complete (lo que también podría ser un tiempo de espera o un error), el callback resume de NGINX despertará a la corrutina Lua. Esto completa la cooperación entre la concurrencia de Lua y los controladores de eventos de NGINX, evitando escribir callbacks en el código Lua.

Podemos observar el siguiente diagrama, que describe todo el proceso. Tanto lua_yield como lua_resume son parte de la lua_CFunction proporcionada por Lua.

Diagram2

Por otro lado, si no hay operaciones de I/O o sleep en el código Lua, como todas las operaciones intensivas de cifrado y descifrado, entonces la máquina virtual LuaJIT será ocupada por la corrutina Lua hasta que se procese toda la solicitud.

He proporcionado un fragmento del código fuente de ngx.sleep a continuación para ayudarte a entender esto más claramente. Este código se encuentra en ngx_http_lua_sleep.c, que puedes encontrar en el directorio src del proyecto lua-nginx-module.

En ngx_http_lua_sleep.c, podemos ver la implementación concreta de la función sleep. Primero debes registrar la API Lua ngx.sleep con la función 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");
}

A continuación se muestra la función principal de sleep, y he extraído solo unas pocas líneas del código principal aquí.

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);
}

Como puedes ver:

  • Aquí se añade primero la función de callback ngx_http_lua_sleep_handler.
  • Luego se llama a ngx_add_timer, una interfaz proporcionada por NGINX, para añadir un temporizador al bucle de eventos de NGINX.
  • Finalmente, se utiliza lua_yield para suspender la concurrencia de Lua, cediendo el control al bucle de eventos de NGINX.

La función de callback ngx_http_lua_sleep_handler se activa cuando la operación de sleep se completa. Llama a ngx_http_lua_sleep_resume y finalmente despierta a la corrutina Lua usando lua_resume. Puedes recuperar los detalles de la llamada tú mismo en el código, por lo que no entraré en detalles aquí.

ngx.sleep es solo el ejemplo más simple, pero al diseccionarlo, puedes ver los principios básicos del módulo lua-nginx-module.

Conceptos básicos

Después de analizar los principios, refresquemos nuestra memoria y recordemos los dos conceptos importantes de etapas y no bloqueo en OpenResty.

OpenResty, al igual que NGINX, tiene el concepto de etapas, y cada etapa tiene su propio papel distintivo:

  • set_by_lua, que se utiliza para establecer variables.
  • rewrite_by_lua, para reenvío, redirección, etc.
  • access_by_lua, para acceso, permisos, etc.
  • content_by_lua, para generar contenido de retorno.
  • header_filter_by_lua, para el procesamiento de filtros de cabeceras de respuesta.
  • body_filter_by_lua, para el filtrado del cuerpo de la respuesta.
  • log_by_lua, para el registro.

Por supuesto, si la lógica de tu código no es demasiado compleja, es posible ejecutarlo todo en la fase de rewrite o content.

Sin embargo, ten en cuenta que las APIs de OpenResty tienen límites de uso por fase. Cada API tiene una lista de fases en las que se puede usar, y obtendrás un error si la usas fuera de su alcance. Esto es muy diferente de otros lenguajes de desarrollo.

Como ejemplo, usaré ngx.sleep. Según la documentación, sé que solo se puede usar en los siguientes contextos y no incluye la fase log.

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

Y si no sabes esto y usas sleep en una fase log que no soporta:

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

En el registro de errores de NGINX, hay una indicación de nivel 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'

Por lo tanto, antes de usar una API, siempre recuerda consultar la documentación para determinar si se puede usar en el contexto de tu código.

Después de revisar el concepto de fases, repasemos el no bloqueo. Primero, aclaremos que todas las APIs proporcionadas por OpenResty son no bloqueantes.

Continuaré con el requisito de sleep de 1 segundo como ejemplo. Si deseas implementarlo en Lua, debes hacer esto.

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

Dado que Lua estándar no tiene una función sleep, uso un bucle aquí para seguir determinando si se ha alcanzado el tiempo especificado. Esta implementación es bloqueante, y durante el segundo que sleep está en ejecución, Lua no está haciendo nada mientras que otras solicitudes que necesitan ser procesadas están esperando.

Sin embargo, si cambiamos a ngx.sleep(1), según el código fuente que analizamos anteriormente, OpenResty aún puede procesar otras solicitudes (como request B) durante este segundo. El contexto de la solicitud actual (llamémosla request A) se guardará y será despertado por el mecanismo de eventos de NGINX y luego volverá a request A, de modo que la CPU siempre esté en un estado de trabajo natural.

Variables y ciclo de vida

Además de estos dos conceptos importantes, el ciclo de vida de las variables también es un área fácil de equivocarse en el desarrollo de OpenResty.

Como dije antes, en OpenResty, te recomiendo que declares todas las variables como variables locales y uses herramientas como luacheck y lua-releng para detectar variables globales. Esto es lo mismo para los módulos, como el siguiente.

local ngx_re = require "ngx.re"

En OpenResty, excepto en las dos fases init_by_lua y init_worker_by_lua, se establece una tabla aislada de variables globales para todas las fases para evitar contaminar otras solicitudes durante el procesamiento. Incluso en estas dos fases donde puedes definir variables globales, debes intentar evitarlo.

Como regla, los problemas que se intentan resolver con variables globales deberían resolverse mejor con variables en módulos y serán mucho más claros. A continuación se muestra un ejemplo de una variable en un módulo.

local _M = {}

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

  return _M

Definí un módulo en un archivo llamado hello.lua, que contiene la tabla color, y luego añadí la siguiente configuración a nginx.conf.

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

Esta configuración requerirá el módulo en la fase content e imprimirá el valor de green como el cuerpo de la respuesta HTTP.

Puede que te preguntes por qué la variable del módulo es tan asombrosa.

El módulo solo se cargará una vez en el mismo proceso Worker; después de eso, todas las solicitudes manejadas por el Worker compartirán los datos en el módulo. Decimos que los datos "globales" son adecuados para encapsular en módulos porque los Workers de OpenResty están completamente aislados entre sí, por lo que cada Worker carga el módulo de forma independiente, y los datos del módulo no pueden cruzar Workers.

En cuanto al manejo de los datos que necesitan compartirse entre Workers, lo dejaré para un capítulo posterior, por lo que no tienes que profundizar en ello aquí.

Sin embargo, hay una cosa que puede salir mal aquí: al acceder a las variables del módulo, es mejor mantenerlas de solo lectura y no intentar modificarlas, o obtendrás una condición de carrera en caso de alta concurrencia, un error que no se puede detectar con pruebas unitarias, que ocurre ocasionalmente en línea y es difícil de localizar.

Por ejemplo, el valor actual de la variable del módulo green es 3, y haces una operación de más 1 en tu código, entonces, ¿el valor de green es ahora 4? No necesariamente; podría ser 4, 5 o 6 porque OpenResty no bloquea al escribir en una variable del módulo. Entonces hay competencia, y el valor de la variable del módulo se actualiza por múltiples solicitudes simultáneamente.

Habiendo dicho eso sobre las variables globales, locales y de módulo, hablemos de las variables que cruzan fases.

Hay situaciones en las que necesitamos variables que abarquen fases y puedan ser leídas y escritas. Variables como $host, $scheme, etc., que nos son familiares en NGINX, no se pueden crear dinámicamente aunque satisfagan la condición de cruzar fases, y tienes que definirlas en el archivo de configuración antes de poder usarlas. Por ejemplo, si escribes algo como lo siguiente.

location /foo {
      set $my_var ; # necesitas crear la variable $my_var primero
      content_by_lua_block {
          ngx.var.my_var = 123
      }
  }

OpenResty proporciona ngx.ctx para resolver este tipo de problemas. Es una tabla Lua que se puede usar para almacenar datos Lua basados en solicitudes con el mismo ciclo de vida que la solicitud actual. Veamos este ejemplo de la documentación oficial.

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)
      }
  }

Puedes ver que hemos definido una variable foo que se almacena en ngx.ctx. Esta variable abarca las fases de rewrite, access y content y finalmente imprime el valor en la fase content, que es 79 como esperábamos.

Por supuesto, ngx.ctx tiene sus limitaciones.

Por ejemplo, las solicitudes secundarias creadas con ngx.location.capture tendrán sus propios datos ngx.ctx separados, independientes de los datos ngx.ctx de la solicitud principal.

Además, las redirecciones internas creadas con ngx.exec destruyen el ngx.ctx de la solicitud original y lo regeneran con un ngx.ctx en blanco.

Ambas limitaciones tienen ejemplos de código detallados en la documentación oficial, por lo que puedes revisarlos tú mismo si estás interesado.

Resumen

Finalmente, diré unas palabras más. Estamos aprendiendo los principios de OpenResty y algunos conceptos importantes, pero no necesitas memorizarlos. Después de todo, siempre tienen sentido y cobran vida cuando se combinan con requisitos y código del mundo real.

¿Cómo lo entiendes? Bienvenido a dejar un comentario y discutir conmigo, y también te invito a compartir este artículo con tus colegas y amigos. Comunicémonos juntos, progresemos juntos.