Design wechseln

Nginx dynamischer Upstream: Echtzeit-Service-Discovery mit Lua

Easton editorial illustration: OpenResty service-discovery bench, dynamic upstream target rail, health-check gate

Alarm in der Produktionsumgebung.

Ein Docker-Container wurde neu gestartet. Die IP hat sich geändert. In der nginx.conf steht noch die alte Adresse.

Sie müssen aufstehen, die Konfiguration manuell anpassen und nginx -s reload ausführen. Die QPS-Kurve zuckt kurz, im Monitoring entsteht ein Loch. Mit etwas Glück erholt sich alles in wenigen Sekunden. Ohne Glück kommen Beschwerden von Nutzern.

Das ist mir mehr als einmal passiert. Jedes Mal die gleiche Frage: Kann Nginx Backend-Services nicht selbst entdecken – wie Consul, das bei IP-Wechseln automatisch aktualisiert, ohne dass man mitten in der Nacht die Konfiguration anfassen muss?

OpenResty kann das seit Langem. Mit Lua-Skripten lassen sich Upstream-Konfigurationen zur Laufzeit ändern – ganz ohne Reload. Cloudflare nutzt genau diesen Mechanismus; ihre CDN-Edge-Knoten steuern den Traffic dynamisch darüber.

Dieser Artikel zeigt, wie Sie mit einer Drei-Schichten-Architektur (ngx.balancer + lua-resty-balancer + Health Check) dynamische Upstreams aufbauen, die beiden gängigen Health-Check-Bibliotheken vergleichen und vollständigen Integrationscode für Consul, Nacos und etcd liefern. Nach dem Lesen können Sie das Setup in Produktion deployen.

Warum dynamische Upstreams nötig sind

Die Upstream-Konfiguration von Nginx ist statisch. Die server-Adressen in der nginx.conf werden beim Start einmal geladen – Änderungen erfordern ein Reload.

In Container-Umgebungen ist das mühsam. Docker-Container starten neu, die IP ändert sich. K8s-Pods werden umgeplant, die IP ändert sich ebenfalls. Sie können die nginx.conf nicht bei jedem Vorgang manuell anpassen. Technische Teams haben genau dieses Problem erlebt – von manueller Konfiguration über Template-Rendering bis hin zu Consul-basierter dynamischer Service-Discovery.

Manche schlagen NGINX Plus vor – die kommerzielle Variante unterstützt dynamische Upstreams. Stimmt, aber die Lizenz kostet Zehntausende Dollar pro Jahr, und der Code ist nicht Open Source. Bei Problemen warten Sie auf den Hersteller. Für die meisten Teams ist das keine gute Wahl.

OpenResty bietet einen anderen Weg. Es integriert die LuaJIT-VM in Nginx; mit Lua-Skripten ändern Sie Upstream-Konfigurationen zur Laufzeit – ohne Reload, sogar mitten in der Request-Verarbeitung.

Der Schlüssel ist balancer_by_lua_block. In der Phase, in der Nginx den Upstream-Server wählt, übernehmen Sie mit Lua die Routing-Entscheidung. Backend-IP-Listen können im Shared Memory, in Redis oder in Consul liegen. Fällt ein Backend aus, entfernt Lua es automatisch. Kommt ein neuer Service online, erkennt Lua ihn automatisch.

Typische Einsatzszenarien:

  • K8s-Ingress-Gateway: Pod-IPs ändern sich häufig; Nginx als Ingress muss dynamisch reagieren
  • Microservice-Canary-Releases: Alte und neue Versionen parallel; Routing nach Header oder Cookie
  • Automatisches Aussondern bei Fehlern: Langsame oder ausgefallene Backends werden aktiv erkannt und aus dem Pool entfernt
  • Cross-DC-Scheduling: Dynamische Wahl des nächsten Rechenzentrums nach Geo-Lage oder Latenz

Cloudflares CDN-Edge-Knoten basieren auf diesem Mechanismus. Hunderte Knoten weltweit, Millionen Requests pro Sekunde – alles über OpenResty gesteuert. Teile der Implementierung sind auf GitHub einsehbar.

Drei-Schichten-Architektur und Kernkomponenten

Dynamische Upstreams bei OpenResty sind kein Einzeltrick, sondern ein Zusammenspiel dreier Schichten:

┌─────────────────────────────────────────┐
│  Schicht 3: Health Check                 │
│  lua-resty-healthcheck                   │
│  - Aktive Prüfung der Backend-Verfügbarkeit │
│  - Upstream-Status im Shared Memory aktualisieren │
└──────────────┬──────────────────────────┘
               │ Status-Synchronisation
┌──────────────▼──────────────────────────┐
│  Schicht 2: Load-Balancing-Algorithmus   │
│  lua-resty-balancer                      │
│  - resty.roundrobin (Round Robin)        │
│  - resty.chash (Consistent Hash)         │
│  - Gesunde Backend-Liste aus Shared Memory │
└──────────────┬──────────────────────────┘
               │ Auswahlergebnis
┌──────────────▼──────────────────────────┐
│  Schicht 1: Low-Level-API                │
│  ngx.balancer                            │
│  - set_current_peer(host, port)         │
│  - get_last_failure()                   │
│  - set_more_tries(n)                    │
│  - Wird in der balancer_by_lua-Phase aufgerufen │
└─────────────────────────────────────────┘

ngx.balancer: Low-Level-API

Diese Schicht liegt am nächsten am Nginx-Kern. Das Modul ngx.balancer stellt drei zentrale APIs bereit:

  • set_current_peer(host, port): Legt fest, wohin der aktuelle Request weitergeleitet wird
  • get_last_failure(): Liefert Fehlerinformationen des letzten Versuchs (für Retry-Logik)
  • set_more_tries(n): Setzt zusätzliche Retry-Versuche

Diese APIs müssen in balancer_by_lua_block aufgerufen werden. In dieser Phase wählt Nginx den Upstream-Server – Ihr Lua-Code übernimmt die Routing-Entscheidung vollständig.

Minimales Beispiel:

upstream backend {
    server 0.0.0.1;  # Platzhalter – mindestens eine server-Direktive erforderlich
    balancer_by_lua_block {
        local balancer = require "ngx.balancer"

        -- Backend dynamisch wählen
        local host = "192.168.1.10"
        local port = 8080

        local ok, err = balancer.set_current_peer(host, port)
        if not ok then
            ngx.log(ngx.ERR, "failed to set peer: ", err)
            return ngx.exit(500)
        end
    }
}

Hinweis: server 0.0.0.1 ist ein Platzhalter. Nginx verlangt mindestens eine server-Direktive im upstream-Block; das tatsächliche Backend wählen Sie per Lua – diese Adresse wird nie angesprochen.

lua-resty-balancer: Load-Balancing-Algorithmen

Direkt mit ngx.balancer zu arbeiten ist mühsam: Round Robin, Hashing und Backend-Listen müssen Sie selbst implementieren. lua-resty-balancer kapselt diese Algorithmen.

Zwei Load Balancer stehen bereit:

  • resty.roundrobin: Round Robin – Backends der Reihe nach
  • resty.chash: Consistent Hash – gleicher Client landet immer auf demselben Backend (Session-Affinität)

Initialisierung in init_worker_by_lua_block:

init_worker_by_lua_block {
    local roundrobin = require "resty.roundrobin"
    local chash = require "resty.chash"

    -- Backend-Liste (dynamisch aus Consul/Nacos)
    local servers = {
        { "192.168.1.10", 8080, weight = 10 },
        { "192.168.1.11", 8080, weight = 5 },
        { "192.168.1.12", 8080, weight = 3 },
    }

    -- Round-Robin-Load-Balancer erstellen
    local rr_upstream = roundrobin:new(servers)

    -- In Shared Memory speichern, balancer-Phase liest daraus
    local shared_dict = ngx.shared.upstreams
    shared_dict:set("backend_rr", rr_upstream)
}

Verwendung in balancer_by_lua_block:

upstream backend {
    server 0.0.0.1;
    balancer_by_lua_block {
        local shared_dict = ngx.shared.upstreams
        local rr_upstream = shared_dict:get("backend_rr")

        -- Nächsten Server wählen
        local host, port = rr_upstream:select()

        local balancer = require "ngx.balancer"
        balancer.set_current_peer(host, port)
    }
}

Nginx-Phasen im Detail

Nginx verarbeitet Requests in einer festen Phasenfolge. Nur wer die Phasen kennt, platziert Lua-Code richtig:

1. init_by_lua_block     → Beim Start des Master-Prozesses
2. init_worker_by_lua    → Beim Start jedes Worker-Prozesses
3. ssl_certificate_by_lua → SSL-Handshake
4. set_by_lua            → Variablenzuweisung
5. rewrite_by_lua        → URL-Rewrite
6. access_by_lua         → Zugriffskontrolle
7. balancer_by_lua       → Upstream-Server wählen (Kern)
8. header_filter_by_lua  → Response-Header
9. body_filter_by_lua    → Response-Body
10. log_by_lua           → Logging

balancer_by_lua_block liegt in Phase 7. Der Request ist noch nicht weitergeleitet – Sie entscheiden, wohin er geht. Bei Retries (Backend-Fehler) sagt get_last_failure(), warum der letzte Versuch scheiterte – Grundlage für die Wahl eines anderen Backends.

Health-Check-Implementierung im Vergleich

Die dritte Schicht dynamischer Upstreams ist der Health Check. Backends können jederzeit ausfallen – Sie sollten proaktiv prüfen, statt erst bei fehlgeschlagenen Requests zu reagieren.

In der OpenResty-Community gibt es zwei gängige Lösungen: die offizielle lua-resty-upstream-healthcheck und die ausgereiftere lua-resty-healthcheck. Nach eigener Erfahrung empfehle ich Letztere.

lua-resty-upstream-healthcheck: Offizielle Lösung

Vom OpenResty-Team gepflegt. Bietet aktive Checks – im Hintergrund werden periodisch HTTP-Requests an Backends gesendet.

Konfigurationsbeispiel:

-- Shared Memory im http-Block der nginx.conf
lua_shared_dict healthcheck 1m;

-- Health Check in init_worker_by_lua_block starten
init_worker_by_lua_block {
    local hc = require "resty.upstream.healthcheck"

    local ok, err = hc.spawn_checker{
        shm = "healthcheck",             -- Name des Shared Memory
        upstream = "backend",            -- Upstream-Name
        type = "http",                   -- Prüftyp (http oder tcp)

        -- Inhalt des Health-Check-Requests
        http_req = "GET /health HTTP/1.0\r\nHost: backend\r\n\r\n",

        interval = 2000,   -- Intervall: 2000 ms (2 Sekunden)
        timeout = 1000,    -- Timeout pro Probe: 1 Sekunde
        fall = 3,          -- 3 aufeinanderfolgende Fehler → down
        rise = 2,          -- 2 aufeinanderfolgende Erfolge → up

        valid_statuses = { 200, 302 },   -- Als erfolgreich geltende HTTP-Statuscodes
    }

    if not ok then
        ngx.log(ngx.ERR, "failed to spawn health checker: ", err)
    end
}

Alle 2 Sekunden sendet die Bibliothek einen Request an /health jedes Backends. Nach 3 Fehlern in Folge wird der Server als down markiert und vom Load Balancer ausgeschlossen. Nach Wiederherstellung braucht es 2 Erfolge in Folge für up.

Statusdaten liegen im konfigurierten Shared Memory (lua_shared_dict healthcheck). In balancer_by_lua_block können Sie den Status lesen und Backends gezielt ein- oder ausschließen.

lua-resty-healthcheck: Produktionsempfehlung

Die offizielle Bibliothek funktioniert, ist aber begrenzt: nur aktive Checks, keine passiven (basierend auf echten Request-Fehlern). In Randfällen gibt es bekannte Bugs.

lua-resty-healthcheck ist die Community-Variante mit mehr Funktionen:

  • Aktive Checks: Periodische HTTP/TCP-Probes
  • Passive Checks: Statusanpassung aus Fehlerinfos in balancer_by_lua_block
  • Flexiblere Konfiguration: Custom-Logik, Callbacks
  • Stabiler: In Apache APISIX und anderen Projekten großflächig erprobt

Konfigurationsbeispiel:

-- Ebenfalls Shared Memory erforderlich
lua_shared_dict healthcheck 2m;

init_worker_by_lua_block {
    local healthcheck = require "resty.healthcheck"

    local checker = healthcheck.new({
        name = "backend_checker",
        shm_name = "healthcheck",

        checks = {
            active = {
                type = "http",
                http_path = "/health",
                healthy = {
                    interval = 2,     -- Alle 2 Sekunden prüfen
                    successes = 2,    -- 2 Erfolge in Folge → up
                },
                unhealthy = {
                    interval = 1,     -- Nach down häufiger prüfen
                    tcp_failures = 1, -- TCP-Fehler → sofort down
                    http_failures = 3, -- 3 HTTP-Fehler → down
                },
            },
            passive = {
                healthy = {
                    successes = 3,    -- 3 erfolgreiche echte Requests → up
                },
                unhealthy = {
                    tcp_failures = 2, -- 2 TCP-Fehler → down
                    http_failures = 3, -- 3 HTTP-Fehler → down
                },
            },
        },
    })

    -- Backends zum Prüfen hinzufügen
    checker:add_target("192.168.1.10", 8080, "backend", true)
    checker:add_target("192.168.1.11", 8080, "backend", true)
    checker:add_target("192.168.1.12", 8080, "backend", true)
}

Passive Checks sind mächtig: Selbst wenn aktive Probes nichts finden, markiert die Bibliothek ein Backend als down, wenn viele echte Requests scheitern – schnellere Reaktion auf plötzliche Ausfälle.

Vergleich der beiden Ansätze

Kriteriumlua-resty-upstream-healthchecklua-resty-healthcheck
MaintainerOpenResty offiziellCommunity (APISIX-validiert)
Aktive ChecksJaJa
Passive ChecksNeinJa
KonfigurationsflexibilitätGeringHoch (Callbacks, Custom-Logik)
ProduktionsstabilitätMittel (bekannte Bugs)Hoch (großflächig validiert)
DokumentationOffiziellAusführlich, mit Beispielen
EmpfehlungFür EinstiegProduktionsempfehlung

Anfangs nutzte ich die offizielle Bibliothek. In Produktion trat ein Problem auf: Ein Backend lieferte HTTP 200, aber der Body enthielt Fehler – intern defekt, äußerlich „gesund“. Die offizielle Bibliothek erkannte das nicht. Mit lua-resty-healthcheck und custom Check-Logik (Body parsen) war das Problem gelöst.

Meine Empfehlung: Direkt lua-resty-healthcheck verwenden. Der Code ist klarer; Apache APISIX baut darauf auf – deren Health-Check-Konfiguration ist eine gute Referenz.

Service-Discovery in der Praxis

Health Checks beantworten: Was passiert, wenn ein Backend ausfällt? Die Vorfrage: Woher kommt die Backend-Liste?

In Container-Umgebungen ändern sich Backend-IPs ständig. Hardcoding in der Konfiguration scheitert. Sie brauchen ein Service-Register, das Nginx mitteilt, welche Services laufen.

Drei gängige Optionen: Consul, Nacos, etcd – mit Integrationscode für jede.

Consul-Integration: Die reifste Lösung

Consul von HashiCorp ist weit verbreitet in Microservice-Architekturen: Registrierung, Health Checks, KV-Storage.

Ansatz für Nginx: Periodisch die Consul-API abfragen und die Liste im Shared Memory aktualisieren.

Vollständiger Code:

-- Shared Memory für Service-Liste
lua_shared_dict upstream_servers 5m;

-- Periodisch Service-Liste von Consul laden
init_worker_by_lua_block {
    local timer = require "ngx.timer"
    local http = require "resty.http"
    local cjson = require "cjson.safe"

    -- Consul Service-Discovery-API
    local consul_host = "consul.service.consul"
    local consul_port = 8500
    local service_name = "backend"

    -- Funktion zum Aktualisieren der Service-Liste
    local function update_upstream(premature)
        if premature then return end

        local httpc = http.new()
        httpc:set_timeout(1000)  -- 1 Sekunde Timeout

        -- Consul Catalog API
        local res, err = httpc:request_uri(
            "http://" .. consul_host .. ":" .. consul_port ..
            "/v1/catalog/service/" .. service_name,
            {
                method = "GET",
                headers = { Accept = "application/json" }
            }
        )

        if not res then
            ngx.log(ngx.ERR, "failed to query consul: ", err)
            return
        end

        -- Consul-Antwort parsen
        local services = cjson.decode(res.body)
        if not services or #services == 0 then
            ngx.log(ngx.WARN, "no backend services found in consul")
            return
        end

        -- Backend-Liste aufbauen
        local servers = {}
        for _, svc in ipairs(services) do
            -- Consul liefert Address und ServicePort
            -- Nur gesunde Services werden zurückgegeben (Consul Health Check)
            servers[#servers + 1] = {
                svc.ServiceAddress or svc.Address,
                svc.ServicePort,
                weight = 10  -- Standard-Gewicht
            }
        end

        -- In Shared Memory speichern
        local shared_dict = ngx.shared.upstream_servers
        local packed = cjson.encode(servers)
        shared_dict:set("backend_servers", packed)

        ngx.log(ngx.INFO, "updated upstream servers: ", #servers, " instances")
    end

    -- Alle 5 Sekunden aktualisieren
    timer.every(5, update_upstream)

    -- Beim Start sofort einmal ausführen
    update_upstream(false)
}

In balancer_by_lua_block die Daten lesen:

upstream backend {
    server 0.0.0.1;
    balancer_by_lua_block {
        local cjson = require "cjson.safe"
        local roundrobin = require "resty.roundrobin"
        local shared_dict = ngx.shared.upstream_servers

        -- Service-Liste aus Shared Memory
        local packed = shared_dict:get("backend_servers")
        if not packed then
            ngx.log(ngx.ERR, "no upstream servers available")
            return ngx.exit(503)
        end

        local servers = cjson.decode(packed)

        -- Round-Robin-Load-Balancer
        local rr = roundrobin:new(servers)
        local host, port = rr:select()

        -- Backend setzen
        local balancer = require "ngx.balancer"
        local ok, err = balancer.set_current_peer(host, port)
        if not ok then
            ngx.log(ngx.ERR, "failed to set peer: ", err)
            return ngx.exit(500)
        end
    }
}

Vorteil: Consul bringt eigene Health Checks mit. Bei der Registrierung konfigurieren Sie HTTP-Checks; die Catalog-API liefert nur gesunde Instanzen – Nginx erhält bereits gefilterte Listen.

Nacos-Integration: Häufig in China

Nacos ist Alibabas Open-Source-Plattform für Service-Discovery und Konfiguration; Spring Cloud Alibaba nutzt es standardmäßig.

Die API ähnelt Consul, das Format weicht leicht ab.

Integrationscode:

lua_shared_dict upstream_servers 5m;

init_worker_by_lua_block {
    local timer = require "ngx.timer"
    local http = require "resty.http"
    local cjson = require "cjson.safe"

    -- Nacos-Konfiguration
    local nacos_host = "nacos.service.nacos"
    local nacos_port = 8848
    local namespace_id = "public"  -- Nacos-Namespace
    local service_name = "backend-service"
    local group_name = "DEFAULT_GROUP"

    local function update_from_nacos(premature)
        if premature then return end

        local httpc = http.new()
        httpc:set_timeout(2000)

        -- Nacos Service-Discovery-API
        local url = "http://" .. nacos_host .. ":" .. nacos_port ..
                    "/nacos/v1/ns/instance/list?serviceName=" .. service_name ..
                    "&groupName=" .. group_name ..
                    "&namespaceId=" .. namespace_id

        local res, err = httpc:request_uri(url, { method = "GET" })

        if not res then
            ngx.log(ngx.ERR, "failed to query nacos: ", err)
            return
        end

        local data = cjson.decode(res.body)
        if not data or not data.hosts then
            ngx.log(ngx.WARN, "no instances found in nacos")
            return
        end

        -- hosts enthält Instanzliste
        local servers = {}
        for _, instance in ipairs(data.hosts) do
            -- Nur healthy=true verwenden
            if instance.healthy then
                servers[#servers + 1] = {
                    instance.ip,
                    instance.port,
                    weight = instance.weight or 10
                }
            end
        end

        local shared_dict = ngx.shared.upstream_servers
        shared_dict:set("backend_servers", cjson.encode(servers))

        ngx.log(ngx.INFO, "updated from nacos: ", #servers, " instances")
    end

    timer.every(5, update_from_nacos)
    update_from_nacos(false)
}

Besonderheit von Nacos: dynamische Gewichtsanpassung. Gewicht einer Instanz in der Konsole ändern – beim nächsten Pull passt Nginx die Traffic-Verteilung an. Ideal für Canary-Releases: neue Version zunächst mit wenig Traffic, schrittweise hochfahren.

etcd-Integration: Leichtgewichtige Option

etcd ist der verteilte KV-Store von CoreOS; Kubernetes speichert damit Cluster-Status. Liegen Registrierungsdaten in etcd, kann Nginx direkt daraus lesen.

Integrationscode:

lua_shared_dict upstream_servers 5m;

init_worker_by_lua_block {
    local timer = require "ngx.timer"
    local http = require "resty.http"
    local cjson = require "cjson.safe"

    -- etcd-Konfiguration
    local etcd_host = "etcd.service.etcd"
    local etcd_port = 2379
    -- Key für Service-Registrierung (eigenes Format)
    local service_key = "/services/backend"

    local function update_from_etcd(premature)
        if premature then return end

        local httpc = http.new()
        httpc:set_timeout(1000)

        -- etcd V3 API (POST erforderlich)
        local url = "http://" .. etcd_host .. ":" .. etcd_port .. "/v3/kv/range"
        local body = cjson.encode({ key = service_key, range_end = service_key .. "/" })

        local res, err = httpc:request_uri(url, {
            method = "POST",
            body = body,
            headers = { ["Content-Type"] = "application/json" }
        })

        if not res then
            ngx.log(ngx.ERR, "failed to query etcd: ", err)
            return
        end

        local data = cjson.decode(res.body)
        if not data or not data.kvs then
            ngx.log(ngx.WARN, "no services found in etcd")
            return
        end

        -- Key-Value-Paare parsen
        local servers = {}
        for _, kv in ipairs(data.kvs) do
            -- kv.value ist Instanzinfo (Base64)
            local value = ngx.decode_base64(kv.value)
            local instance = cjson.decode(value)

            if instance and instance.healthy then
                servers[#servers + 1] = {
                    instance.host,
                    instance.port,
                    weight = instance.weight or 10
                }
            end
        end

        local shared_dict = ngx.shared.upstream_servers
        shared_dict:set("backend_servers", cjson.encode(servers))
    end

    timer.every(5, update_from_etcd)
    update_from_etcd(false)
}

Vorteil von etcd: einfach und leicht. Kein vollständiges Service-Discovery-Ökosystem wie bei Consul oder Nacos – Registrierung müssen Sie selbst designen. Wer Kubernetes nutzt, profitiert von der nativen Integration.

Vergleich der drei Optionen

KriteriumConsulNacosetcd
Native Health ChecksJa (HTTP/TCP)JaNein (selbst bauen)
Dynamische GewichteJaJa (UI)Selbst implementieren
Spring CloudUnterstütztStandardZusatzkonfiguration
KonsoleWeb-UIWeb-UI (ausgereifter)Keine (Third-Party)
Config ManagementJa (KV)Ja (mächtiger)Ja
Community (China)MittelHochHoch (K8s-Ökosystem)
EinsatzAllgemeine MicroservicesSpring Cloud AlibabaK8s-Umgebung

Meine Wahl: Spring Cloud → Nacos. Kubernetes → etcd. Unabhängige, vollständige Service-Discovery → Consul.

Praxis-Szenarien und Performance-Tuning

Architektur und Service Discovery stehen. Nun typische Szenarien und Optimierung.

Szenario 1: Kubernetes-Ingress-Gateway

Pod-Lebenszyklen in K8s sind kurz. Skalierung, Shrinking und Upgrades erzeugen neue Pods mit neuen IPs. Statische Upstreams sind untauglich.

OpenResty kann Pod-Änderungen dynamisch erfassen:

  1. Timer in init_worker_by_lua_block, alle 5 Sekunden K8s-API oder CoreDNS
  2. Pod-IP-Liste des Services parsen
  3. Shared Memory aktualisieren
  4. balancer_by_lua_block verteilt Last auf Pod-Liste

K8s-API-Beispiel:

local function watch_k8s_services(premature)
    local httpc = http.new()

    -- K8s API: Endpoints eines Services (= Pod-IPs)
    local url = "https://kubernetes.default/api/v1/namespaces/default/endpoints/backend-service"

    -- K8s API braucht Auth – Token aus ServiceAccount
    local token_file = "/var/run/secrets/kubernetes.io/serviceaccount/token"
    local token = read_file(token_file)  -- eigene Hilfsfunktion

    local res, err = httpc:request_uri(url, {
        headers = {
            Authorization = "Bearer " .. token
        }
    })

    if res then
        local endpoints = cjson.decode(res.body)
        -- endpoints.subsets enthält Adressen und Ports
        -- Parsen und in Shared Memory speichern...
    end
end

timer.every(5, watch_k8s_services)

In Produktion nutzen viele K8s-Ingress-Controller (NGINX Ingress, Traefik) mit eingebauter Logik. Bei speziellen Anforderungen (Custom-Routing, Canary-Strategien) ist eigener OpenResty-Code flexibler.

Szenario 2: Canary-Release

Neue Service-Version: 90 % alter Traffic, 10 % neue Version. Nach einer Woche schrittweise 50 %, dann 100 %.

OpenResty: Header-Routing plus dynamische Gewichte:

upstream backend {
    server 0.0.0.1;
    balancer_by_lua_block {
        local cjson = require "cjson.safe"
        local chash = require "resty.chash"
        local shared_dict = ngx.shared.upstream_servers

        -- Alte und neue Version aus Shared Memory
        local old_servers = cjson.decode(shared_dict:get("old_version"))
        local new_servers = cjson.decode(shared_dict:get("new_version"))

        -- Canary: nach Request-Header routen
        local version_header = ngx.req.get_headers()["X-Version"]

        if version_header == "new" then
            -- Erzwingen: neue Version (für Tester)
            local host, port = select_random(new_servers)
            balancer.set_current_peer(host, port)
        else
            -- Gewichtet zufällig: 90 % alt, 10 % neu
            local rand = math.random()
            if rand < 0.1 then
                local host, port = select_random(new_servers)
                balancer.set_current_peer(host, port)
            else
                local host, port = select_random(old_servers)
                balancer.set_current_peer(host, port)
            end
        end
    }
}

Gewichte in Shared Memory oder Redis; Ops passt sie per Management-API an, z. B. POST /admin/traffic-weight { "old": 90, "new": 10 }.

Szenario 3: Automatisches Aussondern bei Fehlern

Backend fällt plötzlich aus – Nginx soll schnell reagieren und keinen Traffic mehr dorthin senden.

lua-resty-healthcheck prüft im Hintergrund; nach 3 Fehlern in Folge → down.

In balancer_by_lua_block zuerst Gesundheit prüfen:

balancer_by_lua_block {
    local checker = ngx.shared.healthcheck
    local servers = get_all_servers()  -- aus Service Discovery

    -- Ungesunde Backends filtern
    local healthy_servers = {}
    for _, srv in ipairs(servers) do
        local key = srv[1] .. ":" .. srv[2]
        if checker:get(key) == "up" then  -- Health-Status abfragen
            healthy_servers[#healthy_servers + 1] = srv
        end
    end

    if #healthy_servers == 0 then
        return ngx.exit(503)  -- alle Backends down
    end

    -- Aus gesunder Liste wählen
    local rr = roundrobin:new(healthy_servers)
    local host, port = rr:select()
    balancer.set_current_peer(host, port)
}

Retry-Logik ist wichtig: Schlägt ein Versuch fehl, anderes Backend probieren statt sofort 500 an den Client.

-- Retry-Anzahl setzen
balancer.set_more_tries(2)

-- Bei Fehler protokollieren und erneut versuchen
local last_failure = balancer.get_last_failure()
if last_failure then
    ngx.log(ngx.WARN, "request failed: ", last_failure.type, " to ", last_failure.host)
    -- Passiver Health Check zeichnet Fehler auf
    -- Nächste Wahl überspringt diesen Server
end

Performance-Tuning

Dynamische Mechanismen haben Overhead: Health-Check-Probes, Remote-API-Abfragen. Falsche Konfiguration bremst die Antwortzeit.

Praxiserfahrung:

  1. Probe-Intervall: 2–10 Sekunden empfohlen. Zu kurz kostet Ressourcen, zu lang verzögert Fehlererkennung. Hohe Last: 2 s; niedrige Last: 5 s.

  2. Shared-Memory-Größe: lua_shared_dict healthcheck mindestens 1 MB. Pro Upstream ca. 100 KB. Bei 10 Upstreams: 2 MB sicher.

  3. Keepalive-Connection-Pool: Keepalive am Backend reduziert Verbindungsaufbau:

upstream backend {
    server 0.0.0.1;
    keepalive 64;  -- 64 Verbindungen im Pool
}
  1. Asynchrone Health Checks: ngx.timer läuft asynchron und blockiert Requests nicht. Probes verbrauchen dennoch HTTP-Verbindungen – bei vielen Backends Intervall reduzieren.

  2. Status-Cache: Service-Discovery-Ergebnisse 5 Sekunden cachen, häufige Consul/Nacos-Calls vermeiden. 5 s Verzögerung ist meist akzeptabel.

Vollständiges Produktionsbeispiel:

# Shared Memory
lua_shared_dict healthcheck 2m;
lua_shared_dict upstream_servers 5m;

# http-Block
http {
    # Connection Pool
    keepalive_timeout 60s;
    keepalive_requests 100;

    init_worker_by_lua_block {
        -- Health Check (2 s Intervall)
        local healthcheck = require "resty.healthcheck"
        local checker = healthcheck.new({
            shm_name = "healthcheck",
            checks = {
                active = {
                    interval = 2,
                    healthy = { successes = 2 },
                    unhealthy = { tcp_failures = 1, http_failures = 3 }
                },
                passive = {
                    healthy = { successes = 3 },
                    unhealthy = { tcp_failures = 2, http_failures = 3 }
                }
            }
        })

        -- Service Discovery (5 s Update)
        timer.every(5, update_upstream_from_consul)
    }

    upstream backend {
        server 0.0.0.1;
        keepalive 64;  -- Connection Pool

        balancer_by_lua_block {
            -- Gesundes Backend wählen
            local host, port = select_healthy_backend()
            balancer.set_current_peer(host, port)
            balancer.set_more_tries(2)  -- max. 2 Retries
        }
    }
}

Diese Konfiguration lief bei uns ein halbes Jahr in Produktion: 5000 Requests/Sekunde, Antwortzeiten stabil unter 50 ms. Entscheidend sind passende Parameter – weder zu aggressiv noch zu konservativ.

Fazit

Dynamische Upstreams in drei Schichten: ngx.balancer liefert die Low-Level-API, lua-resty-balancer kapselt Algorithmen, lua-resty-healthcheck übernimmt Health Checks. Zusammen wählen Sie Backends zur Laufzeit – ohne Nginx-Reload.

Service Discovery: Consul am reifsten, Nacos für Spring Cloud, etcd für K8s. Wählen Sie nach Ihrem Stack, nicht nach einem theoretischen „Best Case“.

Probieren Sie es aus: Starten Sie mit lua-resty-healthcheck. Beobachten Sie Ausfall, automatisches Aussondern, Wiederherstellung, automatisches Zurücknehmen. Wenn der Ablauf sitzt, Service Discovery integrieren. Apache APISIX’ balancer.lua hat nur 400 Zeilen – gute Referenz, nicht bei null anfangen.

Der Kern: Nginx wird lebendig. Statische Konfiguration wird dynamische Wahrnehmung – Nächte mit manuellen Config-Änderungen können vorbei sein.

Nginx dynamischen Upstream implementieren

OpenResty Drei-Schichten-Architektur für dynamische Service-Discovery und Health Checks

⏱️ Estimated time: 120 min

  1. 1

    Step 1: Abhängigkeiten installieren

    OpenResty und benötigte Lua-Bibliotheken installieren:

    • OpenResty (inkl. ngx.balancer)
    • lua-resty-balancer (Load-Balancing-Algorithmen)
    • lua-resty-healthcheck (Health Check)
    • lua-resty-http (HTTP-Client für Service-Discovery-APIs)
  2. 2

    Step 2: Shared Memory konfigurieren

    Im http-Block der nginx.conf:

    ```nginx
    lua_shared_dict healthcheck 2m;
    lua_shared_dict upstream_servers 5m;
    ```

    • healthcheck: Health-Check-Status (ca. 100 KB pro Upstream)
    • upstream_servers: Service-Liste (aus Consul/Nacos/etcd)
  3. 3

    Step 3: Health Check implementieren

    In init_worker_by_lua_block starten:

    ```lua
    local healthcheck = require "resty.healthcheck"
    local checker = healthcheck.new({
    shm_name = "healthcheck",
    checks = {
    active = {
    type = "http",
    http_path = "/health",
    interval = 2,
    healthy = { successes = 2 },
    unhealthy = { http_failures = 3 }
    }
    }
    })
    ```

    • active: alle 2 Sekunden HTTP-Probe
    • unhealthy: 3 Fehler in Folge → down
  4. 4

    Step 4: Service Discovery integrieren

    Eine Option wählen:

    • Consul: /v1/catalog/service/{name} API
    • Nacos: /nacos/v1/ns/instance/list API
    • etcd: /v3/kv/range API

    Mit ngx.timer.every alle 5 Sekunden aktualisieren und in Shared Memory speichern.
  5. 5

    Step 5: Dynamischen Upstream konfigurieren

    balancer_by_lua_block im upstream-Block:

    ```nginx
    upstream backend {
    server 0.0.0.1; # Platzhalter
    keepalive 64; # Connection Pool
    balancer_by_lua_block {
    local servers = get_healthy_servers()
    local rr = roundrobin:new(servers)
    local host, port = rr:select()
    balancer.set_current_peer(host, port)
    balancer.set_more_tries(2)
    }
    }
    ```

    • server 0.0.0.1 ist Platzhalter – echtes Backend wählt Lua
    • keepalive 64 hält 64 Verbindungen im Pool
    • set_more_tries(2) max. 2 Retries
  6. 6

    Step 6: Testen und tunen

    In Testumgebung deployen und prüfen:

    • Health Check: Backend stoppen – entfernt Nginx es automatisch?
    • Service Discovery: Container neu starten – aktualisiert sich die IP?
    • Performance: wrk oder ab für QPS und Latenz
    • Parameter: Probe-Intervall (2–10 s), Pool-Größe (64–128), Retries (2–3)

FAQ

Was ist der Unterschied zwischen lua-resty-upstream-healthcheck und lua-resty-healthcheck?
lua-resty-upstream-healthcheck ist die offizielle OpenResty-Bibliothek mit nur aktiven Checks. lua-resty-healthcheck ist die Community-Variante mit aktiven und passiven Checks, in Apache APISIX großflächig erprobt. Empfehlung: direkt lua-resty-healthcheck.
Wie aktualisiere ich Upstreams in Nginx dynamisch ohne Reload?
Mit OpenRestys balancer_by_lua_block wählen Sie Backends zur Laufzeit per Lua. Listen können im Shared Memory, in Redis oder per Service-Discovery-API liegen – ganz ohne nginx -s reload.
Consul, Nacos oder etcd für Service Discovery?
Abhängig vom Stack:

• Consul: am reifsten, vollständig, für allgemeine Microservices
• Nacos: Standard in Spring Cloud Alibaba, gute Konsole
• etcd: leichtgewichtig, native K8s-Integration
Beeinträchtigt dynamischer Upstream die Performance?
Overhead ist vorhanden, aber beherrschbar. Health Checks und Service Discovery laufen asynchron und blockieren Requests nicht. Gemessen: QPS 5000+, stabile Antwortzeiten &lt;50 ms. Wichtig: Probe-Intervall 2–10 s, Shared Memory 2–5 MB, Connection Pool 64–128.
Dynamische Service Discovery in Kubernetes?
Zwei Wege: 1) K8s-API direkt, Endpoints lesen für Pod-IPs; 2) CoreDNS, Service-Name per DNS auflösen. Option 1 flexibler für Canary-Releases und Custom-Routing.
Welches Intervall für Health-Check-Probes?
2–10 Sekunden empfohlen. Hohe Last: 2 s für schnelle Fehlererkennung. Niedrige Last: 5–10 s weniger Ressourcenverbrauch. Zu kurz belastet Backends und Nginx; zu lang verzögert Erkennung. fall/rise-Parameter gemeinsam abstimmen.

15 Min. Lesezeit · Veröffentlicht am: 7. Mai 2026 · Aktualisiert am: 14. Juli 2026

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog