Changer le thème

Upstream dynamique Nginx : découverte de services en temps réel avec Lua

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

Alerte en production.

Un conteneur Docker redémarre. L’IP change. nginx.conf contient encore l’ancienne adresse.

Il faut se lever, modifier la config à la main, lancer nginx -s reload. Le QPS en ligne vacille, la courbe de monitoring creuse un trou. Avec un peu de chance, retour à la normale en quelques secondes. Sinon, les appels des utilisateurs arrivent.

Ça m’est arrivé plus d’une fois. À chaque fois la même question : Nginx ne peut-il pas découvrir les backends tout seul ? Comme Consul — l’IP change, la config se met à jour, sans se lever la nuit pour patcher nginx.conf ?

OpenResty le permet depuis longtemps. Ses scripts Lua modifient la config upstream à l’exécution, sans reload. Cloudflare fonctionne ainsi : leurs nœuds edge CDN s’appuient sur ce mécanisme pour router le trafic dynamiquement.

Cet article explique comment mettre en place un upstream dynamique en trois couches (ngx.balancer + lua-resty-balancer + health check), compare deux bibliothèques de health check courantes, et fournit du code complet pour Consul, Nacos et etcd. À la fin, vous pourrez déployer l’ensemble en production.

Pourquoi un upstream dynamique

La config upstream de Nginx est statique. Les adresses server dans nginx.conf sont chargées au démarrage ; pour les changer ensuite, il faut reload.

En conteneurs, c’est pénible. Un conteneur Docker redémarre, l’IP change. Un Pod K8s est reschedulé, l’IP change aussi. Impossible de modifier nginx.conf à chaque fois. L’équipe technique de zbj.com a vécu ce piège : config manuelle, puis templates, puis Consul — poussés par ce problème.

On vous dira NGINX Plus, la version commerciale avec upstream dynamique. Oui, mais plusieurs dizaines de milliers de dollars par an, code non open source. En cas de bug, vous attendez le correctif officiel. Pour la plupart des équipes, ce n’est pas le bon choix.

OpenResty ouvre une autre voie. Il embarque LuaJIT dans Nginx : des scripts Lua modifient upstream à l’exécution, sans reload, même pendant le traitement des requêtes.

Le levier clé est balancer_by_lua_block. Nginx y choisit le serveur upstream ; votre Lua décide où envoyer la requête. La liste des IP peut vivre en mémoire partagée, Redis ou Consul. Backend down → retrait automatique. Nouveau service → découverte automatique.

Cas d’usage fréquents :

  • Passerelle d’entrée K8s : IP de Pod changeantes, Nginx en Ingress doit les suivre
  • Déploiement canary microservices : anciennes et nouvelles versions, routage dynamique par en-tête ou cookie
  • Retrait automatique des pannes : backend lent ou down, sondes actives et retrait du pool
  • Routage inter-datacenter : choix du datacenter le plus proche selon géolocalisation ou latence

Les nœuds edge CDN de Cloudflare reposent sur ce mécanisme. Des centaines de nœuds, des millions de requêtes par seconde, le trafic piloté par OpenResty. Une partie de l’implémentation est open source sur GitHub.

Architecture en trois couches et composants clés

L’upstream dynamique OpenResty repose sur trois couches :

┌─────────────────────────────────────────┐
│  Couche 3 : health check                │
│  lua-resty-healthcheck                  │
│  - sondes actives des backends          │
│  - mise à jour de l'état en shm         │
└──────────────┬──────────────────────────┘
               │ sync d'état
┌──────────────▼──────────────────────────┐
│  Couche 2 : algorithmes de LB           │
│  lua-resty-balancer                     │
│  - resty.roundrobin (round-robin)       │
│  - resty.chash (hash consistant)        │
│  - lit la liste saine en shm            │
└──────────────┬──────────────────────────┘
               │ choix
┌──────────────▼──────────────────────────┐
│  Couche 1 : API bas niveau              │
│  ngx.balancer                           │
│  - set_current_peer(host, port)         │
│  - get_last_failure()                   │
│  - set_more_tries(n)                    │
│  - appelé en phase balancer_by_lua      │
└─────────────────────────────────────────┘

ngx.balancer : API bas niveau

Couche la plus proche du noyau Nginx. Le module ngx.balancer expose trois API :

  • set_current_peer(host, port) : cible backend pour cette requête
  • get_last_failure() : infos d’échec de la dernière tentative (retry)
  • set_more_tries(n) : nombre de tentatives supplémentaires

Ces API s’appellent uniquement dans balancer_by_lua_block, phase où Nginx choisit l’upstream — votre Lua prend alors la décision de routage.

Exemple minimal :

upstream backend {
    server 0.0.0.1;  # adresse placeholder, server obligatoire
    balancer_by_lua_block {
        local balancer = require "ngx.balancer"

        -- choix dynamique du backend
        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
    }
}

Note : server 0.0.0.1 est un placeholder. Nginx exige au moins une directive server dans upstream ; le vrai backend est choisi en Lua, cette adresse n’est jamais contactée.

lua-resty-balancer : algorithmes de répartition

Utiliser ngx.balancer seul est rudimentaire : round-robin, hash et liste des backends à coder vous-même. lua-resty-balancer encapsule ces algorithmes.

Deux équilibreurs :

  • resty.roundrobin : round-robin entre les backends
  • resty.chash : hash consistant, même client → même backend (affinité de session)

Initialisation dans init_worker_by_lua_block :

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

    -- liste des backends (Consul/Nacos en dynamique)
    local servers = {
        { "192.168.1.10", 8080, weight = 10 },
        { "192.168.1.11", 8080, weight = 5 },
        { "192.168.1.12", 8080, weight = 3 },
    }

    -- équilibreur round-robin
    local rr_upstream = roundrobin:new(servers)

    -- stockage en shm pour la phase balancer
    local shared_dict = ngx.shared.upstreams
    shared_dict:set("backend_rr", rr_upstream)
}

Usage dans 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")

        -- prochain serveur
        local host, port = rr_upstream:select()

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

Phases d’exécution

Nginx suit un ordre strict de phases. Pour placer le Lua au bon endroit :

1. init_by_lua_block     → démarrage du master
2. init_worker_by_lua    → démarrage de chaque worker
3. ssl_certificate_by_lua → handshake SSL
4. set_by_lua            → affectation de variables
5. rewrite_by_lua        → réécriture d'URL
6. access_by_lua         → contrôle d'accès
7. balancer_by_lua       → choix upstream (cœur)
8. header_filter_by_lua  → en-têtes de réponse
9. body_filter_by_lua    → corps de réponse
10. log_by_lua           → journalisation

balancer_by_lua_block est en phase 7. La requête n’est pas encore proxifiée ; vous choisissez la cible. En retry, get_last_failure() indique pourquoi la dernière tentative a échoué, pour en choisir une autre.

Comparaison des implémentations de health check

La dernière couche : le health check. Un backend peut tomber à tout moment ; mieux vaut sonder activement que découvrir passivement à chaque échec.

Deux options courantes : lua-resty-upstream-healthcheck (officiel) et lua-resty-healthcheck (enrichi). Après avoir pris des claques, je recommande fortement le second.

lua-resty-upstream-healthcheck : solution officielle

Bibliothèque maintenue par OpenResty. Checks actifs : requêtes HTTP périodiques en arrière-plan.

Exemple :

-- shm dans le bloc http de nginx.conf
lua_shared_dict healthcheck 1m;

-- lancement en init_worker_by_lua_block
init_worker_by_lua_block {
    local hc = require "resty.upstream.healthcheck"

    local ok, err = hc.spawn_checker{
        shm = "healthcheck",             -- nom du shm
        upstream = "backend",            -- nom upstream
        type = "http",                   -- http ou tcp

        -- requête de health check
        http_req = "GET /health HTTP/1.0\r\nHost: backend\r\n\r\n",

        interval = 2000,   -- intervalle : 2000 ms (2 s)
        timeout = 1000,    -- timeout par sonde : 1 s
        fall = 3,          -- 3 échecs consécutifs → down
        rise = 2,          -- 2 succès consécutifs → up

        valid_statuses = { 200, 302 },   -- codes HTTP considérés sains
    }

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

Toutes les 2 s, la lib interroge /health sur chaque backend. Trois échecs consécutifs → marqué down, exclu du LB. Deux succès consécutifs → remis up.

L’état vit dans le shm configuré (lua_shared_dict healthcheck). En balancer_by_lua_block, vous lisez cet état avant de choisir un backend.

lua-resty-healthcheck : recommandé en production

La lib officielle fonctionne, mais manque de checks passifs (ajustement selon les échecs réels) et a des bugs sur certains cas limites.

lua-resty-healthcheck est la version communautaire enrichie :

  • Checks actifs : sondes HTTP/TCP planifiées
  • Checks passifs : ajustement via les échecs en balancer_by_lua_block
  • Config flexible : logique custom, callbacks
  • Plus stable : validé à grande échelle (Apache APISIX, etc.)

Exemple :

-- shm requis
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,     -- sonde toutes les 2 s
                    successes = 2,    -- 2 succès → up
                },
                unhealthy = {
                    interval = 1,     -- plus fréquent une fois down
                    tcp_failures = 1, -- échec TCP → down immédiat
                    http_failures = 3, -- 3 échecs HTTP → down
                },
            },
            passive = {
                healthy = {
                    successes = 3,    -- 3 requêtes OK → up
                },
                unhealthy = {
                    tcp_failures = 2, -- 2 échecs TCP → down
                    http_failures = 3, -- 3 échecs HTTP → down
                },
            },
        },
    })

    -- backends à surveiller
    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)
}

Les checks passifs aident quand les sondes actives ne voient rien mais le trafic réel échoue en masse — retrait plus rapide en incident.

Comparaison des deux approches

Critèrelua-resty-upstream-healthchecklua-resty-healthcheck
MainteneurOpenResty officielCommunauté (validé APISIX)
Check actifOuiOui
Check passifNonOui
FlexibilitéFaibleÉlevée (callbacks, logique custom)
Stabilité prodMoyenne (bugs connus)Élevée (validation à grande échelle)
DocumentationOfficielleDétaillée, avec exemples
RecommandationOK pour débuterRecommandé en prod

Au départ j’utilisais la lib officielle. En prod, un backend renvoyait HTTP 200 avec un corps d’erreur (panne interne) — la lib officielle le voyait comme sain. Avec lua-resty-healthcheck, j’ai customisé la logique pour parser le corps ; problème résolu.

Mon conseil : partez directement sur lua-resty-healthcheck. Code plus clair ; APISIX s’en sert — inspirez-vous de sa config health check.

Intégration service discovery en pratique

Le health check répond à « que faire si le backend tombe ? ». Question amont : d’où vient la liste des backends ?

En conteneurs, les IP changent souvent. Pas de liste en dur : un registre de services indique qui tourne.

Trois options courantes : Consul, Nacos, etcd. Code d’intégration pour chacune.

Intégration Consul : la plus mature

Consul (HashiCorp) : enregistrement, health check, KV — très répandu en microservices.

Principe Nginx : timer en arrière-plan qui interroge l’API Consul et met à jour le shm.

Implémentation complète :

-- shm pour la liste de services
lua_shared_dict upstream_servers 5m;

-- pull périodique depuis Consul
init_worker_by_lua_block {
    local timer = require "ngx.timer"
    local http = require "resty.http"
    local cjson = require "cjson.safe"

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

    local function update_upstream(premature)
        if premature then return end

        local httpc = http.new()
        httpc:set_timeout(1000)  -- timeout 1 s

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

        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

        local servers = {}
        for _, svc in ipairs(services) do
            -- Address et ServicePort ; seuls les services sains (checks Consul)
            servers[#servers + 1] = {
                svc.ServiceAddress or svc.Address,
                svc.ServicePort,
                weight = 10  -- poids par défaut
            }
        end

        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

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

Lecture en balancer_by_lua_block :

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

        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)

        local rr = roundrobin:new(servers)
        local host, port = rr:select()

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

Atout Consul : health check natif à l’enregistrement. L’API Catalog ne renvoie que les instances saines — Nginx reçoit une liste déjà filtrée.

Intégration Nacos : courante en Chine

Nacos (Alibaba) : discovery et config ; défaut de Spring Cloud Alibaba.

API proche de Consul, format légèrement différent.

Code d’intégration :

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"

    local nacos_host = "nacos.service.nacos"
    local nacos_port = 8848
    local namespace_id = "public"
    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)

        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

        local servers = {}
        for _, instance in ipairs(data.hosts) do
            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)
}

Nacos permet d’ajuster les poids dynamiquement depuis la console ; au prochain pull, Nginx adapte la répartition — utile en canary (nouvelle version à faible trafic, montée progressive).

Intégration etcd : solution légère

etcd (CoreOS) : KV distribué, état de cluster K8s. Si vos enregistrements vivent dans etcd, lecture directe.

Code d’intégration :

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"

    local etcd_host = "etcd.service.etcd"
    local etcd_port = 2379
    local service_key = "/services/backend"

    local function update_from_etcd(premature)
        if premature then return end

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

        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

        local servers = {}
        for _, kv in ipairs(data.kvs) do
            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)
}

etcd est simple et léger, sans écosystème discovery complet de Consul/Nacos — il faut concevoir l’enregistrement. Sur K8s, intégration naturelle.

Comparaison des trois approches

CritèreConsulNacosetcd
Health check natifOui (HTTP/TCP)OuiNon (à construire)
Ajustement des poidsOuiOui (UI)À implémenter
Intégration Spring CloudOuiIntégration par défautConfig supplémentaire
ConsoleWeb UIWeb UI (riche)Non (tiers)
Gestion de configOui (KV)Oui (plus complet)Oui
Communauté (Chine)MoyenneForteForte (écosystème K8s)
Cas d’usageMicroservices généralistesSpring Cloud AlibabaEnvironnement K8s

Mon choix : Spring Cloud → Nacos. K8s → etcd pratique. Plateforme discovery autonome → Consul le plus mature.

Scénarios pratiques et optimisation des performances

Architecture en place, discovery intégrée. Quelques cas typiques.

Scénario 1 : passerelle d’entrée Kubernetes

Cycle de vie court des Pods : scale, IP changeantes. Un upstream statique ne tient pas.

OpenResty peut suivre les Pods :

  1. Timer en init_worker_by_lua_block, toutes les 5 s : API K8s ou CoreDNS
  2. Parser la liste d’IP des Pods du service
  3. Mettre à jour le shm
  4. balancer_by_lua_block répartit sur cette liste

Exemple d’appel API K8s :

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

    local url = "https://kubernetes.default/api/v1/namespaces/default/endpoints/backend-service"

    local token_file = "/var/run/secrets/kubernetes.io/serviceaccount/token"
    local token = read_file(token_file)

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

    if res then
        local endpoints = cjson.decode(res.body)
        -- subsets contient adresses et ports des Pods
        -- parser et stocker en shm...
    end
end

timer.every(5, watch_k8s_services)

En prod, NGINX Ingress Controller ou Traefik encapsulent souvent cette logique. Pour des règles custom (canary, routage avancé), OpenResty maison reste plus flexible.

Scénario 2 : déploiement canary

Nouvelle version : 90 % trafic ancienne, 10 % nouvelle ; montée progressive sur une semaine.

OpenResty : routage par en-tête + poids dynamiques :

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

        local old_servers = cjson.decode(shared_dict:get("old_version"))
        local new_servers = cjson.decode(shared_dict:get("new_version"))

        local version_header = ngx.req.get_headers()["X-Version"]

        if version_header == "new" then
            local host, port = select_random(new_servers)
            balancer.set_current_peer(host, port)
        else
            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
    }
}

Les ratios peuvent vivre en shm ou Redis ; une API admin les met à jour : POST /admin/traffic-weight { "old": 90, "new": 10 }.

Scénario 3 : retrait automatique des pannes

Backend down soudainement : Nginx doit arrêter d’y envoyer du trafic.

lua-resty-healthcheck sonde en continu ; trois échecs consécutifs → down.

En balancer_by_lua_block, filtrer par état :

balancer_by_lua_block {
    local checker = ngx.shared.healthcheck
    local servers = get_all_servers()

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

    if #healthy_servers == 0 then
        return ngx.exit(503)
    end

    local rr = roundrobin:new(healthy_servers)
    local host, port = rr:select()
    balancer.set_current_peer(host, port)
}

Retry : si la requête proxifiée échoue, tenter un autre backend plutôt qu’un 500 immédiat.

balancer.set_more_tries(2)

local last_failure = balancer.get_last_failure()
if last_failure then
    ngx.log(ngx.WARN, "request failed: ", last_failure.type, " to ", last_failure.host)
    -- le check passif enregistre l'échec
    -- prochain choix : skip ce serveur
end

Conseils de réglage perf

Coût : sondes health check, appels API discovery. Mal réglé, latence globale impactée.

Retours d’expérience :

  1. Intervalle de sonde : 2–10 s. Trop court = charge ; trop long = détection lente. Haut QPS → 2 s ; faible trafic → 5 s.

  2. Taille du shm : lua_shared_dict healthcheck ≥ 1 Mo. ~100 Ko par upstream. Dix upstreams → 2 Mo prudent.

  3. Pool keepalive : réduit le coût de connexion :

upstream backend {
    server 0.0.0.1;
    keepalive 64;
}
  1. Checks asynchrones : ngx.timer n bloque pas les requêtes ; beaucoup de backends → espacer les sondes.

  2. Cache d’état : résultat discovery en cache 5 s pour limiter les appels Consul/Nacos — délai acceptable en général.

Exemple de config prod complète :

lua_shared_dict healthcheck 2m;
lua_shared_dict upstream_servers 5m;

http {
    keepalive_timeout 60s;
    keepalive_requests 100;

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

        timer.every(5, update_upstream_from_consul)
    }

    upstream backend {
        server 0.0.0.1;
        keepalive 64;

        balancer_by_lua_block {
            local host, port = select_healthy_backend()
            balancer.set_current_peer(host, port)
            balancer.set_more_tries(2)
        }
    }
}

En prod chez nous, six mois à ~5000 req/s, latence stable sous 50 ms. La clé : paramètres équilibrés, ni trop agressifs ni trop laxistes.

Conclusion

L’upstream dynamique en trois couches : ngx.balancer pour la base, lua-resty-balancer pour les algorithmes, lua-resty-healthcheck pour la santé. Assemblés, choix de backend à l’exécution sans reload Nginx.

Discovery : Consul le plus mature, Nacos pour Spring Cloud, etcd pour K8s. Choisissez selon votre stack, pas la « meilleure » solution abstraite.

À tester : commencez par lua-resty-healthcheck. Voyez un backend tomber, être retiré, revenir, être réintégré — puis branchez la discovery. Le balancer.lua d’APISIX fait ~400 lignes : réutilisez-le plutôt que tout réécrire.

L’idée : rendre Nginx « vivant ». Config statique → perception dynamique. Fin des nuits à patcher nginx.conf.

Mettre en place un upstream dynamique Nginx

Architecture OpenResty en trois couches pour discovery dynamique et health check

⏱️ Estimated time: 120 min

  1. 1

    Step 1: Installer les modules requis

    Installer OpenResty et les bibliothèques Lua :

    • OpenResty (inclut ngx.balancer)
    • lua-resty-balancer (algorithmes de LB)
    • lua-resty-healthcheck (health check)
    • lua-resty-http (client HTTP pour les API discovery)
  2. 2

    Step 2: Configurer la mémoire partagée

    Dans le bloc http de nginx.conf :

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

    • healthcheck : état des checks (~100 Ko par upstream)
    • upstream_servers : liste des services (Consul/Nacos/etcd)
  3. 3

    Step 3: Implémenter le health check

    Lancer en init_worker_by_lua_block :

    ```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 : sonde HTTP toutes les 2 s
    • unhealthy : 3 échecs consécutifs → down
  4. 4

    Step 4: Intégrer la discovery

    Choisir une option :

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

    Utiliser ngx.timer.every pour mettre à jour la liste toutes les 5 s en shm.
  5. 5

    Step 5: Configurer l'upstream dynamique

    Dans le bloc upstream, balancer_by_lua_block :

    ```nginx
    upstream backend {
    server 0.0.0.1; # placeholder
    keepalive 64;
    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 : placeholder, backends choisis en Lua
    • keepalive 64 : pool de 64 connexions
    • set_more_tries(2) : jusqu'à 2 retries
  6. 6

    Step 6: Tests et réglage

    Valider en environnement de test :

    • Health check : arrêter un backend, vérifier le retrait automatique
    • Discovery : redémarrer un conteneur, vérifier la mise à jour d'IP
    • Perf : wrk ou ab pour QPS et latence
    • Paramètres : intervalle de sonde (2–10 s), pool (64–128), retries (2–3)

FAQ

Quelle différence entre lua-resty-upstream-healthcheck et lua-resty-healthcheck ?
lua-resty-upstream-healthcheck est la lib officielle OpenResty, checks actifs uniquement. lua-resty-healthcheck est la version communautaire avec checks actifs et passifs, validée à grande échelle par Apache APISIX. Recommandation : lua-resty-healthcheck directement.
Comment mettre à jour upstream dynamiquement sans reload Nginx ?
Utiliser le hook balancer_by_lua_block d'OpenResty : le Lua choisit le backend à l'exécution. La liste peut vivre en shm, Redis ou via une API discovery — sans nginx -s reload.
Consul, Nacos ou etcd pour la discovery ?
Selon la stack :

• Consul : le plus mature, complet, microservices généralistes
• Nacos : intégration par défaut Spring Cloud Alibaba, console riche
• etcd : léger, natif K8s, adapté aux environnements Kubernetes
L'upstream dynamique impacte-t-il les performances ?
Coût oui, mais maîtrisable. Health check et discovery sont asynchrones, sans blocage des requêtes. Mesures : QPS 5000+ avec latence stable &lt;50 ms. Réglages clés : intervalle 2–10 s, shm 2–5 Mo, pool 64–128.
Discovery dynamique dans Kubernetes ?
Deux voies : 1) API K8s, lire les Endpoints pour la liste d'IP des Pods ; 2) CoreDNS, résolution DNS du nom de service. La voie 1 est plus flexible (canary, routage custom).
Quel intervalle pour les sondes de health check ?
2–10 s recommandé. Haut QPS : 2 s pour détecter vite ; faible trafic : 5–10 s pour limiter la charge. Intervalle trop court = pression sur backends et Nginx ; trop long = retard de détection. Ajuster fall (échecs consécutifs) et rise (succès consécutifs) ensemble.

15 min de lecture · Publié le: 7 mai 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog