La lección anterior terminó con un problema sin resolver: api-reservas y tienda-web corren en rutas-norte-dev, pero como pods sueltos. Si el nodo cae, si el kubelet los desaloja por falta de memoria o si alguien los borra, desaparecen y nadie los devuelve a la vida. Necesitamos algo que vigile permanentemente y actúe. Ese algo es un controlador, y el primero que vamos a conocer a fondo es el ReplicaSet: el objeto cuya única misión es garantizar que en todo momento exista un número concreto de pods que casen con un conjunto de etiquetas. En esta lección verás qué es exactamente un controlador y cómo el de ReplicaSet materializa el bucle de reconciliación que estudiaste en la arquitectura, escribirás tu primer manifiesto de ReplicaSet para tienda-web, comprobarás la autorreparación borrando pods a propósito, entenderás cómo el borrado en cascada se apoya en ownerReferences, descubrirás el mecanismo de adopción de pods huérfanos y por qué un selector demasiado amplio es una bomba de relojería, y aprenderás a escalar con kubectl scale. Y terminarás sabiendo por qué, pese a todo esto, casi nunca escribirás un ReplicaSet a mano.

Contenido

  1. Qué es un controlador
  2. El controlador de ReplicaSet y el bucle de reconciliación
  3. Anatomía del manifiesto: replicas, selector y template
  4. La regla de oro: el template debe casar con el selector
  5. Autorreparación en directo con tienda-web
  6. ownerReferences y borrado en cascada
  7. Adopción de pods huérfanos y el peligro de los selectores amplios
  8. Escalado manual con kubectl scale
  9. ReplicaSet frente a ReplicationController
  10. Por qué casi nunca se crea un ReplicaSet a mano

  1. Qué es un controlador

En Arquitectura de Kubernetes definimos el bucle de reconciliación como el corazón del sistema. Un controlador es simplemente un programa que ejecuta ese bucle para un tipo de objeto:

flowchart LR
    A["Observa<br/>el estado deseado<br/>(spec)"] --> B["Observa<br/>el estado real<br/>(mundo)"]
    B --> C{"¿Coinciden?"}
    C -->|Sí| D["No hace nada.<br/>Actualiza status"]
    C -->|No| E["Actúa para<br/>acercar el real<br/>al deseado"]
    E --> A
    D --> A

Características que comparten todos los controladores de Kubernetes y que conviene interiorizar:

  • No terminan nunca. Son bucles infinitos, no scripts de un solo disparo.
  • Son idempotentes. Ejecutar el bucle mil veces con el estado ya correcto no cambia nada.
  • Solo hablan con el kube-apiserver. Nunca contactan directamente con los nodos ni con los contenedores. Piden cambios a la API y el kubelet los materializa.
  • Actúan por nivel, no por evento. No reaccionan a "se ha borrado un pod"; comparan constantemente cuántos hay contra cuántos debería haber. Por eso son robustos: si el controlador estuvo caído diez minutos, al volver corrige la diferencia acumulada sin necesidad de reproducir el histórico.

La mayoría vive dentro del kube-controller-manager: el de Deployment, el de ReplicaSet, el de Job, el de Namespace, el de endpoints, el de ServiceAccount... Todos con la misma anatomía y responsabilidades distintas.

  1. El controlador de ReplicaSet y el bucle de reconciliación

Concretemos el bucle genérico para el caso que nos ocupa. El controlador de ReplicaSet ejecuta, sin parar, para cada ReplicaSet del clúster:

  1. Lee spec.replicas: cuántos pods deben existir (estado deseado).
  2. Lee spec.selector: qué etiquetas identifican a sus pods.
  3. Consulta a la API cuántos pods de ese namespace casan con el selector y no están terminando (estado real).
  4. Compara:
    • Si faltan pods, crea tantos como falten usando spec.template como molde.
    • Si sobran, elige víctimas y las borra.
    • Si coinciden, no hace nada y actualiza el status.
flowchart TD
    RS["ReplicaSet tienda-web<br/>spec.replicas = 3<br/>spec.selector: app=tienda-web"]
    RS --> Q{"Pods con app=tienda-web<br/>encontrados: 2"}
    Q -->|2 < 3| CREATE["Crear 1 pod<br/>a partir de spec.template"]
    CREATE --> API["kube-apiserver<br/>persiste el nuevo pod"]
    API --> SCH["kube-scheduler<br/>le asigna nodo"]
    SCH --> KUB["kubelet<br/>arranca los contenedores"]
    KUB --> Q

Un matiz decisivo, y que explica todo lo que viene después: el ReplicaSet no lleva una lista de los pods que ha creado. No tiene memoria. Cada vuelta del bucle vuelve a preguntar "¿qué pods hay con estas etiquetas?". Su relación con los pods es puramente por etiqueta. Esto tiene dos consecuencias enormes:

  • Si borras un pod suyo, en la siguiente vuelta ve que faltan y crea uno nuevo. Autorreparación.
  • Si aparece por ahí un pod con esas mismas etiquetas creado por otro, el ReplicaSet lo cuenta como propio. Adopción. Y si con él ya sobran, borrará a alguien.

Cuándo elige a quién borrar cuando sobran: el controlador prioriza eliminar pods Pending sobre Running, los que llevan menos tiempo listos, los que tienen más reinicios y los que están en nodos con más réplicas. Es decir, sacrifica siempre lo menos valioso.

  1. Anatomía del manifiesto: replicas, selector y template

Vamos a convertir tienda-web en algo que se cuide solo. Crea el fichero en el repositorio del proyecto:

# k8s/base/tienda-web-replicaset.yaml
apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: tienda-web
  namespace: rutas-norte-dev
  labels:
    app: tienda-web
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  replicas: 3
  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"

Lectura campo a campo, con atención a lo que cambia respecto al pod suelto:

  • apiVersion: apps/v1. Aquí sí hay grupo. El Pod está en el grupo core (v1), pero ReplicaSet, Deployment, StatefulSet y DaemonSet viven en el grupo apps. Si escribes v1 a secas, la API rechazará el manifiesto con no matches for kind "ReplicaSet" in version "v1".
  • metadata (el de arriba): identifica al ReplicaSet, no a los pods. Sus etiquetas sirven para encontrar el propio ReplicaSet con kubectl get rs -l ....
  • spec.replicas: 3: el estado deseado. Es un número, no un rango. El escalado automático por carga llega en el módulo 9.
  • spec.selector.matchLabels: qué pods son míos. Aquí exigimos dos etiquetas: un pod debe tener app=tienda-web y entorno=dev para contar. El selector admite también expresiones más ricas (matchExpressions), que veremos en Etiquetas, Selectores y Anotaciones. Es un campo obligatorio e inmutable.
  • spec.template: la plantilla del pod. Es exactamente un manifiesto de pod sin apiVersion ni kind, con su propio metadata (etiquetas de los pods hijos) y su spec (contenedores). Es el molde que el controlador usa para fabricar réplicas.

Fíjate en la estructura de muñeca rusa, que es el punto donde más se traba todo el mundo al principio:

ReplicaSet
├── metadata        <- identifica al ReplicaSet
└── spec
    ├── replicas    <- cuántos pods
    ├── selector    <- qué pods son míos
    └── template
        ├── metadata  <- etiquetas de CADA POD creado
        └── spec      <- contenedores de CADA POD creado

Antes de aplicarlo, retira el pod suelto de tienda-web para no interferir en el experimento (verás en el apartado 7 exactamente qué pasaría si no lo hiciéramos):

kubectl delete pod tienda-web --ignore-not-found
kubectl apply -f k8s/base/tienda-web-replicaset.yaml
kubectl get rs,pods
pod "tienda-web" deleted
replicaset.apps/tienda-web created

NAME                         DESIRED   CURRENT   READY   AGE
replicaset.apps/tienda-web   3         3         3       8s

NAME                       READY   STATUS    RESTARTS   AGE
pod/api-reservas           1/1     Running   0          51m
pod/tienda-web-4kx7d       1/1     Running   0          8s
pod/tienda-web-9wq2m       1/1     Running   0          8s
pod/tienda-web-pv6cl       1/1     Running   0          8s

Dos observaciones sobre esa salida:

  • Los pods se llaman tienda-web-<sufijo aleatorio>. El controlador genera el nombre a partir del nombre del ReplicaSet más cinco caracteres aleatorios, porque no puede haber tres objetos con el mismo nombre en un namespace.
  • Las columnas del ReplicaSet: DESIRED es spec.replicas; CURRENT, cuántos pods existen; READY, cuántos están listos para recibir tráfico. Cuando las tres coinciden, el bucle está en equilibrio.

  1. La regla de oro: el template debe casar con el selector

Esta es la restricción que más manifiestos rechazados provoca:

Las etiquetas de spec.template.metadata.labels deben satisfacer spec.selector. Si no, la API rechaza el objeto.

La razón es de pura lógica: si el ReplicaSet fabricara pods que su propio selector no reconoce, contaría cero pods propios, crearía tres más, tampoco los reconocería, crearía otros tres… un bucle infinito que llenaría el clúster. Kubernetes lo impide de raíz validando el manifiesto.

Compruébalo provocando el error a propósito:

sed 's/app: tienda-web$/app: tienda-web-mal/' k8s/base/tienda-web-replicaset.yaml \
  | kubectl apply --dry-run=server -f -
The ReplicaSet "tienda-web" is invalid: spec.template.metadata.labels:
  Invalid value: map[string]string{"app":"tienda-web-mal", ...}:
  `selector` does not match template `labels`

Nota la asimetría, que sí es válida y muy útil: el template puede tener más etiquetas que el selector. En nuestro manifiesto el selector pide app y entorno, pero los pods llevan además app.kubernetes.io/part-of. Eso está permitido; lo prohibido es que falte alguna de las que el selector exige.

Como norma de proyecto: mantén el selector lo más pequeño y estable posible (una o dos etiquetas que identifiquen inequívocamente al componente y el entorno) y añade el resto de metadatos solo en el template.

  1. Autorreparación en directo con tienda-web

Ha llegado el momento de comprobar lo que el módulo 1 nos dejó pendiente. Abre dos terminales.

En la primera, ponte a observar:

kubectl get pods -l app=tienda-web -w

En la segunda, mata un pod a traición (usa uno de los nombres reales de tu clúster):

kubectl delete pod tienda-web-9wq2m

En la primera terminal verás algo así:

NAME               READY   STATUS        RESTARTS   AGE
tienda-web-4kx7d   1/1     Running       0          4m
tienda-web-9wq2m   1/1     Running       0          4m
tienda-web-pv6cl   1/1     Running       0          4m
tienda-web-9wq2m   1/1     Terminating   0          4m
tienda-web-t8m4r   0/1     Pending       0          0s
tienda-web-t8m4r   0/1     ContainerCreating 0      0s
tienda-web-9wq2m   0/1     Terminating   0          4m
tienda-web-t8m4r   1/1     Running       0          2s

Menos de dos segundos. Y algo muy revelador: el pod sustituto (tienda-web-t8m4r) aparece en Pending antes de que el borrado del anterior haya terminado. El controlador no espera a que el viejo desaparezca del todo; en cuanto la API marca el pod como terminando, deja de contarlo y actúa.

Los eventos del ReplicaSet lo cuentan por escrito:

kubectl describe rs tienda-web | tail -6
Events:
  Type    Reason            Age   From                   Message
  ----    ------            ----  ----                   -------
  Normal  SuccessfulCreate  4m    replicaset-controller  Created pod: tienda-web-4kx7d
  Normal  SuccessfulCreate  4m    replicaset-controller  Created pod: tienda-web-9wq2m
  Normal  SuccessfulCreate  4m    replicaset-controller  Created pod: tienda-web-pv6cl
  Normal  SuccessfulCreate  9s    replicaset-controller  Created pod: tienda-web-t8m4r

Compara con lo que ocurrió en el módulo 1 al borrar el pod suelto: nada. Silencio. Aquí, en cambio, hay un replicaset-controller firmando cada creación. Esta es, en una línea, la diferencia entre un contenedor y una plataforma. Para Rutas Norte significa que la caída nocturna de cuatro horas y media de marzo se habría resuelto sola en dos segundos, de madrugada, sin que nadie se enterara.

Un experimento aún más contundente: bórralos todos a la vez.

kubectl delete pods -l app=tienda-web
kubectl get pods -l app=tienda-web
pod "tienda-web-4kx7d" deleted
pod "tienda-web-pv6cl" deleted
pod "tienda-web-t8m4r" deleted

NAME               READY   STATUS    RESTARTS   AGE
tienda-web-2jf9x   1/1     Running   0          3s
tienda-web-hs4bd   1/1     Running   0          3s
tienda-web-zq7nm   1/1     Running   0          3s

Tres pods nuevos, con nombres nuevos e IPs nuevas. Y ahí asoma el siguiente problema del curso: si las IPs cambian cada vez, ¿a qué dirección se conecta tienda-web para hablar con api-reservas? Ese es el trabajo de los Servicios.

  1. ownerReferences y borrado en cascada

¿Cómo sabe Kubernetes que esos pods "pertenecen" al ReplicaSet, si la relación es por etiquetas? Porque, al crearlos, el controlador estampa en cada pod una referencia a su dueño:

kubectl get pod tienda-web-2jf9x -o jsonpath='{.metadata.ownerReferences}' | python3 -m json.tool
[
    {
        "apiVersion": "apps/v1",
        "kind": "ReplicaSet",
        "name": "tienda-web",
        "uid": "6c1f8f0e-2b7a-4a91-9a3d-1d8f2c5e77b1",
        "controller": true,
        "blockOwnerDeletion": true
    }
]

Campo a campo:

Campo Significado
kind / name / uid Quién es el dueño. El uid importa: si borras el ReplicaSet y creas otro con el mismo nombre, es un objeto distinto
controller: true Este dueño es el controlador del pod. Solo puede haber uno
blockOwnerDeletion: true El dueño no se considera borrado hasta que este hijo desaparezca

Sobre esas referencias trabaja el garbage collector, otro controlador del kube-controller-manager: recorre los objetos, y cuando encuentra uno cuyo dueño ya no existe, lo borra. Esto es el borrado en cascada, y es la razón de que kubectl delete rs tienda-web se lleve también los tres pods.

Kubernetes ofrece tres políticas de propagación:

Política Comportamiento Cómo se pide
Background (por defecto) Borra el dueño de inmediato; el recolector borra los hijos después kubectl delete rs tienda-web
Foreground Marca el dueño como "en borrado", borra primero los hijos y al final el dueño --cascade=foreground
Orphan Borra solo el dueño y deja vivos a los hijos, eliminándoles el ownerReferences --cascade=orphan

La tercera es un recurso de emergencia muy útil, y de paso nos prepara el terreno para el apartado siguiente:

kubectl delete rs tienda-web --cascade=orphan
kubectl get rs,pods -l app=tienda-web
replicaset.apps "tienda-web" deleted

NAME                   READY   STATUS    RESTARTS   AGE
pod/tienda-web-2jf9x   1/1     Running   0          6m
pod/tienda-web-hs4bd   1/1     Running   0          6m
pod/tienda-web-zq7nm   1/1     Running   0          6m

El ReplicaSet ya no existe, pero los tres pods siguen sirviendo tráfico. Ahora son huérfanos: nadie los vigila. Si borras uno, no vuelve.

  1. Adopción de pods huérfanos y el peligro de los selectores amplios

Tenemos tres pods huérfanos con las etiquetas app=tienda-web, entorno=dev. Vuelve a crear el ReplicaSet:

kubectl apply -f k8s/base/tienda-web-replicaset.yaml
kubectl get rs,pods -l app=tienda-web
replicaset.apps/tienda-web created

NAME                         DESIRED   CURRENT   READY   AGE
replicaset.apps/tienda-web   3         3         3       4s

NAME                   READY   STATUS    RESTARTS   AGE
pod/tienda-web-2jf9x   1/1     Running   0          8m
pod/tienda-web-hs4bd   1/1     Running   0          8m
pod/tienda-web-zq7nm   1/1     Running   0          8m

Mira bien las edades: 8 minutos. El ReplicaSet acaba de nacer y no ha creado ni un solo pod. Ha adoptado los tres huérfanos porque casaban con su selector y no tenían dueño. Al adoptarlos, les ha escrito su ownerReferences:

kubectl get pod tienda-web-2jf9x -o jsonpath='{.metadata.ownerReferences[0].name}{"\n"}'
tienda-web

Las reglas exactas de la adopción:

  1. El pod debe estar en el mismo namespace.
  2. Sus etiquetas deben casar con el selector.
  3. No debe tener ya un ownerReferences con controller: true. Un pod con dueño no se roba: el ReplicaSet lo ignora.

El peligro: selectores demasiado amplios

Aquí está la trampa. Supongamos que alguien, con prisa, define el ReplicaSet de tienda-web con un selector perezoso:

# MAL: selector demasiado amplio
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/part-of: rutas-norte   # ¡todos los componentes llevan esta etiqueta!

Ese selector casa con todos los pods de la plataforma: tienda-web, api-reservas, redis-cache, el worker... Consecuencias, en cadena:

  1. El ReplicaSet cuenta todos los pods de Rutas Norte que hay en el namespace. Supongamos 5.
  2. Su estado deseado son 3. Sobran 2.
  3. Elige dos víctimas y las borra. Puede perfectamente borrar api-reservas y el worker.
  4. Los controladores de esos componentes los recrean, el ReplicaSet vuelve a ver que sobran y los borra otra vez. Guerra de controladores.

Es un incidente real y bastante habitual en clústeres jóvenes, y el síntoma —pods que se borran solos sin explicación— es desconcertante hasta que se entiende el mecanismo. Simulémoslo con seguridad para verlo con nuestros propios ojos:

# Un pod suelto que lleva por casualidad las etiquetas del selector
kubectl run intruso --image=nginx:1.27-alpine \
  --labels="app=tienda-web,entorno=dev,app.kubernetes.io/part-of=rutas-norte"
sleep 5
kubectl get pods -l app=tienda-web
pod/intruso created

NAME               READY   STATUS        RESTARTS   AGE
intruso            1/1     Terminating   0          5s
tienda-web-2jf9x   1/1     Running       0          12m
tienda-web-hs4bd   1/1     Running       0          12m
tienda-web-zq7nm   1/1     Running       0          12m

El ReplicaSet ha adoptado a intruso, ha contado 4 donde debía haber 3 y lo ha ejecutado en el acto (era el más joven, la víctima preferente). Si en vez de un nginx de prueba hubiera sido un pod importante, se habría ido igual.

Las reglas del proyecto que se derivan de esto:

  • El selector debe identificar al componente de forma inequívoca: app: <componente> más entorno: <entorno>, nunca solo part-of.
  • Ningún componente comparte selector con otro.
  • Nunca crees pods sueltos con las etiquetas de un componente gobernado. Para depurar, usa etiquetas distintas o pods efímeros con --rm.
kubectl delete pod intruso --ignore-not-found

  1. Escalado manual con kubectl scale

Cambiar el número de réplicas es cambiar spec.replicas. Hay tres formas.

Imperativa, rápida (para incidencias):

kubectl scale replicaset tienda-web --replicas=5
kubectl get rs tienda-web
replicaset.apps/tienda-web scaled

NAME         DESIRED   CURRENT   READY   AGE
tienda-web   5         5         5       15m

Condicional, muy útil en scripts para evitar pisar cambios de otros:

kubectl scale replicaset tienda-web --current-replicas=5 --replicas=8

Si en ese momento no hay exactamente 5 réplicas, el comando falla en lugar de aplicar el cambio.

Declarativa, la correcta según las convenciones del proyecto: edita replicas: 5 en el fichero, haz commit y aplica.

kubectl apply -f k8s/base/tienda-web-replicaset.yaml
Forma Ventaja Problema
kubectl scale Instantáneo, ideal en un incidente El clúster deja de coincidir con Git; el próximo apply revierte el cambio
Editar el manifiesto Trazable, revisable, reproducible Más lento

La regla que ya conoces del módulo 1 sigue vigente: si un cambio no está en Git, no existe. Escala con kubectl scale para apagar un fuego, pero lleva el cambio al fichero inmediatamente después.

Vuelve a 3 antes de seguir:

kubectl scale replicaset tienda-web --replicas=3

Y observa qué pasa al reducir:

kubectl get pods -l app=tienda-web
NAME               READY   STATUS        RESTARTS   AGE
tienda-web-2jf9x   1/1     Running       0          16m
tienda-web-hs4bd   1/1     Running       0          16m
tienda-web-k9dpq   1/1     Terminating   0          40s
tienda-web-w2sxv   1/1     Terminating   0          40s
tienda-web-zq7nm   1/1     Running       0          16m

Los que se van son los más jóvenes, exactamente como anticipamos: el controlador sacrifica lo menos consolidado.

  1. ReplicaSet frente a ReplicationController

Verás documentación antigua y respuestas de foros hablando de ReplicationController. Es el antecesor del ReplicaSet, de la época de Kubernetes 1.0, y sigue existiendo por compatibilidad, pero no debes usarlo.

Aspecto ReplicationController ReplicaSet
Grupo de API v1 (core) apps/v1
Selector Solo igualdad simple (app: tienda-web) Igualdad y conjuntos (matchExpressions: in, notin, exists)
Campo del selector spec.selector como mapa plano spec.selector.matchLabels / matchExpressions
Lo usa el Deployment No Sí
Estado Obsoleto de hecho Vigente

La diferencia funcional relevante es el selector de conjuntos. Gracias a matchExpressions, un Deployment puede lanzar consultas como "pods de api-reservas cuyo pod-template-hash sea uno de estos dos", que es justo lo que necesita para gestionar dos versiones simultáneas durante una actualización progresiva. Con el selector plano del ReplicationController eso no era expresable, y por eso los Deployments se construyeron sobre ReplicaSets.

Resumen operativo: si ves kind: ReplicationController, es código heredado. Migra a Deployment.

  1. Por qué casi nunca se crea un ReplicaSet a mano

Y aquí llega la sorpresa de la lección: después de todo esto, en tu vida profesional escribirás muy pocos ReplicaSets. Lo que escribirás son Deployments.

La razón es que al ReplicaSet le falta lo esencial para operar un servicio: no sabe cambiar de versión. Es magnífico manteniendo N copias de una plantilla, pero si modificas la imagen de su template:

kubectl set image rs/tienda-web nginx=nginx:1.27.1-alpine
kubectl get pods -l app=tienda-web -o custom-columns=NOMBRE:.metadata.name,IMAGEN:.spec.containers[0].image
replicaset.apps/tienda-web image updated

NOMBRE             IMAGEN
tienda-web-2jf9x   nginx:1.27-alpine
tienda-web-hs4bd   nginx:1.27-alpine
tienda-web-zq7nm   nginx:1.27-alpine

Los pods siguen con la imagen vieja. El ReplicaSet solo usa el template cuando crea un pod; los existentes no se tocan porque el estado deseado ("tres pods con estas etiquetas") sigue satisfecho. Para desplegar la nueva versión tendrías que borrarlos a mano, uno a uno, esperando entre medias. Sin control de ritmo, sin verificación, sin marcha atrás.

Eso es precisamente lo que aporta el Deployment: gestiona ReplicaSets, no pods. Cuando cambias la imagen, crea un ReplicaSet nuevo con la versión nueva y va trasvasando réplicas de uno a otro de forma controlada.

Necesidad ReplicaSet Deployment
Mantener N réplicas vivas Sí Sí (a través de un ReplicaSet)
Escalar Sí Sí
Actualizar la imagen sin corte No Sí
Volver a la versión anterior No Sí (rollout undo)
Historial de revisiones No Sí
Pausar un despliegue a medias No Sí

Por eso la regla del proyecto es:

En Rutas Norte no se crean ReplicaSets a mano. Se crean Deployments, y ellos gestionan sus ReplicaSets.

Entonces, ¿para qué sirve esta lección? Para entender el nivel intermedio. Cuando en la lección siguiente veas dos ReplicaSets del mismo Deployment conviviendo durante una actualización, o cuando en producción tengas que averiguar por qué hay pods de dos versiones a la vez, la explicación estará en lo que acabas de aprender. Los ReplicaSets no desaparecen: se vuelven invisibles porque los gestiona otro.

Deja el clúster listo para la lección siguiente:

kubectl delete rs tienda-web
kubectl delete pod api-reservas --ignore-not-found
kubectl get all
replicaset.apps "tienda-web" deleted
pod "api-reservas" deleted

No resources found in rutas-norte-dev namespace.

Errores Comunes y Consejos

  • Usar apiVersion: v1 para un ReplicaSet. Está en apps/v1. El error es no matches for kind "ReplicaSet" in version "v1".
  • Etiquetas del template que no casan con el selector. La API rechaza el objeto con selector does not match template labels. Revisa que estás mirando spec.template.metadata.labels y no el metadata.labels de arriba: son sitios distintos.
  • Selectores demasiado amplios. Es el error más destructivo de esta lección: un ReplicaSet puede adoptar y borrar pods de otro componente. Selector = componente + entorno, siempre.
  • Crear pods sueltos con las etiquetas de un componente gobernado. Serán adoptados y, muy probablemente, ejecutados en segundos.
  • Esperar que cambiar el template actualice los pods existentes. No lo hace. Solo afecta a los pods que se creen a partir de ese momento. Esa es la carencia que resuelve el Deployment.
  • Intentar cambiar el selector de un ReplicaSet existente. Es inmutable. Hay que borrar y recrear (o, en la práctica, dejar que el Deployment gestione el cambio creando un ReplicaSet nuevo).
  • Confundir CURRENT con READY. CURRENT cuenta pods que existen; READY, pods que pueden atender tráfico. Un 3/3 en CURRENT con 0 en READY es un despliegue roto.
  • Consejo: kubectl get rs -o wide añade las columnas CONTAINERS, IMAGES y SELECTOR. Es la forma más rápida de ver de un vistazo qué versión sirve cada ReplicaSet.
  • Consejo: cuando algo se comporte de forma inexplicable, mira siempre kubectl get pod <nombre> -o jsonpath='{.metadata.ownerReferences}'. Saber quién es el dueño de un pod resuelve la mitad de los misterios.

Ejercicios

Ejercicio 1: ReplicaSet de api-reservas y autorreparación

Escribe k8s/base/api-reservas-replicaset.yaml con un ReplicaSet de 2 réplicas de api-reservas en rutas-norte-dev, reutilizando el contenedor de la lección Pods (imagen node:20-alpine con el servidor mínimo) y respetando las tres etiquetas del proyecto. El selector debe usar app y entorno.

  1. Aplícalo y comprueba que hay 2 pods.
  2. Borra uno y mide cuánto tarda en aparecer el sustituto.
  3. Averigua, con un solo comando, quién es el dueño del pod nuevo.
  4. Muestra los eventos del ReplicaSet que documentan la creación.

Ejercicio 2: Huérfanos y adopción

Partiendo del ReplicaSet del ejercicio anterior:

  1. Bórralo con la política que deja vivos a los pods y comprueba que siguen ahí.
  2. Verifica que los pods ya no tienen dueño.
  3. Vuelve a aplicar el mismo manifiesto y demuestra, mirando la edad de los pods, que no se han creado pods nuevos.
  4. Explica en tres líneas por qué esto es una ventaja operativa real y qué habría pasado si el ReplicaSet nuevo hubiera pedido 1 réplica en lugar de 2.

Ejercicio 3: Investigar un selector peligroso

Un compañero ha desplegado en rutas-norte-dev este manifiesto y desde entonces "los pods se borran solos":

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: monitor-plataforma
  namespace: rutas-norte-dev
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/part-of: rutas-norte
  template:
    metadata:
      labels:
        app.kubernetes.io/part-of: rutas-norte
    spec:
      containers:
        - name: monitor
          image: busybox:1.36
          command: ["sh", "-c", "while true; do sleep 30; done"]
  1. Explica exactamente qué está ocurriendo y por qué.
  2. Predice qué pasaría si estuvieran corriendo los 2 pods de api-reservas del ejercicio 1 cuando se aplica.
  3. Propón el manifiesto corregido.
  4. Indica qué comando usarías, en un clúster real, para identificar al culpable de un borrado inesperado.

Soluciones

Solución 1

# k8s/base/api-reservas-replicaset.yaml
apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: api-reservas
  namespace: rutas-norte-dev
  labels:
    app: api-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api-reservas
      entorno: dev
  template:
    metadata:
      labels:
        app: api-reservas
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      containers:
        - name: api
          image: node:20-alpine
          command: ["node", "-e"]
          args:
            - |
              const http = require('http');
              http.createServer((req, res) => {
                res.writeHead(200, {'Content-Type': 'application/json'});
                res.end(JSON.stringify({servicio: 'api-reservas', pod: process.env.HOSTNAME}));
              }).listen(3000, () => console.log('api-reservas escuchando en 3000'));
          ports:
            - name: http
              containerPort: 3000
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "256Mi"
kubectl apply -f k8s/base/api-reservas-replicaset.yaml
kubectl get rs api-reservas
kubectl get pods -l app=api-reservas
replicaset.apps/api-reservas created

NAME           DESIRED   CURRENT   READY   AGE
api-reservas   2         2         2       12s

NAME                 READY   STATUS    RESTARTS   AGE
api-reservas-c8n4t   1/1     Running   0          12s
api-reservas-xq7dz   1/1     Running   0          12s
kubectl delete pod api-reservas-c8n4t && kubectl get pods -l app=api-reservas
pod "api-reservas-c8n4t" deleted

NAME                 READY   STATUS    RESTARTS   AGE
api-reservas-m3kp9   1/1     Running   0          2s
api-reservas-xq7dz   1/1     Running   0          1m

El sustituto aparece en 1-2 segundos.

kubectl get pod api-reservas-m3kp9 \
  -o jsonpath='{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}{"\n"}'
kubectl describe rs api-reservas | tail -5
ReplicaSet/api-reservas

Events:
  Type    Reason            Age   From                   Message
  Normal  SuccessfulCreate  1m    replicaset-controller  Created pod: api-reservas-c8n4t
  Normal  SuccessfulCreate  1m    replicaset-controller  Created pod: api-reservas-xq7dz
  Normal  SuccessfulCreate  8s    replicaset-controller  Created pod: api-reservas-m3kp9

Solución 2

# 1. Borrado dejando huerfanos
kubectl delete rs api-reservas --cascade=orphan
kubectl get rs,pods -l app=api-reservas
replicaset.apps "api-reservas" deleted

NAME                     READY   STATUS    RESTARTS   AGE
pod/api-reservas-m3kp9   1/1     Running   0          4m
pod/api-reservas-xq7dz   1/1     Running   0          5m
# 2. Sin dueno
kubectl get pod api-reservas-m3kp9 -o jsonpath='{.metadata.ownerReferences}{"\n"}'

La salida vacía confirma que ya no tienen ownerReferences.

# 3. Readopcion
kubectl apply -f k8s/base/api-reservas-replicaset.yaml
kubectl get rs,pods -l app=api-reservas
replicaset.apps/api-reservas created

NAME                           DESIRED   CURRENT   READY   AGE
replicaset.apps/api-reservas   2         2         2       3s

NAME                     READY   STATUS    RESTARTS   AGE
pod/api-reservas-m3kp9   1/1     Running   0          5m
pod/api-reservas-xq7dz   1/1     Running   0          6m

Los pods tienen 5 y 6 minutos, mientras que el ReplicaSet tiene 3 segundos: no se ha creado nada nuevo, se han adoptado los existentes.

  1. La ventaja operativa es que permite sustituir el controlador sin tocar el servicio: si necesitas recrear el ReplicaSet (por ejemplo, porque hay que cambiar su selector, que es inmutable), lo borras con --cascade=orphan, aplicas el nuevo y los pods siguen atendiendo peticiones sin un solo segundo de corte. Si el ReplicaSet nuevo hubiera pedido 1 réplica, habría adoptado los dos huérfanos, habría contado 2 donde debía haber 1 y habría borrado uno de inmediato, eligiendo al más joven.

Solución 3

  1. El selector app.kubernetes.io/part-of: rutas-norte casa con todos los pods de la plataforma, porque esa etiqueta es común a los seis componentes por convención del proyecto. El ReplicaSet monitor-plataforma adopta cuanto encuentra en el namespace, compara con su replicas: 1 y borra todo lo que sobre. Como los otros controladores recrean sus pods, se entra en un ciclo de creación y borrado continuo: la "guerra de controladores".

  2. Con los 2 pods de api-reservas corriendo, al aplicar el manifiesto el monitor los adoptaría junto a su propio pod (3 en total), vería que sobran 2 y borraría dos de ellos, muy probablemente los de api-reservas si son los más jóvenes. El ReplicaSet de api-reservas los recrearía, el monitor los volvería a borrar, y la API se llenaría de eventos SuccessfulDelete y SuccessfulCreate. Con toda probabilidad, api-reservas dejaría de dar servicio de forma intermitente.

  3. Manifiesto corregido: selector propio y exclusivo del componente.

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: monitor-plataforma
  namespace: rutas-norte-dev
  labels:
    app: monitor-plataforma
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  replicas: 1
  selector:
    matchLabels:
      app: monitor-plataforma      # identifica SOLO a este componente
      entorno: dev
  template:
    metadata:
      labels:
        app: monitor-plataforma
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      containers:
        - name: monitor
          image: busybox:1.36
          command: ["sh", "-c", "while true; do sleep 30; done"]
  1. Para identificar al culpable de un borrado inesperado:
# Que controladores han borrado pods recientemente en el namespace
kubectl get events -n rutas-norte-dev --sort-by=.lastTimestamp \
  --field-selector reason=SuccessfulDelete

# Quien es el dueno actual de cada pod: revela adopciones indebidas
kubectl get pods -n rutas-norte-dev \
  -o custom-columns=POD:.metadata.name,DUENO:.metadata.ownerReferences[0].name

# Que selectores hay declarados y si se solapan
kubectl get rs -n rutas-norte-dev -o wide

Conclusión

Ya tienes el primer controlador del curso completamente desmontado. Sabes que un controlador es un bucle infinito, idempotente y por nivel que solo habla con el kube-apiserver, y has visto cómo el controlador de ReplicaSet lo concreta: lee spec.replicas, cuenta los pods que casan con spec.selector y crea o borra hasta cuadrar, usando spec.template como molde. Conoces la estructura de muñeca rusa del manifiesto y la regla innegociable de que las etiquetas del template deben satisfacer el selector.

Has comprobado la autorreparación en primera persona: borraste un pod de tienda-web y volvió en dos segundos; los borraste todos y volvieron los tres. Ese es, literalmente, el problema que le costó a Rutas Norte cuatro horas y media de ventas una noche de marzo, resuelto. Entiendes que la relación dueño-hijo se materializa en ownerReferences, que el garbage collector la usa para el borrado en cascada, y que las tres políticas de propagación —Background, Foreground y Orphan— te dan control fino, incluida la maniobra de sustituir un controlador sin cortar el servicio. Has visto la cara amable de la adopción de huérfanos y su cara peligrosa: un selector demasiado amplio convierte a un ReplicaSet en un destructor de pods ajenos. Y sabes escalar con kubectl scale, sin olvidar que el cambio debe acabar siempre en Git.

Pero también has descubierto su límite: cambiar la imagen del template no actualiza los pods existentes. Un ReplicaSet mantiene, no despliega. No sabe cambiar de versión, ni volver atrás, ni llevar un historial. Todo eso lo aporta el nivel superior, que es exactamente la lección siguiente: Deployments. Allí convertiremos por fin el pod suelto de tienda-web en un Deployment de 3 réplicas, crearemos el de api-reservas, y veremos aparecer bajo el capó, gestionado automáticamente, el mismo ReplicaSet que acabamos de aprender a leer.

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