Durante diez módulos hemos ido conociendo las piezas de Kubernetes por separado: Pods, Deployments, Services, ConfigMaps, Ingress, sondas, HPA, políticas de red, RBAC, Helm, Argo CD. Cada una tenía sentido por sí misma, pero ninguna es suficiente por sí sola para poner una aplicación en producción. Este módulo cambia el enfoque: en lugar de estudiar una pieza, resolvemos un escenario completo de principio a fin.
Empezamos por el caso más frecuente y, en apariencia, más sencillo: llevar una aplicación web sin estado a producción. En Rutas Norte S.L. son dos componentes, tienda-web (nginx sirviendo la SPA de venta de billetes) y api-reservas (la API REST en Node.js que consulta disponibilidad y confirma reservas). Ninguno guarda datos localmente, así que no hay volúmenes que gestionar ni conmutación por error que coordinar. Y aun así, el conjunto de objetos que hacen falta para que ese despliegue sea defendible en una revisión de producción es de trece piezas por componente.
Esta lección es la referencia de manifiestos de todo el curso. Los ficheros que aparecen aquí son los que se dan por buenos en las lecciones siguientes: cuando en 11-03 la canalización actualice un digest, será el de estos Deployments; cuando en 11-04 se convierta api-reservas en un Rollout, se partirá de este manifiesto; cuando en 11-06 se escriba el runbook de "la latencia de la API se ha disparado", se referirá a estas sondas y a este HPA.
Contenido
- Lo que hay que tener resuelto antes del primer manifiesto
- El conjunto completo de objetos de un componente de producción
- Manifiestos completos de
api-reservas - Manifiestos completos de
tienda-web - Orden de aplicación y por qué importa
- Verificación por capas, de dentro afuera
- El checklist de "listo para producción"
- El error característico de cada capa
- Lo que hay que tener resuelto antes del primer manifiesto
Un error común es tratar Kubernetes como el sitio donde se arreglan los problemas de la aplicación. No lo es. Kubernetes amplifica lo que la aplicación ya hace: si la aplicación arranca lenta, el escalado será lento; si no cierra limpiamente, cada despliegue perderá peticiones; si guarda sesión en memoria local, las réplicas se estorbarán entre sí.
Antes de escribir la primera línea de YAML, estos siete puntos deben estar resueltos en el código y en la imagen, no en el clúster.
1.1. Imagen reproducible e identificada por digest
La imagen tiene que construirse siempre igual a partir del mismo commit, y desplegarse referenciada por digest, no por etiqueta móvil.
| Referencia | Ejemplo | ¿Vale en producción? |
|---|---|---|
| Etiqueta móvil | api-reservas:latest |
No. Dos pods del mismo Deployment pueden acabar con código distinto |
| Etiqueta semántica | api-reservas:2.7.0 |
Solo si el registro es inmutable; una etiqueta se puede reescribir |
| Digest | api-reservas@sha256:3f9c... |
Sí. Es la única referencia criptográficamente estable |
Ya vimos en 08-05 cómo construir con multietapa y firmar con Cosign. El requisito previo aquí es organizativo: el proceso que despliega debe conocer el digest, y eso lo garantiza la canalización que veremos en 11-03.
Por qué importa: sin digest, un RollingUpdate que reemplaza pods a lo largo de veinte minutos puede coger dos artefactos distintos si alguien reescribe la etiqueta a mitad. Es un fallo casi imposible de diagnosticar después.
1.2. Configuración totalmente externalizada
Ningún valor que cambie entre rutas-norte-dev, rutas-norte-pre y rutas-norte-pro puede estar dentro de la imagen. La misma imagen, byte a byte, tiene que poder ejecutarse en los tres entornos.
- Lo que cambia y no es secreto → ConfigMap (URLs internas, tiempos de espera, nivel de log).
- Lo que cambia y es secreto → Secret, poblado por External Secrets Operator desde el almacén corporativo (10-05).
- Lo que no cambia → puede quedarse en la imagen.
Por qué importa: si la imagen lleva configuración dentro, lo que se prueba en pre no es lo que se despliega en pro, y toda la cadena de confianza que hemos construido en 08-05 deja de significar nada.
1.3. Procesos sin estado local
Sesiones, ficheros temporales que deban sobrevivir, contadores en memoria compartidos: nada de eso puede vivir en el pod. En Rutas Norte, la sesión del usuario se firma con JWT y el carrito de compra se guarda en redis-cache.
Por qué importa: el HPA crea y destruye réplicas continuamente durante el puente de mayo. Si el estado vive en el pod, el usuario pierde el carrito cada vez que el autoescalado reduce réplicas.
1.4. Cierre limpio ante SIGTERM
Cuando Kubernetes retira un pod, envía SIGTERM al proceso principal y espera terminationGracePeriodSeconds. La aplicación debe:
- Dejar de aceptar peticiones nuevas.
- Terminar las que tiene en curso.
- Cerrar conexiones a base de datos y a Redis.
- Salir con código 0.
// Fragmento del arranque de api-reservas (server.js)
const servidor = app.listen(8080);
let cerrando = false;
// /preparado devuelve 503 en cuanto empieza el cierre: así el Endpoint
// se retira ANTES de que dejemos de aceptar conexiones.
app.get('/preparado', (req, res) => {
if (cerrando) return res.status(503).json({ estado: 'cerrando' });
return res.status(200).json({ estado: 'preparado' });
});
process.on('SIGTERM', async () => {
cerrando = true;
// Damos margen a que kube-proxy y el Ingress actualicen sus tablas.
await new Promise((r) => setTimeout(r, 5000));
servidor.close(async () => {
await pool.end(); // conexiones a postgres-reservas
await redis.quit();
process.exit(0);
});
});Esa pausa de cinco segundos parece un truco sucio, y en cierto modo lo es, pero responde a algo real: la retirada del Endpoint y el envío de SIGTERM ocurren en paralelo, no en secuencia. Sin la pausa, el pod deja de aceptar conexiones mientras el balanceador todavía se las envía, y el usuario ve errores 502 en cada despliegue.
1.5. Logs estructurados a stdout
Como vimos en 07-05, un objeto JSON por línea a stdout, sin ficheros de log, sin rotación propia. Campos mínimos en Rutas Norte: ts, nivel, mensaje, traza_id, ruta, estado, duracion_ms.
1.6. Endpoints de salud diferenciados
Dos endpoints con semántica distinta, no uno solo:
| Endpoint | Pregunta que responde | Qué comprueba en api-reservas |
|---|---|---|
/salud |
¿El proceso está vivo? | Solo que el bucle de eventos responde |
/preparado |
¿Puede atender tráfico ahora? | Conexión a postgres-reservas y a redis-cache, y que no esté cerrando |
Por qué importa: si /salud comprueba la base de datos, una caída de PostgreSQL provoca que el kubelet reinicie todos los pods de la API en bucle, convirtiendo una degradación en una caída total.
1.7. Métricas expuestas
api-reservas expone /metricas en formato Prometheus, con al menos el contador de peticiones por ruta y código, y el histograma de latencia. Es lo que alimentará el SLO de 11-06 y el análisis del canario de 11-04.
- El conjunto completo de objetos de un componente de producción
Esta es la tabla que conviene tener a mano al revisar cualquier despliegue nuevo. Cada fila es un objeto, qué aporta, y qué ocurre exactamente si falta.
| Objeto | Qué aporta | Qué pasa si falta |
|---|---|---|
| Namespace | Frontera de aislamiento, cuotas y políticas por entorno | Todo cae en default, sin cuota ni política; imposible separar entornos |
| ServiceAccount | Identidad del pod frente a la API y frente a la nube | Se usa la default, con permisos indeterminados y sin trazabilidad en auditoría |
| ConfigMap | Configuración no sensible, versionada en Git | Valores incrustados en la imagen o en el Deployment; cambiar uno obliga a reconstruir |
| Secret (vía External Secrets) | Credenciales sincronizadas desde el almacén corporativo | Secretos a mano en el clúster, sin rotación ni rastro de quién los puso |
| Deployment | Réplicas, actualización controlada, securityContext, sondas, recursos, reparto topológico |
Sin él no hay pods gestionados: un pod suelto no se recrea ni se actualiza |
| Service | Nombre DNS estable y balanceo interno | Hay que descubrir IPs de pod a mano; cada reinicio rompe la conexión |
| Ingress con TLS | Entrada desde internet con certificado | El servicio no es accesible desde fuera, o lo es por un NodePort sin cifrar |
| HPA | Ajuste de réplicas a la carga real | El puente de mayo satura las réplicas fijas o se paga de más todo el año |
| PodDisruptionBudget | Mínimo de réplicas durante mantenimientos y drenajes | Un drain de nodos puede dejar el servicio a cero réplicas |
| NetworkPolicy | Solo las conversaciones autorizadas | Cualquier pod comprometido del clúster alcanza la API y la base de datos |
| ServiceMonitor | Prometheus descubre y raspa las métricas | El componente no aparece en Grafana ni dispara alertas: está ciego |
topologySpreadConstraints (en el Deployment) |
Réplicas repartidas entre zonas y nodos | Las tres réplicas pueden acabar en el mismo nodo; ese nodo cae y cae el servicio |
| Sondas (en el Deployment) | Tráfico solo a pods sanos y reinicio de los colgados | Se enruta tráfico a pods que aún arrancan; los colgados nunca se recuperan |
Trece filas. La tentación al desplegar algo nuevo es hacer las cinco primeras y dejar las demás "para cuando haya tiempo". La experiencia dice que las que se dejan fuera son exactamente las que hacen falta el día del incidente.
graph TB
subgraph ns["Namespace rutas-norte-pro"]
subgraph seg["Seguridad e identidad"]
SA[ServiceAccount]
NP[NetworkPolicy]
end
subgraph cfg["Configuración"]
CM[ConfigMap]
SEC[Secret via ESO]
end
subgraph carga["Carga de trabajo"]
DEP[Deployment<br/>sondas + recursos + spread]
HPA[HPA]
PDB[PodDisruptionBudget]
end
subgraph red["Exposición"]
SVC[Service]
ING[Ingress + TLS]
end
SM[ServiceMonitor]
end
SA --> DEP
CM --> DEP
SEC --> DEP
DEP --> SVC
SVC --> ING
HPA -.escala.-> DEP
PDB -.protege.-> DEP
NP -.filtra.-> DEP
SM -.raspa.-> SVC
- Manifiestos completos de
api-reservas
api-reservasLos siguientes ficheros viven en k8s/base/api-reservas/ del repositorio de manifiestos, y las superposiciones de Kustomize (10-04) ajustan réplicas, recursos y dominio por entorno. Aquí se muestran ya resueltos para rutas-norte-pro.
3.1. ServiceAccount y configuración
apiVersion: v1
kind: ServiceAccount
metadata:
name: api-reservas
namespace: rutas-norte-pro
annotations:
# Identidad federada en EKS (10-06): permite leer del bucket de facturas
# sin ninguna clave de acceso de larga vida.
eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/rutasnorte-pro-api-reservas
# El pod no necesita hablar con la API de Kubernetes: no le montamos el token.
automountServiceAccountToken: false
---
apiVersion: v1
kind: ConfigMap
metadata:
name: api-reservas-config
namespace: rutas-norte-pro
data:
NODE_ENV: "production"
LOG_NIVEL: "info"
# DNS interno: nombre corto porque estamos en el mismo namespace.
REDIS_URL: "redis://redis-cache:6379"
# El agrupador de conexiones de CloudNativePG; lo veremos en 11-02.
PG_HOST: "postgres-reservas-pooler-rw"
PG_PUERTO: "5432"
PG_BASE: "reservas"
PG_POOL_MAX: "20"
PAGOS_URL: "https://pagos.proveedorexterno.example/v2"
PAGOS_TIMEOUT_MS: "4000"
RESERVA_TTL_SEGUNDOS: "900"
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: api-reservas-secretos
namespace: rutas-norte-pro
spec:
refreshInterval: 1h
secretStoreRef:
name: almacen-rutasnorte
kind: ClusterSecretStore
target:
name: api-reservas-secretos # nombre del Secret que se creará
creationPolicy: Owner
data:
- secretKey: PG_USUARIO
remoteRef: { key: pro/api-reservas/db, property: usuario }
- secretKey: PG_PASSWORD
remoteRef: { key: pro/api-reservas/db, property: password }
- secretKey: JWT_FIRMA
remoteRef: { key: pro/api-reservas/jwt, property: clave }
- secretKey: PAGOS_API_KEY
remoteRef: { key: pro/api-reservas/pagos, property: api_key }Dos detalles que suelen pasarse por alto. El primero, automountServiceAccountToken: false: api-reservas no llama a la API de Kubernetes, así que montarle el token solo añade superficie de ataque (08-01). El segundo, refreshInterval: 1h: el ExternalSecret vuelve a leer del almacén cada hora, de modo que una rotación de credenciales se propaga sola; lo que no hace por sí solo es reiniciar los pods, y por eso más abajo usamos una suma de comprobación en las anotaciones.
3.2. Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-reservas
namespace: rutas-norte-pro
labels:
app.kubernetes.io/name: api-reservas
app.kubernetes.io/part-of: rutas-norte
app.kubernetes.io/component: backend
rutasnorte.example/equipo: desarrollo
spec:
# replicas NO se fija aquí: lo gobierna el HPA y Argo CD lo ignora
# con ignoreDifferences (10-05). Si lo fijáramos, cada sincronización
# devolvería las réplicas al valor del fichero.
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0 # nunca bajamos del número actual de réplicas sanas
selector:
matchLabels:
app.kubernetes.io/name: api-reservas
template:
metadata:
labels:
app.kubernetes.io/name: api-reservas
app.kubernetes.io/part-of: rutas-norte
rutasnorte.example/equipo: desarrollo
annotations:
# Cambia si cambia el ConfigMap: fuerza un rollout al cambiar configuración.
rutasnorte.example/config-checksum: "sha256-8f1d2a"
spec:
serviceAccountName: api-reservas
automountServiceAccountToken: false
terminationGracePeriodSeconds: 45 # > los 5 s de espera + peticiones en curso
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway # preferimos servicio a simetría perfecta
labelSelector:
matchLabels:
app.kubernetes.io/name: api-reservas
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: api-reservas
containers:
- name: api
# Digest, no etiqueta: lo escribe la canalización de 11-03.
image: registry.rutasnorte.example/rutasnorte/api-reservas@sha256:3f9c1b7e5a04c2d8f61b93ae7c05d2419e8f6ab3c4d5e6f708192a3b4c5d6e7f
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
- name: metricas
containerPort: 9090
envFrom:
- configMapRef:
name: api-reservas-config
- secretRef:
name: api-reservas-secretos
env:
- name: POD_NOMBRE
valueFrom:
fieldRef:
fieldPath: metadata.name
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
memory: 512Mi # sin límite de CPU: evita el estrangulamiento (09-06)
startupProbe:
# Protege el arranque: hasta 60 s (12 x 5 s) antes de dar por muerto el pod.
httpGet: { path: /salud, port: http }
periodSeconds: 5
failureThreshold: 12
livenessProbe:
httpGet: { path: /salud, port: http }
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet: { path: /preparado, port: http }
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2 # sale del balanceo rápido
successThreshold: 1
lifecycle:
preStop:
exec:
# Red de seguridad adicional al manejo de SIGTERM del apartado 1.4.
command: ["/bin/sh", "-c", "sleep 5"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}Comentarios sobre las decisiones menos obvias:
maxUnavailable: 0conmaxSurge: 25%: durante el despliegue nunca hay menos capacidad de la que había. Cuesta un 25 % de recursos temporales; enpromerece la pena.- Sin
limits.cpu: como razonamos en 09-06, un límite de CPU provoca estrangulamiento con picos de latencia en el percentil 95 aunque el nodo esté ocioso. Conrequestsbien puestas y ResourceQuota en el namespace (03-04), el riesgo está acotado. readOnlyRootFilesystem: trueobliga alemptyDiren/tmp. Es una molestia de cinco minutos que corta de raíz media docena de técnicas de ataque (08-02).whenUnsatisfiable: ScheduleAnyway: conDoNotSchedule, durante el puente de mayo el HPA pediría réplicas que no se pueden colocar si una zona está llena. Preferimos réplicas mal repartidas a réplicas inexistentes.
3.3. Service, Ingress, HPA, PDB, NetworkPolicy y ServiceMonitor
apiVersion: v1
kind: Service
metadata:
name: api-reservas
namespace: rutas-norte-pro
labels:
app.kubernetes.io/name: api-reservas
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: api-reservas
ports:
- name: http
port: 80
targetPort: http
- name: metricas
port: 9090
targetPort: metricas
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-reservas
namespace: rutas-norte-pro
annotations:
cert-manager.io/cluster-issuer: letsencrypt-produccion
nginx.ingress.kubernetes.io/proxy-body-size: "1m"
nginx.ingress.kubernetes.io/limit-rps: "200"
spec:
ingressClassName: nginx
tls:
- hosts: ["api.rutasnorte.example"]
secretName: api-rutasnorte-tls # lo rellena cert-manager (04-05)
rules:
- host: api.rutasnorte.example
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-reservas
port:
name: http
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-reservas
namespace: rutas-norte-pro
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-reservas
minReplicas: 4
maxReplicas: 40 # dimensionado para el puente de mayo
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 65 }
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # subir rápido
policies:
- type: Percent
value: 100
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300 # bajar despacio
policies:
- type: Percent
value: 25
periodSeconds: 60
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api-reservas
namespace: rutas-norte-pro
spec:
minAvailable: 75%
selector:
matchLabels:
app.kubernetes.io/name: api-reservas
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-reservas
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: api-reservas
policyTypes: [Ingress, Egress]
ingress:
- from:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: ingress-nginx }
ports:
- { protocol: TCP, port: 8080 }
- from:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: monitorizacion }
ports:
- { protocol: TCP, port: 9090 }
egress:
- to:
- podSelector:
matchLabels: { cnpg.io/cluster: postgres-reservas }
ports: [{ protocol: TCP, port: 5432 }]
- to:
- podSelector:
matchLabels: { app.kubernetes.io/name: redis-cache }
ports: [{ protocol: TCP, port: 6379 }]
- to:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: kube-system }
podSelector:
matchLabels: { k8s-app: kube-dns }
ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }]
- to:
- ipBlock:
cidr: 0.0.0.0/0
except: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
ports: [{ protocol: TCP, port: 443 }] # pagos.proveedorexterno.example
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: api-reservas
namespace: rutas-norte-pro
labels:
release: kube-prometheus-stack # etiqueta que el Prometheus selecciona
spec:
selector:
matchLabels:
app.kubernetes.io/name: api-reservas
endpoints:
- port: metricas
path: /metricas
interval: 30sLa regla de egreso hacia 0.0.0.0/0 con exclusiones privadas merece un comentario: como vimos en 04-06, las NetworkPolicy no entienden de nombres DNS, así que no se puede escribir "deja salir hacia pagos.proveedorexterno.example". Lo que sí se puede es permitir el 443 hacia internet excluyendo los rangos privados, de forma que un pod comprometido no pueda usar esa regla para moverse lateralmente dentro de la red corporativa.
- Manifiestos completos de
tienda-web
tienda-webtienda-web es más simple: nginx sirviendo ficheros estáticos. No tiene secretos, no habla con la base de datos y sus sondas son triviales. Pero necesita exactamente la misma disciplina.
apiVersion: v1
kind: ConfigMap
metadata:
name: tienda-web-nginx
namespace: rutas-norte-pro
data:
default.conf: |
server {
listen 8080;
root /usr/share/nginx/html;
# SPA: cualquier ruta desconocida devuelve index.html
location / { try_files $uri $uri/ /index.html; }
location = /salud { access_log off; return 200 "ok\n"; }
location ~* \.(js|css|woff2|png|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: tienda-web
namespace: rutas-norte-pro
labels:
app.kubernetes.io/name: tienda-web
app.kubernetes.io/part-of: rutas-norte
rutasnorte.example/equipo: desarrollo
spec:
revisionHistoryLimit: 5
strategy:
rollingUpdate: { maxSurge: 25%, maxUnavailable: 0 }
selector:
matchLabels: { app.kubernetes.io/name: tienda-web }
template:
metadata:
labels:
app.kubernetes.io/name: tienda-web
app.kubernetes.io/part-of: rutas-norte
spec:
serviceAccountName: tienda-web
automountServiceAccountToken: false
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 10002
seccompProfile: { type: RuntimeDefault }
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app.kubernetes.io/name: tienda-web }
containers:
- name: nginx
image: registry.rutasnorte.example/rutasnorte/tienda-web@sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90
ports: [{ name: http, containerPort: 8080 }]
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { memory: 128Mi }
readinessProbe:
httpGet: { path: /salud, port: http }
periodSeconds: 5
livenessProbe:
httpGet: { path: /salud, port: http }
periodSeconds: 15
lifecycle:
preStop: { exec: { command: ["/bin/sh", "-c", "sleep 5"] } }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts:
- { name: conf, mountPath: /etc/nginx/conf.d }
- { name: cache, mountPath: /var/cache/nginx }
- { name: run, mountPath: /var/run }
volumes:
- name: conf
configMap: { name: tienda-web-nginx }
- { name: cache, emptyDir: {} }
- { name: run, emptyDir: {} }
---
apiVersion: v1
kind: Service
metadata:
name: tienda-web
namespace: rutas-norte-pro
spec:
selector: { app.kubernetes.io/name: tienda-web }
ports: [{ name: http, port: 80, targetPort: http }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tienda-web
namespace: rutas-norte-pro
annotations:
cert-manager.io/cluster-issuer: letsencrypt-produccion
nginx.ingress.kubernetes.io/from-to-www-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts: ["www.rutasnorte.example"]
secretName: www-rutasnorte-tls
rules:
- host: www.rutasnorte.example
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: tienda-web, port: { name: http } }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: tienda-web
namespace: rutas-norte-pro
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: tienda-web }
minReplicas: 3
maxReplicas: 15
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: tienda-web
namespace: rutas-norte-pro
spec:
minAvailable: 2
selector:
matchLabels: { app.kubernetes.io/name: tienda-web }Fíjate en que readOnlyRootFilesystem: true con nginx obliga a montar emptyDir en /var/cache/nginx y /var/run, y en que el listen es el 8080 y no el 80: un proceso sin privilegios no puede abrir puertos por debajo del 1024.
- Orden de aplicación y por qué importa
Con Argo CD (10-05) el orden lo resuelven las ondas de sincronización (argocd.argoproj.io/sync-wave), pero conviene entender la lógica, porque es la misma que se aplica al desplegar a mano en dev o al depurar.
| Onda | Objetos | Motivo |
|---|---|---|
| -2 | Namespace, ResourceQuota, LimitRange | Nada existe fuera de un namespace |
| -1 | ServiceAccount, RBAC, NetworkPolicy | La identidad y las reglas deben existir antes que la carga |
| 0 | ConfigMap, ExternalSecret | El Deployment falla el arranque si el ConfigMap no está |
| 1 | Deployment, Service | La carga y su nombre estable |
| 2 | Ingress, HPA, PDB, ServiceMonitor | Dependen de que Service y Deployment existan |
Kubernetes es eventualmente consistente: si aplicas el Deployment antes que el ConfigMap, los pods entran en CreateContainerConfigError y se recuperan solos cuando el ConfigMap aparece. El orden no es una obligación técnica estricta, es una forma de que el despliegue no genere alertas espurias ni minutos de confusión.
Hay dos excepciones donde el orden sí es obligatorio: los CRD antes que sus recursos personalizados (aplicar un ServiceMonitor sin el operador de Prometheus instalado da error duro), y la NetworkPolicy antes que el pod en entornos con deny-all, porque si no el pod arranca, falla su sonda de preparación contra la base de datos y entra en reinicios innecesarios.
- Verificación por capas, de dentro afuera
Desplegado no es funcionando. Esta secuencia de ocho comprobaciones va de la capa más interna a la más externa, y cada paso solo tiene sentido si el anterior pasó. Es la rutina que debe ejecutar quien despliega, y la que estructura el runbook de "un despliegue ha salido mal" de 11-06.
graph LR A[1 Pod arranca] --> B[2 Contenedor listo] B --> C[3 Endpoints poblados] C --> D[4 DNS resuelve] D --> E[5 Service responde<br/>desde el clúster] E --> F[6 Ingress responde<br/>desde fuera] F --> G[7 Certificado válido] G --> H[8 Métricas raspadas] H --> I[9 Alertas en silencio]
Capa 1: el pod arranca
kubectl -n rutas-norte-pro rollout status deploy/api-reservas --timeout=5m
kubectl -n rutas-norte-pro get pods -l app.kubernetes.io/name=api-reservas -o wideNAME READY STATUS RESTARTS AGE NODE
api-reservas-7d4b8c9f5d-2xk9p 1/1 Running 0 62s ip-10-0-2-41
api-reservas-7d4b8c9f5d-8mqrt 1/1 Running 0 58s ip-10-0-3-17
api-reservas-7d4b8c9f5d-p4vzn 1/1 Running 0 55s ip-10-0-1-93
api-reservas-7d4b8c9f5d-w6hxl 1/1 Running 0 51s ip-10-0-2-88Si falla: kubectl describe pod y mirar los eventos. ImagePullBackOff apunta al registro o al digest; CreateContainerConfigError, a un ConfigMap o Secret que no existe; Pending, a recursos insuficientes o a un topologySpreadConstraint imposible.
Capa 2: el contenedor está listo
READY 1/1 ya lo dice, pero conviene distinguir "arrancado" de "preparado":
kubectl -n rutas-norte-pro get pods -l app.kubernetes.io/name=api-reservas \
-o custom-columns='POD:.metadata.name,LISTO:.status.conditions[?(@.type=="Ready")].status,REINICIOS:.status.containerStatuses[0].restartCount'Si un pod está Running pero 0/1, la sonda de preparación falla: casi siempre api-reservas no consigue conectar con postgres-reservas o con redis-cache. Se comprueba con kubectl logs y se resuelve mirando la NetworkPolicy o las credenciales.
Capa 3: los Endpoints están poblados
Este es el paso que más gente se salta y el que más veces explica un 503.
kubectl -n rutas-norte-pro get endpointslice -l kubernetes.io/service-name=api-reservas -o yaml | grep -A3 addressesEndpoints vacíos con pods sanos significa selector desalineado: las etiquetas del spec.selector del Service no coinciden con las de template.metadata.labels. Es un fallo silencioso: ningún objeto da error, simplemente no llega tráfico.
Capa 4: el DNS resuelve
kubectl -n rutas-norte-pro run depurar --rm -it --restart=Never \
--image=registry.rutasnorte.example/utiles/netdebug:1.4 -- \
nslookup api-reservas.rutas-norte-pro.svc.cluster.localSi no resuelve: CoreDNS caído, o una NetworkPolicy que no permite el egreso al puerto 53 hacia kube-system (el fallo más habitual tras implantar deny-all).
Capa 5: el Service responde desde dentro del clúster
kubectl -n rutas-norte-pro run depurar --rm -it --restart=Never \
--image=registry.rutasnorte.example/utiles/netdebug:1.4 -- \
curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' \
http://api-reservas/saludSi el pod responde pero el Service no, sospecha del targetPort o de la NetworkPolicy de ingreso.
Capa 6: el Ingress responde desde fuera
curl -s -o /dev/null -w 'http=%{http_code} tls=%{ssl_verify_result} t=%{time_total}s\n' \
https://api.rutasnorte.example/saludSi da 404 desde nginx, revisa ingressClassName y el host. Si da 503, el Ingress apunta a un Service sin Endpoints: has saltado la capa 3.
Capa 7: el certificado es válido
kubectl -n rutas-norte-pro get certificate api-rutasnorte-tls
echo | openssl s_client -connect api.rutasnorte.example:443 -servername api.rutasnorte.example 2>/dev/null \
| openssl x509 -noout -issuer -datesNAME READY SECRET AGE
api-rutasnorte-tls True api-rutasnorte-tls 184d
issuer=C=US, O=Let's Encrypt, CN=R11
notBefore=Apr 18 09:12:44 2026 GMT
notAfter=Jul 17 09:12:43 2026 GMTUn Certificate en False durante más de unos minutos suele ser el reto ACME fallando: mira el Order y el Challenge (04-05).
Capa 8: Prometheus raspa las métricas
kubectl -n monitorizacion port-forward svc/prometheus-operated 9090:9090 &
curl -sG 'http://localhost:9090/api/v1/query' \
--data-urlencode 'query=up{job="api-reservas"}' | jq '.data.result[] | {pod: .metric.pod, valor: .value[1]}'{"pod":"api-reservas-7d4b8c9f5d-2xk9p","valor":"1"}
{"pod":"api-reservas-7d4b8c9f5d-8mqrt","valor":"1"}Un objetivo ausente casi siempre es la etiqueta release: del ServiceMonitor, que debe coincidir con el serviceMonitorSelector del Prometheus.
Capa 9: las alertas están en silencio
curl -s http://alertmanager.rutas-norte.example/api/v2/alerts \
| jq '[.[] | select(.labels.service=="api-reservas")] | length'Cero alertas activas quince minutos después del despliegue es el criterio real de "ha ido bien". Antes de eso, las ventanas de las reglas (for: 10m) todavía no han cerrado.
- El checklist de "listo para producción"
Esta tabla se pega tal cual en la descripción de la petición de cambio que promociona a rutas-norte-pro. Cada línea la marca una persona, no un script.
| # | Comprobación | Cómo se verifica | OK |
|---|---|---|---|
| 1 | Imagen por digest, firmada y escaneada sin críticas | cosign verify + informe de Trivy en la canalización |
☐ |
| 2 | Configuración fuera de la imagen | Diff de ConfigMap entre pre y pro |
☐ |
| 3 | Sin estado local en el pod | Revisión de código; sesión en JWT y carrito en Redis | ☐ |
| 4 | Cierre limpio verificado | Prueba de carga durante un rollout restart: 0 errores 5xx |
☐ |
| 5 | Sondas diferenciadas y con startupProbe |
Manifiesto revisado | ☐ |
| 6 | requests y limits puestos, sin límite de CPU |
Manifiesto revisado; recomendación de VPA consultada | ☐ |
| 7 | securityContext sin root, sin escalada, raíz de solo lectura |
Kyverno en modo bloqueo lo garantiza (08-03) | ☐ |
| 8 | topologySpreadConstraints por zona y nodo |
Manifiesto revisado | ☐ |
| 9 | PDB coherente con minReplicas del HPA |
minAvailable < minReplicas |
☐ |
| 10 | HPA con min y max dimensionados para el pico previsto |
Cálculo del puente de mayo documentado | ☐ |
| 11 | NetworkPolicy de ingreso y egreso explícitas | kubectl describe netpol |
☐ |
| 12 | ServiceMonitor activo y panel de Grafana existente | up{job=...} == 1 y enlace al panel |
☐ |
| 13 | Alertas con enlace a runbook en las anotaciones | Regla de PrometheusRule revisada | ☐ |
| 14 | Ingress con TLS válido y renovación automática | Certificate en Ready |
☐ |
| 15 | Verificación por capas 1-9 ejecutada tras desplegar | Salida pegada en la petición de cambio | ☐ |
La fila 9 es la que más incidencias evita y la que más se olvida: si el HPA puede bajar a 2 réplicas y el PDB exige minAvailable: 3, el drenaje de un nodo se bloquea indefinidamente y el mantenimiento del clúster se queda colgado.
- El error característico de cada capa
Cada capa falla de una manera reconocible. Saber el síntoma característico ahorra la mitad del tiempo de diagnóstico; el procedimiento profundo está en 07-06.
| Capa | Síntoma | Causa más probable | Primer comando |
|---|---|---|---|
| Pod | ImagePullBackOff |
Digest inexistente o credencial del registro | kubectl describe pod |
| Pod | Pending prolongado |
Sin recursos, o topologySpread con DoNotSchedule |
kubectl describe pod (eventos del planificador) |
| Pod | CrashLoopBackOff |
Falla al arrancar, o livenessProbe con umbral muy corto |
kubectl logs --previous |
| Contenedor | Running pero 0/1 |
Dependencia no alcanzable desde /preparado |
kubectl logs + probar la dependencia |
| Endpoints | Lista vacía con pods sanos | Selector del Service desalineado | kubectl get endpointslice |
| DNS | NXDOMAIN desde un pod |
CoreDNS, o egreso al 53 bloqueado | nslookup desde un pod de depuración |
| Service | Conecta pero da tiempo de espera | targetPort erróneo o NetworkPolicy de ingreso |
curl al Service desde el clúster |
| Ingress | 404 de nginx | ingressClassName o host mal |
kubectl describe ing |
| Ingress | 503 | Service sin Endpoints listos | volver a la capa 3 |
| TLS | Aviso del navegador | Certificate no Ready, reto ACME fallido |
kubectl describe certificate |
| Métricas | Objetivo ausente en Prometheus | Etiqueta release: del ServiceMonitor |
up{job="..."} |
| Alertas | Alerta que no se apaga | Regla mal escrita o umbral irreal | Ejecutar la expresión en Prometheus |
Errores Comunes y Consejos
- Desplegar sin
preStopni manejo deSIGTERMy culpar al Ingress de los 502. Es el error más frecuente al pasar dedeva producción. La prueba definitiva: lanza una carga sostenida con k6 y ejecutakubectl rollout restarta mitad. Si aparece un solo 5xx, el cierre limpio no está bien. - Poner
livenessProbesobre un endpoint que comprueba dependencias. Convierte una degradación de la base de datos en un reinicio masivo de toda la API. La sonda de vida solo debe mirar hacia dentro. - Fijar
replicasen el Deployment teniendo HPA. Cada sincronización de Argo CD devuelve las réplicas al valor del fichero, deshaciendo el escalado. Se resuelve conignoreDifferences(10-05) y quitando el campo. - Un PDB con
minAvailableigual al número de réplicas. Bloquea todo drenaje. Usa porcentajes y comprueba la coherencia con elminReplicasdel HPA. - Olvidar la etiqueta
release:en el ServiceMonitor. El componente se despliega perfectamente y queda invisible para la monitorización. Nadie lo nota hasta el primer incidente. - Consejo: automatiza el checklist en lo que se pueda, pero mantén la revisión humana. Kyverno puede exigir las filas 6, 7 y 11; nadie puede exigir por política la fila 10, que requiere haber pensado en el puente de mayo.
- Consejo: guarda la salida de la verificación por capas en la petición de cambio. Cuando algo falle tres semanas después, saber que el día del despliegue el certificado era válido y las métricas se raspaban acota enormemente la búsqueda.
Ejercicios
Ejercicio 1: detectar objetos ausentes
Un compañero ha desplegado un componente nuevo, promociones-web, en rutas-norte-pro con estos objetos: Deployment (3 réplicas fijas, sin sondas), Service e Ingress. Enumera qué objetos faltan de los trece de la tabla del apartado 2 y describe, para cada uno, el escenario concreto en el que su ausencia causará una incidencia en Rutas Norte.
Ejercicio 2: verificación por capas de un fallo real
Tras desplegar api-reservas en rutas-norte-pre, esta es la situación:
$ kubectl -n rutas-norte-pre get pods -l app.kubernetes.io/name=api-reservas
NAME READY STATUS RESTARTS AGE
api-reservas-6c9f7d8b4c-hh2ks 0/1 Running 0 4m
api-reservas-6c9f7d8b4c-tq5rl 0/1 Running 0 4m
$ curl -s -o /dev/null -w '%{http_code}\n' https://api-pre.rutasnorte.example/salud
503Indica en qué capa de las nueve está el fallo, qué tres comandos ejecutarías en qué orden y cuáles son las dos causas más probables.
Ejercicio 3: coherencia entre HPA y PDB
worker-notificaciones tiene un HPA gobernado por KEDA con minReplicaCount: 1 y maxReplicaCount: 20, y un PDB con minAvailable: 2. El equipo de plataforma necesita drenar un nodo para actualizar el clúster y el kubectl drain se queda colgado. Explica por qué y propone la corrección, justificando el valor elegido.
Soluciones
Solución 1. Faltan diez objetos:
| Falta | Escenario de incidencia |
|---|---|
| ServiceAccount propio | Usa la default; la auditoría no puede atribuir acciones y hereda permisos no previstos |
| ConfigMap | La configuración va en la imagen: promocionar de pre a pro exige reconstruir, rompiendo la trazabilidad del digest |
| Secret vía ESO | Credenciales puestas a mano, sin rotación |
| Sondas | Se enruta tráfico a pods que aún arrancan: 502 en cada despliegue |
securityContext |
Kyverno en modo bloqueo (08-03) rechazará el pod; si está en modo aviso, corre como root |
topologySpreadConstraints |
Las 3 réplicas pueden caer en el mismo nodo; ese nodo se pierde y el servicio también |
| HPA | Las 3 réplicas fijas se saturan el puente de mayo |
| PDB | Un drenaje puede llevarse las 3 réplicas a la vez |
| NetworkPolicy | Con deny-all en el namespace, ni siquiera arrancará; sin él, queda expuesto a movimiento lateral |
| ServiceMonitor | Sin métricas ni alertas: el componente es invisible durante el primer incidente |
Solución 2. El fallo está en la capa 2 (contenedor no preparado), y el 503 de la capa 6 es una consecuencia: sin pods listos, no hay Endpoints. Comandos, en orden:
kubectl -n rutas-norte-pre logs -l app.kubernetes.io/name=api-reservas --tail=50
kubectl -n rutas-norte-pre describe pod -l app.kubernetes.io/name=api-reservas | grep -A10 Events
kubectl -n rutas-norte-pre get endpointslice -l kubernetes.io/service-name=api-reservasCausas más probables: (a) /preparado falla porque no hay conexión con postgres-reservas (credencial del ExternalSecret no sincronizada en pre, o NetworkPolicy sin la regla de egreso al 5432); (b) la aplicación arranca pero tarda más que la startupProbe, aunque en ese caso lo normal sería ver reinicios, que aquí son 0, lo que refuerza la hipótesis (a).
Solución 3. Con minReplicaCount: 1, KEDA puede tener el worker a una sola réplica en horas valle. El PDB exige que queden 2 disponibles, condición imposible con 1 réplica total, así que drain no puede desalojar el pod y espera indefinidamente. Corrección: usar minAvailable: 1 o, mejor, maxUnavailable: 1, que se expresa en términos de cuántas réplicas se pueden perder y funciona correctamente en todo el rango de 1 a 20. Alternativa complementaria: subir minReplicaCount a 2 si el negocio exige que las notificaciones nunca queden sin capacidad, asumiendo el coste de la réplica permanente.
Conclusión
Hemos recorrido, de principio a fin, el camino de una aplicación web sin estado hasta producción: los siete requisitos que deben cumplirse en la aplicación antes de tocar el YAML, los trece objetos que forman un componente completo y qué se pierde con cada uno que falte, los manifiestos íntegros de api-reservas y tienda-web que sirven de referencia para el resto del curso, el orden de aplicación, la verificación por capas de dentro afuera con el comando exacto de cada paso, el checklist revisable en una petición de cambio y el síntoma característico del fallo de cada capa.
La lección clave es que "está desplegado" y "está listo para producción" son afirmaciones muy distintas, y que la diferencia entre ambas son precisamente los objetos y las comprobaciones que resulta tentador dejar para después.
Todo esto ha sido relativamente cómodo por una razón: tienda-web y api-reservas no guardan nada. Se pueden matar, mover y multiplicar sin consecuencias. En la próxima lección, Ejecución de Aplicaciones con Estado, entramos en el terreno donde eso deja de ser cierto: postgres-reservas guarda los datos personales de los clientes de Rutas Norte, no se puede reemplazar un pod alegremente, y la primera pregunta que hay que responder honestamente es si esa base de datos debería vivir en Kubernetes.
Curso de Kubernetes
Módulo 1: Introducción a Kubernetes
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
