En la lección anterior cambiamos la imagen de tienda-web y observamos algo que dejamos deliberadamente sin explicar: apareció un segundo ReplicaSet, el viejo se quedó a cero réplicas sin borrarse, y durante unos segundos convivieron pods de dos versiones. Ese mecanismo es la razón de ser del Deployment y la respuesta directa al primero de los cuatro objetivos de Rutas Norte: cero cortes en los despliegues, frente al minuto o dos de parada que hoy obliga a desplegar los martes de madrugada. En esta lección tomamos el control de ese trasvase. Verás las dos estrategias nativas del Deployment y cuándo exige cada una, calcularás pod a pod lo que ocurre durante una actualización de api-reservas, ajustarás el ritmo con maxSurge y maxUnavailable, distinguirás qué cambios generan una revisión nueva y cuáles no, y manejarás el juego completo de kubectl rollout: status, history, undo, pause y resume. Y terminarás rompiendo un despliegue a propósito con una imagen inexistente, diagnosticándolo y deshaciéndolo.

Contenido

  1. Las dos estrategias nativas: RollingUpdate y Recreate
  2. Por qué postgres-reservas no admite RollingUpdate
  3. maxSurge y maxUnavailable, paso a paso con api-reservas
  4. Ritmo y tolerancia: minReadySeconds, progressDeadlineSeconds, revisionHistoryLimit
  5. Qué dispara una revisión nueva y qué no
  6. El juego completo de kubectl rollout
  7. La anotación kubernetes.io/change-cause
  8. Despliegue sin corte: qué hace falta de verdad
  9. Diagnóstico y rollback de un despliegue atascado

  1. Las dos estrategias nativas: RollingUpdate y Recreate

spec.strategy.type solo admite dos valores. No hay más estrategias nativas en Kubernetes: blue-green y canary, que verás en 11-04, se construyen combinando estos ladrillos con Servicios e Ingress.

RollingUpdate (por defecto)

Sustituye los pods de forma progresiva: levanta pods de la versión nueva y retira los de la vieja poco a poco, manteniendo siempre un número mínimo de pods sirviendo.

flowchart LR
    subgraph T0["Inicio"]
        A1["v1"]; A2["v1"]; A3["v1"]; A4["v1"]
    end
    subgraph T1["A mitad"]
        B1["v1"]; B2["v1"]; B3["v2"]; B4["v2"]
    end
    subgraph T2["Fin"]
        C1["v2"]; C2["v2"]; C3["v2"]; C4["v2"]
    end
    T0 --> T1 --> T2

Recreate

Mata todos los pods viejos, espera a que desaparezcan, y solo entonces crea los nuevos. Hay un intervalo, normalmente de segundos a un par de minutos, sin ningún pod sirviendo.

flowchart LR
    subgraph R0["Inicio"]
        D1["v1"]; D2["v1"]
    end
    subgraph R1["CORTE DE SERVICIO"]
        X["Ningún pod"]
    end
    subgraph R2["Fin"]
        E1["v2"]; E2["v2"]
    end
    R0 --> R1 --> R2

Comparativa

Aspecto RollingUpdate Recreate
Corte de servicio No , mientras dura la sustitución
Conviven dos versiones Sí, es inevitable No, nunca
Capacidad extra necesaria Sí, si maxSurge > 0 No
Velocidad Más lento (progresivo) Más rápido
Consumo de recursos en el pico Hasta replicas + maxSurge Nunca supera replicas
Rollback Progresivo, también sin corte Con otro corte
Cuándo usarla Cargas sin estado: tienda-web, api-reservas, worker-notificaciones Cuando dos versiones no pueden coexistir

Cómo se declara explícitamente:

spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
spec:
  strategy:
    type: Recreate      # sin sub-campos: Recreate no admite maxSurge ni maxUnavailable

  1. Por qué postgres-reservas no admite RollingUpdate

Es la pregunta clave de este apartado, y la respuesta enseña más que la estrategia en sí. Imagina que postgres-reservas fuera un Deployment de 1 réplica con RollingUpdate y maxSurge: 1. Durante la actualización, esto es lo que pasaría:

  1. Kubernetes crea el pod nuevo antes de retirar el viejo, porque maxSurge: 1 lo permite.
  2. Durante unos segundos hay dos procesos PostgreSQL simultáneos.
  3. Ambos intentan montar y escribir en el mismo directorio de datos.

Consecuencias reales, en orden de gravedad:

  • Bloqueo del arranque: PostgreSQL detecta el fichero postmaster.pid de la instancia viva y se niega a arrancar. El despliegue se atasca (el caso benigno).
  • Corrupción de datos si el bloqueo falla o el almacenamiento no lo garantiza: dos procesos escribiendo en los mismos ficheros WAL.
  • Escrituras perdidas: reservas confirmadas al cliente que no quedan registradas.

Y hay un segundo motivo, independiente del almacenamiento: la migración de esquema. Si la versión nueva de api-reservas requiere una columna que la vieja no conoce, tener las dos versiones hablando a la vez con la misma base de datos rompe una de las dos. Con RollingUpdate esa coexistencia dura minutos; con Recreate, no existe.

Componente Estrategia Motivo
tienda-web RollingUpdate Sin estado; dos versiones de una SPA conviven sin problema
api-reservas RollingUpdate Sin estado; el contrato de la API se mantiene compatible entre versiones consecutivas
worker-notificaciones RollingUpdate Sin estado; consume de una cola, dos versiones pueden consumir a la vez
redis-cache Recreate Una sola réplica con un volumen; además la caché se repuebla sola
postgres-reservas Ni una ni otra: StatefulSet Necesita identidad y almacenamiento estables (06-01)

La conclusión honesta: para postgres-reservas, la respuesta correcta no es Recreate, es no usar un Deployment. Recreate es la estrategia adecuada para cargas de una sola réplica que no toleran duplicidad pero tampoco necesitan identidad estable, como nuestro redis-cache.

Aplícalo a redis-cache, que ya desplegaste en la lección anterior:

kubectl patch deployment redis-cache --type=merge \
  -p '{"spec":{"strategy":{"type":"Recreate","rollingUpdate":null}}}'
kubectl get deploy redis-cache -o jsonpath='{.spec.strategy.type}{"\n"}'
deployment.apps/redis-cache patched
Recreate

Recuerda llevar también ese cambio al manifiesto en Git.

  1. maxSurge y maxUnavailable, paso a paso con api-reservas

Estos dos parámetros gobiernan por completo el ritmo de una actualización progresiva.

Parámetro Qué limita Valor por defecto Efecto de subirlo
maxSurge Cuántos pods de más puede haber por encima de replicas 25 % Actualización más rápida, más recursos en el pico
maxUnavailable Cuántos pods pueden estar no disponibles por debajo de replicas 25 % Actualización más rápida, menos capacidad durante el proceso

Ambos admiten un número entero o un porcentaje. Los porcentajes se redondean así: maxSurge hacia arriba y maxUnavailable hacia abajo, para pecar siempre por exceso de capacidad. Y hay una restricción: no pueden valer 0 los dos a la vez, porque entonces el despliegue no podría dar ni un paso.

El escenario

api-reservas con 4 réplicas, maxSurge: 1 y maxUnavailable: 1. Actualizamos de la versión 2.4.0 a la 2.5.0. Los números que rigen todo el proceso:

  • Máximo de pods simultáneos = replicas + maxSurge = 4 + 1 = 5
  • Mínimo de pods disponibles = replicas - maxUnavailable = 4 − 1 = 3

Prepara el escenario:

kubectl scale deployment api-reservas --replicas=4
kubectl patch deployment api-reservas --type=merge \
  -p '{"spec":{"strategy":{"type":"RollingUpdate","rollingUpdate":{"maxSurge":1,"maxUnavailable":1}}}}'
kubectl get deploy api-reservas
NAME           READY   UP-TO-DATE   AVAILABLE   AGE
api-reservas   4/4     4            4           41m

El recuento exacto, paso a paso

Paso Acción del controlador Pods v2.4.0 Pods v2.5.0 Total Disponibles ¿Respeta los límites?
0 Estado inicial 4 listos 0 4 4
1 Crea 1 pod nuevo (usa el maxSurge) 4 listos 1 creándose 5 4 Total = 5 = máximo
2 Retira 1 pod viejo (usa el maxUnavailable) 3 listos 1 creándose 4 3 Disponibles = 3 = mínimo
3 El pod nuevo pasa a Ready 3 listos 1 listo 4 4
4 Crea otro pod nuevo 3 listos 1 listo + 1 creándose 5 4 Total = 5
5 Retira otro viejo 2 listos 1 listo + 1 creándose 4 3 Disponibles = 3
6 El segundo nuevo pasa a Ready 2 listos 2 listos 4 4
7 Crea el tercero 2 listos 2 listos + 1 creándose 5 4 Total = 5
8 Retira el tercer viejo 1 listo 2 listos + 1 creándose 4 3 Disponibles = 3
9 El tercero pasa a Ready 1 listo 3 listos 4 4
10 Crea el cuarto 1 listo 3 listos + 1 creándose 5 4 Total = 5
11 Retira el último viejo 0 3 listos + 1 creándose 4 3 Disponibles = 3
12 El cuarto pasa a Ready. Fin 0 4 listos 4 4 Completado

Las dos lecturas importantes de esa tabla:

  • En ningún momento hay menos de 3 pods sirviendo. Rutas Norte nunca pierde capacidad total, solo el 25 % durante unos segundos.
  • En ningún momento hay más de 5 pods. El clúster necesita hueco para un pod extra, no para ocho.
flowchart TD
    P0["Paso 0<br/>4 viejos · 0 nuevos<br/>total 4"] --> P1["Paso 1<br/>4 viejos · 1 nuevo<br/>total 5 ← tope de maxSurge"]
    P1 --> P2["Paso 2<br/>3 viejos · 1 nuevo<br/>disponibles 3 ← suelo de maxUnavailable"]
    P2 --> P3["Paso 3<br/>el nuevo pasa a Ready<br/>disponibles 4"]
    P3 --> PN["Se repite el ciclo<br/>crear · retirar · esperar Ready"]
    PN --> PF["Paso 12<br/>0 viejos · 4 nuevos<br/>despliegue completado"]

Verlo en directo

En una terminal:

kubectl get pods -l app=api-reservas -w

En otra, lanza la actualización:

kubectl set image deployment/api-reservas api=node:20.15-alpine
NAME                     READY   STATUS              RESTARTS   AGE
api-reservas-6b4c9d7f5-x2jkp   1/1   Running             0        41m
api-reservas-6b4c9d7f5-k7wpd   1/1   Running             0        41m
api-reservas-6b4c9d7f5-m4rzt   1/1   Running             0        41m
api-reservas-6b4c9d7f5-q8vnc   1/1   Running             0        41m
api-reservas-9f2a7c531-t3xkb   0/1   Pending             0        0s
api-reservas-9f2a7c531-t3xkb   0/1   ContainerCreating   0        0s
api-reservas-6b4c9d7f5-x2jkp   1/1   Terminating         0        41m
api-reservas-9f2a7c531-t3xkb   1/1   Running             0        3s
api-reservas-9f2a7c531-w9djm   0/1   ContainerCreating   0        0s
api-reservas-6b4c9d7f5-k7wpd   1/1   Terminating         0        41m
...

Y el recuento de réplicas de cada ReplicaSet durante el proceso:

kubectl get rs -l app=api-reservas
NAME                     DESIRED   CURRENT   READY   AGE
api-reservas-6b4c9d7f5   2         2         2       42m
api-reservas-9f2a7c531   3         3         2       25s

El Deployment está trasvasando réplicas de un ReplicaSet a otro. Eso es, literalmente, todo lo que hace un despliegue progresivo.

Combinaciones habituales

maxSurge maxUnavailable Comportamiento Cuándo usarla
25% 25% Por defecto: equilibrio entre velocidad y seguridad La mayoría de los casos
1 0 Nunca baja de la capacidad nominal. Primero levanta, luego retira Producción con capacidad crítica: api-reservas en rutas-norte-pro
0 1 Nunca supera la capacidad nominal. Primero retira, luego levanta Clústeres con recursos muy justos o cuotas ajustadas
100% 0 Levanta todos los nuevos a la vez y luego retira todos los viejos Despliegue muy rápido, exige el doble de recursos
0 0 Inválido: la API lo rechaza

Para producción de Rutas Norte, la elección recomendada es maxSurge: 1 y maxUnavailable: 0: en un puente con el tráfico multiplicado por seis, perder un 25 % de capacidad durante un despliegue no es aceptable.

  1. Ritmo y tolerancia: minReadySeconds, progressDeadlineSeconds, revisionHistoryLimit

Tres campos que completan el control del despliegue.

Campo Por defecto Qué hace Por qué importa
minReadySeconds 0 Segundos que un pod debe llevar listo sin caerse antes de contarse como disponible Evita que el despliegue avance sobre pods que arrancan y mueren a los 5 segundos
progressDeadlineSeconds 600 Segundos sin progreso tras los cuales el despliegue se declara fallido Convierte un atasco silencioso en una condición Progressing: False detectable
revisionHistoryLimit 10 Cuántos ReplicaSets antiguos (a 0 réplicas) se conservan Es tu capacidad de rollback; a 0, no puedes deshacer nada

minReadySeconds merece una explicación aparte porque su efecto es sutil pero muy valioso. Con el valor por defecto de 0, en cuanto un pod nuevo dice "estoy listo", el controlador retira otro viejo y sigue adelante. Si ese pod se cae dos segundos después, el daño ya está hecho: el despliegue avanza sobre una versión rota. Con minReadySeconds: 15, cada pod nuevo debe sobrevivir 15 segundos listo antes de contar. Si se cae antes, el despliegue se frena.

progressDeadlineSeconds es la diferencia entre un despliegue que falla en silencio y uno que avisa. Sin él, un despliegue atascado por una imagen inexistente se quedaría intentándolo eternamente sin que ningún sistema de alertas se enterase.

Manifiesto de api-reservas con los tres campos y la estrategia de producción:

# k8s/base/api-reservas-deployment.yaml (fragmento actualizado)
spec:
  replicas: 4
  revisionHistoryLimit: 5
  minReadySeconds: 15
  progressDeadlineSeconds: 300
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0

Con esta configuración, una actualización de api-reservas en rutas-norte-pro tarda como mínimo 4 × 15 = 60 segundos, y si algo falla se declara fallida a los 5 minutos en lugar de a los 10. Lentitud a cambio de seguridad: en un componente que gestiona pagos de billetes, es un cambio excelente.

  1. Qué dispara una revisión nueva y qué no

Esta es la regla más importante de la lección y la más olvidada:

Solo los cambios bajo spec.template disparan una revisión nueva. Todo lo demás, no.

La lógica es transparente: una revisión es un ReplicaSet, y un ReplicaSet se distingue de otro por el hash de su plantilla de pod. Si la plantilla no cambia, el hash no cambia, y no hay ReplicaSet nuevo que crear.

Cambio ¿Crea revisión? Qué ocurre
spec.template.spec.containers[].image ReplicaSet nuevo; sustitución progresiva de pods
spec.template.spec.containers[].env Ídem: los pods se recrean con las variables nuevas
spec.template.spec.containers[].resources Ídem
spec.template.metadata.labels o annotations Ídem, aunque el contenedor sea idéntico
spec.template.spec.terminationGracePeriodSeconds Ídem
spec.replicas No El ReplicaSet actual sube o baja su número de pods
spec.strategy No Se aplicará en la próxima actualización
spec.minReadySeconds No Ídem
spec.revisionHistoryLimit No Solo limpia ReplicaSets antiguos
metadata.annotations del Deployment No Incluida change-cause: es metadato del Deployment, no del pod

Compruébalo tú mismo:

kubectl rollout history deployment/api-reservas | tail -3
kubectl scale deployment api-reservas --replicas=5
kubectl rollout history deployment/api-reservas | tail -3
REVISION  CHANGE-CAUSE
1         Despliegue inicial de api-reservas 2.4.0
2         <none>

deployment.apps/api-reservas scaled

REVISION  CHANGE-CAUSE
1         Despliegue inicial de api-reservas 2.4.0
2         <none>

Mismo historial: escalar no es desplegar.

Una consecuencia práctica que sorprende a mucha gente: cambiar un ConfigMap no reinicia los pods que lo consumen, porque el ConfigMap no está en el template, solo se referencia desde él. El truco habitual, que verás en el módulo 3, es forzar un cambio en el template con una anotación calculada, o usar directamente:

kubectl rollout restart deployment/api-reservas

rollout restart no cambia nada funcional: escribe una anotación kubectl.kubernetes.io/restartedAt dentro del template, lo que altera el hash y provoca una sustitución progresiva de todos los pods. Es la forma correcta y sin corte de "reiniciar" un Deployment.

  1. El juego completo de kubectl rollout

kubectl set image: actualizar la imagen

kubectl set image deployment/api-reservas api=node:20.16-alpine
deployment.apps/api-reservas image updated

La sintaxis es <nombre-del-contenedor>=<imagen>. El nombre del contenedor es el de spec.template.spec.containers[].name, no el del pod. Con varios contenedores se pueden actualizar a la vez separándolos por espacios.

Aviso de disciplina: en un proyecto declarativo, la vía correcta es editar el manifiesto y aplicarlo. set image es para emergencias, y su cambio se pierde en el siguiente apply.

kubectl rollout status: esperar

kubectl rollout status deployment/api-reservas
Waiting for deployment "api-reservas" rollout to finish: 1 out of 4 new replicas have been updated...
Waiting for deployment "api-reservas" rollout to finish: 2 out of 4 new replicas have been updated...
Waiting for deployment "api-reservas" rollout to finish: 3 out of 4 new replicas have been updated...
Waiting for deployment "api-reservas" rollout to finish: 1 old replicas are pending termination...
deployment "api-reservas" successfully rolled out

kubectl rollout history: el historial

kubectl rollout history deployment/api-reservas
deployment.apps/api-reservas
REVISION  CHANGE-CAUSE
1         Despliegue inicial de api-reservas 2.4.0
2         Actualizacion a node 20.15 por parche de seguridad
3         Actualizacion a node 20.16

Y el detalle completo de una revisión concreta:

kubectl rollout history deployment/api-reservas --revision=2
deployment.apps/api-reservas with revision #2
Pod Template:
  Labels:  app=api-reservas
           app.kubernetes.io/part-of=rutas-norte
           entorno=dev
           pod-template-hash=9f2a7c531
  Annotations:  kubernetes.io/change-cause: Actualizacion a node 20.15 por parche de seguridad
  Containers:
   api:
    Image:  node:20.15-alpine
    Port:   3000/TCP
    Limits:  cpu: 500m, memory: 256Mi
    Requests: cpu: 100m, memory: 128Mi

Esa información sale íntegramente del ReplicaSet a 0 réplicas que quedó guardado. Por eso revisionHistoryLimit: 0 te dejaría sin historial y sin rollback.

kubectl rollout undo: deshacer

kubectl rollout undo deployment/api-reservas
deployment.apps/api-reservas rolled back

Vuelve a la revisión inmediatamente anterior. Para ir a una concreta:

kubectl rollout undo deployment/api-reservas --to-revision=1

Dos precisiones importantes:

  1. Un rollback es un despliegue más: usa la misma estrategia, respeta maxSurge y maxUnavailable, y por tanto tampoco corta el servicio.
  2. Deshacer no borra la revisión mala: crea una revisión nueva con el contenido de la antigua. Si estabas en la 3 y haces undo, apareces en la 4, cuyo contenido es el de la 2. Los números nunca retroceden.
kubectl rollout history deployment/api-reservas
REVISION  CHANGE-CAUSE
1         Despliegue inicial de api-reservas 2.4.0
3         Actualizacion a node 20.16
4         Actualizacion a node 20.15 por parche de seguridad

Fíjate en que la revisión 2 ha desaparecido de la lista: su ReplicaSet ha sido reutilizado como revisión 4.

kubectl rollout pause y resume: congelar a mitad

kubectl rollout pause deployment/api-reservas

Con el Deployment pausado, los cambios que hagas no se aplican. Es la herramienta para agrupar varias modificaciones en un solo despliegue:

kubectl rollout pause deployment/api-reservas
kubectl set image deployment/api-reservas api=node:20.17-alpine
kubectl set resources deployment/api-reservas -c=api --limits=cpu=600m,memory=384Mi
kubectl scale deployment api-reservas --replicas=5
# hasta aqui no ha pasado NADA en el cluster
kubectl rollout resume deployment/api-reservas
kubectl rollout status deployment/api-reservas
deployment.apps/api-reservas paused
deployment.apps/api-reservas image updated
deployment.apps/api-reservas resource requirements updated
deployment.apps/api-reservas scaled
deployment.apps/api-reservas resumed
Waiting for deployment "api-reservas" rollout to finish: 3 of 5 updated replicas are available...
deployment "api-reservas" successfully rolled out

Sin pause, esos tres comandos habrían provocado tres despliegues encadenados, cada uno interrumpiendo al anterior. Con pause, uno solo y una sola revisión.

El otro uso de pause es de emergencia: si ves que un despliegue está sacando pods rotos, kubectl rollout pause lo congela en el acto, dejándote los pods viejos que quedan sirviendo mientras decides si arreglar o deshacer.

Tabla resumen del juego completo:

Comando Para qué
rollout status Esperar y verificar; devuelve código != 0 si falla
rollout history Ver revisiones; con --revision=N, el detalle
rollout undo Volver atrás, con o sin --to-revision
rollout pause / resume Congelar y reanudar; agrupar cambios
rollout restart Recrear todos los pods sin cambiar nada

  1. La anotación kubernetes.io/change-cause

Habrás notado los <none> en la columna CHANGE-CAUSE. Un historial de revisiones sin motivos es casi inútil: a las tres semanas nadie recuerda qué era la revisión 7.

kubernetes.io/change-cause es una anotación del Deployment cuyo valor se copia al ReplicaSet de esa revisión y se muestra en el historial. Tres formas de rellenarla, de peor a mejor:

Con kubectl annotate (rápida, imperativa):

kubectl annotate deployment/api-reservas \
  kubernetes.io/change-cause="Actualizacion a node 20.17 y subida de limites por picos de agosto" \
  --overwrite

En el manifiesto (la correcta según las convenciones del proyecto):

metadata:
  name: api-reservas
  annotations:
    kubernetes.io/change-cause: "v2.5.0 - cache de disponibilidad por expedicion (RN-482)"

Automatizada en CI/CD, que es lo ideal:

kubectl annotate deployment/api-reservas \
  kubernetes.io/change-cause="${CI_COMMIT_SHA} · ${CI_COMMIT_MESSAGE} · ${CI_USER}" \
  --overwrite

Un detalle que ya conoces por el apartado 5, pero que conviene repetir porque genera confusión: cambiar solo esta anotación no dispara una revisión nueva, porque vive en el metadata del Deployment y no en el template. Anótala junto con el cambio real, no después.

Buenas prácticas para el texto de Rutas Norte: versión de la imagen, resumen del cambio en lenguaje llano y referencia al ticket. Ejemplo real del proyecto:

REVISION  CHANGE-CAUSE
1         v2.4.0 - despliegue inicial
2         v2.4.1 - correccion de calculo de plazas libres (RN-455)
3         v2.5.0 - cache de disponibilidad por expedicion (RN-482)
4         v2.4.1 - ROLLBACK: la 2.5.0 devolvia 500 al reservar (RN-489)

  1. Despliegue sin corte: qué hace falta de verdad

Aquí llega el aviso más importante de la lección. Podrías pensar que con RollingUpdate bien configurado ya tienes despliegues sin corte de servicio. No es así, y esta es la causa número uno de errores 502 durante despliegues supuestamente perfectos.

El problema es que, con lo que sabemos hasta ahora, Kubernetes considera que un pod está "listo" en cuanto el proceso arranca. Pero un proceso de Node.js tarda unos segundos en cargar dependencias, conectar a PostgreSQL y quedar operativo. Durante esa ventana:

  1. El pod ya cuenta como Ready.
  2. El Deployment retira un pod viejo, confiado.
  3. El Service empieza a enviarle tráfico real.
  4. El pod nuevo todavía no puede responder: errores 502.
flowchart TD
    A["Pod nuevo arranca"] --> B["Kubernetes lo marca Ready<br/>solo porque el proceso vive"]
    B --> C["El Service le envia trafico"]
    C --> D{"¿La aplicacion esta<br/>realmente operativa?"}
    D -->|Sí| E["Todo correcto"]
    D -->|No: aun conectando a PostgreSQL| F["502 a clientes reales<br/>durante varios segundos"]

La pieza que falta se llama sonda de disponibilidad (readinessProbe): una comprobación que el kubelet ejecuta contra tu aplicación y que decide si el pod está realmente listo para recibir tráfico. Con ella, el paso 1 del esquema no ocurre hasta que la aplicación responde de verdad.

El checklist completo de un despliegue realmente sin corte en Rutas Norte:

Requisito Estado en este momento Dónde se resuelve
Estrategia RollingUpdate bien parametrizada Hecho en esta lección 02-04
Más de una réplica Hecho 02-03
minReadySeconds como colchón Hecho 02-04
readinessProbe que refleje la disponibilidad real Pendiente 07-01
livenessProbe para detectar pods colgados Pendiente 07-01
Apagado ordenado que capture SIGTERM Hecho 02-01
Un Service que enrute a los pods listos Pendiente 02-05
Compatibilidad entre versiones consecutivas de la API Responsabilidad del desarrollo

Es decir: RollingUpdate es necesario pero no suficiente. Sin sondas, el despliegue progresivo avanza a ciegas. Hasta que lleguemos al módulo 7, minReadySeconds es un sustituto pobre pero útil.

  1. Diagnóstico y rollback de un despliegue atascado

Ejercicio final y el más realista de todos. Un viernes por la tarde, alguien despliega en rutas-norte-dev una versión que no existe en el registro.

El desastre

kubectl annotate deployment/api-reservas \
  kubernetes.io/change-cause="v2.6.0 - version que no existe (RN-501)" --overwrite
kubectl set image deployment/api-reservas api=node:99.99-inexistente
kubectl rollout status deployment/api-reservas --timeout=90s
deployment.apps/api-reservas annotated
deployment.apps/api-reservas image updated
Waiting for deployment "api-reservas" rollout to finish: 1 out of 5 new replicas have been updated...
error: timed out waiting for the condition

Paso 1: el estado general

kubectl get deploy api-reservas
NAME           READY   UP-TO-DATE   AVAILABLE   AGE
api-reservas   5/5     1            5           1h

Lectura: hay 5 pods listos y disponibles —el servicio sigue funcionando con la versión anterior— pero solo 1 tiene la versión nueva. El despliegue ha dado un paso y se ha parado. Esto es el sistema funcionando bien: maxUnavailable: 0 ha impedido que se retirase ningún pod viejo hasta que el nuevo estuviera listo, y como nunca lo estará, no se ha retirado ninguno.

Paso 2: los ReplicaSets

kubectl get rs -l app=api-reservas
NAME                     DESIRED   CURRENT   READY   AGE
api-reservas-3d8e1f602   1         1         0       2m
api-reservas-c5a9b47d8   5         5         5       18m

El ReplicaSet nuevo tiene 1 pod que no llega a READY.

Paso 3: el pod culpable

kubectl get pods -l app=api-reservas | grep -v Running
NAME                           READY   STATUS             RESTARTS   AGE
api-reservas-3d8e1f602-h4tqm   0/1     ImagePullBackOff   0          2m
kubectl describe pod api-reservas-3d8e1f602-h4tqm | tail -6
Events:
  Type     Reason     Age                 From               Message
  ----     ------     ----                ----               -------
  Normal   Pulling    2m                  kubelet            Pulling image "node:99.99-inexistente"
  Warning  Failed     2m                  kubelet            Failed to pull image: manifest for node:99.99-inexistente not found
  Warning  Failed     2m                  kubelet            Error: ErrImagePull
  Normal   BackOff    30s (x6 over 2m)    kubelet            Back-off pulling image "node:99.99-inexistente"

Causa raíz identificada, exactamente con el procedimiento de la lección de Pods.

Paso 4: las conditions

kubectl describe deployment api-reservas | grep -A4 Conditions
Conditions:
  Type           Status  Reason
  ----           ------  ------
  Available      True    MinimumReplicasAvailable
  Progressing    False   ProgressDeadlineExceeded

Available: True (seguimos sirviendo) + Progressing: False con ProgressDeadlineExceeded (el despliegue está muerto) es la firma exacta de un despliegue atascado. En un clúster monitorizado, esa combinación debe generar una alerta.

Paso 5: decidir y actuar

Dos opciones, según el momento:

# Opcion A: congelar mientras se investiga (no revierte, solo detiene)
kubectl rollout pause deployment/api-reservas

# Opcion B: deshacer ya (lo normal un viernes por la tarde)
kubectl rollout undo deployment/api-reservas
kubectl rollout status deployment/api-reservas
deployment.apps/api-reservas rolled back
deployment "api-reservas" successfully rolled out

Aviso importante: si el Deployment está pausado, undo no surte efecto. Hay que hacer resume antes.

Paso 6: verificar y documentar

kubectl get deploy,rs -l app=api-reservas
kubectl annotate deployment/api-reservas \
  kubernetes.io/change-cause="ROLLBACK a v2.5.0: la imagen de v2.6.0 no existe en el registro (RN-501)" \
  --overwrite
kubectl rollout history deployment/api-reservas
NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/api-reservas   5/5     5            5           1h

NAME                                     DESIRED   CURRENT   READY   AGE
replicaset.apps/api-reservas-3d8e1f602   0         0         0       6m
replicaset.apps/api-reservas-c5a9b47d8   5         5         5       22m

REVISION  CHANGE-CAUSE
1         Despliegue inicial de api-reservas 2.4.0
4         v2.6.0 - version que no existe (RN-501)
5         ROLLBACK a v2.5.0: la imagen de v2.6.0 no existe en el registro (RN-501)

El ReplicaSet roto se queda a 0 réplicas, sin consumir nada, como testimonio del incidente. Y el historial cuenta la historia completa a quien la lea dentro de seis meses.

La moraleja de todo el apartado: durante los seis pasos anteriores, api-reservas no dejó de atender ni una sola reserva. Con el Docker Compose de partida, ese mismo error habría dejado la plataforma caída hasta que alguien lo notara. Eso es lo que compra un Deployment bien configurado.

Deja el clúster ordenado antes de continuar:

kubectl scale deployment api-reservas --replicas=2
kubectl get deploy
NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
api-reservas            2/2     2            2           1h
redis-cache             1/1     1            1           50m
tienda-web              3/3     3            3           1h
worker-notificaciones   2/2     2            2           45m

Errores Comunes y Consejos

  • Creer que RollingUpdate garantiza cero cortes por sí solo. Sin readinessProbe, el tráfico llega a pods que aún no pueden responder. Es la causa número uno de 502 durante despliegues.
  • Poner maxSurge: 0 y maxUnavailable: 0. La API lo rechaza: el despliegue no podría avanzar.
  • Usar RollingUpdate con una carga que no tolera dos versiones simultáneas. Bases de datos de una réplica, procesos con bloqueo exclusivo o migraciones de esquema incompatibles: ahí va Recreate, o directamente un StatefulSet.
  • Poner revisionHistoryLimit: 0 para "ahorrar". Los ReplicaSets a 0 réplicas no consumen ni CPU ni memoria, solo unos kilobytes en etcd. A cambio, pierdes por completo la capacidad de deshacer.
  • Esperar que cambiar spec.strategy o spec.replicas dispare un despliegue. No están en el template. Solo se aplican en la siguiente actualización real.
  • Esperar que modificar un ConfigMap reinicie los pods. No lo hace. Usa kubectl rollout restart.
  • Olvidar change-cause. Un historial de <none> no sirve de nada cuando hay que decidir a qué revisión volver a las tres de la madrugada.
  • Anotar el change-cause después del despliegue. Como no dispara revisión, se queda asociado al Deployment pero puede no reflejar lo que hizo esa revisión. Anótalo en el mismo apply.
  • Hacer undo sobre un Deployment pausado. No pasa nada hasta que hagas resume. Es un clásico de la depuración a ciegas.
  • Confiar en kubectl apply como verificación. apply solo dice que la API aceptó el objeto. Encadena siempre kubectl rollout status --timeout=... en tus scripts.
  • Consejo: en producción, maxSurge: 1 y maxUnavailable: 0. Cuesta un pod extra de capacidad y garantiza que nunca bajas de la capacidad nominal.
  • Consejo: kubectl rollout status devuelve código de salida distinto de 0 si el despliegue falla. Es lo que convierte un pipeline de CI/CD en algo fiable: kubectl apply -f k8s/ && kubectl rollout status deploy/api-reservas --timeout=300s || kubectl rollout undo deploy/api-reservas.

Ejercicios

Ejercicio 1: Calcular un despliegue sobre el papel

tienda-web está en rutas-norte-pro con 6 réplicas, maxSurge: 2 y maxUnavailable: 1.

  1. ¿Cuál es el número máximo de pods simultáneos y el mínimo de pods disponibles?
  2. Construye una tabla con los primeros cinco pasos de la actualización, indicando en cada uno: pods viejos, pods nuevos, total y disponibles.
  3. Si el equipo de plataforma exige que nunca se baje de 6 pods sirviendo, ¿qué valores pondrías y qué recursos extra necesita el clúster?
  4. Con la configuración del punto 3 y minReadySeconds: 20, ¿cuál es la duración mínima teórica del despliegue si cada pod tarda 5 segundos en arrancar?

Ejercicio 2: Ciclo completo de actualización y rollback

Sobre tienda-web en rutas-norte-dev:

  1. Configura RollingUpdate con maxSurge: 1, maxUnavailable: 0, minReadySeconds: 10 y revisionHistoryLimit: 5, aplicándolo desde el manifiesto.
  2. Actualiza a nginx:1.27.1-alpine con su change-cause correspondiente y espera a que termine.
  3. Actualiza a nginx:1.26-alpine con otro change-cause.
  4. Muestra el historial y el detalle de la revisión 2.
  5. Simula que la 1.26 tiene un fallo grave: vuelve a la 1.27.1 y verifica que las tres réplicas la sirven.
  6. Explica por qué el historial no muestra la numeración que esperarías.

Ejercicio 3: Diagnosticar un despliegue atascado en worker-notificaciones

  1. Rompe worker-notificaciones a propósito desplegando la imagen busybox:9.9.9-inexistente, con change-cause incluido.
  2. Con cuatro comandos, diagnostica el problema siguiendo el orden correcto: estado del Deployment, ReplicaSets, pod culpable y conditions.
  3. Responde: ¿siguen enviándose los correos de confirmación durante el atasco? Justifícalo con la salida de los comandos.
  4. Congela el despliegue, comprueba que undo no hace nada mientras está pausado, reanúdalo y deshaz.
  5. Documenta el rollback con una anotación y muestra el historial final.

Soluciones

Solución 1

  1. Máximo de pods = replicas + maxSurge = 6 + 2 = 8. Mínimo disponibles = replicas - maxUnavailable = 6 − 1 = 5.

  2. Primeros cinco pasos:

Paso Acción Viejos Nuevos Total Disponibles
0 Estado inicial 6 listos 0 6 6
1 Crea 2 pods nuevos (tope de maxSurge) 6 listos 2 creándose 8 6
2 Retira 1 viejo (tope de maxUnavailable) 5 listos 2 creándose 7 5
3 Los 2 nuevos pasan a Ready 5 listos 2 listos 7 7
4 Crea 1 nuevo más (7 + 1 = 8, tope) 5 listos 2 listos + 1 creándose 8 7
5 Retira 2 viejos (disponibles 7 − 2 = 5, suelo) 3 listos 2 listos + 1 creándose 6 5
  1. Para no bajar nunca de 6 sirviendo: maxUnavailable: 0, y maxSurge a 1 o 2 según lo rápido que se quiera ir. Con maxSurge: 2, el clúster debe tener hueco para 8 pods de tienda-web a la vez: con requests de 50m de CPU y 64Mi por pod, eso son 100m de CPU y 128Mi de memoria libres de más durante el despliegue. Con maxUnavailable: 0 el clúster debe tener esa capacidad; si no la tiene, los pods nuevos se quedan en Pending y el despliegue no avanza nunca.

  2. Con maxSurge: 2 y maxUnavailable: 0, los pods se sustituyen en tandas de 2. Cada tanda tarda 5 s (arranque) + 20 s (minReadySeconds) = 25 s. Seis pods en tandas de dos son 3 tandas: 75 segundos como mínimo teórico, sin contar descargas de imagen ni el tiempo de terminación de los viejos.

Solución 2

# k8s/base/tienda-web-deployment.yaml (fragmento)
spec:
  replicas: 3
  revisionHistoryLimit: 5
  minReadySeconds: 10
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
kubectl apply -f k8s/base/tienda-web-deployment.yaml

# 2. Actualizacion a 1.27.1
kubectl annotate deployment/tienda-web \
  kubernetes.io/change-cause="v1.27.1 - parche de seguridad de nginx (RN-470)" --overwrite
kubectl set image deployment/tienda-web nginx=nginx:1.27.1-alpine
kubectl rollout status deployment/tienda-web

# 3. Actualizacion a 1.26
kubectl annotate deployment/tienda-web \
  kubernetes.io/change-cause="v1.26 - vuelta a rama anterior por compatibilidad (RN-473)" --overwrite
kubectl set image deployment/tienda-web nginx=nginx:1.26-alpine
kubectl rollout status deployment/tienda-web
deployment "tienda-web" successfully rolled out
# 4. Historial y detalle
kubectl rollout history deployment/tienda-web
kubectl rollout history deployment/tienda-web --revision=2
deployment.apps/tienda-web
REVISION  CHANGE-CAUSE
1         Despliegue inicial de tienda-web 1.27.0
2         v1.27.1 - parche de seguridad de nginx (RN-470)
3         v1.26 - vuelta a rama anterior por compatibilidad (RN-473)

deployment.apps/tienda-web with revision #2
Pod Template:
  Labels:  app=tienda-web
           pod-template-hash=6f9c4b8d7
  Annotations:  kubernetes.io/change-cause: v1.27.1 - parche de seguridad de nginx (RN-470)
  Containers:
   nginx:
    Image:  nginx:1.27.1-alpine
# 5. Rollback a la revision 2
kubectl rollout undo deployment/tienda-web --to-revision=2
kubectl rollout status deployment/tienda-web
kubectl get pods -l app=tienda-web \
  -o custom-columns=NOMBRE:.metadata.name,IMAGEN:.spec.containers[0].image
deployment.apps/tienda-web rolled back
deployment "tienda-web" successfully rolled out

NOMBRE                        IMAGEN
tienda-web-6f9c4b8d7-42kxr    nginx:1.27.1-alpine
tienda-web-6f9c4b8d7-8vnwq    nginx:1.27.1-alpine
tienda-web-6f9c4b8d7-t9mzd    nginx:1.27.1-alpine
# 6. Historial final
kubectl rollout history deployment/tienda-web
REVISION  CHANGE-CAUSE
1         Despliegue inicial de tienda-web 1.27.0
3         v1.26 - vuelta a rama anterior por compatibilidad (RN-473)
4         v1.27.1 - parche de seguridad de nginx (RN-470)
  1. La numeración sorprende porque las revisiones nunca retroceden. Al deshacer a la revisión 2, Kubernetes no "vuelve" a ella: reutiliza su ReplicaSet y lo renumera como revisión 4, la más reciente. Por eso la 2 desaparece de la lista y aparece una 4 con su mismo change-cause. El número de revisión identifica el orden en que se aplicaron los cambios, no el contenido: un rollback es un cambio más.

Solución 3

# 1. Romperlo
kubectl annotate deployment/worker-notificaciones \
  kubernetes.io/change-cause="v1.3.0 - imagen que no existe (RN-512)" --overwrite
kubectl set image deployment/worker-notificaciones worker=busybox:9.9.9-inexistente
# 2. Diagnostico en cuatro pasos
kubectl get deploy worker-notificaciones
kubectl get rs -l app=worker-notificaciones
kubectl get pods -l app=worker-notificaciones
kubectl describe pod <pod-en-ImagePullBackOff> | tail -6
kubectl describe deployment worker-notificaciones | grep -A4 Conditions
NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
worker-notificaciones   2/2     1            2           1h

NAME                               DESIRED   CURRENT   READY   AGE
worker-notificaciones-5a3f9d21c    1         1         0       90s
worker-notificaciones-8c7b5d94f    2         2         2       1h

NAME                                    READY   STATUS             RESTARTS   AGE
worker-notificaciones-5a3f9d21c-r6bkt   0/1     ImagePullBackOff   0          90s
worker-notificaciones-8c7b5d94f-k2xrt   1/1     Running            0          1h
worker-notificaciones-8c7b5d94f-p9mzq   1/1     Running            0          1h

  Warning  Failed   80s   kubelet   Failed to pull image "busybox:9.9.9-inexistente": manifest unknown

Conditions:
  Type           Status  Reason
  ----           ------  ------
  Available      True    MinimumReplicasAvailable
  Progressing    False   ProgressDeadlineExceeded
  1. Sí, los correos se siguen enviando. La prueba está en tres sitios: READY 2/2 y AVAILABLE 2 en el Deployment, los 2 pods de la revisión anterior en Running, y sobre todo Available: True con MinimumReplicasAvailable en las conditions. Solo UP-TO-DATE 1 delata que hay una versión nueva intentando entrar sin conseguirlo. Confirmación directa:
kubectl logs -l app=worker-notificaciones --tail=1 --prefix | grep enviando
[pod/worker-notificaciones-8c7b5d94f-k2xrt/worker] enviando lote de confirmaciones desde worker-notificaciones-8c7b5d94f-k2xrt
# 4. Pausar, comprobar que undo no hace nada, reanudar y deshacer
kubectl rollout pause deployment/worker-notificaciones
kubectl rollout undo deployment/worker-notificaciones
kubectl get rs -l app=worker-notificaciones
deployment.apps/worker-notificaciones paused
deployment.apps/worker-notificaciones rolled back

NAME                               DESIRED   CURRENT   READY   AGE
worker-notificaciones-5a3f9d21c    1         1         0       4m
worker-notificaciones-8c7b5d94f    2         2         2       1h

El ReplicaSet roto sigue con 1 réplica: el undo se ha registrado pero no se ha ejecutado, porque el Deployment está pausado.

kubectl rollout resume deployment/worker-notificaciones
kubectl rollout status deployment/worker-notificaciones
kubectl get rs -l app=worker-notificaciones
deployment.apps/worker-notificaciones resumed
deployment "worker-notificaciones" successfully rolled out

NAME                               DESIRED   CURRENT   READY   AGE
worker-notificaciones-5a3f9d21c    0         0         0       5m
worker-notificaciones-8c7b5d94f    2         2         2       1h
# 5. Documentar
kubectl annotate deployment/worker-notificaciones \
  kubernetes.io/change-cause="ROLLBACK a v1.2.0: la imagen de v1.3.0 no existe en el registro (RN-512)" \
  --overwrite
kubectl rollout history deployment/worker-notificaciones
REVISION  CHANGE-CAUSE
1         Despliegue inicial de worker-notificaciones 1.2.0
2         v1.3.0 - imagen que no existe (RN-512)
3         ROLLBACK a v1.2.0: la imagen de v1.3.0 no existe en el registro (RN-512)

Conclusión

Has cerrado el círculo que abrimos en la lección anterior con aquel segundo ReplicaSet que apareció sin explicación. Ahora sabes que las dos estrategias nativas del Deployment son RollingUpdate y Recreate, y por qué la elección no es de gusto sino de naturaleza de la carga: tienda-web, api-reservas y worker-notificaciones toleran dos versiones conviviendo y van con RollingUpdate; redis-cache no, y va con Recreate; y postgres-reservas no admite ninguna de las dos porque el Deployment no es su sitio: dos procesos PostgreSQL sobre el mismo directorio de datos significan bloqueo en el mejor caso y corrupción en el peor.

Sabes calcular un despliegue sobre el papel antes de ejecutarlo: replicas + maxSurge es el techo de pods y replicas - maxUnavailable el suelo de disponibles, y con esos dos números puedes reconstruir paso a paso lo que hará el controlador con las 4 réplicas de api-reservas. Conoces las combinaciones útiles y la recomendada para producción, maxSurge: 1 con maxUnavailable: 0, que protege la capacidad nominal en un puente de agosto. Ajustas el ritmo con minReadySeconds, detectas los atascos con progressDeadlineSeconds y conservas tu capacidad de deshacer con revisionHistoryLimit. Tienes clara la regla que gobierna todo: solo los cambios bajo spec.template crean una revisión, y por eso escalar no despliega, cambiar un ConfigMap no reinicia nada y existe kubectl rollout restart. Y manejas el juego completo —status, history, undo, pause, resume— con change-cause documentando cada paso, incluido el rollback de un despliegue atascado por una imagen inexistente, resuelto sin que un solo cliente dejara de comprar su billete.

Queda una advertencia pendiente y una carencia gorda. La advertencia: RollingUpdate es necesario pero no suficiente para desplegar sin corte real; sin una readinessProbe, Kubernetes marca como listo a un pod cuyo proceso acaba de arrancar y le manda tráfico antes de que pueda atenderlo. Esa pieza llega en Verificaciones de Salud y Sondas. Y la carencia: todo el tráfico del que llevamos dos lecciones hablando aún no existe. Nuestros pods tienen IPs que cambian en cada despliegue, y para hablar con api-reservas hemos tenido que buscar a mano la IP de un pod concreto. En la lección siguiente, Servicios, le daremos a cada componente de Rutas Norte una dirección estable que sobreviva a los despliegues, con balanceo de carga entre réplicas incluido.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados