Apuntes DAM
Volver al inicio

Un balanceador de carga: turnos, pesos, menos conexiones, hash de IP y caídas

Ejercicio de PythonDifícilUnos 75 minutos

Simula un balanceador de carga como nginx o HAProxy: round robin, round robin ponderado suave, menos conexiones y hash de la IP, con conexiones que terminan, servidores que se caen y vuelven, 503 cuando no queda ninguno y el resumen del reparto.

  • Balanceo de carga y alta disponibilidad
  • Algoritmos de reparto
  • Afinidad de sesión (hash de IP)
  • Simulación por sucesos
  • Clases y diccionarios
  • Comprobaciones de salud

Enunciado

Cuando una aplicación web no cabe en un servidor, se ponen varios iguales detrás de un balanceador de carga (nginx, HAProxy o el de la nube) que reparte las peticiones. Así se escala (más servidores, más capacidad) y se gana disponibilidad (si uno cae, los demás siguen). La gracia está en cómo se reparte: por turnos, según la potencia de cada servidor, según cuántas conexiones tiene abiertas o, para que cada usuario caiga siempre en el mismo, según su IP.

La entrada tiene la configuración (algoritmo X y una línea servidor nombre peso por servidor), una línea --- y los sucesos en orden de tiempo (en segundos): t peticion IP duración, t caido servidor y t recuperado servidor. Tu programa dice a qué servidor va cada petición y, al final, cómo ha quedado el reparto.

Qué tiene que hacer el programa

  1. Configuración: algoritmo es turno, ponderado, menos-conexiones o hash-ip; cada servidor tiene un nombre que no se repite y un peso entero mayor que 0. Cualquier otra línea: Línea N: no se entiende «línea». Sin algoritmo o sin servidores: Hace falta un algoritmo y al menos un servidor y nada más. Si no, Algoritmo: X · servidores: web1 (peso 3), web2 (peso 1).
  2. Sucesos: una línea mal escrita (tiempo no entero, IP no válida, duración no entera mayor que 0, número de datos incorrecto) da Línea N: no se entiende «línea»; un tiempo menor que el anterior, Línea N: el tiempo no puede ir hacia atrás; un servidor que no existe, Línea N: servidor desconocido «x». Antes de cada suceso, terminan las conexiones cuyo fin (t + duración) es igual o anterior a su tiempo.
  3. Reparto, siempre entre los servidores disponibles: turno sigue el orden de la configuración desde el siguiente al último elegido (saltando los caídos); ponderado es el round robin ponderado suave de nginx (a cada disponible se le suma su peso a su contador, se elige el de contador mayor —el primero si empatan— y se le resta la suma de los pesos de los disponibles; los contadores vuelven a 0 cuando un servidor cae o vuelve); menos-conexiones elige el de menos conexiones abiertas (el primero si empatan); y hash-ip, el disponible número IP como entero % disponibles.
  4. Cada petición escribe t=T IP → servidor [web1:N web2:caído …] con las conexiones abiertas de todos tras asignarla, o t=T IP → 503 Service Unavailable (ningún servidor disponible). caido y recuperado escriben t=T x deja de responder o t=T x vuelve a responder (t=T x ya estaba caído o ya estaba disponible si no cambia nada); las conexiones abiertas de un servidor que cae terminan normalmente.
  5. Al final, Resumen: y, por servidor, nombre: N peticiones (P %), máximo M a la vez (1 petición; P sobre las peticiones atendidas, redondeado) y Sin servicio (503): K.

Entrada

algoritmo X y una línea servidor nombre peso por servidor.

Una línea ---.

Los sucesos: t peticion IP duración, t caido servidor o t recuperado servidor.

Datos de referencia

Algoritmos
AlgoritmoCómo eligePara qué
turnouno detrás de otroservidores iguales y peticiones parecidas
ponderadoproporcional al peso, sin rachasservidores de distinta potencia
menos-conexionesel que tiene menos conexiones abiertaspeticiones de duración muy distinta
hash-ipsiempre el mismo para la misma IPsesiones guardadas en el servidor

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Ponderado con una caída

Entrada

algoritmo ponderado
servidor web1 3
servidor web2 1
servidor web3 1
---
0 peticion 10.0.0.1 4
1 peticion 10.0.0.2 4
2 peticion 10.0.0.3 4
3 peticion 10.0.0.4 4
4 peticion 10.0.0.5 4
5 caido web1
5 peticion 10.0.0.6 2
6 peticion 10.0.0.7 2
7 recuperado web1
8 peticion 10.0.0.8 2

Salida por consola

Algoritmo: ponderado · servidores: web1 (peso 3), web2 (peso 1), web3 (peso 1)
t=0 10.0.0.1 → web1 [web1:1 web2:0 web3:0]
t=1 10.0.0.2 → web2 [web1:1 web2:1 web3:0]
t=2 10.0.0.3 → web1 [web1:2 web2:1 web3:0]
t=3 10.0.0.4 → web3 [web1:2 web2:1 web3:1]
t=4 10.0.0.5 → web1 [web1:2 web2:1 web3:1]
t=5 web1 deja de responder
t=5 10.0.0.6 → web2 [web1:caído web2:1 web3:1]
t=6 10.0.0.7 → web3 [web1:caído web2:1 web3:2]
t=7 web1 vuelve a responder
t=8 10.0.0.8 → web1 [web1:1 web2:0 web3:0]
Resumen:
  web1: 4 peticiones (50 %), máximo 2 a la vez
  web2: 2 peticiones (25 %), máximo 1 a la vez
  web3: 2 peticiones (25 %), máximo 2 a la vez
  Sin servicio (503): 0

Menos conexiones, errores y sin servicio

Entrada

algoritmo menos-conexiones
servidor app1 1
servidor app2 1
servidor app1 2
balanceo rapido
---
0 peticion 192.168.1.10 10
0 peticion 192.168.1.11 2
1 peticion 192.168.1.12 3
3 peticion 192.168.1.13 1
2 peticion 192.168.1.14 1
4 caido app1
4 caido app2
4 peticion 192.168.1.15 1
5 caido app3
6 recuperado app2
6 recuperado app2
6 peticion 192.168.1.300 1
7 peticion 192.168.1.16
8 peticion 192.168.1.16 1

Salida por consola

Línea 4: no se entiende «servidor app1 2»
Línea 5: no se entiende «balanceo rapido»
Algoritmo: menos-conexiones · servidores: app1 (peso 1), app2 (peso 1)
t=0 192.168.1.10 → app1 [app1:1 app2:0]
t=0 192.168.1.11 → app2 [app1:1 app2:1]
t=1 192.168.1.12 → app1 [app1:2 app2:1]
t=3 192.168.1.13 → app2 [app1:2 app2:1]
Línea 11: el tiempo no puede ir hacia atrás
t=4 app1 deja de responder
t=4 app2 deja de responder
t=4 192.168.1.15 → 503 Service Unavailable (ningún servidor disponible)
Línea 15: servidor desconocido «app3»
t=6 app2 vuelve a responder
t=6 app2 ya estaba disponible
Línea 18: no se entiende «6 peticion 192.168.1.300 1»
Línea 19: no se entiende «7 peticion 192.168.1.16»
t=8 192.168.1.16 → app2 [app1:caído app2:1]
Resumen:
  app1: 2 peticiones (40 %), máximo 2 a la vez
  app2: 3 peticiones (60 %), máximo 1 a la vez
  Sin servicio (503): 1

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. El estado del balanceador

Una clase con, por servidor, la lista de finales de sus conexiones abiertas: liberar es filtrar los que ya pasaron, y las conexiones abiertas son la longitud de la lista.

2. El ponderado suave

Con pesos 3, 1 y 1, el round robin ponderado ingenuo daría web1, web1, web1, web2, web3; el suave intercala: web1, web2, web1, web3, web1. Son tres líneas por elección.

python
for s, p in vivos:
    self.actual[s] += p
elegido = max(vivos, key=lambda sp: self.actual[sp[0]])[0]
self.actual[elegido] -= total
3. Hash de la IP

Convierte la IP en un número (cada octeto es una cifra en base 256) y quédate con el resto entre el número de disponibles: la misma IP cae siempre en el mismo servidor... mientras no cambien los disponibles.

4. Las caídas

Un servidor caído no recibe peticiones, pero lo que ya tenía termina. Si no queda ninguno, el balanceador responde 503 él mismo.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 3 casos ocultos que buscan los errores típicos.

🐍PythonUn balanceador de carga: turnos, pesos, menos conexiones, hash de IP y caídasDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
algoritmo ponderado
servidor web1 3
servidor web2 1
servidor web3 1
---
0 peticion 10.0.0.1 4
1 peticion 10.0.0.2 4
2 peticion 10.0.0.3 4
3 peticion 10.0.0.4 4
4 peticion 10.0.0.5 4
5 caido web1
5 peticion 10.0.0.6 2
6 peticion 10.0.0.7 2
7 recuperado web1
8 peticion 10.0.0.8 2
Salida esperada
Algoritmo: ponderado · servidores: web1 (peso 3), web2 (peso 1), web3 (peso 1)
t=0 10.0.0.1 → web1 [web1:1 web2:0 web3:0]
t=1 10.0.0.2 → web2 [web1:1 web2:1 web3:0]
t=2 10.0.0.3 → web1 [web1:2 web2:1 web3:0]
t=3 10.0.0.4 → web3 [web1:2 web2:1 web3:1]
t=4 10.0.0.5 → web1 [web1:2 web2:1 web3:1]
t=5 web1 deja de responder
t=5 10.0.0.6 → web2 [web1:caído web2:1 web3:1]
t=6 10.0.0.7 → web3 [web1:caído web2:1 web3:2]
t=7 web1 vuelve a responder
t=8 10.0.0.8 → web1 [web1:1 web2:0 web3:0]
Resumen:
  web1: 4 peticiones (50 %), máximo 2 a la vez
  web2: 2 peticiones (25 %), máximo 1 a la vez
  web3: 2 peticiones (25 %), máximo 2 a la vez
  Sin servicio (503): 0
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
python
1import sys
2
3ALGORITMOS = ("turno", "ponderado", "menos-conexiones", "hash-ip")
4
5
6def ip_a_entero(ip):
7    """'10.0.0.5' → 167772165, o None si no es una IPv4 válida."""
8    partes = ip.split(".")
9    if len(partes) != 4 or not all(p.isdigit() and int(p) <= 255 for p in partes):
10        return None
11    n = 0
12    for p in partes:
13        n = n * 256 + int(p)
14    return n
15
16class Balanceador:
17    def __init__(self, algoritmo, servidores):
18        self.algoritmo = algoritmo
19        self.servidores = servidores                 # [(nombre, peso)] en el orden de la configuración
20        self.caidos = set()
21        self.activas = {s: [] for s, _ in servidores}    # fin de cada conexión abierta
22        self.servidas = {s: 0 for s, _ in servidores}
23        self.maximo = {s: 0 for s, _ in servidores}
24        self.turno = 0
25        self.actual = {s: 0 for s, _ in servidores}      # contadores del ponderado suave
26        self.sin_servicio = 0
27
28    def disponibles(self):
29        return [(s, p) for s, p in self.servidores if s not in self.caidos]
30
31    def liberar(self, t):
32        for s in self.activas:
33            self.activas[s] = [f for f in self.activas[s] if f > t]
34
35    def elegir(self, ip):
36        vivos = self.disponibles()
37        if not vivos:
38            return None
39        if self.algoritmo == "turno":
40            nombres = [s for s, _ in self.servidores]
41            for k in range(len(nombres)):
42                s = nombres[(self.turno + k) % len(nombres)]
43                if s not in self.caidos:
44                    self.turno = (nombres.index(s) + 1) % len(nombres)
45                    return s
46        if self.algoritmo == "ponderado":
47            # round robin ponderado suave (el de nginx): reparte sin rachas largas del mismo servidor
48            total = sum(p for _, p in vivos)
49            for s, p in vivos:
50                self.actual[s] += p
51            elegido = max(vivos, key=lambda sp: self.actual[sp[0]])[0]
52            self.actual[elegido] -= total
53            return elegido
54        if self.algoritmo == "menos-conexiones":
55            return min(vivos, key=lambda sp: len(self.activas[sp[0]]))[0]
56        return vivos[ip_a_entero(ip) % len(vivos)][0]
57
58    def estado(self):
59        return " ".join(f"{s}:{'caído' if s in self.caidos else len(self.activas[s])}" for s, _ in self.servidores)
60
61    def peticion(self, t, ip, duracion):
62        s = self.elegir(ip)
63        if s is None:
64            self.sin_servicio += 1
65            return f"t={t} {ip} → 503 Service Unavailable (ningún servidor disponible)"
66        self.activas[s].append(t + duracion)
67        self.servidas[s] += 1
68        self.maximo[s] = max(self.maximo[s], len(self.activas[s]))
69        return f"t={t} {ip} → {s} [{self.estado()}]"
70
71    def cambiar(self, t, nombre, caido):
72        if (nombre in self.caidos) == caido:
73            return f"t={t} {nombre} ya estaba {'caído' if caido else 'disponible'}"
74        if caido:
75            self.caidos.add(nombre)
76        else:
77            self.caidos.discard(nombre)
78        self.actual = {s: 0 for s, _ in self.servidores}    # cambia el reparto: los contadores empiezan de cero
79        return f"t={t} {nombre} {'deja de responder' if caido else 'vuelve a responder'}"
80
81
82def main():
83    lineas = sys.stdin.read().splitlines()
84    algoritmo, servidores, i = None, [], 0
85    while i < len(lineas) and lineas[i].strip() != "---":
86        p = lineas[i].split()
87        if len(p) == 2 and p[0] == "algoritmo" and p[1] in ALGORITMOS:
88            algoritmo = p[1]
89        elif len(p) == 3 and p[0] == "servidor" and p[2].isdigit() and int(p[2]) > 0 and p[1] not in [s for s, _ in servidores]:
90            servidores.append((p[1], int(p[2])))
91        elif p:
92            print(f"Línea {i + 1}: no se entiende «{lineas[i].strip()}»")
93        i += 1
94    if algoritmo is None or not servidores:
95        print("Hace falta un algoritmo y al menos un servidor")
96        return
97    print(f"Algoritmo: {algoritmo} · servidores: " + ", ".join(f"{s} (peso {p})" for s, p in servidores))
98    b = Balanceador(algoritmo, servidores)
99    ultimo = 0
100    for num in range(i + 1, len(lineas)):
101        p = lineas[num].split()
102        if not p:
103            continue
104        valido = len(p) >= 3 and p[0].isdigit()
105        if valido and p[1] == "peticion":
106            valido = len(p) == 4 and ip_a_entero(p[2]) is not None and p[3].isdigit() and int(p[3]) > 0
107        elif valido and p[1] in ("caido", "recuperado"):
108            valido = len(p) == 3
109        else:
110            valido = False
111        if not valido:
112            print(f"Línea {num + 1}: no se entiende «{lineas[num].strip()}»")
113            continue
114        t = int(p[0])
115        if t < ultimo:
116            print(f"Línea {num + 1}: el tiempo no puede ir hacia atrás")
117            continue
118        ultimo = t
119        b.liberar(t)
120        if p[1] == "peticion":
121            print(b.peticion(t, p[2], int(p[3])))
122        elif p[2] not in b.activas:
123            print(f"Línea {num + 1}: servidor desconocido «{p[2]}»")
124        else:
125            print(b.cambiar(t, p[2], p[1] == "caido"))
126    total = sum(b.servidas.values())
127    print("Resumen:")
128    for s, _ in servidores:
129        n = b.servidas[s]
130        print(f"  {s}: {n} {'petición' if n == 1 else 'peticiones'} ({round(n * 100 / total) if total else 0} %), máximo {b.maximo[s]} a la vez")
131    print(f"  Sin servicio (503): {b.sin_servicio}")
132
133
134main()

Round robin funciona bien si todas las peticiones cuestan lo mismo; si unas duran mucho más que otras, un servidor puede acumular trabajo mientras otro está libre, y ahí gana menos conexiones.

El hash de IP da afinidad de sesión: útil si la sesión se guarda en la memoria del servidor. Pero cuando un servidor cae, el módulo cambia y casi todos los usuarios cambian de servidor (pierden la sesión); el hash consistente reduce ese baile, y la solución de verdad es guardar las sesiones fuera (Redis, base de datos).

Las comprobaciones de salud (health checks) son las que marcan un servidor como caído: el balanceador pide una URL cada pocos segundos y, tras varios fallos seguidos, lo saca del reparto. Sin ellas, la alta disponibilidad no existe.

El balanceador es a su vez un punto único de fallo: en producción se ponen dos, con una IP flotante que pasa de uno a otro (keepalived), o se usa el de la nube, que ya es redundante.

Para ir más allá

  • Haz el ponderado de menos conexiones (conexiones divididas entre el peso).
  • Implementa hash consistente con un anillo y compara cuántas IP cambian de servidor al caer uno.
  • Simula los health checks: cada 5 segundos, un servidor que falla 3 veces seguidas queda caído.

Dónde se explica