Sandbox standalone para entender, en un ejemplo minimo, dos cosas fundamentales del balanceo de carga sobre un servidor que expone una API REST y un servidor WebSocket en el mismo proceso:
- Como Traefik descubre y balancea automaticamente varias replicas de un
mismo contenedor Node, sin tocar ninguna configuracion cada vez que
escalas (
docker compose up --scale server=N). - Por que escalar una API REST (sin estado) es trivial, pero escalar un
servidor WebSocket (con estado por conexion) no lo es: un Socket.IO
multi-instancia sin adapter compartido rompe el broadcast a rooms
cuando los clientes quedan conectados a instancias distintas — y como
@socket.io/redis-adapterlo resuelve.
Node.js corre con un unico hilo para tu codigo JS (el event loop): un
solo proceso node server.js, sin importar cuantos nucleos tenga la
maquina, solo va a usar un nucleo de CPU. Si el servidor recibe trafico
suficiente como para saturar ese nucleo (muchas requests, mucho trabajo de
CPU por request, muchas conexiones de socket activas), agregar mas RAM o
un CPU mas rapido (escalar verticalmente) no soluciona el cuello de
botella — el proceso sigue limitado a un solo nucleo.
La forma de aprovechar el resto de los nucleos es correr varias
instancias del mismo proceso (una por nucleo, tipicamente) y repartir el
trafico entre todas — escalar horizontalmente. Eso es exactamente lo que
hace docker compose up --scale server=N: levanta N procesos Node
independientes, y Traefik se encarga de repartir las requests entre ellos
sin que tengas que tocar ninguna configuracion manual cada vez que cambias
N.
La contrapartida de tener N procesos independientes en vez de uno solo es que cualquier estado que antes vivia en la memoria de un unico proceso (rooms de Socket.IO, locks, cachés en memoria, etc.) deja de ser visible para las otras N-1 instancias — de ahi la segunda mitad de este sandbox.
"Escalado horizontal" es el mismo termino tanto si las N instancias corren en un solo host (este sandbox) como si corren repartidas en varios servidores/VMs — lo que lo distingue del escalado vertical es multiplicar unidades en vez de agrandar los recursos de una sola. La diferencia entre ambos casos es el techo, no el concepto:
- Un solo host: podes escalar horizontalmente hasta agotar los nucleos o la RAM de esa maquina. Pasado ese punto, cada instancia nueva compite por el mismo pool de CPU en vez de sumar capacidad real.
- Varios hosts: extiende el mismo escalado horizontal mas alla del
limite de una sola maquina. Ahi el balanceador ya no puede descubrir
instancias solo mirando el
docker.socklocal (como hace Traefik en este sandbox) — hace falta un orquestador que las conozca a todas (Docker Swarm, Kubernetes) o un balanceador externo a cada host.
Con una API REST, cada request es independiente: a Traefik le alcanza con hacer round robin entre las replicas, porque cualquier instancia puede responder cualquier request sin necesitar contexto de requests anteriores.
Con un servidor WebSocket (Socket.IO), el cliente abre una conexion persistente y queda pegado a una sola instancia durante toda la duracion de esa conexion:
- Si dos clientes en la misma "room" quedan conectados a instancias
distintas, un
io.to(room).emit(...)ejecutado en la instancia A no llega al cliente conectado a la instancia B — porque por defecto Socket.IO solo conoce los sockets conectados a su propio proceso. - La sticky session (cookie que Traefik usa para mantener a un cliente en la misma instancia) resuelve la reconexion/polling de un mismo cliente, pero no resuelve la comunicacion entre clientes que cayeron en instancias diferentes.
- La solucion es compartir el estado de las rooms entre instancias via un
medio externo (Redis pub/sub, con
@socket.io/redis-adapter), para que elemitde una instancia se propague a los sockets conectados a cualquier otra instancia.
Este sandbox reproduce ambos escenarios (paso 5 sin adapter, paso 6 con adapter) para verlos funcionar en vivo.
traefik-test/
├── README.md
└── app/ # standalone: compose, env y app minima Express + Socket.IO
├── docker-compose.yml # redis + server (N replicas) + traefik, lee .env de esta misma carpeta
├── .env # variables para traefik y server (ya viene con defaults)
├── .dockerignore
├── Dockerfile
├── package.json
├── server.js
└── public/index.html # cliente de prueba en el navegador
app/.env ya viene con defaults funcionales (TRAEFIK_PORT=3305,
USE_REDIS_ADAPTER=false), lo lee tanto el servicio traefik (sustitución dentro de docker-compose.yml)
como el servicio server (vía env_file).
cd traefik-test/app
docker compose up --buildTraefik publica TRAEFIK_PORT (3305 por defecto) al host — ese es el unico
puerto que hay que exponer desde afuera (por ejemplo, desde Apache).
Dashboard de Traefik (solo para esta prueba, no usar asi en produccion): http://localhost:8080/dashboard/
docker compose up --scale server=3 -dMirar los logs para confirmar que hay 3 instancias corriendo, cada una con su
propio INSTANCE_ID (el hostname del contenedor):
docker compose logs -f servercurl http://localhost:3305/healthRepetilo varias veces: en los logs vas a ver que las requests no siempre las atiende la misma instancia (round robin de Traefik).
Abri en el navegador: http://localhost:3305
- Abrila en dos contextos distintos (una pestaña normal + una de
incognito, o dos navegadores) para que no compartan la cookie de sticky
session y probablemente queden pegadas a instancias distintas. La pagina
te muestra a que
instancequedo conectada cada una. - En ambas, tocá "Join room" con el mismo nombre de room (
demo-roompor defecto). - Desde una de las dos, escribi un mensaje y tocá "Enviar".
Con USE_REDIS_ADAPTER=false: si las dos pestañas quedaron en instancias
distintas, el mensaje no llega a la otra pestaña — io.to(room).emit(...)
solo alcanza a los sockets conectados al mismo proceso que ejecuta el emit.
Editá app/.env:
USE_REDIS_ADAPTER=true
Y reiniciá (no hace falta rebuildear, solo cambio una variable de entorno):
docker compose up -dRepeti el paso 5: ahora el mensaje llega a todas las pestañas que estén
en esa room, sin importar a que instancia quedo conectada cada una — porque
server.js arma el adapter con @socket.io/redis-adapter, que usa Redis
(pub/sub) para propagar los eventos de rooms entre todas las instancias.
app/server.js:setupAdapter()es el unico lugar donde se decide si Socket.IO usa o no el adapter compartido. El resto del codigo (join-room, broadcast-message) es identico en ambos casos — la diferencia la hace únicamente el adapter.app/docker-compose.yml: las labels detraefik.*en el servicioserverson lo unico que Traefik necesita para descubrir, balancear y aplicar sticky sessions a las replicas — no hay ningun archivo de configuracion estatico que editar al escalar.
docker compose down -v