Un PaaS que no puede escalar es un PaaS con un techo. Por debajo de ese techo, todo funciona. Por encima, las aplicaciones de tus usuarios empiezan a rechazar peticiones, a agotar tiempos de espera y a perder clientes. El techo suele ser un contenedor en un servidor, y cuando el tráfico se dispara, la única opción es acceder por SSH y arrancar más instancias manualmente.
Necesitábamos escalado horizontal: la capacidad de ejecutar múltiples réplicas de la misma aplicación, distribuir tráfico entre ellas y -- críticamente -- añadir o eliminar réplicas automáticamente basándose en métricas de CPU y memoria en tiempo real. Escalado manual para desarrolladores que saben lo que necesitan. Autoescalado para todos los demás.
Así es como lo construimos: un gestor de réplicas, un balanceador de carga respaldado por Caddy y un autoescalador que evalúa promedios móviles de dos minutos cada 30 segundos con enfriamientos configurables para prevenir la oscilación.
El modelo de escalado
El escalado horizontal en sh0 operaba a nivel de contenedor. Cada "réplica" era un contenedor Docker independiente ejecutando la misma imagen con las mismas variables de entorno, conectado a la misma red. La diferencia entre una réplica y cinco eran cuatro contenedores adicionales y una configuración de balanceador de carga que conocía a todos ellos.
El modelo de datos era un ScalingConfig asociado con cada aplicación:
rustpub struct ScalingConfig {
pub app_id: String,
pub min_replicas: i32,
pub max_replicas: i32,
pub current_replicas: i32,
pub cpu_threshold: f64, // Escalar al alza cuando el promedio de CPU excede esto (ej. 80.0)
pub memory_threshold: f64, // Escalar al alza cuando el promedio de memoria excede esto
pub cooldown_seconds: i64, // Tiempo mínimo entre eventos de escalado
pub lb_policy: String, // "round_robin", "least_conn", "ip_hash"
pub autoscale_enabled: bool,
pub last_scaled_at: Option<DateTime<Utc>>,
}El escalado manual establecía current_replicas directamente y deshabilitaba el autoescalado. El autoescalado establecía los umbrales y dejaba que la tarea en segundo plano gestionara el conteo de réplicas dentro de los límites configurados.
Escalado manual: La ruta CLI
La operación de escalado más simple era explícita:
bash# Escalar a 3 réplicas
sh0 scale my-app 3
# Habilitar autoescalado
sh0 scale my-app --auto
# Verificar estado actual de escalado
sh0 scale my-app --statusCuando un usuario ejecutaba sh0 scale my-app 3, el handler de la API realizaba la siguiente secuencia:
- Validaba que el conteo de réplicas solicitado estuviera entre 1 y el máximo (10 por defecto)
- Determinaba el conteo actual de réplicas listando contenedores con la etiqueta de la aplicación
- Si escalaba al alza: creaba nuevos contenedores con la misma imagen, entorno y configuración de volúmenes
- Si escalaba a la baja: detenía y eliminaba los contenedores excedentes, empezando desde la réplica con número más alto
- Actualizaba la configuración del balanceador de carga Caddy con el nuevo conjunto de direcciones upstream
- Actualizaba el registro en la base de datos con el nuevo conteo de réplicas
Cada contenedor réplica se nombraba con un sufijo: my-app-1, my-app-2, my-app-3. La convención de nomenclatura no era solo cosmética -- hacía trivial identificar qué réplica era cuál en logs, métricas y la lista de contenedores Docker.
Balanceo de carga vía Caddy
sh0 ya usaba Caddy como su proxy inverso (ver artículos anteriores en esta serie). Para aplicaciones de un solo contenedor, Caddy enrutaba tráfico a un upstream. Para aplicaciones escaladas, Caddy necesitaba distribuir tráfico entre múltiples upstreams.
Cuando el conteo de réplicas cambiaba, el gestor de proxy reconstruía la configuración de ruta Caddy con todas las direcciones de réplicas activas:
json{
"handle": [{
"handler": "reverse_proxy",
"upstreams": [
{ "dial": "172.18.0.5:8080" },
{ "dial": "172.18.0.6:8080" },
{ "dial": "172.18.0.7:8080" }
],
"load_balancing": {
"selection_policy": {
"policy": "round_robin"
}
}
}]
}Se soportaban tres políticas de balanceo de carga:
- round_robin -- cada petición va al siguiente upstream en secuencia. La opción por defecto, apropiada para aplicaciones sin estado.
- least_conn -- cada petición va al upstream con menos conexiones activas. Mejor para aplicaciones con duraciones de petición variables.
- ip_hash -- las peticiones de la misma IP de cliente siempre van al mismo upstream. Necesario para aplicaciones con sesiones del lado del servidor que no se comparten entre instancias.
La política era configurable por aplicación a través de la pestaña de escalado del panel o el CLI. Cambiar la política disparaba una recarga inmediata de la configuración Caddy sin interrumpir conexiones activas.
El autoescalador: Una tarea en segundo plano
El autoescalador era una tarea en segundo plano que se ejecutaba en un intervalo configurable (30 segundos por defecto). Su diseño priorizaba la estabilidad sobre la capacidad de respuesta -- en autoescalado, el peor resultado es la oscilación, donde el sistema escala rápidamente hacia arriba y abajo, creando más inestabilidad que el pico de carga original.
rustpub struct AutoScalerContext {
db: Arc<DbPool>,
docker: Arc<DockerClient>,
proxy: Arc<ProxyManager>,
master_key: Option<Arc<MasterKey>>,
}
impl AutoScalerContext {
pub async fn tick(&self) -> Result<()> {
let configs = ScalingConfig::list_autoscale_enabled(&self.db).await?;
for config in configs {
if let Err(e) = self.evaluate_app(&config).await {
tracing::error!(app_id = %config.app_id, "Autoscale error: {e}");
}
}
Ok(())
}
}La función tick() iteraba todas las aplicaciones con autoescalado habilitado. Para cada aplicación, evaluate_app() realizaba una decisión de cuatro pasos:
Paso 1: Verificación de enfriamiento
rustif let Some(last_scaled) = config.last_scaled_at {
let elapsed = Utc::now() - last_scaled;
if elapsed.num_seconds() < config.cooldown_seconds {
return Ok(()); // Todavía en enfriamiento, omitir
}
}Si la aplicación fue escalada dentro de la ventana de enfriamiento (300 segundos / 5 minutos por defecto), el evaluador la omitía por completo. Esto prevenía la oscilación de "escalar arriba, inmediatamente escalar abajo, inmediatamente escalar arriba de nuevo" que afecta a los autoescaladores ingenuos.
Paso 2: Agregación de métricas
El evaluador obtenía los últimos dos minutos de métricas de CPU y memoria de la base de datos y calculaba promedios móviles:
rustlet cpu_metrics = Metric::query_recent(
&self.db, &config.app_id, "cpu", Duration::minutes(2)
).await?;
let memory_metrics = Metric::query_recent(
&self.db, &config.app_id, "memory", Duration::minutes(2)
).await?;
// Requerir mínimo de puntos de datos para evitar reaccionar al ruido
if cpu_metrics.len() < 10 {
return Ok(()); // Datos insuficientes, omitir
}
let avg_cpu = cpu_metrics.iter().map(|m| m.value).sum::<f64>()
/ cpu_metrics.len() as f64;
let avg_memory = memory_metrics.iter().map(|m| m.value).sum::<f64>()
/ memory_metrics.len() as f64;El mínimo de 10 puntos de datos (aproximadamente 100 segundos al intervalo de recolección de 10 segundos por defecto) aseguraba que el autoescalador no reaccionara a un solo pico de CPU. Solo la carga sostenida disparaba el escalado.
Paso 3: Decisión de escalar al alza
rustif avg_cpu > config.cpu_threshold || avg_memory > config.memory_threshold {
if config.current_replicas < config.max_replicas {
let new_count = config.current_replicas + 1;
self.scale_to(&config, new_count).await?;
tracing::info!(
app = %config.app_id,
from = config.current_replicas,
to = new_count,
cpu = avg_cpu,
"Autoscale UP"
);
}
}El escalado al alza era conservador: una réplica a la vez. Si la aplicación necesitaba tres réplicas más, tomaría tres ciclos de evaluación (más enfriamientos) para llegar allí. Esto fue deliberado. Añadir todas las réplicas de una vez podría saturar el daemon Docker y la red, y hacía más difícil determinar el conteo final correcto.
Paso 4: Decisión de escalar a la baja
rustelse if avg_cpu < config.cpu_threshold * 0.5
&& avg_memory < config.memory_threshold * 0.5
{
if config.current_replicas > config.min_replicas {
let new_count = config.current_replicas - 1;
self.scale_to(&config, new_count).await?;
tracing::info!(
app = %config.app_id,
from = config.current_replicas,
to = new_count,
cpu = avg_cpu,
"Autoscale DOWN"
);
}
}El escalado a la baja usaba un umbral de histéresis del 50%. Si el umbral de CPU para escalar al alza era 80%, el sistema solo escalaría a la baja cuando la CPU cayera por debajo del 40%. Esta brecha prevenía que el autoescalador deshiciera inmediatamente un escalado al alza cuando la carga disminuía ligeramente. La aplicación tenía que estar genuinamente infrautilizada antes de que se eliminaran réplicas.
El panel: Pestaña de escalado
El panel añadió una pestaña "Escalado" a la página de detalle de cada aplicación. La pestaña contenía dos paneles:
Escalado manual presentaba un deslizador de 1 a 10 réplicas con el conteo actual mostrado prominentemente. Mover el deslizador y hacer clic en "Aplicar" enviaba la solicitud de escalado inmediatamente. Esta era la salida de emergencia para desarrolladores que sabían exactamente lo que necesitaban -- día de lanzamiento de un producto, por ejemplo, donde quieres cinco réplicas listas antes de que llegue el tráfico.
Configuración de autoescalado presentaba controles de alternancia para habilitar autoescalado, campos de entrada para min/max réplicas y umbrales de CPU/memoria, un desplegable para la política de balanceo de carga y un campo de duración de enfriamiento. Los cambios en la configuración de autoescalado tomaban efecto en el siguiente ciclo del evaluador sin reiniciar la tarea en segundo plano.
Ambos paneles mostraban el estado actual: cuántas réplicas estaban ejecutándose, el promedio actual de CPU y memoria, y la marca de tiempo del último evento de escalado. Este ciclo de retroalimentación permitía a los usuarios verificar que su configuración de autoescalado se comportaba como se esperaba.
Desafíos de propiedad
El autoescalador fue una de las implementaciones más complicadas de Rust en el codebase. La tarea en segundo plano necesitaba acceso al pool de base de datos, cliente Docker y gestor de proxy -- todos los cuales también eran propiedad del AppState del servidor API. Compartir recursos envueltos en Arc entre un servidor Axum y una tarea Tokio en segundo plano requería gestión cuidadosa de tiempos de vida.
El patrón AutoScalerContext resolvió esto sin filtrar el AppState completo (que contenía DashMap y otros tipos que complicaban los trait bounds):
rust// En main.rs
let autoscaler_ctx = AutoScalerContext {
db: db_pool.clone(),
docker: docker_client.clone(),
proxy: proxy_manager.clone(),
master_key: master_key.clone(),
};
let autoscale_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(
Duration::from_secs(autoscale_interval)
);
loop {
interval.tick().await;
if let Err(e) = autoscaler_ctx.tick().await {
tracing::error!("Autoscaler tick failed: {e}");
}
}
});Al clonar los Arcs antes de pasarlos al contexto, el autoescalador poseía handles independientes a los recursos compartidos. El servidor API y el autoescalador podían operar concurrentemente sin coordinación. La base de datos manejaba su propia concurrencia a través del modo WAL. El cliente Docker era inherentemente sin estado (cada llamada API era una petición HTTP independiente sobre el socket Unix). El gestor de proxy usaba mutabilidad interior para su tabla de rutas.
El resultado
Al final de la sesión, 374 tests pasaban. El sistema de escalado soportaba escalado manual de 1 a 10 réplicas, autoescalado con umbrales de CPU y memoria, tres políticas de balanceo de carga, enfriamientos configurables y una interfaz de panel con visualización de estado en tiempo real.
El autoescalador era conservador por diseño. Escalaba al alza una réplica a la vez, solo cuando dos minutos de carga alta sostenida confirmaban la necesidad. Escalaba a la baja solo cuando la carga caía a la mitad del umbral. Se negaba a actuar durante períodos de enfriamiento. Y requería un número mínimo de puntos de datos de métricas antes de tomar cualquier decisión.
En autoescalado, el objetivo no es reaccionar lo más rápido posible. Es reaccionar correctamente, y nunca empeorar las cosas.
Siguiente en la serie: Multi-servidor BYOS: túneles SSH, transferencia de imágenes y Trust On First Use -- cómo habilitamos a los usuarios para traer sus propios servidores con túneles SSH a daemons Docker remotos, transferencia de imágenes basada en disco y verificación de clave de host TOFU.