0%
Pods, Services y Deployments

Pods y Services

Los tres recursos que escribirá todos los días.

15-20 min

Pods, Services y Deployments

Un Pod tiene una dirección IP. Esa dirección cambia cada vez que se recrea el Pod, y un Pod se recrea a menudo: en cada despliegue, en cada fallo, cada vez que el planificador decide moverlo. Por tanto, ninguna aplicación puede llamar a otra por su dirección. Este es el problema que resuelve esta lección, y es más profundo de lo que parece.

Los tres recursos que siguen forman una cadena en la que cada eslabón tiene una razón de ser precisa. El Pod ejecuta. El Deployment garantiza que exista el número correcto de Pods de la versión correcta, y sabe pasar de una versión a otra sin cortes. El Service proporciona un nombre estable y una dirección estable frente a un conjunto de Pods cuya identidad varía constantemente.

Lo que une a estos tres objetos no es una referencia directa, y es el punto que desconcierta al principio: no se conocen por su nombre, sino por labels. Un Service no sabe qué Pods expone; declara un criterio de selección, y el sistema le asocia de forma continua todos los Pods que lo cumplen. Este acoplamiento indirecto es lo que permite que un Pod desaparezca y que su sustituto reciba tráfico de inmediato, sin ninguna reconfiguración.

Comprender este mecanismo de labels es comprender el 80 % de Kubernetes. Un selector mal escrito produce exactamente el síntoma más opaco para el principiante: un Service que responde, pero con un error de conexión, porque no apunta a nada.

El Deployment en lugar del Pod

No se crea un Pod a mano salvo en una prueba desechable. Un Pod solo no tiene ninguna resiliencia: si muere, sigue muerto. Se declara un Deployment, que asume esa responsabilidad.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  labels:
    app: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web            # debe coincidir con los labels del template de abajo
  template:               # el molde a partir del cual se fabrican los Pods
    metadata:
      labels:
        app: web          # los labels que llevarán los Pods creados
    spec:
      containers:
        - name: nginx
          image: nginx:1.27-alpine
          ports:
            - containerPort: 80

Dos trampas en este archivo, ambas ligadas al mismo punto.

La primera: spec.selector.matchLabels y spec.template.metadata.labels deben coincidir. El selector dice «los Pods de los que soy responsable tienen este label», el template dice «los Pods que fabrico llevan este label». Si divergen, el API server rechaza el manifiesto, lo cual es una buena noticia: el error se detecta al escribir.

La segunda: spec.selector es inmutable tras la creación. Para modificarlo, hay que eliminar y volver a crear el Deployment. Elija, por tanto, sus labels con cierto cuidado.

kubectl apply -f deployment.yaml
kubectl get pods -l app=web -w   # observar el escalado en directo
kubectl get deployment web       # réplicas listas sobre réplicas deseadas

El intermediario ReplicaSet

Un Deployment no gestiona los Pods directamente. Crea un ReplicaSet, y es ese ReplicaSet el que mantiene el número de Pods. Esta capa parece superflua hasta que se entiende para qué sirve:

kubectl get replicasets
# NAME             DESIRED   CURRENT   READY   AGE
# web-7d4b8c9f5    3         3         3       2m

Cada versión del template produce un ReplicaSet distinto. En una actualización, el Deployment reduce el ReplicaSet antiguo y hace crecer el nuevo. El antiguo se conserva con cero réplicas: es exactamente lo que hace posible que la reversión sea instantánea: la definición anterior sigue ahí, basta con volver a escalarla.

Compruebe la jerarquía: elimine un Pod a mano y el ReplicaSet lo recreará en unos segundos, al constatar que el número ya no coincide.

kubectl delete pod web-7d4b8c9f5-xk2mp
kubectl get pods -l app=web     # un nuevo Pod, con un nuevo sufijo

Los labels y los selectores

Un label es un par clave-valor libre, aplicado a cualquier objeto. No es documentación: es el mecanismo de enlace del sistema.

metadata:
  labels:
    app: web
    tier: frontend
    version: v1
    environnement: production

kubectl sabe consultar por estos labels, lo que sustituye con ventaja la lectura manual de listas:

kubectl get pods -l app=web                    # igualdad
kubectl get pods -l app=web,tier=frontend      # conjunción
kubectl get pods -l 'app!=web'                 # negación
kubectl get pods -l 'environnement in (staging,production)'   # conjunto
kubectl get pods -l version                    # presencia de la clave
kubectl get pods --show-labels                 # mostrar los labels

El consejo que evita más desorden: reserve los labels del selector para la identidad de la aplicación (app, tier) y ponga todo lo que varía a menudo —un número de versión, un identificador de commit— en labels fuera del selector o en anotaciones. Un selector que contiene version: v1 rompe la continuidad del Service en cada entrega.

El Service: una dirección que no se mueve

Un Service crea una dirección IP virtual y un nombre DNS estables, y reparte el tráfico entre todos los Pods que coinciden con su selector.

ClusterIP, el caso por defecto

apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: ClusterIP         # valor por defecto: accesible desde el interior del clúster
  selector:
    app: web              # todos los Pods con app=web pasan a ser destinos
  ports:
    - name: http
      port: 80            # puerto del Service
      targetPort: 80      # puerto en el que escucha el contenedor
      protocol: TCP

La distinción port / targetPort es constante y conviene fijarla: port es lo que llaman los clientes, targetPort es lo que escucha el contenedor. Nada obliga a que sean iguales, y en la práctica rara vez lo son.

El Service es accesible por su nombre desde cualquier Pod del clúster, gracias al DNS interno:

web                        # mismo namespace
web.atelier                # namespace explícito
web.atelier.svc.cluster.local   # nombre completo

Compruébelo desde un Pod efímero; es la prueba que despeja la duda más rápido:

kubectl run debug --image=busybox:1.36 --rm -it --restart=Never -- \
  wget -qO- http://web:80

El diagnóstico esencial: los endpoints

Un Service que no encuentra ningún Pod no devuelve un error explícito. Existe, tiene una dirección, y simplemente rechaza las conexiones. La causa es casi siempre un selector que no coincide con nada.

kubectl get endpoints web   # los Pods realmente detrás del Service

# ¿Lista vacía? El selector no coincide con ningún Pod. Compare ambos:
kubectl get service web -o jsonpath='{.spec.selector}'
kubectl get pods --show-labels

Quédese con este reflejo: conexión rechazada en un Service, se miran los endpoints antes que nada. Esto convierte media hora de tanteos en treinta segundos.

NodePort

NodePort abre el mismo puerto en todos los nodos del clúster, dentro del rango 30000-32767.

apiVersion: v1
kind: Service
metadata:
  name: web-nodeport
spec:
  type: NodePort
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80
      nodePort: 30080     # opcional; si no, Kubernetes asigna uno
# minikube proporciona la URL directamente accesible
minikube service web-nodeport --url

Es práctico en desarrollo y conviene evitarlo en producción: el puerto no es estándar, no hay TLS ni nombre de dominio, y la exposición afecta a todos los nodos a la vez. Para tráfico HTTP real, la respuesta adecuada es un Ingress, que se verá en la lección 6.

LoadBalancer y su dependencia del proveedor

apiVersion: v1
kind: Service
metadata:
  name: web-lb
spec:
  type: LoadBalancer
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80

Seamos precisos, porque este punto engaña a mucha gente: Kubernetes no sabe crear un balanceador de carga. Emite una solicitud y espera a que un controlador externo la satisfaga. En AWS, Google Cloud o Azure, ese controlador existe y aprovisiona un recurso facturado. En un clúster local o una instalación sin integración con la nube, nadie responde, y el Service se queda indefinidamente con una dirección externa <pending>.

kubectl get service web-lb
# EXTERNAL-IP se queda en <pending> sin controlador: no es un bug

# Con minikube, un comando simula el comportamiento (déjelo en ejecución)
minikube tunnel
TipoAlcanceUso realista
ClusterIPinterno al clústercomunicación entre servicios: el caso habitual
NodePortpuerto abierto en cada nododesarrollo, acceso de depuración
LoadBalancerdirección externa proporcionada por la nubepunto de entrada público, depende del proveedor

Las actualizaciones progresivas

Es el servicio que mejor presta Kubernetes, y es casi gratuito. Cambie la imagen de un Deployment: los Pods se sustituyen por lotes, manteniendo el servicio disponible durante toda la transición.

# Fragmento que se inserta en el spec del Deployment, junto a replicas.
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1     # como máximo 1 Pod no disponible durante el cambio
      maxSurge: 1           # como máximo 1 Pod más de los 4 deseados

Con estos valores, durante la transición hay siempre entre 3 y 5 Pods, de los cuales al menos 3 están disponibles. maxUnavailable: 0 garantiza cero pérdida de capacidad, a costa de un despliegue más lento, ya que hay que crear primero antes de destruir.

kubectl apply -f deployment.yaml    # tras modificar la imagen en el manifiesto

# Variante imperativa, útil para una corrección urgente
kubectl set image deployment/web nginx=nginx:1.27.1-alpine

kubectl rollout status deployment/web   # termina cuando el cambio ha finalizado

La reversión

Como los ReplicaSets antiguos se conservan, volver atrás no exige ninguna reconstrucción:

kubectl rollout history deployment/web              # historial de revisiones
kubectl rollout undo deployment/web                 # volver a la anterior
kubectl rollout undo deployment/web --to-revision=2 # a una revisión concreta

# Pausar y luego reanudar un cambio en curso, si los errores aumentan
kubectl rollout pause deployment/web
kubectl rollout resume deployment/web

Una observación honesta sobre el historial: por defecto no muestra qué ha cambiado entre dos revisiones, solo su número. Para que el historial sea legible, añada una anotación en cada entrega:

metadata:
  name: web
  annotations:
    kubernetes.io/change-cause: "Passage à nginx 1.27.1 — correctif CVE"

El escalado

kubectl scale deployment/web --replicas=6   # imperativo
kubectl apply -f deployment.yaml            # declarativo, tras editar el archivo

Prefiera el enfoque declarativo. Un kubectl scale se sobrescribe en el siguiente apply del manifiesto, lo que produce una regresión silenciosa y difícil de explicar.

Las sondas: liveness y readiness

Sin sonda, Kubernetes considera sano un contenedor en cuanto su proceso principal está en ejecución. Es una aproximación grosera: una aplicación puede haber arrancado su servidor HTTP sin ser capaz de responder —conexión a la base de datos no establecida, caché sin cargar—. El Service enviará entonces tráfico a un Pod que responderá con errores.

Dos sondas responden a dos preguntas distintas, y confundirlas tiene consecuencias serias.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: mon-api:1.0.0
          ports:
            - containerPort: 8080

          # «¿Puede este Pod recibir tráfico ahora?»
          # Si falla: se retira de los endpoints del Service, pero NO se reinicia.
          readinessProbe:
            httpGet:
              path: /ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 3

          # «¿Está este proceso bloqueado definitivamente?»
          # Si falla: el contenedor se MATA y se reinicia.
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 20
            failureThreshold: 3

          # «¿Ha terminado el arranque?»
          # Mientras falle, la livenessProbe queda suspendida.
          startupProbe:
            httpGet:
              path: /healthz
              port: 8080
            periodSeconds: 5
            failureThreshold: 30

Los errores clásicos, que conviene conocer antes de cometerlos:

  • Hacer que la liveness apunte a un endpoint que verifica la base de datos. Si la base de datos se ralentiza, todos los Pods fallan la sonda, se matan a la vez, y una lentitud pasajera se convierte en una caída total. La liveness solo debe comprobar el propio proceso; las dependencias externas corresponden a la readiness.
  • Un initialDelaySeconds demasiado corto en la liveness. La aplicación se mata antes de haber terminado de arrancar, en bucle: es el CrashLoopBackOff inexplicable. La startupProbe es la respuesta limpia para los arranques lentos.
  • No declarar ninguna readiness. El tráfico llega a un Pod que no está listo, y cada despliegue provoca una oleada de errores 502.

Existen tres formas de sondas, según lo que exponga la aplicación:

# HTTP: cualquier código de 200 a 399 es un éxito
readinessProbe:
  httpGet: { path: /ready, port: 8080 }
---
# TCP: ¿se abre la conexión?
readinessProbe:
  tcpSocket: { port: 5432 }
---
# Comando: código de salida 0 = éxito
livenessProbe:
  exec: { command: ["sh", "-c", "test -f /tmp/sain"] }

Diagnosticar un Pod

Los comandos que hay que saber de memoria, en el orden en que se utilizan:

# 1. Visión de conjunto: STATUS y RESTARTS cuentan lo esencial
kubectl get pods -o wide

# 2. La sección Events al final de la salida da la causa real
kubectl describe pod web-7d4b8c9f5-xk2mp

# 3. Los logs de la aplicación
kubectl logs web-7d4b8c9f5-xk2mp
kubectl logs -f deployment/web              # seguimiento en directo
kubectl logs web-7d4b8c9f5-xk2mp --previous # logs del contenedor ANTES de su fallo

# 4. Entrar en el contenedor
kubectl exec -it web-7d4b8c9f5-xk2mp -- sh

# 5. Llegar al Pod desde el equipo, sin exponerlo
kubectl port-forward pod/web-7d4b8c9f5-xk2mp 8080:80

La opción --previous es la que se descubre demasiado tarde. En un Pod en CrashLoopBackOff, kubectl logs muestra el contenedor actual, que a menudo está vacío porque acaba de reiniciarse. --previous muestra la instancia que realmente falló y, por tanto, el mensaje de error.

Los estados más frecuentes y su significado:

EstadoCausa habitual
Pendingel planificador no encuentra ningún nodo: recursos insuficientes o volumen sin enlazar
ImagePullBackOffimagen no encontrada, tag erróneo o registro privado sin credenciales
CrashLoopBackOffel contenedor arranca y se detiene en bucle: consulte logs --previous
Running pero 0/1 READYla readinessProbe falla
OOMKilledse ha superado el límite de memoria (véase la lección 6)

Conclusiones

  • Se declara un Deployment, nunca un Pod a mano. Crea un ReplicaSet por cada versión del template, y es la conservación de los ReplicaSets antiguos lo que hace que kubectl rollout undo sea instantáneo.
  • Los objetos se enlazan por labels, no por nombres. Mantenga estables los labels del selector (app, tier) y saque del selector todo lo que varía con frecuencia —versiones, commits—, ya que de todos modos es inmutable.
  • Un Service inaccesible se diagnostica con kubectl get endpoints: una lista vacía significa que el selector no coincide con ningún Pod.
  • ClusterIP para el tráfico interno, NodePort para desarrollo y LoadBalancer únicamente donde exista un controlador cloud; de lo contrario, la dirección externa se queda en <pending> indefinidamente.
  • readiness retira el Pod del tráfico sin reiniciarlo, mientras que liveness lo mata y lo reinicia. No incluya nunca una dependencia externa en una liveness: una lentitud de la base de datos se convertiría en una caída general.

Consejo: ejecute kubectl get pods -w en un terminal y, a continuación, lance una actualización en otro. Ver cómo los Pods aparecen y desaparecen por lotes fija el mecanismo mejor que cualquier explicación.

Comentarios

Los comentarios funcionan con GitHub Discussions

Inicia sesión con GitHub para participar en la discusión

¡Enlace copiado!