Al buscar un proxy inverso que sirva como punto de entrada para nuestra infraestructura del servidor, nos encontramos con el excelente Traefik. Por supuesto, queríamos equilibrar la carga y, por supuesto, asignar pesos; así que, repasamos la documentación y rápidamente encontramos lo que estábamos buscando. Traefik admite el equilibrio de carga en forma de Round Robin ponderado (WRR), que dirige el tráfico web en ciclos a través de los servicios disponibles. Esto sonaba justo como lo que queríamos. Sin embargo, como descubrimos, no era completamente así. Nuestro viaje comienza aquí.
Actualización: Un día después de proponer nuestra solución de contorno en el repositorio de GitHub de ellos, el equipo de Traefik comenzó a trabajar en la característica. Finalmente llegará a Traefik v3.0.
TL;DR, Puede que desees saltarte directamente a la solución.
Servicios versus Servidores
Había algo que habíamos pasado por alto: una pequeña pista en la documentación que indica que el balanceo de carga ponderado (WRR) solo está disponible para servicios, no para servidores.
Esta estrategia solo está disponible para equilibrar la carga entre servicios y no entre servidores.
Incialmente, teníamos que aprender a configurar correctamente Traefik y no teníamos idea de lo que significaban servicios y servidores en el contexto de Traefik. Ahora que sabemos, queríamos servir un solo servicio en múltiples servidores de la siguiente manera:
services:
my_service:
loadBalancer:
servers:
- url: "https://server1:1234/"
- url: "https://server2:1234/"
- url: ...
Dado que cada servidor tiene un nivel diferente de potencia, también queríamos asignar un peso a cada servidor. Sin embargo, no es posible asignar pesos a los servidores en esta configuración, y hay un problema abierto en GitHub abierto desde 2019.
Solo Traefik v1 tenía una propiedad de peso, pero no queríamos depender de software heredado.
Ideas
Teníamos diferentes ideas sobre cómo superar esta característica perdida.
Idea 1: Abusar de los controles de salud para simular servidores defectuosos
La idea era mimetizar un servidor no saludable cuando alcanzaba su capacidad máxima. De esta manera, Traefik dejaría de dirigir tráfico al servidor una vez que no tuviera ranuras libres.
# Example health check configuration
http:
services:
my-service:
loadBalancer:
healthCheck:
path: /health
interval: "5s"
timeout: "3s"
<pre class="wp-block-syntaxhighlighter-code"># Example FastAPI endpoint
@app.get('/health')
def health():
if COMPUTE_CAPABILITY / job_q.qsize() < 1:
return Response('Too Many Requests', status_code=429)
return Response('OK', status_code=200)</pre>
Cuando la cola de trabajos superara la capacidad computacional del servidor, se reportaría como no saludable a Traefik en un máximo de ocho segundos. Traefik entonces lo eliminaría de la Ronda Robin y dejaría que otros servidores atendieran la solicitud. Probamos esta solución y resultó que causaba problemas.
En primer lugar, la caída del servidor es lenta. Incluso cuando está sobrecargado, los servidores siguen recibiendo solicitudes hasta que el control de salud informa sobre la caída. Ocho segundos pueden ser un tiempo largo, y reducir el intervalo no era una opción para mantener la carga del servidor manejable.
En segundo lugar, y lo más sorprendente, nos encontramos con errores NS_ERROR_NET_PARTIAL_TRANSFER en algunas solicitudes. No investigamos más a fondo, pero creemos que esto ocurrió porque los servidores estaban atrapados entre atender solicitudes de larga duración y se informaron como no saludables, lo que causó la interrupción de la conexión del cliente. Mantener tal infraestructura es indeseable.
En tercer lugar, nuestro servicio es estadoful. Esto significa que utilizamos sesiones pegajosas, asegurando que cada cliente solo se comunique con un servidor dedicado asignado al principio de la sesión. Tener uno de estos servidores disponibles durante una sesión plantea un problema que pensábamos poder solucionar. No pudimos. Desistimos de la Idea 1.
Idea 2: Manipulación de la Sesión
Al establecer una sesión, utilizamos JavaScript fetch() con credenciales incluidas:
await fetch('https://edge_server/', {credentials: 'include'})
De esta manera, Traefik asigna una nueva cookie de sesión al cliente con el encabezado Set-Cookie en la solicitud inicial de fetch.
# Example Traefik configuration with sticky sessions enabled
services:
my_service:
loadBalancer:
sticky:
cookie:
name: session_name
httpOnly: true
Si omitimos las credenciales en la solicitud, Traefik asignaría una nueva sesión en cada nueva solicitud utilizando el procedimiento Round Robin. La idea 2 era permitir que el cliente recopilara nuevas sesiones hasta que encontrara un servidor que tuviera ranuras libres.
Solo teníamos que definir un nuevo punto de conexión de dispatch de FastAPI que informe al cliente si el servidor detrás de la sesión actual tiene ranuras libres, así como un método de reintento de fetch en JavaScript para encontrar el servidor correcto. Para el punto de conexión, podíamos reutilizar el método health() de arriba. Para el método de reintento de fetch, por supuesto también queríamos manejar el caso en que todos los servidores estuvieran ocupados. Se nos ocurrió lo siguiente:
<pre class="wp-block-syntaxhighlighter-code">async function fetch_retry(...args) {
for (let i = 0; i < 300; i++) {
for (let s = 0; s < N_SERVERS; s++) {
const response = await fetch(...args);
if (response.status === 503) {
// Waiting for a free slot...
if (s === (N_SERVERS - 1)) {
await new Promise(r => setTimeout(r, 20000));
}
continue;
}
return response;
}
}
return response;
}
</pre>
Ahora, antes de cualquier otra llamada a fetch(), llamábamos nuestro punto de conexión de dispatch sin credenciales:
await fetch_retry('https://edge_server/dispatch');
Y efectivamente, recibimos un nuevo encabezado Set-Cookie para cada iteración del bucle.
Desafortunadamente, la cookie de sesión existente no se actualiza con la llamada fetch_retry(), ya que esto solo sucede cuando se incluyen credenciales, al menos en Firefox. Luego intentamos incluirlas y limpiar el almacenamiento de sesión cuando tenemos una sesión para un servidor «malo». Desafortunadamente, sessionStorage.clear() no limpia la cookie de credencial, aunque también es una cookie de sesión. Esto es cierto incluso cuando se establece httpOnly en falso (aunque esto no se recomienda por razones de seguridad de todos modos).
Así que tuvimos que dormir otra noche antes de tener otra idea.
La Idea Final: Simulación de Servidores
La idea final se basó en la pregunta: ¿Cómo maneja Traefik las URLs de servidores?
Si pudiéramos señalar múltiples URLs que se ven como diferentes en Traefik hacia el mismo servidor, podríamos emular el peso del servidor fingiendo varias direcciones URL del servidor que apuntan todas al mismo servidor. Y efectivamente, esto funcionó según lo esperado. Solo necesitábamos agregar una ruta no existente, como «/1/» o «/2/», a las URLs, y Traefik las manejaría por separado pero las enrutaría sin errores y sin adjuntar la ruta al servidor correcto.
services:
my_service:
loadBalancer:
servers:
- url: "https://server_A:1234/1/"
- url: "https://server_A:1234/2/"
- url: "https://server_B:1234/"
Solo teníamos que asegurarnos de crear el número correcto de direcciones URL del servidor ficticias correspondientes a la respectiva capacidad del servidor. Así, si teníamos un servidor A, por ejemplo, que era dos veces más capaz que el servidor B, entonces necesitábamos agregar dos URLs para el servidor A. De esta manera, durante Round Robin, el servidor A recibiría dos veces el número de solicitudes que el servidor B.