10

Servicios y balanceo en Traefik

Vistas: 1
Servicios y balanceo en Traefik

Hasta ahora has visto cómo enrutar tráfico a servicios individuales. Pero, ¿qué pasa cuando tienes múltiples instancias del mismo servicio y quieres distribuir la carga entre ellas? ¿O cuando una instancia falla y necesitas que el tráfico se redirija a otra? ¿O cuando necesitas que un usuario siempre vaya a la misma instancia porque guardas el estado de la sesión en memoria?

En este capítulo vas a ver cómo configurar servicios en Traefik, cómo hacer balanceo de carga, cómo definir health checks como un profesional, cómo manejar sticky sessions, y cómo montar esquemas de failover que te saquen del apuro cuando las cosas se ponen feas. Y todo con la voz de la experiencia: esto te puede ahorrar más de un disgusto cuando tengas que gestionar un incidente a las tres de la madrugada.

¿Qué es un servicio en Traefik?

Un servicio en Traefik es el destino final del tráfico. Es la dirección (IP y puerto) del contenedor, máquina virtual o servidor físico que va a procesar la petición. Piensa en ello como el último eslabón de la cadena: Entran las peticiones por los EntryPoints, pasan por los Routers que las clasifican, atraviesan los Middlewares si los hay, y finalmente llegan al Servicio que las procesa.

Cuando no especificas un servicio en las labels, Traefik lo infiere automáticamente a partir del nombre del router. Pero tienes mucho más control si lo defines explícitamente. Y te recomiendo que lo hagas siempre, porque cuando empiezas a escalar servicios, tener el control explícito de cada detalle marca la diferencia entre un sistema que funciona y uno que te da sorpresas.

Traefik distingue entre tres tipos de servicios:

  • HTTP: para tráfico HTTP/HTTPS. Es el más común y el que tiene más opciones de configuración: balanceo, health checks, sticky sessions, circuit breaker.
  • TCP: para tráfico TCP genérico. Ideal para bases de datos, Redis, MongoDB, o cualquier protocolo que no sea HTTP.
  • UDP: para tráfico UDP. Útil para DNS, VPNs, o servidores de juego.

En este capítulo me centro en los servicios HTTP, que son los que usas en el 90% de los casos. Pero te ire contando también cómo funcionan los TCP, porque tarde o temprano vas a necesitar enrutar una base de datos.

Servicio implícito vs explícito

Servicio implícito

labels:
  - "traefik.http.routers.mi-app.rule=Host(`app.dominio.com`)"

Aquí Traefik asume que el servicio se llama mi-app (igual que el router) y que el puerto es el que expone el contenedor. Si tu contenedor solo expone un puerto, funciona de maravilla. Pero en cuanto tienes varios puertos expuestos o quieres personalizar algo, esto se queda corto.

Servicio explícito

labels:
  - "traefik.http.routers.mi-app.rule=Host(`app.dominio.com`)"
  - "traefik.http.routers.mi-app.service=mi-servicio"
  - "traefik.http.services.mi-servicio.loadbalancer.server.port=3000"

Aquí tienes control total sobre el nombre y el puerto del servicio. Fíjate que el nombre del servicio puede ser diferente al del router. Esto te permite, por ejemplo, que un router «api» apunte a un servicio «backend-api» y otro router «web» apunte al mismo servicio. O reutilizar el mismo servicio desde varios routers.

El puerto del servicio: la fuente de más de un dolor de cabeza

Uno de los errores más comunes al configurar Traefik es no especificar bien el puerto del servicio. Cuando un contenedor expone varios puertos, Traefik no sabe a cuál tiene que enviar el tráfico. Y en lugar de adivinarlo correctamente, a veces elige el que no es.

Puedes especificarlo de varias formas:

Por nombre de puerto en el Dockerfile

Si tu contenedor tiene un puerto EXPOSE en el Dockerfile con un nombre:

labels:
  - "traefik.http.services.mi-servicio.loadbalancer.server.port=web"

El nombre web debe coincidir con el EXPOSE del Dockerfile. Por ejemplo, EXPOSE 3000 no tiene nombre, pero EXPOSE 3000/tcp sí puedes nombrarlo como web en docker-compose con la propiedad ports.

Por número de puerto explícito

labels:
  - "traefik.http.services.mi-servicio.loadbalancer.server.port=8080"

Esta es la opción más clara y la que te recomiendo. No hay ambigüedad: dices puerto 8080 y Traefik envía el tráfico al puerto 8080 del contenedor.

Por puerto implícito

Si no lo especificas, Traefik usa el primer puerto que encuentra en el contenedor. Esto puede ser problemático si tu contenedor expone varios puertos. Por ejemplo, un contenedor de WordPress expone el 80 y el 443. Si no especificas, Traefik puede elegir el 443 cuando tú querías el 80, o al revés. Es mejor no dejarlo al azar.

Ojo con el mapeo de puertos

Un detalle importante: cuando usas Docker con redes personalizadas (que es lo recomendable), Traefik se comunica con los contenedores a través de la red interna de Docker, no a través de los puertos mapeados con ports: en el docker-compose. Esto significa que el puerto que especificas en el servicio es el puerto interno del contenedor, no el puerto mapeado en el host.

Si tu contenedor tiene ports: "3000:8080", el puerto que le tienes que decir a Traefik es el 8080, porque es el que escucha el contenedor internamente. El 3000 es el del host y no le interesa a Traefik. Esto te puede ahorrar más de un disgusto cuando estás debuggeando por qué no llega el tráfico.

Balanceo de carga

Traefik hace balanceo de carga de forma nativa. Ya lo vimos en el capítulo de primeros pasos cuando escalamos el servicio con --scale. Pero vamos a profundizar en serio.

Balanceo con múltiples instancias del mismo contenedor

Puedes escalar un servicio con Docker Compose:

services:
  miapp:
    image: miapp:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.miapp.rule=Host(`app.dominio.com`)"
      - "traefik.http.routers.miapp.entrypoints=websecure"
      - "traefik.http.routers.miapp.tls=true"

Y luego escalar:

docker compose up -d --scale miapp=3

Traefik detectará automáticamente las 3 instancias gracias al Docker provider, que escucha los eventos del Docker daemon. En cuanto levantas una nueva instancia, Traefik la añade al pool de servidores disponibles. Y cuando la paras, la quita. Todo en tiempo real, sin necesidad de recargar configuración.

Balanceo desde un archivo de configuración (File provider)

Si usas el File provider, puedes definir los servidores manualmente:

http:
  services:
    mi-servicio:
      loadBalancer:
        servers:
          - url: "http://192.168.1.10:3000"
          - url: "http://192.168.1.11:3000"
          - url: "http://192.168.1.12:3000"

Esto te permite balancear entre servidores que no están en Docker. Por ejemplo, máquinas físicas, VPS en otros proveedores, o servicios que tengas en tu red local.

Estrategias de balanceo

Traefik usa round-robin por defecto. No hay una opción directa para cambiar la estrategia a algo como «least connections» o «IP hash» como en otros balanceadores, pero puedes conseguir comportamientos equivalentes combinando distintas técnicas.

Round-robin puro

Es el algoritmo por defecto. Traefik va rotando las peticiones entre las instancias disponibles de forma secuencial. Si tienes 3 instancias, la primera petición va a la 1, la segunda a la 2, la tercera a la 3, la cuarta a la 1, y así sucesivamente.

Ventajas: es simple, justo cuando todas las instancias tienen la misma capacidad, y no necesita estado.
Inconvenientes: no tiene en cuenta la carga real de cada instancia ni el tiempo de respuesta.

Weighted Round-Robin (WRR)

Traefik no expone directamente un peso por servidor en la configuración por labels, pero sí puedes simularlo combinando sticky sessions y varios servicios, o usando el File provider con un truco: definir varios servicios con los mismos servidores pero duplicados según el peso que quieras darle.

Otra opción más práctica es usar el balanceo por DNS, que explicaré más adelante. Pero la realidad es que para la mayoría de los casos, el round-robin por defecto es más que suficiente.

Balanceo por DNS

Si tienes servidores en distintas ubicaciones geográficas, puedes combinar Traefik con un DNS que haga balanceo por geolocalización o por latencia. En ese caso, cada servidor tiene su propia instancia de Traefik, y el DNS reparte el tráfico entre ellas. Luego, cada Traefik local hace round-robin entre las réplicas de esa ubicación.

Estrategia con mirroring para canary releases

Traefik trae un tipo de servicio llamado mirroring que te permite enviar una copia del tráfico a otro servicio. Esto es muy útil para hacer pruebas canario: envías el 100% del tráfico a tu servicio estable y, además, una copia de ese tráfico (o un porcentaje) a la nueva versión. Así puedes ver cómo se comporta sin impactar a los usuarios.

http:
  services:
    mi-app:
      loadBalancer:
        servers:
          - url: "http://192.168.1.10:3000"

    mi-app-v2:
      loadBalancer:
        servers:
          - url: "http://192.168.1.20:3000"

    mi-app-canary:
      mirroring:
        service: mi-app
        maxBodySize: 1024
        mirrors:
          - name: mi-app-v2
            percent: 10

En este ejemplo, el 100% del tráfico va al servicio principal mi-app, y el 10% de las peticiones se duplica y se envía también a mi-app-v2. El cliente recibe la respuesta solo del servicio principal (mi-app). La copia se envía «por detrás» sin que el cliente lo note. Si la versión nueva falla, el usuario ni se entera.

Ojo: mirroring no es balanceo. El cliente recibe la respuesta solo del servicio principal. La copia se envía «por detrás» sin que el cliente lo note. Si la versión nueva falla, el usuario ni se entera.

Health checks detallados

Los health checks permiten a Traefik saber si una instancia de un servicio está funcionando correctamente. Si una instancia falla el health check, Traefik la marca como no disponible y deja de enviarle tráfico hasta que se recupere.

Esto es importantísimo en producción. Te puede ahorrar más de un disgusto cuando un servicio se cuelga sin morir del todo. Porque una cosa es que un servicio no responda (fácil de detectar) y otra muy distinta es que responda con errores internos 500 sin llegar a cerrar la conexión (más difícil).

Traefik lanza los health checks periódicamente contra cada servidor. Si un servidor falla el check, se marca como «down» y se saca del pool de balanceo. Pasado un tiempo, Traefik vuelve a intentarlo. Si se recupera, vuelve al pool automáticamente.

Health check con Docker labels

labels:
  - "traefik.http.services.mi-servicio.loadbalancer.healthcheck.path=/health"
  - "traefik.http.services.mi-servicio.loadbalancer.healthcheck.interval=10s"
  - "traefik.http.services.mi-servicio.loadbalancer.healthcheck.timeout=5s"

Los parámetros básicos son:

  • path: la ruta que Traefik consulta para verificar el estado. El servicio debe devolver un código 2xx o 3xx para considerarse sano.
  • interval: cada cuánto tiempo se hace la comprobación. Por defecto son 30 segundos. Si tu servicio es crítico, bájalo a 5 o 10 segundos.
  • timeout: tiempo máximo de espera para la respuesta. Si el servicio no responde en este tiempo, se considera caído.

Parámetros avanzados de health check

Además de los básicos, Traefik ofrece parámetros más finos que te recomiendo conocer:

labels:
  - "traefik.http.services.mi-servicio.loadbalancer.healthcheck.path=/health"
  - "traefik.http.services.mi-servicio.loadbalancer.healthcheck.interval=5s"
  - "traefik.http.services.mi-servicio.loadbalancer.healthcheck.timeout=3s"
  - "traefik.http.services.mi-servicio.loadbalancer.healthcheck.hostname=miapp.local"
  - "traefik.http.services.mi-servicio.loadbalancer.healthcheck.port=8080"
  - "traefik.http.services.mi-servicio.loadbalancer.healthcheck.scheme=https"
  - "traefik.http.services.mi-servicio.loadbalancer.healthcheck.headers.X-Custom-Header=true"
  - "traefik.http.services.mi-servicio.loadbalancer.healthcheck.followredirects=true"

Vamos por partes:

  • hostname: el valor del header Host que Traefik envía en la petición de health check. Si tu servicio usa virtual hosting, necesitas esto para que el health check llegue al virtual host correcto.
  • port: el puerto específico para el health check. Puede ser diferente del puerto del servicio. Por ejemplo, puedes tener un endpoint de health en el puerto 8081 mientras que el servicio corre en el 8080.
  • scheme: http o https. Si tu servicio solo acepta HTTPS, ponlo aquí.
  • headers: cabeceras personalizadas que se envían en la petición de health check. Útil si tu endpoint de health requiere autenticación o una cabecera especial.
  • followRedirects: si Traefik debe seguir redirecciones en el health check. Por defecto es true.

Health check con File provider

http:
  services:
    mi-servicio:
      loadBalancer:
        servers:
          - url: "http://192.168.1.10:3000"
          - url: "http://192.168.1.11:3000"
        healthCheck:
          path: /health
          interval: 10s
          timeout: 5s
          hostname: miapp.local
          port: 8080
          scheme: https
          headers:
            X-Custom-Header: "true"
          followRedirects: false

Diseñar un buen endpoint de health

Que un endpoint de health sea útil no es tan trivial como parece. Mucha gente pone un /health que devuelve 200 siempre, y eso no sirve de nada. Un buen endpoint de health debería comprobar:

  1. Que la aplicación responde (lo básico).
  2. Que la conexión a la base de datos funciona (si no hay BD, la app no sirve).
  3. Que las dependencias externas responden (APIs, Redis, etc.).
  4. Que no hay bloqueos o deadlocks (hilos atascados, conexiones agotadas).

Si el health check solo comprueba que el proceso está vivo, te puede engañar. He visto servicios que respondían 200 en /health pero llevaban 10 minutos sin poder conectar a la base de datos. El health check decía «todo ok» y los usuarios veían páginas de error.

Diseña tu endpoint de health con una jerarquía:

  • Liveness: la app responde. Devuelve 200 si el proceso está vivo.
  • Readiness: la app está lista para recibir tráfico. Devuelve 200 si puede procesar peticiones.
  • Dependencies: devuelve 200 solo si las dependencias esenciales están operativas.

Traefik no distingue entre liveness y readiness como Kubernetes, pero puedes usar el path para diferenciarlos. Por ejemplo, /health/live para liveness y /health/ready para readiness.

Health checks en TCP

Para servicios TCP (no HTTP), los health checks son diferentes. No puedes consultar una ruta HTTP, así que se basan en la conexión TCP:

labels:
  - "traefik.tcp.services.mi-servicio-tcp.loadbalancer.healthcheck.interval=10s"
  - "traefik.tcp.services.mi-servicio-tcp.loadbalancer.healthcheck.timeout=5s"

Estos health checks simplemente intentan abrir una conexión TCP al servidor. Si el servidor acepta la conexión, se considera sano. Si la rechaza o no responde, se considera caído.

Para servicios TCP no hay opción de cabeceras personalizadas ni path, porque no hay HTTP de por medio. Pero puedes afinar el comportamiento con:

labels:
  - "traefik.tcp.services.mi-servicio-tcp.loadbalancer.healthcheck.interval=10s"
  - "traefik.tcp.services.mi-servicio-tcp.loadbalancer.healthcheck.timeout=5s"
  - "traefik.tcp.services.mi-servicio-tcp.loadbalancer.healthcheck.port=3306"

El parámetro port te permite hacer el health check en un puerto diferente al del servicio. Esto es útil si tienes, por ejemplo, un servicio MySQL que corre en el 3306 pero quieres hacer el health check contra un proxy de admin en otro puerto. O si el servicio tiene un puerto específico de monitoring.

Frecuencia de health checks: no te pases

Un error común es poner los health checks demasiado frecuentes. Si pones interval=1s, Traefik va a estar pegando al servicio cada segundo por cada instancia. Si tienes 10 instancias, son 10 peticiones por segundo solo de health checks. En servicios pequeños no pasa nada, pero si tu endpoint de health hace consultas pesadas a la base de datos, puedes estar generando una carga innecesaria.

Mi recomendación: empieza con interval=10s y timeout=3s. Si necesitas más sensibilidad, baja a 5s. Pero no bajes de ahí a menos que sea estrictamente necesario.

Sticky sessions

Las sticky sessions (sesiones persistentes o afinidad de sesión) aseguran que un cliente siempre vaya a la misma instancia del servicio. Esto es necesario cuando tu servicio guarda el estado de la sesión en memoria y no usas una sesión compartida (como Redis o una base de datos).

Cómo funcionan internamente

Traefik implementa sticky sessions mediante cookies. Cuando un cliente hace su primera petición, Traefik elige una instancia (con round-robin) y guarda la referencia en una cookie. Las siguientes peticiones del mismo cliente incluyen esa cookie, y Traefik usa el valor para dirigir al cliente a la misma instancia.

Es decir, Traefik no necesita mantener una tabla de correspondencias en memoria. Toda la información de afinidad está en la cookie del cliente. Esto hace que el sistema sea stateless desde el punto de vista de Traefik, y puedas escalar horizontalmente el propio Traefik sin problemas.

Configuración con Docker labels

labels:
  - "traefik.http.services.mi-servicio.loadbalancer.sticky.cookie.name=MI_SESSION"
  - "traefik.http.services.mi-servicio.loadbalancer.sticky.cookie.httponly=true"
  - "traefik.http.services.mi-servicio.loadbalancer.sticky.cookie.secure=true"
  - "traefik.http.services.mi-servicio.loadbalancer.sticky.cookie.samesite=lax"
  - "traefik.http.services.mi-servicio.loadbalancer.sticky.cookie.maxage=3600"

Los parámetros disponibles son:

  • cookie.name: el nombre de la cookie. Personalízalo para evitar conflictos con otras cookies de tu aplicación. Si tienes varios servicios con sticky sessions, cada uno debe tener un nombre de cookie diferente.
  • cookie.httponly: la cookie no es accesible desde JavaScript. Ponerlo a true es buena práctica de seguridad.
  • cookie.secure: la cookie solo se envía por HTTPS. A true también, siempre que uses TLS.
  • cookie.samesite: controla el envío de la cookie en peticiones cross-site. Valores posibles: none, lax, strict. lax es un buen equilibrio entre seguridad y funcionalidad.
  • cookie.maxage: tiempo de vida de la cookie en segundos. Pasado ese tiempo, el cliente pierde la afinidad y se le asigna una nueva instancia en la siguiente petición. Por defecto no expira (cookie de sesión).

Sticky sessions con File provider

http:
  services:
    mi-servicio:
      loadBalancer:
        servers:
          - url: "http://192.168.1.10:3000"
          - url: "http://192.168.1.11:3000"
        sticky:
          cookie:
            name: MI_SESSION
            httpOnly: true
            secure: true
            sameSite: lax
            maxAge: 3600

Cómo se comporta Traefik con la cookie

Cuando un cliente hace su primera petición, Traefik:

  1. Elige una instancia del pool usando round-robin (o el que toque).
  2. Genera una cookie con el nombre que le hayas configurado.
  3. En la cookie guarda un hash que identifica la instancia seleccionada.
  4. Envía la cookie al cliente en la respuesta.
  5. En las siguientes peticiones, Traefik lee la cookie y dirige al cliente a la misma instancia.

Si la instancia a la que apunta la cookie está caída (porque falló el health check), Traefik redirige al cliente a otra instancia sana y actualiza la cookie. Esto significa que el usuario pierde su sesión si estaba en memoria, pero al menos sigue recibiendo servicio.

Cuándo usar sticky sessions

  • Cuando tu servicio guarda sesiones en memoria (PHP sin Redis, Node.js con almacenamiento local, etc.).
  • Cuando tienes WebSockets que necesitan mantener la conexión con la misma instancia.
  • Cuando haces uploads por partes y necesitas que todas las partes lleguen al mismo servidor.
  • Cuando no puedes usar una sesión compartida (Redis, base de datos) por restricciones de infraestructura o coste.

Cuándo NO usar sticky sessions

  • Cuando tus servicios son stateless. Si puedes evitarlas, mejor. Un servicio stateless escala mejor, es más resiliente, y no tienes que preocuparte por la afinidad.
  • Cuando quieres balanceo uniforme. Las sticky sessions pueden desbalancear la carga si hay clientes muy activos. Un cliente que hace 1000 peticiones por segundo va siempre a la misma instancia, mientras que otra instancia está infrautilizada.
  • Cuando tienes muchos clientes con largas sesiones. La distribución puede volverse muy desigual con el tiempo.
  • En despliegues blue/green o canary. Si cambias las instancias, las cookies apuntan a instancias que ya no existen y los usuarios pierden la sesión.

Mi recomendación: si puedes hacer tu servicio stateless, hazlo. Si no puedes (por ejemplo, con aplicaciones legacy), las sticky sessions son un mal menor, pero intenta ponerles un maxAge razonable para que la afinidad se renueve periódicamente y el balanceo no se degrade con el tiempo.

Servicio con servidores externos

No todo tiene que ser Docker. Puedes configurar servicios que apunten a servidores externos usando el File provider:

http:
  services:
    api-externa:
      loadBalancer:
        servers:
          - url: "https://api.ejemplo.com"
        passHostHeader: false

El parámetro passHostHeader: false hace que Traefik no pase el Host original del cliente, sino que use el del servidor de destino. Esto es útil cuando el servidor externo espera un Host específico y no el de tu dominio.

Servidores externos con TLS

Cuando tu servidor externo usa HTTPS, puedes configurar la comunicación entre Traefik y el backend:

http:
  services:
    api-externa:
      loadBalancer:
        servers:
          - url: "https://api.ejemplo.com"
        serversTransport: miap@file
        passHostHeader: false

serversTransports:
  miap@file:
    insecureSkipVerify: false
    rootCAs:
      - /etc/ssl/certs/ca-certificates.crt
    certificates:
      - certFile: /etc/traefik/certs/client.crt
        keyFile: /etc/traefik/certs/client.key

Esto te permite:

  • insecureSkipVerify: si lo pones a true, Traefik no valida el certificado del backend. Útil en desarrollo o con certificados autofirmados, pero no lo uses en producción si puedes evitarlo. Es como ir sin cinturón de seguridad.
  • rootCAs: lista de autoridades certificadoras para validar el certificado del backend.
  • certificates: certificado y clave del cliente para autenticación mutua TLS (mTLS), si el backend lo requiere.

Múltiples servidores externos

Puedes combinar servidores internos y externos en el mismo servicio:

http:
  services:
    api-hibrida:
      loadBalancer:
        servers:
          - url: "http://192.168.1.10:3000"
          - url: "http://192.168.1.11:3000"
          - url: "https://backup.cloud.ejemplo.com"
        healthCheck:
          path: /health
          interval: 10s
          timeout: 5s

Esto te permite tener, por ejemplo, servidores locales como primarios y un servidor en la nube como respaldo. Pero ojo: los health checks tienen que funcionar igual para todos. Si el servidor en la nube tiene un path de health diferente, mejor lo pones en un servicio aparte.

Failover y alta disponibilidad

¿Qué pasa cuando todas las instancias de un servicio fallan? Traefik devuelve un error 503. Pero puedes hacer más cosas para que la experiencia del usuario no sea tan catastrófica.

Circuit breaker

Traefik incluye un middleware de circuit breaker que puede proteger tu sistema de fallos en cascada. La idea es sencilla: si un servicio empieza a fallar, el circuito se abre y Traefik deja de enviarle tráfico durante un tiempo, dándole oportunidad de recuperarse.

http:
  middlewares:
    cb-mi-servicio:
      circuitBreaker:
        expression: "NetworkErrorRatio() > 0.5 || ResponseCodeRatio(500, 600, 0, 600) > 0.3"
        checkPeriod: 10s
        fallbackDuration: 30s
        recoveryDuration: 10s

Vamos a desgranar esto:

  • expression: una expresión que determina cuándo abrir el circuito. Puedes usar funciones como:
  • NetworkErrorRatio(): ratio de errores de red (timeouts, conexiones rechazadas).
  • ResponseCodeRatio(desde, hasta, baseDesde, baseHasta): ratio de códigos de respuesta en un rango.
  • LatencyAtQuantileMS(50.0): latencia en milisegundos en un percentil.
  • checkPeriod: cada cuánto se evalúa la expresión.
  • fallbackDuration: tiempo que el circuito se mantiene abierto antes de pasar a semi-abierto.
  • recoveryDuration: tiempo en semi-abierto antes de cerrarse completamente.

En el ejemplo: «abre el circuito si más del 50% de las peticiones tienen errores de red, o si más del 30% devuelven errores 5xx». Cuando el circuito se abre, Traefik deja de enviar tráfico al servicio y devuelve un error 503 directamente. Después de 30 segundos, prueba con una petición. Si funciona, cierra el circuito. Si no, espera otros 30 segundos.

Esto te puede ahorrar más de un disgusto porque evita el efecto «dogpiling»: cuando un servicio empieza a ir lento, los clientes hacen timeout y reintentan, aumentando la carga y empeorando la situación. El circuit breaker corta ese círculo vicioso.

Retry middleware

Otro middleware útil para failover es el de reintentos:

labels:
  - "traefik.http.middlewares.retry-miapp.retry.attempts=3"
  - "traefik.http.routers.miapp.middlewares=retry-miapp"

Este middleware reintenta la petición hasta attempts veces si falla. Es útil para errores transitorios: un timeout, una conexión caída que se recupera rápido, un health check que no ha dado tiempo a actualizarse.

Pero ojo: los reintentos pueden empeorar las cosas si lo que falla es la capacidad del servicio. Si el servicio está saturado, reintentar solo añade más carga. Por eso es buena idea combinar retry con circuit breaker.

Una advertencia importante: los reintentos solo funcionan con métodos de petición idempotentes (GET, HEAD, PUT, DELETE). Traefik no reintenta automáticamente peticiones POST o PATCH porque podrían duplicar efectos secundarios (como crear un recurso dos veces).

Esquema de failover completo

Combinando health checks, circuit breaker y retry, puedes montar un sistema bastante robusto:

http:
  services:
    mi-servicio:
      loadBalancer:
        servers:
          - url: "http://192.168.1.10:3000"
          - url: "http://192.168.1.11:3000"
          - url: "http://192.168.1.12:3000"
        healthCheck:
          path: /health
          interval: 5s
          timeout: 3s

  middlewares:
    proteccion:
      circuitBreaker:
        expression: "NetworkErrorRatio() > 0.3 || ResponseCodeRatio(500, 600, 0, 600) > 0.2"
        checkPeriod: 10s
        fallbackDuration: 30s
        recoveryDuration: 10s
      retry:
        attempts: 2

  routers:
    miapp:
      rule: "Host(`app.dominio.com`)"
      service: mi-servicio
      middlewares:
        - proteccion

En este esquema:

  1. Los health checks detectan instancias caídas y las sacan del pool.
  2. Si el servicio en general empieza a degradarse (más del 30% de errores de red o 20% de errores 5xx), el circuit breaker abre el circuito.
  3. Las peticiones que fallan se reintentan hasta 2 veces contra instancias sanas.
  4. Combinado, tienes un sistema que aguanta bastante bien los fallos parciales.

Página personalizada de error cuando todo falla

Si todas las instancias están caídas y el circuit breaker está abierto, Traefik devuelve un 503. Puedes mejorar la experiencia con una página de error personalizada usando el middleware ErrorPage que vimos en el capítulo de middlewares básicos:

http:
  services:
    pagina-error:
      loadBalancer:
        servers:
          - url: "http://servidor-estatico:80"

  middlewares:
    errores:
      errors:
        status:
          - "500-599"
        service: pagina-error
        query: /error-503.html

Así, cuando el servicio falle, el usuario ve una página bonita en lugar de un «503 Service Unavailable» genérico. Y tú ganas tiempo para arreglar el problema sin que los usuarios piensen que tu web se ha roto para siempre.

Servicio de respaldo con Weighted Round-Robin (WRR)

Aunque Traefik no expone una estrategia «least connections», sí tiene un tipo de servicio llamado Weighted Round-Robin (WRR) que te permite distribuir tráfico entre varios servicios con pesos diferentes:

http:
  services:
    servicio-principal:
      loadBalancer:
        servers:
          - url: "http://192.168.1.10:3000"
          - url: "http://192.168.1.11:3000"
          - url: "http://192.168.1.12:3000"
        healthCheck:
          path: /health
          interval: 10s
          timeout: 5s

    servicio-respaldo:
      loadBalancer:
        servers:
          - url: "http://backup-slow.ejemplo.com:3000"
        healthCheck:
          path: /health
          interval: 30s
          timeout: 10s

    mi-app:
      weighted:
        services:
          - name: servicio-principal
            weight: 9
          - name: servicio-respaldo
            weight: 1

En este ejemplo, el 90% del tráfico va al servicio principal (tres servidores rápidos) y el 10% al servicio de respaldo (un servidor más lento en otro lado). Si todo el servicio principal cae, el peso se ajusta automáticamente y el 100% del tráfico va al respaldo.

Esto es muy útil para:

  • Despliegues blue/green: pones peso 10 al azul (versión actual) y peso 0 al verde (nueva versión). Cuando quieres migrar, cambias los pesos gradualmente: 9-1, 5-5, 1-9, 0-10. Sin cortes de servicio.
  • Canary releases: peso 99 para la versión estable y peso 1 para la canary. Si la canary se comporta bien, aumentas el peso.
  • Multi-cloud: tienes servidores en dos proveedores de cloud y quieres distribuir el tráfico según costes o capacidad.

Cómo hacer un despliegue blue/green con WRR

Voy a dejarte un ejemplo práctico de despliegue blue/green con Traefik. La idea es que tienes dos grupos de servidores (azul y verde) y puedes cambiar el tráfico entre ellos sin reiniciar nada:

http:
  services:
    app-azul:
      loadBalancer:
        servers:
          - url: "http://192.168.1.10:3000"
          - url: "http://192.168.1.11:3000"

    app-verde:
      loadBalancer:
        servers:
          - url: "http://192.168.1.20:3000"
          - url: "http://192.168.1.21:3000"

    app:
      weighted:
        services:
          - name: app-azul
            weight: 10
          - name: app-verde
            weight: 0

Cuando despliegas la nueva versión en el grupo verde, cambias los pesos a 0 para azul y 10 para verde. Todo el tráfico se redirige sin cortes. Si algo falla, vuelves a poner peso 10 a azul.

Y lo mejor: como es configuración dinámica, puedes cambiar los pesos modificando el archivo del File provider y Traefik lo recoge al instante, sin reinicios.

Advertencias y buenas prácticas

Después de años liándola con balanceadores, te dejo una lista de cosas que he aprendido a base de golpes:

1. El health check no es un ping

No hagas un health check que solo devuelva 200. Haz que compruebe algo útil: conexión a la base de datos, cola de mensajes, espacio en disco. Un health check que siempre devuelve ok es peor que no tener health check, porque te da una falsa sensación de seguridad.

2. Las sticky sessions y el escalado no se llevan bien

Si escalas tu servicio a 10 instancias y tienes sticky sessions, el balanceo deja de ser uniforme al instante. Los clientes con más actividad acumulan más peticiones en la misma instancia. Si esperas tráfico desigual, plantéate seriamente usar una sesión compartida (Redis, Memcached) y olvidarte de las sticky sessions.

3. Cuidado con el nombre de la cookie

Si tienes varios servicios con sticky sessions, cada uno debe tener un nombre de cookie diferente. Si dos servicios usan el mismo nombre, las cookies se sobrescriben y los clientes saltan de una instancia a otra aleatoriamente. Te recomiendo un naming como APP_SESSION, API_SESSION, etc.

4. Los health checks en redes lentas

Si tus servidores están en redes con latencia alta o variable, ajusta el timeout del health check. Un timeout de 3 segundos puede ser demasiado justo si el servidor está en otro continente. Ponlo a 10 segundos y ajusta según veas.

5. No abuses de los reintentos

El middleware retry parece inocente, pero puede ser devastador si lo combinas con peticiones lentas. Si tienes 100 usuarios haciendo peticiones que tardan 30 segundos, y cada petición se reintenta 3 veces, has multiplicado la carga por 3. El servicio se hunde más rápido. Usa retry con moderación y siempre con un circuit breaker.

6. Servidores externos con nombres DNS

Si pones URLs con nombres DNS en la configuración de servidores, ten en cuenta que Traefik resuelve el DNS al iniciar y lo cachea. Si el DNS cambia, Traefik no se entera hasta que reinicies. Para servidores externos que cambian de IP dinámicamente, mejor usa un proxy DNS intermedio o un servicio de descubrimiento.

7. El puerto del servicio no es el puerto del host

Vale, ya lo he dicho antes, pero es tan importante que lo repito: cuando configures el puerto del servicio en Docker, usa el puerto interno del contenedor, no el puerto mapeado en el host. Si en tu docker-compose pone ports: "8080:3000", el puerto que le tienes que decir a Traefik es 3000.

8. Los health checks consumen recursos

Cada health check es una petición HTTP a tu servicio. Si tienes 20 instancias con health checks cada 5 segundos, son 4 peticiones por segundo solo de health checks. Si tu endpoint de health consulta la base de datos, son 4 consultas por segundo adicionales. En servicios grandes, esto se nota. Diseña el endpoint de health para que sea ligero.

Ejemplo práctico completo: API REST con balanceo, health checks, failover y sticky sessions

Voy a dejarte un ejemplo completo que integra todo lo que hemos visto. Es un docker-compose.yml con Traefik y una API REST escalada a 3 instancias, con health checks, sticky sessions, circuit breaker y página de error personalizada.

services:
  traefik:
    image: traefik:v3.7
    container_name: traefik
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./traefik.yml:/etc/traefik/traefik.yml:ro
      - ./config/dynamic.yml:/etc/traefik/dynamic.yml:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./letsencrypt:/letsencrypt
    networks:
      - traefik-net

  api:
    image: miapi:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.api.rule=Host(`api.dominio.com`)"
      - "traefik.http.routers.api.entrypoints=websecure"
      - "traefik.http.routers.api.tls=true"
      - "traefik.http.routers.api.tls.certresolver=letsencrypt"
      - "traefik.http.routers.api.middlewares=cb-api,retry-api"
      # Servicio con puerto explícito
      - "traefik.http.services.api.loadbalancer.server.port=3000"
      # Health check avanzado
      - "traefik.http.services.api.loadbalancer.healthcheck.path=/health/ready"
      - "traefik.http.services.api.loadbalancer.healthcheck.interval=10s"
      - "traefik.http.services.api.loadbalancer.healthcheck.timeout=3s"
      - "traefik.http.services.api.loadbalancer.healthcheck.hostname=api.local"
      # Sticky sessions para WebSockets
      - "traefik.http.services.api.loadbalancer.sticky.cookie.name=API_SESSION"
      - "traefik.http.services.api.loadbalancer.sticky.cookie.httponly=true"
      - "traefik.http.services.api.loadbalancer.sticky.cookie.secure=true"
      - "traefik.http.services.api.loadbalancer.sticky.cookie.samesite=lax"
    networks:
      - traefik-net

  pagina-error:
    image: nginx:alpine
    volumes:
      - ./error-pages:/usr/share/nginx/html:ro
    networks:
      - traefik-net

networks:
  traefik-net:
    name: traefik-net

Y la configuración dinámica con los middlewares de protección:

# config/dynamic.yml
http:
  middlewares:
    cb-api:
      circuitBreaker:
        expression: "NetworkErrorRatio() > 0.3 || ResponseCodeRatio(500, 600, 0, 600) > 0.2"
        checkPeriod: 10s
        fallbackDuration: 30s
        recoveryDuration: 10s

    retry-api:
      retry:
        attempts: 2

    error-page:
      errors:
        status:
          - "500-599"
        service: pagina-error
        query: /503.html

Puedes escalar este servicio:

docker compose up -d --scale api=3

Y Traefik balanceará entre las 3 instancias, comprobando que cada una responde en /health/ready, manteniendo la sesión del cliente en la misma instancia gracias a la cookie, protegiendo el servicio con circuit breaker, y mostrando una página de error personalizada si todo falla.

Ejemplo práctico 2: Base de datos MySQL con balanceo TCP

No todo son APIs REST. A veces necesitas balancear tráfico TCP directamente. Aquí tienes un ejemplo con MySQL:

labels:
  - "traefik.tcp.routers.mysql.rule=HostSNI(`mysql.dominio.com`)"
  - "traefik.tcp.routers.mysql.entrypoints=mysql"
  - "traefik.tcp.routers.mysql.tls=true"
  - "traefik.tcp.services.mysql.loadbalancer.server.port=3306"
  - "traefik.tcp.services.mysql.loadbalancer.healthcheck.interval=10s"
  - "traefik.tcp.services.mysql.loadbalancer.healthcheck.timeout=5s"

Y si usas File provider con servidores externos:

tcp:
  services:
    mysql-cluster:
      loadBalancer:
        servers:
          - address: "192.168.1.10:3306"
          - address: "192.168.1.11:3306"
          - address: "192.168.1.12:3306"
        healthCheck:
          interval: 10s
          timeout: 5s

  routers:
    mysql:
      rule: "HostSNI(`mysql.dominio.com`)"
      service: mysql-cluster
      tls: {}

Con esto, tienes un balanceador TCP para tu cluster de MySQL con health checks que detectan si un nodo se cae. Y todo gestionado por Traefik.

Buenas prácticas

  1. Siempre especifica el puerto del servicio. No dejes que Traefik lo adivine. Te ahorrarás problemas cuando cambies de imagen o cuando el contenedor exponga varios puertos.
  2. Usa health checks en producción siempre. Sin ellos, si una instancia falla, Traefik seguirá enviándole tráfico y tus usuarios verán errores. Y no uses un health check de mentira: haz que compruebe algo real.
  3. Evita sticky sessions si puedes. Son cómodas pero impiden un balanceo uniforme y pueden dar problemas si una instancia se cae (el usuario pierde la sesión). Si puedes usar Redis o una base de datos para compartir sesiones, hazlo.
  4. Para servicios críticos, combina health checks con circuit breaker. Un servicio que responde lentamente es peor que uno caído, porque mantiene ocupados los hilos de Traefik y puede degradar el rendimiento general.
  5. Usa el File provider para servicios externos. No intentes enrutar a servidores fuera de Docker con labels. El File provider te da mucho más control y puedes definir transports, certificados y configuraciones avanzadas.
  6. Prueba los health checks antes de ponerlos en producción. Haz una petición manual al endpoint de health desde fuera del contenedor para asegurarte de que responde como esperas. He visto configuraciones de health check que apuntaban a rutas que no existían.
  7. Documenta la configuración de los servicios. Cuando tengas 20 servicios con diferentes health checks, sticky sessions y circuit breakers, agradecerás tenerlo todo documentado en el repositorio.

Conclusión

Los servicios y el balanceo de carga son el eslabón final de la cadena de enrutamiento de Traefik. Con health checks bien configurados, sticky sessions controladas, circuit breaker para proteger tus servicios de fallos en cascada, y la capacidad de balancear entre múltiples instancias, tienes todo lo necesario para montar servicios resilientes y profesionales.

La clave está en diseñar pensando en el fallo. Asume que las instancias van a caerse, que las conexiones van a fallar, que los servicios se van a ralentizar. Y configura Traefik para que todo eso se gestione de forma automática, sin que tú tengas que levantarte a las 3 de la madrugada.

En el próximo capítulo veremos la Configuración dinámica con archivos, una forma de centralizar toda la configuración y reutilizarla entre servicios sin depender de labels de Docker. Dale caña.

Más información

Deja una respuesta