Pods y Services
Los tres recursos que escribirá todos los días.
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
| Tipo | Alcance | Uso realista |
|---|---|---|
ClusterIP | interno al clúster | comunicación entre servicios: el caso habitual |
NodePort | puerto abierto en cada nodo | desarrollo, acceso de depuración |
LoadBalancer | dirección externa proporcionada por la nube | punto 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
initialDelaySecondsdemasiado corto en la liveness. La aplicación se mata antes de haber terminado de arrancar, en bucle: es elCrashLoopBackOffinexplicable. LastartupProbees 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:
| Estado | Causa habitual |
|---|---|
Pending | el planificador no encuentra ningún nodo: recursos insuficientes o volumen sin enlazar |
ImagePullBackOff | imagen no encontrada, tag erróneo o registro privado sin credenciales |
CrashLoopBackOff | el contenedor arranca y se detiene en bucle: consulte logs --previous |
Running pero 0/1 READY | la readinessProbe falla |
OOMKilled | se 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 undosea 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. ClusterIPpara el tráfico interno,NodePortpara desarrollo yLoadBalancerú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 -wen 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