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

  1. Lo que hay que tener resuelto antes del primer manifiesto
  2. El conjunto completo de objetos de un componente de producción
  3. Manifiestos completos de api-reservas
  4. Manifiestos completos de tienda-web
  5. Orden de aplicación y por qué importa
  6. Verificación por capas, de dentro afuera
  7. El checklist de "listo para producción"
  8. El error característico de cada capa

  1. 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:

  1. Dejar de aceptar peticiones nuevas.
  2. Terminar las que tiene en curso.
  3. Cerrar conexiones a base de datos y a Redis.
  4. 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.

  1. 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

  1. Manifiestos completos de api-reservas

Los 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: 0 con maxSurge: 25%: durante el despliegue nunca hay menos capacidad de la que había. Cuesta un 25 % de recursos temporales; en pro merece 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. Con requests bien puestas y ResourceQuota en el namespace (03-04), el riesgo está acotado.
  • readOnlyRootFilesystem: true obliga al emptyDir en /tmp. Es una molestia de cinco minutos que corta de raíz media docena de técnicas de ataque (08-02).
  • whenUnsatisfiable: ScheduleAnyway: con DoNotSchedule, 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: 30s

La 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.

  1. Manifiestos completos de tienda-web

tienda-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.

  1. 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
metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "-1"

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.

  1. 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 wide
NAME                            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-88

Si 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 addresses
    addresses:
    - 10.244.2.31
      conditions:
        ready: true

Endpoints 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.local

Si 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/salud
200 0.004s

Si 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/salud

Si 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 -dates
NAME                 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 GMT

Un 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.

  1. 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.

  1. 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 preStop ni manejo de SIGTERM y culpar al Ingress de los 502. Es el error más frecuente al pasar de dev a producción. La prueba definitiva: lanza una carga sostenida con k6 y ejecuta kubectl rollout restart a mitad. Si aparece un solo 5xx, el cierre limpio no está bien.
  • Poner livenessProbe sobre 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 replicas en el Deployment teniendo HPA. Cada sincronización de Argo CD devuelve las réplicas al valor del fichero, deshaciendo el escalado. Se resuelve con ignoreDifferences (10-05) y quitando el campo.
  • Un PDB con minAvailable igual al número de réplicas. Bloquea todo drenaje. Usa porcentajes y comprueba la coherencia con el minReplicas del 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
503

Indica 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-reservas

Causas 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

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