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

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
| Criterio | lua-resty-upstream-healthcheck | lua-resty-healthcheck |
|---|---|---|
| Mantenedor | OpenResty oficial | Comunidad (validada por APISIX) |
| Comprobación activa | Sí | Sí |
| Comprobación pasiva | No | Sí |
| Flexibilidad de config. | Baja | Alta (callbacks, lógica custom) |
| Estabilidad en producción | Regular (bugs conocidos) | Alta (validación a gran escala) |
| Calidad de documentación | Documentación oficial | Detallada, con ejemplos |
| Recomendación | Aceptable para empezar | Recomendado 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
| Criterio | Consul | Nacos | etcd |
|---|---|---|---|
| Health check nativo | Sí (HTTP/TCP) | Sí | No (hay que implementarlo) |
| Ajuste dinámico de pesos | Sí | Sí (interfaz visual) | Requiere implementación propia |
| Integración Spring Cloud | Sí | Integración por defecto | Requiere configuración extra |
| Consola | Web UI | Web UI (más completa) | No (hace falta terceros) |
| Gestión de configuración | Sí (KV) | Sí (más potente) | Sí |
| Comunidad en China | Media | Alta | Alta (ecosistema K8s) |
| Caso de uso ideal | Microservicios general | Spring Cloud Alibaba | Entornos 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:
- En
init_worker_by_lua_block, un timer consulta la API de K8s o CoreDNS cada 5 segundos - Parsea la lista de IPs del servicio
- Actualiza memoria compartida
balancer_by_lua_blockbalancea 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:
-
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.
-
Tamaño de memoria compartida:
lua_shared_dict healthcheckal menos 1 MB. Cada upstream ~100 KB. Con 10 upstreams, 2 MB es prudente. -
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
}
-
Health check asíncrono: usa ngx.timer; no bloquea el procesamiento de peticiones. Los sondeos consumen conexiones HTTP; con muchos backends, reduce la frecuencia.
-
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
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
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
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
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
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
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?
¿Cómo actualizar upstream en Nginx sin reload?
¿Consul, Nacos o etcd para descubrimiento de servicios?
• 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?
¿Cómo implementar descubrimiento dinámico en Kubernetes?
¿Qué intervalo de sondeo conviene para el health check?
19 min de lectura · Publicado el: 7 may 2026 · Actualizado el: 21 ago 2026
Guía práctica de Nginx
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Balanceo de carga con Nginx en la práctica: configuración upstream y comprobaciones de salud
Guía detallada de la configuración de balanceo de carga upstream en Nginx: asignación por pesos, cinco estrategias, comprobaciones de salud pasivas y activas, y buenas prácticas de seguridad en producción
Parte 4 de 6
Siguiente
Ajuste de rendimiento de Nginx: gzip, caché y pool de conexiones
Guía de ajuste de rendimiento de Nginx: compresión gzip para reducir 60-80% el volumen de transferencia, estrategia proxy_cache con 95% de aciertos, worker_connections y pool de conexiones para multiplicar por 3-4 la concurrencia
Parte 6 de 6



Comentarios
Inicia sesión con GitHub para dejar un comentario