Escalando WebSockets: Cómo manejar 1 millón de conexiones concurrentes
El "problema C10K" es historia antigua. Hoy en día, hablamos del problema C1M: manejar 1 millón de conexiones concurrentes en un solo servidor.
Si estás construyendo una aplicación en tiempo real —ya sea un chat, un feed de deportes en vivo o una herramienta de edición colaborativa— eventualmente te toparás con un muro. Tu servidor colapsará, las nuevas conexiones darán timeout y tu latencia se disparará. Y el culpable rara vez es el código de tu aplicación. Generalmente es la configuración del Sistema Operativo.
En este artículo, veremos los pasos exactos para ajustar un servidor Linux y manejar 1 millón de conexiones WebSocket concurrentes. Cubriremos descriptores de archivos, puertos efímeros y la arquitectura necesaria para escalar más allá de un solo nodo.
El cuello de botella NO es Node.js (Usualmente)
Muchos desarrolladores asumen que Node.js (o Python/Ruby) es demasiado lento para manejar millones de conexiones. Si bien es cierto que un solo hilo tiene límites, el primer cuello de botella que encontrarás es casi siempre el Sistema Operativo.
Por defecto, Linux está configurado para computación de propósito general, no para mantener millones de conexiones TCP persistentes.
1. El error "Too Many Open Files"
En Linux, todo es un archivo. Un socket es un archivo. Un archivo en disco es un archivo. Cuando un proceso abre una conexión, consume un descriptor de archivo (FD).
Revisa tu límite actual:
ulimit -n # Salida: 1024 (usualmente)
Si intentas abrir 1,025 conexiones, tu aplicación fallará con EMFILE: too many open files.
La Solución:
Necesitas aumentar tanto el límite del sistema como el límite por proceso.
Edita /etc/sysctl.conf:
fs.file-max = 2097152
Edita /etc/security/limits.conf:
* soft nofile 1048576 * hard nofile 1048576 root soft nofile 1048576 root hard nofile 1048576
Aplica los cambios con sysctl -p. Ahora tu SO permite suficientes descriptores de archivo. Pero apenas estamos empezando.
2. La trampa del Agotamiento de Puertos Efímeros
Este es el "asesino silencioso" más común en arquitecturas WebSocket de alta escala.
Cuando un cliente se conecta a un servidor, usa una IP de origen y un puerto de origen. Cuando tu servidor se conecta a una base de datos backend u otro servicio, él se convierte en el cliente y usa un puerto local.
Solo hay 65,535 puertos disponibles. Si tienes un balanceador de carga o proxy frente a tu servidor WebSocket, podrías quedarte sin puertos para las conexiones salientes.
La Tupla TCP
Una conexión TCP se identifica por una tupla de 4 elementos:
{IP Origen, Puerto Origen, IP Destino, Puerto Destino}
Si tu balanceador de carga se conecta a tu servidor WebSocket, y ambos están en IPs específicas, estás limitado por el número de puertos de origen disponibles (aprox 60k).
La Solución:
-
Aumenta el rango de puertos locales:
# /etc/sysctl.conf net.ipv4.ip_local_port_range = 1024 65535 -
Habilita la Reutilización de TCP:
net.ipv4.tcp_tw_reuse = 1Esto permite al SO reclamar puertos en estado
TIME_WAITmás rápidamente.
3. Uso de Memoria: El costo real de un Socket
¿Cuánta RAM consumen 1 millón de conexiones?
En el pasado, una conexión podía costar unos cuantos kilobytes de memoria del kernel. En kernels modernos, es mucho más eficiente, pero la memoria de la aplicación es el factor más grande.
Si usas Node.js, cada objeto Socket ocupa espacio en el heap.
- Socket Vacío: ~2-4 KB
- Con Metadatos (User ID, info del canal): ~10 KB
Matemáticas:
1,000,000 conexiones * 10 KB = 10 GB de RAM.
Esto es factible en un servidor moderno, pero debes tener cuidado.
Tip de Optimización:
No guardes el objeto de Usuario completo en el socket. Guarda solo el userId y busca los detalles en Redis cuando sea necesario.
// MAL socket.user = { id: 1, name: "Alice", email: "...", bio: "..." }; // BIEN socket.userId = 1;
4. El Lag del Event Loop
Node.js es single-threaded. Si tienes 1 millón de usuarios conectados e intentas transmitir un mensaje a todos ellos, bloquearás el event loop.
// NO HAGAS ESTO users.forEach(user => { user.socket.send("¡Hola!"); });
Enviar 1 millón de paquetes toma tiempo. Si toma 0.01ms por envío, son 10 segundos de tiempo de bloqueo. Tu servidor no responderá durante 10 segundos.
La Solución: Batching y Workers
- No transmitas a todos a la vez. Divide tus transmisiones en trozos (chunks).
- Usa múltiples procesos. Usa el módulo
clusterde Node.js o corre múltiples instancias de contenedores.
5. Arquitectura para Escala Infinita
Un solo servidor puede manejar 1M de conexiones con ajustes. ¿Pero qué pasa con 10M? ¿100M?
Necesitas una Arquitectura WebSocket Distribuida.
El Enfoque por Capas
- Balanceador de Carga (Nginx/HAProxy): Termina SSL, distribuye conexiones a nodos backend.
- Nodos WebSocket (Node.js/Go): Mantienen las conexiones activas. Son "stateful" en el sentido de que tienen el socket, pero "stateless" respecto a la lógica de negocio.
- Capa Pub/Sub (Redis/NATS): El pegamento que mantiene todo unido.
Cómo funciona Pub/Sub
Cuando el Usuario A (conectado al Servidor 1) quiere enviar un mensaje al Usuario B (conectado al Servidor 2):
- Usuario A envía mensaje al Servidor 1.
- Servidor 1 publica el mensaje a un canal de Redis:
publish('user:B', payload). - Servidor 2 (y todos los otros servidores) están suscritos a Redis.
- Servidor 2 recibe el mensaje, verifica si el Usuario B está conectado localmente.
- Servidor 2 envía el mensaje al Usuario B.
Esta arquitectura te permite agregar servidores horizontalmente sin límites.
6. Probando tus Límites
No esperes a que producción falle. Necesitas probar esto.
Herramientas como Artillery o k6 son geniales, pero para concurrencia masiva, podrías necesitar una flota de máquinas cliente.
Tsung es una herramienta de pruebas de carga distribuida basada en Erlang que es excelente para simular millones de usuarios concurrentes.
Conclusión
Manejar 1 millón de conexiones es una medalla de honor para los ingenieros de sistemas. Requiere salir de la zona de confort de "npm install" y sumergirse en las entrañas de Linux.
Checklist Resumen:
- Aumentar
ulimit(Open Files). - Ajustar
sysctl(Rango de puertos, reutilización TCP). - Optimizar Memoria de Aplicación (Guardar IDs, no Objetos).
- Usar un backend Pub/Sub (Redis) para escalado horizontal.
La próxima vez que tu servidor colapse bajo carga, no solo aumentes el tamaño de la instancia. Mira el kernel. La respuesta suele estar ahí.
Explora herramientas relacionadas
Prueba estas herramientas gratuitas de Pockit