inicio / blog / red
Publicado: 18 de diciembre de 2025 12 min de lectura red
Arquitectura de servidor dedicado: 48 jugadores por contenedor

Arquitectura de servidor dedicado: 48 jugadores por contenedor

Separamos el servidor de juego autoritativo del backend, reescribimos el matchmaking y la asignación de salas, y medimos el coste real por jugador concurrente.

Noche de playtest: 40 jugadores, un proceso, un crash

En febrero del año pasado hicimos un playtest cerrado con 40 personas, un viernes a las 21:00. El juego giraba en torno a partidas de 8 jugadores y esa noche había cinco partidas corriendo a la vez. A las 23:40 un tercio de los jugadores se cayó de golpe. Lo único que quedó fue una línea: OutOfMemoryException, y debajo cinco valores distintos de matchId.

La causa no era un bug, era la arquitectura. Las cinco partidas vivían dentro de un mismo proceso, una única build headless de Unity. Una fuga de memoria en una partida se llevó por delante a las otras cuatro. Lo que aprendimos esa noche es que el aislamiento por sala no es una optimización, es un requisito de corrección. También probamos a poner un tope de memoria por proceso, pero lo que moría seguía siendo el proceso compartido.

Durante los tres meses siguientes rehicimos la arquitectura del servidor de juego desde cero. Las cifras y las alternativas descartadas que siguen son mediciones de ese periodo, todas de un prototipo de shooter competitivo de 8 jugadores. El motor es Unity 2022 LTS y el transporte es nuestro propio canal fiable sobre UDP.

Por qué el servidor autoritativo no se negocia

En la primera versión el cliente calculaba el movimiento y le comunicaba el resultado al servidor. En el segundo playtest, tres de los 40 jugadores editaron paquetes y duplicaron su velocidad de carrera. La parte rota no era el código de detección, era la arquitectura: si el cliente escribe posiciones, el cheat se convierte en una carrera de validación y esa carrera se pierde. Probamos a imponer un límite de velocidad en el servidor, pero cada nueva habilidad de movimiento obligaba a reajustar ese límite.

En un servidor autoritativo el cliente solo envía input. En nuestro caso es un struct InputCommand: número de tick, vector de movimiento, ángulos de cámara y bits de botones, 14 bytes en total. El servidor ejecuta la simulación con un tick fijo de 30 Hz y difunde el estado. El cliente sigue prediciendo en local, pero la autoridad nunca se mueve hacia él.

El coste es real: ahora el servidor ejecuta la física de todos los jugadores. En nuestras mediciones una sesión de 8 jugadores consumía en torno al 38% de un núcleo a 3,4 GHz, incluyendo el character controller, el hit-scan y el filtrado de visibilidad. Ese único número acabó siendo la entrada de todos los cálculos de escalado y de coste posteriores. Además ese 38% es una media, no un pico: en tiroteos concurridos vimos la misma sesión llegar al 55%.

El servidor de juego y el backend no son lo mismo

Un servidor de juego guarda estado, vive poco y, cuando muere, se lleva exactamente una partida. El backend de juego, es decir cuentas, inventario, progresión y lista de amigos, no guarda estado de sesión, vive mucho y, cuando muere, afecta a todo el mundo. Tener los dos en el mismo proceso nos ahorró dos semanas en la primera versión y nos costó dos meses después. La regla que uso: si un dato sobrevive a la partida, no es asunto del servidor de juego.

La línea la trazamos así: durante la partida el servidor de juego no escribe en ninguna base de datos persistente. Al terminar, se envía por POST al backend un único cuerpo MatchResult con puntuación, duración, daño y objetos usados por jugador. En lugar de ocho servidores escribiendo en la tabla de inventario en el mismo instante, tenemos unos cientos de peticiones pequeñas por minuto que se pueden encolar. Para los jugadores que se caen a mitad de partida enviamos además un registro intermedio cada 30 segundos, así un crash nunca borra el progreso de nadie.

La autenticación también es cosa del backend. Al iniciar sesión el jugador recibe un sessionTicket válido durante 120 segundos y se lo entrega al servidor de juego junto con la dirección de la sala; el servidor lo valida contra el backend en una sola llamada. El servidor de juego no tiene ninguna credencial de base de datos. Un servidor de juego comprometido no llega al inventario, como mucho arruina esa partida concreta.

Cómo funcionan la cola de matchmaking y la asignación

Para nosotros el matchmaking es un único servicio: MatchQueue. Cuando un jugador entra en la cola se registra con su puntuación de habilidad, su región y su id de grupo. El emparejador recorre la cola cada 2 segundos, agrupa a los jugadores de la misma región dentro de una banda de ±100 puntos y ensancha esa banda 50 puntos cada 5 segundos. A los 45 segundos la banda toca techo y el tiempo de espera gana a la calidad.

Una vez encontrados los ocho jugadores, el trabajo pasa a RoomAllocator. Probamos la alternativa: levantar un contenedor nuevo para cada partida. La build headless de Unity necesitaba entre 6 y 9 segundos para cargar la escena y quedar lista, y ese tiempo basta para deshacer un grupo ya formado. En su lugar mantenemos cuatro sesiones vacías en caliente por región, y la asignación termina en menos de 300 ms.

Si el pool en caliente se vacía, las sesiones nuevas nacen en segundo plano y los jugadores ven, en el peor de los casos, esos 8 segundos. No fijes el tamaño del pool: entre las 21:00 y las 24:00 nuestro número de jugadores concurrentes es seis veces el de la franja diurna, así que el pool pasa de 4 a 12 según un perfil horario. Con un pool fijo, o alargas la cola de noche o pagas máquinas ociosas de día.

Sesiones por contenedor y cómo funciona el escalado

Un proceso de servidor de juego gestiona exactamente una partida y termina cuando esa partida acaba. El consumo medido es de 180 MB de RSS por sesión y 0,42 vCPU de media. En un nodo de 2 vCPU y 4 GB caben cómodamente cuatro sesiones, y seis si aprietas. Seis sesiones por ocho jugadores son 48 jugadores concurrentes por nodo. Intentamos meter más: en la octava sesión el tick empezó a superar los 33 ms y el movimiento se degradaba de forma visible.

No ates el escalado al porcentaje de CPU. La métrica correcta es el número de sesiones libres: cuando freeSessions baja de 8 se pide un nodo nuevo, y cuando sube por encima de 24 se marca un nodo para drenaje. Nuestra primera versión, guiada por CPU, intentaba apagar nodos que todavía tenían jugadores cada vez que terminaban partidas y la carga bajaba. Mantén esta métrica por región, o la capacidad sobrante en Fráncfort tapará la escasez en Estambul.

El apagado siempre pasa por el drenaje. El nodo deja de recibir asignaciones y las partidas que tiene encima terminan por muerte natural. Como en nuestro caso una partida dura 20 minutos como máximo, el peor drenaje son también 20 minutos. Por la misma razón usamos máquinas spot y preemptible solo para el pool en caliente; un nodo con partidas en vivo no debería ser el barato.

El logging y la recogida de crashes se montan en esta capa. Cada proceso escribe en stdout un objeto JSON por línea, y cada línea lleva sessionId, matchId, buildId y el número de tick. Como en un crash el proceso desaparece, el recolector tiene que correr de forma independiente y sacar fuera Player.log y el volcado de memoria antes de que el contenedor muera. Hasta que montamos eso, nunca supimos la causa de tres crashes distintos. El volumen de log ronda las 400 líneas por sesión y minuto, una cifra pequeña como para guardarla sin muestreo.

Región, coste y hasta dónde llega un relay

La región se elige por ping, no por preferencia del jugador. Tiempos de ida y vuelta medidos desde Bursa: Estambul 18-22 ms, Fráncfort 48-55 ms, Norte de Virginia 128-140 ms. Con un tick de 30 Hz cada tick dura 33 ms, así que Fráncfort es jugable y Virginia no lo es, al menos para un shooter competitivo. Al entrar, el cliente lanza un sondeo UDP a tres regiones y se pone en cola con las dos mejores. Si los miembros del grupo están en ciudades distintas, gana la mejor región común y decide el peor ping del grupo.

Calcula el coste por jugador concurrente, no por máquina. Un nodo de 2 vCPU y 4 GB nos sale por unos 0,09 dólares la hora y lleva 48 jugadores, es decir 0,0019 dólares por hora de jugador cuando está lleno. Pero lleno no se está nunca: nuestra ocupación media es del 35% y, sumando el pool en caliente y la capacidad mínima por región, la cifra real sube a 0,006 dólares. Con 500 jugadores concurrentes y un perfil de cuatro horas de pico al día, eso son unos 270 dólares al mes. El ancho de banda se quedó en el 8% de la factura total, con unos 20 kbps de subida por jugador.

Para un equipo de tres personas esa cifra es pequeña al lado del gasto de verdad. El servicio de asignación, la lógica de drenaje, la tubería de logs y la recogida de crashes nos costaron cerca de un mes de trabajo a tiempo completo. Si estás haciendo un cooperativo no competitivo de 4 a 6 jugadores y tu concurrencia se queda en unos pocos cientos, un listen server sobre un relay es suficiente. El relay resuelve el NAT, la autoridad se queda en el host y el único precio que pagas es la ventaja del host.

El umbral que aplico es simple: si algo toca las tablas de clasificación, el ranking o la economía, necesitas servidor dedicado y autoritativo; si no, empieza con un relay. Lo único que abarata el cambio posterior es mantener desde el principio la lógica de juego en una forma que pueda correr en el servidor. Nuestra clase GameSession no depende de ningún MonoBehaviour, y el mismo código corre tanto en listen server como en la build headless. Si haces esa separación el primer día, aplazar la decisión no cuesta casi nada.

← Todas las entradas