DevOps, CI/CD y contenedores

Patrones de Kubernetes: probes, recursos, escalado, almacenamiento y seguridad

Avanzado11 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Un Deployment y un Service bastan para ejecutar tu aplicación, pero no harán que sobreviva a una mala release, a un vecino ruidoso o a la caída de un nodo. Esta guía repasa los patrones que separan un clúster de demo de uno de producción: probes que dicen la verdad, requests que hacen predecible la planificación, despliegues seguros, autoscaling, estado persistente, mínimo privilegio y una forma sensata de empaquetar los manifiestos por entorno.

Lo que aprenderás

  • Configurar probes de liveness, readiness y startup que reflejen el estado real de la aplicación
  • Definir requests y limits con criterio y prever la clase QoS y el comportamiento de desalojo resultantes
  • Controlar los rollouts con los parámetros de surge, los disruption budgets y el rollback
  • Escalar cargas de trabajo horizontalmente según la CPU y métricas personalizadas
  • Ejecutar cargas con estado con StatefulSets, PersistentVolumeClaims y StorageClasses
  • Aplicar RBAC, security contexts y network policies, y empaquetar manifiestos con Helm o Kustomize

Entre «funciona en el clúster» y «funciona en producción» hay una serie de decisiones fáciles de aplazar: cómo decide el clúster que tu contenedor está sano, qué pasa cuando consume más memoria de la prevista, cómo se contiene una mala release, qué se rompe cuando se drena un nodo por mantenimiento. Si te las saltas, los síntomas son conocidos: reinicios en los picos de tráfico, despliegues que pierden peticiones y un servicio voraz que degrada todo lo demás en el nodo.

Esta guía recorre esas decisiones como un conjunto de patrones, cada uno con el fragmento de manifiesto que lo implementa. Da por conocidos los objetos de la guía de fundamentos de Kubernetes y está pensada para clústeres de la serie 1.3x.

Probes que dicen la verdad

Kubernetes hace a cada contenedor tres preguntas distintas, y confundirlas provoca más incidentes en producción que cualquier otro error de configuración.

La liveness pregunta: ¿está este proceso bloqueado sin remedio? Si falla, el contenedor se reinicia. La readiness pregunta: ¿puede esta instancia atender tráfico ahora mismo? Si falla, el Pod sale de los endpoints del Service pero sigue en marcha. La startup pregunta: ¿ha terminado de arrancar el proceso? Mientras falla, la liveness y la readiness quedan en suspenso, lo que permite que una aplicación de arranque lento evite un bucle de reinicios sin relajar los tiempos de liveness durante el resto de su vida.

startupProbe:
  httpGet: { path: /actuator/health/liveness, port: http }
  failureThreshold: 30
  periodSeconds: 5          # allows up to 150s to boot
livenessProbe:
  httpGet: { path: /actuator/health/liveness, port: http }
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet: { path: /actuator/health/readiness, port: http }
  periodSeconds: 5
  failureThreshold: 2

Las reglas que se derivan de estas definiciones:

  • El endpoint de liveness no debe comprobar dependencias. Debe devolver 200 siempre que el proceso aún pueda atender peticiones. Un endpoint que hace ping a la base de datos convierte cualquier tropiezo de la base en una tormenta de reinicios en todo el clúster.
  • El endpoint de readiness sí debe comprobar las dependencias que la instancia realmente necesita, y debe empezar a informar de fallo en cuanto la aplicación recibe SIGTERM, para que el tráfico se drene antes de que el proceso termine.
  • Los tiempos de las probes deben ajustarse a la aplicación. Un servicio JVM que tarda 90 segundos en calentarse necesita una startup probe, no un initialDelaySeconds de 120 segundos en la liveness.

Los frameworks ayudan: Spring Boot expone grupos de liveness y readiness separados a través de Actuator, como se describe en la guía de observabilidad, y la mayoría de los ecosistemas tienen algo equivalente.

Combina la readiness con un apagado ordenado (graceful shutdown). Cuando se borra un Pod, Kubernetes lo saca de los endpoints y envía SIGTERM casi a la vez, así que pueden seguir llegando peticiones durante uno o dos segundos. Un sleep en el hook preStop cubre ese hueco:

lifecycle:
  preStop:
    exec: { command: ["sh", "-c", "sleep 5"] }
terminationGracePeriodSeconds: 45

Requests, limits y lo que hace con ellos el scheduler

Una request es lo que reserva el scheduler; un limit es lo que impone el runtime. El scheduler solo coloca un Pod en un nodo cuya capacidad no reservada cubra la suma de las requests de sus contenedores; el consumo real no influye en esa decisión.

resources:
  requests:
    cpu: "250m"
    memory: "512Mi"
  limits:
    memory: "512Mi"

Los dos recursos se comportan de forma totalmente distinta. La CPU es comprimible: superar el limit hace que el kernel estrangule el cgroup, lo que añade latencia a rachas que parecen misteriosos picos en la latencia de cola. La memoria es incomprimible: superar el limit significa que el contenedor muere con el código de salida 137 y OOMKilled en su estado. Esa asimetría explica el consejo habitual: define siempre limits de memoria, iguala las requests de memoria a esos limits en todo lo importante, ajusta con cuidado las requests de CPU y desconfía de los limits de CPU.

Requests y limits determinan juntos la clase QoS del Pod, que decide quién muere primero cuando un nodo se queda sin memoria:

Clase QoSCondiciónOrden de desalojo
GuaranteedTodos los contenedores tienen requests iguales a sus limits, tanto de CPU como de memoriaEl último
BurstableAl menos una request definida, pero sin coincidir con los limitsIntermedio
BestEffortNinguna request ni limitEl primero

Da a las cargas de producción la clase Guaranteed o una Burstable bien ajustada; deja BestEffort para lo que no te importa perder.

Dos objetos a nivel de namespace mantienen la disciplina en un clúster compartido. Un LimitRange aporta valores por defecto y topes para que no se pueda crear un Pod sin recursos, y un ResourceQuota acota el consumo total del namespace:

apiVersion: v1
kind: ResourceQuota
metadata: { name: team-quota, namespace: staging }
spec:
  hard:
    requests.cpu: "20"
    requests.memory: 40Gi
    limits.memory: 60Gi
    pods: "80"

En cargas JVM, recuerda que el contenedor tiene que conocer el limit. Las JVM modernas leen automáticamente los límites del cgroup, pero define -XX:MaxRAMPercentage=75 para que el heap deje sitio al metaspace, a las pilas de los threads y a los buffers nativos: un heap dimensionado al 100 % del limit de memoria es un OOMKilled esperando a ocurrir.

Rollouts de confianza

La strategy de un Deployment determina cómo sustituye los Pods:

spec:
  minReadySeconds: 15
  progressDeadlineSeconds: 600
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # may create one extra Pod above replicas
      maxUnavailable: 0    # never drop below the desired count

maxUnavailable: 0 con maxSurge: 1 es la configuración conservadora: la capacidad nunca baja, a cambio de un rollout más lento y de los recursos de un Pod adicional. minReadySeconds evita que un Pod que pasa la readiness y luego se cae cuente como un éxito, y eso es lo que hace que una mala release se atasque en lugar de propagarse.

progressDeadlineSeconds marca como fallido un rollout atascado, pero no lo revierte. Haz que tu pipeline se encargue:

kubectl apply -f k8s/
if ! kubectl rollout status deploy/shop-api --timeout=10m; then
  kubectl rollout undo deploy/shop-api
  exit 1
fi

Un PodDisruptionBudget protege la disponibilidad durante las interrupciones voluntarias: drenado de nodos, actualizaciones del clúster, reducción por parte del autoscaler. No sirve de nada ante un nodo que se cae, pero impide que un administrador desaloje todas tus réplicas de golpe:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: shop-api }
spec:
  minAvailable: 2
  selector:
    matchLabels: { app: shop-api }

Pon minAvailable por debajo de tu número de réplicas; si no, el budget bloquea por completo los drenados y alguien acabará borrándolo a las 3 de la madrugada. Las estrategias blue-green y canary, y sus ventajas e inconvenientes, se tratan en la guía de pipelines de entrega; en Kubernetes suelen implementarse con un controlador de progressive delivery y no a mano.

Escalado

El HorizontalPodAutoscaler ajusta el número de réplicas de un Deployment para acercarse a un valor objetivo de una métrica. Necesita metrics-server instalado y requests definidas: el uso de CPU se expresa como porcentaje de la request, así que un Pod sin request no tiene denominador.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: shop-api }
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: shop-api
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 70 }
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - { type: Percent, value: 25, periodSeconds: 60 }
    scaleUp:
      policies:
        - { type: Percent, value: 100, periodSeconds: 30 }

El bloque behavior es lo que da calma al autoscaling: escalar rápido cuando llega la carga y reducir despacio, con una ventana de estabilización de cinco minutos, para que un bajón pasajero no retire capacidad que volverás a necesitar un minuto después. En cargas donde la CPU no es el cuello de botella (consumidores de colas, por ejemplo), escala según una métrica personalizada o externa, como la profundidad de la cola; KEDA es la forma habitual de conectar ese tipo de métricas.

Dos complementos: el VerticalPodAutoscaler recomienda o aplica mejores requests y limits, y resulta útil en modo recomendación aunque nunca le dejes actuar; el Cluster Autoscaler (o Karpenter en AWS) añade y quita nodos cuando hay Pods que no se pueden planificar. Escalar Pods horizontalmente no sirve de nada si el clúster no tiene sitio donde ponerlos.

Cómo hacer entrar el tráfico

Un Service LoadBalancer por aplicación significa un balanceador de carga en la nube y una IP pública por aplicación. El Ingress los consolida: un único controlador (ingress-nginx, Traefik, HAProxy o uno nativo del proveedor cloud) detrás de un solo balanceador, que enruta por host y ruta y termina TLS.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
  ingressClassName: nginx
  tls:
    - hosts: [shop.example.com]
      secretName: shop-tls
  rules:
    - host: shop.example.com
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: shop-api
                port: { name: http }

El punto débil del Ingress es que todo lo que va más allá del enrutamiento por host y ruta vive en anotaciones específicas de cada controlador, así que los manifiestos dejan de ser portables. La Gateway API es su sucesora: GatewayClass, Gateway y HTTPRoute son recursos tipados de primera clase que admiten coincidencia por cabeceras, reparto del tráfico por pesos y delegación entre namespaces, con una separación clara entre el equipo de plataforma, dueño del Gateway, y los equipos de aplicación, dueños de las rutas. Es la dirección que toman los clústeres nuevos; el Ingress sigue soportado y está en todas partes.

Cargas con estado

El almacenamiento se expresa mediante tres objetos. Un PersistentVolumeClaim es una solicitud de almacenamiento; una StorageClass describe un tipo de almacenamiento y el provisioner que lo crea bajo demanda; un PersistentVolume es el almacenamiento resultante. En un clúster en la nube escribes claims que hacen referencia a una clase, y el resto es automático.

Un StatefulSet se diferencia de un Deployment en aspectos clave para bases de datos y brokers: los Pods reciben nombres ordinales estables (kafka-0, kafka-1), un nombre DNS estable a través de un Service headless, su propio PersistentVolumeClaim creado a partir de una plantilla y conservado entre reinicios, y rollouts ordenados, de uno en uno.

apiVersion: apps/v1
kind: StatefulSet
metadata: { name: postgres }
spec:
  serviceName: postgres-headless
  replicas: 3
  selector: { matchLabels: { app: postgres } }
  template:
    metadata: { labels: { app: postgres } }
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: postgres
          image: postgres:16
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: fast-ssd
        resources: { requests: { storage: 100Gi } }

Fíjate en ReadWriteOnce: la mayoría del almacenamiento de bloques solo puede montarse en un nodo a la vez, así que dos Pods no pueden compartir un volumen de ese tipo. El acceso compartido de lectura y escritura requiere una clase de almacenamiento de ficheros que admita ReadWriteMany.

Un StatefulSet te da identidad y almacenamiento. No te da replicación, failover ni copias de seguridad: eso lo aporta el software de base de datos y, en la práctica, un operator, un controlador que encapsula el conocimiento operativo de un sistema concreto, vigila recursos personalizados y se encarga de copias de seguridad, failovers y actualizaciones de versión. Ejecutar en serio una base de datos en Kubernetes significa ejecutar su operator, o bien usar un servicio gestionado y apuntar el clúster hacia él.

Trabajos por lotes

Un Job ejecuta Pods hasta que un número determinado de ellos termina con éxito. Un CronJob crea Jobs según una programación.

apiVersion: batch/v1
kind: CronJob
metadata: { name: nightly-reconcile }
spec:
  schedule: "17 2 * * *"
  timeZone: "Europe/Paris"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 300
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 3600
      ttlSecondsAfterFinished: 86400
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: job
              image: registry.example.com/acme/reconcile:1.4.2

concurrencyPolicy: Forbid impide que una ejecución lenta se solape con la siguiente; el valor por defecto, Allow, ha provocado mucho procesamiento duplicado. ttlSecondsAfterFinished limpia los Jobs completados; sin él, el namespace acumula miles de Pods terminados. Y activeDeadlineSeconds evita que un job colgado se ejecute indefinidamente.

Init containers y sidecars

Los init containers se ejecutan hasta completarse, en orden, antes de que arranquen los contenedores de la aplicación. Son el sitio adecuado para esperar a una dependencia, ejecutar una migración de esquema o descargar un fichero en un emptyDir compartido.

Los sidecars se ejecutan junto a la aplicación durante toda la vida del Pod. Desde la 1.29 tienen soporte nativo: decláralos como init containers con restartPolicy: Always y el kubelet los arranca antes que los contenedores de la aplicación y los detiene después, lo que por fin resuelve el viejo problema del log shipper que moría antes de vaciar su búfer o del sidecar que mantenía vivo para siempre el Pod de un Job.

initContainers:
  - name: wait-for-db
    image: busybox:1.36
    command: ["sh", "-c", "until nc -z postgres 5432; do sleep 2; done"]
  - name: log-shipper           # a native sidecar
    image: fluent-bit:3.1
    restartPolicy: Always
    volumeMounts:
      - { name: logs, mountPath: /var/log/app }

Las variantes adapter y ambassador de la misma idea (un contenedor que transforma la salida de la aplicación, o que hace de proxy para sus llamadas salientes) son lo que los service meshes instalan automáticamente.

Mínimo privilegio

Cuatro mecanismos, y cada uno cierra una puerta distinta.

El security context restringe el propio contenedor:

securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities: { drop: ["ALL"] }
  seccompProfile: { type: RuntimeDefault }

Un sistema de ficheros raíz de solo lectura te obliga a montar volúmenes emptyDir allí donde la aplicación escribe de verdad, lo que ya es de por sí una auditoría útil. Pod Security Admission impone estos ajustes en todo un namespace mediante una etiqueta como pod-security.kubernetes.io/enforce: restricted, de modo que un manifiesto que los omita se rechaza en lugar de quedar inseguro sin que nadie lo note.

RBAC regula lo que un sujeto puede hacer a través de la API. Da a cada carga de trabajo su propio ServiceAccount y concede solo lo que necesita:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { namespace: staging, name: config-reader }
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { namespace: staging, name: shop-api-config-reader }
subjects:
  - kind: ServiceAccount
    name: shop-api
roleRef: { kind: Role, name: config-reader, apiGroup: rbac.authorization.k8s.io }

Role y RoleBinding tienen ámbito de namespace; ClusterRole y ClusterRoleBinding se aplican a todo el clúster. Define automountServiceAccountToken: false en las cargas que nunca llaman a la API. kubectl auth can-i --list --as=system:serviceaccount:staging:shop-api te dice lo que un service account puede hacer realmente, que suele ser más de lo que suponía su responsable.

La NetworkPolicy restringe el tráfico entre Pods, que de otro modo está completamente abierto. El patrón consiste en denegar todo por defecto en un namespace y luego permitir flujos concretos:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny-ingress, namespace: staging }
spec:
  podSelector: {}
  policyTypes: ["Ingress"]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: api-from-ingress, namespace: staging }
spec:
  podSelector: { matchLabels: { app: shop-api } }
  policyTypes: ["Ingress"]
  ingress:
    - from:
        - namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: ingress-nginx } }
      ports:
        - { protocol: TCP, port: 8080 }

Las policies las aplica el plugin CNI, así que comprueba que el tuyo las soporta: con algunos plugins los objetos se aceptan y se ignoran en silencio, lo cual es peor que no tenerlos.

Empaquetar los manifiestos

Copiar el YAML para cada entorno no resiste la llegada de un tercer entorno. Dos herramientas dominan el panorama.

Kustomize viene integrado en kubectl. Un directorio base contiene manifiestos planos y los overlays los modifican con parches:

# overlays/prod/kustomization.yaml
resources:
  - ../../base
namespace: prod
images:
  - name: registry.example.com/acme/shop-api
    newTag: "1.4.2"
replicas:
  - { name: shop-api, count: 6 }
patches:
  - path: resources-patch.yaml
    target: { kind: Deployment, name: shop-api }
configMapGenerator:
  - name: shop-api-config
    files: [application.yaml]

No hay plantillas, así que cada fichero es YAML válido que puedes leer, y kubectl kustomize overlays/prod imprime exactamente lo que se va a aplicar. Los generadores añaden un hash del contenido al nombre de los ConfigMaps, de modo que cambiar la configuración dispara automáticamente un rollout, que es el problema que la anotación de checksum resuelve a mano.

Helm empaqueta los manifiestos como un chart versionado con plantillas de Go y un values.yaml. Es el formato de distribución de prácticamente todos los componentes de terceros (helm install ingress-nginx ingress-nginx/ingress-nginx -f values.yaml) y registra las releases, de modo que helm rollback restaura los manifiestos anteriores. El precio es que el YAML con plantillas es incómodo de leer y depurar; ejecutar helm template antes de helm install debería ser un acto reflejo.

Elijas lo que elijas, los manifiestos van en Git y deben aplicarse mediante un proceso automatizado, no desde un portátil. Los controladores GitOps como Argo CD y Flux llevan esta idea hasta el final: el repositorio es el estado deseado, el controlador reconcilia continuamente el clúster con él y las desviaciones se notifican en lugar de descubrirse por casualidad.

Ideas clave

  • La liveness reinicia, la readiness drena y la startup protege los arranques lentos: nunca dejes que una liveness probe compruebe una dependencia, y añade un retardo en preStop para que las conexiones se vacíen.
  • Las requests guían la planificación y los limits los aplica el runtime; define siempre limits de memoria, trata con desconfianza los limits de CPU y conoce la clase QoS en la que acaban tus Pods.
  • Los rolling updates con maxUnavailable: 0 y minReadySeconds fallan de forma segura, pero el rollback es cosa de tu pipeline; un PodDisruptionBudget te protege durante los drenados.
  • Escala según la CPU relativa a las requests, ajusta el bloque behavior para que la reducción sea lenta y recuerda que el cluster autoscaler tiene que aportar los nodos.
  • Consolida hoy los puntos de entrada detrás de un controlador Ingress y planifica el paso a la Gateway API; mantén el comportamiento específico del controlador en anotaciones que puedas encontrar más adelante.
  • Los StatefulSets aportan identidad y almacenamiento por Pod; la replicación, el failover y las copias de seguridad vienen del software o de su operator, no de Kubernetes.
  • Blinda las cargas de trabajo con un security context restrictivo, un ServiceAccount por carga con un RBAC mínimo y una NetworkPolicy de denegación por defecto que tu CNI aplique de verdad.
  • Usa overlays de Kustomize para tus propios servicios y charts de Helm para el software de terceros, guarda ambos en Git y deja que un controlador GitOps los aplique.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Debo definir limits de CPU?
A menudo, no. La CPU es comprimible: superar el limit no mata el contenedor, sino que lo estrangula (throttling), y un throttling agresivo produce picos de latencia difíciles de diagnosticar. Una request de CPU garantiza una parte del procesador y guía la planificación, que es lo que importa. La memoria es otra historia: no es comprimible, así que un limit de memoria es imprescindible para que un contenedor no tumbe un nodo entero, y en las cargas críticas debe ser igual a la request.
¿Por qué se reinicia mi Pod si la aplicación funciona bien?
Normalmente por una liveness probe demasiado estricta. Si el endpoint comprueba la conexión a la base de datos, una consulta lenta o un corte breve basta para dar el contenedor por muerto y Kubernetes lo reinicia, lo que no arregla la base de datos y sí descarta las peticiones en curso. La liveness solo debe comprobar si el proceso está bloqueado. La salud de las dependencias corresponde a la readiness probe, que saca el Pod del balanceo sin matarlo.
¿Qué pasa realmente durante un rolling update?
El Deployment crea un ReplicaSet nuevo y lo va escalando mientras reduce el antiguo, respetando maxSurge y maxUnavailable. Un Pod nuevo solo cuenta como disponible cuando supera su readiness probe y ha transcurrido minReadySeconds. Si los Pods nuevos nunca llegan a estar listos, progressDeadlineSeconds acaba marcando el rollout como fallido, pero no hace rollback automático: vigila el estado del rollout en tu pipeline o usa un controlador que lo haga.
¿Helm o Kustomize?
Kustomize encaja con tus propias aplicaciones: mantienes YAML plano y superpones parches específicos de cada entorno sobre una base, sin ningún lenguaje de plantillas que depurar. Helm encaja con el software que distribuyes o consumes, porque un chart es un paquete versionado con values, dependencias e historial de releases. Muchos equipos usan los dos, Helm para componentes de terceros y Kustomize para sus propios servicios, y esa combinación es perfectamente razonable.