Was macht OpenResty so besonders?

API7.ai

October 14, 2022

OpenResty (NGINX + Lua)

In früheren Artikeln haben Sie die beiden Grundpfeiler von OpenResty kennengelernt: NGINX und LuaJIT, und ich bin sicher, dass Sie bereit sind, die APIs zu lernen, die OpenResty bietet.

Aber seien Sie nicht zu voreilig. Bevor Sie damit beginnen, sollten Sie etwas mehr Zeit darauf verwenden, sich mit den Prinzipien und Grundkonzepten von OpenResty vertraut zu machen.

Prinzipien

Diagram1

Die Master- und Worker-Prozesse von OpenResty enthalten beide eine LuaJIT-VM, die von allen Coroutinen innerhalb desselben Prozesses gemeinsam genutzt wird und in der Lua-Code ausgeführt wird.

Und zu jedem Zeitpunkt kann jeder Worker-Prozess nur Anfragen eines Benutzers verarbeiten, was bedeutet, dass nur eine Coroutine läuft. Sie könnten sich fragen: Da NGINX C10K (zehntausende gleichzeitige Verbindungen) unterstützen kann, muss es dann nicht 10.000 Anfragen gleichzeitig verarbeiten?

Natürlich nicht. NGINX verwendet epoll, um Ereignisse anzutreiben, um Wartezeiten und Leerlauf zu reduzieren, sodass so viele CPU-Ressourcen wie möglich zur Verarbeitung von Benutzeranfragen genutzt werden können. Schließlich erreicht das Ganze nur dann eine hohe Leistung, wenn einzelne Anfragen schnell genug verarbeitet werden. Wenn ein Multithreading-Modus verwendet wird, sodass eine Anfrage einem Thread entspricht, dann können bei C10K leicht die Ressourcen erschöpft sein.

Auf der Ebene von OpenResty arbeiten Lua-Coroutinen in Verbindung mit dem Ereignismechanismus von NGINX. Wenn in Lua-Code eine I/O-Operation wie das Abfragen einer MySQL-Datenbank auftritt, wird zunächst die yield-Funktion der Lua-Coroutine aufgerufen, um sich selbst anzuhalten, und dann ein Callback in NGINX registriert; nachdem die I/O-Operation abgeschlossen ist (was auch ein Timeout oder ein Fehler sein könnte), wird der NGINX-Callback resume die Lua-Coroutine wieder aufwecken. Damit ist die Zusammenarbeit zwischen der Lua-Concurrency und dem NGINX-Ereignisantrieb abgeschlossen, wodurch das Schreiben von Callbacks im Lua-Code vermieden wird.

Wir können uns das folgende Diagramm ansehen, das den gesamten Prozess beschreibt. Sowohl lua_yield als auch lua_resume sind Teil der lua_CFunction, die von Lua bereitgestellt wird.

Diagram2

Andererseits, wenn es im Lua-Code keine I/O- oder sleep-Operationen gibt, wie z.B. alle intensiven Verschlüsselungs- und Entschlüsselungsoperationen, dann wird die LuaJIT-VM von der Lua-Coroutine belegt, bis die gesamte Anfrage verarbeitet ist.

Ich habe unten einen Ausschnitt des Quellcodes für ngx.sleep bereitgestellt, um Ihnen das Verständnis zu erleichtern. Dieser Code befindet sich in ngx_http_lua_sleep.c, das Sie im src-Verzeichnis des lua-nginx-module-Projekts finden können.

In ngx_http_lua_sleep.c können wir die konkrete Implementierung der sleep-Funktion sehen. Sie müssen zunächst die Lua-API ngx.sleep mit der C-Funktion ngx_http_lua_ngx_sleep registrieren.

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

Das Folgende ist die Hauptfunktion von sleep, und ich habe hier nur einige Zeilen des Hauptcodes extrahiert.

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

Wie Sie sehen können:

  • Hier wird zunächst die Callback-Funktion ngx_http_lua_sleep_handler hinzugefügt.
  • Dann wird ngx_add_timer, eine von NGINX bereitgestellte Schnittstelle, aufgerufen, um einen Timer zur NGINX-Ereignisschleife hinzuzufügen.
  • Schließlich wird lua_yield verwendet, um die Lua-Concurrency anzuhalten und die Kontrolle an die NGINX-Ereignisschleife zu übergeben.

Die Callback-Funktion ngx_http_lua_sleep_handler wird ausgelöst, wenn der Sleep-Vorgang abgeschlossen ist. Sie ruft ngx_http_lua_sleep_resume auf und weckt schließlich die Lua-Coroutine mit lua_resume wieder auf. Sie können die Details des Aufrufs selbst im Code nachschlagen, daher werde ich hier nicht näher darauf eingehen.

ngx.sleep ist nur das einfachste Beispiel, aber durch seine Analyse können Sie die grundlegenden Prinzipien des lua-nginx-module-Moduls erkennen.

Grundkonzepte

Nach der Analyse der Prinzipien lassen Sie uns unser Gedächtnis auffrischen und uns an die beiden wichtigen Konzepte von Phasen und Nicht-Blocking in OpenResty erinnern.

OpenResty hat, wie NGINX, das Konzept von Phasen, und jede Phase hat ihre eigene spezifische Rolle:

  • set_by_lua, das zum Setzen von Variablen verwendet wird.
  • rewrite_by_lua, für Weiterleitungen, Umleitungen usw.
  • access_by_lua, für Zugriff, Berechtigungen usw.
  • content_by_lua, für die Generierung von Rückgabeinhalten.
  • header_filter_by_lua, für die Verarbeitung von Antwortheader-Filtern.
  • body_filter_by_lua, für die Filterung von Antwortkörpern.
  • log_by_lua, für die Protokollierung.

Natürlich ist es möglich, den gesamten Code in der rewrite- oder content-Phase auszuführen, wenn die Logik Ihres Codes nicht zu komplex ist.

Beachten Sie jedoch, dass die APIs von OpenResty Phaseneinschränkungen haben. Jede API hat eine Liste von Phasen, in denen sie verwendet werden kann, und Sie erhalten einen Fehler, wenn Sie sie außerhalb dieses Bereichs verwenden. Dies unterscheidet sich stark von anderen Entwicklungssprachen.

Als Beispiel verwende ich ngx.sleep. Aus der Dokumentation weiß ich, dass es nur in den folgenden Kontexten verwendet werden kann und die log-Phase nicht einschließt.

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

Und wenn Sie dies nicht wissen und sleep in einer log-Phase verwenden, die es nicht unterstützt:

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

Im NGINX-Fehlerprotokoll gibt es einen Hinweis auf error-Ebene.

[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'

Bevor Sie also eine API verwenden, sollten Sie immer die Dokumentation konsultieren, um festzustellen, ob sie im Kontext Ihres Codes verwendet werden kann.

Nachdem wir das Konzept der Phasen wiederholt haben, lassen Sie uns das Nicht-Blocking wiederholen. Zunächst sollten wir klarstellen, dass alle von OpenResty bereitgestellten APIs nicht blockierend sind.

Ich werde das Beispiel 1 Sekunde schlafen fortsetzen. Wenn Sie dies in Lua implementieren möchten, müssen Sie dies tun.

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

Da Standard-Lua keine sleep-Funktion hat, verwende ich hier eine Schleife, um kontinuierlich zu überprüfen, ob die angegebene Zeit erreicht ist. Diese Implementierung ist blockierend, und während der Sekunde, in der sleep läuft, tut Lua nichts, während andere Anfragen, die verarbeitet werden müssen, einfach warten.

Wenn wir jedoch zu ngx.sleep(1) wechseln, kann OpenResty laut dem oben analysierten Quellcode während dieser Sekunde weiterhin andere Anfragen (wie Anfrage B) verarbeiten. Der Kontext der aktuellen Anfrage (nennen wir sie Anfrage A) wird gespeichert und von der NGINX-Ereignismechanik wieder aufgeweckt, um dann zu Anfrage A zurückzukehren, sodass die CPU immer in einem natürlichen Arbeitszustand ist.

Variablen und Lebenszyklus

Neben diesen beiden wichtigen Konzepten ist der Lebenszyklus von Variablen auch ein Bereich, in dem bei der OpenResty-Entwicklung leicht Fehler gemacht werden können.

Wie ich bereits sagte, empfehle ich in OpenResty, dass Sie alle Variablen als lokale Variablen deklarieren und Tools wie luacheck und lua-releng verwenden, um globale Variablen zu erkennen. Dies gilt auch für Module, wie das folgende.

local ngx_re = require "ngx.re"

In OpenResty wird, abgesehen von den beiden Phasen init_by_lua und init_worker_by_lua, für alle Phasen eine isolierte Tabelle von globalen Variablen eingerichtet, um eine Kontamination anderer Anfragen während der Verarbeitung zu vermeiden. Selbst in diesen beiden Phasen, in denen Sie globale Variablen definieren können, sollten Sie versuchen, dies zu vermeiden.

Als Faustregel gilt, dass Probleme, die mit globalen Variablen gelöst werden sollen, besser mit Variablen in Modulen gelöst werden sollten und viel klarer sein werden. Das Folgende ist ein Beispiel für eine Variable in einem Modul.

local _M = {}

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

  return _M

Ich habe ein Modul in einer Datei namens hello.lua definiert, das die Tabelle color enthält, und dann habe ich die folgende Konfiguration zu nginx.conf hinzugefügt.

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

Diese Konfiguration wird das Modul in der content-Phase laden und den Wert von green als HTTP-Antwortkörper ausgeben.

Sie fragen sich vielleicht, warum die Modulvariable so erstaunlich ist?

Das Modul wird nur einmal im selben Worker-Prozess geladen; danach teilen sich alle Anfragen, die von dem Worker verarbeitet werden, die Daten im Modul. Wir sagen, dass "globale" Daten geeignet sind, in Modulen gekapselt zu werden, weil die Worker von OpenResty völlig voneinander isoliert sind, sodass jeder Worker das Modul unabhängig lädt und die Daten des Moduls nicht zwischen Workers übergreifen können.

Was die Daten betrifft, die zwischen Workers geteilt werden müssen, werde ich das für ein späteres Kapitel aufheben, sodass Sie sich hier nicht weiter damit beschäftigen müssen.

Es gibt jedoch eine Sache, die hier schiefgehen kann: Wenn Sie auf Modulvariablen zugreifen, sollten Sie sie besser schreibgeschützt halten und nicht versuchen, sie zu ändern, da Sie sonst im Falle von hoher Parallelität einen race-Zustand erhalten, einen Fehler, der durch Unit-Tests nicht erkannt werden kann und der gelegentlich online auftritt und schwer zu lokalisieren ist.

Zum Beispiel ist der aktuelle Wert der Modulvariablen green 3, und Sie führen in Ihrem Code eine plus 1-Operation durch. Ist der Wert von green jetzt 4? Nicht unbedingt; es könnte 4, 5 oder 6 sein, weil OpenResty beim Schreiben in eine Modulvariable nicht sperrt. Dann gibt es Konkurrenz, und der Wert der Modulvariablen wird von mehreren Anfragen gleichzeitig aktualisiert.

Nachdem wir über globale, lokale und Modulvariablen gesprochen haben, lassen Sie uns über phasenübergreifende Variablen sprechen.

Es gibt Situationen, in denen wir Variablen benötigen, die Phasen überspannen und gelesen und geschrieben werden können. Variablen wie $host, $scheme usw., die uns in NGINX vertraut sind, können nicht dynamisch erstellt werden, obwohl sie die phasenübergreifende Bedingung erfüllen, und Sie müssen sie in der Konfigurationsdatei definieren, bevor Sie sie verwenden können. Zum Beispiel, wenn Sie so etwas wie das Folgende schreiben.

location /foo {
      set $my_var ; # müssen Sie die $my_var-Variable zuerst erstellen
      content_by_lua_block {
          ngx.var.my_var = 123
      }
  }

OpenResty bietet ngx.ctx an, um dieses Problem zu lösen. Es ist eine Lua-Tabelle, die verwendet werden kann, um anfragebasierte Lua-Daten zu speichern, die dieselbe Lebensdauer wie die aktuelle Anfrage haben. Sehen wir uns dieses Beispiel aus der offiziellen Dokumentation an.

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

Sie können sehen, dass wir eine Variable foo definiert haben, die in ngx.ctx gespeichert ist. Diese Variable überspannt die rewrite-, access- und content-Phasen und gibt schließlich den Wert in der content-Phase aus, der, wie erwartet, 79 ist.

Natürlich hat ngx.ctx seine Grenzen.

Zum Beispiel haben Unteranfragen, die mit ngx.location.capture erstellt werden, ihre eigenen ngx.ctx-Daten, unabhängig von den ngx.ctx-Daten der übergeordneten Anfrage.

Ebenso zerstören interne Umleitungen, die mit ngx.exec erstellt werden, die ngx.ctx-Daten der ursprünglichen Anfrage und generieren sie mit einem leeren ngx.ctx neu.

Beide Einschränkungen haben detaillierte Codebeispiele in der offiziellen Dokumentation, sodass Sie sie selbst nachschlagen können, wenn Sie interessiert sind.

Zusammenfassung

Abschließend möchte ich noch ein paar Worte sagen. Wir lernen die Prinzipien von OpenResty und einige wichtige Konzepte, aber Sie müssen sie nicht auswendig lernen. Schließlich machen sie immer Sinn und werden lebendig, wenn sie mit realen Anforderungen und Code kombiniert werden.

Ich frage mich, wie Sie es verstehen? Hinterlassen Sie gerne einen Kommentar und diskutieren Sie mit mir, aber teilen Sie diesen Artikel auch gerne mit Ihren Kollegen und Freunden. Wir kommunizieren gemeinsam und machen Fortschritte zusammen.