Teil 3: Wie man ein Microservices API Gateway mit OpenResty erstellt

API7.ai

February 3, 2023

OpenResty (NGINX + Lua)

In diesem Artikel kommt der Aufbau des Microservices-API-Gateways zum Abschluss. Lassen Sie uns anhand eines minimalen Beispiels die zuvor ausgewählten Komponenten zusammenfügen und gemäß dem entworfenen Blueprint ausführen!

NGINX-Konfiguration und Initialisierung

Wir wissen, dass das API-Gateway verwendet wird, um den Traffic-Eingang zu verarbeiten. Daher müssen wir zunächst eine einfache Konfiguration in nginx.conf vornehmen, damit der gesamte Traffic durch den Lua-Code des Gateways verarbeitet wird.

server {
    listen 9080;

    init_worker_by_lua_block {
        apisix.http_init_worker()
    }

    location / {
        access_by_lua_block {
            apisix.http_access_phase()
        }
        header_filter_by_lua_block {
            apisix.http_header_filter_phase()
        }
        body_filter_by_lua_block {
            apisix.http_body_filter_phase()
        }
        log_by_lua_block {
            apisix.http_log_phase()
        }
    }
}

Hier verwenden wir das Open-Source-API-Gateway Apache APISIX als Beispiel, daher enthält das obige Codebeispiel das Schlüsselwort apisix. In diesem Beispiel lauschen wir auf Port 9080 und fangen alle Anfragen an diesen Port durch location / ab, um sie dann durch die Phasen access, rewrite, header filter, body filter und log zu verarbeiten, wobei in jeder Phase die entsprechenden Plugin-Funktionen aufgerufen werden. Die rewrite-Phase ist in der Funktion apisix.http_access_phase kombiniert.

Die Initialisierung des Systems erfolgt in der init_worker-Phase, die das Lesen der Konfigurationsparameter, das Vorabsetzen des Verzeichnisses in etcd, das Abrufen der Plugin-Liste aus etcd und das Sortieren der Plugins nach Priorität umfasst. Ich habe die wichtigsten Teile des Codes hier aufgelistet und erklärt, und Sie können eine vollständigere Initialisierungsfunktion auf GitHub einsehen.

function _M.http_init_worker()
    -- Initialisierung von Routing, Services und Plugins - die drei wichtigsten Teile
    router.init_worker()
    require("apisix.http.service").init_worker()
    require("apisix.plugins.ext-plugin.init").init_worker()
end

Wie Sie an diesem Code sehen können, ist die Initialisierung des Routers und der Plugin-Teile etwas komplizierter, da sie hauptsächlich das Lesen von Konfigurationsparametern und einige abhängige Entscheidungen beinhaltet. Da dies das Lesen von Daten aus etcd beinhaltet, verwenden wir ngx.timer, um die Einschränkung "keine Verwendung von Cosocket in der init_worker-Phase" zu umgehen. Wenn Sie an diesem Teil interessiert sind, empfehlen wir, den Quellcode zu lesen, um ihn besser zu verstehen.

Routen-Matching

Zu Beginn der access-Phase müssen wir zunächst die Route basierend auf der Anfrage, die uri, host, args, cookies usw. enthält, mit den festgelegten Routing-Regeln abgleichen.

router.router_http.match(api_ctx)

Der einzige öffentlich zugängliche Code ist die obige Zeile, in der api_ctx die uri, host, args und cookie-Informationen der Anfrage speichert. Die spezifische Implementierung der Match-Funktion verwendet das zuvor erwähnte lua-resty-radixtree. Wenn keine Route gefunden wird, hat die Anfrage kein entsprechendes Upstream und gibt 404 zurück.

local router = require("resty.radixtree")

local match_opts = {}

function _M.match(api_ctx)
    -- Holen Sie die Parameter der Anfrage aus dem ctx und verwenden Sie sie als Bedingung für die Route
    match_opts.method = api_ctx.var.method
    match_opts.host = api_ctx.var.host
    match_opts.remote_addr = api_ctx.var.remote_addr
    match_opts.vars = api_ctx.var
    -- Rufen Sie die Entscheidungsfunktion der Route auf
    local ok = uri_router:dispatch(api_ctx.var.uri, match_opts, api_ctx)
    -- Wenn keine Route gefunden wird, wird 404 zurückgegeben
    if not ok then
        core.log.info("not find any matched route")
        return core.response.exit(404)
    end

    return true
end

Laden von Plugins

Natürlich, wenn die Route gefunden wird, geht es zum Schritt des Filterns und Ladens der Plugins, was das Kernstück des API-Gateways ist. Beginnen wir mit dem folgenden Code.

local plugins = core.tablepool.fetch("plugins", 32, 0)
-- Die Liste der Plugins in etcd und die Liste der Plugins in der lokalen Konfigurationsdatei werden geschnitten
api_ctx.plugins = plugin.filter(route, plugins)

-- Führen Sie die Funktionen, die von den Plugins in den Phasen rewrite und access gemountet wurden, der Reihe nach aus
run_plugin("rewrite", plugins, api_ctx)
run_plugin("access", plugins, api_ctx)

In diesem Code fordern wir zunächst eine Tabelle der Länge 32 über den Tabellenpool an, was eine zuvor eingeführte Leistungsoptimierungstechnik ist. Dann kommt die Filterfunktion des Plugins. Sie fragen sich vielleicht, warum dieser Schritt notwendig ist. In der init worker-Phase des Plugins haben wir doch bereits die Plugin-Liste aus etcd abgerufen und sortiert?

Das Filtern hier erfolgt im Vergleich mit der lokalen Konfiguration aus den folgenden beiden Gründen:

  1. Erstens muss ein neu entwickeltes Plugin canary-released werden. Zu diesem Zeitpunkt existiert das neue Plugin in der etcd-Liste, ist aber nur in einigen der Gateway-Knoten geöffnet. Daher müssen wir eine zusätzliche Schnittmenge durchführen.
  2. Um den Debug-Modus zu unterstützen. Welche Plugins werden von der Anfrage des Clients verarbeitet? Was ist die Ladereihenfolge dieser Plugins? Diese Informationen sind beim Debuggen nützlich, daher bestimmt die Filterfunktion auch, ob sie sich im Debug-Modus befindet, und zeichnet diese Informationen im Antwortheader auf.

Am Ende der access-Phase nehmen wir also diese gefilterten Plugins und führen sie der Reihe nach nach Priorität aus, wie im folgenden Code gezeigt.

local function run_plugin(phase, plugins, api_ctx)
    for i = 1, #plugins, 2 do
        local phase_fun = plugins[i][phase]
        if phase_fun then
            -- Der Kernaufrufcode
            phase_fun(plugins[i + 1], api_ctx)
        end
    end

    return api_ctx
end

Beim Durchlaufen der Plugins können Sie sehen, dass wir dies in Abständen von 2 tun. Dies liegt daran, dass jedes Plugin zwei Komponenten hat: das Plugin-Objekt und die Konfigurationsparameter des Plugins. Schauen wir uns nun die Kernzeile des obigen Beispielcodes an.

phase_fun(plugins[i + 1], api_ctx)

Wenn diese Codezeile etwas abstrakt ist, ersetzen wir sie durch ein konkretes limit_count-Plugin, was viel klarer sein wird.

limit_count_plugin_rewrite_function(conf_of_plugin, api_ctx)

An diesem Punkt sind wir mit dem Gesamtfluss des API-Gateways fast fertig. All dieser Code befindet sich in derselben Datei, die mehr als 400 Codezeilen enthält, aber der Kern des Codes sind die wenigen Dutzend Zeilen, die wir oben beschrieben haben.

Schreiben von Plugins

Jetzt bleibt noch eine Sache zu tun, bevor ein vollständiges Demo ausgeführt werden kann, und das ist das Schreiben eines Plugins. Nehmen wir das limit-count-Plugin als Beispiel. Die vollständige Implementierung umfasst nur etwas mehr als 60 Codezeilen, die Sie durch Klicken auf den Link einsehen können. Hier erkläre ich die Schlüsselzeilen des Codes im Detail:

Zuerst führen wir lua-resty-limit-traffic als Basisbibliothek für die Begrenzung der Anzahl der Anfragen ein.

local limit_count_new = require("resty.limit.count").new

Dann verwenden wir das json schema in rapidjson, um zu definieren, was die Parameter dieses Plugins sind:

local schema = {
    type = "object",
    properties = {
        count = {type = "integer", minimum = 0},
        time_window = {type = "integer", minimum = 0},
        key = {type = "string",
        enum = {"remote_addr", "server_addr"},
        },
        rejected_code = {type = "integer", minimum = 200, maximum = 600},
    },
    additionalProperties = false,
    required = {"count", "time_window", "key", "rejected_code"},
}

Diese Parameter des Plugins entsprechen den meisten Parametern von resty.limit.count, die den Schlüssel der Begrenzung, die Größe des Zeitfensters und die Anzahl der zu begrenzenden Anfragen enthalten. Darüber hinaus fügt das Plugin einen Parameter hinzu: rejected_code, der den angegebenen Statuscode zurückgibt, wenn die Anfrage begrenzt wird.

Im letzten Schritt mounten wir die Handler-Funktion des Plugins auf die rewrite-Phase:

function _M.rewrite(conf, ctx)
    -- Holen Sie das Limit-Count-Objekt aus dem Cache, falls nicht vorhanden, verwenden Sie die Funktion `create_limit_obj`, um ein neues Objekt zu erstellen und es zu cachen
    local lim, err = core.lrucache.plugin_ctx(plugin_name, ctx,  create_limit_obj, conf)

    -- Holen Sie den Wert des Schlüssels aus `ctx.var` und setzen Sie einen neuen Schlüssel zusammen mit dem Konfigurationstyp und der Konfigurationsversionsnummer
    local key = (ctx.var[conf.key] or "") .. ctx.conf_type .. ctx.conf_version

    -- Funktion zur Bestimmung, ob die Begrenzung eintritt
    local delay, remaining = lim:incoming(key, true)
    if not delay then
        local err = remaining
        -- Wenn der Schwellenwert überschritten wird, wird der angegebene Statuscode zurückgegeben
        if err == "rejected" then
            return conf.rejected_code
        end

        core.log.error("failed to limit req: ", err)
        return 500
    end

    -- Wenn der Schwellenwert nicht überschritten wird, wird er freigegeben und der entsprechende Antwortheader gesetzt
    core.response.set_header("X-RateLimit-Limit", conf.count,
                             "X-RateLimit-Remaining", remaining)
end

Es gibt nur eine Zeile Logik im obigen Code, die die Begrenzungsentscheidung trifft, der Rest dient der Vorbereitung und dem Setzen der Antwortheader. Wenn der Schwellenwert nicht überschritten wird, wird das nächste Plugin gemäß der Priorität ausgeführt.

Zusammenfassung

Abschließend hinterlasse ich Ihnen eine nachdenkliche Frage. Wir wissen, dass API-Gateways nicht nur Layer-7-Traffic, sondern auch Layer-4-Traffic verarbeiten können. Basierend darauf, können Sie sich einige Anwendungsszenarien dafür vorstellen? Hinterlassen Sie gerne Ihre Kommentare und teilen Sie diesen Artikel, um mit mehr Menschen zu lernen und zu kommunizieren.

Vorherige Teile: Teil 1: Wie man ein Microservices-API-Gateway mit OpenResty baut Teil 2: Wie man ein Microservices-API-Gateway mit OpenResty baut