Rolling deploy seguro: por qué el tuyo puede no serlo
Cambiar contenedor sin downtime parece simple — pull nueva imagen, mata viejo, sube nuevo. Funciona hasta el primer viernes a las 17h. Los 6 detalles que separan rolling deploy real de teatro.
Todo equipo de ingeniería que opera contenedor en producción, antes o después, escribe una frase parecida en el canal de status: "deploy concluido, sin downtime". La frase es optimista. En al menos la mitad de los casos que ya hemos auditado — en scripts caseros, en paneles self-hosted populares, en tutoriales oficiales que se volvieron referencia —, lo que pasó de hecho fue una ventana de 5 a 30 segundos en que el balanceador de carga retornó 502, algunos uploads cortaron por la mitad, y nadie se dio cuenta porque el monitoreo muestrea cada minuto.
Rolling deploy parece el problema más simple de orquestación: tienes N contenedores corriendo una versión antigua, quieres tener N contenedores corriendo una versión nueva, y quieres que la app siga respondiendo durante el cambio. La receta conceptual cabe en tres líneas. Sustituir un contenedor viejo por uno nuevo, uno por uno, manteniendo el tráfico siempre dirigido hacia quien está vivo. Lo que vuelve eso difícil no es la estrategia — es el conjunto de seis detalles que necesitan estar correctos al mismo tiempo. Cada uno solo parece detalle de implementación. Los seis juntos son la diferencia entre "deploy sin downtime de verdad" y "deploy que parece sin downtime el viernes por la mañana, pero tiene treinta segundos de error en medio del día a las 17h".
Este post mapea los seis. Al final tiene una receta en formato de spec, una comparación honesta de quién implementa qué, y una sección de tests que puedes correr en tu sistema actual para descubrir si está mal — antes de que tu cliente lo descubra primero.
Detalle 1 — Health check antes de promover el nuevo contenedor
El error más común en rolling deploy casero es confiar en el estado running que el runtime de contenedor reporta. Subes un contenedor nuevo, Docker (o equivalente) lo marca como running en milisegundos, y tu script considera eso prueba de que puede matar al viejo. Mata al viejo. El nuevo, internamente, aún está iniciando — esperando conexión con base, cargando cache en memoria, bajando configuración de feature flag, abriendo pool de threads. Durante ese intervalo, el balanceador rutea tráfico a un proceso que aún no está listo para recibir y devuelve 502 o 503.
La ventana es corta — generalmente entre 5 y 30 segundos por contenedor —, y por eso engaña al monitoreo. Si tu métrica de error es muestreada cada minuto y cambias cinco contenedores en secuencia, cada uno con 10 segundos de "está running pero no está listo", el pico no siempre cae exactamente sobre una ventana de colecta. Quedas con la impresión estadística de que todo salió bien.
Lo que rolling debe hacer, en lugar de eso, es separar dos conceptos: "el proceso está corriendo" y "el proceso está listo para servir tráfico". Corriendo es estado de runtime; listo es respuesta afirmativa de un endpoint que la propia app expone — /healthz, /readyz, o equivalente. El orquestador hace HTTP GET en ese endpoint del contenedor nuevo, espera recibir 200, espera esa respuesta sustentada por un período (min_healthy_time, típicamente 10 segundos), y solo entonces remueve el contenedor viejo del balanceador.
El min_healthy_time es el detalle que mucha gente salta. Existe porque un único 200 no significa nada — puede ser que la app respondió antes de cerrar una conexión crítica, y va a empezar a fallar al segundo siguiente. Esperar 10 segundos consecutivos de respuestas saludables filtra esos falsos positivos sin alargar el deploy de forma absurda.
El Watchtower clásico — script popular para actualizar contenedores a partir de nuevas tags de imagen — no hace nada de eso. Hace pull, para el viejo, sube el nuevo. Coolify y Dokploy implementan parcialmente, dependiendo de la configuración de la aplicación y del tipo de health check que activaste. Un orquestador de cluster serio trata eso como requisito mínimo.
Detalle 2 — Connection draining y graceful shutdown
Incluso cuando ordenas al router que pare de mandar conexiones nuevas al contenedor viejo, aún existen conexiones en curso — uploads de archivo, downloads grandes, requests largos que aguardan respuesta de base, websocket abierto, streaming de evento. Si simplemente envías SIGKILL (o dejas al runtime mandar, después de un timeout demasiado corto), todas esas conexiones cortan por la mitad. El usuario ve error de red en el momento exacto del deploy.
El flujo correcto tiene cuatro pasos ordenados. Primero, señalizas al router que el contenedor viejo no debe más recibir conexiones nuevas — eso generalmente es una remoción del balanceador o un drain del nodo. Segundo, envías SIGTERM al proceso. SIGTERM es una señal capturable; la app puede tratar e iniciar shutdown gracioso. Tercero, esperas. El timeout depende del perfil de la aplicación — 30 a 60 segundos cubre la vasta mayoría de los web apps; APIs con upload de archivo grande pueden necesitar 120 o más. Cuarto, y solo después de ese timeout, mandas SIGKILL a lo que aún no terminó.
Existe una trampa conocida en ese paso: la app necesita, ella misma, lidiar con SIGTERM. Node, Rails, Django, Go HTTP server — todos tienen middleware o helpers para eso, pero ninguno de ellos viene activado por defecto en template básico. Si tu aplicación no captura SIGTERM, la señal se vuelve no-op y el orquestador va a esperar el timeout completo antes de matar con SIGKILL. El resultado es deploy lento y, aún así, conexiones cortadas — porque la app solo se dio cuenta de que iba a morir en el instante del KILL.
Verifica tu app. Específicamente: cuando recibe SIGTERM, ¿deja de aceptar conexiones nuevas, espera las conexiones en curso terminar, y solo entonces cierra? Si la respuesta es "no sé", tu rolling deploy está roto en ese detalle.
Detalle 3 — Imagen anterior pre-puxada para rollback rápido
Bug crítico en producción, tres minutos después del deploy. Necesitas volver a la versión anterior ahora. El comando pull de la imagen antigua toma 30 a 60 segundos por nodo — porque, claro, "limpiaste" la vieja para ahorrar disco, o el cache de imagen del nodo ya fue rotacionado. Multiplica por número de réplicas, suma el tiempo de orquestación del propio cambio, y tu incidente de cinco minutos se volvió quince. Quince minutos es la diferencia entre "inestabilidad momentánea" en el postmortem e "incidente reportado al cliente".
La corrección es trivial y casi nadie la implementa: mantener la imagen N-1 pre-puxada en los nodos que la corrían. Rollback se vuelve cambiar la tag pointed-to y reiniciar — operación de aproximadamente 10 segundos por contenedor, dominada por el health check del contenedor antiguo volviendo a la vida.
La versión más sofisticada es mantener snapshot del estado completo del job — no solo imagen, sino variables de entorno, configuraciones de red, secretos asociados, recursos asignados. Rollback parcial (solo imagen) cubre la mayoría de los casos, pero no cubre regresión introducida en una feature flag o en una string de conexión. Snapshot total es lo que separa rollback rápido de rollback completo.
Detalle 4 — Detección automática de fallo y auto-revert
Escenario común en script casero: el deploy sube, el contenedor nuevo entra en crash-loop (vuelve a running, muere en 5 segundos, vuelve de nuevo, muere de nuevo). El sistema queda esperando que alguien vea el problema y aborte manualmente. Si eso pasa a las cuatro de la mañana y la alerta va a un Slack que nadie está mirando, el downtime se prolonga hasta que alguien despierte.
Lo que rolling debe hacer es definir un healthy_deadline — un techo absoluto de tiempo dentro del cual el contenedor nuevo necesita entrar y permanecer en estado saludable. Nuestro default es 300 segundos; cinco minutos cubre apps que tardan en inicializar (apps Java pesadas, apps con warm-up de cache) sin dar margen indefinido. Si pasó el deadline y el contenedor no está saludable, el orquestador revierte automáticamente a la versión anterior. Alerta al equipo después, sin urgencia — porque el sistema ya se protegió solo.
La implementación práctica cuenta dos señales combinadas: cuenta de restarts en el contenedor nuevo (si pasó de 3 en 60 segundos, es crash-loop) y tiempo total transcurrido sin health check positivo sostenido. Cualquiera de los dos disparándose antes de que el min_healthy_time sea alcanzado aborta el deploy de aquella réplica y dispara el revert.
Un detalle sutil: auto-revert solo tiene sentido si el detalle 3 (imagen anterior pre-puxada) está implementado. Revertir a una imagen que necesita ser bajada de nuevo durante el auto-revert anula la ganancia.
Detalle 5 — max_parallel: 1 en cluster multi-instance
Tienes cinco réplicas del mismo servicio. Tentado de cambiar todas las cinco al mismo tiempo: deploy paralelo, espera todas quedar saludables, listo. Ese camino tiene tres problemas. Primero, durante la ventana en que todas están siendo cambiadas, todo el tráfico pasa por la versión nueva — si tiene bug, 100% de los usuarios sienten, sin fallback. Segundo, el pico de uso de recursos durante el cambio es 2× (porque viejas y nuevas coexisten por instantes), lo que puede reventar memoria del nodo y generar OOM en cascada. Tercero, pierdes la oportunidad barata de detectar regresión antes de propagar.
Rolling debe cambiar una réplica por vez (max_parallel: 1) — o, en clusters muy grandes, una fracción pequeña (10-25%). Cambia la primera, espera quedar saludable, espera el min_healthy_time, cambia la segunda, y así sucesivamente. La capacidad total del servicio queda mantenida durante toda la ventana de deploy: si cinco réplicas aguantaban el tráfico antes, cuatro nuevas + una vieja (o vice-versa) también aguantan.
El tradeoff es tiempo. Diez réplicas con 30 segundos cada una de cambio + min_healthy_time = cinco minutos de ventana total de deploy. No es rápido. A cambio, ganas rollback barato: si la primera réplica nueva falla, el orquestador para de cambiar las otras. Quedas con nueve viejas + una nueva fracasada, descartas la fracasada, volviste al estado anterior sin ningún impacto en capacidad. El coste de deploy lento es pagado por la seguridad de no tener cluster entero con bug corriendo antes de que alguien perciba.
Existe un knob extra que ayuda: stagger, el intervalo entre cambios consecutivos. Default razonable es 30 segundos. Ese atraso permite que métricas y logs del contenedor recién cambiado sean colectados y evaluados antes de partir hacia el próximo — ventana mínima para detectar bug que solo aparece bajo tráfico real.
Detalle 6 — Pre-stop hooks para long-running jobs
Ese detalle afecta específicamente a apps que tienen worker asíncrono — Sidekiq, Celery, Resque, RQ, BullMQ, cualquier cola de job en background. El contenedor del worker tomó un job que tarda 30 minutos en procesar (envío de e-mail en lote, generación de reporte, procesamiento de pago). En medio del procesamiento, viene el SIGTERM del deploy. Si el worker no tiene tratamiento adecuado, el job se pierde — queda en estado intermedio, o vuelve a la cola y es procesado en duplicado, o simplemente desaparece.
El flujo correcto es más elaborado que el del detalle 2. Antes mismo del SIGTERM, el orquestador necesita ejecutar un pre-stop hook que señalice al worker para entrar en modo de drenaje: parar de aceptar jobs nuevos, pero terminar los que ya tomó. El orquestador entonces espera (con timeout configurable — 60 a 300 segundos es rango normal) por la cola local drenar. Solo después manda SIGTERM al proceso.
La implementación varía. Apps más sofisticadas exponen un endpoint /pause o /drain que coloca al worker en modo gracioso. Apps más simples usan un archivo de centinela — pre-stop crea un archivo, worker chequea el archivo a cada loop y para de tomar job nuevo si existe. En ambos casos, la clave es que el orquestador necesita esperar la confirmación de que la cola local drenó antes de mandar SIGTERM.
Sin eso, tu tasa de fallo de jobs asíncronos durante deploy es directamente proporcional al tiempo medio de procesamiento de job × número de workers cambiados. En apps que procesan pago o envío de e-mail, esa tasa de fallo se vuelve problema serio rápido.
La receta completa
Los seis detalles se componen en una especificación reconocible. En formato spec:
update:
max_parallel: 1
min_healthy_time: 10 # 10s sostenidos saludable
healthy_deadline: 300 # 5 min máx para quedar saludable
auto_revert: true # si pasa deadline, revierte
stagger: 30 # 30s entre réplicas cambiadas
tasks:
- name: web
healthcheck:
path: /healthz
interval: 5s
timeout: 2s
retries: 3
lifecycle:
pre_stop:
timeout: 60 # 60s para worker drenar
command: ["/bin/sh", "-c", "kill -TERM 1; sleep 30"]
Esa configuración — en texto, en archivo de configuración de orquestador, o implícita en código de deploy — es el mínimo viable de rolling deploy seguro. Cubrir los seis detalles es lo que diferencia una orquestación seria de una serie de comandos docker enfilados.
Quién implementa qué (la versión honesta)
La grilla abajo cubre el ecosistema más común en el nicho self-hosted/cluster pequeño. No es exhaustiva — hay herramientas excelentes que quedaron fuera —, pero es honesta sobre las que aparecen en las decisiones de arquitectura típicas.
Kubernetes. Implementa todos los seis cuando el manifiesto está completo: readinessProbe cubre el detalle 1, terminationGracePeriodSeconds + preStop cubre el 2 y el 6, imagePullPolicy + cache local cubre el 3, progressDeadlineSeconds + revisionHistoryLimit cubre el 4, maxUnavailable + maxSurge cubre el 5. El problema no es lo que hace; es el tamaño del manifiesto necesario para hacer eso, y el número de campos cuyo default no es lo que necesitas.
Docker Swarm. Implementa rolling vía docker service update. Las primitivas existen (--update-parallelism, --update-delay, --update-failure-action), pero los defaults son agresivos demás — paralelismo default alto, sin health check obligatorio, y el comportamiento de auto-revert es opt-in con un flag específico. Necesita ser tuneado para cada servicio; raramente es tuneado.
Nomad. Implementa nativamente, con defaults sensatos. update block tiene max_parallel, min_healthy_time, healthy_deadline, auto_revert, stagger — básicamente los mismos campos de la spec arriba, porque esa nomenclatura no es coincidencia: es la herencia de buenas prácticas que los principales orquestadores convergieron.
HeroCtl. Implementa todos los seis detalles nativamente, con la configuración prácticamente idéntica a la spec arriba. La elección de coordinador del plano de control toma cerca de 7 segundos cuando el nodo líder cae, así que incluso un deploy ejecutado en pleno momento de cambio de coordinador es resiliente — el nuevo coordinador retoma el ciclo de health check del punto donde estaba. Los defaults son max_parallel: 1, min_healthy_time: 10, healthy_deadline: 300, auto_revert: true. Si envías un job sin configurar nada, eso es lo que captura.
Watchtower. No. Watchtower es una herramienta útil para un caso específico — actualización automática de contenedores en ambiente donde aceptas downtime corto y pérdida de conexión como coste de no tener pipeline de deploy. En producción seria, falla en cinco de los seis detalles. No es crítica al proyecto; es crítica al uso de él en contexto erróneo.
Coolify y Dokploy. Implementan parcialmente. Health check existe pero necesita ser configurado por aplicación. Connection draining depende del app capturar SIGTERM (responsabilidad compartida). Auto-revert es manual en ambos. Pre-stop hook genérico no es primitivo de primera clase. Para single-server, es suficiente; para cluster, es frágil.
Scripts caseros. Aquella combinación de docker pull && docker stop && docker run en un shell script gestionado por cron o disparado por webhook de GitHub. Cero de los seis. Cobertura honesta de lo que es eso: un deploy con downtime corto, no un rolling deploy.
Los cuatro patrones más allá de rolling
Rolling no es la única estrategia. Conforme el requisito y el presupuesto, tres otras tienen sentido en contextos específicos.
Blue-green. Dos ambientes paralelos completos, cada uno con el stack entero. Deploy es subir el ambiente alternativo (verde) con la versión nueva, validar en paralelo, y hacer switch vía DNS o balanceador — un único momento atómico en que el tráfico entero cambia. Más seguro que rolling porque puedes validar la versión nueva con tráfico sintético antes de cualquier usuario real. Cuesta, en capacidad, 2× durante la ventana de deploy. Recomendado para apps donde el coste de bug en producción es alto y el coste de capacidad extra es bajo.
Canary. Manda 5% (o 1%, o cualquier fracción pequeña) del tráfico hacia la versión nueva, monitorea métricas clave por un período de observación (15 minutos a algunas horas), y escala gradualmente — 5%, 25%, 50%, 100%. Detecta regresión antes de afectar al usuario principal. Prerrequisito es tener métricas confiables con sensibilidad alta; sin eso, canary solo atrasa el deploy sin ganar seguridad. Combina bien con rolling: rolling es el mecanismo, canary es la estrategia de promoción.
Rainbow. Varias versiones coexistiendo simultáneamente en producción, con tráfico ruteado por clave de cliente o tipo de tenant. Caso de uso raro, generalmente en B2B con requisitos de versión por contrato. No es la primera opción casi nunca.
Recreate. Para todo, sube nuevo. Downtime explícito y aceptado. Aceptable para apps internos con ventana de mantenimiento o para ambientes de desarrollo. Sorprendentemente apropiado en casos específicos: deploy que envuelve migración de base que rompe schema, o deploy de app cuya arquitectura no soporta dos versiones coexistiendo. Cuando recreate es la elección correcta, es la elección correcta — no hay premio por hacer rolling de todo.
Cómo detectar que tu rolling está mal
Cuatro tests directos. Los dos primeros son métricas que puedes mirar en el monitoreo que ya tienes; los dos siguientes son experimentos que necesitan ser hechos con intención.
Tasa de 5xx durante la ventana de deploy. Si tu tasa de 5xx en producción es estadísticamente diferente de cero durante la ventana del deploy, está mal. "Estadísticamente diferente" significa: toma 30 deploys consecutivos, mide la tasa de 5xx en el minuto que precede al deploy y en el minuto del deploy. Si la media del segundo es mayor, hay error real, y la ventana está cortando conexión.
Latencia p99 durante el deploy. Si p99 sube 3× o más durante la ventana de deploy, está mal. Spike de latencia indica que requests están siendo reiniciados internamente, o que el balanceador está reaceptando conexiones para contenedores lentos para responder.
Test de crash forzado. Antes de un deploy programado, fuerza la app en el contenedor nuevo a fallar — chmod 000 en el binario, o variable de entorno que hace process.exit(1) en el startup. ¿El sistema revierte automáticamente dentro del healthy_deadline? Si queda trabado esperando intervención humana, el detalle 4 está roto.
Deploy de viernes 17h con tráfico real. El test social. Haz un deploy no-trivial en horario de pico, en un día en que nadie del equipo está mirando activamente. Si la métrica de tu app durante esa ventana es indistinguible de una ventana aleatoria, tu rolling deploy es seguro. Si fue necesaria intervención, o si el canal de status registró algo, no es.
FAQ
¿Watchtower es seguro para producción? Para producción pequeña con tolerancia explícita a downtime corto y herramienta de fallback (rollback manual rápido), sí. Para producción que tiene cliente pagante con expectativa de SLA, no. Watchtower fue hecho para un problema diferente.
¿Health check en /healthz o /readyz?
La convención que más ayuda en la práctica: /healthz indica "el proceso está vivo" (liveness — usado por orquestador para decidir si reinicia el contenedor) y /readyz indica "estoy listo para servir tráfico" (readiness — usado por el orquestador para decidir si incluye el contenedor en el balanceador). Para rolling deploy, lo que importa es el readiness; el orquestador solo promueve un contenedor al pool de tráfico cuando el readiness retorna 200. Si tienes solo un endpoint y responde 200 inmediatamente después de que el proceso suba, tu readiness no está midiendo lo que debía.
¿min_healthy_time debe ser cuánto?
Rango típico es 10 a 30 segundos. Más corto que eso (3 segundos, 5 segundos) deja pasar falsos positivos — apps que responden 200 luego al inicio pero empiezan a fallar cuando el tráfico real llega. Más largo que 60 segundos se vuelve impedimento operacional sin ganancia proporcional. Si tu aplicación tiene warm-up complejo (cache en memoria, conexión con terceros lentos), el lugar para cubrir eso es en el health check en sí — hacer que solo responda 200 después del warm-up —, no inflar el min_healthy_time.
¿Cómo hago pre-stop en app Rails?
Rails responde nativamente a SIGTERM con graceful shutdown desde la 5.x — el servidor para de aceptar conexión nueva y termina las en curso. Para Sidekiq, la señal correcta es SIGTSTP (pause workers), seguida por SIGTERM después de que Sidekiq.redis { |c| c.llen("queue:default") } se ponga a cero. En práctica, el pre-stop hook ejecuta un script pequeño que manda SIGTSTP, hace polling de cola por hasta N segundos, y retorna — el orquestador entonces hace el SIGTERM convencional.
¿Sticky sessions y rolling deploy se llevan bien? Mal. Sticky session significa que tu arquitectura está delegando estado al balanceador, y durante rolling ese estado es descartado cuando la réplica que sostenía la sesión es cambiada. Resultado: usuario es deslogado, pierde formulario por la mitad, o tiene comportamiento inconsistente. Si necesitas sticky, eso es síntoma — refactoriza a estado externo (Redis, base) y el rolling deploy se vuelve trivial.
¿Migración de base en el rolling deploy? La regla práctica que evita 90% de los dolores: toda migración necesita ser compatible con la versión anterior de la app durante la ventana de deploy. Añadir columna nullable: tranquilo. Remover columna: hazlo en dos releases (release N para de usar la columna; release N+1 la remueve). Renombrar columna: ídem, con columna nueva como copia. Eso permite que viejas y nuevas réplicas coexistan, que es exactamente lo que rolling presupone.
¿Puedo testear rolling deploy localmente?
Puedes. Sube tres réplicas locales con docker-compose, simula un balanceador (nginx o caddy) al frente, dispara hey o wrk con tráfico sostenido, y ejecuta un script de cambio como tu pipeline ejecutaría en producción. Mide 5xx durante la ventana. Es un test imperfecto (el tráfico es sintético, el nodo es único, no hay red real entre nodos), pero captura los bugs gruesos en los detalles 1, 2 y 3 antes de que filtren a producción.
Cierre
Rolling deploy seguro no es un botón; es un conjunto de seis comportamientos coordinados, y la mayoría de las herramientas que prometen "zero downtime" cubre tres o cuatro de ellos. La diferencia práctica se vuelve visible el viernes a las 17h, o en el incidente de miércoles por la mañana, o en el postmortem en que alguien pregunta por qué tres usuarios reportaron error durante el último deploy.
La receta está arriba. Si tu herramienta actual cubre los seis, óptimo. Si no, o asumes el coste de cubrir manualmente los faltantes, o cambias por algo que cubre nativamente.
HeroCtl cubre nativamente. Plan Community es gratuito permanente, sin límite de servidores o jobs, con la configuración de rolling deploy descrita aquí como default. Plan Business añade SSO, RBAC, auditoría y soporte con SLA para equipos con requisitos formales de plataforma. Plan Enterprise añade escrow de código fuente y contrato de continuidad.
Para empezar:
curl -sSL https://get.heroctl.com/install.sh | sh
Si quieres ver los otros lados del asunto, lee Por qué creamos HeroCtl para el contexto de producto, y en los próximos posts vamos a cubrir deploy de Docker en producción, del compose al cluster y estrategias de backup de base en cluster para las tres de la mañana.
La intención sigue siendo la misma: orquestación de contenedores, sin ceremonia — y sin teatro.