La lección anterior terminó con una carencia muy concreta: un ReplicaSet mantiene con vida N copias de una plantilla, pero no sabe cambiar de versión. Cambiabas la imagen del template y los pods existentes seguían tan tranquilos con la imagen antigua. El Deployment es la pieza que cierra ese hueco y, con diferencia, el objeto que más vas a escribir en tu vida profesional con Kubernetes. Gobierna ReplicaSets, y a través de ellos, pods: te da réplicas, autorreparación, escalado, actualizaciones controladas, historial de revisiones y marcha atrás. En esta lección entenderás la cadena Deployment → ReplicaSet → Pod y qué aporta exactamente cada eslabón, convertirás por fin el pod suelto de tienda-web en un Deployment de 3 réplicas, crearás el de api-reservas, aprenderás a leer con precisión las columnas de kubectl get deploy y las conditions del estado, escalarás de dos formas distintas y observarás qué ocurre en el clúster al cambiar una imagen. Terminarás sabiendo también cuándo un Deployment no es la carga de trabajo adecuada.

Contenido

  1. La cadena Deployment → ReplicaSet → Pod
  2. De pod suelto a Deployment: tienda-web
  3. El manifiesto completo, comentado
  4. El Deployment de api-reservas
  5. Leer kubectl get deploy: READY, UP-TO-DATE, AVAILABLE
  6. status, conditions y kubectl rollout status
  7. Escalado: kubectl scale y manifiesto
  8. Observación: qué ocurre al cambiar la imagen
  9. Cuándo un Deployment no es la carga adecuada

  1. La cadena Deployment → ReplicaSet → Pod

Kubernetes podría haber metido toda la funcionalidad en un solo objeto. No lo hizo, y la razón es buena: cada nivel tiene una responsabilidad y solo una.

flowchart TD
    D["<b>Deployment</b> tienda-web<br/>Gestiona VERSIONES<br/>historial, actualización, rollback"]
    RS1["<b>ReplicaSet</b> tienda-web-7d9f8c6b4<br/>versión 1.27.0 · réplicas: 3<br/>Gestiona CANTIDAD"]
    RS2["<b>ReplicaSet</b> tienda-web-5c8b7a2d1<br/>versión 1.26.0 · réplicas: 0<br/>revisión anterior, conservada"]
    P1["Pod tienda-web-7d9f8c6b4-4kx7d"]
    P2["Pod tienda-web-7d9f8c6b4-9wq2m"]
    P3["Pod tienda-web-7d9f8c6b4-pv6cl"]
    D --> RS1
    D --> RS2
    RS1 --> P1
    RS1 --> P2
    RS1 --> P3

La división de trabajo, en una tabla:

Nivel Su única pregunta Qué sabe hacer Qué no sabe hacer
Pod ¿Están vivos mis contenedores? Reiniciar contenedores caídos (vía kubelet y restartPolicy) Recrearse si desaparece
ReplicaSet ¿Hay N pods con estas etiquetas? Crear y borrar pods hasta cuadrar el número Cambiar de versión
Deployment ¿Qué versión debe estar sirviendo, y cómo llego a ella? Crear ReplicaSets, trasvasar réplicas entre ellos, guardar historial, deshacer Nada más: delega todo lo demás

La clave para entender los Deployments es esta frase: un Deployment no gestiona pods, gestiona ReplicaSets. Cada vez que cambias algo del template, el Deployment crea un ReplicaSet nuevo y va moviendo réplicas del viejo al nuevo. Un ReplicaSet por revisión, y cada uno sabe mantener su propia cantidad de pods. El Deployment orquesta el trasvase.

Ese sufijo hexadecimal de los nombres (tienda-web-7d9f8c6b4) no es aleatorio: es el pod-template-hash, un hash calculado sobre el contenido del template. El Deployment lo añade como etiqueta a cada pod y al selector de cada ReplicaSet, y así consigue que dos ReplicaSets del mismo Deployment nunca se peleen por los mismos pods. Es exactamente la clase de selector de conjuntos que, como vimos, el viejo ReplicationController no podía expresar.

  1. De pod suelto a Deployment: tienda-web

Al final del módulo 1 dejamos tienda-web como un pod suelto, y comprobamos que al borrarlo no volvía. Ha llegado el momento de saldar esa deuda.

Partimos del namespace vacío, tal como quedó al final de la lección anterior:

kubectl config set-context --current --namespace=rutas-norte-dev
kubectl get all
No resources found in rutas-norte-dev namespace.

La conversión de un pod a un Deployment sigue siempre el mismo patrón mecánico:

Pod suelto Deployment
apiVersion: v1 apiVersion: apps/v1
kind: Pod kind: Deployment
metadata metadata (del Deployment)
— spec.replicas
— spec.selector
spec: (los contenedores) spec.template.spec
metadata.labels spec.template.metadata.labels

Es decir: el pod entero se hunde un nivel y se convierte en el template, y por encima aparecen replicas y selector.

  1. El manifiesto completo, comentado

# k8s/base/tienda-web-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: tienda-web
  namespace: rutas-norte-dev
  labels:
    app: tienda-web
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
  annotations:
    kubernetes.io/change-cause: "Despliegue inicial de tienda-web 1.27.0"
spec:
  replicas: 3
  revisionHistoryLimit: 5
  selector:
    matchLabels:
      app: tienda-web
      entorno: dev
  template:
    metadata:
      labels:
        app: tienda-web
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      containers:
        - name: nginx
          image: nginx:1.27-alpine
          ports:
            - name: http
              containerPort: 80
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "200m"
              memory: "128Mi"

Lo nuevo respecto al ReplicaSet de la lección anterior, campo a campo:

  • kind: Deployment, mismo grupo apps/v1.
  • metadata.annotations con kubernetes.io/change-cause: texto libre que quedará registrado en el historial de revisiones como el "motivo" de este despliegue. Lo explotaremos a fondo en la lección siguiente.
  • spec.replicas: 3: tres réplicas de tienda-web. El Deployment no las crea él; se lo encarga a su ReplicaSet.
  • spec.revisionHistoryLimit: 5: cuántos ReplicaSets antiguos (con 0 réplicas) se conservan para poder deshacer. Por defecto son 10.
  • spec.selector: obligatorio e inmutable, igual que en el ReplicaSet. Y con el mismo aviso: que sea preciso, app + entorno.
  • spec.template: idéntico al del ReplicaSet. Aquí está la clave conceptual: cualquier cambio bajo template dispara una nueva revisión; los cambios fuera de él (como replicas) no.

No aparece spec.strategy porque el valor por defecto, RollingUpdate, es el que queremos. Ese campo y todos sus parámetros son el contenido íntegro de la lección siguiente, Actualizaciones, Rollbacks y Estrategias de Despliegue.

Aplícalo:

kubectl apply -f k8s/base/tienda-web-deployment.yaml
kubectl get deploy,rs,pods
deployment.apps/tienda-web created

NAME                         READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/tienda-web   3/3     3            3           9s

NAME                                    DESIRED   CURRENT   READY   AGE
replicaset.apps/tienda-web-7d9f8c6b4    3         3         3       9s

NAME                               READY   STATUS    RESTARTS   AGE
pod/tienda-web-7d9f8c6b4-4kx7d     1/1     Running   0          9s
pod/tienda-web-7d9f8c6b4-9wq2m     1/1     Running   0          9s
pod/tienda-web-7d9f8c6b4-pv6cl     1/1     Running   0          9s

Ahí está la cadena completa, materializada en tres bloques de salida. Has creado un objeto y han aparecido tres niveles. Comprueba la genealogía:

kubectl get rs tienda-web-7d9f8c6b4 -o jsonpath='{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}{"\n"}'
kubectl get pod tienda-web-7d9f8c6b4-4kx7d -o jsonpath='{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}{"\n"}'
Deployment/tienda-web
ReplicaSet/tienda-web-7d9f8c6b4

Y mira la etiqueta que ha añadido el Deployment por su cuenta a los pods:

kubectl get pods -l app=tienda-web --show-labels
NAME                          READY   STATUS    RESTARTS   AGE   LABELS
tienda-web-7d9f8c6b4-4kx7d    1/1     Running   0          2m    app=tienda-web,app.kubernetes.io/part-of=rutas-norte,entorno=dev,pod-template-hash=7d9f8c6b4

Tú no escribiste pod-template-hash. Lo añadió el Deployment, y es lo que le permitirá distinguir los pods de una revisión de los de otra.

Verifica que ahora sí se autorrepara:

kubectl delete pod tienda-web-7d9f8c6b4-4kx7d
kubectl get pods -l app=tienda-web
pod "tienda-web-7d9f8c6b4-4kx7d" deleted

NAME                          READY   STATUS    RESTARTS   AGE
tienda-web-7d9f8c6b4-9wq2m    1/1     Running   0          3m
tienda-web-7d9f8c6b4-hs4bd    1/1     Running   0          2s
tienda-web-7d9f8c6b4-pv6cl    1/1     Running   0          3m

El pod suelto del módulo 1 no volvía. Este vuelve en dos segundos, y quien lo devuelve no es el Deployment sino su ReplicaSet, exactamente como aprendiste en la lección anterior.

  1. El Deployment de api-reservas

Segundo componente. Mismo esquema, con las particularidades de la API.

# k8s/base/api-reservas-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-reservas
  namespace: rutas-norte-dev
  labels:
    app: api-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
  annotations:
    kubernetes.io/change-cause: "Despliegue inicial de api-reservas 2.4.0"
spec:
  replicas: 2
  revisionHistoryLimit: 5
  selector:
    matchLabels:
      app: api-reservas
      entorno: dev
  template:
    metadata:
      labels:
        app: api-reservas
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: api
          image: node:20-alpine
          command: ["node", "-e"]
          args:
            - |
              const http = require('http');
              const pod = process.env.HOSTNAME;
              http.createServer((req, res) => {
                res.writeHead(200, {'Content-Type': 'application/json'});
                res.end(JSON.stringify({servicio: 'api-reservas', version: '2.4.0', pod}));
              }).listen(3000, () => console.log(`api-reservas 2.4.0 lista en ${pod}`));
          ports:
            - name: http
              containerPort: 3000
          env:
            - name: ENTORNO
              value: "dev"
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "256Mi"

Detalles deliberados de este manifiesto:

  • replicas: 2: en desarrollo bastan dos. En producción el número lo decidirá el autoescalado del módulo 9.
  • terminationGracePeriodSeconds: 30 dentro del template.spec: recuerda de la lección de Pods que es lo que permite que una reserva en curso no se corte durante un despliegue.
  • La respuesta incluye el nombre del pod (process.env.HOSTNAME, que en un pod es su nombre). Esto nos servirá en la lección de Servicios para demostrar el balanceo de carga: cada petición responderá con un pod distinto.
kubectl apply -f k8s/base/api-reservas-deployment.yaml
kubectl get deploy
deployment.apps/api-reservas created

NAME           READY   UP-TO-DATE   AVAILABLE   AGE
api-reservas   2/2     2            2           14s
tienda-web     3/3     3            3           9m

Comprueba que la API responde, usando un pod efímero como los de la lección de Pods:

kubectl run test --rm -it --image=curlimages/curl:8.8.0 --restart=Never -- \
  curl -s http://$(kubectl get pod -l app=api-reservas -o jsonpath='{.items[0].status.podIP}'):3000/disponibilidad
{"servicio":"api-reservas","version":"2.4.0","pod":"api-reservas-6b4c9d7f5-x2jkp"}
pod "test" deleted

Funciona, pero fíjate en la incomodidad: hemos tenido que averiguar la IP de un pod concreto para hablarle. Esa IP cambiará en el próximo despliegue. El problema está servido para la lección de Servicios.

  1. Leer kubectl get deploy: READY, UP-TO-DATE, AVAILABLE

Estas tres columnas son la información más densa de todo Kubernetes, y confundirlas lleva a diagnósticos equivocados. Las tres cuentan pods, pero cuentan cosas distintas.

NAME           READY   UP-TO-DATE   AVAILABLE   AGE
api-reservas   2/2     2            2           14s
Columna Qué cuenta exactamente Campo del status
READY pods listos / pods deseados. "Listo" = todos sus contenedores pasan sus comprobaciones readyReplicas / replicas
UP-TO-DATE Pods creados con la revisión actual del template updatedReplicas
AVAILABLE Pods que llevan listos al menos minReadySeconds seguidos availableReplicas

La forma útil de leerlas es preguntarse qué significa cada discrepancia:

Situación Interpretación
READY 3/3, UP-TO-DATE 3, AVAILABLE 3 Todo correcto y estable
READY 2/3 Un pod no está listo: arrancando, o fallando
UP-TO-DATE 1 de 3 Actualización en curso: solo 1 pod tiene la versión nueva
READY 3/3 pero AVAILABLE 2 Un pod está listo pero aún no ha cumplido minReadySeconds; se considera "joven"
READY 0/3 durante minutos Despliegue roto: mira los pods con describe

Un ejemplo de actualización a medias, que verás mucho:

NAME           READY   UP-TO-DATE   AVAILABLE   AGE
api-reservas   4/4     2            3           12m

Eso se lee así: hay 4 pods listos aunque el deseado sean 4, pero solo 2 tienen la versión nueva, y solo 3 llevan el tiempo suficiente para contarse como disponibles. Estamos en mitad de un despliegue progresivo, y todavía conviven pods de dos revisiones.

Para ver más, -o wide añade imágenes y selector:

kubectl get deploy -o wide
NAME           READY   UP-TO-DATE   AVAILABLE   AGE   CONTAINERS   IMAGES            SELECTOR
api-reservas   2/2     2            2           14m   api          node:20-alpine    app=api-reservas,entorno=dev
tienda-web     3/3     3            3           23m   nginx        nginx:1.27-alpine app=tienda-web,entorno=dev

  1. status, conditions y kubectl rollout status

Como todo objeto de Kubernetes, un Deployment tiene un spec (lo que pides) y un status (lo que hay). El status completo:

kubectl get deploy tienda-web -o jsonpath='{.status}' | python3 -m json.tool
{
    "availableReplicas": 3,
    "conditions": [
        {
            "lastTransitionTime": "2026-08-05T12:03:11Z",
            "lastUpdateTime": "2026-08-05T12:03:11Z",
            "message": "Deployment has minimum availability.",
            "reason": "MinimumReplicasAvailable",
            "status": "True",
            "type": "Available"
        },
        {
            "lastTransitionTime": "2026-08-05T12:03:05Z",
            "lastUpdateTime": "2026-08-05T12:03:11Z",
            "message": "ReplicaSet \"tienda-web-7d9f8c6b4\" has successfully progressed.",
            "reason": "NewReplicaSetAvailable",
            "status": "True",
            "type": "Progressing"
        }
    ],
    "observedGeneration": 1,
    "readyReplicas": 3,
    "replicas": 3,
    "updatedReplicas": 3
}

Las conditions son el mecanismo estándar de Kubernetes para expresar estados parciales, y un Deployment tiene tres tipos:

Condition status: True significa status: False significa
Available Hay suficientes réplicas disponibles para dar servicio El Deployment no está sirviendo con garantías
Progressing El despliegue avanza (o terminó bien) Se ha atascado: superó progressDeadlineSeconds
ReplicaFailure (solo aparece si hay problema) No se pueden crear pods: cuota, permisos, límites —

La combinación Available: True + Progressing: True con razón NewReplicaSetAvailable es la firma de un Deployment sano y terminado.

Otro campo que merece atención es observedGeneration. Cada cambio en el spec incrementa metadata.generation; el controlador copia ese número a status.observedGeneration cuando ha procesado el cambio. Si ves generation: 5 y observedGeneration: 4, el controlador aún no ha reaccionado a tu último cambio.

En el día a día no lees el JSON: usas el comando que lo interpreta por ti.

kubectl rollout status deployment/tienda-web
deployment "tienda-web" successfully rolled out

kubectl rollout status bloquea la terminal hasta que el despliegue termina y devuelve código de salida 0 si va bien, distinto de 0 si falla. Por eso es la instrucción que se pone en cualquier pipeline de CI/CD justo detrás del kubectl apply: convierte "he enviado el cambio" en "el cambio está funcionando". Admite --timeout:

kubectl apply -f k8s/base/tienda-web-deployment.yaml
kubectl rollout status deployment/tienda-web --timeout=120s

  1. Escalado: kubectl scale y manifiesto

Rutas Norte tiene un puente a la vista y hay que subir réplicas de api-reservas. Dos vías, las mismas que con el ReplicaSet.

Imperativa, para reaccionar ya:

kubectl scale deployment api-reservas --replicas=4
kubectl get deploy api-reservas
deployment.apps/api-reservas scaled

NAME           READY   UP-TO-DATE   AVAILABLE   AGE
api-reservas   4/4     4            4           18m

Observa un detalle importantísimo:

kubectl get rs -l app=api-reservas
NAME                     DESIRED   CURRENT   READY   AGE
api-reservas-6b4c9d7f5   4         4         4       18m

No ha aparecido un ReplicaSet nuevo. Escalar no cambia el template, así que no es una revisión nueva: el mismo ReplicaSet pasa de 2 a 4. Esta distinción —cambios que crean revisión frente a cambios que no— es el eje de la lección siguiente.

Declarativa, la correcta según las convenciones del proyecto: edita replicas: 4 en k8s/base/api-reservas-deployment.yaml, haz commit y aplica.

kubectl apply -f k8s/base/api-reservas-deployment.yaml

Y una advertencia práctica muy común: si escalas con kubectl scale y no llevas el cambio al fichero, el siguiente kubectl apply devolverá las réplicas al valor del manifiesto. Se te caerá la capacidad extra en el peor momento posible. Es la razón de la regla del proyecto: el clúster refleja Git, no al revés.

Un tercer camino, útil para automatizar sin editar ficheros a mano:

kubectl patch deployment api-reservas --type=merge -p '{"spec":{"replicas":4}}'

Volvamos a 2 antes de continuar:

kubectl scale deployment api-reservas --replicas=2

  1. Observación: qué ocurre al cambiar la imagen

Esta es la observación que motiva toda la lección siguiente. Cambia la versión de nginx de tienda-web y mira la estructura que aparece, sin preocuparte todavía del mecanismo.

kubectl set image deployment/tienda-web nginx=nginx:1.27.1-alpine
kubectl get rs -l app=tienda-web
deployment.apps/tienda-web image updated

NAME                    DESIRED   CURRENT   READY   AGE
tienda-web-5f6d8b9c7    3         3         3       25s
tienda-web-7d9f8c6b4    0         0         0       31m

Hay dos ReplicaSets. El nuevo (5f6d8b9c7) tiene las 3 réplicas; el viejo (7d9f8c6b4) se ha quedado a 0 pero no se ha borrado. Y ahí está la clave del historial: ese ReplicaSet vacío conserva la definición completa de la versión anterior, así que volver atrás consiste simplemente en devolverle sus réplicas.

Compara con lo que ocurría en la lección anterior al hacer kubectl set image sobre un ReplicaSet: nada, los pods seguían con la imagen vieja. Aquí, en cambio, los pods son nuevos y tienen la imagen nueva:

kubectl get pods -l app=tienda-web -o custom-columns=NOMBRE:.metadata.name,IMAGEN:.spec.containers[0].image
NOMBRE                        IMAGEN
tienda-web-5f6d8b9c7-2xk4m    nginx:1.27.1-alpine
tienda-web-5f6d8b9c7-7q9wd    nginx:1.27.1-alpine
tienda-web-5f6d8b9c7-nb3rt    nginx:1.27.1-alpine

Y el historial ya tiene dos entradas:

kubectl rollout history deployment/tienda-web
deployment.apps/tienda-web
REVISION  CHANGE-CAUSE
1         Despliegue inicial de tienda-web 1.27.0
2         <none>

De momento, quédate con estas cuatro observaciones:

  1. Cambiar el template crea un ReplicaSet nuevo, con un pod-template-hash distinto.
  2. El Deployment trasvasa réplicas del viejo al nuevo de forma progresiva, no de golpe.
  3. Los ReplicaSets antiguos se conservan a 0 réplicas como historial (hasta revisionHistoryLimit).
  4. Durante el trasvase conviven pods de dos versiones, que es lo que hace posible desplegar sin corte de servicio.

Cómo se controla ese trasvase paso a paso —maxSurge, maxUnavailable, Recreate, pause, undo— es exactamente el contenido de Actualizaciones, Rollbacks y Estrategias de Despliegue. Y las variantes avanzadas, blue-green y canary, esperan en 11-04.

Devuelve tienda-web a la imagen original para empezar la lección siguiente en un punto conocido:

kubectl apply -f k8s/base/tienda-web-deployment.yaml
kubectl rollout status deployment/tienda-web
deployment.apps/tienda-web configured
deployment "tienda-web" successfully rolled out

  1. Cuándo un Deployment no es la carga adecuada

El Deployment es la opción por defecto, pero no la única. Está diseñado para cargas sin estado, intercambiables y de larga duración: sus pods son ganado, no mascotas. Se llaman con sufijos aleatorios, se crean y se destruyen en cualquier orden y ninguno tiene identidad propia.

Cuando eso no encaja, hay otro objeto:

Necesidad Objeto correcto Por qué falla el Deployment Lección
Identidad y almacenamiento estables por réplica (bases de datos, colas, clústeres con quórum) StatefulSet Nombres aleatorios, orden de arranque no garantizado, todas las réplicas compartirían el mismo volumen 06-01
Exactamente un pod en cada nodo (agentes de logs, métricas, red) DaemonSet Un Deployment reparte N réplicas donde quepan, sin garantizar cobertura de nodos 06-02
Tarea que se ejecuta, termina y ya está Job Un Deployment reiniciaría el pod eternamente al terminar (restartPolicy: Always obligatoria) 06-03
Tarea programada por horario CronJob Un Deployment no tiene noción de calendario 06-03

Aplicado a los seis componentes de Rutas Norte:

Componente Carga de trabajo Motivo
tienda-web Deployment Sin estado, réplicas idénticas e intercambiables
api-reservas Deployment Sin estado; toda la persistencia está en PostgreSQL y Redis
postgres-reservas StatefulSet Necesita disco propio, nombre de red estable y arranque ordenado
redis-cache Deployment (una réplica) o StatefulSet Con una sola instancia y estado prescindible, un Deployment basta
worker-notificaciones Deployment Proceso de larga duración sin estado; no recibe tráfico, así que no llevará Service
informes-ocupacion CronJob Se ejecuta a las 02:30, termina y no vuelve hasta el día siguiente

Un aviso que evita un error clásico: redis-cache como Deployment solo es aceptable con una réplica. Si escalaras a 3, tendrías tres cachés independientes detrás del mismo nombre, y cada petición de api-reservas iría a una caché distinta con contenido distinto. El resultado serían aciertos de caché erráticos e imposibles de diagnosticar. Escalar no es gratis: solo funciona si el componente es realmente sin estado.

Errores Comunes y Consejos

  • Olvidar spec.selector. Es obligatorio en apps/v1. Sin él, la API rechaza el manifiesto.
  • Intentar cambiar el selector de un Deployment existente. Es inmutable. Si lo necesitas, hay que borrar el Deployment (con --cascade=orphan si no quieres cortar el servicio) y crear uno nuevo.
  • Poner las etiquetas solo en el metadata de arriba. Las que gobiernan los pods son las de spec.template.metadata.labels. Es el error de principiante más repetido.
  • Escalar con kubectl scale y no tocar el fichero. El siguiente apply revertirá el cambio, probablemente en mal momento.
  • Confundir READY con AVAILABLE. READY es "puede atender"; AVAILABLE es "puede atender y lleva estable el tiempo mínimo exigido".
  • Creer que kubectl apply espera a que termine el despliegue. No espera: solo envía el objeto. Para esperar de verdad, encadena kubectl rollout status.
  • Usar un Deployment para PostgreSQL. Con más de una réplica, varias instancias intentarían montar el mismo volumen y escribir a la vez. Corrupción garantizada.
  • Escalar redis-cache a varias réplicas con un Deployment. Tendrás N cachés incoherentes, no una caché replicada.
  • Consejo: genera el esqueleto con kubectl create deployment api-reservas --image=node:20-alpine --replicas=2 --dry-run=client -o yaml > deploy.yaml y añade después etiquetas, recursos y anotaciones.
  • Consejo: para ver la cadena completa de un componente de un vistazo, kubectl get deploy,rs,pods -l app=tienda-web. Tres niveles en una sola orden.

Ejercicios

Ejercicio 1: Deployment de worker-notificaciones

worker-notificaciones es un proceso de fondo que consume una cola y envía correos. No recibe tráfico entrante, así que no lleva ports ni Service.

Escribe k8s/base/worker-notificaciones-deployment.yaml con 2 réplicas en rutas-norte-dev, imagen busybox:1.36, un comando que escriba cada 10 segundos enviando lote de confirmaciones desde <nombre del pod>, las tres etiquetas del proyecto, terminationGracePeriodSeconds: 45 y requests de 50m/64Mi. Después:

  1. Aplícalo y espera con el comando que bloquea hasta que termine el despliegue.
  2. Muestra la cadena Deployment → ReplicaSet → Pod con un solo comando.
  3. Comprueba en los logs que ambos pods trabajan.
  4. Explica por qué este componente lleva un plazo de gracia mayor que tienda-web.

Ejercicio 2: Leer el estado de un despliegue

Un compañero te envía esta captura de un clúster de preproducción y te pide diagnóstico:

NAME           READY   UP-TO-DATE   AVAILABLE   AGE
api-reservas   3/5     2            3           7m
Conditions:
  Type           Status  Reason
  ----           ------  ------
  Available      True    MinimumReplicasAvailable
  Progressing    False   ProgressDeadlineExceeded

Responde, justificando cada respuesta:

  1. ¿Cuántos pods deberían existir y cuántos están listos?
  2. ¿Cuántos tienen la versión nueva?
  3. ¿Está el servicio caído?
  4. ¿Qué ha pasado con el despliegue?
  5. ¿Qué dos comandos ejecutarías, en este orden, para encontrar la causa raíz?

Ejercicio 3: De pod suelto a Deployment, y decidir la carga correcta

  1. Crea a mano un pod suelto redis-cache en rutas-norte-dev con redis:7.2-alpine y las tres etiquetas del proyecto.
  2. Conviértelo en un Deployment de 1 réplica llamado redis-cache, escribiendo el manifiesto y borrando el pod suelto. Verifica que la caché responde a redis-cli ping.
  3. Escala el Deployment a 3 réplicas y explica por qué esto es un error de diseño para Rutas Norte, aunque técnicamente funcione. Vuelve a 1.
  4. Para cada uno de los seis componentes de Rutas Norte, indica el objeto correcto y una frase de justificación.

Soluciones

Solución 1

# k8s/base/worker-notificaciones-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: worker-notificaciones
  namespace: rutas-norte-dev
  labels:
    app: worker-notificaciones
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
  annotations:
    kubernetes.io/change-cause: "Despliegue inicial de worker-notificaciones 1.2.0"
spec:
  replicas: 2
  selector:
    matchLabels:
      app: worker-notificaciones
      entorno: dev
  template:
    metadata:
      labels:
        app: worker-notificaciones
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: worker
          image: busybox:1.36
          command: ["sh", "-c"]
          args:
            - |
              trap 'echo "cerrando: terminando envios pendientes"; sleep 5; exit 0' TERM
              while true; do
                echo "enviando lote de confirmaciones desde $HOSTNAME"
                sleep 10
              done
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "200m"
              memory: "128Mi"
kubectl apply -f k8s/base/worker-notificaciones-deployment.yaml
kubectl rollout status deployment/worker-notificaciones
kubectl get deploy,rs,pods -l app=worker-notificaciones
kubectl logs -l app=worker-notificaciones --tail=2 --prefix
deployment.apps/worker-notificaciones created
deployment "worker-notificaciones" successfully rolled out

NAME                                    READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/worker-notificaciones   2/2     2            2           12s

NAME                                               DESIRED   CURRENT   READY   AGE
replicaset.apps/worker-notificaciones-8c7b5d94f    2         2         2       12s

NAME                                          READY   STATUS    RESTARTS   AGE
pod/worker-notificaciones-8c7b5d94f-k2xrt     1/1     Running   0          12s
pod/worker-notificaciones-8c7b5d94f-p9mzq     1/1     Running   0          12s

[pod/worker-notificaciones-8c7b5d94f-k2xrt/worker] enviando lote de confirmaciones desde worker-notificaciones-8c7b5d94f-k2xrt
[pod/worker-notificaciones-8c7b5d94f-p9mzq/worker] enviando lote de confirmaciones desde worker-notificaciones-8c7b5d94f-p9mzq
  1. El plazo de gracia es mayor porque worker-notificaciones puede estar a mitad de un envío de correo cuando recibe el SIGTERM. Si el kubelet lo mata antes de terminar, un cliente que ha pagado su billete se queda sin justificante y el mensaje puede quedar en un estado ambiguo (consumido de la cola pero no enviado). tienda-web solo sirve ficheros estáticos: sus peticiones duran milisegundos y 30 segundos sobran.

Solución 2

  1. Deberían existir 5 pods (spec.replicas = 5, denominador de READY) y hay 3 listos. Faltan 2.
  2. Solo 2 tienen la versión nueva (UP-TO-DATE). Los otros pods listos son de la revisión anterior.
  3. No está caído. Available: True con razón MinimumReplicasAvailable indica que hay suficientes réplicas disponibles para atender tráfico. Sí está degradado: sirve con 3 pods en lugar de 5.
  4. El despliegue se ha atascado: Progressing: False con razón ProgressDeadlineExceeded significa que superó progressDeadlineSeconds (600 s por defecto) sin avanzar. Los 2 pods de la versión nueva no llegan a estar listos, así que el Deployment no puede seguir retirando los viejos. Causa típica: imagen inexistente, CrashLoopBackOff por configuración o una sonda mal ajustada.
  5. Los dos comandos, en este orden:
# 1. Ver que pods estan fallando y con que razon
kubectl get pods -l app=api-reservas -n rutas-norte-pre

# 2. La causa raiz del pod problematico
kubectl describe pod <pod-que-no-arranca> -n rutas-norte-pre
# y, si el contenedor reinicia:
kubectl logs <pod-que-no-arranca> -n rutas-norte-pre --previous

Solución 3

# 1. Pod suelto
kubectl run redis-cache --image=redis:7.2-alpine \
  --labels="app=redis-cache,app.kubernetes.io/part-of=rutas-norte,entorno=dev"
# 2. k8s/base/redis-cache-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis-cache
  namespace: rutas-norte-dev
  labels:
    app: redis-cache
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
  annotations:
    kubernetes.io/change-cause: "Despliegue inicial de redis-cache 7.2"
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis-cache
      entorno: dev
  template:
    metadata:
      labels:
        app: redis-cache
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      containers:
        - name: redis
          image: redis:7.2-alpine
          ports:
            - name: redis
              containerPort: 6379
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "200m"
              memory: "256Mi"
kubectl delete pod redis-cache
kubectl apply -f k8s/base/redis-cache-deployment.yaml
kubectl rollout status deployment/redis-cache
kubectl exec deploy/redis-cache -- redis-cli ping
pod "redis-cache" deleted
deployment.apps/redis-cache created
deployment "redis-cache" successfully rolled out
PONG

Nota práctica: kubectl exec deploy/redis-cache elige automáticamente uno de los pods del Deployment, así no hay que copiar el nombre con su sufijo aleatorio.

# 3. Escalar a 3 y volver
kubectl scale deployment redis-cache --replicas=3
kubectl get pods -l app=redis-cache
kubectl scale deployment redis-cache --replicas=1

Es un error de diseño porque las tres réplicas no forman una caché replicada: son tres cachés independientes y vacías, cada una con su propia memoria. Cuando en la lección siguiente pongamos un Service delante, cada consulta de api-reservas caerá en una réplica distinta, así que la disponibilidad de plazas que se escribió en la réplica A no se encontrará al leer de la B. El resultado sería una tasa de aciertos de caché en torno al 33 %, consultas extra a postgres-reservas y, lo peor, respuestas incoherentes según a qué réplica caiga la petición. Replicar Redis de verdad requiere un StatefulSet con configuración de réplica o cluster, tema del módulo 6.

  1. Objeto correcto por componente:
Componente Objeto Justificación
tienda-web Deployment Sin estado; cualquier réplica sirve cualquier petición
api-reservas Deployment Sin estado; persiste todo en PostgreSQL y Redis
postgres-reservas StatefulSet Necesita volumen propio, identidad de red estable y arranque ordenado
redis-cache Deployment de 1 réplica Estado prescindible y reconstruible; con más réplicas haría falta StatefulSet
worker-notificaciones Deployment Proceso de larga duración, sin estado y sin tráfico entrante
informes-ocupacion CronJob Ejecución programada y finita cada noche a las 02:30

Conclusión

Ya dominas el objeto que sostiene la mayor parte de cualquier plataforma en Kubernetes. Has visto la cadena Deployment → ReplicaSet → Pod con una responsabilidad nítida en cada eslabón: el pod mantiene vivos sus contenedores, el ReplicaSet mantiene la cantidad, y el Deployment gestiona las versiones. Sabes que un Deployment no manda sobre pods sino sobre ReplicaSets, y que el pod-template-hash es el truco que permite que dos revisiones convivan sin pelearse por los mismos pods.

Has saldado la deuda que arrastrábamos desde el módulo 1: tienda-web ya no es un pod frágil sino un Deployment de 3 réplicas que se recupera solo en dos segundos, y api-reservas tiene el suyo con 2 réplicas. Sabes leer las tres columnas de kubectl get deploy sin confundirlas —READY es lo que atiende, UP-TO-DATE lo que tiene la versión actual, AVAILABLE lo que además lleva estable el tiempo mínimo— e interpretar las conditions Available y Progressing, que son las que te dirán en producción si un despliegue va bien, va degradado o se ha quedado atascado. Escalas con kubectl scale para apagar fuegos y con el manifiesto para que el cambio perdure, y sabes que el clúster refleja Git y no al revés. Además, tienes el criterio para no forzar la herramienta: PostgreSQL pide StatefulSet, un agente por nodo pide DaemonSet, el informe nocturno pide CronJob, y escalar redis-cache a tres réplicas rompería la coherencia de la caché.

Queda por explicar lo más interesante de lo que has visto: al cambiar la imagen apareció un segundo ReplicaSet, el viejo se quedó a 0 réplicas sin borrarse y durante unos segundos convivieron pods de dos versiones. Eso no es un efecto secundario, es el mecanismo con el que Rutas Norte va a conseguir por fin su objetivo de cero cortes en los despliegues, frente a los uno o dos minutos de parada de los martes de madrugada. En la lección siguiente, Actualizaciones, Rollbacks y Estrategias de Despliegue, controlaremos ese trasvase pod a pod con maxSurge y maxUnavailable, veremos por qué postgres-reservas no admitiría una actualización progresiva, y aprenderemos a diagnosticar y deshacer un despliegue roto con kubectl rollout undo.

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