Cambiar tema

Upstream dinámico en Nginx: descubrimiento de servicios en tiempo real con Lua

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

Alerta en producción.

Un contenedor Docker se reinició. La IP cambió. En nginx.conf sigue la dirección antigua.

Te levantas, cambias la configuración a mano y ejecutas nginx -s reload. El QPS en línea da un tirón y la curva de monitorización se hunde. Con suerte, se recupera en unos segundos. Si no, empiezan las quejas de usuarios.

Me ha pasado más de una vez. Cada vez pienso lo mismo: ¿no hay forma de que Nginx descubra los backends por sí solo? Como Consul: la IP cambia y se actualiza sola, sin levantarte a medianoche a tocar la configuración.

OpenResty lleva tiempo pudiendo hacerlo. Sus scripts Lua pueden modificar la configuración de upstream en tiempo de ejecución, sin reload. Cloudflare funciona así: sus nodos edge de CDN dependen de este mecanismo para programar el tráfico de forma dinámica.

En este artículo verás cómo implementar upstream dinámico con una arquitectura de tres capas (ngx.balancer + lua-resty-balancer + health check), compararemos dos librerías de health check habituales y daremos código completo para integrar Consul, Nacos y etcd. Al terminar, podrás desplegar esto en producción.

¿Por qué necesitas upstream dinámico?

La configuración de upstream en Nginx es estática. Las direcciones de server que escribes en nginx.conf se cargan al arrancar; si quieres cambiarlas después, solo queda hacer reload.

En entornos containerizados es un dolor de cabeza. Un contenedor Docker se reinicia y la IP cambia. Un Pod de K8s se reprograma y la IP también. No puedes editar nginx.conf a mano cada vez. El equipo técnico de Zhubajie, una plataforma china de servicios freelance, cayó en esta trampa: pasaron de configuración manual a plantillas renderizadas y acabaron adoptando descubrimiento dinámico con Consul, empujados por este mismo problema.

Algunos proponen NGINX Plus, la versión comercial con upstream dinámico. Cierto, pero la licencia cuesta decenas de miles de dólares al año y el código no es abierto. Si algo falla, esperas al parche oficial. Para la mayoría de equipos no es la mejor opción.

OpenResty abre otro camino. Incrusta la máquina virtual LuaJIT en Nginx y puedes modificar upstream en tiempo de ejecución con scripts Lua, sin reload, incluso mientras se procesan peticiones.

La pieza clave es balancer_by_lua_block. Interviene cuando Nginx elige el servidor upstream: tu código Lua decide a qué backend enviar la petición. La lista de IPs puede vivir en memoria compartida, Redis o Consul. Si un backend cae, Lua lo retira. Si entra un servicio nuevo, Lua lo detecta.

Los casos de uso son variados:

  • Gateway de entrada en K8s: las IPs de los Pods cambian con frecuencia; Nginx como Ingress debe percibirlo en tiempo real
  • Despliegue gradual en microservicios: versiones nueva y antigua conviven; enrutamiento dinámico según cabeceras o cookies
  • Retirada automática ante fallos: si un backend responde lento o cae, Nginx lo detecta y lo saca del pool
  • Enrutamiento entre centros de datos: elegir el centro más cercano según ubicación del usuario o latencia

Los nodos edge de CDN de Cloudflare dependen de este mecanismo. Cientos de nodos en todo el mundo, millones de peticiones por segundo, todo controlado con OpenResty. Han publicado parte de la implementación en GitHub.

Arquitectura de tres capas y componentes principales

El upstream dinámico en OpenResty no es un truco aislado: son tres capas que colaboran:

┌─────────────────────────────────────────┐
│  Tercera capa: health check             │
│  lua-resty-healthcheck                   │
│  - Sondeo activo del estado de backends │
│  - Actualiza el estado en memoria comp. │
└──────────────┬──────────────────────────┘
               │ sincronización de estado
┌──────────────▼──────────────────────────┐
│  Segunda capa: algoritmos de balanceo   │
│  lua-resty-balancer                      │
│  - resty.roundrobin (round-robin)       │
│  - resty.chash (hash consistente)       │
│  - Lee la lista de backends sanos       │
└──────────────┬──────────────────────────┘
               │ resultado de selección
┌──────────────▼──────────────────────────┐
│  Primera capa: API de bajo nivel        │
│  ngx.balancer                            │
│  - set_current_peer(host, port)         │
│  - get_last_failure()                   │
│  - set_more_tries(n)                    │
│  - Se invoca en la fase balancer_by_lua │
└─────────────────────────────────────────┘

ngx.balancer: API de bajo nivel

Esta capa está más cerca del núcleo de Nginx. El módulo ngx.balancer expone tres APIs principales:

  • set_current_peer(host, port): indica a qué backend enviar esta petición
  • get_last_failure(): obtiene información del último intento fallido (para lógica de reintento)
  • set_more_tries(n): define reintentos adicionales

Estas APIs solo pueden llamarse dentro de balancer_by_lua_block, la fase en la que Nginx elige el servidor upstream. Tu código Lua toma el control del enrutamiento.

Un ejemplo mínimo:

upstream backend {
    server 0.0.0.1;  # Dirección placeholder; upstream requiere al menos un server
    balancer_by_lua_block {
        local balancer = require "ngx.balancer"

        -- Selección dinámica del 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
    }
}

Nota: server 0.0.0.1 es un placeholder. Nginx exige al menos una directiva server en el bloque upstream, pero Lua elige el backend real; esta dirección nunca se usa.

lua-resty-balancer: algoritmos de balanceo de carga

Usar ngx.balancer a pelo es demasiado básico: round-robin, hash y lista de backends los implementas tú. lua-resty-balancer encapsula esos algoritmos.

Ofrece dos balanceadores:

  • resty.roundrobin: round-robin, selección secuencial
  • resty.chash: hash consistente; el mismo cliente siempre va al mismo backend (útil para afinidad de sesión)

Inicialízalos en init_worker_by_lua_block:

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

    -- Lista de backends (puede obtenerse dinámicamente de 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 },
    }

    -- Crear balanceador round-robin
    local rr_upstream = roundrobin:new(servers)

    -- Guardar en memoria compartida para la fase balancer
    local shared_dict = ngx.shared.upstreams
    shared_dict:set("backend_rr", rr_upstream)
}

Luego en 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")

        -- Seleccionar el siguiente servidor
        local host, port = rr_upstream:select()

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

Fases de ejecución en detalle

Nginx procesa las peticiones en un orden estricto de fases. Entenderlas te ayuda a colocar el código Lua correctamente:

1. init_by_lua_block     → Al arrancar el proceso master
2. init_worker_by_lua    → Al arrancar cada worker
3. ssl_certificate_by_lua → Fase de handshake SSL
4. set_by_lua            → Asignación de variables
5. rewrite_by_lua        → Reescritura de URL
6. access_by_lua         → Control de acceso
7. balancer_by_lua       → Selección de upstream (núcleo)
8. header_filter_by_lua  → Cabeceras de respuesta
9. body_filter_by_lua    → Cuerpo de respuesta
10. log_by_lua           → Registro de logs

balancer_by_lua_block está en la fase 7. La petición aún no se ha reenviado: decides el destino. En reintentos (error del backend), get_last_failure() explica el fallo anterior para elegir otro servidor.

Comparación de implementaciones de health check

La última capa del upstream dinámico es el health check. Los backends pueden caer en cualquier momento; conviene sondearlos de forma activa, no esperar a que fallen las peticiones reales.

En la comunidad OpenResty hay dos opciones habituales: la oficial lua-resty-upstream-healthcheck y la más completa lua-resty-healthcheck. Después de tropezar en producción, recomiendo la segunda.

lua-resty-upstream-healthcheck: solución oficial

Es la librería de health check mantenida por OpenResty. Hace comprobaciones activas: en segundo plano envía peticiones HTTP para sondear el estado.

Ejemplo de configuración:

-- Configurar memoria compartida en el bloque http de nginx.conf
lua_shared_dict healthcheck 1m;

-- Iniciar health check 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",             -- Nombre de memoria compartida
        upstream = "backend",            -- Nombre del upstream
        type = "http",                   -- Tipo de comprobación (http o tcp)

        -- Contenido de la petición de health check
        http_req = "GET /health HTTP/1.0\r\nHost: backend\r\n\r\n",

        interval = 2000,   -- Intervalo de sondeo: 2000 ms (2 s)
        timeout = 1000,    -- Timeout por sondeo: 1 s
        fall = 3,          -- 3 fallos seguidos → down
        rise = 2,          -- 2 éxitos seguidos → up

        valid_statuses = { 200, 302 },   -- Códigos HTTP considerados válidos
    }

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

Tras arrancar, la librería envía cada 2 segundos una petición a /health de cada backend. Tres fallos seguidos marcan el servidor como down y el balanceo deja de elegirlo. Cuando se recupera, hacen falta dos éxitos seguidos para volver a up.

El estado vive en la memoria compartida configurada (lua_shared_dict healthcheck). En balancer_by_lua_block puedes leerlo para decidir si usar un backend.

lua-resty-healthcheck: recomendado para producción

La librería oficial funciona, pero le faltan funciones: solo comprobación activa, sin pasiva (ajuste según fallos de peticiones reales), y en algunos casos límite tiene bugs.

lua-resty-healthcheck es la versión mejorada por la comunidad:

  • Comprobación activa: sondeos HTTP/TCP programados
  • Comprobación pasiva: ajuste según fallos en balancer_by_lua_block
  • Configuración flexible: lógica personalizada y callbacks
  • Más estable: validada a gran escala en Apache APISIX y otros proyectos

Ejemplo de configuración:

-- También requiere memoria compartida
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,     -- Sondeo cada 2 s
                    successes = 2,    -- 2 éxitos seguidos → up
                },
                unhealthy = {
                    interval = 1,     -- Tras down, sondeo cada 1 s
                    tcp_failures = 1, -- Fallo TCP → down de inmediato
                    http_failures = 3, -- 3 fallos HTTP → down
                },
            },
            passive = {
                healthy = {
                    successes = 3,    -- 3 peticiones normales exitosas → up
                },
                unhealthy = {
                    tcp_failures = 2, -- 2 fallos TCP → down
                    http_failures = 3, -- 3 fallos HTTP → down
                },
            },
        },
    })

    -- Añadir backends a comprobar
    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)
}

La comprobación pasiva ayuda cuando el sondeo activo no detecta el problema: si muchas peticiones reales fallan, el checker marca el backend como down y reacciona antes ante fallos súbitos.

Comparación de ambas soluciones

Criteriolua-resty-upstream-healthchecklua-resty-healthcheck
MantenedorOpenResty oficialComunidad (validada por APISIX)
Comprobación activa
Comprobación pasivaNo
Flexibilidad de config.BajaAlta (callbacks, lógica custom)
Estabilidad en producciónRegular (bugs conocidos)Alta (validación a gran escala)
Calidad de documentaciónDocumentación oficialDetallada, con ejemplos
RecomendaciónAceptable para empezarRecomendado en producción

Al principio usé la librería oficial. En producción apareció un caso: un backend devolvía 200 pero el cuerpo indicaba error interno (falso positivo de salud). Con lua-resty-healthcheck personalicé la lógica para analizar el cuerpo y decidir si estaba realmente sano. Problema resuelto.

Mi consejo: usa directamente lua-resty-healthcheck. El código es más claro y Apache APISIX se basa en ella; puedes tomar su configuración de health check como referencia.

Integración práctica de descubrimiento de servicios

El health check responde a «¿qué hago si un backend cae?». Pero queda la pregunta previa: ¿de dónde sale la lista de backends?

En entornos containerizados las IPs cambian con frecuencia. No puedes hardcodearlas. Hace falta un registro de servicios que diga a Nginx qué instancias están activas.

Las tres opciones más habituales son Consul, Nacos y etcd. Para cada una, código de integración.

Integración con Consul: la solución más madura

Consul, de HashiCorp, es ampliamente usado en arquitecturas de microservicios: registro, health check y almacén KV.

La idea: un timer en segundo plano consulta la API de Consul y actualiza la lista en memoria compartida.

Implementación completa:

-- Memoria compartida para la lista de servicios
lua_shared_dict upstream_servers 5m;

-- Obtener la lista desde Consul de forma periódica
init_worker_by_lua_block {
    local timer = require "ngx.timer"
    local http = require "resty.http"
    local cjson = require "cjson.safe"

    -- Dirección de la API de descubrimiento de Consul
    local consul_host = "consul.service.consul"
    local consul_port = 8500
    local service_name = "backend"

    -- Función para actualizar la lista de servicios
    local function update_upstream(premature)
        if premature then return end

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

        -- API Catalog de Consul
        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

        -- Parsear la lista devuelta por Consul
        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

        -- Construir lista de backends
        local servers = {}
        for _, svc in ipairs(services) do
            -- Consul devuelve Address y ServicePort
            -- Solo servicios sanos (health check propio de Consul)
            servers[#servers + 1] = {
                svc.ServiceAddress or svc.Address,
                svc.ServicePort,
                weight = 10  -- Peso por defecto
            }
        end

        -- Guardar en memoria compartida
        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

    -- Actualizar cada 5 segundos
    timer.every(5, update_upstream)

    -- Ejecutar una vez al arrancar
    update_upstream(false)
}

En balancer_by_lua_block, lee esos datos:

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

        -- Leer lista desde memoria compartida
        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)

        -- Crear balanceador round-robin
        local rr = roundrobin:new(servers)
        local host, port = rr:select()

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

Ventaja: Consul trae health check integrado. Al registrar un servicio puedes definir la ruta HTTP de comprobación y Consul sondea solo. La API Catalog devuelve instancias ya filtradas; Nginx recibe una lista depurada.

Integración con Nacos: muy usada en el ecosistema chino

Nacos, de Alibaba, es popular en microservicios en China. Spring Cloud Alibaba lo usa por defecto.

Su API de descubrimiento es similar a Consul, con formato ligeramente distinto.

Código de integración:

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"

    -- Configuración de Nacos
    local nacos_host = "nacos.service.nacos"
    local nacos_port = 8848
    local namespace_id = "public"  -- Namespace de Nacos
    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)

        -- API de descubrimiento de Nacos
        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

        -- El campo hosts contiene las instancias
        local servers = {}
        for _, instance in ipairs(data.hosts) do
            -- Solo instancias con healthy=true
            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 permite ajustar pesos de instancias en caliente. Cambias el peso en la consola y Nginx lo refleja en la siguiente consulta; la proporción de tráfico se adapta. Ideal para despliegue gradual: la versión nueva empieza con poco tráfico y subes el porcentaje poco a poco.

Integración con etcd: solución ligera

etcd, de CoreOS, es el almacén KV distribuido que usa Kubernetes para el estado del clúster. Si registras tus servicios en etcd, Nginx puede leerlos directamente.

Código de integración:

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"

    -- Configuración de etcd
    local etcd_host = "etcd.service.etcd"
    local etcd_port = 2379
    -- Clave del registro de servicios (formato personalizado)
    local service_key = "/services/backend"

    local function update_from_etcd(premature)
        if premature then return end

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

        -- API V3 de etcd (requiere POST)
        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

        -- Parsear pares clave-valor de etcd
        local servers = {}
        for _, kv in ipairs(data.kvs) do
            -- kv.value está en 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)
}

etcd es simple y ligero, pero no tiene el ecosistema completo de Consul o Nacos: debes diseñar tu propio registro. Si ya usas Kubernetes, la integración con etcd es natural.

Comparación de las tres opciones

CriterioConsulNacosetcd
Health check nativoSí (HTTP/TCP)No (hay que implementarlo)
Ajuste dinámico de pesosSí (interfaz visual)Requiere implementación propia
Integración Spring CloudIntegración por defectoRequiere configuración extra
ConsolaWeb UIWeb UI (más completa)No (hace falta terceros)
Gestión de configuraciónSí (KV)Sí (más potente)
Comunidad en ChinaMediaAltaAlta (ecosistema K8s)
Caso de uso idealMicroservicios generalSpring Cloud AlibabaEntornos K8s

Mi criterio: con Spring Cloud, Nacos. Con K8s, etcd encaja bien. Si quieres una plataforma de descubrimiento independiente y madura, Consul.

Escenarios prácticos y optimización de rendimiento

Con la arquitectura de tres capas y el descubrimiento de servicios montados, veamos aplicaciones típicas.

Escenario 1: gateway de entrada en Kubernetes

El ciclo de vida de los Pods en K8s es corto. Escalar, reducir o actualizar recrea Pods y cambia las IPs. Un upstream estático no sirve.

OpenResty puede percibir los cambios de Pods:

  1. En init_worker_by_lua_block, un timer consulta la API de K8s o CoreDNS cada 5 segundos
  2. Parsea la lista de IPs del servicio
  3. Actualiza memoria compartida
  4. balancer_by_lua_block balancea sobre esa lista

Ejemplo de llamada a la API de K8s:

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

    -- API K8s: Endpoints del Service (lista de IPs de Pods)
    local url = "https://kubernetes.default/api/v1/namespaces/default/endpoints/backend-service"

    -- La API K8s requiere token del ServiceAccount
    local token_file = "/var/run/secrets/kubernetes.io/serviceaccount/token"
    local token = read_file(token_file)  -- Función auxiliar para leer archivo

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

    if res then
        local endpoints = cjson.decode(res.body)
        -- endpoints.subsets contiene direcciones y puertos de Pods
        -- Parsear y guardar en memoria compartida...
    end
end

timer.every(5, watch_k8s_services)

En producción puedes usar un Ingress Controller de K8s (NGINX Ingress Controller, Traefik) que ya encapsula esta lógica. Si necesitas reglas de enrutamiento o estrategias de despliegue gradual a medida, OpenResty a mano da más flexibilidad.

Escenario 2: despliegue gradual

Imagina que publicas una versión nueva del servicio. La antigua atiende el 90% del tráfico y la nueva el 10%. Si va bien una semana, subes a 50% y luego al 100%.

Con OpenResty puedes combinar «enrutamiento por cabecera + pesos dinámicos»:

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

        -- Leer listas de versión antigua y nueva desde memoria compartida
        local old_servers = cjson.decode(shared_dict:get("old_version"))
        local new_servers = cjson.decode(shared_dict:get("new_version"))

        -- Estrategia gradual: enrutar según cabecera de petición
        local version_header = ngx.req.get_headers()["X-Version"]

        if version_header == "new" then
            -- Forzar versión nueva (para pruebas)
            local host, port = select_random(new_servers)
            balancer.set_current_peer(host, port)
        else
            -- Selección aleatoria según peso
            -- 90% versión antigua, 10% nueva
            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
    }
}

Los porcentajes pueden vivir en memoria compartida o Redis. Un endpoint de administración permite ajustarlos: POST /admin/traffic-weight { "old": 90, "new": 10 } y OpenResty actualiza la configuración.

Escenario 3: retirada automática ante fallos

Un backend cae de repente. Quieres que Nginx lo detecte rápido y deje de enviarle tráfico.

Aquí entra lua-resty-healthcheck: sondea en segundo plano y, tras tres fallos seguidos, marca el servidor como down.

En balancer_by_lua_block, filtra por estado de salud:

balancer_by_lua_block {
    local checker = ngx.shared.healthcheck
    local servers = get_all_servers()  -- Desde descubrimiento de servicios

    -- Filtrar backends no sanos
    local healthy_servers = {}
    for _, srv in ipairs(servers) do
        local key = srv[1] .. ":" .. srv[2]
        if checker:get(key) == "up" then  -- Consultar estado de salud
            healthy_servers[#healthy_servers + 1] = srv
        end
    end

    if #healthy_servers == 0 then
        return ngx.exit(503)  -- Todos los backends caídos
    end

    -- Seleccionar desde la lista sana
    local rr = roundrobin:new(healthy_servers)
    local host, port = rr:select()
    balancer.set_current_peer(host, port)
}

Los reintentos también importan. Si el backend devuelve error tras el reenvío, conviene probar otro en lugar de responder 500 al usuario.

-- Configurar reintentos
balancer.set_more_tries(2)

-- Si falla este intento, registrar y reintentar
local last_failure = balancer.get_last_failure()
if last_failure then
    ngx.log(ngx.WARN, "request failed: ", last_failure.type, " to ", last_failure.host)
    -- El health check pasivo registra el fallo
    -- En la siguiente selección se omitirá ese servidor
end

Recomendaciones de ajuste de rendimiento

Este mecanismo dinámico tiene coste: sondeos de health check y consultas a APIs de descubrimiento. Mal configurado, puede ralentizar las respuestas.

Experiencia medida en producción:

  1. Intervalo de sondeo: 2-10 segundos. Muy frecuente consume recursos; muy espaciado retrasa la detección. Con alta concurrencia, 2 s; con baja, 5 s.

  2. Tamaño de memoria compartida: lua_shared_dict healthcheck al menos 1 MB. Cada upstream ~100 KB. Con 10 upstreams, 2 MB es prudente.

  3. Pool keepalive: activa keepalive en backends para reducir el coste de nuevas conexiones:

upstream backend {
    server 0.0.0.1;
    keepalive 64;  -- Pool de 64 conexiones
}
  1. Health check asíncrono: usa ngx.timer; no bloquea el procesamiento de peticiones. Los sondeos consumen conexiones HTTP; con muchos backends, reduce la frecuencia.

  2. Caché de estado: cachea el resultado del descubrimimiento 5 segundos para no saturar la API de Consul/Nacos. En la mayoría de casos, 5 s de retraso es aceptable.

Ejemplo de configuración completa para producción:

# Memoria compartida
lua_shared_dict healthcheck 2m;
lua_shared_dict upstream_servers 5m;

# Bloque http
http {
    # Pool de conexiones
    keepalive_timeout 60s;
    keepalive_requests 100;

    init_worker_by_lua_block {
        -- Health check (sondeo cada 2 s)
        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 }
                }
            }
        })

        -- Descubrimiento de servicios (actualización cada 5 s)
        timer.every(5, update_upstream_from_consul)
    }

    upstream backend {
        server 0.0.0.1;
        keepalive 64;  -- Pool de conexiones

        balancer_by_lua_block {
            -- Seleccionar backend sano
            local host, port = select_healthy_backend()
            balancer.set_current_peer(host, port)
            balancer.set_more_tries(2)  -- Hasta 2 reintentos
        }
    }
}

Esta configuración lleva medio año en nuestro entorno de producción: ~5000 peticiones por segundo y latencia estable por debajo de 50 ms. La clave es calibrar parámetros: ni demasiado agresivo ni demasiado conservador.

Conclusión

La arquitectura de tres capas de upstream dinámico se apoya en la API ngx.balancer, lua-resty-balancer para algoritmos y lua-resty-healthcheck para salud. Enlazadas, permiten elegir backends en tiempo de ejecución sin reload de Nginx.

Para descubrimiento de servicios: Consul es el más maduro, Nacos encaja con Spring Cloud y etcd con K8s. Elige según tu stack actual; no persigas una «solución óptima» universal.

Pruébalo: empieza con lua-resty-healthcheck y pon en marcha el health check. Ver cómo un backend cae, se retira, se recupera y vuelve al pool te da confianza; después integra el descubrimiento. El balancer.lua de Apache APISIX tiene unas 400 líneas: úsalo de referencia en lugar de escribir desde cero.

En esencia, esto hace que Nginx «cobre vida»: configuración estática pasa a percepción dinámica. Los días de levantarte a medianoche a cambiar la configuración pueden quedar atrás.

Implementar upstream dinámico en Nginx

Usar la arquitectura de tres capas de OpenResty para descubrimiento de servicios y health check

⏱️ Estimated time: 120 min

  1. 1

    Step 1: Instalar módulos dependientes

    Instala OpenResty y las librerías Lua necesarias:

    • OpenResty (incluye ngx.balancer)
    • lua-resty-balancer (algoritmos de balanceo)
    • lua-resty-healthcheck (health check)
    • lua-resty-http (cliente HTTP para APIs de descubrimiento)
  2. 2

    Step 2: Configurar memoria compartida

    Añade en el bloque http de nginx.conf:

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

    • healthcheck: estado de health check (~100 KB por upstream)
    • upstream_servers: lista de servicios (desde Consul/Nacos/etcd)
  3. 3

    Step 3: Implementar health check

    Inicia el health check 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: sondeo HTTP cada 2 segundos
    • unhealthy: 3 fallos seguidos → down
  4. 4

    Step 4: Integrar descubrimiento de servicios

    Elige una opción de descubrimiento:

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

    Usa ngx.timer.every para actualizar la lista cada 5 segundos en memoria compartida.
  5. 5

    Step 5: Configurar upstream dinámico

    En el bloque upstream usa balancer_by_lua_block:

    ```nginx
    upstream backend {
    server 0.0.0.1; # Placeholder
    keepalive 64; # Pool de conexiones
    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 es placeholder; el backend real lo elige Lua
    • keepalive 64 mantiene un pool de 64 conexiones
    • set_more_tries(2) permite hasta 2 reintentos
  6. 6

    Step 6: Pruebas y ajuste

    Despliega en entorno de prueba y valida:

    • Health check: detén un backend y comprueba que Nginx lo retire
    • Descubrimiento: reinicia un contenedor y verifica que la IP se actualice
    • Rendimiento: prueba QPS y latencia con wrk o ab
    • Parámetros: intervalo de sondeo (2-10 s), tamaño del pool (64-128), reintentos (2-3)

FAQ

¿Cuál es la diferencia entre lua-resty-upstream-healthcheck y lua-resty-healthcheck?
lua-resty-upstream-healthcheck es la librería oficial de OpenResty y solo soporta comprobación activa. lua-resty-healthcheck es la versión mejorada por la comunidad, con comprobación activa y pasiva, validada a gran escala en Apache APISIX. Recomendación: usa directamente lua-resty-healthcheck.
¿Cómo actualizar upstream en Nginx sin reload?
Usa el hook balancer_by_lua_block de OpenResty para elegir backends en tiempo de ejecución con código Lua. La lista puede vivir en memoria compartida, Redis o en una API de descubrimiento de servicios, sin ejecutar nginx -s reload.
¿Consul, Nacos o etcd para descubrimiento de servicios?
Depende del stack:

• Consul: más maduro y completo, ideal para microservicios generales
• Nacos: integración por defecto en Spring Cloud Alibaba, consola muy completa
• etcd: ligero, integración nativa con Kubernetes, ideal en entornos K8s
¿El upstream dinámico afecta al rendimiento?
Tiene coste, pero es controlable. Health check y descubrimiento se ejecutan de forma asíncrona y no bloquean las peticiones. Medido: QPS 5000+ con respuesta estable &lt;50ms. Ajusta bien los parámetros: intervalo de sondeo 2-10 s, memoria compartida 2-5 MB, pool 64-128.
¿Cómo implementar descubrimiento dinámico en Kubernetes?
Dos vías: 1) llamar a la API de K8s y leer Endpoints para obtener IPs de Pods; 2) usar CoreDNS y resolver el nombre del servicio por DNS. La opción 1 es más flexible para despliegue gradual y reglas de enrutamiento personalizadas.
¿Qué intervalo de sondeo conviene para el health check?
Recomendado 2-10 segundos. Con alta concurrencia, 2 s para detectar fallos rápido; con baja, 5-10 s para reducir consumo. Un intervalo muy corto aumenta la carga en backends y en Nginx; muy largo retrasa la detección. Ajusta junto con fall (fallos seguidos) y rise (éxitos seguidos).

19 min de lectura · Publicado el: 7 may 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog