Plan de estudio Java 2026
Módulo 09 crítico Días 18–20, 22–23 ≈ 10 h de estudio activo

Del JAR a producción: Docker, Kubernetes, CI/CD y nube

Escribir el código es la mitad del trabajo. La otra mitad es empaquetarlo de forma reproducible, ejecutarlo con límites de recursos que la JVM entienda, desplegarlo sin perder una sola petición, operarlo con datos y no con intuiciones y elegir servicios gestionados sin arruinar a la empresa. Este módulo recorre ese camino completo: los doce factores traducidos a código Spring Boot, el build, la imagen, el contenedor, el orquestador, el pipeline, la nube y la operación. Siempre con el por qué, porque una receta de kubectl que no entiendes es una receta que te va a explotar a las tres de la mañana.

Progreso de este módulo0 / 0
Cómo leer este módulo: las secciones 1 y 2 son los cimientos (qué es desplegar y de dónde sale el artefacto). Las secciones 3 a 6 son contenedores: si solo tienes tres horas, estudia la 4 y la 5, que son las que más entrevistas se ganan y más incidentes evitan. Las secciones 7 a 10 son Kubernetes, de menos a más: modelo mental, objetos, ciclo de vida del pod y despliegue declarativo. La 11 es el pipeline. Las 12 a 14 son nube e infraestructura. La 15 es operación, que es donde se demuestra si todo lo anterior estaba bien hecho.
Qué necesitas instalado para seguirlo: JDK 21 (Temurin), Maven 3.9 o Gradle 8.10, Docker 27 (o Podman 5, casi todo es equivalente), kubectl 1.31, kind o minikube para tener un clúster local de verdad, helm 3.16, kustomize (viene dentro de kubectl), terraform 1.9 y una cuenta gratuita en cualquier nube. Todo lo de este módulo salvo la sección 12 se puede practicar sin gastar un euro en un portátil con 16 GB de RAM.
Relación con el módulo 08: el módulo de microservicios cubre resiliencia (timeouts, reintentos, circuit breaker, sagas) y observabilidad conceptual (los tres pilares, SLI/SLO, Micrometer, OpenTelemetry). Aquí no lo repetimos: el foco de este módulo es empaquetado, ejecución y plataforma. Cuando algo se solape lo verás enlazado, y en la sección 15 lo que hacemos es aterrizar la observabilidad al día a día de Kubernetes y de un servicio en producción, no volver a explicar qué es una traza.

1 · Del portátil a producción: qué significa desplegar hoy

1.1 Qué es «desplegar» realmente

Hace quince años desplegar era copiar un .war por FTP a un Tomcat que alguien había instalado a mano, reiniciarlo y cruzar los dedos. Ese modelo tenía tres problemas estructurales que hoy son inaceptables:

Hoy, «desplegar» significa publicar una versión inmutable de tu aplicación y pedirle a una plataforma que reemplace progresivamente lo que está corriendo por lo nuevo, sin interrumpir el servicio, con posibilidad de volver atrás en segundos. Cada palabra de esa frase tiene consecuencias técnicas concretas:

Palabra de la definiciónConsecuencia técnicaQué se rompe si falta
versión inmutable El artefacto que se prueba es bit a bit el que se despliega. Nada se recompila ni se reconfigura por el camino: la configuración entra desde fuera. «En mi máquina funciona». El bug de producción no se reproduce porque el binario no es el mismo.
una plataforma Alguien —Kubernetes, ECS, Cloud Run— decide dónde corre el proceso, lo reinicia si muere y lo saca del balanceo si no está listo. Tú describes el estado deseado, no los pasos. Scripts imperativos que funcionan una vez y fallan la segunda porque el estado inicial es otro.
progresivamente Conviven la versión N y la N+1 durante minutos. Tu código y tu esquema de base de datos deben tolerar esa convivencia (compatibilidad hacia atrás y hacia delante). Errores 500 durante el despliegue, o una migración que rompe las instancias antiguas.
sin interrumpir Readiness probe antes de recibir tráfico y apagado ordenado al terminar (SIGTERM → dejar de aceptar → terminar en curso → cerrar). 502 y 504 en cada despliegue. Los usuarios se enteran de que has desplegado.
volver atrás en segundos Las versiones anteriores siguen existiendo en el registro por digest, y el despliegue está descrito en Git, así que revertir es un revert o un rollout undo. Un incidente de 5 minutos se convierte en uno de 2 horas mientras se «arregla hacia delante».
La frase que resume el módulo: build once, deploy many. Se construye un artefacto y ese mismo artefacto pasa por integración, preproducción y producción, cambiando solo la configuración inyectada. Si tu pipeline recompila para producción con un perfil distinto, has perdido la garantía más valiosa que tenías: que lo que probaste es lo que corre.

1.2 Las nueve piezas del rompecabezas

Todo el vocabulario de este módulo cabe en nueve piezas. Si sabes qué hace cada una y qué pasa cuando falla, ya tienes el mapa mental completo.

#PiezaQué esHerramientas típicasFallo característico
1 Build El proceso que convierte fuentes + dependencias declaradas en un artefacto. Debe ser determinista: mismas entradas, misma salida. Maven, Gradle, ejecutado en CI Build que funciona en tu portátil y falla en CI (o al contrario) por versiones o caché local.
2 Artefacto El resultado del build: un fat jar ejecutable de Spring Boot con tu código, las dependencias y un servidor embebido. target/pedidos-1.4.2.jar Artefacto no versionado, o versionado con SNAPSHOT en producción: no sabes qué corre.
3 Imagen El artefacto más su entorno de ejecución (JRE, certificados, zona horaria, usuario) empaquetado en capas de solo lectura con un formato estándar (OCI). Dockerfile, buildpacks, Jib Imagen de 900 MB con Maven y el código fuente dentro; o imagen que corre como root.
4 Registro El almacén de imágenes, direccionable por etiqueta y por digest criptográfico. GHCR, ECR, ACR, Artifact Registry, Harbor Usar :latest: dos nodos descargan contenidos distintos con el mismo nombre.
5 Orquestador Quien decide en qué máquina corre cada contenedor, cuántas copias hay, cuándo se reinicia y cómo se sustituye una versión por otra. Kubernetes, ECS, Nomad, Cloud Run Pods en Pending eternamente porque las requests no caben en ningún nodo.
6 Red Cómo se encuentran y se hablan los servicios (DNS interno), y cómo entra el tráfico de internet (balanceador, TLS, enrutado por host y ruta). Service, Ingress, Gateway API, malla 503 del ingress porque el Service no tiene endpoints (readiness fallando).
7 Configuración Todo lo que cambia entre entornos: URLs, tamaños de pool, banderas, niveles de log. Vive fuera del artefacto. Variables de entorno, ConfigMap, servidor de configuración Config duplicada en tres sitios; nadie sabe qué valor gana realmente en producción.
8 Secretos El subconjunto de la configuración que no puede aparecer en un log, en Git ni en una imagen. Secrets Manager, Vault, External Secrets, SOPS Contraseña en el application.yml del repositorio, descubierta por un escáner tres años después.
9 Observabilidad Logs, métricas y trazas que salen del proceso y se agregan en algún sitio consultable, más las alertas que te avisan antes que el cliente. Micrometer, Prometheus, Loki, Tempo, OTel Un incidente que se investiga entrando por SSH a un contenedor que ya no existe.

1.3 El camino completo de un commit a producción

Este es el diagrama que deberías poder dibujar en una pizarra en una entrevista, señalando dónde está cada garantía. Fíjate en que el artefacto se construye una sola vez y que el digest viaja intacto hasta producción.

┌─────────────┐
│ git push    │  rama feature → pull request
└──────┬──────┘
       │
       ▼
┌──────────────────────────────────────────────────────────────────────┐
│ CI: VERIFICAR (minutos, en cada push)                                │
│  · mvn verify        → compila, tests unitarios, tests de            │
│                        integración con Testcontainers               │
│  · análisis estático → SpotBugs, Checkstyle, SonarQube              │
│  · SCA               → dependency-check / Snyk (CVEs)               │
│  · secretos          → gitleaks                                     │
│  Si algo falla: el PR no se puede fusionar. Fin.                    │
└──────┬───────────────────────────────────────────────────────────────┘
       │  merge a main
       ▼
┌──────────────────────────────────────────────────────────────────────┐
│ CI: CONSTRUIR EL ARTEFACTO (una sola vez en toda su vida)            │
│  · jar ejecutable por capas (layered jar)                            │
│  · imagen OCI multi-stage, usuario no root, sin shell                │
│  · SBOM (CycloneDX) + escaneo de imagen (Trivy)                      │
│  · firma (cosign, keyless con OIDC)                                  │
│  · push a GHCR con etiquetas: 1.4.2 · 1.4 · sha-a1b2c3d              │
│  ► SALIDA CLAVE: el digest sha256:9f8e7d… ← esto es «la versión»     │
└──────┬───────────────────────────────────────────────────────────────┘
       │  el digest, no la etiqueta
       ▼
┌──────────────────────────────────────────────────────────────────────┐
│ CD: DESPLEGAR (mismo digest en los tres entornos)                    │
│  1. integración  → automático, tests de humo, tests de contrato      │
│  2. staging      → automático, prueba de carga corta                 │
│  3. producción   → aprobación manual (o automática si confías en las │
│                    puertas anteriores), canary 5% → 50% → 100%       │
│  Mecanismo: commit en el repo de manifiestos → Argo CD sincroniza    │
└──────┬───────────────────────────────────────────────────────────────┘
       │
       ▼
┌──────────────────────────────────────────────────────────────────────┐
│ OPERAR                                                               │
│  · métricas (latencia p99, errores, saturación) y SLO                │
│  · logs estructurados con trace_id                                   │
│  · alertas sobre síntomas                                            │
│  · si algo va mal: rollback = revert del commit (segundos)            │
└──────────────────────────────────────────────────────────────────────┘
Los cuatro puntos donde este flujo se rompe en la vida real: (1) alguien construye la imagen desde su portátil «para salir del paso» y ya no sabes qué hay dentro; (2) el pipeline reconstruye la imagen para producción con otro perfil; (3) el manifiesto de producción apunta a :latest, así que el despliegue no es reproducible ni reversible; (4) la migración de base de datos se ejecuta en el arranque de la aplicación y bloquea el despliegue. Las cuatro se tratan en detalle en este módulo.

1.4 Los doce factores, uno a uno, aplicados a Spring Boot

The Twelve-Factor App es un documento de 2011 de la gente de Heroku. Sigue siendo la mejor checklist que existe para saber si una aplicación es apta para una plataforma moderna, porque no habla de tecnologías sino de propiedades. La mayoría de los problemas que verás en Kubernetes son, en realidad, un factor incumplido. Vamos uno a uno, con lo que hay que cambiar en el código.

Factor 1 · Código base: un repositorio, muchos despliegues

Una aplicación desplegable = un repositorio con historia. Del mismo repositorio salen todos los entornos; lo que cambia es la configuración, no el código. El antipatrón clásico en Java es la rama release/cliente-a que lleva dos años divergiendo: eso no son entornos, son productos distintos disfrazados.

En la práctica: si compartes código entre servicios, extráelo a una librería versionada y publicada, no a una rama ni a un copy-paste. Y publica esa librería con versión semántica; un SNAPSHOT compartido entre servicios reintroduce el acoplamiento que querías evitar.

Factor 2 · Dependencias: declaradas explícitamente y aisladas

Nada se «asume instalado en el servidor». En Java esto lo tienes casi resuelto por el pom.xml, pero hay cuatro fugas habituales:

# Aislar el entorno de forma explícita: nada depende del host
TZ=UTC
LANG=C.UTF-8
JAVA_TOOL_OPTIONS=-Duser.timezone=UTC -Dfile.encoding=UTF-8

Factor 3 · Configuración: en el entorno, nunca en el código

El test para saber si cumples este factor es brutal y muy claro: ¿podrías hacer público tu repositorio ahora mismo sin filtrar ninguna credencial? Si la respuesta es no, la configuración está en el código.

Spring Boot lo pone fácil porque la relajación de nombres (relaxed binding) traduce variables de entorno a propiedades automáticamente: la propiedad spring.datasource.url se puede sobrescribir con la variable SPRING_DATASOURCE_URL. Y el orden de precedencia está documentado, lo que evita el juego de adivinar qué valor gana.

PrioridadFuente de configuraciónUso recomendado
1 (gana)Argumentos de línea de comandos (--server.port=9090)Depuración puntual y Jobs que necesitan un parámetro.
2SPRING_APPLICATION_JSONInyectar un bloque entero de configuración desde una sola variable.
3Variables de entorno del sistemaEl mecanismo principal en Kubernetes. Secretos y valores por entorno.
4Propiedades del sistema (-D)Ajustes de la JVM y de librerías que solo leen System.getProperty.
5application-{perfil}.yml externo (junto al jar o en /config)Ficheros montados desde un ConfigMap.
6application-{perfil}.yml empaquetadoValores por defecto por tipo de entorno, nunca secretos.
7 (pierde)application.yml empaquetadoLos valores por defecto sensatos para que la app arranque en local.
El error de diseño más común con perfiles. Meter en el jar un application-prod.yml con los hosts, los usuarios y los tamaños de pool de producción, y activar el perfil con SPRING_PROFILES_ACTIVE=prod. Parece limpio, pero: (1) para cambiar un timeout hay que recompilar y volver a desplegar el artefacto, que ya no es el que se probó; (2) la topología de producción está en el repositorio, lo que es información útil para un atacante; (3) el número de perfiles crece sin control (prod, prod-eu, prod-eu-canary…). Usa los perfiles para activar comportamientos (qué beans existen: un MailSender real o uno de mentira), y las variables de entorno para los valores.

Factor 4 · Servicios de respaldo: recursos conectables

La base de datos, Redis, Kafka y el almacén de objetos son recursos adjuntos, identificados por una URL de configuración. Tu código no debe distinguir entre un PostgreSQL local en Docker y un Aurora gestionado: solo cambia la URL, el usuario y la contraseña. Esto es lo que permite que un test con Testcontainers y producción usen el mismo código de acceso a datos.

Consecuencia práctica en el código: nada de if (entorno.equals("local")) dentro de un repositorio. Y nada de rutas absolutas del sistema de ficheros: los ficheros que suben los usuarios van a S3 o a un volumen, nunca a /opt/app/uploads, porque ese directorio desaparece cuando el pod se recrea.

Factor 5 · Construir, liberar, ejecutar: tres etapas separadas y estrictas

EtapaEntradaSalidaQuién la haceEs inmutable
BuildCommit + dependenciasImagen con digestCISí, para siempre
ReleaseImagen + configuración del entornoUna versión desplegable identificada (v42)CD / GitOpsSí; un cambio de config crea una release nueva
RunLa releaseProcesos en ejecuciónOrquestadorNo cambia nada en caliente

La regla dura: en la etapa run no se modifica nada. Ni un kubectl edit a las tres de la mañana, ni un exec para tocar un fichero, ni una recompilación. Si hace falta un cambio, se crea una release nueva. Todo cambio en run se perderá en el siguiente reinicio y, peor, nadie sabrá que existía.

Factor 6 · Procesos: sin estado y sin compartir nada

El proceso puede morir en cualquier momento sin previo aviso —lo mata el autoscaler, lo desaloja el nodo, lo reemplaza un despliegue— y no debe perderse nada. Todo estado persistente vive en un servicio de respaldo. Los cuatro estados que la gente deja accidentalmente en el proceso:

Estado escondidoPor qué falla al escalarSolución en Spring Boot
Sesión HTTP en memoria La segunda petición va a otra réplica y el usuario aparece desconectado. spring-session-data-redis, o autenticación con token sin estado (módulo 10).
Caché local sin coordinación Cada réplica tiene datos distintos; invalidar en una no invalida en las demás. Caché distribuida (Redis) o TTL corto asumiendo la incoherencia a propósito y documentándola.
Ficheros en disco local El fichero subido a la réplica 1 no existe en la 2; y desaparece al recrear el pod. S3 con el SDK v2, o un PersistentVolume ReadWriteMany si no hay alternativa.
@Scheduled en todas las réplicas Con 4 réplicas el informe nocturno se envía 4 veces. ShedLock, o mejor un CronJob de Kubernetes con la misma imagen (sección 8).

Factor 7 · Asignación de puertos: la aplicación se autocontiene

Tu aplicación es un proceso que escucha en un puerto, no un artefacto que se despliega dentro de un servidor de aplicaciones que alguien administra. Esto ya lo hace Spring Boot con Tomcat embebido, y es justo lo que permite que un contenedor sea la unidad de despliegue. Detalles que importan:

Factor 8 · Concurrencia: escala por procesos, no por hilos infinitos

El modelo es «añadir réplicas», no «subir el número de hilos a 5.000». Un proceso Java con un pool de 200 hilos de plataforma y un pool de 10 conexiones a base de datos no escala más que uno con 50 hilos: el cuello está en la base de datos, y con más hilos solo consigues que las peticiones esperen dentro de tu proceso en vez de en la cola del balanceador, con timeouts peores y diagnóstico más difícil.

Con hilos virtuales (Java 21) la regla no cambia, pero el cálculo sí. Activando spring.threads.virtual.enabled=true el límite de concurrencia deja de ser el pool de hilos y pasa a ser el recurso escaso de verdad, casi siempre el pool de conexiones. Eso es bueno, pero significa que ahora tienes que poner el límite explícito (un semáforo, un bulkhead de Resilience4j) donde antes lo ponía el pool por accidente. Ver módulo 03.

Factor 9 · Desechabilidad: arranca rápido y muere con elegancia

Este es el factor que más se incumple en Java y el que provoca más errores 502 durante los despliegues. Consta de dos mitades:

# Las dos líneas que hacen tu aplicación «desechable» de verdad
server:
  shutdown: graceful           # deja de aceptar y termina lo que está en curso
spring:
  lifecycle:
    timeout-per-shutdown-phase: 25s   # menor que terminationGracePeriodSeconds (30s)

Factor 10 · Paridad de entornos: local se parece a producción

El objetivo es reducir tres brechas: la de tiempo (que pasen horas, no meses, entre escribir el código y desplegarlo), la de personal (quien lo escribe lo despliega) y la de herramientas (el mismo motor de base de datos en local y en producción).

H2 en tests y PostgreSQL en producción es la brecha de paridad más caras que se paga en Java. Difieren en tipos, en sintaxis de ON CONFLICT, en funciones de ventana, en el manejo de NULL en índices únicos, en el comportamiento de las secuencias y en el aislamiento. Los tests pasan y producción falla. Con Testcontainers (módulo 07) tienes el motor real en 2 segundos; en 2026 no hay excusa.

Factor 11 · Logs: un flujo de eventos hacia stdout

La aplicación no gestiona ficheros de log: escribe a la salida estándar y se olvida. Rotación, agregación, retención e indexado son problema de la plataforma. Esto no es un capricho: en un contenedor, un fichero de log llena el sistema de ficheros del nodo y tumba a los vecinos, y desaparece cuando el pod se recrea, justo cuando lo necesitabas.

# Spring Boot 3.4+ trae codificador JSON nativo: no hace falta logstash-logback-encoder
logging:
  structured:
    format:
      console: ecs          # ecs | gelf | logstash
  level:
    root: INFO
    com.ejemplo.pedidos: DEBUG

Factor 12 · Procesos de administración: tareas puntuales con el mismo código

Las migraciones de esquema, la reindexación, la corrección de datos de un incidente: todo eso se ejecuta como un proceso separado usando exactamente la misma imagen y la misma versión del código que la aplicación. En Kubernetes es un Job; en local, un docker run --entrypoint. Nunca un script que alguien tiene en su portátil ni un UPDATE pegado en una consola de producción.

FactorAutotest de 10 segundos: ¿lo cumples?
1 · Código base¿Hay una sola rama de la que sale producción?
2 · Dependencias¿Arranca en una máquina limpia con solo Docker instalado?
3 · Configuración¿Podrías hacer público el repositorio sin filtrar nada?
4 · Servicios de respaldo¿Puedes cambiar de base de datos cambiando solo la URL?
5 · Build/release/run¿El digest de producción es el mismo que pasó los tests?
6 · Procesos¿Puedes matar una réplica al azar sin que nadie lo note?
7 · Puertos¿La app arranca con java -jar sin instalar nada más?
8 · Concurrencia¿Duplicar réplicas duplica la capacidad, o el cuello es otro?
9 · Desechabilidad¿Un despliegue produce cero errores 5xx? ¿Lo has medido?
10 · Paridad¿Los tests usan el mismo motor y versión que producción?
11 · Logs¿Hay algún FileAppender en tu logback-spring.xml?
12 · Administración¿La última corrección de datos quedó registrada en algún sitio?

1.5 Configuración por entorno sin recompilar: el patrón completo

Vamos a bajar el factor 3 a código real. El objetivo: una imagen, cuatro entornos (local, integración, staging, producción), cero recompilaciones, cero secretos en Git y la posibilidad de saber en cualquier momento qué valor está activo.

# src/main/resources/application.yml — SOLO valores por defecto seguros
spring:
  application:
    name: pedidos
  datasource:
    url: ${DB_URL:jdbc:postgresql://localhost:5432/pedidos}
    username: ${DB_USER:app}
    password: ${DB_PASSWORD:}          # vacío por defecto: falla pronto y con claridad
    hikari:
      maximum-pool-size: ${DB_POOL_MAX:10}
      connection-timeout: 3000
      leak-detection-threshold: 20000
  jpa:
    open-in-view: false                # ver módulo 05: esto siempre a false
  flyway:
    enabled: false                     # las migraciones las lanza un Job, no el arranque

server:
  port: 8080
  shutdown: graceful
  tomcat:
    threads:
      max: ${TOMCAT_MAX_THREADS:200}

management:
  server:
    port: 8081                         # Actuator en otro puerto: no sale por el ingress
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,metrics
  endpoint:
    health:
      probes:
        enabled: true                  # habilita /health/liveness y /health/readiness
      group:
        readiness:
          include: readinessState,db
        liveness:
          include: livenessState
  metrics:
    tags:
      application: ${spring.application.name}
      # OJO: no metas aquí nada de cardinalidad alta (usuario, id de pedido)

pedidos:
  catalogo-url: ${CATALOGO_URL:http://localhost:8081}
  timeout-ms: ${CATALOGO_TIMEOUT_MS:2000}
  reintentos: ${CATALOGO_REINTENTOS:2}
// Configuración tipada y validada: falla en el arranque, no en la primera petición.
// Es la diferencia entre un pod que no pasa readiness (y no recibe tráfico) y un
// NullPointerException a las 2 de la mañana en el peor momento posible.
package com.ejemplo.pedidos.config;

import jakarta.validation.constraints.*;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;

@Validated
@ConfigurationProperties(prefix = "pedidos")
public record PedidosProperties(
        @NotBlank String catalogoUrl,
        @NotNull @DurationMin(millis = 100) @DurationMax(seconds = 10) Duration timeout,
        @Min(0) @Max(5) int reintentos) {

    public PedidosProperties {
        if (reintentos > 0 && timeout.toMillis() * (reintentos + 1) > 10_000) {
            throw new IllegalArgumentException(
                "timeout x (reintentos+1) supera 10s: el cliente de arriba habrá abandonado antes");
        }
    }
}
// Habilitar el binding y comprobar la configuración efectiva al arrancar.
@SpringBootApplication
@EnableConfigurationProperties(PedidosProperties.class)
public class PedidosApplication {

    private static final Logger log = LoggerFactory.getLogger(PedidosApplication.class);

    public static void main(String[] args) {
        SpringApplication.run(PedidosApplication.class, args);
    }

    // Un log de arranque que te ahorra horas: qué configuración está ACTIVA de verdad.
    // Nunca loguees el valor de un secreto; solo si está presente y su longitud.
    @Bean
    ApplicationRunner trazaDeConfiguracion(Environment env, PedidosProperties props) {
        return args -> {
            log.info("perfiles activos = {}", Arrays.toString(env.getActiveProfiles()));
            log.info("catalogo = {} (timeout {} ms, {} reintentos)",
                    props.catalogoUrl(), props.timeout().toMillis(), props.reintentos());
            String pass = env.getProperty("spring.datasource.password", "");
            log.info("password de BD presente = {} (longitud {})", !pass.isBlank(), pass.length());
        };
    }
}
Cómo depurar «¿de dónde sale este valor?». Actuator tiene el endpoint /actuator/env, que lista todas las fuentes de propiedades en orden de precedencia y el valor que gana. Con /actuator/configprops ves el resultado del binding a tus @ConfigurationProperties. Los dos endpoints son sensibles: exponlos solo en el puerto de gestión y protégelos. En un incidente valen su peso en oro.

1.6 Cultura DevOps y qué se espera de un desarrollador senior

DevOps no es un puesto ni una herramienta: es la decisión de que el mismo equipo que construye un servicio es el responsable de operarlo. La frase de Amazon, «you build it, you run it», resume el cambio de incentivos: si te van a llamar cuando falle, escribirás logs útiles, pondrás métricas y no dejarás una migración que bloquee el arranque.

Modelo antiguoConsecuenciaModelo actual
Desarrollo entrega un WAR a Operaciones Incentivos opuestos: desarrollo quiere cambiar, operaciones quiere estabilidad. El resultado son comités de cambio y despliegues trimestrales. El equipo de producto es dueño del servicio en producción, con guardia incluida.
Operaciones instala y configura servidores Servidores irreproducibles, conocimiento en la cabeza de una persona. Infraestructura como código, revisada en un pull request como cualquier otro cambio.
Un despliegue grande al trimestre Cientos de cambios juntos: cuando falla, nadie sabe cuál fue. Rollback imposible. Despliegues pequeños y frecuentes. Lote pequeño = riesgo pequeño y diagnóstico trivial.
Culpar a quien rompió producción La gente esconde los errores y no se aprende nada. Postmortem sin culpa: se buscan causas sistémicas y se arregla el sistema.
Un «equipo DevOps» que despliega por ti Es Operaciones con nombre nuevo: mismo cuello de botella, más siglas. Equipo de plataforma que construye caminos pavimentados y autoservicio.

Traducido a lo que se espera de ti en una entrevista para un puesto senior de Java en 2026:

El nivel que separa a un senior de verdad: no es conocer más comandos de kubectl, es saber elegir. Un senior te dirá «para tres servicios y un equipo de cinco personas, Kubernetes es un impuesto que no puedes pagar; usa Cloud Run y vuelve a esta conversación cuando tengas veinte servicios». Esa frase vale más que cualquier certificación, y es exactamente el tipo de juicio que se evalúa en las preguntas abiertas de la sección 17.

2 · Build reproducible: de las fuentes al artefacto

Todo lo que viene después depende de esto. Si el build no es reproducible, la imagen no es reproducible, el despliegue no es reproducible y el rollback es una lotería. Un build reproducible significa: mismo commit + misma configuración de build = artefacto funcionalmente idéntico, hoy y en seis meses, en tu portátil y en CI.

2.1 Maven: el ciclo de vida y lo que de verdad hay que saber

Maven no ejecuta «tareas» sino fases de un ciclo de vida predefinido. Cuando invocas una fase, se ejecutan todas las anteriores. Entender esto elimina el 90% de la confusión: por eso mvn test compila antes, y por eso mvn package ejecuta los tests unitarios.

FaseQué hacePlugin que la implementaCuándo la invocas tú
validateComprueba que el proyecto es correcto y están las dependencias.Casi nunca.
compileCompila src/main/java a target/classes.maven-compiler-pluginPara ver rápido si compila.
testEjecuta los tests unitarios (*Test.java).maven-surefire-pluginBucle de desarrollo.
packageEmpaqueta en target/*.jar. Con Spring Boot, además repackage a jar ejecutable.maven-jar-plugin + spring-boot-maven-pluginCuando quieres el jar sin tests de integración.
verifyEjecuta los tests de integración (*IT.java) y las comprobaciones de calidad.maven-failsafe-plugin, jacocoEsta es la que va en CI.
installCopia el artefacto al repositorio local ~/.m2.maven-install-pluginSolo para consumirlo desde otro proyecto local.
deployPublica el artefacto en un repositorio remoto (Nexus, Artifactory).maven-deploy-pluginSolo para librerías compartidas, no para servicios.
Diferencia crítica entre Surefire y Failsafe (pregunta de entrevista sorprendentemente frecuente). Surefire ejecuta en test y, si un test falla, corta el build inmediatamente. Failsafe ejecuta en integration-test, guarda los resultados y solo falla en la fase verify, después de haber ejecutado post-integration-test. Esa diferencia existe para poder apagar los recursos levantados (contenedores, servidores embebidos) aunque los tests fallen. Si pones tus tests de integración con nombre *Test, los ejecuta Surefire y te quedas con contenedores huérfanos.
# Los comandos de Maven que usarás de verdad
mvn -B clean verify                      # LO QUE VA EN CI: limpio, no interactivo, todo
mvn -B verify -DskipITs                  # salta solo los tests de integración
mvn -B verify -Dtest=PedidoServiceTest   # un único test (surefire)
mvn -B verify -Dit.test=PedidoIT         # un único test de integración (failsafe)
mvn -B package -DskipTests               # jar rápido (SOLO en local; nunca en CI)
mvn -o verify                            # modo offline: verifica que no falta nada en ~/.m2

mvn -B dependency:tree                   # el árbol completo de dependencias
mvn -B dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind
mvn -B dependency:analyze                # declaradas y no usadas / usadas y no declaradas
mvn -B versions:display-dependency-updates
mvn -B help:effective-pom                # el POM real tras heredar del padre y los BOM
mvn -B help:active-profiles              # qué perfiles están activos y por qué

# Diagnóstico cuando «en CI falla y en local no»
mvn -B -X verify                         # traza completa (muy verbosa, pero definitiva)
mvn -B -Dmaven.repo.local=/tmp/m2 verify # build desde un repositorio local vacío
Por qué -B siempre en CI. --batch-mode desactiva la salida con colores y las barras de progreso de descarga, que en un log de CI generan miles de líneas de ruido y a veces caracteres de control que rompen el visor. Añade también -Dstyle.color=never si tu versión aún los emite, y -Dorg.slf4j.simpleLogger.showDateTime=true si quieres saber qué paso tardó.

2.2 El wrapper: la primera condición de reproducibilidad

El wrapper (mvnw / gradlew) es un script que descarga y usa la versión exacta de Maven o Gradle declarada en el repositorio. Sin él, tu build depende de la versión que cada persona y cada runner tenga instalada, y hay diferencias de comportamiento reales entre versiones de Maven (resolución de dependencias, orden de perfiles, plugins).

# Generar o actualizar el wrapper de Maven
mvn wrapper:wrapper -Dmaven=3.9.9

# Estos ficheros SE VERSIONAN (y el .jar del wrapper también)
#   mvnw  mvnw.cmd  .mvn/wrapper/maven-wrapper.properties

# A partir de aquí, en CI y en el README siempre ./mvnw, nunca mvn
./mvnw -B clean verify
# .mvn/maven.config — argumentos que se aplican SIEMPRE, también en local.
# Ventaja: el comando del README es idéntico al de CI y nadie olvida un flag.
-B
--no-transfer-progress
-Dmaven.build.cache.enabled=true
# .mvn/jvm.config — memoria del propio proceso Maven (no de tu app).
# Útil en monorrepos grandes donde Maven se queda sin metaspace.
-Xmx2g
-XX:MaxMetaspaceSize=512m

2.3 Gestión de versiones: el BOM de Spring Boot y por qué no debes tocar versiones

Un BOM (Bill of Materials) es un POM que no aporta código: solo declara, en su bloque dependencyManagement, qué versión debe usarse de cada artefacto. El BOM de Spring Boot gestiona más de 400 dependencias con versiones que el equipo de Spring ha probado juntas. Ese «juntas» es todo el valor: Jackson, Hibernate, Tomcat, Micrometer y Netty tienen combinaciones que fallan en tiempo de ejecución con NoSuchMethodError, y el BOM te garantiza una que no.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

  <!-- Opción A (la habitual): heredar del parent de Spring Boot.
       Además del dependencyManagement te da configuración de plugins,
       filtrado de recursos y el perfil de repackage ya montado. -->
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.0</version>
    <relativePath/>
  </parent>

  <groupId>com.ejemplo</groupId>
  <artifactId>pedidos</artifactId>
  <version>1.4.2</version>

  <properties>
    <java.version>21</java.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <!-- Sobrescribir una versión gestionada: SIEMPRE por propiedad, nunca
         poniendo <version> en la dependencia. Así se aplica también a las
         dependencias transitivas y queda documentado en un solo sitio. -->
    <testcontainers.version>1.20.4</testcontainers.version>
    <!-- Reproducibilidad: fija la fecha de los ficheros del jar.
         Sin esto, dos builds del mismo commit producen jars con hash distinto. -->
    <project.build.outputTimestamp>2026-01-15T00:00:00Z</project.build.outputTimestamp>
  </properties>

  <dependencyManagement>
    <dependencies>
      <!-- Opción B: importar BOMs adicionales. Imprescindible cuando no puedes
           heredar del parent (porque ya heredas de un parent corporativo). -->
      <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-dependencies</artifactId>
        <version>2025.0.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>software.amazon.awssdk</groupId>
        <artifactId>bom</artifactId>
        <version>2.30.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <!-- Fíjate: NINGUNA lleva <version>. La pone el BOM. -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
      <groupId>io.micrometer</groupId>
      <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
    <dependency>
      <groupId>org.flywaydb</groupId>
      <artifactId>flyway-database-postgresql</artifactId>
    </dependency>
    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <scope>runtime</scope>
    </dependency>

    <!-- Soporte de Docker Compose en desarrollo: levanta compose.yaml al arrancar.
         optional=true para que NO se propague a quien dependa de este módulo. -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-docker-compose</artifactId>
      <scope>runtime</scope>
      <optional>true</optional>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>postgresql</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
        <configuration>
          <!-- Jar por capas: la clave para que la caché de Docker funcione (2.9) -->
          <layers><enabled>true</enabled></layers>
          <!-- Nombre estable del jar: el Dockerfile no depende de la versión -->
          <finalName>app</finalName>
          <image>
            <name>ghcr.io/ejemplo/pedidos:${project.version}</name>
            <env>
              <BP_JVM_VERSION>21</BP_JVM_VERSION>
              <BPE_DELIM_JAVA_TOOL_OPTIONS> </BPE_DELIM_JAVA_TOOL_OPTIONS>
              <BPE_APPEND_JAVA_TOOL_OPTIONS>-XX:MaxRAMPercentage=75</BPE_APPEND_JAVA_TOOL_OPTIONS>
            </env>
          </image>
        </configuration>
      </plugin>

      <!-- Tests de integración: *IT.java con Failsafe -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-failsafe-plugin</artifactId>
        <executions>
          <execution>
            <goals>
              <goal>integration-test</goal>
              <goal>verify</goal>
            </goals>
          </execution>
        </executions>
      </plugin>

      <!-- Cobertura con umbral: una puerta de calidad real, no un informe decorativo -->
      <plugin>
        <groupId>org.jacoco</groupId>
        <artifactId>jacoco-maven-plugin</artifactId>
        <version>0.8.12</version>
        <executions>
          <execution><goals><goal>prepare-agent</goal></goals></execution>
          <execution>
            <id>comprobar-cobertura</id>
            <phase>verify</phase>
            <goals><goal>check</goal></goals>
            <configuration>
              <rules>
                <rule>
                  <element>BUNDLE</element>
                  <limits>
                    <limit>
                      <counter>LINE</counter>
                      <value>COVEREDRATIO</value>
                      <minimum>0.70</minimum>
                    </limit>
                  </limits>
                </rule>
              </rules>
            </configuration>
          </execution>
        </executions>
      </plugin>

      <!-- SBOM en formato CycloneDX: se genera en cada build y se archiva -->
      <plugin>
        <groupId>org.cyclonedx</groupId>
        <artifactId>cyclonedx-maven-plugin</artifactId>
        <version>2.9.1</version>
        <executions>
          <execution>
            <phase>package</phase>
            <goals><goal>makeAggregateBom</goal></goals>
          </execution>
        </executions>
      </plugin>

      <!-- Reglas duras del build: sin dependencias duplicadas ni versiones dinámicas -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-enforcer-plugin</artifactId>
        <version>3.5.0</version>
        <executions>
          <execution>
            <id>reglas</id>
            <goals><goal>enforce</goal></goals>
            <configuration>
              <rules>
                <requireMavenVersion><version>[3.9,)</version></requireMavenVersion>
                <requireJavaVersion><version>[21,)</version></requireJavaVersion>
                <banDuplicatePomDependencyVersions/>
                <banDynamicVersions/>
                <dependencyConvergence/>
              </rules>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>
Nunca uses versiones dinámicas. <version>LATEST</version>, RELEASE o un rango como [2.0,3.0) destruyen la reproducibilidad: el mismo commit construido en marzo y en abril produce artefactos distintos, y un build que funcionaba deja de funcionar sin que nadie haya cambiado nada. La regla banDynamicVersions del Enforcer lo impide de forma automática. Y los -SNAPSHOT de terceros son la misma trampa con otro nombre.

2.4 Dependencias transitivas y cómo resolver un conflicto con dependency:tree

Tú declaras 15 dependencias y acabas con 180. Las otras 165 son transitivas y, cuando dos caminos piden versiones distintas de la misma librería, Maven aplica la regla de la declaración más cercana (nearest wins): gana la que está a menos saltos de tu POM y, a igual distancia, la declarada antes. No gana «la más nueva», que es lo que casi todo el mundo asume.

Esa regla es la causa de la clase de error más desconcertante en Java: el código compila perfectamente y en ejecución lanza NoSuchMethodError, NoClassDefFoundError o AbstractMethodError. Compilaste contra una versión y en el classpath hay otra.

# 1. Ver el árbol y localizar el conflicto. Maven avisa con «omitted for conflict with»
./mvnw -B dependency:tree -Dverbose -Dincludes=com.fasterxml.jackson.core

[INFO] com.ejemplo:pedidos:jar:1.4.2
[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:3.5.0:compile
[INFO] |  \- com.fasterxml.jackson.core:jackson-databind:jar:2.18.2:compile
[INFO] \- com.proveedor:sdk-facturacion:jar:4.1.0:compile
[INFO]    \- (com.fasterxml.jackson.core:jackson-databind:jar:2.13.0:compile
[INFO]        - omitted for conflict with 2.18.2)      <── AQUÍ está la respuesta

# 2. Ver el classpath final, ya resuelto, en el orden real
./mvnw -B dependency:list -DincludeScope=runtime | sort

# 3. Detectar dependencias declaradas y no usadas (y al contrario)
./mvnw -B dependency:analyze
# [WARNING] Used undeclared dependencies found:   ← PELIGRO: usas algo transitivo.
#                                                   Si el intermediario lo quita, rompes.
# [WARNING] Unused declared dependencies found:   ← limpia el POM (menos CVEs que revisar)
SituaciónSolución correctaPor qué no la alternativa
Quieres subir la versión de una librería que gestiona el BOM Sobrescribe la propiedad: <jackson.version>2.18.2</jackson.version> Poner <version> en una dependencia solo afecta a esa, no a las 12 hermanas del mismo grupo: acabas con versiones mezcladas.
Una dependencia arrastra algo que no quieres (por ejemplo, commons-logging) <exclusions> en esa dependencia Excluirlo globalmente con un provided falso oculta el problema y falla en runtime.
Dos dependencias piden versiones incompatibles y ninguna funciona con la otra Fija la versión en dependencyManagement y añade un test de integración que ejerza el camino de código afectado Confiar en «compila, luego funciona» es exactamente el error que produce el NoSuchMethodError.
Necesitas Tomcat en compilación pero no en el jar (despliegue en WAR) <scope>provided</scope> Con compile acabas con dos Tomcat en el classpath.
Quieres detectar conflictos antes de que exploten Regla <dependencyConvergence/> del Enforcer en CI Revisar el árbol a mano no escala y nadie lo hace de forma sistemática.
Nota histórica útil en entrevistas: el fat jar de Spring Boot no aplana las dependencias en un solo espacio de nombres (a diferencia del shade de Maven), sino que las mantiene como jars dentro de BOOT-INF/lib/ y las carga con un ClassLoader propio. Ventaja: no hay colisiones de ficheros de recursos ni de META-INF/services, que era el infierno del shade plugin. Coste: no puedes ejecutar el jar como una dependencia normal de otro proyecto; para eso Spring Boot genera además el jar «plano» con el clasificador -original.

2.5 Perfiles de Maven: úsalos poco y con criterio

Los perfiles de Maven activan configuración de build: plugins, dependencias, recursos. No los confundas con los perfiles de Spring, que son de runtime. La regla es sencilla:

NecesidadHerramienta correcta
Otra URL de base de datos en producciónVariable de entorno (perfil de Spring como mucho). Nunca un perfil de Maven.
Compilar la imagen nativa de GraalVM solo cuando se pidaPerfil de Maven native. Correcto: cambia el build.
Saltar los tests lentos en el bucle localEtiquetas de JUnit 5 (@Tag) + -Dgroups. Mejor que un perfil.
Firmar el artefacto solo al publicarPerfil release. Correcto.
<profiles>
  <!-- Perfil legítimo: cambia CÓMO se construye, no qué configuración lee la app -->
  <profile>
    <id>native</id>
    <build>
      <plugins>
        <plugin>
          <groupId>org.graalvm.buildtools</groupId>
          <artifactId>native-maven-plugin</artifactId>
          <configuration>
            <buildArgs>
              <buildArg>--no-fallback</buildArg>
              <buildArg>-march=compatibility</buildArg>
            </buildArgs>
          </configuration>
        </plugin>
      </plugins>
    </build>
  </profile>

  <!-- Perfil que se activa solo en CI, para no ralentizar el bucle local -->
  <profile>
    <id>ci</id>
    <activation>
      <property><name>env.CI</name></property>
    </activation>
    <build>
      <plugins>
        <plugin>
          <groupId>com.github.spotbugs</groupId>
          <artifactId>spotbugs-maven-plugin</artifactId>
          <executions>
            <execution><phase>verify</phase><goals><goal>check</goal></goals></execution>
          </executions>
        </plugin>
      </plugins>
    </build>
  </profile>
</profiles>

2.6 Gradle: las mismas ideas con otra sintaxis

Gradle es más rápido (caché de configuración, build incremental, ejecución en paralelo y demonio persistente) y más flexible, a cambio de más complejidad conceptual. Si tienes elección para un servicio nuevo, cualquiera de los dos funciona; si el monorrepo tiene 40 módulos, Gradle gana claramente por tiempo de build.

// build.gradle.kts — Kotlin DSL, que es el estándar actual
plugins {
    java
    id("org.springframework.boot") version "3.5.0"
    id("io.spring.dependency-management") version "1.1.7"  // aplica el BOM de Spring Boot
    id("org.cyclonedx.bom") version "2.1.0"
}

group = "com.ejemplo"
version = "1.4.2"

java {
    toolchain {
        // Toolchain: Gradle DESCARGA el JDK 21 si no está. Esto es reproducibilidad
        // de verdad: no depende del JAVA_HOME de quien ejecuta el build.
        languageVersion = JavaLanguageVersion.of(21)
        vendor = JvmVendorSpec.ADOPTIUM
    }
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("io.micrometer:micrometer-registry-prometheus")
    runtimeOnly("org.postgresql:postgresql")
    developmentOnly("org.springframework.boot:spring-boot-docker-compose")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.testcontainers:postgresql")
}

// api vs implementation: la diferencia clave frente a Maven.
//   implementation → NO se expone a quien depende de este módulo (menos recompilación)
//   api            → sí se expone (solo para librerías, y con cuidado)

tasks.test {
    useJUnitPlatform()
    // Tests de integración separados por etiqueta
    systemProperty("junit.jupiter.execution.parallel.enabled", "true")
}

tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
    // Jar por capas para la caché de Docker
    layered { enabled = true }
    archiveFileName = "app.jar"
}

tasks.named<org.springframework.boot.gradle.tasks.bundling.BootBuildImage>("bootBuildImage") {
    imageName = "ghcr.io/ejemplo/pedidos:${'$'}{project.version}"
    environment = mapOf("BP_JVM_VERSION" to "21")
}

// Builds reproducibles: sin marcas de tiempo ni orden dependiente del sistema
tasks.withType<AbstractArchiveTask>().configureEach {
    isPreserveFileTimestamps = false
    isReproducibleFileOrder = true
}
ConceptoMavenGradle
Compilar y probar todo./mvnw -B clean verify./gradlew build
Solo el jar./mvnw package -DskipTests./gradlew bootJar
Ejecutar la app./mvnw spring-boot:run./gradlew bootRun
Árbol de dependenciasdependency:treedependencies --configuration runtimeClasspath
Por qué está esta versióndependency:tree -DverbosedependencyInsight --dependency jackson-databind
Ámbito «no propagar»no existe (todo es compile)implementation
Resolución de conflictoEl más cercano ganaLa versión más alta gana (¡ojo, es al revés!)
Versiones bloqueadasdependency:go-offline + BOMdependencyLocking con gradle.lockfile
Construir imagenspring-boot:build-imagebootBuildImage
Caché de build remotaExtensión de caché de build de MavenNativa (--build-cache, Develocity)

2.7 Build en CI frente a build local: caché de dependencias

En CI el runner arranca limpio, así que sin caché descargas 200 MB de dependencias en cada ejecución: dos o tres minutos de reloj y una carga innecesaria en Maven Central. La caché resuelve eso, pero hay que hacerla bien o introduce un problema peor: builds contaminados.

AspectoLocalCIConsecuencia práctica
~/.m2/repository Acumula meses de artefactos, incluidos SNAPSHOT instalados a mano Vacío o restaurado de una caché con clave determinista El clásico «en mi máquina compila»: usas un jar que solo existe en tu .m2.
Clave de la caché Hash de todos los pom.xml / ficheros de Gradle Si la clave incluye el SHA del commit, nunca aciertas; si no incluye el POM, usas dependencias viejas.
Tests de integración Docker Desktop El demonio Docker del runner (Testcontainers lo detecta solo) En runners sin Docker hay que usar un servicio, o Testcontainers Cloud.
Paralelismo 8–16 núcleos 2–4 núcleos en los runners gratuitos Un test que depende del timing pasa en local y falla en CI: no es «flaky», es que ahí sí se ve la carrera.
Zona horaria y locale Europe/Madrid, es_ES UTC, C.UTF-8 Tests de fechas y de formato que fallan solo en CI. Fija la zona en la configuración de Surefire.
# La caché bien hecha en GitHub Actions. setup-java lo integra: no uses actions/cache a mano.
- uses: actions/setup-java@v4
  with:
    distribution: temurin
    java-version: '21'
    cache: maven          # clave = hash de **/pom.xml, restauración parcial incluida

# Si necesitas control fino (por ejemplo, excluir SNAPSHOTs de la caché):
- uses: actions/cache@v4
  with:
    path: ~/.m2/repository
    key: m2-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
    restore-keys: |
      m2-${{ runner.os }}-
# Y en el paso de build, evita envenenar la caché con artefactos propios:
#   ./mvnw -B verify -Dmaven.install.skip=true
<!-- Fijar zona horaria y locale en los tests: elimina una clase entera de fallos
     que solo aparecen en CI. Va en la configuración de surefire y de failsafe. -->
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <argLine>-Duser.timezone=UTC -Duser.language=es -Duser.country=ES -Dfile.encoding=UTF-8</argLine>
    <!-- Reproducibilidad del orden de los tests: si dependen del orden, quieres saberlo -->
    <runOrder>alphabetical</runOrder>
  </configuration>
</plugin>

2.8 Anatomía del jar ejecutable de Spring Boot

Un jar ejecutable de Spring Boot no es un jar normal ni un shaded jar. Es un jar con una estructura específica y un cargador de clases propio. Saber esto te permite depurar problemas de classpath y entender por qué existen las capas.

app.jar
├── META-INF/
│   ├── MANIFEST.MF
│   │     Main-Class: org.springframework.boot.loader.launch.JarLauncher   ← arranca ESTO
│   │     Start-Class: com.ejemplo.pedidos.PedidosApplication              ← tu main()
│   │     Spring-Boot-Version: 3.5.0
│   │     Spring-Boot-Classes: BOOT-INF/classes/
│   │     Spring-Boot-Lib: BOOT-INF/lib/
│   │     Spring-Boot-Layers-Index: BOOT-INF/layers.idx
│   └── build-info.properties        ← lo genera build-info; alimenta /actuator/info
├── org/springframework/boot/loader/…   ← spring-boot-loader, ~450 KB, SIN comprimir
│                                          (tiene que poder leerse antes de nada)
├── BOOT-INF/
│   ├── classes/                     ← TU código y TUS recursos
│   │   ├── com/ejemplo/pedidos/*.class
│   │   ├── application.yml
│   │   └── db/migration/V1__inicial.sql
│   ├── lib/                         ← 150+ jars de dependencias, tal cual, sin aplanar
│   │   ├── spring-web-6.2.0.jar
│   │   ├── jackson-databind-2.18.2.jar
│   │   └── postgresql-42.7.4.jar
│   ├── layers.idx                   ← el orden y contenido de las capas (2.9)
│   └── classpath.idx                ← orden EXACTO del classpath (determinista)
└── (opcional) target/app.jar.original ← el jar «plano», sin dependencias

El proceso de arranque es: la JVM ejecuta JarLauncher; este lee classpath.idx, crea un LaunchedClassLoader capaz de leer jars anidados sin extraerlos a disco, y con él carga la Start-Class. En Spring Boot 3 se reescribió el loader (paquete org.springframework.boot.loader.launch) y ahora usa ZipFile/NIO en lugar de la implementación propia, lo que reduce el tiempo de arranque y el consumo de memoria nativa.

# Inspeccionar el jar: sorprendentemente útil para depurar
unzip -l target/app.jar | head -30
unzip -p target/app.jar META-INF/MANIFEST.MF
unzip -p target/app.jar BOOT-INF/classpath.idx | head
unzip -p target/app.jar BOOT-INF/layers.idx

# ¿Qué versión de una librería hay REALMENTE dentro del artefacto desplegado?
unzip -l target/app.jar | grep -i jackson-databind
# BOOT-INF/lib/jackson-databind-2.18.2.jar     ← la respuesta definitiva

# Las 20 dependencias más pesadas: por aquí empieza el adelgazamiento
unzip -l target/app.jar | grep 'BOOT-INF/lib' | sort -rn -k1 | head -20

# Ejecutar la clase original sin el launcher (para depurar el classpath)
java -cp "target/classes:$(ls target/dependency/*.jar | tr '\n' ':')" \
     com.ejemplo.pedidos.PedidosApplication

# Extraer el jar para ejecutarlo «explotado»: arranca un 5-10% más rápido porque
# no hay que abrir el jar anidado. Es lo que hace CDS y lo que recomiendan los
# buildpacks para imágenes de contenedor.
java -Djarmode=tools -jar target/app.jar extract --destination /app/extracted
java -jar /app/extracted/app.jar
-Djarmode=tools (Spring Boot 3.3+) sustituye al antiguo -Djarmode=layertools y hace tres cosas útiles dentro de un Dockerfile: extract (explota el jar, con --layers para separarlas), list-layers y la generación del archivo CDS. Es la forma oficial y estable de trocear el artefacto en el build de la imagen.

2.9 Layered jars: la razón por la que tu build de Docker tarda 4 segundos y no 90

Este es el concepto de la sección que más impacto práctico tiene. Un jar de Spring Boot pesa unos 60 MB, de los cuales tu código son 300 KB: el resto son dependencias que cambian una vez al mes. Si copias el jar entero en una sola capa de Docker, cada cambio de una línea de tu código invalida los 60 MB: hay que reconstruir la capa, subirla al registro y descargarla en cada nodo.

Las capas del jar (activadas por defecto en Spring Boot 3) reordenan el contenido por frecuencia de cambio, de menor a mayor, para que Docker pueda reutilizar las estables:

OrdenCapaContenidoTamaño típicoFrecuencia de cambio
1dependenciesTodas las dependencias sin SNAPSHOT55 MBCuando subes el BOM: una vez al mes
2spring-boot-loaderLas clases del launcher450 KBSolo al subir Spring Boot
3snapshot-dependenciesDependencias -SNAPSHOT0–2 MBA menudo (y no deberías tenerlas)
4applicationTu código y tus recursos300 KBEn cada commit
# Ver el índice de capas del jar construido
unzip -p target/app.jar BOOT-INF/layers.idx

- "dependencies":
  - "BOOT-INF/lib/spring-core-6.2.0.jar"
  - "BOOT-INF/lib/jackson-databind-2.18.2.jar"
  # … 148 más
- "spring-boot-loader":
  - "org/"
- "snapshot-dependencies":
- "application":
  - "BOOT-INF/classes/"
  - "BOOT-INF/classpath.idx"
  - "BOOT-INF/layers.idx"
  - "META-INF/"

El resultado medido en un proyecto real de tamaño medio, cambiando una sola línea de un controlador:

EstrategiaCapas que se invalidanBytes a subir al registroTiempo de build incremental
COPY target/*.jar app.jar (una capa)1 capa de 61 MB61 MB~75 s
Capas del jar con extract --layers1 capa de 0,3 MB0,3 MB~6 s
Jib o buildpacks (capas equivalentes)1 capa de 0,3 MB0,3 MB~8 s
El ahorro real no está en tu portátil, está en el clúster. Con 30 réplicas repartidas en 10 nodos, cada despliegue descarga la capa nueva en cada nodo. 61 MB × 10 nodos = 610 MB de tráfico y varios segundos de retraso por pod frente a 3 MB. Eso es la diferencia entre un despliegue de 40 segundos y uno de 4 minutos, multiplicado por 20 despliegues al día.

2.10 Buildpacks: construir la imagen sin escribir un Dockerfile

Los Cloud Native Buildpacks (implementación Paketo, integrada en Spring Boot como spring-boot:build-image) inspeccionan tu proyecto, deciden qué necesita y construyen una imagen OCI optimizada. No escribes nada: ni Dockerfile, ni elección de base, ni usuario.

# Construir la imagen. Necesita un demonio Docker en marcha.
./mvnw -B spring-boot:build-image \
  -Dspring-boot.build-image.imageName=ghcr.io/ejemplo/pedidos:1.4.2

# Publicar directamente en el registro, sin pasar por el demonio local
./mvnw -B spring-boot:build-image \
  -Dspring-boot.build-image.publish=true \
  -Dspring-boot.build-image.imageName=ghcr.io/ejemplo/pedidos:1.4.2

# Variables de configuración del buildpack (van al BUILD, no al runtime)
#   BP_JVM_VERSION=21            versión del JDK/JRE a instalar
#   BP_JVM_TYPE=JRE              JRE (por defecto) o JDK
#   BP_SPRING_CLOUD_BINDINGS_DISABLED=true
#   BP_JVM_CDS_ENABLED=true      genera un archivo CDS: arranque ~20-30% más rápido
#   BP_NATIVE_IMAGE=true         compila imagen nativa con GraalVM
#   BPE_APPEND_JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=75

# Lo que hace el «memory calculator» de Paketo en tiempo de ARRANQUE, y que es su
# rasgo más característico: calcula -Xmx a partir del límite del contenedor,
# el número de clases cargadas y los hilos, en lugar de un porcentaje fijo.
#   -Xmx = límite - (metaspace + direct + pilas de hilos + code cache + reservado)
Ventajas de los buildpacksInconvenientes
No escribes ni mantienes Dockerfiles. En 20 servicios, eso son 20 ficheros menos que revisar. Imagen más grande que un multi-stage cuidado (~330 MB frente a ~200 MB): lleva el lifecycle, el calculador de memoria y utilidades.
Usuario no root, capas óptimas, etiquetas OCI y SBOM automáticos. Menos control: si necesitas instalar fontconfig o un binario, hay que aprender a extender el buildpack.
Rebase: puedes actualizar el sistema base ante un CVE sin reconstruir tu aplicación, cambiando solo las capas de abajo. Esto es potentísimo para parchear rápido. Requiere demonio Docker para el modo local (o modo publish con credenciales).
El calculador de memoria acierta más que un MaxRAMPercentage puesto a ojo. El build es más lento la primera vez (descarga el builder, ~600 MB).

2.11 Jib: imágenes sin demonio Docker

Jib (de Google) construye la imagen desde Maven o Gradle, en Java, sin demonio Docker y sin Dockerfile. Analiza tu proyecto, separa dependencias, recursos y clases en capas distintas y sube directamente al registro. Es la opción más rápida y la más cómoda en CI restringido (runners sin Docker, contenedores sin privilegios).

<plugin>
  <groupId>com.google.cloud.tools</groupId>
  <artifactId>jib-maven-plugin</artifactId>
  <version>3.4.4</version>
  <configuration>
    <from>
      <!-- Base fijada por DIGEST: reproducibilidad total -->
      <image>eclipse-temurin:21.0.5_11-jre-noble@sha256:1a2b3c…</image>
    </from>
    <to>
      <image>ghcr.io/ejemplo/pedidos</image>
      <tags>
        <tag>${project.version}</tag>
        <tag>latest</tag>
      </tags>
    </to>
    <container>
      <user>1000:1000</user>
      <ports><port>8080</port><port>8081</port></ports>
      <jvmFlags>
        <jvmFlag>-XX:MaxRAMPercentage=75</jvmFlag>
        <jvmFlag>-XX:+ExitOnOutOfMemoryError</jvmFlag>
      </jvmFlags>
      <environment>
        <TZ>UTC</TZ>
      </environment>
      <!-- Fecha fija = imagen reproducible bit a bit (por defecto Jib usa epoch) -->
      <creationTime>USE_CURRENT_TIMESTAMP</creationTime>
      <labels>
        <org.opencontainers.image.source>https://github.com/ejemplo/pedidos</org.opencontainers.image.source>
        <org.opencontainers.image.revision>${git.commit.id}</org.opencontainers.image.revision>
      </labels>
    </container>
  </configuration>
</plugin>
# Construir y subir al registro SIN demonio Docker (lo normal en CI)
./mvnw -B compile jib:build

# Construir en el demonio local, para probar la imagen antes de subirla
./mvnw -B compile jib:dockerBuild

# Exportar a un tar (para cargarlo en kind, por ejemplo)
./mvnw -B compile jib:buildTar
kind load image-archive target/jib-image.tar

2.12 Las cuatro formas de construir la imagen, comparadas

CriterioDockerfile multi-stageBuildpacksJibdocker init
Control sobre la imagenTotalBajo (hay que extender)MedioTotal (genera un Dockerfile)
Necesita demonio DockerSí (o buildkit/podman)Sí (o modo publish)No
Tamaño típico (Spring Boot web + JPA)190–240 MB310–360 MB220–260 MB250–300 MB
Capas óptimas «gratis»No: hay que hacerlo bien a manoSí (la plantilla las usa)
Velocidad del build incrementalRápida con cachéMediaMuy rápidaRápida
Instalar paquetes del sistemaTrivial (apt-get)Requiere buildpack propioDifícil (cambiar la base)Trivial
SBOM automáticoNo (lo añades con Syft)ParcialNo
Rebase para parchear CVEsNo: reconstruirNo: reconstruir (pero es rápido)No
Curva de aprendizajeMedia: hay que saber DockerBaja al empezar, alta al personalizarBajaMuy baja
Cuándo elegirloNecesitas control, imagen mínima o dependencias del sistema. Lo que se espera que sepas en una entrevista.Muchos servicios homogéneos y un equipo de plataforma que mantiene el builderCI sin Docker, o quieres velocidad máxima sin aprender DockerPunto de partida para aprender: genera Dockerfile + compose y los editas
Recomendación honesta. Aprende a escribir un Dockerfile multi-stage bien: es lo que te van a preguntar y lo que necesitas para entender qué hacen los otros. Después, en un equipo con muchos servicios, usa buildpacks o Jib para no mantener 20 Dockerfiles casi idénticos. La combinación mala es tener 20 Dockerfiles copiados que nadie ha revisado desde 2021 y que siguen usando openjdk:8-jdk.

2.13 SBOM: el inventario de lo que has empaquetado

Un SBOM (Software Bill of Materials) es la lista, legible por máquinas, de todos los componentes de tu artefacto con su versión y su licencia. Los formatos estándar son CycloneDX (el más usado en el mundo Java) y SPDX. Deja de ser un formalismo cuando entiendes para qué sirve de verdad:

# Generar el SBOM del artefacto (con el plugin del pom: se hace en cada build)
./mvnw -B package
ls target/*.json target/*.xml
# target/bom.json  target/bom.xml   ← CycloneDX

# Generar el SBOM de la IMAGEN (incluye paquetes del sistema, no solo Java)
syft ghcr.io/ejemplo/pedidos:1.4.2 -o cyclonedx-json=sbom-imagen.json

# Preguntar al SBOM si hay vulnerabilidades (sin volver a analizar la imagen)
grype sbom:sbom-imagen.json --fail-on high

# Adjuntar el SBOM a la imagen en el registro, firmado (attestation)
cosign attest --predicate sbom-imagen.json \
  --type cyclonedx ghcr.io/ejemplo/pedidos@sha256:9f8e7d…

# La pregunta del incidente: ¿qué imágenes usan jackson-databind 2.13?
grep -l 'jackson-databind@2.13' sboms/*.json
Dos SBOM distintos, no uno. El del artefacto (generado por Maven) lista tus dependencias Java. El de la imagen (generado por Syft o por el buildpack) lista además el sistema operativo base, OpenSSL, glibc y el JRE. Los CVE críticos de los últimos años han venido tanto de un lado como del otro. Genera y archiva los dos, y hazlo en el pipeline: un SBOM que se genera a mano cuando alguien se acuerda no sirve para responder a un incidente.

3 · Contenedores desde los cimientos

Casi todo el mundo usa contenedores sin saber qué son, y eso está bien hasta el día en que algo falla de forma incomprensible: la JVM ve 64 CPUs en un pod limitado a 500 milicores, un fichero escrito dentro del contenedor desaparece, o docker stats muestra 200 MB mientras el pod muere por OOM. Los cuatro conceptos de esta sección explican todos esos casos.

3.1 Qué es realmente un contenedor

Un contenedor no es una máquina virtual ligera. Es un proceso normal del kernel del anfitrión al que se le ha mentido sobre el mundo que le rodea. No hay hipervisor, no hay kernel invitado, no hay emulación. Si ejecutas ps aux en el host, verás tu proceso Java ahí, con su PID real. La mentira se construye con tres mecanismos del kernel de Linux:

1 · Namespaces: aislamiento de visión

Un namespace hace que el proceso vea solo una parte del sistema. Hay siete tipos y cada uno aísla una cosa distinta.

  • pid: tu proceso es el PID 1 y no ve los del host.
  • net: interfaces, IP, rutas y puertos propios. Por eso dos contenedores pueden usar el 8080.
  • mnt: su propio árbol de directorios (la imagen).
  • uts: su propio hostname.
  • ipc: memoria compartida y semáforos propios.
  • user: mapeo de UIDs (el root de dentro puede ser el UID 100000 de fuera).
  • cgroup: oculta la jerarquía de cgroups del host.

2 · cgroups: limitación de recursos

Los control groups (versión 2 en todo lo moderno) limitan y contabilizan CPU, memoria, E/S y número de procesos. Es lo que hace cumplir el --memory=512m.

  • memory.max: superarlo → el OOM killer mata el proceso (exit 137).
  • cpu.max: cuota por periodo; superarla no mata, ralentiza (throttling).
  • pids.max: límite de procesos e hilos.
  • io.max: ancho de banda de disco.

Esto es lo que la JVM lee para decidir su heap y su número de hilos (sección 5).

3 · Unión de capas: el sistema de ficheros

La imagen es una pila de capas de solo lectura. OverlayFS las presenta como un único árbol y añade encima una capa de escritura efímera propia del contenedor.

  • Escribir un fichero que ya existe abajo lo copia a la capa de arriba (copy-on-write): la primera escritura de un fichero grande es lenta.
  • Borrar un fichero de una capa inferior solo lo oculta: sigue ocupando espacio. Por eso un rm en un RUN posterior no adelgaza la imagen.
  • Al eliminar el contenedor, la capa de escritura desaparece: ahí está la razón de los volúmenes.
# Demostración de que un contenedor es un proceso del host, no una VM.
docker run -d --name demo --memory=512m --cpus=0.5 eclipse-temurin:21-jre \
  java -XX:+PrintFlagsFinal -version

# 1) El proceso existe en el host con su PID real
pgrep -af java

# 2) Dentro, se cree el PID 1
docker exec demo ps -ef
#   UID  PID  PPID  CMD
#   root   1     0  java -version        ← es el PID 1 de SU namespace

# 3) Los namespaces son ficheros en /proc
sudo ls -l /proc/$(docker inspect -f '{{.State.Pid}}' demo)/ns
#   cgroup -> cgroup:[4026532...]   ipc -> ipc:[...]   mnt -> mnt:[...]
#   net -> net:[...]   pid -> pid:[...]   uts -> uts:[...]

# 4) Los límites son ficheros de cgroup v2 que el contenedor PUEDE LEER
docker exec demo cat /sys/fs/cgroup/memory.max      # 536870912  (512 MiB)
docker exec demo cat /sys/fs/cgroup/cpu.max         # 50000 100000  (0,5 CPU)
docker exec demo cat /sys/fs/cgroup/memory.current  # uso actual

# 5) Y esto es EXACTAMENTE lo que la JVM lee para decidir su heap
docker exec demo java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|ActiveProcessorCount'
AspectoMáquina virtualContenedorConsecuencia práctica
Qué virtualiza El hardware. Cada VM tiene su kernel. Nada. Comparte el kernel del host. No puedes correr un contenedor Windows en un kernel Linux, ni cargar un módulo de kernel.
Tamaño GB (SO completo) MB (solo librerías y tu app) Una imagen se descarga en segundos; una AMI, en minutos.
Arranque Decenas de segundos a minutos Milisegundos (el proceso arranca ya) El escalado reactivo es viable. Con Java, el cuello pasa a ser el arranque de la JVM, no el contenedor.
Aislamiento de seguridad Fuerte: superficie = hipervisor Más débil: superficie = todo el kernel No ejecutes código no confiable de varios clientes en el mismo kernel. Para eso: Firecracker, Kata, gVisor.
Densidad Decenas por host Cientos por host Es la razón económica de los contenedores.
Estado Persistente por naturaleza Efímero por diseño Cambia tu forma de pensar: nada valioso en el sistema de ficheros del contenedor.
¿Y Docker Desktop en Mac o Windows? Ahí hay una máquina virtual Linux, porque los contenedores Linux necesitan un kernel Linux. Docker Desktop la gestiona por ti (con Virtualization framework en macOS, WSL2 en Windows). Consecuencias prácticas: el rendimiento de E/S en carpetas montadas desde el host es notablemente peor (usa volúmenes nombrados para la base de datos), la memoria disponible es la que asignes a la VM, y localhost desde el contenedor no es tu Mac (para eso existe host.docker.internal).

3.2 Imagen frente a contenedor

La analogía correcta para un desarrollador Java es directa y hay que tenerla clara porque es una pregunta de entrevista de calentamiento:

Concepto de contenedoresEquivalente en JavaDetalle
Imagen La clase Plantilla inmutable de solo lectura. Se identifica por digest (sha256:…) y opcionalmente por una o varias etiquetas.
Contenedor La instancia Imagen + capa de escritura + proceso en ejecución + configuración (variables, puertos, volúmenes).
Capa Una clase de la jerarquía de herencia Cada instrucción que modifica el sistema de ficheros crea una capa. Se comparten entre imágenes.
Dockerfile El fichero .java La receta declarativa que se «compila» a imagen.
Registro Maven Central / Nexus Almacén con nombres, versiones y verificación por hash.
Etiqueta (tag) Una versión de Maven… pero mutable Aquí se rompe la analogía y es importante: 1.4.2 se puede reasignar a otra imagen. Solo el digest es inmutable.
La etiqueta miente, el digest no. ghcr.io/ejemplo/pedidos:1.4.2 es un puntero: cualquiera con permiso de escritura puede hacer que apunte a otra imagen. Si el nodo A descargó la imagen ayer y el nodo B la descarga hoy, pueden estar ejecutando código distinto con el mismo nombre y sin que nada lo indique. Por eso en producción se despliega ghcr.io/ejemplo/pedidos@sha256:9f8e7d…: el digest es el hash del manifiesto, así que es criptográficamente inmutable. Las etiquetas son para humanos; los digests, para máquinas.

3.3 Los comandos de Docker que usas cada día, y qué hace cada uno por dentro

# ─── EJECUTAR ──────────────────────────────────────────────────────────────────
# docker run = create + start. Estas son las banderas que importan de verdad:
docker run \
  --name pedidos \             # nombre estable (si no, te pone uno gracioso al azar)
  --rm \                       # borra el contenedor al salir: evita acumular basura
  -d \                         # detached: al fondo. Sin esto, ocupa tu terminal
  -p 8080:8080 \               # HOST:CONTENEDOR. Publica el puerto en el host
  -p 127.0.0.1:8081:8081 \     # solo accesible desde localhost del host (más seguro)
  -e SPRING_PROFILES_ACTIVE=local \
  --env-file ./.env.local \    # muchas variables de golpe (no lo subas a git)
  --memory=512m \              # cgroup memory.max. La JVM lo lee.
  --memory-swap=512m \         # igual que memory ⇒ swap desactivado (lo que quieres)
  --cpus=1.5 \                 # cgroup cpu.max
  --read-only \                # sistema de ficheros raíz de solo lectura
  --tmpfs /tmp:rw,size=64m \   # …pero /tmp escribible en memoria (la JVM lo necesita)
  --user 1000:1000 \           # UID:GID, no root
  --network pedidos-net \      # red propia con DNS entre contenedores
  -v pgdata:/var/lib/postgresql/data \   # volumen nombrado (persistente)
  --health-cmd='wget -qO- http://localhost:8081/actuator/health/liveness || exit 1' \
  --health-interval=10s --health-start-period=40s \
  ghcr.io/ejemplo/pedidos:1.4.2

# Ejecutar en primer plano para ver el arranque y salir con Ctrl+C: lo mejor
# para depurar. Sin -d y con --rm.
docker run --rm -it -p 8080:8080 ghcr.io/ejemplo/pedidos:1.4.2

# Sobrescribir el comando: para inspeccionar la imagen sin arrancar la app
docker run --rm -it --entrypoint sh ghcr.io/ejemplo/pedidos:1.4.2
# Si la imagen es distroless y no tiene shell, esto falla. Ver 4.4.

# ─── INSPECCIONAR ──────────────────────────────────────────────────────────────
docker ps                      # en ejecución
docker ps -a                   # incluidos los parados (y su código de salida)
docker ps --filter status=exited --format '{{.Names}}\t{{.Status}}'

docker logs -f --tail=100 pedidos          # sigue la salida estándar
docker logs --since=10m --timestamps pedidos
docker logs pedidos 2>&1 | grep -i error   # stderr también

docker exec -it pedidos sh                 # shell dentro (si la hay)
docker exec pedidos jcmd 1 VM.flags        # ejecutar una herramienta sin shell
docker exec -u root pedidos apk add curl   # entrar como root a un contenedor no-root

docker inspect pedidos                     # TODO en JSON: el comando definitivo
docker inspect -f '{{.State.ExitCode}}' pedidos
docker inspect -f '{{.State.OOMKilled}}' pedidos    # ← ¿lo mató el OOM killer?
docker inspect -f '{{.HostConfig.Memory}}' pedidos
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' pedidos
docker inspect -f '{{json .Config.Env}}' pedidos | jq

docker stats                   # CPU, memoria, red y E/S en vivo (todos)
docker stats --no-stream pedidos
docker top pedidos             # procesos dentro del contenedor
docker diff pedidos            # ficheros añadidos/modificados/borrados respecto a la
                               # imagen. Muy útil: revela escrituras que no esperabas.
docker port pedidos            # mapeo real de puertos
docker events --since 5m       # eventos del demonio: arranques, muertes, OOM

# ─── COPIAR Y EXTRAER ──────────────────────────────────────────────────────────
docker cp pedidos:/tmp/heapdump.hprof ./heapdump.hprof   # sacar un volcado
docker cp ./application-debug.yml pedidos:/config/        # meter un fichero (¡solo depurando!)

# ─── IMÁGENES ──────────────────────────────────────────────────────────────────
docker build -t pedidos:1.4.2 .
docker build --progress=plain --no-cache -t pedidos:1.4.2 .   # ver todo, sin caché
docker build --target build -t pedidos-build .                # parar en una etapa
docker images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' | sort -k2 -h
docker history --no-trunc pedidos:1.4.2      # capas y qué instrucción creó cada una
docker image inspect pedidos:1.4.2 -f '{{.Config.User}} {{.Config.Entrypoint}}'
docker tag pedidos:1.4.2 ghcr.io/ejemplo/pedidos:1.4.2
docker push ghcr.io/ejemplo/pedidos:1.4.2
docker pull ghcr.io/ejemplo/pedidos@sha256:9f8e7d…    # por digest: reproducible
docker save pedidos:1.4.2 | gzip > pedidos.tar.gz    # exportar (sin registro)
docker load < pedidos.tar.gz

# ─── LIMPIAR (tu disco te lo agradecerá) ───────────────────────────────────────
docker system df               # QUÉ ocupa espacio: imágenes, contenedores, volúmenes, caché
docker system df -v            # desglose por elemento
docker container prune         # contenedores parados
docker image prune             # imágenes sin etiqueta (dangling)
docker image prune -a          # TODAS las no usadas por ningún contenedor
docker builder prune           # caché de BuildKit (suele ser la que más ocupa: 20+ GB)
docker volume prune            # ⚠ volúmenes sin usar: BORRA DATOS
docker system prune -af --volumes   # ⚠⚠ todo. Solo si sabes lo que haces.
Necesito…ComandoPor qué ese y no otro
Saber por qué murió un contenedordocker inspect -f '{{.State}}' X y docker logs Xps -a te da el código de salida, pero inspect te dice si fue OOM.
Ver si un fichero se está escribiendo donde no debedocker diff XRevela escrituras en la capa efímera: logs a fichero, cachés locales, subidas.
Comprobar el consumo real de memoriadocker stats + jcmd 1 VM.native_memorystats da el RSS total del contenedor, que es lo que mira el OOM killer; jcmd lo desglosa.
Entender por qué la imagen pesa 800 MBdocker history y luego divehistory te da el culpable por capa en 2 segundos.
Reproducir un problema de producción en localdocker run con la imagen por digest y las mismas variablesCon la etiqueta puede que descargues otra imagen distinta.
Liberar 40 GB de discodocker builder prune -afLa caché de BuildKit crece sin límite y es lo que nadie limpia nunca.

3.4 Volúmenes y persistencia: dónde viven los datos

La capa de escritura del contenedor muere con él. Cualquier dato que deba sobrevivir tiene que estar fuera. Docker ofrece tres mecanismos, y elegir mal es una causa habitual de problemas de rendimiento y de permisos.

TipoSintaxisDónde viveCuándo usarloTrampas
Volumen nombrado -v pgdata:/var/lib/postgresql/data Gestionado por Docker en /var/lib/docker/volumes Datos de bases de datos y de servicios con estado. Es la opción por defecto. Sobrevive a docker compose down; hace falta -v para borrarlo. Si cambias de versión mayor de PostgreSQL, el volumen viejo no arranca.
Bind mount -v $(pwd)/config:/config:ro Un directorio real del host Código fuente en desarrollo, ficheros de configuración, sacar volcados. Rendimiento malo en Mac/Windows. Y los UID del host y del contenedor deben coincidir, o tendrás permission denied.
tmpfs --tmpfs /tmp:rw,size=64m Memoria RAM del host Ficheros temporales con un sistema raíz de solo lectura. La JVM necesita /tmp escribible. Cuenta como memoria del contenedor: un /tmp de 512 MB lleno puede provocar el OOM kill.
# Sintaxis moderna --mount: más verbosa pero explícita y sin ambigüedades
docker run -d \
  --mount type=volume,source=pgdata,target=/var/lib/postgresql/data \
  --mount type=bind,source="$(pwd)"/config,target=/config,readonly \
  --mount type=tmpfs,target=/tmp,tmpfs-size=67108864 \
  postgres:16

# Operaciones con volúmenes
docker volume ls
docker volume inspect pgdata
docker volume create --name pgdata

# Copia de seguridad de un volumen (el patrón estándar: contenedor auxiliar)
docker run --rm -v pgdata:/datos -v "$(pwd)":/copia alpine \
  tar czf /copia/pgdata-$(date +%F).tar.gz -C /datos .

# Restauración
docker run --rm -v pgdata:/datos -v "$(pwd)":/copia alpine \
  sh -c 'rm -rf /datos/* && tar xzf /copia/pgdata-2026-07-31.tar.gz -C /datos'

# Copia LÓGICA de una base de datos (mejor que copiar los ficheros: es portable
# entre versiones y verificable)
docker exec pg16 pg_dump -U postgres -Fc pedidos > pedidos.dump
El problema de permisos que todo el mundo sufre una vez. Ejecutas con --user 1000:1000 y montas un directorio del host. Si ese directorio pertenece al UID 1001, obtienes Permission denied y no hay forma de arreglarlo desde dentro. El contenedor no ve nombres de usuario, solo números: el UID del proceso tiene que coincidir con el dueño de los ficheros del host. Soluciones: chown -R 1000:1000 ./datos en el host, ejecutar con --user "$(id -u):$(id -g)", o usar un volumen nombrado (Docker copia los permisos correctos al inicializarlo). En Kubernetes el equivalente es fsGroup en el securityContext del pod.

3.5 Redes: cómo se encuentran dos contenedores

DriverQué haceDNS entre contenedoresCuándo
bridge (por defecto) Red virtual privada con NAT hacia el exterior. No en la red bridge por defecto; en una red bridge creada por ti. Lo normal. Crea siempre una red propia por proyecto.
host Sin aislamiento de red: usa la pila del host directamente. No aplica: es el DNS del host. Rendimiento extremo o herramientas de red. Pierdes el aislamiento y los puertos colisionan.
none Solo loopback. Sin red. Procesos por lotes que solo leen de un volumen. Máximo aislamiento.
overlay Red entre varios hosts (Swarm). Raro hoy: para esto se usa Kubernetes.
macvlan El contenedor obtiene una MAC y una IP de la red física. El de la red Integración con equipos de red antiguos.
# El patrón correcto: una red por proyecto, con DNS automático por nombre
docker network create pedidos-net

docker run -d --name db --network pedidos-net \
  -e POSTGRES_PASSWORD=secreto -e POSTGRES_DB=pedidos postgres:16-alpine

docker run -d --name app --network pedidos-net -p 8080:8080 \
  -e DB_URL=jdbc:postgresql://db:5432/pedidos \
  ghcr.io/ejemplo/pedidos:1.4.2
#                          ↑↑
#          «db» resuelve al contenedor: Docker tiene un DNS interno en 127.0.0.11.
#          Fíjate en que NO se publica el puerto 5432: la base de datos no es
#          accesible desde el host. Menos superficie de ataque, gratis.

# Diagnóstico de red desde dentro
docker exec app getent hosts db
docker exec app sh -c 'nc -zv db 5432'
docker network inspect pedidos-net | jq '.[0].Containers'

# «Mi contenedor no llega a un servicio de mi propia máquina»
# En Linux: usa --add-host=host.docker.internal:host-gateway
# En Mac/Windows: host.docker.internal ya existe
docker run --rm --add-host=host.docker.internal:host-gateway alpine \
  sh -c 'nc -zv host.docker.internal 5432'
Publicar un puerto no es un detalle inocuo. -p 5432:5432 en Linux inserta una regla de iptables que salta por encima de UFW y de firewalld: tu base de datos queda expuesta en todas las interfaces aunque el firewall diga lo contrario. Es una fuente real de bases de datos comprometidas. Publica siempre con IP explícita: -p 127.0.0.1:5432:5432. Y si dos contenedores solo hablan entre ellos, no publiques nada: la red interna basta.

3.6 Variables de entorno y secretos en tiempo de build

# Tres formas de pasar variables, de peor a mejor para secretos
docker run -e DB_PASSWORD=secreto imagen        # ⚠ visible en `docker inspect`,
                                                #   en `ps -ef` del host y en el historial
docker run --env-file ./.env imagen             # mejor: fuera del historial de shell
docker run -v ./secrets:/run/secrets:ro imagen  # el mejor: fichero montado

# En Spring Boot, leer un secreto desde un FICHERO en lugar de una variable:
#   spring.datasource.password=${DB_PASSWORD_FILE_CONTENT}
# o mejor, con la sintaxis de Spring Boot 3 para ficheros:
#   spring.config.import=optional:file:/run/secrets/
#   → cada fichero del directorio se convierte en una propiedad con su nombre

# ─── SECRETOS EN TIEMPO DE BUILD: NUNCA con ARG ────────────────────────────────
# MAL: el valor queda GRABADO EN LA CAPA para siempre y se ve con `docker history`
#   ARG NEXUS_TOKEN
#   RUN mvn -s settings.xml package    # el token queda en el historial de la imagen

# BIEN: montaje de secreto de BuildKit. No se persiste en ninguna capa.
DOCKER_BUILDKIT=1 docker build \
  --secret id=m2settings,src=$HOME/.m2/settings.xml \
  --secret id=nexus_token,env=NEXUS_TOKEN \
  -t pedidos:1.4.2 .

# Y en el Dockerfile:
#   RUN --mount=type=secret,id=m2settings,target=/root/.m2/settings.xml \
#       --mount=type=cache,target=/root/.m2/repository \
#       ./mvnw -B package -DskipTests
Comprueba tú mismo que un ARG filtra el secreto. Construye una imagen con ARG TOKEN y RUN echo $TOKEN > /dev/null, y ejecuta docker history --no-trunc. Verás el valor en claro. Lo mismo pasa con COPY .npmrc o COPY settings.xml seguidos de un RM: la capa anterior sigue existiendo dentro de la imagen y cualquiera que la descargue puede extraerla. Un secreto que ha entrado en una capa está comprometido y hay que rotarlo, no borrarlo.

3.7 docker init y el ecosistema alrededor

docker init (Docker Desktop 4.19+) es un asistente que detecta el lenguaje del proyecto y genera Dockerfile, compose.yaml, .dockerignore y README.Docker.md. Para Java detecta Maven o Gradle y produce un multi-stage decente. No es perfecto —conviene revisarlo con la sección 4 en la mano— pero como punto de partida ahorra tiempo y evita empezar copiando un Dockerfile de 2018 de Stack Overflow.

cd mi-proyecto
docker init
# ? What application platform does your project use? Java
# ? What's the relative directory for your app? ./
# ? What version of Java do you want to use? 21
# ? What port does your server listen on? 8080
# Crea: .dockerignore  Dockerfile  compose.yaml  README.Docker.md

# Después, RE VÍSALO. Lo que suele faltar o conviene cambiar:
#  · USER no root explícito con UID numérico (no un nombre)
#  · límites de memoria de la JVM (-XX:MaxRAMPercentage)
#  · HEALTHCHECK
#  · etiquetas OCI
#  · caché de dependencias como capa separada
HerramientaQué esPor qué te puede importar
containerd El runtime de contenedores de bajo nivel que usa Docker por debajo y que Kubernetes usa directamente desde 1.24. Explica por qué «Kubernetes ya no usa Docker»: no necesita el demonio de Docker, solo el runtime. Tus imágenes siguen funcionando: el formato es OCI estándar.
Podman Alternativa a Docker sin demonio y con soporte real de rootless. CLI casi idéntica (alias docker=podman funciona el 95% de las veces). Estándar en entornos Red Hat. Genera manifiestos de Kubernetes con podman generate kube. Testcontainers lo soporta configurando el socket.
BuildKit El motor de build moderno (por defecto desde Docker 23): paralelismo entre etapas, montajes de caché y de secretos, salida a varios destinos. Es lo que hace posible --mount=type=cache para el repositorio de Maven, que es la mejor optimización de build que existe.
buildx El CLI de BuildKit: builds multiplataforma (amd64 + arm64) con un comando. Imprescindible si desarrollas en un Mac con Apple Silicon y despliegas en amd64, o al revés.
nerdctl CLI compatible con Docker para containerd. Cuando trabajas directamente contra containerd en un nodo.
Colima / Rancher Desktop / OrbStack Alternativas a Docker Desktop en macOS. Docker Desktop requiere licencia de pago en empresas grandes; estas no. OrbStack es notablemente más rápido.
# Build multiplataforma: lo necesitas si tu portátil es ARM y el clúster es x86
docker buildx create --name multi --use --bootstrap
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t ghcr.io/ejemplo/pedidos:1.4.2 \
  --push .                     # multiplataforma exige --push: no cabe en el demonio local

# Verificar qué plataformas tiene una imagen (índice de manifiestos)
docker buildx imagetools inspect ghcr.io/ejemplo/pedidos:1.4.2

# Síntoma clásico: «exec format error» al arrancar el pod
#   → construiste arm64 y el nodo es amd64. Comprueba la plataforma de la imagen.

4 · Dockerfile para Java hecho bien

Esta sección es la que más rendimiento te va a dar por minuto invertido. Un Dockerfile de Java bien escrito ocupa 30 líneas, produce una imagen de 200 MB que arranca en 3 segundos, no corre como root, se reconstruye en 5 segundos cuando cambias una línea y no tiene ninguna vulnerabilidad crítica. Uno mal escrito ocupa 8 líneas y produce lo contrario en todos los ejes.

4.1 El Dockerfile ingenuo y sus cinco problemas

Este es, literalmente, el Dockerfile que aparece en la mayoría de los tutoriales y en la mayoría de los repositorios corporativos. Funciona. Y tiene cinco problemas graves.

# ❌ NO USES ESTO. Sirve para aprender qué está mal.
FROM openjdk:latest
ADD . /app
WORKDIR /app
RUN ./mvnw package
EXPOSE 8080
CMD java -jar target/pedidos-1.4.2.jar
#ProblemaConsecuencia concreta y medibleSolución
1 FROM openjdk:latest Tres cosas mal a la vez: openjdk está obsoleto desde 2022 (no recibe parches); latest hace el build no reproducible y puede saltar de Java 17 a 25 sin avisar; y trae el JDK completo (compilador, javadoc, herramientas) cuando en runtime solo necesitas el JRE. FROM eclipse-temurin:21.0.5_11-jre-noble, mejor aún fijado por digest.
2 ADD . /app antes de compilar Sin multi-stage, la imagen final contiene Maven, el JDK, el código fuente, el .git y el repositorio .m2: 900 MB en lugar de 200, y tu código fuente en producción. Además ADD descomprime tars y acepta URLs, comportamientos que casi nunca quieres: usa COPY. Multi-stage: compilar en una etapa, copiar solo el jar a la final.
3 Copiar todo antes del RUN ./mvnw package Destruye la caché. Cambiar un carácter en un comentario invalida la capa de COPY, así que Maven vuelve a descargar 200 MB de dependencias. Build de 3 minutos donde debería ser de 15 segundos. Copiar pom.xml primero, resolver dependencias, y solo después copiar src.
4 Sin USER: corre como root Si alguien logra ejecución de código en tu aplicación, es root dentro del contenedor, lo que le da un punto de partida mucho mejor para escapar al host. Muchos clústeres directamente rechazan la imagen (Pod Security Standards restricted) y el pod ni arranca. USER 10001:10001 con UID numérico.
5 CMD java -jar … en shell form Se ejecuta como /bin/sh -c "java -jar …", así que el PID 1 es sh y SIGTERM llega a la shell, no a la JVM. Resultado: no hay apagado ordenado, Kubernetes espera 30 segundos y mata el proceso a SIGKILL, y pierdes las peticiones en curso en cada despliegue. Además el nombre del jar lleva la versión: hay que editar el Dockerfile en cada release. ENTRYPOINT ["java", "-jar", "/app/app.jar"] en exec form.
Bonus: cuatro problemas menores del mismo ejemplo. No hay .dockerignore (el contexto de build sube el target/ y el .git, a veces cientos de MB); no hay configuración de memoria para la JVM; no hay HEALTHCHECK; y no hay etiquetas OCI, así que nadie puede saber de qué commit salió la imagen que está corriendo.

4.2 El multi-stage completo, comentado línea a línea

Este es el Dockerfile que puedes copiar a un proyecto real. Cada línea tiene una razón; las explico todas después.

# syntax=docker/dockerfile:1.10
# ══════════════════════════════════════════════════════════════════════════════
#  ETAPA 1 · DEPENDENCIAS
#  Se separa de la compilación para que un cambio en el código NO invalide la
#  descarga de dependencias. Es la optimización de caché con más impacto.
# ══════════════════════════════════════════════════════════════════════════════
FROM maven:3.9.9-eclipse-temurin-21 AS deps
WORKDIR /build

# Solo los descriptores del build. Si no cambian, todo lo de abajo sale de caché.
COPY pom.xml ./
COPY .mvn/ .mvn/
COPY mvnw ./

# --mount=type=cache: BuildKit mantiene ~/.m2 entre builds SIN meterlo en la imagen.
# Es mucho mejor que dependency:go-offline, que descarga de más y falla en proyectos
# con plugins que resuelven en tiempo de ejecución.
RUN --mount=type=cache,target=/root/.m2/repository,sharing=locked \
    ./mvnw -B -q dependency:go-offline -DskipTests

# ══════════════════════════════════════════════════════════════════════════════
#  ETAPA 2 · COMPILAR Y EMPAQUETAR
# ══════════════════════════════════════════════════════════════════════════════
FROM deps AS build
WORKDIR /build

COPY src/ src/

# Los tests NO se ejecutan aquí: ya se ejecutaron en el pipeline (sección 11) con
# Testcontainers, red y servicios de verdad. Repetirlos dentro del build de la
# imagen duplica el tiempo y no añade ninguna garantía.
RUN --mount=type=cache,target=/root/.m2/repository,sharing=locked \
    ./mvnw -B -q clean package -DskipTests

# Explotar el jar en sus capas. layers.idx manda: dependencies, loader,
# snapshot-dependencies, application. jarmode=tools es la forma oficial en 3.3+.
RUN java -Djarmode=tools -jar target/app.jar extract --layers --destination /build/extracted

# ══════════════════════════════════════════════════════════════════════════════
#  ETAPA 3 · IMAGEN FINAL
#  Base JRE (no JDK): ~180 MB menos. Fijada por versión completa Y por digest,
#  que es lo único que garantiza que el build de hoy y el de mañana son iguales.
# ══════════════════════════════════════════════════════════════════════════════
FROM eclipse-temurin:21.0.5_11-jre-noble AS runtime

# Etiquetas OCI: metadatos estándar. Sin esto, nadie puede saber de qué commit
# salió la imagen que está corriendo en producción a las 3 de la mañana.
ARG VERSION=dev
ARG REVISION=unknown
ARG CREATED=unknown
LABEL org.opencontainers.image.title="pedidos" \
      org.opencontainers.image.description="Servicio de pedidos" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.revision="${REVISION}" \
      org.opencontainers.image.created="${CREATED}" \
      org.opencontainers.image.source="https://github.com/ejemplo/pedidos" \
      org.opencontainers.image.licenses="Apache-2.0" \
      org.opencontainers.image.base.name="eclipse-temurin:21.0.5_11-jre-noble"

# Paquetes del sistema: solo lo imprescindible, en un único RUN (una capa),
# limpiando la caché de apt EN LA MISMA instrucción (si no, la capa ya la contiene).
#   curl        → para el HEALTHCHECK (si usas distroless, ver 4.4: no lo tendrás)
#   tzdata      → zonas horarias; sin él, TZ=Europe/Madrid se ignora en silencio
#   fontconfig  → SOLO si generas PDFs o imágenes con java.awt
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl tzdata \
 && rm -rf /var/lib/apt/lists/*

# Usuario no root con UID NUMÉRICO. Numérico y no un nombre porque Kubernetes
# necesita el número para verificar runAsNonRoot; con un nombre no puede.
# UID alto (10001) para no colisionar con usuarios del sistema.
RUN groupadd --system --gid 10001 app \
 && useradd --system --uid 10001 --gid app --home /app --shell /sbin/nologin app

WORKDIR /app

# ─── EL ORDEN DE ESTOS CUATRO COPY ES LO QUE HACE QUE EL BUILD SEA RÁPIDO ─────
# De menos a más volátil. Cada uno es una capa independiente, así que al cambiar
# tu código solo se invalida (y solo se sube al registro) la última: ~300 KB.
COPY --from=build --chown=10001:10001 /build/extracted/dependencies/ ./
COPY --from=build --chown=10001:10001 /build/extracted/spring-boot-loader/ ./
COPY --from=build --chown=10001:10001 /build/extracted/snapshot-dependencies/ ./
COPY --from=build --chown=10001:10001 /build/extracted/application/ ./

USER 10001:10001

EXPOSE 8080 8081

# Ajustes de la JVM. JAVA_TOOL_OPTIONS (no JAVA_OPTS) porque la JVM lo lee de forma
# nativa: no hace falta una shell que lo expanda, así se mantiene el exec form.
ENV JAVA_TOOL_OPTIONS="\
-XX:MaxRAMPercentage=70 \
-XX:InitialRAMPercentage=70 \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/heapdump.hprof \
-XX:+UseSerialGC \
-XX:MaxMetaspaceSize=192m \
-Djava.security.egd=file:/dev/urandom \
-Duser.timezone=UTC \
-Dfile.encoding=UTF-8" \
    SPRING_MAIN_BANNER_MODE=off \
    TZ=UTC

# HEALTHCHECK: lo usa Docker y Compose. Kubernetes lo IGNORA (usa sus probes),
# pero conviene tenerlo para el desarrollo local y para plataformas tipo ECS.
HEALTHCHECK --interval=15s --timeout=3s --start-period=45s --retries=3 \
  CMD curl -fsS http://localhost:8081/actuator/health/liveness || exit 1

# EXEC FORM (lista JSON), no shell form. Así java es el PID 1 y recibe SIGTERM
# directamente, lo que hace posible el apagado ordenado. Es LA línea que evita
# los errores 502 en cada despliegue.
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Decisión del DockerfileEl «por qué» que debes poder explicar
# syntax=docker/dockerfile:1.10 Activa la sintaxis extendida de BuildKit: --mount=type=cache, --mount=type=secret, COPY --link. Sin esta línea, esas opciones no existen.
Tres etapas en vez de dos Separar «resolver dependencias» de «compilar» permite que un cambio en el código reutilice la resolución. Con dos etapas, cualquier cambio en src reinvalida menos, pero la etapa deps se puede compartir entre varios servicios de un monorrepo.
--mount=type=cache para .m2 La caché vive en BuildKit, no en una capa. Ventaja doble: la imagen no engorda y la caché sobrevive a cambios del pom.xml (descarga solo lo nuevo, no todo).
extract --layers y cuatro COPY Convierte los 61 MB del jar en cuatro capas por volatilidad. Sin esto, cada commit sube 61 MB al registro y los descarga cada nodo.
--chown=10001:10001 en el COPY Establece el dueño al crear la capa. Un RUN chown -R posterior duplicaría el tamaño de esos ficheros (copy-on-write copia todo lo modificado a una capa nueva).
JarLauncher en lugar de -jar app.jar El jar está explotado en directorios, ya no hay un fichero app.jar. Se invoca el launcher directamente, que lee classpath.idx. Arranca un 5–10% más rápido que abriendo el jar anidado.
-XX:+UseSerialGC Con 1–2 CPUs y menos de 1 GB de heap, el recolector serie tiene menos overhead y menos hilos que G1, y su pausa es aceptable en un servicio pequeño. Con más recursos, quítalo (sección 5.4).
-XX:+ExitOnOutOfMemoryError Una JVM que ha sufrido OutOfMemoryError está en un estado inconsistente e impredecible: algunos hilos han muerto, otros no. Es mucho mejor morir y dejar que el orquestador reinicie un proceso limpio.
-Djava.security.egd=file:/dev/urandom Herencia útil: en contenedores sin suficiente entropía, SecureRandom se bloqueaba en /dev/random y el arranque tardaba minutos. En kernels modernos ya no es un problema, pero es inocuo y sigues viéndolo en todas partes.

4.3 Elegir la imagen base: la decisión con más consecuencias

Imagen baseTamaño (JRE 21)libcShellVentajasInconvenientesCuándo
eclipse-temurin:21-jre-noble ~265 MB glibc Sí (bash) La opción por defecto sensata. Builds de Adoptium certificados TCK, parches al día, glibc (cero sorpresas de rendimiento), herramientas para depurar dentro. Ni la más pequeña ni la más segura. Empieza aquí. El 80% de los casos.
eclipse-temurin:21-jre-alpine ~175 MB musl Sí (ash) 90 MB menos. Muy poca superficie de ataque en el sistema base. Ver el aviso de abajo: musl no es glibc. Cuando el tamaño importa de verdad y has probado el rendimiento.
gcr.io/distroless/java21-debian12 ~230 MB glibc No Sin shell, sin gestor de paquetes, sin curl, sin utilidades: casi nada que explotar y muchísimos menos CVE en los informes. Usuario no root por defecto (65532). No puedes hacer exec -it sh para depurar (usa ephemeral containers). El HEALTHCHECK con curl no funciona. Producción con requisitos de seguridad, y un equipo que sabe depurar sin shell.
cgr.dev/chainguard/jre ~150 MB glibc No Reconstruida a diario, objetivo de cero CVE conocidos, con SBOM y firma de origen incluidos. Solo la etiqueta :latest es gratuita; las versiones fijadas son de pago. Cuando el informe de vulnerabilidades es un requisito contractual.
ibm-semeru-runtimes:open-21-jre ~230 MB glibc JVM OpenJ9: consume bastante menos memoria en reposo y arranca más rápido con shared classes cache. Otro JIT y otro GC: rendimiento distinto en picos, menos documentación y menos gente que sepa depurarlo. Muchas réplicas pequeñas donde la memoria es el coste dominante.
registry.access.redhat.com/ubi9/openjdk-21-runtime ~400 MB glibc Soporte de Red Hat, certificación FIPS, requisito habitual en banca y administración pública. La más grande con diferencia. Cuando lo exige el contrato o la política corporativa.
scratch + jlink ~80–110 MB estática No Lo más pequeño posible con JVM. Hay que construir el runtime a mano, y falta /etc/passwd, certificados CA y zonas horarias: los tienes que añadir tú. Casos extremos: miles de réplicas, edge, ancho de banda caro.
GraalVM native image ~90 MB (binario ~80 MB) glibc o estática No Arranca en 50 ms y consume ~60 MB de RSS. Cambia las reglas del juego en serverless. Build de 5–15 minutos, reflexión necesita configuración, sin JIT de perfil (menor rendimiento máximo), sin las herramientas de la JVM. Funciones, CLI, escalado a cero. Ver sección 13.
El asunto de Alpine y musl, explicado bien. Alpine usa musl libc en lugar de glibc. Durante años esto significaba que la JVM directamente no funcionaba (hacía falta el port Portola); hoy Temurin publica imágenes Alpine oficiales y funcionan. Pero quedan diferencias reales que debes conocer antes de decidir:
  • El asignador de memoria de musl es más lento y fragmenta más en cargas con muchos hilos. Se han medido degradaciones del 10–30% en aplicaciones intensivas en asignación. La solución habitual es instalar jemalloc, lo que anula parte del ahorro de tamaño.
  • La resolución DNS es distinta. musl no soporta algunas opciones de /etc/resolv.conf (como ndots tal y como lo usa Kubernetes) y consulta los servidores en paralelo. Aparecen fallos intermitentes de resolución que son muy difíciles de diagnosticar.
  • Menos herramientas de diagnóstico y menos gente que las conozca. Cuando necesitas perf o un async-profiler a las tres de la mañana, echarás de menos Debian.
  • El ahorro es menor de lo que parece: 90 MB sobre 265 suena a mucho, pero las capas del sistema base se comparten entre todas tus imágenes en el nodo, así que el ahorro marginal por servicio es prácticamente cero.
Recomendación: si quieres una imagen pequeña, ve a distroless antes que a Alpine. Tienes glibc, menos superficie de ataque y un tamaño parecido.
# Distroless: la etapa final cambia poco, pero hay tres detalles importantes
FROM gcr.io/distroless/java21-debian12:nonroot AS runtime
WORKDIR /app

# 1) El usuario ya es 65532:65532 (nonroot). No hay useradd, así que se usa el que hay.
COPY --from=build --chown=65532:65532 /build/extracted/dependencies/ ./
COPY --from=build --chown=65532:65532 /build/extracted/spring-boot-loader/ ./
COPY --from=build --chown=65532:65532 /build/extracted/snapshot-dependencies/ ./
COPY --from=build --chown=65532:65532 /build/extracted/application/ ./
USER 65532:65532

# 2) El ENTRYPOINT de la imagen distroless-java ya es ["java"]: se pasan solo argumentos.
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

# 3) NO hay HEALTHCHECK posible con curl (no existe). Opciones:
#    · en Kubernetes: usa httpGet en las probes (no necesita nada dentro del contenedor)
#    · en Compose/ECS: la variante :debug de distroless SÍ trae busybox
#    · o compila un binario de health check estático y cópialo

# Depurar un distroless en Kubernetes: contenedor efímero con las herramientas
#   kubectl debug -it pedidos-7c9f-xyz --image=busybox:1.36 --target=app
# Comparte los namespaces de red y PID con tu contenedor, así que ves sus
# procesos y su red, pero con un sistema de ficheros que sí tiene shell.

Desde Java 9, la JVM está modularizada, así que puedes construir un runtime que contenga solo los módulos que tu aplicación usa. Un JRE completo son unos 180 MB; un runtime hecho a medida para una aplicación Spring Boot típica ronda los 60–70 MB.

# PASO 1 · Averiguar qué módulos necesitas de verdad, con jdeps.
# Ojo: jdeps hace análisis ESTÁTICO. Spring usa reflexión a mansalva, así que la
# lista que saca es INCOMPLETA. Sirve como punto de partida, no como verdad.
jdeps \
  --print-module-deps \
  --ignore-missing-deps \
  --multi-release 21 \
  --recursive \
  --class-path 'extracted/dependencies/BOOT-INF/lib/*' \
  extracted/application/BOOT-INF/classes

# Salida típica:
#   java.base,java.desktop,java.instrument,java.management,java.naming,
#   java.net.http,java.security.jgss,java.sql,java.transaction.xa,jdk.unsupported

# PASO 2 · En la práctica, para Spring Boot se usa esta lista, que añade los
# módulos que la reflexión necesita y que jdeps no puede detectar:
JAVA_MODULES="java.base,java.compiler,java.desktop,java.instrument,\
java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,\
java.security.jgss,java.security.sasl,java.sql,java.sql.rowset,\
java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.jdwp.agent,\
jdk.jfr,jdk.management,jdk.management.agent,jdk.unsupported,jdk.httpserver"
#   jdk.crypto.ec        → sin esto, TLS falla con curvas elípticas (¡silenciosamente!)
#   jdk.jfr + management → sin esto, no puedes diagnosticar nada en producción
#   jdk.jdwp.agent       → depuración remota; quítalo si no la quieres ni poder activar

# PASO 3 · Construir el runtime
jlink \
  --add-modules "$JAVA_MODULES" \
  --strip-debug \
  --no-man-pages \
  --no-header-files \
  --compress=zip-6 \
  --output /javaruntime
# Dockerfile con jlink: la imagen final no lleva JRE, lleva TU runtime
# syntax=docker/dockerfile:1.10
FROM eclipse-temurin:21.0.5_11-jdk-noble AS jre-build
ENV JAVA_MODULES="java.base,java.compiler,java.desktop,java.instrument,\
java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,\
java.security.jgss,java.security.sasl,java.sql,java.sql.rowset,\
java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.jfr,\
jdk.management,jdk.management.agent,jdk.unsupported,jdk.httpserver"
RUN "$JAVA_HOME/bin/jlink" \
      --add-modules "$JAVA_MODULES" \
      --strip-debug --no-man-pages --no-header-files --compress=zip-6 \
      --output /javaruntime

FROM debian:12-slim AS runtime
# ca-certificates es OBLIGATORIO: sin él, toda llamada HTTPS falla con
# «unable to find valid certification path». Es el error nº 1 de las bases mínimas.
RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates tzdata \
 && rm -rf /var/lib/apt/lists/* \
 && groupadd --system --gid 10001 app \
 && useradd --system --uid 10001 --gid app --home /app --shell /sbin/nologin app

ENV JAVA_HOME=/opt/java
ENV PATH="${JAVA_HOME}/bin:${PATH}"
COPY --from=jre-build /javaruntime $JAVA_HOME

WORKDIR /app
COPY --from=build --chown=10001:10001 /build/extracted/dependencies/ ./
COPY --from=build --chown=10001:10001 /build/extracted/spring-boot-loader/ ./
COPY --from=build --chown=10001:10001 /build/extracted/snapshot-dependencies/ ./
COPY --from=build --chown=10001:10001 /build/extracted/application/ ./
USER 10001:10001
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
# Resultado: ~150 MB en lugar de ~265 MB
Antes de meter jlink en producción, lee esto. Un módulo que falta no da un error de compilación: da un fallo en tiempo de ejecución y a veces silencioso. Los tres casos que muerden en la vida real: sin jdk.crypto.ec las conexiones TLS con curvas elípticas fallan (y la mayoría lo son); sin java.desktop revienta cualquier librería que toque java.awt (generación de PDFs, escalado de imágenes, algunos codificadores de códigos de barras); y sin jdk.management.agent no puedes conectar JMX ni sacar un volcado en un incidente. Si vas a usar jlink, tu suite de tests de integración tiene que ejecutarse contra la imagen final, no contra el JRE completo. Y sinceramente: para ahorrar 100 MB, en la mayoría de los proyectos no vale la pena el riesgo. Usa distroless.

4.5 El orden de las instrucciones y la caché de capas

La regla de la caché de Docker es simple y absoluta: una instrucción se saca de caché si y solo si la instrucción y todas las anteriores no han cambiado. Para COPY y ADD «no ha cambiado» significa que el checksum de los ficheros copiados es idéntico; para las demás, que el texto de la instrucción es idéntico. Una vez que una capa se invalida, todas las siguientes se reconstruyen, aunque no hayan cambiado.

De ahí sale la única heurística que necesitas: ordena de menos volátil a más volátil.

OrdenInstrucciónCambia…
1FROMcada varios meses
2RUN apt-get install …cada varios meses
3RUN useradd …nunca
4COPY pom.xml + resolución de dependenciascuando tocas dependencias: semanas
5COPY capa dependenciessemanas
6COPY capa application (tu código)cada commit
7ENV, USER, ENTRYPOINT, LABEL con la versióncasi nunca (y son capas de 0 bytes)
# ❌ Cada RUN es una capa. Y borrar en un RUN posterior NO libera espacio:
#    la capa anterior sigue en la imagen con los 300 MB dentro.
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*        # ← inútil: la capa 1 ya tiene la lista

# ✅ Una sola capa, con la limpieza DENTRO de la misma instrucción
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

# ❌ Un LABEL con la versión colocado ARRIBA invalida TODO lo que viene después
FROM eclipse-temurin:21-jre
LABEL version="${VERSION}"     # cambia en cada release → se reconstruye la imagen entera
RUN apt-get update && ...

# ✅ Los LABEL volátiles, al final. Son capas de metadatos: 0 bytes.

# ❌ COPY . . copia también el .git, el target/ y el .idea. Y cualquier cambio
#    en cualquier fichero del proyecto invalida la capa.
COPY . /build

# ✅ Copia explícita de lo que necesitas
COPY pom.xml mvnw ./
COPY .mvn/ .mvn/
COPY src/ src/
# Reutilizar la caché ENTRE builds de máquinas distintas (imprescindible en CI,
# donde cada ejecución empieza en un runner limpio y sin caché local).
docker buildx build \
  --cache-from type=registry,ref=ghcr.io/ejemplo/pedidos:buildcache \
  --cache-to   type=registry,ref=ghcr.io/ejemplo/pedidos:buildcache,mode=max \
  -t ghcr.io/ejemplo/pedidos:1.4.2 --push .

# mode=max exporta también las capas intermedias de las etapas de build (no solo
# las de la imagen final): es la diferencia entre reutilizar la descarga de Maven
# y volver a descargarla.

# En GitHub Actions, la alternativa integrada:
#   cache-from: type=gha
#   cache-to:   type=gha,mode=max
# Límite: 10 GB por repositorio, con expulsión LRU. Suficiente para un servicio.

# Diagnosticar la caché: --progress=plain muestra CACHED en cada paso
docker build --progress=plain -t pedidos:test . 2>&1 | grep -E '^#[0-9]+ (CACHED|\[)'

4.6 .dockerignore: el fichero que todo el mundo olvida

Antes de ejecutar la primera instrucción, el cliente de Docker empaqueta y envía todo el directorio al demonio (el «contexto de build»). Sin .dockerignore, eso incluye el .git (que en un repositorio con historia puede ser de cientos de MB), el target/ con los jars del build anterior, y —lo grave— tu .env con credenciales.

# .dockerignore — pégalo tal cual en cualquier proyecto Java
# ─── Regla de oro: prohibir todo y permitir lo necesario ──────────────────────
*
!pom.xml
!mvnw
!.mvn/
!src/

# Si prefieres el enfoque de lista negra (más frágil pero más legible):
# .git
# .gitignore
# .github/
# target/
# build/
# .gradle/
# *.iml
# .idea/
# .vscode/
# .env
# .env.*
# *.log
# **/node_modules/
# Dockerfile*
# compose*.yaml
# k8s/
# helm/
# docs/
# README*
# *.md

# ¿Por qué excluir el .git?
#   1. Peso: 50-500 MB que se transfieren en cada build.
#   2. SEGURIDAD: contiene todo el historial. Si alguien commiteó una credencial
#      hace dos años y luego la borró, sigue estando en el .git. Si el .git entra
#      en la imagen, la credencial viaja a producción y al registro.

# Comprobar el tamaño real del contexto que estás enviando:
#   docker build . 2>&1 | head -2
#   => "Sending build context to Docker daemon  1.2MB"    ← esto quieres ver
#   => "Sending build context to Docker daemon  847MB"    ← te falta .dockerignore

4.7 Usuario no root y sistema de ficheros de solo lectura

Un contenedor no es un límite de seguridad fuerte: comparte kernel con el host. Correr como root dentro del contenedor significa que, si un atacante consigue ejecución de código, tiene UID 0 y muchas más posibilidades de aprovechar una vulnerabilidad del kernel o una mala configuración (socket de Docker montado, capacidades excesivas) para escapar. Es defensa en profundidad barata.

# En el Dockerfile
RUN groupadd --system --gid 10001 app \
 && useradd --system --uid 10001 --gid app --home /app --shell /sbin/nologin app
USER 10001:10001
#    ↑ NUMÉRICO. Con USER app, Kubernetes no puede verificar runAsNonRoot y falla:
#      "container has runAsNonRoot and image has non-numeric user (app)"

# Comprobarlo desde fuera antes de desplegar
docker image inspect pedidos:1.4.2 -f 'usuario={{.Config.User}}'
docker run --rm pedidos:1.4.2 id
# uid=10001(app) gid=10001(app)     ← correcto
# uid=0(root) gid=0(root)           ← corrígelo

# Sistema de ficheros raíz de solo lectura: impide que un atacante escriba un
# binario, modifique un jar o deje una puerta trasera persistente.
docker run --rm \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=128m \
  --cap-drop=ALL \
  --security-opt no-new-privileges \
  pedidos:1.4.2

# ¿Qué necesita escribir una aplicación Spring Boot? Casi siempre solo /tmp:
#   · volcados de heap (-XX:HeapDumpPath=/tmp)
#   · el directorio de trabajo de Tomcat para subidas multipart
#   · ficheros temporales de Testcontainers, de PDFBox, de fuentes…
#   Configúralo explícitamente:
#     server.tomcat.basedir=/tmp/tomcat
#     spring.servlet.multipart.location=/tmp

# Descubrir qué escribe de verdad tu aplicación: ejecútala sin --read-only,
# hazle pasar por sus casos de uso y mira las diferencias.
docker diff pedidos
#   C /tmp
#   A /tmp/tomcat.8080.123456
#   A /app/logs/app.log      ← ¡AQUÍ! Tienes un FileAppender que no sabías.

4.8 ENTRYPOINT, CMD, señales y el problema del PID 1

FormaSintaxisCómo se ejecuta¿Recibe SIGTERM tu proceso?
exec form ENTRYPOINT ["java", "-jar", "app.jar"] execve("java", …) directamente. java es el PID 1. Sí. Es lo que quieres.
shell form ENTRYPOINT java -jar app.jar /bin/sh -c "java -jar app.jar". sh es el PID 1 y java su hijo. No. sh lo recibe y no lo reenvía. Tu apagado ordenado no se ejecuta.
ENTRYPOINT + CMD ENTRYPOINT ["java","-jar","app.jar"]
CMD ["--server.port=8080"]
CMD son los argumentos por defecto, sustituibles en docker run o con args en Kubernetes.

El PID 1 tiene dos peculiaridades del kernel de Linux que hay que conocer:

  1. Ignora las señales que no maneja explícitamente. Un proceso normal muere por defecto con SIGTERM; el PID 1, no. La JVM sí instala un shutdown hook para SIGTERM, así que como PID 1 se comporta bien. Pero sh no reenvía la señal a sus hijos, y ahí está el problema.
  2. Es responsable de adoptar y recolectar procesos huérfanos (zombies). La JVM no lo hace. Si tu aplicación lanza subprocesos con ProcessBuilder y no espera su salida, acumularás zombies hasta agotar la tabla de procesos.
# Si NECESITAS una shell (por ejemplo, para expandir una variable en los argumentos),
# usa exec para REEMPLAZAR la shell por el proceso: así java pasa a ser el PID 1.
ENTRYPOINT ["/bin/sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
#                             ^^^^ ESTA palabra es toda la diferencia

# Mejor aún: no necesites la shell. JAVA_TOOL_OPTIONS lo lee la JVM directamente.
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=70"
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

# Si lanzas subprocesos, añade un init que recolecte zombies
docker run --init pedidos:1.4.2      # inyecta tini como PID 1
# En Kubernetes: shareProcessNamespace o un initContainer no valen para esto;
# la opción es incluir tini en la imagen:
#   ENTRYPOINT ["/usr/bin/tini", "--", "java", "-jar", "/app/app.jar"]

# ─── COMPROBAR QUE EL APAGADO ORDENADO FUNCIONA (hazlo, de verdad) ────────────
docker run -d --name t pedidos:1.4.2
docker exec t ps -ef            # comprueba que el PID 1 es java, no sh
docker stop -t 30 t             # envía SIGTERM y espera hasta 30s antes de SIGKILL
docker logs t | tail -20
# DEBES ver:
#   Commencing graceful shutdown. Waiting for active requests to complete
#   Graceful shutdown complete
#   HikariPool-1 - Shutdown initiated... / completed.
# Si en lugar de eso el contenedor tarda exactamente 30 s y muere sin decir nada,
# la señal no está llegando a la JVM: revisa el ENTRYPOINT.

4.9 HEALTHCHECK: útil en Docker, ignorado por Kubernetes

# Con curl (necesita que curl esté en la imagen)
HEALTHCHECK --interval=15s --timeout=3s --start-period=45s --retries=3 \
  CMD curl -fsS http://localhost:8081/actuator/health/liveness || exit 1

# Sin curl ni wget: la propia JVM como cliente HTTP (Java 11+). Cuesta ~200 ms
# de arranque de JVM por comprobación, pero funciona en cualquier imagen con Java.
HEALTHCHECK --interval=20s --timeout=5s --start-period=45s --retries=3 \
  CMD ["java", "-e", "java.net.http.HttpClient.newHttpClient().send(java.net.http.HttpRequest.newBuilder(java.net.URI.create(\"http://localhost:8081/actuator/health/liveness\")).build(), java.net.http.HttpResponse.BodyHandlers.discarding()).statusCode() == 200 ? 0 : 1"]

# Parámetros y su significado real
#   --interval        cada cuánto se comprueba
#   --timeout         cuánto se espera la respuesta
#   --start-period    ★ EL IMPORTANTE EN JAVA: durante este tiempo los fallos NO
#                       cuentan. Una JVM tarda 3-10 s en arrancar; sin start-period
#                       el contenedor se marca unhealthy antes de estar listo.
#   --retries         fallos consecutivos para marcar unhealthy

# Ver el estado y el historial de comprobaciones
docker inspect -f '{{.State.Health.Status}}' pedidos
docker inspect -f '{{json .State.Health.Log}}' pedidos | jq '.[-1]'
Kubernetes ignora HEALTHCHECK por completo. Usa sus propias livenessProbe, readinessProbe y startupProbe (sección 9), que son mejores por tres razones: se configuran en el manifiesto y no en la imagen (puedes ajustarlas sin reconstruir), distinguen «está vivo» de «puede recibir tráfico», y las ejecuta el kubelet desde fuera, así que no necesitas curl dentro del contenedor. Aun así, pon el HEALTHCHECK: lo usan Docker Compose (para depends_on: service_healthy), ECS y Docker Swarm, y te sirve en el desarrollo local.

4.10 Etiquetas OCI: la trazabilidad de la imagen

Cuando estás en un incidente y ves un pod ejecutando pedidos@sha256:9f8e…, necesitas saber de qué commit salió. Las etiquetas estándar OCI son la respuesta y cuestan cero bytes.

# En el Dockerfile (al final, para no invalidar la caché)
ARG VERSION REVISION CREATED
LABEL org.opencontainers.image.title="pedidos" \
      org.opencontainers.image.description="Servicio de gestión de pedidos" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.revision="${REVISION}" \
      org.opencontainers.image.created="${CREATED}" \
      org.opencontainers.image.source="https://github.com/ejemplo/pedidos" \
      org.opencontainers.image.url="https://github.com/ejemplo/pedidos" \
      org.opencontainers.image.documentation="https://github.com/ejemplo/pedidos#readme" \
      org.opencontainers.image.vendor="Ejemplo S.L." \
      org.opencontainers.image.licenses="Apache-2.0" \
      org.opencontainers.image.base.name="eclipse-temurin:21.0.5_11-jre-noble"

# Al construir
docker build \
  --build-arg VERSION="$(./mvnw -q help:evaluate -Dexpression=project.version -DforceStdout)" \
  --build-arg REVISION="$(git rev-parse HEAD)" \
  --build-arg CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -t ghcr.io/ejemplo/pedidos:1.4.2 .

# EN EL INCIDENTE: de un digest a un commit en 5 segundos
docker inspect ghcr.io/ejemplo/pedidos@sha256:9f8e7d… \
  -f '{{index .Config.Labels "org.opencontainers.image.revision"}}'
# a1b2c3d4e5f6…   → git show a1b2c3d

# También conviene exponerlo por Actuator, para poder preguntárselo a la aplicación
# viva sin acceso al registro. Con el plugin build-info de Spring Boot:
#   ./mvnw spring-boot:build-info  →  /actuator/info devuelve versión y commit
curl -s localhost:8081/actuator/info | jq

4.11 Tamaño final: la tabla comparada

Medido sobre la misma aplicación Spring Boot 3.5 con web, JPA, Actuator y el driver de PostgreSQL, docker images (tamaño descomprimido) y el tamaño comprimido que se transfiere:

EstrategiaTamañoTransferidoCapa por commitCVE HIGH/CRIT típicosArranqueRSS en reposo
openjdk:latest + jar, una capa~830 MB~340 MB61 MB30–604,5 s420 MB
temurin:21-jdk + jar, una capa~510 MB~210 MB61 MB8–204,2 s410 MB
temurin:21-jre + jar, una capa~330 MB~135 MB61 MB6–154,0 s400 MB
temurin:21-jre + capas del jar~265 MB~110 MB0,3 MB6–153,8 s400 MB
Buildpacks (Paketo)~340 MB~140 MB0,3 MB4–103,6 s390 MB
Jib + Temurin JRE~250 MB~105 MB0,3 MB6–153,8 s400 MB
temurin:21-jre-alpine + capas~175 MB~72 MB0,3 MB1–44,1 s410 MB
distroless java21 + capas~230 MB~95 MB0,3 MB0–33,8 s400 MB
Chainguard JRE + capas~150 MB~62 MB0,3 MB03,8 s400 MB
debian:12-slim + jlink + capas~150 MB~60 MB0,3 MB1–53,5 s380 MB
GraalVM native image~95 MB~40 MB80 MB (¡todo!)0–20,06 s75 MB
Cómo leer esta tabla. La columna que más importa no es el tamaño total, es la «capa por commit»: pasar de 61 MB a 0,3 MB es el 95% del beneficio práctico y es gratis (una línea de configuración y cuatro COPY). La segunda columna que importa es la de CVE, porque es la que genera trabajo recurrente para el equipo. El tamaño total absoluto solo importa cuando escalas muy rápido o pagas el ancho de banda. Y fíjate en la última fila: la imagen nativa gana en todo excepto en la capa por commit, porque el binario es monolítico y se reconstruye entero en cada cambio.

4.12 Analizar la imagen: docker history y dive

# Primer diagnóstico, siempre: qué instrucción creó cada capa y cuánto pesa
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' pedidos:1.4.2

# 265MB   /bin/sh -c #(nop) ADD file:… in /            ← la base
# 0B      /bin/sh -c #(nop) ENV JAVA_HOME=…
# 12.3MB  RUN apt-get update && apt-get install -y curl tzdata …
# 2.8kB   RUN groupadd --system --gid 10001 app …
# 55.1MB  COPY /build/extracted/dependencies/ ./       ← estable
# 0.4MB   COPY /build/extracted/spring-boot-loader/ ./
# 0B      COPY /build/extracted/snapshot-dependencies/ ./
# 312kB   COPY /build/extracted/application/ ./        ← lo único que cambia
# 0B      ENV JAVA_TOOL_OPTIONS=…
# 0B      ENTRYPOINT ["java" …]

# dive: explorador interactivo de capas. Te dice el «wasted space»: ficheros
# escritos en una capa y borrados o sobrescritos en otra.
dive pedidos:1.4.2

# Y en CI, como puerta de calidad automática:
CI=true dive pedidos:1.4.2 --highestUserWastedPercent 0.10 --lowestEfficiency 0.95
# Falla el build si más del 10% de la imagen es espacio desperdiciado.

# Alternativas rápidas
docker image inspect pedidos:1.4.2 -f '{{len .RootFS.Layers}} capas'
crane config ghcr.io/ejemplo/pedidos:1.4.2 | jq '.history'
crane manifest ghcr.io/ejemplo/pedidos:1.4.2 | jq '.layers[].size'  # tamaño comprimido real

# ¿Qué ocupa dentro? Exportar el sistema de ficheros y medirlo
docker create --name tmp pedidos:1.4.2
docker export tmp | tar -tv | sort -rn -k3 | head -30
docker rm tmp

4.13 Escaneo de vulnerabilidades: Trivy y Grype

Un escáner compara el inventario de paquetes de la imagen (sistema operativo + jars de Java) con bases de datos de vulnerabilidades (NVD, avisos de las distribuciones, GitHub Advisory Database). Es la comprobación con mejor relación entre esfuerzo y riesgo evitado que existe: dos líneas en el pipeline.

# ─── TRIVY: el más completo y el estándar de facto ───────────────────────────
trivy image ghcr.io/ejemplo/pedidos:1.4.2

# En CI: falla solo con lo que se puede arreglar y es grave
trivy image \
  --severity HIGH,CRITICAL \
  --ignore-unfixed \
  --exit-code 1 \
  --scanners vuln,secret,misconfig \
  ghcr.io/ejemplo/pedidos:1.4.2

#   --ignore-unfixed  ← IMPRESCINDIBLE para no bloquear el pipeline con CVEs que
#                       no tienen parche disponible. Si no lo pones, tu equipo
#                       aprenderá a ignorar el escáner, que es mucho peor.

# Escanear también el Dockerfile y los manifiestos de Kubernetes
trivy config ./Dockerfile
trivy config ./k8s/
trivy fs --scanners vuln,secret .

# Aceptar de forma explícita, documentada y con fecha de caducidad
cat > .trivyignore <<'EOF'
# CVE-2024-XXXXX: en spring-web, solo explotable si usas MultipartResolver con
# ficheros de usuario, que no hacemos. Revisar el 2026-09-30.
CVE-2024-XXXXX exp:2026-09-30
EOF

# ─── GRYPE: más rápido, y funciona sobre un SBOM ya generado ──────────────────
grype ghcr.io/ejemplo/pedidos:1.4.2 --fail-on high
grype sbom:./target/bom.json --fail-on high
syft ghcr.io/ejemplo/pedidos:1.4.2 -o cyclonedx-json | grype --fail-on critical

# ─── Escaneo continuo: lo que casi nadie hace y es lo más importante ─────────
# Una imagen que hoy tiene 0 CVE tendrá 5 en tres meses SIN QUE NADIE LA TOQUE,
# porque las vulnerabilidades se descubren después. El escaneo en el pipeline es
# una foto; hace falta reescanear lo que está DESPLEGADO, a diario.
for img in $(kubectl get pods -A -o jsonpath='{..image}' | tr ' ' '\n' | sort -u); do
  echo "── $img"
  trivy image --severity CRITICAL --quiet "$img"
done
SituaciónQué hacer
CVE en un paquete del sistema base con parche disponibleReconstruir la imagen (basta con --pull para traer la base actualizada). Ten un pipeline nocturno que reconstruya y publique: la mayoría de los CVE del sistema se arreglan solos así.
CVE en una dependencia Java gestionada por el BOMSubir la versión de Spring Boot. Casi siempre está ya arreglado en el siguiente parche del BOM.
CVE sin parche (unfixed)Documentar el análisis de explotabilidad y aceptarlo con fecha de revisión. No bloquees el pipeline con esto.
CVE en una librería que ya no mantiene nadieSustituirla. Es un problema de deuda técnica, no de seguridad, y el escáner solo es el mensajero.
200 CVE en la imagen baseCambiar de base. Pasar de ubuntu a distroless elimina el 95% de los hallazgos de golpe porque elimina el 95% de los paquetes.

4.14 Imágenes reproducibles bit a bit

Una imagen es reproducible si construir el mismo commit dos veces produce el mismo digest. Suena a purismo, pero tiene un valor concreto: permite verificar que la imagen del registro se corresponde con el código fuente que dice, lo que es la única defensa real contra un ataque a la cadena de construcción. Hay cuatro fuentes de no determinismo:

# 1) MARCAS DE TIEMPO en los ficheros del jar y de la imagen
#    Maven: propiedad estándar (Reproducible Builds)
#      <project.build.outputTimestamp>2026-01-15T00:00:00Z</project.build.outputTimestamp>
#    Docker/BuildKit: variable estándar de la especificación
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
docker buildx build --build-arg SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \
  --output type=image,name=ghcr.io/ejemplo/pedidos:1.4.2,rewrite-timestamp=true .

# 2) IMAGEN BASE MUTABLE: fija por digest, no por etiqueta
FROM eclipse-temurin:21.0.5_11-jre-noble@sha256:8e2f4a6b1c9d3e5f7a8b0c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f

# 3) PAQUETES DEL SISTEMA con versión flotante: fija la versión exacta
RUN apt-get update \
 && apt-get install -y --no-install-recommends \
      curl=8.5.0-2ubuntu10.6 \
      tzdata=2024a-3ubuntu1.1 \
 && rm -rf /var/lib/apt/lists/*

# 4) ORDEN DE FICHEROS no determinista en los archivos: ya resuelto por
#    classpath.idx en Spring Boot y por isReproducibleFileOrder en Gradle.

# ─── VERIFICAR ────────────────────────────────────────────────────────────────
docker buildx build -t prueba1 . && docker buildx build --no-cache -t prueba2 .
docker inspect prueba1 -f '{{.Id}}'
docker inspect prueba2 -f '{{.Id}}'    # deben coincidir

# El objetivo final: que cualquiera pueda reconstruir tu imagen desde el código
# fuente y comprobar que sale el mismo digest que hay firmado en el registro.
# Eso es lo que exige el nivel 3 de SLSA y es la mejor defensa que existe frente
# a un pipeline comprometido.

5 · La JVM dentro de un contenedor

Si solo puedes estudiar una sección de este módulo, que sea esta. Es la que más se pregunta en entrevistas para puestos senior de Java, la que más incidentes de producción explica y la que casi nadie sabe explicar bien. El resumen es que la JVM se dimensiona sola en el arranque leyendo el entorno, y en un contenedor ese entorno miente si no se lo cuentas bien.

5.1 Cómo la JVM detecta CPU y memoria

Al arrancar sin parámetros, la JVM decide su heap máximo, su recolector de basura y el tamaño de varios pools internos en función de la máquina que cree tener. Históricamente miraba /proc/meminfo y /proc/cpuinfo, que en un contenedor muestran los datos del host. El resultado era un desastre: un contenedor con límite de 512 MB en un nodo de 64 GB pedía un heap de 16 GB (¼ de la RAM del host) y el kernel lo mataba en cuanto crecía.

Desde Java 10 (y retroportado a 8u191) existe -XX:+UseContainerSupport, activo por defecto, que lee los ficheros de cgroup en lugar de /proc. Desde Java 15 se soporta cgroups v2, que es lo que usan todas las distribuciones y todos los Kubernetes modernos.

Qué decide la JVMFichero de cgroup v2 que leeValor por defecto
MaxHeapSize (-Xmx implícito) /sys/fs/cgroup/memory.max 25% del límite si hay más de 256 MB; 50% entre 96 y 256 MB. Casi siempre demasiado poco.
InitialHeapSize (-Xms) Idem 1/64 del límite. Provoca que el heap crezca a saltos durante el arranque.
ActiveProcessorCount /sys/fs/cgroup/cpu.max (cuota/periodo) y cpu.weight ceil(cuota / periodo). Con cpu.max = 500000 100000 → 5. Con cpu.max = max 100000 (sin límite) → todas las CPU del nodo.
Recolector elegido Derivado de los dos anteriores G1 si hay ≥ 2 CPU y ≥ 1792 MB de memoria; SerialGC en caso contrario.
Hilos de GC, de JIT y del common pool de ForkJoin ActiveProcessorCount Proporcional al número de CPU detectado. Aquí está la trampa de la sección 5.6.
# ── COMPROBAR QUÉ VE LA JVM: el comando que deberías ejecutar siempre ────────
docker run --rm --memory=512m --cpus=1 eclipse-temurin:21-jre \
  java -XX:+PrintFlagsFinal -version | grep -E \
  'MaxHeapSize|InitialHeapSize|ActiveProcessorCount|UseContainerSupport|UseG1GC|UseSerialGC|MaxRAMPercentage'

#   bool   UseContainerSupport            = true
#   uintx  MaxHeapSize                    = 134217728    ← 128 MB = 25% de 512 MB
#   uintx  InitialHeapSize                = 8388608      ← 8 MB
#   intx   ActiveProcessorCount           = -1           ← -1 significa «detectar»
#   bool   UseSerialGC                    = true         ← 512 MB < 1792 MB
#   double MaxRAMPercentage               = 25.0

# La forma corta y legible
docker run --rm --memory=512m eclipse-temurin:21-jre \
  java -XshowSettings:system -version
# Operating System Metrics:
#    Provider: cgroupv2
#    Memory Limit: 512.00M
#    CPU Quota: -1
#    CPU Period: 100000us
#    Active Processors: 8            ← ¡OJO! Sin --cpus ve las 8 del host

# Y desde dentro de un pod que ya está corriendo
kubectl exec -it pedidos-7c9f-xyz -- java -XshowSettings:system -version
kubectl exec -it pedidos-7c9f-xyz -- cat /sys/fs/cgroup/memory.max
kubectl exec -it pedidos-7c9f-xyz -- jcmd 1 VM.flags | tr ' ' '\n' | grep -i heap
El 25% por defecto es un desastre en un contenedor y hay que cambiarlo siempre. Ese valor tiene sentido en una máquina compartida con otros procesos, bases de datos y el sistema operativo. En un contenedor tu JVM es el único proceso relevante: dejar el 75% de la memoria sin usar significa que pagas por 512 MB y usas 128. La consecuencia visible es peor: con un heap tan pequeño, el GC se ejecuta constantemente, la latencia p99 se dispara y acabas concluyendo que «Java va lento en contenedores».

5.2 MaxRAMPercentage frente a -Xmx: qué poner y por qué

OpciónVentajaInconvenienteVeredicto
-Xmx512m (absoluto) Explícito, predecible, se ve en el manifiesto. Hay que cambiarlo en dos sitios cuando cambias el límite del pod, y siempre se olvida uno. Si subes el límite a 2 Gi y no tocas -Xmx, no ganas nada. Aceptable si lo generas desde el mismo sitio que el límite (Helm, Kustomize).
-XX:MaxRAMPercentage=70 Se adapta automáticamente al límite del contenedor. Un solo valor a mantener. El porcentaje correcto depende del tamaño: el 70% de 4 GB deja 1,2 GB para lo demás (de sobra); el 70% de 256 MB deja 77 MB (insuficiente). La opción recomendada para contenedores de 512 MB en adelante.
Calculador de memoria de Paketo Calcula el heap restando todos los consumos estimados (metaspace por clases, pilas por hilos, code cache, direct). Solo con buildpacks; hay que decirle el número de hilos y de clases. Lo más preciso si ya usas buildpacks.

Los porcentajes que funcionan en la práctica, según el límite de memoria del contenedor:

Límite del contenedorMaxRAMPercentageHeap resultanteFuera del heapComentario
256 MiNo lo intentes con Spring Boot y JVM. Cabe con imagen nativa.
512 Mi50–55~270 Mi~240 MiMínimo viable para un servicio pequeño. Ajustado; con SerialGC.
768 Mi60~460 Mi~300 MiCómodo para un CRUD con JPA.
1 Gi65~665 Mi~360 MiEl punto dulce para la mayoría de los servicios Spring Boot.
2 Gi70–75~1,4–1,5 Gi~500–600 MiServicios con caché en memoria o procesamiento de lotes.
4 Gi o más75–803–3,2 Gi~800 Mi–1 GiA partir de aquí lo que no es heap crece poco, así que el porcentaje puede subir.
# El patrón completo en Kubernetes: un solo sitio donde cambiar la memoria
containers:
  - name: app
    env:
      - name: JAVA_TOOL_OPTIONS
        value: >-
          -XX:MaxRAMPercentage=65
          -XX:InitialRAMPercentage=65
          -XX:MaxMetaspaceSize=192m
          -XX:MaxDirectMemorySize=128m
          -XX:+ExitOnOutOfMemoryError
          -XX:+HeapDumpOnOutOfMemoryError
          -XX:HeapDumpPath=/tmp/heapdump.hprof
          -XX:NativeMemoryTracking=summary
    resources:
      requests:
        memory: "1Gi"      # requests == limits en memoria: QoS Guaranteed
        cpu: "500m"
      limits:
        memory: "1Gi"      # ← el único número que hay que tocar
        # sin límite de CPU (ver 5.5)
Por qué InitialRAMPercentage igual a MaxRAMPercentage. Si el heap empieza pequeño y crece, durante el arranque hay varias expansiones, cada una con su pausa de GC y su llamada al kernel para reservar memoria. Fijando ambos al mismo valor, el heap se reserva de una vez: el arranque es más rápido y predecible, y la RSS que ves desde el principio es la real (lo que evita la falsa impresión de una «fuga de memoria» que en realidad es el heap creciendo). El coste es que la RSS es alta desde el minuto 0, lo que en un pod con memoria garantizada no tiene ninguna desventaja.

5.3 La memoria que se olvida, y por qué el pod muere con el heap medio vacío

Aquí está la respuesta a la pregunta de entrevista más reveladora de todas: «el heap está al 40% y el pod muere con OOMKilled, ¿qué pasa?». La respuesta es que el kernel no mira el heap: mira el RSS del proceso, y el heap es solo una parte de él.

╔══════════════════════════════════════════════════════════════════════════════╗
║  MEMORIA TOTAL DEL CONTENEDOR (memory.max = 1 GiB)                           ║
║  Superar esta línea ⇒ el OOM killer del kernel mata el proceso ⇒ exit 137     ║
╠══════════════════════════════════════════════════════════════════════════════╣
║                                                                              ║
║  ┌────────────────────────────────────────────────────┐                       ║
║  │ HEAP JAVA                       -Xmx / MaxRAMPct   │  ~665 MiB  (65%)     ║
║  │  Eden + Survivor + Old                             │                       ║
║  │  ► Un OutOfMemoryError aquí lanza una EXCEPCIÓN    │                       ║
║  └────────────────────────────────────────────────────┘                       ║
║                                                                              ║
║  ─── A PARTIR DE AQUÍ, TODO ES MEMORIA «INVISIBLE» ─────────────────────────  ║
║                                                                              ║
║  ┌──────────────────────┐  Metaspace          80–180 MiB                     ║
║  │ Clases cargadas      │  Spring Boot carga 12.000-20.000 clases; con        ║
║  │ y metadatos          │  Hibernate y proxies CGLIB, más. NO tiene límite    ║
║  │                      │  por defecto ⇒ puede crecer hasta comerse el pod.   ║
║  └──────────────────────┘                                                    ║
║  ┌──────────────────────┐  Pilas de hilos     nº hilos × 1 MiB               ║
║  │ Thread stacks        │  200 hilos de Tomcat = 200 MiB de reserva virtual   ║
║  │                      │  (el RSS real es menor, pero no despreciable)       ║
║  └──────────────────────┘                                                    ║
║  ┌──────────────────────┐  Code cache (JIT)   ~50–240 MiB                    ║
║  │ Código compilado     │  Crece durante los primeros minutos de tráfico.     ║
║  │ + metadatos del JIT  │  ReservedCodeCacheSize por defecto: 240 MiB.        ║
║  └──────────────────────┘                                                    ║
║  ┌──────────────────────┐  Direct / mapped    ¿? MiB                         ║
║  │ ByteBuffer directos  │  ★ EL SOSPECHOSO HABITUAL. Netty, gRPC, el driver   ║
║  │ NIO, Netty, Kafka    │  de Kafka y los clientes HTTP reactivos usan        ║
║  │                      │  memoria FUERA del heap. Sin MaxDirectMemorySize    ║
║  │                      │  el límite por defecto es… el tamaño del heap.      ║
║  └──────────────────────┘                                                    ║
║  ┌──────────────────────┐  GC overhead        30–100 MiB                     ║
║  │ Estructuras del GC   │  Card tables, remembered sets, marcado. G1 usa      ║
║  │                      │  ~5-10% del heap solo en sus estructuras.           ║
║  └──────────────────────┘                                                    ║
║  ┌──────────────────────┐  Malloc del sistema 20–60 MiB                      ║
║  │ glibc, JNI, zlib,    │  Compresión, TLS, drivers nativos. Y la            ║
║  │ librerías nativas    │  fragmentación de glibc con muchos hilos (arenas).  ║
║  └──────────────────────┘                                                    ║
║  ┌──────────────────────┐  Page cache y tmpfs                                ║
║  │ ¡Cuenta en cgroup v2!│  Un heapdump de 600 MiB escrito en /tmp (tmpfs)     ║
║  │                      │  CUENTA como memoria del contenedor. Escribir el    ║
║  │                      │  volcado puede ser lo que mate al pod.              ║
║  └──────────────────────┘                                                    ║
╚══════════════════════════════════════════════════════════════════════════════╝

REGLA PRÁCTICA:  RSS ≈ heap + metaspace + (hilos × 1 MiB × 0,3) + code cache
                       + direct + 10% de margen de GC + 40 MiB de nativo
SíntomaCausa probableCómo confirmarloArreglo
Exit 137 / OOMKilled, heap al 40% Memoria nativa: direct buffers, metaspace o hilos jcmd 1 VM.native_memory summary (requiere -XX:NativeMemoryTracking=summary) Poner MaxDirectMemorySize y MaxMetaspaceSize, y bajar el número de hilos
Exit 137 justo al escribir el heapdump El volcado va a /tmp montado como tmpfs, y eso es RAM que cuenta mount | grep /tmp dentro del pod emptyDir con medium: "" (disco) para /tmp, o un volumen aparte para volcados
OutOfMemoryError: Java heap space Fuga de verdad, o heap infradimensionado para la carga Analizar el heapdump con Eclipse MAT (dominator tree) Arreglar la fuga; solo después subir el heap
OutOfMemoryError: Metaspace Redespliegue en caliente, generación dinámica de clases, muchos proxies jcmd 1 GC.class_stats, métrica jvm_classes_loaded Subir MaxMetaspaceSize; investigar si el número de clases crece sin parar
OutOfMemoryError: unable to create native thread Límite pids.max del cgroup, o memoria agotada para pilas cat /sys/fs/cgroup/pids.max y pids.current Subir el límite de PIDs; casi siempre indica una fuga de hilos en tu código
RSS crece despacio y sin límite durante días Fuga nativa (JNI, zlib, un driver) o fragmentación de glibc NMT con baseline y diff; probar con jemalloc MALLOC_ARENA_MAX=2 ayuda mucho con muchos hilos; o cambiar a jemalloc
# ── DIAGNOSTICAR LA MEMORIA NATIVA: el procedimiento completo ────────────────
# 1) Activar Native Memory Tracking (coste ~5% de rendimiento; en un incidente,
#    ese 5% es irrelevante. Tenlo activado en summary siempre.)
#    -XX:NativeMemoryTracking=summary

# 2) Ver el desglose desde dentro del pod
kubectl exec -it pedidos-7c9f-xyz -- jcmd 1 VM.native_memory summary scale=MB

# Native Memory Tracking:
# Total: reserved=2154MB, committed=982MB          ← COMMITTED es lo que importa
# -                 Java Heap (reserved=665MB, committed=665MB)
# -                     Class (reserved=180MB, committed=104MB)   ← metaspace
# -                    Thread (reserved=232MB, committed=32MB)    ← 227 hilos
# -                      Code (reserved=250MB, committed=88MB)    ← JIT
# -                        GC (reserved=52MB,  committed=52MB)
# -                  Internal (reserved=14MB,  committed=14MB)
# -                     Other (reserved=145MB, committed=145MB)   ← DIRECT BUFFERS
# -                    Symbol (reserved=24MB,  committed=24MB)

# 3) Encontrar QUÉ crece: línea base y diferencia 10 minutos después
kubectl exec pedidos-7c9f-xyz -- jcmd 1 VM.native_memory baseline
sleep 600
kubectl exec pedidos-7c9f-xyz -- jcmd 1 VM.native_memory summary.diff scale=MB
# Busca las líneas con «+» grande: ahí está tu fuga.

# 4) Comparar lo que dice la JVM con lo que ve el kernel (que es quien mata)
kubectl exec pedidos-7c9f-xyz -- cat /sys/fs/cgroup/memory.current   # bytes usados
kubectl exec pedidos-7c9f-xyz -- cat /sys/fs/cgroup/memory.max
kubectl exec pedidos-7c9f-xyz -- cat /sys/fs/cgroup/memory.peak      # ★ el pico histórico
kubectl exec pedidos-7c9f-xyz -- cat /sys/fs/cgroup/memory.events
#   oom 0
#   oom_kill 2         ← ya lo han matado dos veces

# 5) Y desde Prometheus, la comparación definitiva (métricas de Micrometer):
#      jvm_memory_used_bytes{area="heap"}                 lo que ve la JVM
#      jvm_memory_used_bytes{area="nonheap"}
#      jvm_buffer_memory_used_bytes{id="direct"}
#      container_memory_working_set_bytes                 lo que ve el kernel
#    Si la segunda crece y las primeras no, es memoria nativa.
El caso real más frecuente: los direct buffers de Netty. Si usas WebFlux, un WebClient, gRPC o el cliente de Kafka, hay memoria fuera del heap que la JVM no limita por defecto de forma útil: MaxDirectMemorySize vale, si no lo fijas, aproximadamente el tamaño del heap. Con un heap de 665 MB, la JVM se permite otros 665 MB de memoria directa: 1,33 GB solo entre esos dos, en un pod de 1 GB. Fíjalo siempre de forma explícita: -XX:MaxDirectMemorySize=128m, y añade -Dio.netty.maxDirectMemory=0 para que Netty use el contador de la JVM en lugar de su propio contador paralelo (si no, cada uno lleva su cuenta y ninguno ve el total).

5.4 Elegir el recolector de basura según los recursos disponibles

RecolectorBanderaHilosPausas típicasSobrecoste de memoriaCuándo usarlo en un contenedor
Serial -XX:+UseSerialGC 1 50–500 ms Mínimo ≤ 1 CPU o ≤ 1 GiB de heap. Es la elección correcta para un contenedor pequeño: sin hilos de GC compitiendo por la única CPU, arranque más rápido y menos RSS. Y es lo que la JVM elige sola en ese escenario.
Parallel -XX:+UseParallelGC N 100 ms – 2 s Bajo Procesos por lotes y Jobs donde solo importa el rendimiento total y las pausas no molestan a nadie.
G1 (por defecto) -XX:+UseG1GC N 10–200 ms (objetivo configurable) Medio (~8% del heap) ≥ 2 CPU y ≥ 2 GiB de heap. El equilibrio por defecto para un servicio web. Ajusta con -XX:MaxGCPauseMillis=200.
ZGC generacional -XX:+UseZGC N < 1 ms Alto (~15–20%) Heaps grandes (≥ 8 GiB) donde la latencia p99 es un requisito contractual. En Java 21 ya es generacional y por fin es una opción realista; necesita CPU de sobra.
Shenandoah -XX:+UseShenandoahGC N < 10 ms Medio-alto Alternativa a ZGC que funciona mejor con heaps medianos (2–8 GiB). Disponible en Temurin.
# Configuración recomendada según el tamaño del contenedor. Copia y adapta.

# ── Contenedor de 512 Mi – 1 Gi, 0,5–1 CPU (microservicio pequeño) ───────────
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=60 -XX:InitialRAMPercentage=60 \
  -XX:+UseSerialGC \
  -XX:MaxMetaspaceSize=160m -XX:MaxDirectMemorySize=64m \
  -XX:ReservedCodeCacheSize=96m \
  -XX:+ExitOnOutOfMemoryError -XX:NativeMemoryTracking=summary"

# ── Contenedor de 2 Gi, 2 CPU (servicio web típico) ─────────────────────────
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=70 \
  -XX:+UseG1GC -XX:MaxGCPauseMillis=200 \
  -XX:MaxMetaspaceSize=256m -XX:MaxDirectMemorySize=192m \
  -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/dumps/heap.hprof -XX:NativeMemoryTracking=summary"

# ── Contenedor de 8 Gi, 4 CPU, latencia crítica ─────────────────────────────
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 \
  -XX:+UseZGC -XX:+ZGenerational \
  -XX:MaxMetaspaceSize=384m -XX:MaxDirectMemorySize=512m \
  -XX:+ExitOnOutOfMemoryError -XX:StartFlightRecording=maxsize=200m,filename=/dumps/app.jfr"

# ── SIEMPRE: logs de GC. Cuestan casi nada y son la diferencia entre
#    diagnosticar en 5 minutos y adivinar durante 3 horas.
-Xlog:gc*,safepoint:file=/dumps/gc.log:time,uptime,level,tags:filecount=5,filesize=20M

# Ver los logs de GC de un pod en vivo
kubectl exec pedidos-7c9f-xyz -- tail -f /dumps/gc.log

5.5 Límites de CPU: throttling, el JIT y el arranque

La memoria y la CPU se comportan de forma radicalmente distinta al superar el límite, y esta es la asimetría más importante que hay que entender:

MemoriaCPU
Tipo de recursoIncompresible: o la tienes o noCompresible: se puede repartir en el tiempo
Al superar el límiteEl kernel mata el proceso (exit 137)El kernel frena el proceso (throttling)
Cómo se manifiestaReinicio del pod, evidente en los eventosLatencia p99 pésima, sin ningún error: invisible si no lo mides
Recomendaciónrequests == limits. Pon límite siempre.Pon requests. Normalmente, sin limits.

El throttling de CFS funciona por cuotas en ventanas de 100 ms. Con limits.cpu: 500m, tu contenedor puede usar 50 ms de CPU por cada ventana de 100 ms; al agotar la cuota, se queda congelado hasta la ventana siguiente. Y aquí está el problema con Java: la JVM es multihilo, así que 4 hilos ejecutando a la vez consumen la cuota en 12,5 ms y el proceso se queda parado los 87,5 ms restantes. Aparecen picos de latencia de decenas de milisegundos sin ninguna causa aparente en tu código.

# ── DETECTAR THROTTLING (hazlo antes de culpar a tu código) ──────────────────
kubectl exec pedidos-7c9f-xyz -- cat /sys/fs/cgroup/cpu.stat
# usage_usec 45231000
# nr_periods 89234        ← ventanas de 100 ms transcurridas
# nr_throttled 12043      ← ventanas en las que se agotó la cuota
# throttled_usec 8934000  ← microsegundos TOTALES congelado

# Ratio de throttling = nr_throttled / nr_periods = 13,5%  ← MUY alto.
# Por encima del 1-2% ya deberías investigarlo.

# En Prometheus, la alerta que deberías tener:
#   rate(container_cpu_cfs_throttled_periods_total[5m])
#     / rate(container_cpu_cfs_periods_total[5m]) > 0.05

# ── EFECTO SOBRE EL ARRANQUE: el más doloroso ────────────────────────────────
# Arrancar una JVM es la fase MÁS intensiva en CPU de toda la vida del proceso:
# hay que cargar y verificar 15.000 clases y compilar los métodos calientes.
# Con limits.cpu: 500m, un arranque de 4 s pasa a 25-40 s. Consecuencias:
#   · el startupProbe agota su presupuesto y Kubernetes reinicia el pod
#   · CrashLoopBackOff que parece un bug de la aplicación y es de configuración
#   · el escalado tarda tanto que llega después del pico de tráfico

# Solución A: sin límite de CPU (lo recomendado en la mayoría de los casos).
#   Las requests garantizan el mínimo; el pod usa CPU libre del nodo cuando la hay.
# Solución B: startupProbe generoso (failureThreshold alto) + requests holgadas.
# Solución C: si la política de la empresa obliga a poner límites, pon
#   limits.cpu al menos 2x requests, y usa un initContainer o un límite
#   temporalmente mayor durante el arranque (con VPA en modo Initial).
«Pon siempre límites de CPU» es un consejo equivocado en la mayoría de los casos, y hay que saber argumentarlo. El argumento a favor es el aislamiento: un vecino ruidoso no debe robarte CPU. Pero requests ya garantiza tu parte proporcional mediante cpu.weight: cuando hay contención, el kernel reparte según los pesos. El límite solo añade una cosa: que no puedas usar CPU que está libre. Es decir, pagas por un nodo con CPU ociosa y aceptas latencia peor a cambio de nada. Los casos en los que el límite sí tiene sentido son concretos: entornos multi-inquilino con facturación por consumo, procesos por lotes que se comerían el nodo entero, y benchmarks donde necesitas resultados repetibles. Para un servicio web normal: requests sí, limits no.

5.6 Hilos y availableProcessors: la trampa silenciosa

Un montón de decisiones dentro de la JVM y de las librerías dependen de Runtime.getRuntime().availableProcessors(). Si ese número está mal, todo lo demás está mal:

Qué se dimensiona con availableProcessors()FórmulaCon 8 CPU detectadasProblema si en realidad tienes 0,5
Hilos de GC paraleloParallelGCThreads ≈ 5/8 × n5 hilos5 hilos peleándose por media CPU: pausas de GC larguísimas
Hilos del compilador JITCICompilerCount3–4La compilación compite con tu aplicación
ForkJoinPool.commonPool()n − 17parallelStream() más lento que el secuencial
Bucles de eventos de Netty / Reactor2 × n16Cambios de contexto constantes, cero paralelismo real
Pool por defecto de HikariCP en algunas guías2 × n + 117 conexionesConexiones que no se usan pero cuentan en max_connections
Programador de hilos virtuales (Java 21)n8 hilos portadoresMenos paralelismo del esperado o exceso de portadores
# El caso peligroso: pod SIN límite de CPU (que es lo que recomendamos en 5.5).
# cpu.max = "max 100000" ⇒ la JVM detecta TODAS las CPU del nodo.
kubectl exec pedidos-7c9f-xyz -- nproc                      # 64  ← ¡el nodo!
kubectl exec pedidos-7c9f-xyz -- java -XshowSettings:system -version 2>&1 | grep Processors
#   Active Processors: 64

# Con requests.cpu: 500m eso significa 64 hilos de bucle de eventos de Netty,
# 40 hilos de GC y un commonPool de 63, para media CPU garantizada. Desastre.

# ── SOLUCIÓN: decirle a la JVM cuántas CPU asumir, de forma explícita ────────
-XX:ActiveProcessorCount=2
# Regla práctica: ceil(requests.cpu) con un mínimo de 2. Con requests 500m → 2.
# Ojo: esto NO limita el uso real de CPU (eso lo hace el cgroup); solo cambia el
# número que la JVM usa para dimensionar sus pools. Es exactamente lo que quieres.

# Verificarlo
kubectl exec pedidos-7c9f-xyz -- jcmd 1 VM.flags | tr ' ' '\n' \
  | grep -E 'ActiveProcessorCount|ParallelGCThreads|CICompilerCount'
# Patrón completo: coherencia entre requests, ActiveProcessorCount y los pools
env:
  - name: JAVA_TOOL_OPTIONS
    value: >-
      -XX:MaxRAMPercentage=70
      -XX:ActiveProcessorCount=2
      -XX:+UseG1GC
      -XX:+ExitOnOutOfMemoryError
  # Dimensionar los pools de la aplicación con criterio, no con 2×CPU
  - name: SERVER_TOMCAT_THREADS_MAX
    value: "50"          # con 10 conexiones de BD, 200 hilos solo generan espera
  - name: SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE
    value: "10"          # réplicas × pool ≤ max_connections de PostgreSQL
resources:
  requests: { cpu: "500m", memory: "1Gi" }
  limits:   { memory: "1Gi" }        # sin límite de CPU
Cuenta el pool de conexiones antes de escalar. Con 20 réplicas × 10 conexiones = 200 conexiones a PostgreSQL, más el pool de otro servicio, más las migraciones, más tu consola de psql. El max_connections por defecto de PostgreSQL es 100. Escalar horizontalmente sin mirar esto convierte un pico de tráfico en una caída total con FATAL: too many connections. La solución es PgBouncer o bajar el pool por réplica; ver módulo 06.

5.7 Tiempo de arranque: lazy init, AppCDS, CRaC e imagen nativa

El arranque importa por tres razones concretas: define cuánto tarda un despliegue, cuánto tarda el autoescalado en responder a un pico, y si puedes permitirte escalar a cero (que es la diferencia entre pagar y no pagar en serverless).

TécnicaMejora típicaCosteRiesgoRecomendación
spring.main.lazy-initialization=true 30–50% menos de arranque Ninguno en el build Alto en producción: los errores de configuración de un bean aparecen en la primera petición que lo usa, no al arrancar. Y la primera petición a cada endpoint es lentísima. Solo en desarrollo y en tests. Nunca en producción.
Quitar dependencias que no usas 10–30% Un rato de dependency:analyze Ninguno Hazlo primero, siempre. Cada starter son autoconfiguraciones que se evalúan al arrancar.
Jar explotado (extract) 5–10% Ninguno (ya lo haces por las capas) Ninguno Sí. Viene gratis con el Dockerfile de 4.2.
AppCDS / Project Leyden AOT cache 20–40% Un paso más en el build Bajo: el archivo se invalida solo si el classpath cambia (y entonces arranca normal) La mejor relación beneficio/riesgo. Spring Boot 3.3+ lo integra.
CRaC (Coordinated Restore at Checkpoint) 90–95% (arranque en ~50 ms) JDK específico (Azul Zulu, Liberica), un paso de checkpoint en el build Medio: hay que gestionar los recursos que no se pueden serializar (conexiones, ficheros abiertos, aleatoriedad) implementando Resource Cuando el arranque es un requisito duro y no puedes usar imagen nativa.
GraalVM native image 98% (~50 ms) y −80% de RSS Build de 5–15 min y mucha más RAM en CI Medio-alto: reflexión y proxies dinámicos necesitan configuración; sin JIT de perfil el rendimiento máximo es menor; herramientas de diagnóstico distintas Serverless, CLI, escalado a cero. Ver sección 13.
# ── AppCDS: la opción práctica. Tres pasos en el Dockerfile. ────────────────
# Spring Boot 3.3+ soporta la generación del archivo CDS de forma nativa con
# jarmode=tools, y desde 3.5 también el caché AOT de Project Leyden (JDK 24+).

# En la etapa de build, tras extraer las capas:
FROM eclipse-temurin:21.0.5_11-jre-noble AS cds
WORKDIR /app
COPY --from=build /build/extracted/ ./
# 1) Arrancar la aplicación en modo «entrenamiento»: sube, registra las clases y sale
RUN java -XX:ArchiveClassesAtExit=/app/app.jsa \
         -Dspring.context.exit=onRefresh \
         org.springframework.boot.loader.launch.JarLauncher

FROM eclipse-temurin:21.0.5_11-jre-noble AS runtime
WORKDIR /app
COPY --from=cds --chown=10001:10001 /app/ ./
USER 10001:10001
# 2) Usar el archivo al arrancar. Si el classpath cambió, la JVM lo ignora y
#    arranca normal: es seguro por diseño.
ENV JAVA_TOOL_OPTIONS="-XX:SharedArchiveFile=/app/app.jsa -Xshare:auto \
  -XX:MaxRAMPercentage=70"
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

# 3) Verificar que se está usando (si no, arrancas sin ganancia y sin saberlo)
#    -Xlog:cds  →  «Opened archive /app/app.jsa»
#    -Xshare:on en lugar de auto FALLA si no puede usarlo: útil en un test de CI
#    para garantizar que el archivo es válido.

# Medición real en un Spring Boot con web + JPA + Actuator:
#   sin CDS: 4,20 s   ·   con AppCDS: 2,75 s   (−35%)
#   con AppCDS + jar explotado + lazy off: 2,60 s
# Ajustes de arranque que no cuestan nada y que casi nadie pone
spring:
  jmx:
    enabled: false            # el registro JMX de todos los beans cuesta cientos de ms
  main:
    banner-mode: off
  jpa:
    open-in-view: false
    properties:
      hibernate:
        # Sin esto, Hibernate escanea el classpath buscando @Entity: costoso
        archive.autodetection: class
  flyway:
    enabled: false            # migraciones en un Job, no en el arranque (sección 8)
  datasource:
    hikari:
      minimum-idle: 2         # abrir 10 conexiones al arrancar cuesta segundos
      initialization-fail-timeout: 10000

# Y en el arranque, el orden importa: si Flyway corre al arrancar, el pod no está
# listo hasta que termine la migración, la probe agota su presupuesto y entra en
# CrashLoopBackOff justo cuando más falta hace que arranque.

5.8 Volcados y diagnóstico dentro de un pod

# ── PREPARACIÓN (esto va en el manifiesto ANTES de necesitarlo) ──────────────
# Un volumen para volcados que NO sea tmpfs: si /tmp es RAM, escribir un
# heapdump de 600 MB puede provocar el propio OOMKill que intentas investigar.
volumeMounts:
  - name: dumps
    mountPath: /dumps
volumes:
  - name: dumps
    emptyDir:
      medium: ""             # "" = disco del nodo. "Memory" sería tmpfs: NO.
      sizeLimit: 2Gi

# ── COMANDOS DE DIAGNÓSTICO DENTRO DE UN POD ─────────────────────────────────
# jcmd es la navaja suiza. Está en el JDK; en una imagen JRE puede no estar
# (Temurin JRE sí lo incluye; distroless :nonroot no).
kubectl exec -it pedidos-7c9f-xyz -- jcmd                     # lista los procesos
kubectl exec -it pedidos-7c9f-xyz -- jcmd 1 help              # comandos disponibles

kubectl exec -it pedidos-7c9f-xyz -- jcmd 1 VM.flags          # flags efectivas
kubectl exec -it pedidos-7c9f-xyz -- jcmd 1 VM.system_properties
kubectl exec -it pedidos-7c9f-xyz -- jcmd 1 VM.uptime
kubectl exec -it pedidos-7c9f-xyz -- jcmd 1 VM.native_memory summary
kubectl exec -it pedidos-7c9f-xyz -- jcmd 1 GC.heap_info
kubectl exec -it pedidos-7c9f-xyz -- jcmd 1 Thread.print      # volcado de hilos
kubectl exec -it pedidos-7c9f-xyz -- jcmd 1 GC.class_histogram | head -40

# ── VOLCADO DE HEAP: sacarlo del pod ─────────────────────────────────────────
# 1) Generarlo (¡pausa la aplicación varios segundos! Saca de rotación el pod
#    antes: kubectl label pod X app- para que el Service deje de enviarle tráfico)
kubectl exec pedidos-7c9f-xyz -- jcmd 1 GC.heap_dump -all /dumps/heap.hprof

# 2) Copiarlo (600 MB tardan; usa compresión por el camino)
kubectl exec pedidos-7c9f-xyz -- gzip -c /dumps/heap.hprof > heap.hprof.gz
#    o directamente
kubectl cp pedidos-7c9f-xyz:/dumps/heap.hprof ./heap.hprof

# 3) Analizarlo en local con Eclipse MAT: Leak Suspects → Dominator Tree
#    Busca la clase con más «retained heap»: ahí está tu fuga.

# ── JFR: LO QUE DEBERÍAS USAR SIEMPRE ────────────────────────────────────────
# Java Flight Recorder tiene un coste del 1-2% y registra CPU, asignaciones,
# GC, bloqueos, E/S y excepciones. Es la mejor herramienta de la JVM.
# Grabación continua en anillo, para tener SIEMPRE los últimos 15 minutos:
-XX:StartFlightRecording=name=continua,maxsize=200m,maxage=15m,\
settings=profile,filename=/dumps/continua.jfr,dumponexit=true

# Volcar la grabación en curso cuando notas el problema
kubectl exec pedidos-7c9f-xyz -- jcmd 1 JFR.dump name=continua filename=/dumps/inc.jfr
kubectl cp pedidos-7c9f-xyz:/dumps/inc.jfr ./inc.jfr
# Abrir con JDK Mission Control: Automated Analysis Results da el diagnóstico casi hecho

# ── VOLCADO DE HILOS SIN jcmd (imagen mínima) ───────────────────────────────
# SIGQUIT hace que la JVM imprima el volcado de hilos por stdout ⇒ va a los logs
kubectl exec pedidos-7c9f-xyz -- kill -3 1
kubectl logs pedidos-7c9f-xyz | tail -300

# Y si no hay ni shell (distroless), Actuator al rescate:
kubectl port-forward pedidos-7c9f-xyz 8081:8081
curl -s localhost:8081/actuator/threaddump | jq '.threads[] | select(.threadState=="BLOCKED")'
curl -s localhost:8081/actuator/heapdump -o heap.hprof     # ¡sí, Actuator puede!
curl -s localhost:8081/actuator/metrics/jvm.memory.used | jq
La regla de oro del diagnóstico en Kubernetes: saca el pod de rotación antes de investigarlo, no lo mates. Un kubectl delete pod destruye toda la evidencia. En cambio, si cambias la etiqueta que usa el Service (kubectl label pod X app=pedidos-cuarentena --overwrite), el Deployment crea un pod nuevo para reemplazarlo, el tráfico deja de llegar al enfermo, y tú te quedas con él vivo y aislado para hacer todos los volcados que quieras. Es la técnica más útil de esta sección y casi nadie la conoce.

6 · Desarrollo local con Docker Compose

Compose es la herramienta para desarrollar, no para producir. Su valor es que un desarrollador nuevo clone el repositorio, ejecute un comando y tenga todo el entorno funcionando en dos minutos, con las mismas versiones de base de datos y de broker que producción. Eso es el factor 10 (paridad de entornos) hecho realidad.

6.1 Un compose.yaml completo y comentado

# compose.yaml — entorno local completo para un servicio Spring Boot.
# Nota: en la especificación actual ya NO se pone «version:» al principio.
name: pedidos

services:
  # ─────────────────────────────────────────────────────────────────────────────
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: runtime            # para el desarrollo puedes apuntar a otra etapa
      args:
        VERSION: dev
    image: pedidos:dev
    ports:
      - "127.0.0.1:8080:8080"    # aplicación (solo desde localhost)
      - "127.0.0.1:8081:8081"    # Actuator
      - "127.0.0.1:5005:5005"    # depuración remota
    environment:
      SPRING_PROFILES_ACTIVE: local
      # Los nombres de servicio son DNS dentro de la red de compose
      DB_URL: jdbc:postgresql://db:5432/pedidos
      DB_USER: app
      DB_PASSWORD: secreto
      SPRING_DATA_REDIS_HOST: redis
      SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
      MANAGEMENT_OTLP_TRACING_ENDPOINT: http://jaeger:4318/v1/traces
      MANAGEMENT_TRACING_SAMPLING_PROBABILITY: "1.0"    # 100% en local
      JAVA_TOOL_OPTIONS: >-
        -XX:MaxRAMPercentage=70
        -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
    depends_on:
      db:
        condition: service_healthy      # espera a que responda, no a que arranque
      redis:
        condition: service_healthy
      kafka:
        condition: service_healthy
      migraciones:
        condition: service_completed_successfully   # ★ espera a que TERMINE el Job
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8081/actuator/health/readiness"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 45s          # ★ la JVM necesita margen
    restart: unless-stopped
    develop:
      watch:                     # docker compose watch: recarga sin reconstruir
        - action: sync+restart
          path: ./src/main/resources
          target: /app/BOOT-INF/classes
        - action: rebuild
          path: ./pom.xml

  # ─── Migraciones como un paso separado, igual que en producción ──────────────
  migraciones:
    image: flyway/flyway:11-alpine
    command: -connectRetries=20 migrate
    environment:
      FLYWAY_URL: jdbc:postgresql://db:5432/pedidos
      FLYWAY_USER: app
      FLYWAY_PASSWORD: secreto
      FLYWAY_LOCATIONS: filesystem:/flyway/sql
    volumes:
      - ./src/main/resources/db/migration:/flyway/sql:ro
    depends_on:
      db:
        condition: service_healthy

  # ─────────────────────────────────────────────────────────────────────────────
  db:
    image: postgres:16.6-alpine
    environment:
      POSTGRES_DB: pedidos
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secreto
      # Acelera el arranque en desarrollo (¡NUNCA en producción!)
      POSTGRES_INITDB_ARGS: "--data-checksums"
    command:
      - postgres
      - -c
      - shared_buffers=256MB
      - -c
      - max_connections=200
      - -c
      - log_min_duration_statement=200      # ★ ve tus consultas lentas en local
      - -c
      - log_line_prefix=%m [%p] %u@%d
      - -c
      - shared_preload_libraries=pg_stat_statements
    ports:
      - "127.0.0.1:5432:5432"    # para conectar con tu IDE o con psql
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./ops/seed.sql:/docker-entrypoint-initdb.d/10-seed.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d pedidos"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 10s

  redis:
    image: redis:7.4-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru --appendonly no
    ports:
      - "127.0.0.1:6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      retries: 10

  kafka:
    image: apache/kafka:3.9.0        # KRaft: ya no hace falta ZooKeeper
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:29092
      # ★ Dos listeners: uno para dentro de la red de compose (kafka:9092) y otro
      #   para tu portátil (localhost:29092). Sin esto, o funciona la app o
      #   funciona tu consola de Kafka, pero no las dos.
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:29092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
    ports:
      - "127.0.0.1:29092:29092"
    healthcheck:
      test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list || exit 1"]
      interval: 10s
      timeout: 10s
      retries: 12
      start_period: 20s

  # ─── Observabilidad local: sin esto no puedes probar tus trazas ni tus alertas ─
  jaeger:
    image: jaegertracing/all-in-one:1.62
    environment:
      COLLECTOR_OTLP_ENABLED: "true"
    ports:
      - "127.0.0.1:16686:16686"   # interfaz web
      - "127.0.0.1:4318:4318"     # OTLP/HTTP

  prometheus:
    image: prom/prometheus:v3.0.1
    command:
      - --config.file=/etc/prometheus/prometheus.yml
      - --web.enable-lifecycle
    volumes:
      - ./ops/prometheus.yml:/etc/prometheus/prometheus.yml:ro
    ports:
      - "127.0.0.1:9090:9090"

  grafana:
    image: grafana/grafana:11.4.0
    environment:
      GF_SECURITY_ADMIN_PASSWORD: admin
      GF_AUTH_ANONYMOUS_ENABLED: "true"
      GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
    volumes:
      - ./ops/grafana/provisioning:/etc/grafana/provisioning:ro
      - ./ops/grafana/dashboards:/var/lib/grafana/dashboards:ro
    ports:
      - "127.0.0.1:3000:3000"
    depends_on: [prometheus]

  # ─── Servicios que solo quieres a veces: perfiles de compose ────────────────
  mailhog:
    image: axllent/mailpit:v1.21
    profiles: [extras]
    ports:
      - "127.0.0.1:8025:8025"

  localstack:
    image: localstack/localstack:3.8
    profiles: [aws]
    environment:
      SERVICES: s3,sqs,secretsmanager,dynamodb
      DEBUG: "0"
    ports:
      - "127.0.0.1:4566:4566"
    volumes:
      - ./ops/localstack-init.sh:/etc/localstack/init/ready.d/init.sh:ro

volumes:
  pgdata:
  # OJO: este volumen SOBREVIVE a `docker compose down`. Para borrarlo de verdad:
  #   docker compose down -v

6.2 Los comandos de Compose y los perfiles

# Nota: es «docker compose» (plugin, v2), no «docker-compose» (script Python, v1,
# sin soporte desde 2023). Si tu equipo aún usa el guion, actualizadlo.

docker compose up -d                    # levantar todo al fondo
docker compose up -d --build            # reconstruir imágenes antes
docker compose up -d --wait             # ★ esperar a que TODO esté healthy
                                        #   (imprescindible en un script de CI)
docker compose up app                   # solo un servicio y sus dependencias
docker compose logs -f app db           # logs de varios servicios entrelazados
docker compose ps                       # estado y salud
docker compose exec db psql -U app -d pedidos
docker compose run --rm migraciones     # ejecutar un servicio puntual y borrarlo
docker compose restart app
docker compose stop                     # parar sin borrar
docker compose down                     # parar y borrar contenedores y red
docker compose down -v                  # ⚠ y también los volúmenes (borra datos)
docker compose config                   # ★ el YAML resuelto: variables, extends,
                                        #   overrides. El primer comando a ejecutar
                                        #   cuando algo no cuadra.
docker compose top
docker compose watch                    # sincroniza y reinicia al cambiar ficheros

# ─── PERFILES: servicios opcionales ─────────────────────────────────────────
docker compose up -d                        # sin perfiles: los servicios base
docker compose --profile extras up -d       # + mailpit
docker compose --profile aws up -d          # + localstack
COMPOSE_PROFILES=extras,aws docker compose up -d

# ─── FICHEROS MÚLTIPLES: base + variación ───────────────────────────────────
# compose.yaml               ← la base, versionada
# compose.override.yaml      ← se aplica AUTOMÁTICAMENTE si existe (ponlo en
#                              .gitignore: es el ajuste personal de cada uno)
# compose.ci.yaml            ← para el pipeline: sin puertos publicados, sin watch
docker compose -f compose.yaml -f compose.ci.yaml up -d --wait

# Variables desde .env (Compose lo lee automáticamente del directorio actual)
#   POSTGRES_VERSION=16.6
#   → image: postgres:${POSTGRES_VERSION:-16-alpine}
# compose.override.yaml — el fichero personal de cada desarrollador (en .gitignore)
services:
  app:
    # Montar las clases compiladas para recargar sin reconstruir la imagen
    volumes:
      - ./target/classes:/app/BOOT-INF/classes:ro
    environment:
      LOGGING_LEVEL_COM_EJEMPLO: TRACE
      LOGGING_LEVEL_ORG_HIBERNATE_SQL: DEBUG
      LOGGING_LEVEL_ORG_HIBERNATE_ORM_JDBC_BIND: TRACE   # ver los parámetros
  db:
    ports:
      - "15432:5432"      # otro puerto porque ya tienes un PostgreSQL local

6.3 docker compose watch: el bucle de desarrollo rápido

watch observa el sistema de ficheros y reacciona según la acción configurada. Es lo que convierte Compose en una herramienta de desarrollo real y no solo en un lanzador de dependencias.

AcciónQué haceCuándo usarla en Java
syncCopia los ficheros al contenedor en marcha, sin reiniciar.Plantillas, ficheros estáticos, application.yml si usas refresh.
sync+restartCopia y reinicia el contenedor.Clases compiladas: con mvn compile en un terminal y esto en otro, tienes recarga en ~4 s.
rebuildReconstruye la imagen y recrea el contenedor.Cambios en pom.xml o en el Dockerfile.
sync+execCopia y ejecuta un comando dentro.Aplicar una migración nueva sin reiniciar la aplicación.
Para Java, el bucle más rápido no es Compose: es Spring Boot DevTools o JRebel corriendo la aplicación en tu IDE, con Compose levantando solo las dependencias. Ejecutar tu aplicación dentro de un contenedor durante el desarrollo te cuesta el depurador cómodo, la recarga en caliente y el hot swap del IDE, y te da poco a cambio. El patrón que mejor funciona: compose up db redis kafka y la aplicación desde IntelliJ. Y para eso, precisamente, existe lo siguiente.

6.4 El soporte de Docker Compose de Spring Boot 3

Spring Boot 3.1 introdujo algo muy práctico: si añades la dependencia spring-boot-docker-compose, al arrancar la aplicación desde tu IDE o con bootRun, Spring levanta el compose.yaml por ti y configura automáticamente las propiedades de conexión a partir de los servicios que encuentra. No tienes que escribir la URL de la base de datos en ningún sitio.

# application.yml — configuración del soporte de Compose
spring:
  docker:
    compose:
      enabled: true
      file: compose.yaml
      lifecycle-management: start_and_stop   # start_only | start_and_stop | none
      start:
        command: up            # o up --build
        log-level: info
        skip:
          in-tests: false      # en tests suele ser mejor Testcontainers
      stop:
        command: down          # o stop, si prefieres conservar los contenedores
        timeout: 20s
      # Solo levanta los servicios de estos perfiles
      profiles:
        active: [ ]
Servicio detectado (por imagen)Propiedades que configura automáticamente
postgresspring.datasource.url, username, password (leídos de las variables del contenedor)
redisspring.data.redis.host, port
mongospring.data.mongodb.uri
rabbitmqspring.rabbitmq.*
kafka / confluentinc/cp-kafkaspring.kafka.bootstrap-servers
elasticsearchspring.elasticsearch.uris
otel/opentelemetry-collectormanagement.otlp.*
Cualquiera, con anotación explícitalabels: org.springframework.boot.service-connection: postgres
Docker Compose supportTestcontainers
Para qué sirveDesarrollo interactivo: arrancar la app y trabajarTests automatizados y reproducibles
Ciclo de vidaLos contenedores sobreviven entre arranques (los datos persisten)Contenedor limpio por test o por clase
Definicióncompose.yaml, compartido con el equipoCódigo Java, versionado con los tests
VelocidadMuy rápida tras el primer arranqueSegundos por contenedor (reutilizables con withReuse)
RecomendaciónÚsalo en desarrolloÚsalo en tests. Ver módulo 07

6.5 Paridad con producción: hasta dónde llega Compose y dónde miente

AspectoCompose (local)Kubernetes (producción)Riesgo de la diferencia
Descubrimiento de servicios DNS por nombre de servicio dentro de la red de Compose DNS de Kubernetes: svc.namespace.svc.cluster.local Bajo. Las URLs se configuran por variable, así que solo cambia el valor.
Réplicas Normalmente 1 de cada servicio N réplicas con balanceo ALTO. El estado en memoria, los @Scheduled duplicados y las condiciones de carrera no aparecen en local. Prueba con --scale app=3.
Límites de recursos Sin límites por defecto Requests y limits estrictos ALTO. Un OOMKilled nunca se reproduce en local. Añade deploy.resources.limits al compose.
Probes y ciclo de vida healthcheck básico liveness, readiness, startup, preStop, apagado ordenado ALTO. Los problemas de despliegue solo se ven en un clúster. Usa kind para eso.
Red Todo se ve con todo NetworkPolicy, mTLS, ingress Medio. Descubres que faltaba una política de red al desplegar.
Secretos e identidad Variables en claro en el YAML Secrets, IRSA, workload identity Medio. El código que obtiene credenciales no se ejercita en local. Usa LocalStack.
Latencia y fallos de red Loopback: 0,1 ms y sin pérdidas Milisegundos, reintentos, cortes ALTO. Los timeouts se calibran mal. Usa toxiproxy para inyectar latencia.
Volumen de datos 100 filas de seed Millones de filas ALTÍSIMO. Es la causa nº 1 de sorpresas en producción. Genera datos sintéticos a escala.
# Acercar Compose a producción: límites y varias réplicas
services:
  app:
    deploy:
      replicas: 3
      resources:
        limits:
          memory: 1G           # ★ reproduce el OOMKilled en local
          cpus: "1.0"
        reservations:
          memory: 1G
    # Con 3 réplicas no puedes publicar un puerto fijo: pon un balanceador delante
    # o usa el DNS interno de compose (round-robin sobre las réplicas).
Compose no es para producción, y conviene tener claro el argumento. No tiene reprogramación automática si muere el nodo, ni actualizaciones progresivas con control de disponibilidad, ni autoescalado, ni gestión de secretos, ni RBAC, ni políticas de red, ni la noción de «estado deseado» reconciliado continuamente. Se ejecuta en una sola máquina. Para un proyecto personal o una demo interna en un único servidor puede ser perfectamente razonable —mejor Compose bien hecho que Kubernetes mal hecho—, pero en cuanto necesites que algo siga funcionando si se apaga una máquina, necesitas un orquestador de verdad.

7 · Kubernetes: el modelo mental

Kubernetes tiene fama de complicado y en parte la merece, pero su complejidad accidental (mil objetos y mil banderas) esconde una idea esencial muy simple. Si entiendes esa idea, el resto son detalles que se buscan en la documentación. Si no la entiendes, acabarás copiando YAML de internet y rezando.

7.1 Por qué existe un orquestador

Imagina que tienes 12 servicios, cada uno con 3 réplicas, sobre 6 máquinas. Ahora responde, sin orquestador, a estas preguntas:

PreguntaSin orquestadorCon orquestador
¿En qué máquina arranco esta réplica? Una hoja de cálculo y criterio humano. Se desequilibra en semanas. El scheduler lo decide según recursos libres, afinidades y restricciones.
Un proceso ha muerto. ¿Quién lo reinicia? systemd, si te acordaste de configurarlo. Y si la máquina entera muere, nadie. El kubelet reinicia el contenedor; el controlador de ReplicaSet recrea el pod en otro nodo.
¿Cómo encuentra el servicio A al servicio B? Una lista de IPs en un fichero de configuración que se queda obsoleta. DNS interno estable por nombre de Service, con balanceo entre las réplicas sanas.
¿Cómo despliego la versión nueva sin caída? Un script con ssh en bucle, sacando del balanceador a mano. kubectl set image: sustitución progresiva respetando la disponibilidad mínima.
Hay que actualizar el kernel de una máquina. Ventana de mantenimiento y movimientos manuales. kubectl drain: los pods se recolocan solos respetando el PodDisruptionBudget.
El tráfico se ha multiplicado por 4. Alguien arranca procesos a mano, si está despierto. HPA añade réplicas; el cluster autoscaler añade nodos si hacen falta.

Kubernetes es, en una frase, un bucle de control distribuido que mantiene el sistema en el estado que has declarado. Tú no das órdenes («arranca esto aquí»), describes un objetivo («quiero 3 copias de esta imagen, con estos recursos, accesibles en este nombre») y el sistema trabaja continuamente para que la realidad coincida con esa descripción.

Y el corolario incómodo: todo esto tiene un coste. Un clúster de Kubernetes gestionado requiere que alguien entienda de red, de almacenamiento, de RBAC, de actualizaciones de versión (cada 4 meses hay una nueva y el soporte dura ~14 meses) y de una docena de complementos. Para tres servicios y un equipo pequeño, ese coste supera el beneficio con mucho: Cloud Run o App Runner te dan el 80% del valor con el 5% del coste operativo. Sé honesto sobre esto en una entrevista; te va a distinguir.

7.2 Arquitectura del clúster: quién hace qué

╔═══════════════════════════════════════════════════════════════════════════════╗
║  PLANO DE CONTROL (control plane) · en la nube gestionada, no lo ves ni pagas ║
║                                     por sus máquinas (pagas una cuota)        ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║                                                                               ║
║   ┌──────────────────────────────────────────────────────────────────┐        ║
║   │  kube-apiserver                                                  │        ║
║   │  LA ÚNICA PUERTA. Todo pasa por aquí: kubectl, los controladores, │        ║
║   │  los kubelets. Hace autenticación → autorización (RBAC) →         │        ║
║   │  admisión (webhooks, validación) → persistencia en etcd.          │        ║
║   │  Es una API REST. Si se cae, el clúster sigue EJECUTANDO lo que    │        ║
║   │  ya hay, pero no puedes cambiar nada.                             │        ║
║   └───────────────────────────┬──────────────────────────────────────┘        ║
║                               │                                               ║
║   ┌───────────────────────────▼──────────────────────────────────────┐        ║
║   │  etcd — la ÚNICA fuente de verdad                                │        ║
║   │  Base de datos clave-valor distribuida (Raft). Guarda TODOS los   │        ║
║   │  objetos. Copia de seguridad de etcd = copia del clúster.         │        ║
║   │  Nadie habla con etcd salvo el apiserver.                         │        ║
║   └──────────────────────────────────────────────────────────────────┘        ║
║                                                                               ║
║   ┌──────────────────────────┐  ┌──────────────────────────────────┐          ║
║   │  kube-scheduler          │  │  kube-controller-manager         │          ║
║   │  Ve pods sin nodo        │  │  Docenas de bucles de control:    │          ║
║   │  asignado y elige uno:   │  │   · Deployment → ReplicaSet       │          ║
║   │  filtra (¿cabe? ¿tolera  │  │   · ReplicaSet → Pods             │          ║
║   │  los taints? ¿afinidad?) │  │   · Node (¿nodo muerto?)          │          ║
║   │  y puntúa (equilibrio).  │  │   · Job, CronJob, endpoints…      │          ║
║   │  NO arranca nada: solo   │  │  Cada uno: observar → comparar →  │          ║
║   │  escribe spec.nodeName.  │  │  actuar. Para siempre.            │          ║
║   └──────────────────────────┘  └──────────────────────────────────┘          ║
║                                                                               ║
║   ┌──────────────────────────────────────────────────────────────────┐        ║
║   │  cloud-controller-manager · crea balanceadores, discos y rutas    │        ║
║   │  reales en AWS/Azure/GCP cuando declaras un Service o un PVC.     │        ║
║   └──────────────────────────────────────────────────────────────────┘        ║
╚═══════════════════════════════════════════════════════════════════════════════╝
                                     ▲  ▼   (los nodos consultan; nadie les habla)
╔═══════════════════════════════════════════════════════════════════════════════╗
║  NODOS DE TRABAJO · aquí corre tu código y aquí pagas las máquinas            ║
╠═══════════════════════════════════════════════════════════════════════════════╣
║  NODO 1                              │  NODO 2                                ║
║  ┌────────────────────────────────┐  │  ┌──────────────────────────────────┐  ║
║  │ kubelet                        │  │  │ kubelet                          │  ║
║  │  El agente. Pregunta al        │  │  │                                  │  ║
║  │  apiserver «¿qué pods me       │  │  │                                  │  ║
║  │  tocan?», arranca contenedores │  │  │                                  │  ║
║  │  vía CRI, ejecuta las PROBES   │  │  │                                  │  ║
║  │  y reporta el estado.          │  │  │                                  │  ║
║  ├────────────────────────────────┤  │  ├──────────────────────────────────┤  ║
║  │ containerd (CRI)               │  │  │ containerd                       │  ║
║  │  Descarga imágenes y ejecuta   │  │  │                                  │  ║
║  │  contenedores (runc).          │  │  │                                  │  ║
║  ├────────────────────────────────┤  │  ├──────────────────────────────────┤  ║
║  │ kube-proxy (o eBPF)            │  │  │ kube-proxy                       │  ║
║  │  Programa iptables/IPVS para   │  │  │                                  │  ║
║  │  que la IP del Service llegue  │  │  │                                  │  ║
║  │  a un pod sano. NO es un       │  │  │                                  │  ║
║  │  proxy en el camino de datos.  │  │  │                                  │  ║
║  ├────────────────────────────────┤  │  ├──────────────────────────────────┤  ║
║  │ CNI (Calico, Cilium, VPC CNI)  │  │  │ CNI                              │  ║
║  │  Da una IP a cada pod y hace   │  │  │                                  │  ║
║  │  que todos se vean entre sí    │  │  │                                  │  ║
║  │  sin NAT. Aplica NetworkPolicy.│  │  │                                  │  ║
║  ├────────────────────────────────┤  │  ├──────────────────────────────────┤  ║
║  │  [pod pedidos-a] [pod pagos-x] │  │  │  [pod pedidos-b] [pod db-0]      │  ║
║  └────────────────────────────────┘  │  └──────────────────────────────────┘  ║
╚═══════════════════════════════════════════════════════════════════════════════╝
ComponenteSi se cae…Te afecta como desarrollador porque…
kube-apiserverNo puedes desplegar ni consultar nada; lo que corre sigue corriendo.Tus kubectl fallan con timeout. Los pods no se recrean si mueren.
etcdIgual que el apiserver, y es irrecuperable sin copia de seguridad.Es el argumento de por qué el estado importante va en una base de datos, no en el clúster.
kube-schedulerLos pods nuevos se quedan en Pending para siempre.Si ves Pending sin eventos de FailedScheduling, sospecha del scheduler.
controller-managerNo se crean ReplicaSets, ni Jobs, ni se actualizan endpoints.Un despliegue se queda a medias sin explicación.
kubelet de un nodoEl nodo pasa a NotReady y sus pods se recrean en otro (tras ~5 min).Explica los reinicios «espontáneos» de tus pods.
kube-proxy / CNILa red del nodo deja de funcionar: los Services no llegan.Errores de conexión intermitentes que parecen bugs de tu código.
CoreDNSLa resolución de nombres falla en todo el clúster.UnknownHostException en masa. El primer sospechoso de un incidente raro de red.

7.3 Estado deseado y bucles de reconciliación

Este es el concepto. Todo objeto de Kubernetes tiene dos partes:

Y hay un controlador para cada tipo que ejecuta, para siempre, este bucle: leer spec, observar el mundo, calcular la diferencia, actuar para reducirla, actualizar status. Repetir.

Tú:  kubectl apply -f deployment.yaml  (replicas: 3, image: pedidos:1.4.2)
                        │
                        ▼
      apiserver valida, aplica admisión y GUARDA en etcd. Y aquí acaba tu parte:
      el apiserver NO arranca nada. Solo ha registrado un deseo.

      ─── A partir de aquí, todo es asíncrono y en bucle ───

  [controlador de Deployment]   spec dice 1.4.2, status dice 1.4.1
                                ⇒ crea un ReplicaSet nuevo para 1.4.2
                                ⇒ va subiendo su tamaño y bajando el del viejo,
                                   respetando maxSurge y maxUnavailable

  [controlador de ReplicaSet]   spec dice 3 pods, veo 2
                                ⇒ crea 1 Pod (sin nodo asignado)

  [scheduler]                   veo un Pod con spec.nodeName vacío
                                ⇒ filtra nodos (¿caben las requests? ¿taints?
                                   ¿afinidad? ¿topología?) y puntúa
                                ⇒ escribe spec.nodeName = nodo-2

  [kubelet del nodo-2]          veo un Pod asignado a mí
                                ⇒ pide la imagen a containerd, monta volúmenes,
                                   arranca contenedores, ejecuta las probes
                                ⇒ actualiza status.phase y containerStatuses

  [controlador de endpoints]    el Pod pasa readiness y sus etiquetas encajan
                                con el selector de un Service
                                ⇒ añade su IP a EndpointSlice

  [kube-proxy en todos los nodos] EndpointSlice cambió
                                ⇒ reprograma iptables/IPVS: la IP del Service
                                   ya reparte tráfico al pod nuevo

  ▲ CONSECUENCIAS PRÁCTICAS DE ESTE DISEÑO:
  1. `kubectl apply` que devuelve «configured» NO significa «desplegado».
     Significa «anotado». Para saber si ha funcionado: kubectl rollout status.
  2. Si borras un pod a mano, VUELVE. Su dueño (el ReplicaSet) lo recrea.
     Para que no vuelva, cambia el spec del dueño.
  3. Un cambio manual (kubectl edit en el pod) se DESHACE o se pierde al recrear.
  4. Si algo no ocurre, la respuesta está en los EVENTOS y en los logs del
     controlador correspondiente. Nunca en «reiniciar y a ver».
Por qué esto hace que GitOps sea la conclusión natural. Si el clúster ya funciona reconciliando un estado deseado contra la realidad, el siguiente paso lógico es que ese estado deseado viva en Git y que un agente lo reconcilie continuamente. Argo CD y Flux no son una herramienta más: son la extensión del modelo de Kubernetes un nivel más arriba. Sección 10.

7.4 Objetos y API declarativa

# La estructura que comparten TODOS los objetos de Kubernetes
apiVersion: apps/v1        # grupo/versión de la API. "v1" (sin grupo) = grupo core
kind: Deployment           # el tipo
metadata:
  name: pedidos            # único dentro del namespace y del tipo
  namespace: produccion
  labels:                  # ★ para SELECCIONAR y agrupar. Se consultan.
    app.kubernetes.io/name: pedidos
    app.kubernetes.io/version: "1.4.2"
  annotations:             # ★ para METADATOS y para configurar herramientas.
    kubernetes.io/change-cause: "Subida a 1.4.2 (PR #412)"
    prometheus.io/scrape: "true"
spec:                      # LO QUE QUIERES (lo escribes tú)
  replicas: 3
status:                    # LO QUE HAY (lo escribe el sistema; no lo toques)
  readyReplicas: 3
  observedGeneration: 7
# Descubrir la API sin buscar en Google: kubectl es autodocumentado
kubectl api-resources                       # todos los tipos, su alias y su grupo
kubectl api-resources --namespaced=false    # los que son de ámbito de clúster
kubectl api-versions

kubectl explain deployment                              # descripción del tipo
kubectl explain deployment.spec.strategy                # un campo concreto
kubectl explain deployment.spec.template.spec.containers.resources --recursive
kubectl explain pod.spec.containers.livenessProbe

# Ver el objeto REAL tal y como lo guarda el apiserver (con los valores por
# defecto rellenados). Es la mejor forma de aprender qué existe.
kubectl get deploy pedidos -o yaml
kubectl get deploy pedidos -o yaml --show-managed-fields   # quién cambió qué (SSA)

# Generar YAML de partida sin escribirlo a mano
kubectl create deployment pedidos --image=ghcr.io/ejemplo/pedidos:1.4.2 \
  --replicas=3 --dry-run=client -o yaml > deployment.yaml
kubectl create configmap pedidos-config --from-file=application.yml \
  --dry-run=client -o yaml > configmap.yaml
kubectl expose deployment pedidos --port=80 --target-port=8080 \
  --dry-run=client -o yaml > service.yaml
EnfoqueComandoCuándoProblema
Imperativo kubectl create deployment …, kubectl scale, kubectl set image Aprender, experimentar, actuar en una emergencia. No queda registro de qué se hizo ni por qué. Irreproducible.
Declarativo kubectl apply -f / -k Siempre en cualquier entorno que no sea tu juguete. Requiere disciplina: si alguien hace un cambio imperativo, aparece deriva.
GitOps Un commit; el agente aplica Producción, equipos, auditoría. Una pieza más de infraestructura que mantener.
apply frente a create y replace. create falla si el objeto ya existe. replace lo sustituye entero, perdiendo los campos que otros gestionan. apply usa Server-Side Apply: cada «gestor de campos» (tú, el HPA, un webhook) es dueño de los campos que declara, y el apiserver fusiona. Por eso puedes tener un HPA controlando replicas y seguir haciendo apply del resto del Deployment sin pelearse… siempre que quites replicas de tu manifiesto. Si lo dejas, cada apply revierte el escalado del HPA, que volverá a escalar, en un bucle absurdo que sí ocurre en la vida real.

7.5 El kubectl que necesitas de verdad

# ─── CONFIGURACIÓN Y CONTEXTO (empieza siempre por aquí) ─────────────────────
kubectl config get-contexts
kubectl config current-context           # ★ MÍRALO ANTES DE CADA CAMBIO
kubectl config use-context prod-eu
kubectl config set-context --current --namespace=produccion

# Instala kubectx y kubens: cambiar de contexto y de namespace en un segundo.
# Y pon el contexto en tu prompt (starship, kube-ps1). Ha salvado producciones.

# ─── VER (el 80% de tu tiempo) ───────────────────────────────────────────────
kubectl get pods                                   # el namespace actual
kubectl get pods -A                                # todos los namespaces
kubectl get pods -o wide                           # + nodo, IP, contenedores listos
kubectl get pods -w                                # en vivo
kubectl get pods -l app=pedidos                    # por etiqueta
kubectl get pods --field-selector status.phase=Running
kubectl get pods --sort-by=.status.containerStatuses[0].restartCount
kubectl get all -l app=pedidos                     # todo lo relacionado
kubectl get deploy,svc,ing,hpa,cm,secret -o wide

# Salidas personalizadas: mucho más útiles que -o yaml para buscar algo concreto
kubectl get pods -o custom-columns=\
'NOMBRE:.metadata.name,NODO:.spec.nodeName,IMAGEN:.spec.containers[0].image,\
REINICIOS:.status.containerStatuses[0].restartCount,QOS:.status.qosClass'

kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[*].image}{"\n"}{end}'

# ─── DIAGNOSTICAR (el orden importa) ─────────────────────────────────────────
kubectl describe pod pedidos-7c9f-xyz              # ① SIEMPRE EL PRIMERO.
#   Al final están los EVENTS: por qué no arranca, por qué no se programa,
#   por qué falla la probe, si se lo cargó el OOM killer.
kubectl logs pedidos-7c9f-xyz                      # ② los logs
kubectl logs pedidos-7c9f-xyz --previous           # ③ ★ del contenedor que MURIÓ
kubectl logs -f deploy/pedidos --all-containers --tail=100
kubectl logs -l app=pedidos --prefix --tail=50     # de todas las réplicas
kubectl logs pedidos-7c9f-xyz --since=15m --timestamps

kubectl get events --sort-by=.lastTimestamp -A | tail -30
kubectl events --for pod/pedidos-7c9f-xyz          # sintaxis nueva (1.28+)

kubectl top pods --containers                      # consumo real (requiere metrics-server)
kubectl top nodes

# ─── ENTRAR Y PROBAR ─────────────────────────────────────────────────────────
kubectl exec -it pedidos-7c9f-xyz -- sh
kubectl exec pedidos-7c9f-xyz -- env | sort         # ★ ver la config efectiva
kubectl exec pedidos-7c9f-xyz -- cat /config/application.yml
kubectl port-forward svc/pedidos 8080:80            # túnel a tu portátil
kubectl port-forward pedidos-7c9f-xyz 8081:8081     # Actuator sin exponerlo

# Un pod desechable con herramientas de red, en la misma red del clúster
kubectl run tmp --rm -it --image=nicolaka/netshoot --restart=Never -- bash
#   dig pedidos.produccion.svc.cluster.local
#   curl -v http://pedidos/actuator/health
#   nc -zv postgres-rw 5432

# Depurar un contenedor sin shell (distroless): contenedor efímero
kubectl debug -it pedidos-7c9f-xyz --image=busybox:1.36 --target=app
# Copia de un pod con el entrypoint cambiado, para depurar un CrashLoop
kubectl debug pedidos-7c9f-xyz -it --copy-to=depurar --container=app -- sh

# ─── CAMBIAR ─────────────────────────────────────────────────────────────────
kubectl apply -f k8s/                               # declarativo, recursivo con -R
kubectl apply -k overlays/produccion                # con Kustomize
kubectl diff -f k8s/                                # ★ QUÉ CAMBIARÍA. Úsalo siempre.
kubectl rollout status deploy/pedidos --timeout=5m  # ★ espera y falla si no sale bien
kubectl rollout history deploy/pedidos
kubectl rollout undo deploy/pedidos                 # a la revisión anterior
kubectl rollout undo deploy/pedidos --to-revision=3
kubectl rollout restart deploy/pedidos              # recrea los pods (releer Secrets)
kubectl scale deploy/pedidos --replicas=5
kubectl set image deploy/pedidos app=ghcr.io/ejemplo/pedidos@sha256:9f8e…
kubectl annotate deploy/pedidos kubernetes.io/change-cause="hotfix #418"
kubectl delete -f k8s/ --wait=true

# ─── DIAGNÓSTICO DE RED Y SERVICIOS ──────────────────────────────────────────
kubectl get endpointslices -l kubernetes.io/service-name=pedidos
#   ★ SI ESTÁ VACÍO, el Service no tiene destinos: readiness falla o el
#     selector no coincide. Es la causa nº 1 de los 503 del ingress.
kubectl get svc pedidos -o yaml | grep -A5 selector
kubectl get pods -l app=pedidos --show-labels

# ─── NODOS Y MANTENIMIENTO ───────────────────────────────────────────────────
kubectl get nodes -o wide
kubectl describe node nodo-2 | sed -n '/Allocated resources/,/Events/p'
kubectl cordon nodo-2                               # no programar más pods aquí
kubectl drain nodo-2 --ignore-daemonsets --delete-emptydir-data
kubectl uncordon nodo-2

# ─── CALIDAD DE VIDA ─────────────────────────────────────────────────────────
alias k=kubectl
source <(kubectl completion zsh)
export KUBE_EDITOR="code --wait"
# Plugins con krew: ctx, ns, tree, neat, stern, view-secret, resource-capacity
kubectl krew install ctx ns tree neat stern view-secret resource-capacity
kubectl tree deployment pedidos      # árbol de propiedad: Deploy→RS→Pods
kubectl neat get pod X -o yaml       # YAML limpio, sin los campos generados
stern pedidos                        # logs de TODAS las réplicas, con color
kubectl view-secret pedidos-secret   # sin pelearte con base64
SíntomaComando exacto, en este orden
«No arranca»describe pod (eventos) → logs --previousget events
«Está arrancado pero no responde»get endpointslicesdescribe pod (readiness) → port-forward y probar directo
«Va lento»top podscpu.stat (throttling) → métricas de la app → jcmd Thread.print
«Se reinicia solo»describe podLast State y Reason → si es OOMKilled, sección 5.3
«El despliegue no termina»rollout statusget rsdescribe del RS nuevo
«No encuentro por qué se aplicó este cambio»rollout historyget deploy -o yaml --show-managed-fields

7.6 Namespaces, etiquetas, selectores y anotaciones

Estos cuatro mecanismos son la forma en que Kubernetes organiza y relaciona objetos. Usarlos bien es la diferencia entre un clúster navegable y una sopa de nombres.

Namespaces: agrupación con aislamiento parcial

Qué aísla un namespaceQué NO aísla
Nombres de objetos (puede haber un pedidos en cada uno)La red: por defecto cualquier pod habla con cualquier pod de cualquier namespace. Hace falta NetworkPolicy.
Ámbito de RBAC (permisos por namespace con Role)Los nodos y su CPU/memoria: se comparten físicamente.
Cuotas de recursos (ResourceQuota, LimitRange)Objetos de ámbito de clúster: Node, PersistentVolume, StorageClass, ClusterRole, CRDs.
Referencias: un Secret solo se puede montar desde su namespaceEl DNS: se puede resolver svc.otro-namespace.svc.cluster.local sin restricción.
apiVersion: v1
kind: Namespace
metadata:
  name: produccion
  labels:
    # Estas etiquetas activan los Pod Security Standards por namespace: el
    # apiserver RECHAZA pods que corran como root o pidan privilegios.
    # Es la protección de línea base más rentable que puedes activar.
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.31
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted
    entorno: produccion

Etiquetas y selectores: el pegamento del clúster

Las etiquetas no son decoración: son funcionales. Un Service encuentra sus pods por etiqueta; un Deployment posee sus pods por etiqueta; un NetworkPolicy permite tráfico por etiqueta. Cambiar una etiqueta cambia el comportamiento del clúster.

# Las etiquetas RECOMENDADAS por la comunidad (app.kubernetes.io/*): úsalas.
# Herramientas como Helm, Argo CD, Grafana y los paneles de las nubes las entienden.
metadata:
  labels:
    app.kubernetes.io/name: pedidos          # el componente
    app.kubernetes.io/instance: pedidos-prod # esta instalación concreta
    app.kubernetes.io/version: "1.4.2"       # ★ NO la pongas en el selector
    app.kubernetes.io/component: api         # api | worker | migracion
    app.kubernetes.io/part-of: ventas        # el sistema al que pertenece
    app.kubernetes.io/managed-by: argocd
    equipo: pedidos                          # ★ propias: para coste y para avisar
    coste-centro: "CC-4711"
# Selectores basados en igualdad
kubectl get pods -l app.kubernetes.io/name=pedidos
kubectl get pods -l 'app.kubernetes.io/name=pedidos,app.kubernetes.io/component=api'
kubectl get pods -l 'app.kubernetes.io/name!=pedidos'

# Selectores basados en conjuntos (más potentes)
kubectl get pods -l 'entorno in (staging,produccion)'
kubectl get pods -l 'app.kubernetes.io/name notin (pedidos)'
kubectl get pods -l 'equipo'          # que TENGA la etiqueta, con cualquier valor
kubectl get pods -l '!equipo'         # que NO la tenga  ← auditoría: pods sin dueño

# Uso operativo: borrar todo lo de una versión, o sacar un pod de rotación
kubectl delete pods -l 'app.kubernetes.io/version=1.4.1'
kubectl label pod pedidos-7c9f-xyz app.kubernetes.io/name=cuarentena --overwrite
El selector de un Deployment es inmutable, y esto muerde de verdad. Una vez creado, no puedes cambiar spec.selector.matchLabels: el apply falla con «field is immutable» y la única salida es borrar y recrear el Deployment, con caída de servicio. Por eso el selector debe contener solo etiquetas de identidad estable (app.kubernetes.io/name y instance) y nunca la versión, el commit ni el entorno. La versión va en las etiquetas de la plantilla del pod, que sí pueden cambiar.

Anotaciones: metadatos y configuración de herramientas

AnotaciónQuién la leePara qué
kubernetes.io/change-causekubectl rollout historyExplicar por qué se hizo un despliegue. Ponla siempre, con el número del PR.
prometheus.io/scrape, /port, /pathPrometheus (con descubrimiento por anotaciones)Que se recojan tus métricas sin tocar la configuración de Prometheus.
eks.amazonaws.com/role-arnEl webhook de IRSA en EKSDar permisos de AWS a la ServiceAccount sin claves. Sección 12.
nginx.ingress.kubernetes.io/*El controlador de ingressTimeouts, tamaño de cuerpo, reescritura de rutas, límites de tasa.
checksum/configNada: es un trucoUn hash del ConfigMap dentro de la plantilla del pod: al cambiar la configuración cambia el hash, cambia la plantilla y se reinician los pods. Es el patrón estándar para recargar configuración.
argocd.argoproj.io/sync-waveArgo CDOrdenar la aplicación de recursos (primero migraciones, después la app).
kubectl.kubernetes.io/last-applied-configurationkubectl apply (modo cliente)Cómo apply sabía qué campos gestionaba antes de Server-Side Apply.
La regla para decidir entre etiqueta y anotación: si vas a buscar o seleccionar por ese dato, es una etiqueta (y tiene límites: 63 caracteres, alfanumérico). Si es información descriptiva o configuración para una herramienta, es una anotación (admite cualquier texto, incluido JSON multilínea). Meter un JSON en una etiqueta no funciona; meter el nombre del equipo solo en una anotación te impide filtrar por equipo, que es justo lo que vas a querer hacer el día que revises la factura.

8 · Los objetos que usarás cada día, con manifiestos completos

Kubernetes 1.31 tiene más de 60 tipos de objeto y decenas de CRDs por cada complemento. En el día a día de un desarrollador Java se usan unos quince. Esta sección los recorre con manifiestos que puedes copiar, explicando en cada uno qué campos importan y cuáles son puro ruido.

8.1 Pod: la unidad de ejecución (y por qué no lo creas a mano)

Un Pod es un grupo de uno o más contenedores que comparten namespace de red (la misma IP y los mismos puertos, se ven en localhost), namespace IPC y volúmenes. Es la unidad más pequeña que Kubernetes programa; no programa contenedores sueltos.

Un pod es efímero e irreparable: si su nodo muere, el pod no «se mueve», simplemente desaparece y alguien tiene que crear otro. Ese «alguien» es un controlador. Por eso nunca creas pods a mano salvo para depurar: un pod suelto no se recrea, no se actualiza, no se escala y no se cuenta en ningún PodDisruptionBudget.

# Un Pod «pelado», solo para entender la anatomía. En la práctica esto va DENTRO
# de la plantilla de un Deployment.
apiVersion: v1
kind: Pod
metadata:
  name: pedidos-manual
  labels:
    app.kubernetes.io/name: pedidos
spec:
  # ── Identidad y seguridad a nivel de POD (aplica a todos los contenedores) ──
  serviceAccountName: pedidos
  automountServiceAccountToken: false     # ★ si no llamas a la API de Kubernetes,
                                          #   no montes su token: menos superficie
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001                        # dueño de los volúmenes montados
    seccompProfile:
      type: RuntimeDefault                # obligatorio en el perfil «restricted»

  # ── Programación ────────────────────────────────────────────────────────────
  # nodeSelector / affinity / tolerations / topologySpreadConstraints: ver 8.17

  # ── Ciclo de vida ───────────────────────────────────────────────────────────
  restartPolicy: Always                   # Always | OnFailure | Never
  terminationGracePeriodSeconds: 45       # ver sección 9.4
  enableServiceLinks: false               # ★ evita decenas de variables de entorno
                                          #   heredadas de todos los Services

  containers:
    - name: app
      image: ghcr.io/ejemplo/pedidos@sha256:9f8e7d6c5b4a…   # por DIGEST
      imagePullPolicy: IfNotPresent       # con digest, nunca hace falta Always
      ports:
        - name: http
          containerPort: 8080
        - name: management
          containerPort: 8081
      env:
        - name: SPRING_PROFILES_ACTIVE
          value: produccion
        - name: POD_NAME                  # ★ útil en los logs para saber quién habla
          valueFrom:
            fieldRef: { fieldPath: metadata.name }
        - name: NODE_NAME
          valueFrom:
            fieldRef: { fieldPath: spec.nodeName }
        - name: MEMORY_LIMIT              # el límite del cgroup, como variable
          valueFrom:
            resourceFieldRef:
              containerName: app
              resource: limits.memory
      resources:
        requests: { cpu: "500m", memory: "1Gi" }
        limits:   { memory: "1Gi" }
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: tmp
          mountPath: /tmp
        - name: config
          mountPath: /config
          readOnly: true
  volumes:
    - name: tmp
      emptyDir: { sizeLimit: 256Mi }
    - name: config
      configMap: { name: pedidos-config }
Patrón de varios contenedoresPara quéEjemplo en un servicio Java
SidecarUn ayudante que corre junto a la aplicación durante toda su vida.Proxy de la malla (Envoy/Linkerd), Cloud SQL Auth Proxy, agente de recogida de logs.
AmbassadorUn proxy local que simplifica el acceso a un servicio externo.PgBouncer local en el pod para agrupar conexiones a PostgreSQL.
AdapterTraduce la salida de la aplicación a un formato estándar.Un exportador que convierte métricas propietarias a formato Prometheus.
Init containerCorre antes y hasta terminar. Bloquea el arranque de los demás.Esperar a que la base de datos responda, aplicar migraciones, descargar un fichero.

8.2 ReplicaSet: el controlador que no tocas

Un ReplicaSet garantiza que existan exactamente N pods que encajen con su selector. Es un bucle de tres líneas: cuento los pods que coinciden; si son menos de N, creo; si son más, borro. Su importancia práctica es que tú no lo creas nunca: lo crea el Deployment, uno por cada versión de la plantilla del pod. Pero sí lo miras al diagnosticar.

# Durante un despliegue verás DOS ReplicaSets: el viejo bajando, el nuevo subiendo
kubectl get rs -l app.kubernetes.io/name=pedidos
# NAME                 DESIRED  CURRENT  READY  AGE
# pedidos-7c9f4d8b5    0        0        0      6d     ← versión 1.4.1, ya vacío
# pedidos-8d4a1c2e9    3        3        3      4m     ← versión 1.4.2, activa

# ¿Por qué el despliegue está atascado? La respuesta está en el RS nuevo:
kubectl describe rs pedidos-8d4a1c2e9
#   Events: FailedCreate  ...  exceeded quota / forbidden: violates PodSecurity

# ¿Qué imagen tiene cada uno? Muy útil para saber a qué vuelves con un undo.
kubectl get rs -l app.kubernetes.io/name=pedidos \
  -o custom-columns='RS:.metadata.name,REPLICAS:.spec.replicas,IMAGEN:.spec.template.spec.containers[0].image'

# El historial de revisiones que conserva el Deployment
kubectl get deploy pedidos -o jsonpath='{.spec.revisionHistoryLimit}'   # por defecto 10
# Bájalo a 3-5 en clústeres con muchos servicios: cada RS viejo es un objeto en etcd.

8.3 Deployment: el objeto que despliegas de verdad

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pedidos
  namespace: produccion
  labels:
    app.kubernetes.io/name: pedidos
    app.kubernetes.io/instance: pedidos-prod
    app.kubernetes.io/version: "1.4.2"
    app.kubernetes.io/component: api
    app.kubernetes.io/part-of: ventas
  annotations:
    kubernetes.io/change-cause: "Subida a 1.4.2 · PR #412 · corrige el cálculo de IVA"
spec:
  # replicas: NO lo pongas si tienes un HPA (ver el aviso de 7.4)
  replicas: 3

  # ★ INMUTABLE. Solo etiquetas de identidad estable. Nunca la versión.
  selector:
    matchLabels:
      app.kubernetes.io/name: pedidos
      app.kubernetes.io/instance: pedidos-prod

  revisionHistoryLimit: 5
  progressDeadlineSeconds: 600      # si en 10 min no progresa, marca el rollout
                                    # como fallido (y GitOps/CI puede reaccionar)
  minReadySeconds: 10               # ★ un pod cuenta como disponible solo si lleva
                                    #   10 s listo. Evita avanzar el rollout con
                                    #   pods que pasan readiness y luego se caen.

  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1                   # cuántos pods EXTRA se permiten (o "25%")
      maxUnavailable: 0             # ★ CERO: nunca bajar de las réplicas deseadas.
                                    #   Con 0, el rollout crea uno nuevo, espera a
                                    #   que esté LISTO y solo entonces mata uno viejo.
                                    #   Es más lento y es lo que quieres en producción.
  template:
    metadata:
      labels:
        app.kubernetes.io/name: pedidos
        app.kubernetes.io/instance: pedidos-prod
        app.kubernetes.io/version: "1.4.2"     # aquí SÍ: la plantilla puede cambiar
      annotations:
        # ★ EL TRUCO DE LA RECARGA DE CONFIGURACIÓN: si cambia el ConfigMap cambia
        #   este hash, cambia la plantilla y el Deployment recrea los pods.
        #   Sin esto, un cambio de ConfigMap no reinicia nada y no se aplica.
        checksum/config: "b3a91f7e2c4d8a6f0b5e3c1d9a7f2e4b"
        prometheus.io/scrape: "true"
        prometheus.io/port: "8081"
        prometheus.io/path: "/actuator/prometheus"
    spec:
      serviceAccountName: pedidos
      automountServiceAccountToken: false
      enableServiceLinks: false
      terminationGracePeriodSeconds: 45
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        seccompProfile: { type: RuntimeDefault }

      # Reparto entre zonas y nodos: ver 8.17
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: pedidos
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: pedidos

      # Espera a que la base de datos responda antes de arrancar la JVM: así el
      # arranque no falla por una dependencia que tarda 5 s más en estar lista.
      initContainers:
        - name: esperar-bd
          image: ghcr.io/ejemplo/pedidos@sha256:9f8e7d6c5b4a…
          command:
            - sh
            - -c
            - |
              for i in $(seq 1 60); do
                nc -z "$DB_HOST" 5432 && echo "BD lista" && exit 0
                echo "esperando la base de datos ($i/60)…"; sleep 2
              done
              echo "la base de datos no responde"; exit 1
          env:
            - name: DB_HOST
              valueFrom:
                configMapKeyRef: { name: pedidos-config, key: DB_HOST }
          resources:
            requests: { cpu: "50m", memory: "64Mi" }
            limits:   { memory: "64Mi" }
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }

      containers:
        - name: app
          image: ghcr.io/ejemplo/pedidos@sha256:9f8e7d6c5b4a…
          imagePullPolicy: IfNotPresent
          ports:
            - { name: http, containerPort: 8080, protocol: TCP }
            - { name: management, containerPort: 8081, protocol: TCP }

          envFrom:
            - configMapRef: { name: pedidos-config }
            - secretRef:    { name: pedidos-secret }
          env:
            - name: JAVA_TOOL_OPTIONS
              value: >-
                -XX:MaxRAMPercentage=70
                -XX:InitialRAMPercentage=70
                -XX:MaxMetaspaceSize=256m
                -XX:MaxDirectMemorySize=128m
                -XX:ActiveProcessorCount=2
                -XX:+ExitOnOutOfMemoryError
                -XX:+HeapDumpOnOutOfMemoryError
                -XX:HeapDumpPath=/dumps/heap.hprof
                -XX:NativeMemoryTracking=summary
                -Xlog:gc*:file=/dumps/gc.log:time,uptime:filecount=3,filesize=20M
            - name: POD_NAME
              valueFrom: { fieldRef: { fieldPath: metadata.name } }
            - name: SPRING_CONFIG_IMPORT
              value: "optional:configtree:/secretos/"

          resources:
            requests: { cpu: "500m", memory: "1Gi" }
            limits:   { memory: "1Gi" }        # sin límite de CPU (sección 5.5)

          # Las tres probes: sección 9.3
          startupProbe:
            httpGet: { path: /actuator/health/liveness, port: management }
            periodSeconds: 2
            failureThreshold: 60          # hasta 120 s para arrancar
          livenessProbe:
            httpGet: { path: /actuator/health/liveness, port: management }
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 3
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: management }
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 2
            successThreshold: 1

          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 8"]   # sección 9.4: drenaje del Service

          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }

          volumeMounts:
            - { name: tmp,      mountPath: /tmp }
            - { name: dumps,    mountPath: /dumps }
            - { name: config,   mountPath: /config,    readOnly: true }
            - { name: secretos, mountPath: /secretos,  readOnly: true }

      volumes:
        - name: tmp
          emptyDir: { sizeLimit: 256Mi }
        - name: dumps
          emptyDir: { sizeLimit: 2Gi }        # medium vacío = disco, no RAM
        - name: config
          configMap: { name: pedidos-config }
        - name: secretos
          secret:
            secretName: pedidos-secret
            defaultMode: 0400
CombinaciónComportamientoCuándo
maxSurge: 1, maxUnavailable: 0 Añade uno, espera a que esté listo, mata uno. Nunca hay menos capacidad de la pedida. Producción. Requiere cuota para una réplica extra.
maxSurge: 25%, maxUnavailable: 25% (por defecto) Más rápido, pero puede bajar al 75% de capacidad durante el despliegue. Entornos no críticos. En producción con 3 réplicas significa quedarte con 2.
maxSurge: 100%, maxUnavailable: 0 Crea el conjunto completo nuevo y luego elimina el viejo: un blue-green pobre. Despliegues rápidos con capacidad de sobra. Cuidado con el pool de conexiones a la BD: se duplica.
type: Recreate Mata todo y luego crea. Hay caída. Cuando dos versiones NO pueden coexistir: migración incompatible, bloqueo exclusivo, licencia de un solo uso.

8.4 Service y el DNS interno

Las IPs de los pods cambian constantemente. Un Service es una IP virtual estable y un nombre DNS que reparten tráfico entre los pods que estén listos y encajen con su selector. Que estén listos es la clave: un pod que no pasa readiness no recibe tráfico.

TipoQué creaAccesible desdeCuándo usarlo
ClusterIP (por defecto) Una IP virtual interna del clúster. Solo desde dentro del clúster. Casi siempre. Comunicación entre servicios. El tráfico externo entra por Ingress.
NodePort Abre el mismo puerto (30000–32767) en todos los nodos. Desde fuera, apuntando a cualquier nodo. Casi nunca directamente. Es el mecanismo sobre el que se construye LoadBalancer.
LoadBalancer Un balanceador real y facturable de la nube (ALB, Azure LB, GCLB). Internet. Uno por clúster, para el controlador de ingress. No uno por servicio: son 20 €/mes cada uno.
Headless (clusterIP: None) Sin IP virtual: el DNS devuelve las IPs de todos los pods. Dentro del clúster. StatefulSets (identidad por pod), balanceo en el cliente, gRPC (que necesita ver todos los destinos).
ExternalName Un CNAME del DNS a un nombre externo. Dentro. Dar un nombre interno estable a una base de datos gestionada fuera del clúster.
apiVersion: v1
kind: Service
metadata:
  name: pedidos
  namespace: produccion
  labels:
    app.kubernetes.io/name: pedidos
spec:
  type: ClusterIP
  selector:                          # ★ debe coincidir con las ETIQUETAS DEL POD
    app.kubernetes.io/name: pedidos  #   (no con el selector del Deployment,
    app.kubernetes.io/instance: pedidos-prod   # aunque en la práctica sean iguales)
  ports:
    - name: http
      port: 80                       # el puerto del Service
      targetPort: http               # ★ el NOMBRE del puerto del contenedor:
      protocol: TCP                  #   si cambia el número, no tocas el Service
  # Envía siempre al mismo pod según la IP de origen (sesiones «pegajosas» pobres)
  sessionAffinity: None
  # Con esto, el DNS del Service devuelve el pod incluso si no está listo.
  # Necesario en StatefulSets para que los miembros se encuentren al arrancar.
  publishNotReadyAddresses: false
  # Prefiere pods del mismo nodo/zona: ahorra tráfico entre zonas (que se paga)
  trafficDistribution: PreferClose   # 1.31+ (antes: topologyKeys / hints)
---
# Servicio SOLO para las métricas: así el ingress no puede llegar a Actuator
apiVersion: v1
kind: Service
metadata:
  name: pedidos-metrics
  labels:
    app.kubernetes.io/name: pedidos
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: pedidos
  ports:
    - { name: management, port: 8081, targetPort: management }
---
# Headless: para descubrir todas las réplicas (gRPC, StatefulSet)
apiVersion: v1
kind: Service
metadata:
  name: pedidos-headless
spec:
  clusterIP: None
  selector:
    app.kubernetes.io/name: pedidos
  ports:
    - { name: http, port: 8080 }
---
# ExternalName: nombre interno estable para una base de datos gestionada
apiVersion: v1
kind: Service
metadata:
  name: postgres
  namespace: produccion
spec:
  type: ExternalName
  externalName: pedidos-prod.abc123.eu-west-1.rds.amazonaws.com
# Ventaja: la aplicación se conecta a «postgres:5432» en TODOS los entornos.
# En local ese nombre lo resuelve Compose; en el clúster, este ExternalName.
═══ EL DNS INTERNO: LO QUE HAY QUE SABERSE DE MEMORIA ═══════════════════════════

Nombre completo de un Service:
    <servicio>.<namespace>.svc.cluster.local

Desde un pod del namespace «produccion», estas cuatro formas resuelven igual:
    pedidos                                    ← lo normal dentro del namespace
    pedidos.produccion
    pedidos.produccion.svc
    pedidos.produccion.svc.cluster.local       ← siempre funciona, desde cualquier sitio

Desde otro namespace hay que cualificar:
    pedidos.produccion       (desde el namespace «pagos»)

Service headless: el DNS devuelve TODAS las IPs de los pods (registros A múltiples)
    pedidos-headless.produccion.svc.cluster.local → 10.1.2.3, 10.1.2.7, 10.1.4.9

Pods de un StatefulSet: cada uno tiene su propio nombre ESTABLE
    postgres-0.postgres-headless.produccion.svc.cluster.local
    postgres-1.postgres-headless.produccion.svc.cluster.local

Registros SRV (puerto incluido), que usa alguna librería de descubrimiento:
    _http._tcp.pedidos.produccion.svc.cluster.local

─── LA TRAMPA DE ndots:5 (y por qué tus DNS son lentos) ─────────────────────────
/etc/resolv.conf de un pod:
    search produccion.svc.cluster.local svc.cluster.local cluster.local
    options ndots:5

«ndots:5» significa: si el nombre tiene MENOS de 5 puntos, prueba primero con
todos los sufijos de «search» antes de intentarlo como nombre absoluto.

Resolver «api.stripe.com» (2 puntos) genera:
    api.stripe.com.produccion.svc.cluster.local   → NXDOMAIN
    api.stripe.com.svc.cluster.local              → NXDOMAIN
    api.stripe.com.cluster.local                  → NXDOMAIN
    api.stripe.com                                → ✓  (a la cuarta)
Y por cada una, consultas A y AAAA: 8 consultas para resolver un nombre.

Síntomas: latencia extra de 10-50 ms en cada llamada externa, y saturación de
CoreDNS con miles de NXDOMAIN.

SOLUCIONES:
  1) Punto final en las URLs externas: «https://api.stripe.com./v1/charges»
     (el punto lo convierte en FQDN absoluto: una sola consulta)
  2) dnsConfig en el pod: options ndots:2
  3) NodeLocal DNSCache en el clúster (caché de DNS en cada nodo)
  4) En Java, activar la caché de DNS de la JVM:
     networkaddress.cache.ttl=30    (por defecto 30 s con security manager,
                                     ¡pero -1 = para siempre en algunos setups!)
# Reducir ndots en el pod: mejora medible en servicios que llaman mucho fuera
spec:
  dnsPolicy: ClusterFirst
  dnsConfig:
    options:
      - { name: ndots, value: "2" }
      - { name: timeout, value: "2" }
      - { name: attempts, value: "2" }
      - { name: single-request-reopen }   # workaround de una carrera de glibc

8.5 Ingress y controladores de ingress

Un Ingress es una regla de enrutado HTTP de capa 7: «el host api.ejemplo.com con la ruta /pedidos va al Service pedidos». Por sí solo no hace nada: es un objeto declarativo que necesita un controlador de ingress instalado en el clúster (nginx, Traefik, HAProxy, o el nativo de la nube) que lo lea y configure un proxy real.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: pedidos
  namespace: produccion
  annotations:
    # cert-manager pide y renueva el certificado de Let's Encrypt solo
    cert-manager.io/cluster-issuer: letsencrypt-prod
    # ★ Los timeouts: una petición Java lenta cortada a los 60 s por defecto es
    #   la causa de muchos 504 misteriosos.
    nginx.ingress.kubernetes.io/proxy-connect-timeout: "5"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
    # Límite de tasa como primera línea de defensa
    nginx.ingress.kubernetes.io/limit-rps: "100"
    nginx.ingress.kubernetes.io/limit-burst-multiplier: "3"
    # Cabeceras de seguridad y HSTS
    nginx.ingress.kubernetes.io/configuration-snippet: |
      more_set_headers "X-Content-Type-Options: nosniff";
      more_set_headers "Referrer-Policy: strict-origin-when-cross-origin";
spec:
  ingressClassName: nginx
  tls:
    - hosts: [api.ejemplo.com]
      secretName: api-ejemplo-tls      # lo crea cert-manager
  rules:
    - host: api.ejemplo.com
      http:
        paths:
          - path: /pedidos
            pathType: Prefix           # Prefix | Exact | ImplementationSpecific
            backend:
              service:
                name: pedidos
                port: { name: http }
          - path: /pagos
            pathType: Prefix
            backend:
              service:
                name: pagos
                port: { name: http }
# ★ FÍJATE: no hay ninguna regla que apunte al puerto 8081. Actuator queda
#   inalcanzable desde internet por construcción, no por configuración de
#   seguridad que alguien pueda desactivar por error.
ControladorPuntos fuertesA tener en cuenta
ingress-nginxEl más usado, muy documentado, enorme cantidad de anotaciones.Recarga la configuración de nginx al cambiar (breve corte de conexiones nuevas en clústeres con muchos ingress).
TraefikConfiguración dinámica sin recargas, buen soporte de Gateway API, panel web.Sus CRDs propios (IngressRoute) atan más.
AWS Load Balancer ControllerCrea ALB/NLB reales: WAF, certificados de ACM, integración con IAM.Cada Ingress puede crear un ALB facturable. Usa group.name para compartir uno.
Gateway API (Envoy Gateway, Istio, Cilium)El sucesor estándar: reparto de tráfico por peso (canary sin trucos), separación de roles.Ecosistema más joven. Es a donde va todo. Ver 8.6.

8.6 Gateway API: el sucesor de Ingress

Ingress tiene dos problemas de diseño que no se pueden arreglar: casi toda su funcionalidad real vive en anotaciones propietarias (así que no es portable), y mezcla en un solo objeto responsabilidades de tres roles distintos (quien gestiona la infraestructura, quien gestiona el clúster y quien desarrolla la aplicación). Gateway API, estable desde 2023, separa esos roles en objetos distintos.

# ── ROL 1: administrador de infraestructura (una vez por clúster) ────────────
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: publico
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
# ── ROL 2: operador del clúster (define los puntos de entrada y el TLS) ──────
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: entrada-publica
  namespace: infra
spec:
  gatewayClassName: publico
  listeners:
    - name: https
      protocol: HTTPS
      port: 443
      hostname: "*.ejemplo.com"
      tls:
        mode: Terminate
        certificateRefs:
          - { kind: Secret, name: comodin-ejemplo-tls }
      allowedRoutes:
        namespaces:
          from: Selector        # ★ solo los namespaces etiquetados pueden usarlo
          selector:
            matchLabels: { gateway-publico: "si" }
---
# ── ROL 3: desarrollador (en SU namespace, sin tocar la infraestructura) ─────
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: pedidos
  namespace: produccion
spec:
  parentRefs:
    - { name: entrada-publica, namespace: infra, kind: Gateway }
  hostnames: ["api.ejemplo.com"]
  rules:
    # ── CANARY NATIVO POR PESO: esto es lo que Ingress no puede hacer ─────────
    - matches:
        - path: { type: PathPrefix, value: /pedidos }
      backendRefs:
        - { name: pedidos-estable, port: 80, weight: 95 }
        - { name: pedidos-canary,  port: 80, weight: 5 }
      timeouts:
        request: 30s
        backendRequest: 25s
      retry:                       # reintentos declarativos (1.2+)
        codes: [502, 503]
        attempts: 2
        backoff: 100ms

    # ── Enrutado por cabecera: dark launch para el equipo interno ────────────
    - matches:
        - path: { type: PathPrefix, value: /pedidos }
          headers:
            - { name: X-Canary, value: "si" }
      backendRefs:
        - { name: pedidos-canary, port: 80 }

    # ── Reescritura de ruta y cabeceras ─────────────────────────────────────
    - matches:
        - path: { type: PathPrefix, value: /v1/pedidos }
      filters:
        - type: URLRewrite
          urlRewrite:
            path: { type: ReplacePrefixMatch, replacePrefixMatch: /pedidos }
        - type: RequestHeaderModifier
          requestHeaderModifier:
            set:
              - { name: X-Api-Version, value: "v1" }
      backendRefs:
        - { name: pedidos, port: 80 }
NecesidadIngressGateway API
Enrutar por host y ruta
Reparto por peso (canary)Solo con anotaciones propietariasNativo y portable
Enrutar por cabecera o parámetroAnotacionesNativo
Timeouts y reintentosAnotacionesNativo
TCP, UDP, gRPC, TLS pass-throughNoSí (TCPRoute, GRPCRoute…)
Separación de roles y delegación seguraNoSí (allowedRoutes, ReferenceGrant)
Madurez del ecosistemaTotalBuena y creciendo rápido
Qué usar hoyLo que ya tienes: no migres por gustoPara proyectos nuevos, si tu controlador lo soporta bien

8.7 ConfigMap, Secret y cómo los consume Spring Boot

# ── ConfigMap: configuración NO sensible ────────────────────────────────────
apiVersion: v1
kind: ConfigMap
metadata:
  name: pedidos-config
  namespace: produccion
data:
  # Forma 1: pares clave-valor → variables de entorno con envFrom
  SPRING_PROFILES_ACTIVE: "produccion"
  DB_HOST: "postgres-rw.produccion.svc.cluster.local"
  DB_POOL_MAX: "10"
  CATALOGO_URL: "http://catalogo.produccion.svc.cluster.local"
  CATALOGO_TIMEOUT_MS: "2000"
  LOGGING_LEVEL_COM_EJEMPLO: "INFO"
  TOMCAT_MAX_THREADS: "50"

  # Forma 2: un fichero completo → se monta como volumen
  application-produccion.yml: |
    spring:
      jpa:
        properties:
          hibernate:
            jdbc:
              batch_size: 50
      cache:
        type: redis
    resilience4j:
      circuitbreaker:
        instances:
          catalogo:
            slidingWindowSize: 50
            failureRateThreshold: 50
            waitDurationInOpenState: 10s
---
# ── Secret: configuración sensible ──────────────────────────────────────────
apiVersion: v1
kind: Secret
metadata:
  name: pedidos-secret
  namespace: produccion
type: Opaque
stringData:            # ★ stringData: Kubernetes codifica en base64 por ti.
  DB_USER: "app"       #   Legible en el manifiesto… lo cual es justo el problema:
  DB_PASSWORD: "no-pongas-esto-en-git"   # esto NO va a Git. Ver el aviso.
  API_KEY_PASARELA: "sk_live_…"
Un Secret de Kubernetes NO está cifrado: está codificado en base64. Cualquiera con permiso de lectura sobre Secrets en ese namespace lo ve en claro con un comando, y por defecto se guarda en etcd también en claro. Base64 no es cifrado, es una codificación reversible sin clave. Las tres cosas que hay que hacer:
  1. Activar cifrado en reposo en etcd (EncryptionConfiguration con un proveedor KMS). En las nubes gestionadas es una casilla que hay que marcar; compruébalo, porque no siempre viene activada.
  2. RBAC estricto: el permiso get secrets en un namespace equivale a conocer todas sus credenciales. Casi nadie debería tenerlo.
  3. No guardar el Secret en Git en claro: usa SealedSecrets, SOPS o External Secrets (sección 10.5).
# ── LAS TRES FORMAS DE CONSUMIRLOS, Y CUÁL ELEGIR ───────────────────────────
spec:
  containers:
    - name: app

      # FORMA 1 · Variables de entorno con envFrom (todo el ConfigMap de golpe)
      # ✅ Simple, funciona con el relaxed binding de Spring Boot sin configurar nada.
      # ❌ Un cambio en el ConfigMap NO se refleja: hay que reiniciar el pod.
      # ❌ Las variables se ven en `kubectl describe pod` y en los volcados de error.
      envFrom:
        - configMapRef: { name: pedidos-config }
        - secretRef:    { name: pedidos-secret }

      # FORMA 2 · Variables sueltas (control fino y renombrado)
      env:
        - name: SPRING_DATASOURCE_PASSWORD
          valueFrom:
            secretKeyRef:
              name: pedidos-secret
              key: DB_PASSWORD
              optional: false        # ★ false: si falta, el pod NO arranca.
                                     #   Fallar pronto y claro, en vez de a medias.

      # FORMA 3 · Ficheros montados  ← ★ LA MEJOR PARA SECRETOS
      # ✅ No aparecen en `describe pod` ni en el entorno del proceso.
      # ✅ El kubelet los ACTUALIZA solo cuando cambia el Secret (~60 s).
      # ✅ Spring Boot los lee de forma nativa con configtree.
      volumeMounts:
        - { name: config,   mountPath: /config,   readOnly: true }
        - { name: secretos, mountPath: /secretos, readOnly: true }
      env:
        - name: SPRING_CONFIG_IMPORT
          # configtree: cada FICHERO del directorio es una propiedad cuyo nombre
          # sale del nombre del fichero. /secretos/spring.datasource.password
          # se convierte en la propiedad spring.datasource.password.
          value: "optional:configtree:/secretos/,optional:file:/config/"

  volumes:
    - name: config
      configMap:
        name: pedidos-config
        items:
          - key: application-produccion.yml
            path: application-produccion.yml
    - name: secretos
      secret:
        secretName: pedidos-secret
        defaultMode: 0400            # solo lectura para el dueño
        items:
          - { key: DB_PASSWORD,      path: spring.datasource.password }
          - { key: API_KEY_PASARELA, path: pedidos.pasarela.api-key }
CriterioVariables de entornoFicheros montados
Se actualizan sin reiniciarNo, nuncaSí (el kubelet las sincroniza, ~1 min)
Visibles en describe podSí (¡también los Secrets referenciados!)No
Visibles en /proc/1/environSí: cualquier proceso del pod las leeNo
Riesgo de aparecer en un log de errorAlto (muchos frameworks vuelcan el entorno)Bajo
Facilidad con Spring BootMáxima (relaxed binding)Alta (configtree)
Límite de tamañoPráctico: unos KB1 MiB por ConfigMap/Secret
RecomendaciónConfiguración no sensibleSecretos, siempre

Recargar configuración sin reiniciar: las cuatro opciones honestas

OpciónCómo funcionaVeredicto
Reinicio controlado (hash en la anotación) El hash del ConfigMap va en metadata.annotations de la plantilla del pod: al cambiar, el Deployment hace un rolling update. La opción recomendada. Es explícita, auditable, reversible con rollout undo y no tiene estados intermedios raros. Herramientas: stakater/Reloader, o el hash generado por Helm/Kustomize.
kubectl rollout restart Añade una anotación con la fecha, lo que fuerza pods nuevos. Perfecto para hacerlo a mano en un momento puntual.
Spring Cloud Kubernetes + @RefreshScope Observa el ConfigMap por la API y publica un RefreshEvent; los beans anotados se recrean. Solo para propiedades concretas y bien acotadas (niveles de log, banderas). El pod necesita permisos de RBAC para leer ConfigMaps, y el estado intermedio (unos beans recargados y otros no) es difícil de razonar. No lo uses para cambiar el pool de la base de datos.
Cambiar el nivel de log en caliente Endpoint de Actuator POST /actuator/loggers/{nombre}. Excelente y muy útil en un incidente. No requiere recargar nada.
# Subir el nivel de log de un paquete durante un incidente, sin reiniciar nada
kubectl port-forward pedidos-7c9f-xyz 8081:8081 &
curl -X POST localhost:8081/actuator/loggers/com.ejemplo.pedidos.pago \
  -H 'Content-Type: application/json' \
  -d '{"configuredLevel":"DEBUG"}'

# … investigar en los logs …

# Y volver a dejarlo como estaba (¡no te olvides: DEBUG en producción cuesta dinero)
curl -X POST localhost:8081/actuator/loggers/com.ejemplo.pedidos.pago \
  -H 'Content-Type: application/json' -d '{"configuredLevel":null}'

# Ver el estado actual de todos los loggers
curl -s localhost:8081/actuator/loggers | jq '.loggers | to_entries
  | map(select(.value.configuredLevel != null))'

# Generar el hash del ConfigMap para la anotación (lo que hace Helm por ti)
kubectl create configmap pedidos-config --from-file=application.yml \
  --dry-run=client -o yaml | sha256sum | cut -c1-32

8.8 ServiceAccount y RBAC básico

Toda petición a la API de Kubernetes viene de una identidad. Para las personas son certificados o OIDC; para los pods es una ServiceAccount. Por defecto, cada pod recibe la ServiceAccount default de su namespace, con su token montado en /var/run/secrets/kubernetes.io/serviceaccount/token. Si tu aplicación no llama a la API de Kubernetes —y lo normal es que no lo haga—, ese token es superficie de ataque gratuita: desmóntalo.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: pedidos
  namespace: produccion
  annotations:
    # Aquí se conecta la identidad de la nube (sección 12.5): permisos de AWS
    # sin ninguna clave de acceso estática.
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/pedidos-prod
    # En GKE:   iam.gke.io/gcp-service-account: pedidos@proyecto.iam.gserviceaccount.com
    # En AKS:   azure.workload.identity/client-id: 00000000-0000-0000-0000-000000000000
automountServiceAccountToken: false     # ★ por defecto, no montar el token
---
# Role: permisos DENTRO de un namespace. ClusterRole: en todo el clúster.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: pedidos-lector-config
  namespace: produccion
rules:
  # Mínimo privilegio: solo estos ConfigMaps concretos, y solo leerlos.
  - apiGroups: [""]
    resources: ["configmaps"]
    resourceNames: ["pedidos-config", "pedidos-features"]
    verbs: ["get", "list", "watch"]
  # Necesario si usas ShedLock con la API de Kubernetes o elección de líder
  - apiGroups: ["coordination.k8s.io"]
    resources: ["leases"]
    verbs: ["get", "create", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: pedidos-lector-config
  namespace: produccion
subjects:
  - kind: ServiceAccount
    name: pedidos
    namespace: produccion
roleRef:
  kind: Role
  name: pedidos-lector-config
  apiGroup: rbac.authorization.k8s.io
# ── COMPROBAR PERMISOS: el comando que resuelve el 90% de los «forbidden» ────
kubectl auth can-i --list --as=system:serviceaccount:produccion:pedidos -n produccion
kubectl auth can-i get secrets --as=system:serviceaccount:produccion:pedidos -n produccion
kubectl auth can-i create pods --as=system:serviceaccount:produccion:pedidos -n produccion

# Y para ti mismo, antes de intentar algo en producción
kubectl auth can-i delete deployments -n produccion
kubectl auth whoami                                   # 1.28+

# ── AUDITORÍA: quién puede leer secretos (la pregunta más importante) ─────────
kubectl get clusterrolebindings,rolebindings -A -o json \
  | jq -r '.items[] | select(.roleRef.name=="cluster-admin")
           | "\(.kind)/\(.metadata.name): \(.subjects // [] | map(.name) | join(", "))"'

# ── ANTIPATRONES QUE VERÁS EN CLÚSTERES REALES ───────────────────────────────
# ❌  verbs: ["*"] / resources: ["*"] / apiGroups: ["*"]   ← cluster-admin de facto
# ❌  ClusterRoleBinding a la ServiceAccount «default»     ← todos los pods, admin
# ❌  El pipeline de CI con cluster-admin permanente
# ❌  «Le doy get secrets porque es más fácil que montarlos»
#     → get secrets en un namespace = conocer TODAS sus credenciales

8.9 StatefulSet y almacenamiento persistente

Un StatefulSet es como un Deployment pero con tres garantías más, que son exactamente las que necesita una base de datos o un broker:

apiVersion: v1
kind: Service
metadata:
  name: postgres-headless
  namespace: produccion
spec:
  clusterIP: None                    # headless: DNS por pod
  selector: { app: postgres }
  ports: [{ name: postgres, port: 5432 }]
  publishNotReadyAddresses: true     # ★ los miembros deben verse ANTES de estar listos
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: produccion
spec:
  serviceName: postgres-headless     # obligatorio: el Service headless
  replicas: 3
  podManagementPolicy: OrderedReady  # OrderedReady | Parallel
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      partition: 0                   # ★ actualiza solo los pods con índice ≥ partition:
                                     #   ponlo a 2 para actualizar solo postgres-2 y
                                     #   verificar antes de seguir. Canary manual.
  selector:
    matchLabels: { app: postgres }
  template:
    metadata:
      labels: { app: postgres }
    spec:
      terminationGracePeriodSeconds: 120   # ★ una BD necesita cerrar bien
      securityContext:
        fsGroup: 999                       # el UID de postgres en su imagen
      containers:
        - name: postgres
          image: postgres:16.6-alpine
          ports: [{ name: postgres, containerPort: 5432 }]
          env:
            - name: POSTGRES_DB
              value: pedidos
            - name: POSTGRES_PASSWORD
              valueFrom: { secretKeyRef: { name: postgres-secret, key: password } }
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata   # ★ subdirectorio: el punto de
                                     # montaje tiene lost+found y initdb se queja
          resources:
            requests: { cpu: "1", memory: "2Gi" }
            limits:   { memory: "2Gi" }
          readinessProbe:
            exec: { command: ["pg_isready", "-U", "postgres"] }
            initialDelaySeconds: 10
            periodSeconds: 5
          livenessProbe:
            exec: { command: ["pg_isready", "-U", "postgres"] }
            initialDelaySeconds: 30
            periodSeconds: 15
            failureThreshold: 4
          volumeMounts:
            - { name: datos, mountPath: /var/lib/postgresql/data }
  # ★ Cada pod obtiene SU PVC: datos-postgres-0, datos-postgres-1, datos-postgres-2
  volumeClaimTemplates:
    - metadata:
        name: datos
      spec:
        accessModes: [ReadWriteOnce]
        storageClassName: gp3-cifrado
        resources:
          requests: { storage: 100Gi }
# ── StorageClass: define QUÉ disco se crea y con qué política ───────────────
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3-cifrado
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
  iops: "4000"
  throughput: "250"
  encrypted: "true"
  kmsKeyId: arn:aws:kms:eu-west-1:123456789012:key/abcd-1234
reclaimPolicy: Retain              # ★ Retain: al borrar el PVC, el disco NO se borra.
                                   #   Delete es el valor por defecto y borra los datos.
                                   #   Para bases de datos, SIEMPRE Retain.
allowVolumeExpansion: true         # permite ampliar el PVC sin recrearlo
volumeBindingMode: WaitForFirstConsumer   # ★ crea el disco en la MISMA zona que el
                                   # pod. Sin esto, el disco puede quedar en una zona
                                   # donde el pod no cabe y quedarse en Pending eterno.
---
# ── PVC suelto (para un Deployment con almacenamiento, no un StatefulSet) ───
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: ficheros-subidos
  namespace: produccion
spec:
  accessModes: [ReadWriteMany]     # ver la tabla de abajo
  storageClassName: efs-sc
  resources:
    requests: { storage: 50Gi }
Modo de accesoSignificadoQuién lo soportaUso típico
ReadWriteOnce (RWO)Un solo nodo puede montarlo para lectura y escritura.Todos los discos de bloque: EBS, Azure Disk, PD.Bases de datos, colas, cualquier StatefulSet.
ReadWriteOncePod (RWOP)Un solo pod. Más estricto que RWO.CSI moderno (1.29+ estable).Garantizar que dos pods nunca escriben el mismo volumen.
ReadOnlyMany (ROX)Muchos nodos, solo lectura.NFS, EFS, discos preexistentes.Datos de referencia compartidos.
ReadWriteMany (RWX)Muchos nodos, lectura y escritura.Solo sistemas de ficheros en red: EFS, Azure Files, Filestore, CephFS. Nunca discos de bloque.Ficheros subidos por usuarios compartidos entre réplicas. Muy lento; usa S3 mejor.
Antes de meter tu base de datos en Kubernetes, responde a esto. ¿Quién hace las copias de seguridad y quién ha probado una restauración completa este trimestre? ¿Cómo se actualiza de PostgreSQL 16 a 17 sin pérdida? ¿Qué pasa si el nodo con postgres-0 muere: cuánto tarda el disco EBS en poder montarse en otro nodo (spoiler: minutos, y solo dentro de la misma zona)? ¿Quién sabe hacer failover a las tres de la mañana? Si no tienes respuestas sólidas y un operador serio (CloudNativePG, Zalando Postgres Operator, Crunchy), usa una base de datos gestionada: RDS o Cloud SQL cuestan más en la factura y muchísimo menos en incidentes y en horas de tu equipo. Es la decisión de arquitectura más rentable que puedes tomar, y saber argumentarla es señal de criterio.

8.10 Job y CronJob: migraciones y tareas programadas

Un Job ejecuta pods hasta que terminan con éxito. Es el factor 12 (procesos de administración) implementado: la misma imagen, la misma versión, otro punto de entrada. El caso de uso estrella para un desarrollador Java es la migración de esquema con Flyway.

# ── Job de migración: se ejecuta ANTES del despliegue de la aplicación ──────
apiVersion: batch/v1
kind: Job
metadata:
  # ★ El nombre incluye la versión: un Job es inmutable, no se puede «reaplicar».
  #   Con Helm/Kustomize se genera con el hash de la imagen.
  name: pedidos-migracion-1-4-2
  namespace: produccion
  annotations:
    # Con Argo CD: sync-wave negativo ⇒ se aplica ANTES que el Deployment y
    # Argo espera a que termine bien antes de continuar.
    argocd.argoproj.io/sync-wave: "-1"
    argocd.argoproj.io/hook: PreSync
spec:
  backoffLimit: 2                 # reintentos del POD antes de darlo por fallido
  activeDeadlineSeconds: 900      # ★ mata el Job a los 15 min: una migración
                                  #   colgada por un bloqueo no debe esperar horas
  ttlSecondsAfterFinished: 86400  # se autoborra a las 24 h (limpieza de etcd)
  completions: 1
  parallelism: 1
  template:
    spec:
      restartPolicy: Never        # obligatorio en Jobs: Never u OnFailure
      serviceAccountName: pedidos
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
      containers:
        - name: flyway
          # ★ LA MISMA IMAGEN que la aplicación: mismas migraciones, misma versión
          image: ghcr.io/ejemplo/pedidos@sha256:9f8e7d6c5b4a…
          # Se invoca el modo de Flyway del propio jar, no un contenedor aparte:
          # así no puede haber desajuste de versiones de los scripts.
          args:
            - "--spring.main.web-application-type=none"
            - "--spring.flyway.enabled=true"
            - "--spring.flyway.locations=classpath:db/migration"
            - "--spring.flyway.baseline-on-migrate=false"
            - "--spring.flyway.out-of-order=false"
            - "--spring.flyway.validate-on-migrate=true"
            - "--spring.flyway.lock-retry-count=50"
            - "--spring.task.execution.pool.core-size=1"
            - "--spring.main.banner-mode=off"
          envFrom:
            - configMapRef: { name: pedidos-config }
            - secretRef:    { name: pedidos-secret-migracion }   # ★ usuario DDL,
                                  # distinto del de la aplicación (mínimo privilegio)
          env:
            - name: JAVA_TOOL_OPTIONS
              value: "-XX:MaxRAMPercentage=70 -XX:+UseSerialGC"
          resources:
            requests: { cpu: "200m", memory: "512Mi" }
            limits:   { memory: "512Mi" }
# ── CronJob: la forma correcta de las tareas programadas en Kubernetes ──────
apiVersion: batch/v1
kind: CronJob
metadata:
  name: pedidos-informe-diario
  namespace: produccion
spec:
  schedule: "0 3 * * *"           # 03:00 todos los días
  timeZone: "Europe/Madrid"       # ★ 1.27+ estable. Sin esto, es UTC y en verano
                                  #   tu informe «de las 3» sale a las 5.
  concurrencyPolicy: Forbid       # ★ Forbid | Allow | Replace
                                  #   Forbid: si el anterior aún corre, NO arranca
                                  #   otro. Es lo que quieres casi siempre.
  startingDeadlineSeconds: 600    # si el control plane estuvo caído, ¿cuánto
                                  # margen hay para arrancar con retraso?
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3        # ★ conserva los fallidos: sus logs son la única
                                   #   forma de saber por qué falló anoche
  suspend: false                   # true = pausar sin borrar (útil en incidentes)
  jobTemplate:
    spec:
      backoffLimit: 1
      activeDeadlineSeconds: 3600
      ttlSecondsAfterFinished: 259200
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: informe
              image: ghcr.io/ejemplo/pedidos@sha256:9f8e7d6c5b4a…
              args:
                - "--spring.main.web-application-type=none"
                - "--pedidos.tarea=informe-diario"
              envFrom:
                - configMapRef: { name: pedidos-config }
                - secretRef:    { name: pedidos-secret }
              resources:
                requests: { cpu: "500m", memory: "1Gi" }
                limits:   { memory: "1Gi" }
Tarea programadaCon @Scheduled de SpringCon CronJob de Kubernetes
Con 3 réplicasSe ejecuta 3 veces. Hace falta ShedLock o elección de líder.Una vez. Garantizado por diseño.
Consumo de recursosOcupa memoria en el pod de la API todo el día para trabajar 5 minutos.Un pod que nace, trabaja y muere. Cero coste el resto del día.
Una tarea pesadaCompite por CPU con las peticiones de los usuarios y estropea la latencia p99.Aislada, con sus propios recursos y su propio límite.
Cambiar la hora o pausarlaRequiere redespliegue (o configuración dinámica).kubectl patch cronjob … suspend=true. Inmediato.
Reejecutar la de ayerImposible sin un endpoint expuesto a propósito.kubectl create job --from=cronjob/X manual-1
Visibilidad de fallosUn ERROR en el log, entre otros mil.Un Job en estado Failed, alertable con una regla trivial.
Cuándo usar cada unoTareas muy cortas y frecuentes (cada 30 s) que necesitan el contexto de la aplicación caliente.Todo lo demás.
# Operar Jobs y CronJobs
kubectl get jobs,cronjobs
kubectl create job migracion-manual --from=cronjob/pedidos-informe-diario
kubectl patch cronjob pedidos-informe-diario -p '{"spec":{"suspend":true}}'
kubectl logs job/pedidos-migracion-1-4-2                 # logs del Job
kubectl logs -l job-name=pedidos-migracion-1-4-2 --tail=-1
kubectl wait --for=condition=complete --timeout=15m job/pedidos-migracion-1-4-2
kubectl describe job pedidos-migracion-1-4-2 | tail -20  # por qué falló

# ★ En el pipeline: esperar a la migración y ABORTAR el despliegue si falla
kubectl apply -f k8s/job-migracion.yaml
if ! kubectl wait --for=condition=complete --timeout=15m job/pedidos-migracion-1-4-2; then
  echo "La migración ha fallado. Se aborta el despliegue."
  kubectl logs job/pedidos-migracion-1-4-2 --tail=200
  exit 1
fi
kubectl apply -f k8s/deployment.yaml
kubectl rollout status deploy/pedidos --timeout=10m
Por qué las migraciones NO deben ejecutarse en el arranque de la aplicación. Con spring.flyway.enabled=true y 3 réplicas: las tres arrancan a la vez, las tres intentan migrar, dos se quedan esperando el bloqueo de Flyway y su startupProbe agota el presupuesto. Resultado: CrashLoopBackOff justo durante un despliegue. Y si la migración tarda 4 minutos (un índice sobre una tabla grande), ningún pod está listo durante esos 4 minutos: caída total. Además, un error en la migración deja el despliegue a medias y el rollout undo no revierte el esquema. Con un Job la migración ocurre una vez, antes, y de forma verificable; si falla, el despliegue no llega a empezar. Sobre cómo escribir migraciones compatibles hacia atrás (expand and contract), ver módulo 06 y la sección 11.5.

8.11 DaemonSet

Un DaemonSet ejecuta exactamente una copia del pod en cada nodo (o en cada nodo que encaje con un selector), y añade la copia automáticamente cuando entra un nodo nuevo. Como desarrollador de aplicaciones casi nunca escribirás uno, pero conviene saber qué son porque los verás en kubectl get pods -A y porque consumen recursos de los nodos que pagas.

Uso típicoEjemploPor qué debe estar en todos los nodos
Recogida de logsFluent Bit, Vector, PromtailLos ficheros de log de los contenedores están en el disco de cada nodo.
Métricas del nodonode-exporterMide CPU, disco y red de ese nodo.
RedCalico, Cilium, kube-proxyPrograma las reglas de red del nodo.
AlmacenamientoControladores CSIMonta volúmenes en el nodo.
SeguridadFalco, agentes EDRVigila las llamadas al sistema del kernel del nodo.
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: recolector-logs
  namespace: observabilidad
spec:
  selector:
    matchLabels: { app: recolector-logs }
  updateStrategy:
    type: RollingUpdate
    rollingUpdate: { maxUnavailable: 1 }
  template:
    metadata:
      labels: { app: recolector-logs }
    spec:
      # ★ Un DaemonSet de infraestructura debe correr también en nodos «marcados»
      tolerations:
        - operator: Exists            # tolera CUALQUIER taint
      containers:
        - name: fluent-bit
          image: fluent/fluent-bit:3.2
          resources:
            requests: { cpu: "50m", memory: "100Mi" }
            limits:   { memory: "200Mi" }
          volumeMounts:
            - { name: varlog, mountPath: /var/log, readOnly: true }
      volumes:
        - name: varlog
          hostPath: { path: /var/log }

8.12 HorizontalPodAutoscaler: escalar por número de réplicas

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: pedidos
  namespace: produccion
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: pedidos
  minReplicas: 3        # ★ mínimo 3 en producción: soporta perder una zona
  maxReplicas: 20       # ★ ponlo: protege de un bucle de escalado que arruine la factura

  metrics:
    # ── 1 · CPU: el clásico. Sobre las REQUESTS, no sobre el límite. ──────────
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70     # 70% de requests.cpu = 350m con requests 500m
    # ── 2 · Memoria: casi nunca sirve para escalar una JVM ───────────────────
    #   La JVM RESERVA memoria y no la devuelve: el uso está siempre cerca del
    #   máximo, así que el HPA por memoria escala hasta maxReplicas y no baja nunca.
    #   Úsala solo como red de seguridad con un umbral muy alto, o no la uses.

    # ── 3 · Métrica de la aplicación: MUCHO mejor para un servicio web ───────
    #   Peticiones por segundo por pod, vía prometheus-adapter o KEDA.
    - type: Pods
      pods:
        metric:
          name: http_server_requests_seconds_count_rate
        target:
          type: AverageValue
          averageValue: "50"         # 50 rps por pod

    # ── 4 · Métrica externa: la longitud de una cola. La mejor para workers. ─
    - type: External
      external:
        metric:
          name: sqs_messages_visible
          selector:
            matchLabels: { queue: pedidos-pendientes }
        target:
          type: AverageValue
          averageValue: "30"         # 30 mensajes pendientes por pod

  # ── COMPORTAMIENTO: la parte que evita la oscilación (y que casi nadie pone) ─
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0        # subir rápido: el coste de subir de más
                                           # es dinero; el de no subir, un incidente
      policies:
        - { type: Percent, value: 100, periodSeconds: 30 }   # duplicar cada 30 s
        - { type: Pods,    value: 4,   periodSeconds: 30 }   # o 4 pods, el mayor
      selectPolicy: Max
    scaleDown:
      stabilizationWindowSeconds: 300      # ★ ESPERA 5 MIN antes de bajar, y usa
                                           # el MÁXIMO de la ventana. Es lo que
                                           # elimina el «flapping».
      policies:
        - { type: Percent, value: 25, periodSeconds: 60 }    # como mucho -25%/min
      selectPolicy: Min
Problema del HPACausaSolución
No escala: <unknown> en las métricas No hay metrics-server, o el contenedor no tiene requests.cpu Instalar metrics-server; poner requests (el HPA por CPU es un porcentaje de requests: sin requests no hay porcentaje)
Oscila entre 3 y 12 réplicas cada minuto Sin stabilizationWindowSeconds en scaleDown; o el arranque de la JVM consume CPU y dispara el escalado, en bucle Ventana de estabilización de 300 s al bajar; escalar por rps en lugar de por CPU
Escala, pero la latencia no mejora El cuello es la base de datos, no la aplicación. Más réplicas = más conexiones = peor Medir dónde está el tiempo (trazas). Poner un límite superior realista al escalado
Los pods nuevos tardan 2 minutos en servir Arranque de la JVM + startupProbe + descarga de imagen Escalar con antelación (por rps, no por CPU al 90%); precalentar la imagen en los nodos; AppCDS
El HPA y el apply se pelean por replicas replicas está en el manifiesto que aplicas Quitar replicas del manifiesto (ver 7.4)
Escala a 50 pods y no caben No hay cluster autoscaler, o no hay cuota Karpenter o cluster-autoscaler; y un maxReplicas coherente con la capacidad real
KEDA para escalar por eventos. Si tu servicio consume de Kafka, SQS, RabbitMQ o una tabla, KEDA es mejor que el HPA nativo: trae más de 60 «escaladores» listos, sabe escalar a cero cuando no hay trabajo (un HPA nativo no baja de 1) y expone la métrica sin que tengas que montar prometheus-adapter. Para un worker que procesa una cola es, casi siempre, la elección correcta.

8.13 VerticalPodAutoscaler: dimensionar requests y limits

El VPA no cambia el número de réplicas: cambia cuánta CPU y memoria pide cada una. Su utilidad principal no es el modo automático, es el modo recomendación: te dice, con datos reales de semanas, qué requests deberías poner. Es la mejor herramienta que existe para atacar el sobredimensionado, que es el gasto número uno en la factura de Kubernetes.

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: pedidos
  namespace: produccion
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: pedidos
  updatePolicy:
    # "Off"     → SOLO recomienda. ★ EMPIEZA AQUÍ SIEMPRE.
    # "Initial" → aplica al crear el pod, no lo toca después. Buen punto medio.
    # "Auto"    → recrea pods para cambiar los recursos (¡reinicios inesperados!)
    # "InPlaceOrRecreate" → 1.33+: cambia en caliente si puede. Prometedor.
    updateMode: "Off"
  resourcePolicy:
    containerPolicies:
      - containerName: app
        minAllowed: { cpu: "200m", memory: "512Mi" }
        maxAllowed: { cpu: "2",    memory: "4Gi" }
        controlledResources: ["cpu", "memory"]
        controlledValues: RequestsOnly     # no tocar los limits
# Leer la recomendación (tras 1-2 semanas de datos, no antes)
kubectl describe vpa pedidos
# Recommendation:
#   Container: app
#     Lower Bound:  cpu: 180m   memory: 620Mi     ← con esto va justo
#     Target:       cpu: 340m   memory: 780Mi     ← ★ pon ESTO en requests
#     Upper Bound:  cpu: 1200m  memory: 1400Mi    ← nunca ha necesitado más

# El cálculo del ahorro, que es el argumento que convence a la dirección:
#   requests actuales: 1000m CPU × 20 servicios × 3 réplicas = 60 CPU
#   requests según VPA: 340m × 20 × 3 = 20,4 CPU
#   ⇒ el mismo trabajo cabe en un tercio de los nodos.

# ⚠ NO uses VPA y HPA sobre la MISMA métrica: se pelean (el VPA sube requests,
#   con lo que baja el % de utilización, con lo que el HPA reduce réplicas, con
#   lo que sube el uso por pod, con lo que el VPA sube requests…). Combinación
#   válida: HPA por una métrica personalizada (rps) + VPA solo para memoria.

8.14 PodDisruptionBudget: proteger la disponibilidad durante el mantenimiento

Hay dos clases de interrupciones. Las involuntarias (se muere el nodo, OOM, fallo de hardware) no se pueden negociar. Las voluntarias (drenar un nodo para actualizarlo, reducir el clúster, recolocar pods) sí: y un PodDisruptionBudget es el contrato que dice «puedes tocarme, pero nunca me dejes por debajo de esto».

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: pedidos
  namespace: produccion
spec:
  # Usa UNO de los dos, nunca ambos.
  minAvailable: 2          # deben quedar al menos 2 pods LISTOS
  # maxUnavailable: 1      # equivalente y a menudo más claro con muchas réplicas
  selector:
    matchLabels:
      app.kubernetes.io/name: pedidos
      app.kubernetes.io/instance: pedidos-prod
  # 1.27+: qué hacer con pods sanos cuando el PDB no se puede cumplir
  unhealthyPodEvictionPolicy: AlwaysAllow
RéplicasPDB recomendadoEfecto al drenar un nodo
1Ninguno (o maxUnavailable: 1)Con minAvailable: 1 y una sola réplica, el drenaje se bloquea para siempre: el nodo no se puede actualizar nunca. Error muy común.
2–3maxUnavailable: 1Se desaloja de uno en uno, esperando a que el sustituto esté listo.
≥ 4minAvailable: 75%Escala automáticamente con el número de réplicas.
Base de datos (3 miembros con quórum)maxUnavailable: 1Nunca se pierde el quórum. Imprescindible.
# Verificar que el PDB funciona (¡pruébalo antes de necesitarlo!)
kubectl get pdb
# NAME     MIN AVAILABLE  MAX UNAVAILABLE  ALLOWED DISRUPTIONS  AGE
# pedidos  2              N/A              1                    5d
#                                          ↑ si es 0, un drenaje se BLOQUEARÁ

kubectl drain nodo-2 --ignore-daemonsets --delete-emptydir-data --dry-run=server
# "Cannot evict pod as it would violate the pod's disruption budget"
#   → correcto: el PDB está protegiéndote. Espera a que haya capacidad.

8.15 NetworkPolicy: cortafuegos entre pods

Por defecto, en Kubernetes cualquier pod puede conectarse con cualquier otro pod de cualquier namespace. Eso significa que un pod comprometido de una aplicación de prueba puede intentar conectarse a tu base de datos de producción. Las NetworkPolicy lo arreglan, y son acumulativas y de permiso: en cuanto un pod es seleccionado por alguna política, todo lo que no esté explícitamente permitido queda denegado.

# ── PASO 1: denegar todo en el namespace (la base de la confianza cero) ─────
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: denegar-todo
  namespace: produccion
spec:
  podSelector: {}                 # {} = TODOS los pods del namespace
  policyTypes: [Ingress, Egress]
  # sin reglas ⇒ nada entra y nada sale
---
# ── PASO 2: permitir el DNS (si no, NADA funciona y perderás una hora) ──────
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: permitir-dns
  namespace: produccion
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: kube-system }
          podSelector:
            matchLabels: { k8s-app: kube-dns }
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }
---
# ── PASO 3: la política concreta del servicio ───────────────────────────────
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: pedidos
  namespace: produccion
spec:
  podSelector:
    matchLabels: { app.kubernetes.io/name: pedidos }
  policyTypes: [Ingress, Egress]

  ingress:
    # Del controlador de ingress, solo al puerto de la aplicación
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: ingress-nginx }
      ports:
        - { protocol: TCP, port: 8080 }
    # De otros servicios internos que tienen derecho
    - from:
        - podSelector:
            matchExpressions:
              - key: app.kubernetes.io/name
                operator: In
                values: [pagos, envios]
      ports:
        - { protocol: TCP, port: 8080 }
    # De Prometheus, solo al puerto de gestión
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: observabilidad }
      ports:
        - { protocol: TCP, port: 8081 }

  egress:
    # A la base de datos
    - to:
        - podSelector:
            matchLabels: { app: postgres }
      ports:
        - { protocol: TCP, port: 5432 }
    # A Redis y a Kafka
    - to:
        - podSelector: { matchLabels: { app: redis } }
      ports: [{ protocol: TCP, port: 6379 }]
    - to:
        - podSelector: { matchLabels: { app: kafka } }
      ports: [{ protocol: TCP, port: 9092 }]
    # A otro servicio interno
    - to:
        - podSelector: { matchLabels: { app.kubernetes.io/name: catalogo } }
      ports: [{ protocol: TCP, port: 8080 }]
    # A internet (la pasarela de pago), EXCLUYENDO las redes privadas.
    # ★ Esto es la mitigación de SSRF a nivel de red: aunque tu aplicación tenga
    #   un SSRF, no puede llegar al metadata endpoint de la nube (169.254.169.254),
    #   que es lo que se usa para robar credenciales.
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 10.0.0.0/8
              - 172.16.0.0/12
              - 192.168.0.0/16
              - 169.254.0.0/16
      ports:
        - { protocol: TCP, port: 443 }
Tres cosas que sorprenden de las NetworkPolicy. (1) No sirven de nada sin un CNI que las implemente: Calico, Cilium y el VPC CNI de AWS con la opción activada sí; el CNI básico de algunos clústeres de juguete, no, y tus políticas se aplican sin efecto alguno, dándote una falsa sensación de seguridad. Compruébalo con un test. (2) Son a nivel de IP y puerto (capa 3/4), no de HTTP: no puedes decir «solo GET /pedidos»; para eso hace falta una malla de servicios o Cilium con políticas de capa 7. (3) Olvidar el DNS es el error clásico: al aplicar denegar-todo la aplicación pierde la resolución de nombres y todo falla con UnknownHostException, que no parece un problema de red.

8.16 ResourceQuota y LimitRange

ResourceQuotaLimitRange
ÁmbitoEl total del namespaceCada pod o contenedor individual
Qué hace«Este namespace no puede pedir más de 20 CPU en total»«Ningún contenedor puede pedir más de 2 CPU» y «si no pones requests, te pongo estos»
Efecto al superarloLa creación del pod es rechazada por el apiserverSe aplica el valor por defecto, o se rechaza si viola el máximo
Valor principalEvitar que un equipo se coma el clústerEvitar pods BestEffort sin requests, que son los primeros en ser desalojados
apiVersion: v1
kind: ResourceQuota
metadata:
  name: cuota-produccion
  namespace: produccion
spec:
  hard:
    requests.cpu: "40"
    requests.memory: 80Gi
    limits.memory: 100Gi
    persistentvolumeclaims: "20"
    requests.storage: 2Ti
    count/deployments.apps: "30"
    count/services.loadbalancers: "1"      # ★ evita 20 balanceadores facturables
    pods: "200"
---
apiVersion: v1
kind: LimitRange
metadata:
  name: limites-por-defecto
  namespace: produccion
spec:
  limits:
    - type: Container
      # Si el contenedor NO declara requests/limits, se le ponen estos.
      # Consecuencia clave: ningún pod acaba en QoS BestEffort por descuido.
      default:
        cpu: "500m"
        memory: "512Mi"
      defaultRequest:
        cpu: "100m"
        memory: "256Mi"
      max:
        cpu: "4"
        memory: "8Gi"
      min:
        cpu: "50m"
        memory: "64Mi"
      # Impide requests: 100m / limits: 4 (ratio 40): fuente de vecinos ruidosos
      maxLimitRequestRatio:
        cpu: "8"
        memory: "2"
---
apiVersion: v1
kind: LimitRange
metadata:
  name: limites-pvc
  namespace: produccion
spec:
  limits:
    - type: PersistentVolumeClaim
      max: { storage: 500Gi }
      min: { storage: 1Gi }
# Ver el consumo de la cuota (el primer sitio a mirar si un pod no se crea)
kubectl describe quota -n produccion
# Name:            cuota-produccion
# Resource         Used   Hard
# requests.cpu     32     40
# requests.memory  61Gi   80Gi
# pods             147    200

# Síntoma típico: el Deployment dice 5 réplicas y solo hay 3, sin pods en Pending.
# El ReplicaSet tiene el error:
kubectl describe rs pedidos-8d4a1c2e9 | grep -A3 Events
#   Error creating: pods "pedidos-…" is forbidden: exceeded quota:
#     cuota-produccion, requested: requests.cpu=500m, used: 40, limited: 40

8.17 Reparto, afinidades y tolerancias: dónde acaban tus pods

spec:
  # ── 1 · topologySpreadConstraints: reparto uniforme. LA HERRAMIENTA MODERNA ──
  # Sustituye a la antipreferencia de pods para el caso «reparte por zonas»,
  # y es más expresiva y más fácil de razonar.
  topologySpreadConstraints:
    - maxSkew: 1                                 # diferencia máxima entre dominios
      topologyKey: topology.kubernetes.io/zone   # el dominio: zona de disponibilidad
      whenUnsatisfiable: DoNotSchedule           # ★ duro: no programar si se rompe
      labelSelector:
        matchLabels: { app.kubernetes.io/name: pedidos }
      matchLabelKeys: [pod-template-hash]        # ★ 1.27+: cuenta solo los pods de
                                    # ESTA versión. Sin esto, durante un rollout los
                                    # pods viejos cuentan y el reparto se bloquea.
    - maxSkew: 1
      topologyKey: kubernetes.io/hostname        # y también entre nodos
      whenUnsatisfiable: ScheduleAnyway          # blando: preferencia, no requisito
      labelSelector:
        matchLabels: { app.kubernetes.io/name: pedidos }

  affinity:
    # ── 2 · nodeAffinity: en qué NODOS puede correr ──────────────────────────
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - key: kubernetes.io/arch
                operator: In
                values: [amd64]                   # tu imagen no es multiplataforma
              - key: node.kubernetes.io/instance-type
                operator: NotIn
                values: [t3.micro, t3.small]      # demasiado pequeños para una JVM
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 80
          preference:
            matchExpressions:
              - key: karpenter.sh/capacity-type
                operator: In
                values: [spot]                    # preferir spot: ahorro del 70%

    # ── 3 · podAntiAffinity: separar las réplicas entre sí ───────────────────
    podAntiAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 100
          podAffinityTerm:
            topologyKey: kubernetes.io/hostname
            labelSelector:
              matchLabels: { app.kubernetes.io/name: pedidos }

    # ── 4 · podAffinity: juntar pods que se hablan mucho ─────────────────────
    #   Ahorra latencia y tráfico entre zonas (que se factura). Úsalo con cuidado:
    #   juntar demasiado va en contra de la tolerancia a fallos.
    podAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 50
          podAffinityTerm:
            topologyKey: topology.kubernetes.io/zone
            labelSelector:
              matchLabels: { app: redis }

  # ── 5 · tolerations: aceptar nodos «marcados» con taints ──────────────────
  #   Un taint en un nodo REPELE pods; una toleration permite ignorarlo.
  #   Es cómo se reservan nodos para cargas concretas.
  tolerations:
    - key: dedicado
      operator: Equal
      value: pedidos
      effect: NoSchedule
    # Aceptar nodos spot, que pueden desaparecer con 2 min de aviso
    - key: karpenter.sh/disruption
      operator: Exists
      effect: NoSchedule
    # Reaccionar más rápido si el nodo deja de responder (por defecto: 300 s)
    - key: node.kubernetes.io/not-ready
      operator: Exists
      effect: NoExecute
      tolerationSeconds: 60

  # ── 6 · priorityClassName: quién sobrevive cuando falta capacidad ─────────
  priorityClassName: produccion-alta
Quiero…MecanismoAviso
Que mis 3 réplicas no estén en el mismo nodotopologySpreadConstraints por hostname con ScheduleAnywayCon DoNotSchedule y menos nodos que réplicas, los pods se quedan en Pending.
Sobrevivir a la caída de una zona completaReparto por zone + minReplicas: 3 + PDBCon 2 réplicas en 2 zonas, perder una zona te deja al 50% de capacidad.
Correr en nodos con más memorianodeAffinity o nodeSelectorSi esos nodos se llenan, tus pods esperan en lugar de ir a otros.
Reservar nodos para un equipoTaint en el nodo + toleration en el podLa toleration permite pero no obliga: añade también nodeAffinity, o tus pods irán a otros nodos.
Ahorrar con instancias spotnodeAffinity preferida + toleration + PDBNunca pongas el 100% en spot: mezcla, y asegúrate de que el apagado ordenado funciona (2 min de aviso).
Que en un incidente sobreviva lo importantePriorityClassLos pods de prioridad baja son desalojados para hacer sitio a los de prioridad alta. Úsalo con intención.
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: produccion-alta
value: 1000000
globalDefault: false
description: "Servicios de cara al cliente. Desalojan a los de prioridad menor."
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: lotes-baja
value: 100
preemptionPolicy: Never       # nunca desaloja a nadie; solo espera su turno
description: "Procesos por lotes e informes. Se apartan cuando falta capacidad."

9 · Ciclo de vida del pod y una aplicación bien portada

Esta sección es donde tu código Spring Boot y la plataforma se dan la mano. Casi todos los problemas de «errores 502 en cada despliegue», «el pod se reinicia solo» y «CrashLoopBackOff misterioso» se explican con lo que hay aquí.

9.1 Fases del pod y estados de los contenedores

FASES DEL POD (status.phase) — solo hay cinco
  Pending     Aceptado por el apiserver pero aún no corre todo. Puede ser porque
              el scheduler no le encuentra nodo, o porque está descargando la
              imagen, o porque un initContainer todavía está trabajando.
  Running     Está asignado a un nodo y al menos un contenedor está en marcha.
              ★ OJO: «Running» NO significa «listo para recibir tráfico».
  Succeeded   Todos los contenedores terminaron con éxito y no se reinician (Jobs).
  Failed      Todos terminaron y al menos uno falló.
  Unknown     No se puede obtener el estado (normalmente el nodo no responde).

ESTADOS DE UN CONTENEDOR (status.containerStatuses[].state)
  Waiting     Con un «reason» que es donde está la información útil:
                ContainerCreating · ImagePullBackOff · ErrImagePull
                CrashLoopBackOff · CreateContainerConfigError
  Running     Con startedAt
  Terminated  Con exitCode, reason, startedAt y finishedAt

CONDICIONES DEL POD (status.conditions) — más informativas que la fase
  PodScheduled       ¿tiene nodo?
  Initialized        ¿han terminado todos los initContainers?
  ContainersReady    ¿todos los contenedores pasan su readinessProbe?
  Ready              ★ ESTA es la que decide si el Service le manda tráfico

CÓDIGOS DE SALIDA QUE HAY QUE SABERSE
  0     Terminó bien
  1     Excepción de la aplicación (mira los logs: normalmente un fallo de arranque)
  137   128 + 9  = SIGKILL   → ★ OOMKilled, o SIGTERM ignorado y matado a la fuerza
  143   128 + 15 = SIGTERM   → apagado ordenado correcto (esto es BUENO)
  126   El comando no se pudo ejecutar (permisos)
  127   Comando no encontrado (típico en imágenes distroless con un ENTRYPOINT malo)

EL BACKOFF DEL REINICIO (por qué «CrashLoopBackOff» y no «CrashLoop»)
  Reintentos con retardo exponencial: 10s, 20s, 40s, 80s, 160s, 300s (tope).
  El contador se reinicia si el contenedor consigue estar 10 minutos en marcha.
  Consecuencia práctica: un pod que falla al arrancar tarda cada vez MÁS en
  reintentar, así que un arreglo aplicado ahora puede tardar 5 minutos en verse.
  Para forzarlo: kubectl delete pod X (el ReplicaSet crea otro inmediatamente).

9.2 initContainers y contenedores sidecar

initContainers clásicoSidecar nativo (1.29+ estable)Contenedor normal como sidecar
Cómo se declarainitContainersinitContainers con restartPolicy: Alwayscontainers
Cuándo arrancaAntes que todo, en orden, uno a unoAntes que los contenedores principales, y sigue corriendoEn paralelo con los demás
Cuándo terminaDebe terminar para que siga el podAl final, después de los principalesCuando le toque
¿Funciona en un Job?Sí: el Job termina cuando acaban los principalesNo: el Job nunca termina porque el sidecar sigue vivo
Problema que resuelvePreparar el terrenoEl sidecar está listo antes que la app y muere después: se acabaron los fallos de arranque porque el proxy aún no estaba y las peticiones perdidas al cerrarLo que se hacía antes, con esos dos problemas
spec:
  initContainers:
    # ── 1 · initContainer CLÁSICO: espera y termina ──────────────────────────
    - name: esperar-dependencias
      image: busybox:1.36
      command:
        - sh
        - -c
        - |
          set -e
          echo "Comprobando PostgreSQL…"
          for i in $(seq 1 60); do nc -z postgres-rw 5432 && break; sleep 2; done
          nc -z postgres-rw 5432 || { echo "postgres no responde"; exit 1; }
          echo "Comprobando Redis…"
          for i in $(seq 1 30); do nc -z redis 6379 && break; sleep 2; done
          echo "Dependencias listas."
      resources:
        requests: { cpu: "50m", memory: "32Mi" }
        limits:   { memory: "32Mi" }
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities: { drop: ["ALL"] }

    # ── 2 · SIDECAR NATIVO: arranca antes, muere después ─────────────────────
    #   El caso de uso perfecto: el proxy de Cloud SQL. Antes, con un contenedor
    #   normal, la aplicación podía arrancar antes que el proxy y fallar la
    #   conexión; y al apagar, el proxy podía morir antes, cortando las
    #   consultas en curso. Con restartPolicy: Always eso se resuelve.
    - name: cloud-sql-proxy
      image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.14.1
      restartPolicy: Always          # ★ ESTO lo convierte en sidecar nativo
      args:
        - "--structured-logs"
        - "--port=5432"
        - "proyecto:europe-west1:pedidos-prod"
      resources:
        requests: { cpu: "100m", memory: "128Mi" }
        limits:   { memory: "128Mi" }
      # Un sidecar nativo PUEDE tener probes: el pod no está listo hasta que lo esté
      startupProbe:
        tcpSocket: { port: 5432 }
        periodSeconds: 1
        failureThreshold: 30
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities: { drop: ["ALL"] }

  containers:
    - name: app
      image: ghcr.io/ejemplo/pedidos@sha256:9f8e7d…
      env:
        - name: DB_URL
          value: jdbc:postgresql://127.0.0.1:5432/pedidos   # ★ localhost: el
                          # sidecar comparte el namespace de red con la aplicación

9.3 Las tres probes: qué hace cada una y qué NO poner en ellas

ProbePregunta que respondeSi falla…Cuándo se ejecuta
startupProbe «¿Ha terminado ya de arrancar?» Se reinicia el contenedor. Pero mientras está en marcha, las otras dos están desactivadas. Desde el segundo 0 hasta que pasa por primera vez. Después, nunca más.
readinessProbe «¿Puede atender tráfico ahora Se le quita del EndpointSlice: el Service deja de mandarle peticiones. No se reinicia. Durante toda la vida del contenedor.
livenessProbe «¿Está irrecuperablemente colgado?» Se MATA y se reinicia el contenedor. Durante toda la vida, tras la startupProbe.
El desastre número uno de Kubernetes con Java: poner la base de datos en la liveness probe. Imagina que /actuator/health (el endpoint completo, que incluye el indicador db) está configurado como liveness de tus 10 réplicas. La base de datos tiene 30 segundos de problemas —un failover, una consulta que bloquea, saturación de conexiones—. Secuencia:
  1. La liveness falla en las 10 réplicas a la vez.
  2. Kubernetes mata y reinicia los 10 pods.
  3. Los 10 arrancan de golpe, y cada uno abre 10 conexiones nuevas: 100 conexiones simultáneas contra una base de datos que ya estaba mal.
  4. La base de datos, ahora sí, cae del todo. La liveness sigue fallando. Vuelta al paso 2.
Un incidente de 30 segundos se ha convertido en una caída total en bucle que no se recupera sola. Y lo peor: reiniciar el proceso no arregla nada, porque el problema no está en tu proceso. La regla es absoluta: la liveness solo comprueba TU proceso; las dependencias externas van en la readiness (y, en muchos casos, tampoco: ver el aviso siguiente).
# ── LA CONFIGURACIÓN CORRECTA, CON LOS GRUPOS DE HEALTH DE ACTUATOR ─────────
# application.yml
management:
  server:
    port: 8081
  endpoint:
    health:
      probes:
        enabled: true          # crea /health/liveness y /health/readiness
      show-details: when-authorized
      group:
        liveness:
          include: livenessState            # ★ SOLO el estado interno. Nada más.
          # livenessState responde UP salvo que el contexto de Spring esté roto.
        readiness:
          include: readinessState,db,redis  # dependencias imprescindibles
          # Si no puedes servir NINGUNA petición sin la BD, ponla aquí.
          # Si puedes servir en modo degradado, NO la pongas: ver el aviso de abajo.
      # Marcar como no crítico algo que puede fallar sin impedir el servicio:
      # un indicador que devuelva DOWN en un grupo hace DOWN todo el grupo.
  health:
    diskspace:
      enabled: false           # ★ desactívalo: en un contenedor no aporta nada y
                               #   ha provocado readiness en rojo por /tmp lleno
    kafka:
      enabled: false           # el productor no necesita Kafka para responder
# ── LAS TRES PROBES EN EL MANIFIESTO, CON VALORES RAZONADOS ─────────────────
containers:
  - name: app
    ports:
      - { name: management, containerPort: 8081 }

    # ① startupProbe: le da a la JVM todo el tiempo que necesite SIN relajar
    #    las otras dos. Es la forma correcta de manejar arranques lentos:
    #    mucho mejor que initialDelaySeconds, porque en cuanto arranca (aunque
    #    tarde 8 s) pasa a la vigilancia estricta.
    startupProbe:
      httpGet: { path: /actuator/health/liveness, port: management }
      periodSeconds: 2
      timeoutSeconds: 2
      failureThreshold: 60            # 60 × 2 s = hasta 120 s para arrancar
      # Presupuesto = periodSeconds × failureThreshold. Mídelo con tu arranque
      # real ×3 de margen: en un nodo cargado o con CPU limitada tarda mucho más.

    # ② readinessProbe: rápida y estricta. Es el interruptor del tráfico.
    readinessProbe:
      httpGet: { path: /actuator/health/readiness, port: management }
      periodSeconds: 5
      timeoutSeconds: 2               # ★ menor que periodSeconds
      failureThreshold: 2             # sale de rotación en ~10 s
      successThreshold: 1             # vuelve en cuanto responda bien

    # ③ livenessProbe: LENTA y TOLERANTE. Solo detecta cuelgues definitivos.
    livenessProbe:
      httpGet: { path: /actuator/health/liveness, port: management }
      periodSeconds: 10
      timeoutSeconds: 2
      failureThreshold: 3             # mata tras ~30 s de fallo continuado
      # Ni initialDelaySeconds (lo cubre la startupProbe) ni umbrales agresivos.
¿Debe la base de datos estar en la readiness? Depende, y esta es la respuesta matizada que se espera de un senior. Si TODAS tus peticiones necesitan la base de datos, ponerla en readiness es correcto: sin ella no puedes servir y es mejor devolver un 503 del balanceador que un 500 de tu aplicación. Pero cuidado con el efecto de rebaño: si la base de datos cae, todas las réplicas salen de rotación a la vez, el Service se queda sin endpoints y el ingress devuelve 503 para todo, incluidos los endpoints que sí funcionaban (un /health, una respuesta cacheada, una página de estado). Muchos equipos maduros optan por no poner las dependencias en readiness y manejar el fallo en la aplicación con un circuit breaker y degradación elegante (módulo 08), devolviendo 503 solo en los endpoints afectados. La readiness queda entonces para lo que fue diseñada: «¿ha terminado de arrancar y no está saturado?».
Tipo de probeSintaxisCuándo
httpGethttpGet: { path: /…, port: 8081 }Lo normal. No necesita nada dentro del contenedor: la ejecuta el kubelet. Éxito = código 200–399.
tcpSockettcpSocket: { port: 5432 }Servicios no HTTP. Solo comprueba que el puerto acepta conexiones, que es poco.
execexec: { command: [...] }Cuando no hay HTTP. El más caro: lanza un proceso en cada comprobación. Con Java, evítalo (arrancar una JVM cada 10 s es absurdo).
grpcgrpc: { port: 9090 }Servicios gRPC que implementan el protocolo de health estándar.
// Un indicador de health propio, bien hecho: rápido, con timeout y sin
// efectos secundarios. Nunca hagas una consulta pesada en un health check:
// se ejecuta cada 5 segundos en cada réplica, para siempre.
package com.ejemplo.pedidos.infra.health;

import org.springframework.boot.actuate.health.*;
import org.springframework.stereotype.Component;

@Component("catalogo")
class CatalogoHealthIndicator implements HealthIndicator {

    private final CircuitBreaker breaker;   // el mismo del cliente real

    CatalogoHealthIndicator(CircuitBreakerRegistry registry) {
        this.breaker = registry.circuitBreaker("catalogo");
    }

    @Override
    public Health health() {
        // ★ NO llamamos al catálogo aquí. Miramos el estado del circuito, que ya
        //   refleja la realidad del tráfico real y no añade ni una petición.
        //   Un health check que llama a un servicio externo multiplica su carga
        //   por (réplicas × 12 comprobaciones por minuto) sin aportar nada.
        var estado = breaker.getState();
        var metricas = breaker.getMetrics();
        return switch (estado) {
            case CLOSED, HALF_OPEN -> Health.up()
                    .withDetail("circuito", estado)
                    .withDetail("tasaFallo", metricas.getFailureRate())
                    .build();
            // OPEN = el catálogo está caído, pero NOSOTROS seguimos sirviendo
            // en modo degradado. Devolvemos UP con un aviso, no DOWN: si
            // devolviéramos DOWN, saldríamos de rotación sin necesidad.
            default -> Health.up()
                    .withDetail("circuito", estado)
                    .withDetail("aviso", "catálogo no disponible: modo degradado")
                    .build();
        };
    }
}

9.4 Apagado ordenado: la secuencia completa y por qué se pierden peticiones

Aquí está la explicación de los errores 502 durante los despliegues, y es una carrera que hay que entender con detalle porque no es intuitiva.

t=0    Kubernetes decide eliminar el pod (rollout, escalado, drenaje del nodo).
       A partir de este instante ocurren DOS COSAS EN PARALELO, y ahí está el problema:

       ┌─────────────────────────────┐   ┌──────────────────────────────────────┐
       │ CAMINO A (el rápido)        │   │ CAMINO B (el lento, ASÍNCRONO)       │
       │ El kubelet ejecuta preStop  │   │ El pod se marca «Terminating»        │
       │ y envía SIGTERM al PID 1    │   │  → el controlador de endpoints lo    │
       │                             │   │    quita del EndpointSlice           │
       │                             │   │  → kube-proxy de CADA nodo reprograma│
       │                             │   │    iptables/IPVS                     │
       │                             │   │  → el controlador de ingress recarga │
       │                             │   │    su lista de destinos              │
       │                             │   │  ⏱ TARDA entre 1 y 10 SEGUNDOS       │
       └─────────────────────────────┘   └──────────────────────────────────────┘

       ★ LA CARRERA: si tu aplicación cierra el puerto en 200 ms (camino A) pero el
         balanceador sigue mandándole peticiones durante 3 segundos (camino B),
         esas peticiones reciben «connection refused» ⇒ 502 al usuario.

       ★ LA SOLUCIÓN: preStop con un `sleep` que dé tiempo al camino B. Parece un
         apaño y es la recomendación oficial: no hay forma de que la aplicación
         sepa cuándo el último balanceador ha dejado de enviarle tráfico.

t=0    preStop: sleep 8
       Durante estos 8 segundos la aplicación SIGUE SIRVIENDO NORMALMENTE.
       El SIGTERM aún no se ha enviado. Es exactamente lo que queremos.

t=8    El kubelet envía SIGTERM al PID 1 (tu JVM).
       Spring Boot con server.shutdown=graceful:
         · deja de aceptar conexiones NUEVAS
         · espera a que terminen las peticiones EN CURSO
           (hasta spring.lifecycle.timeout-per-shutdown-phase)
         · ejecuta los @PreDestroy y cierra los beans en orden inverso
         · cierra el pool de Hikari, los consumidores de Kafka (commit de offsets),
           el planificador de tareas, los clientes HTTP

t=8+n  El proceso termina por su cuenta con código 143 (128+15). ✅ CORRECTO.

t=45   Si NO ha terminado (terminationGracePeriodSeconds):
       SIGKILL. Muerte inmediata, código 137, peticiones cortadas a medias,
       transacciones abiertas, offsets sin confirmar. ❌

═══ LA REGLA ARITMÉTICA QUE HAY QUE RESPETAR ═══════════════════════════════════
  terminationGracePeriodSeconds  ≥  preStop + timeout-per-shutdown-phase + margen
              45                 ≥      8    +          25              +   12  ✓

  Si te equivocas y la suma se pasa, SIGKILL llega en mitad del apagado ordenado
  y tienes lo peor de los dos mundos: lento Y con pérdida de peticiones.
# ── EL LADO DE KUBERNETES ───────────────────────────────────────────────────
spec:
  terminationGracePeriodSeconds: 45
  containers:
    - name: app
      lifecycle:
        preStop:
          # Opción A: sleep con la shell (necesita shell en la imagen)
          exec:
            command: ["sh", "-c", "sleep 8"]
          # Opción B (1.30+, MEJOR): sleep nativo, sin shell. Funciona en distroless.
          # sleep:
          #   seconds: 8
# ── EL LADO DE SPRING BOOT ──────────────────────────────────────────────────
server:
  shutdown: graceful                     # ★ por defecto es «immediate»
  tomcat:
    connection-timeout: 5s
spring:
  lifecycle:
    timeout-per-shutdown-phase: 25s      # ★ menor que el grace period menos preStop
  datasource:
    hikari:
      # Que Hikari no espere a conexiones colgadas al cerrar
      connection-timeout: 3000
  kafka:
    listener:
      # Confirmar los offsets antes de morir: sin esto, reprocesas mensajes
      immediate-stop: false
  task:
    execution:
      shutdown:
        await-termination: true
        await-termination-period: 20s     # espera a las tareas @Async en curso
    scheduling:
      shutdown:
        await-termination: true
        await-termination-period: 20s
// Trabajo pendiente en el apagado: hazlo con @PreDestroy o SmartLifecycle,
// nunca con un shutdown hook a pelo (Spring los ordena; los tuyos, no).
@Component
class ConsumidorDeCola implements SmartLifecycle {

    private static final Logger log = LoggerFactory.getLogger(ConsumidorDeCola.class);
    private volatile boolean corriendo = false;

    @Override public void start() { corriendo = true; }

    @Override
    public void stop() {
        log.info("Parando el consumidor: no se toman mensajes nuevos");
        corriendo = false;
        // Esperar a que los mensajes en vuelo terminen (con límite)
        // El bucle de consumo comprueba «corriendo» en cada iteración.
    }

    @Override public boolean isRunning() { return corriendo; }

    // Fase: los componentes con fase MÁS ALTA se paran ANTES. Queremos parar de
    // consumir antes de que se cierre el pool de la base de datos.
    @Override public int getPhase() { return Integer.MAX_VALUE - 100; }
}
# ── VERIFICAR QUE FUNCIONA: el test que casi nadie hace y que lo demuestra ──
# 1) Carga constante contra el servicio
hey -z 120s -c 50 https://api.ejemplo.com/pedidos > resultado.txt &
#   (o: vegeta attack -duration=120s -rate=100 | vegeta report)

# 2) Mientras corre, despliega
kubectl set image deploy/pedidos app=ghcr.io/ejemplo/pedidos@sha256:nuevo…
kubectl rollout status deploy/pedidos

# 3) Mira el resultado
grep -E 'Status code distribution' -A6 resultado.txt
#   [200] 11998 responses      ← ✅ objetivo: CERO respuestas 5xx
#   [502] 0 responses
#   [503] 0 responses

# 4) Y comprueba en los logs que el apagado fue ordenado
kubectl logs -l app.kubernetes.io/name=pedidos --previous --tail=40 | grep -i shut
#   Commencing graceful shutdown. Waiting for active requests to complete
#   Graceful shutdown complete
#   HikariPool-1 - Shutdown completed.

# 5) Y el código de salida: 143 es correcto, 137 es que llegó el SIGKILL
kubectl get pod pedidos-viejo -o jsonpath='{.status.containerStatuses[0].lastState.terminated.exitCode}'

# ─── Si sigues viendo 502, la lista de sospechosos en orden ────────────────
#   1. ENTRYPOINT en shell form: SIGTERM no llega a la JVM (sección 4.8)
#   2. Falta server.shutdown=graceful
#   3. Falta preStop, o es demasiado corto para tu ingress
#   4. El controlador de ingress mantiene conexiones keep-alive al pod muerto
#      → en nginx: nginx.ingress.kubernetes.io/upstream-keepalive-timeout
#   5. maxUnavailable > 0 con pocas réplicas: te quedas sin capacidad
#   6. La readiness del pod NUEVO pasa antes de que la app esté de verdad lista
#      (por ejemplo, la primera petición dispara la inicialización perezosa)

9.5 Requests, limits, calidad de servicio y desalojos

CampoQué hace exactamenteQuién lo usa
requests.cpu Reserva para el scheduler y peso relativo (cpu.weight) cuando hay contención. Es un mínimo garantizado, no un máximo. Scheduler + kernel
requests.memory Reserva para el scheduler. No limita nada en tiempo de ejecución. Scheduler
limits.cpu Cuota dura (cpu.max): al agotarla, el proceso se congela hasta la ventana siguiente. Kernel (CFS)
limits.memory memory.max: al superarlo, el OOM killer mata el proceso. Kernel
Clase de QoSCondiciónPrioridad al desalojarCuándo usarla
Guaranteed requests == limits para CPU y memoria, en todos los contenedores La última en ser desalojada Servicios críticos. Nota: exige poner límite de CPU, lo que choca con 5.5. Ver el matiz de abajo.
Burstable Tiene requests, pero no coinciden con limits (o falta alguno) Intermedia: se desaloja según cuánto se pasa de sus requests Lo habitual y recomendado: memoria con requests == limits y CPU solo con requests.
BestEffort Sin requests ni limits La primera en morir Nunca en producción. Un LimitRange lo evita por descuido (8.16).
El matiz de Guaranteed que casi nadie explica. Para tener QoS Guaranteed hace falta limits.cpu, y eso implica throttling (sección 5.5). En la práctica, la configuración recomendada para un servicio Java es memoria con requests == limits y CPU solo con requests, lo que da QoS Burstable. ¿Se pierde algo? En cuanto a desalojo por memoria, prácticamente nada: la fórmula de desalojo penaliza el uso por encima de las requests, y si tus requests de memoria igualan el límite, nunca estarás por encima. Lo que sí conviene añadir es una PriorityClass alta (8.17), que es un mecanismo más directo y más explícito para decir «este servicio importa».
# ── CÓMO ELEGIR LOS NÚMEROS (no los inventes) ────────────────────────────────
# 1. Despliega con valores generosos y observa una semana entera (incluye el
#    lunes por la mañana y el cierre de mes: los picos de verdad).
kubectl top pods -l app.kubernetes.io/name=pedidos --containers

# 2. Métricas de Prometheus: los percentiles, no la media
#    CPU (p95 de 7 días):
#      quantile_over_time(0.95, rate(container_cpu_usage_seconds_total{pod=~"pedidos-.*"}[5m])[7d:5m])
#    Memoria (máximo de 7 días — con la JVM el máximo es el número que importa):
#      max_over_time(container_memory_working_set_bytes{pod=~"pedidos-.*"}[7d])

# 3. Reglas prácticas
#    requests.cpu    = p95 de uso  (redondeado hacia arriba)
#    requests.memory = MÁXIMO observado × 1,25
#    limits.memory   = requests.memory   ← iguales, para no descubrir el techo
#                                          en el peor momento
#    limits.cpu      = sin poner (o ≥ 2× requests si la política obliga)

# 4. Confírmalo con el VPA en modo recomendación (8.13) tras 1-2 semanas.

# ── DESALOJOS POR PRESIÓN DEL NODO ──────────────────────────────────────────
# Cuando un NODO se queda sin memoria (no un pod), el kubelet desaloja pods
# según: QoS → cuánto se pasan de sus requests → prioridad.
kubectl get events -A --field-selector reason=Evicted
kubectl describe node nodo-2 | grep -A8 Conditions
#   MemoryPressure   True    ← el nodo está desalojando
#   DiskPressure     False
# ★ Un pod desalojado NO se reinicia: se borra y su controlador crea otro,
#   posiblemente en otro nodo. Si ves desalojos frecuentes, el problema es el
#   dimensionamiento del nodo o requests demasiado bajas en algún vecino.

9.6 Diagnóstico de un pod que falla: tabla de síntomas

SíntomaQué significaCausas por probabilidadComando exacto
Pending El scheduler no le encuentra nodo 1. Requests que no caben en ningún nodo. 2. Cuota del namespace agotada. 3. Taint sin toleration. 4. PVC sin volumen disponible (o en otra zona). 5. topologySpreadConstraints con DoNotSchedule imposible de cumplir. kubectl describe pod X | grep -A10 Events → busca FailedScheduling: dice exactamente qué predicado falló y en cuántos nodos.
ImagePullBackOff / ErrImagePull No puede descargar la imagen 1. Etiqueta o digest que no existe (typo). 2. Falta imagePullSecrets para un registro privado. 3. Límite de descargas de Docker Hub. 4. Plataforma equivocada (arm64 en un nodo amd64). kubectl describe pod X → el evento trae el error del registro literal. Prueba en un nodo: crictl pull IMAGEN.
CrashLoopBackOff Arranca y muere, en bucle con retardo creciente 1. Fallo de arranque de la app (config que falta, BD inaccesible). 2. Liveness demasiado agresiva. 3. OOMKilled repetido. 4. Migración de Flyway que falla. 5. ENTRYPOINT mal (código 127). kubectl logs X --previousel comando clave. Y describe para ver Last State y el Exit Code.
OOMKilled (exit 137) El kernel mató el proceso por superar limits.memory 1. MaxRAMPercentage demasiado alto para lo que hay fuera del heap. 2. Direct buffers sin límite. 3. Metaspace creciendo. 4. Fuga real. 5. Heapdump escrito en tmpfs. describe podLast State: Terminated, Reason: OOMKilled. Después: sección 5.3 (VM.native_memory).
CreateContainerConfigError La configuración del contenedor es imposible 1. Un ConfigMap o Secret referenciado no existe. 2. Una clave concreta no existe (optional: false). kubectl describe pod X → dice el nombre exacto de lo que falta.
CreateContainerError El runtime no puede crear el contenedor 1. El ejecutable del command no existe en la imagen. 2. Conflicto de puntos de montaje. describe; prueba la imagen en local con docker run.
Init:CrashLoopBackOff Un initContainer falla 1. La dependencia que espera no llega. 2. La migración falla. kubectl logs X -c nombre-del-init
Running pero 0/1 READY La readiness no pasa: no recibe tráfico 1. Puerto o ruta equivocados en la probe. 2. Un indicador de health en DOWN (mira /actuator/health). 3. La app aún arranca y no hay startupProbe. kubectl port-forward X 8081:8081 y curl -s localhost:8081/actuator/health/readiness | jq ← ves qué indicador está en DOWN.
Evicted El kubelet lo expulsó por presión del nodo 1. El nodo se quedó sin memoria o sin disco. 2. QoS BestEffort. 3. emptyDir superó su sizeLimit. describe pod X → mensaje con el recurso agotado. describe nodeMemoryPressure.
502 / 503 desde el ingress No hay destinos sanos 1. EndpointSlice vacío (readiness). 2. targetPort mal. 3. Selector del Service que no coincide. 4. NetworkPolicy que bloquea al ingress. kubectl get endpointslices -l kubernetes.io/service-name=pedidossi está vacío, ahí está.
504 desde el ingress El backend tarda más que el timeout del proxy 1. Timeout del ingress (60 s por defecto) menor que tu operación. 2. Pool de conexiones agotado. 3. Consulta lenta. Anotaciones de timeout del ingress; y jcmd 1 Thread.print para ver dónde esperan los hilos.
Reinicios sin OOM y sin errores en los logs La liveness lo está matando 1. Liveness con timeout demasiado corto para una JVM en pausa de GC. 2. Liveness que consulta una dependencia externa. describe pod → evento Unhealthy: Liveness probe failed. Sube timeoutSeconds y failureThreshold.
Latencia p99 terrible sin errores Throttling de CPU limits.cpu demasiado bajo para una JVM multihilo. kubectl exec X -- cat /sys/fs/cgroup/cpu.stat → mira nr_throttled.
El rollout se queda a medias Los pods nuevos no llegan a estar disponibles 1. Readiness que nunca pasa. 2. Cuota agotada. 3. PodSecurity rechaza el pod. 4. minReadySeconds con pods que se caen. kubectl rollout status, luego describe rs del ReplicaSet nuevo.
UnknownHostException en masa El DNS del clúster no responde 1. CoreDNS saturado o caído. 2. NetworkPolicy que bloquea el puerto 53. 3. ndots generando avalancha de NXDOMAIN. kubectl -n kube-system logs -l k8s-app=kube-dns; y probar con nicolaka/netshoot.
# ── EL SCRIPT DE TRIAJE: pégalo en tu runbook ────────────────────────────────
#!/usr/bin/env bash
# uso: triaje.sh  [namespace]
POD="$1"; NS="${2:-$(kubectl config view --minify -o jsonpath='{..namespace}')}"

echo "════ 1. ESTADO Y RAZÓN ════"
kubectl -n "$NS" get pod "$POD" -o custom-columns=\
'FASE:.status.phase,LISTO:.status.containerStatuses[0].ready,\
REINICIOS:.status.containerStatuses[0].restartCount,\
RAZON:.status.containerStatuses[0].state.*.reason,\
ULTIMA:.status.containerStatuses[0].lastState.terminated.reason,\
EXIT:.status.containerStatuses[0].lastState.terminated.exitCode,\
QOS:.status.qosClass,NODO:.spec.nodeName'

echo "════ 2. EVENTOS (la respuesta suele estar aquí) ════"
kubectl -n "$NS" describe pod "$POD" | sed -n '/^Events:/,$p'

echo "════ 3. LOGS DEL CONTENEDOR ACTUAL ════"
kubectl -n "$NS" logs "$POD" --tail=60 --all-containers 2>/dev/null

echo "════ 4. LOGS DEL CONTENEDOR QUE MURIÓ (lo más valioso) ════"
kubectl -n "$NS" logs "$POD" --previous --tail=80 2>/dev/null || echo "(no hay anterior)"

echo "════ 5. RECURSOS Y LÍMITES ════"
kubectl -n "$NS" get pod "$POD" -o jsonpath=\
'{range .spec.containers[*]}{.name}: req={.resources.requests} lim={.resources.limits}{"\n"}{end}'
kubectl -n "$NS" top pod "$POD" --containers 2>/dev/null

echo "════ 6. ¿RECIBE TRÁFICO? ════"
APP=$(kubectl -n "$NS" get pod "$POD" -o jsonpath='{.metadata.labels.app\.kubernetes\.io/name}')
kubectl -n "$NS" get endpointslices -l "kubernetes.io/service-name=$APP" -o wide 2>/dev/null

echo "════ 7. THROTTLING DE CPU ════"
kubectl -n "$NS" exec "$POD" -- sh -c 'cat /sys/fs/cgroup/cpu.stat 2>/dev/null' 2>/dev/null

echo "════ 8. MEMORIA SEGÚN EL KERNEL ════"
kubectl -n "$NS" exec "$POD" -- sh -c \
  'echo "max: $(cat /sys/fs/cgroup/memory.max)"; \
   echo "actual: $(cat /sys/fs/cgroup/memory.current)"; \
   echo "pico: $(cat /sys/fs/cgroup/memory.peak 2>/dev/null)"; \
   cat /sys/fs/cgroup/memory.events' 2>/dev/null

10 · Empaquetado y despliegue declarativo: Helm, Kustomize y GitOps

Un servicio Spring Boot bien desplegado en Kubernetes son unas 300 líneas de YAML repartidas en 8 objetos. Multiplícalo por 4 entornos y por 15 servicios: 18.000 líneas, el 90 % idénticas. Copiar y pegar no escala, y las copias divergen. Esta sección trata de las tres formas de resolverlo y de cómo se despliega de verdad en 2026.

10.1 kubectl apply frente a plantillas

Antes de elegir herramienta, hay que entender qué hace kubectl apply, porque todas las demás terminan llamándolo (o hablando con la misma API).

ComandoSemánticaCuándo usarlo
kubectl create -f Falla si el objeto ya existe. Casi nunca. Solo para objetos de un solo uso, como un Job con nombre generado.
kubectl replace -f Sustituye el objeto entero; pierde los campos que otros controladores hubieran puesto. Nunca en un pipeline.
kubectl apply -f Fusión declarativa. Compara tu manifiesto con la última configuración aplicada y con el objeto vivo, y calcula un parche. Los campos que tú no gestionas (por ejemplo, el número de réplicas que puso el HPA) se respetan. Siempre. Es la base del modelo declarativo.
kubectl apply --server-side Server-Side Apply: el servidor registra qué campo gestiona cada actor (managedFields) y detecta conflictos explícitamente. Recomendado en herramientas automatizadas (Argo CD lo usa). Evita el borrado accidental de campos que gestiona otro controlador.
kubectl diff -f Muestra qué cambiaría el apply, sin aplicarlo. Antes de cada apply manual. Es el terraform plan de Kubernetes.
El problema del apply con ficheros borrados. Si eliminas un fichero YAML del repositorio y ejecutas kubectl apply -f k8s/, el objeto sigue existiendo en el clúster: nadie le ha dicho que lo borre. Esto acumula basura invisible (un Service huérfano, una NetworkPolicy antigua que sigue bloqueando tráfico). Soluciones: kubectl apply --prune -l app.kubernetes.io/part-of=pedidos (delicado, borra lo que no coincida), o usar una herramienta que lleve inventario: Helm lo hace con su release, Argo CD con su Application. Es una de las razones más fuertes para no quedarse en kubectl apply a pelo.
EnfoqueCómo maneja «lo que cambia por entorno»Su punto débil
YAML plano duplicado por entorno Copiar los ficheros y editar los valores a mano. Divergen. Un arreglo aplicado en staging se olvida en producción y aparece el bug que «solo pasa en producción».
envsubst / sed en un script Marcadores ${VERSION} sustituidos por el pipeline. Vale para dos valores. Sin validación ni tipos: si falta una variable, generas YAML corrupto en silencio.
Helm Plantillas Go + un values.yaml por entorno. Las plantillas se vuelven ilegibles: dependes de la indentación y de la lógica de plantilla a la vez.
Kustomize Una base de YAML válido más parches por entorno. Sin condicionales ni bucles: la lógica compleja no cabe.
Generar YAML con código (cdk8s, jsonnet, Pulumi) Un lenguaje real, con tipos y tests unitarios del manifiesto. Otra herramienta y otra curva; menos gente del equipo sabe leerlo.
La recomendación práctica. Si consumes software de terceros (PostgreSQL, Prometheus, ingress-nginx, Kafka), usa Helm: es el formato en el que se distribuye todo y no tienes elección real. Si despliegas tus propios servicios, usa Kustomize o un único chart interno parametrizado que sirva para los 15 servicios. Lo que no debes hacer es escribir un chart artesanal por servicio: acabas manteniendo 15 copias de la misma plantilla con derivas sutiles y nadie se atreve a tocarlas.

10.2 Helm: el gestor de paquetes de Kubernetes

Helm hace tres cosas: renderiza plantillas a YAML, instala el resultado guardando un inventario de lo que instaló (la release, almacenada en un Secret del namespace) y permite volver atrás a una revisión anterior. Esa segunda parte —el inventario— es la diferencia real con kubectl apply.

Estructura de un chart

pedidos-chart/
├── Chart.yaml              metadatos del chart y sus dependencias
├── values.yaml             VALORES POR DEFECTO (todos documentados con un comentario)
├── values.schema.json      ★ esquema JSON: valida los values ANTES de renderizar
├── values-staging.yaml
├── values-produccion.yaml
├── templates/
│   ├── _helpers.tpl        funciones reutilizables (nombres, etiquetas comunes)
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── configmap.yaml
│   ├── serviceaccount.yaml
│   ├── hpa.yaml
│   ├── pdb.yaml
│   ├── job-migracion.yaml  con hook pre-upgrade
│   ├── NOTES.txt           lo que se imprime tras instalar (URL, siguientes pasos)
│   └── tests/
│       └── test-conexion.yaml   pod que se ejecuta con `helm test`
├── charts/                 subcharts descargados por `helm dependency update`
└── .helmignore
# Chart.yaml
apiVersion: v2
name: pedidos
description: Servicio de pedidos (Spring Boot 3.5)
type: application

# version    = versión DEL CHART (cambia cuando cambias las plantillas)
# appVersion = versión de la APLICACIÓN (la etiqueta de la imagen por defecto)
# Son independientes a propósito: puedes corregir una plantilla sin tocar la app.
version: 2.3.1
appVersion: "1.4.2"

# Dependencias: subcharts que se instalan con este. Útil para dependencias de
# desarrollo; en producción la base de datos NO debería vivir en un chart.
dependencies:
  - name: postgresql
    version: "16.2.1"
    repository: https://charts.bitnami.com/bitnami
    condition: postgresql.enabled     # se instala solo si postgresql.enabled=true

maintainers:
  - name: equipo-pedidos
    email: pedidos@ejemplo.com
# values.yaml — los valores por defecto. Regla de oro: que instalar el chart
# SIN pasar ningún -f funcione en un clúster local y no arranque nada peligroso.
replicaCount: 2

image:
  repository: ghcr.io/ejemplo/pedidos
  pullPolicy: IfNotPresent
  # Vacío a propósito: si no se pasa, Helm usa .Chart.AppVersion.
  # En el pipeline pasamos el DIGEST, no la etiqueta.
  tag: ""
  digest: ""

imagePullSecrets: []

serviceAccount:
  create: true
  name: ""
  annotations: {}        # aquí va el rol de IRSA / Workload Identity

podAnnotations: {}
podLabels: {}

podSecurityContext:
  runAsNonRoot: true
  runAsUser: 10001
  fsGroup: 10001
  seccompProfile:
    type: RuntimeDefault

securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]

service:
  type: ClusterIP
  port: 8080
  managementPort: 8081

ingress:
  enabled: false
  className: nginx
  annotations: {}
  hosts:
    - host: pedidos.local
      paths:
        - path: /
          pathType: Prefix
  tls: []

resources:
  requests:
    cpu: 500m
    memory: 1Gi
  limits:
    memory: 1Gi          # sin límite de CPU a propósito (ver sección 9)

autoscaling:
  enabled: false
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

pdb:
  enabled: false
  minAvailable: 1

# --- Configuración de la aplicación ---
app:
  profile: default
  logLevel: INFO
  jvm:
    maxRamPercentage: 70.0
    extraOpts: ""
  catalogo:
    url: http://catalogo:8080
    timeoutMs: 2000
  db:
    host: postgresql
    port: 5432
    name: pedidos
    poolMax: 10
    # El secreto NO se define aquí: se referencia un Secret existente,
    # creado por External Secrets o por Terraform.
    existingSecret: pedidos-db
    userKey: username
    passwordKey: password

migracion:
  enabled: true          # Job con hook pre-upgrade que ejecuta Flyway

postgresql:
  enabled: false         # true solo en local/CI
// values.schema.json — Helm valida los values contra este esquema antes de
// renderizar. Convierte un error silencioso ("puse replicaCount: dos") en un
// mensaje claro en el pipeline. Cuesta 20 minutos escribirlo y ahorra horas.
{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["image", "resources"],
  "properties": {
    "replicaCount": { "type": "integer", "minimum": 1, "maximum": 100 },
    "image": {
      "type": "object",
      "required": ["repository"],
      "properties": {
        "repository": { "type": "string", "minLength": 1 },
        "tag": { "type": "string" },
        "digest": { "type": "string", "pattern": "^(sha256:[a-f0-9]{64})?$" },
        "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] }
      }
    },
    "app": {
      "type": "object",
      "properties": {
        "logLevel": { "enum": ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"] },
        "jvm": {
          "type": "object",
          "properties": {
            "maxRamPercentage": { "type": "number", "minimum": 25, "maximum": 90 }
          }
        }
      }
    }
  }
}

Las plantillas: _helpers.tpl y el deployment

{{/* templates/_helpers.tpl — nombres y etiquetas coherentes en todo el chart */}}

{{- define "pedidos.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{/* Nombre completo: -, salvo que el release ya lo contenga. */}}
{{- define "pedidos.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}

{{/* Etiquetas recomendadas por Kubernetes. Van en TODOS los objetos. */}}
{{- define "pedidos.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
{{ include "pedidos.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: tienda
{{- end -}}

{{/* Selector: SOLO campos inmutables. Si metes la versión aquí, el
     Deployment deja de poder actualizarse (selector es inmutable). */}}
{{- define "pedidos.selectorLabels" -}}
app.kubernetes.io/name: {{ include "pedidos.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}

{{/* Referencia de imagen: digest si está, si no etiqueta, si no appVersion. */}}
{{- define "pedidos.image" -}}
{{- if .Values.image.digest -}}
{{ .Values.image.repository }}@{{ .Values.image.digest }}
{{- else -}}
{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
{{- end -}}
{{- end -}}
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "pedidos.fullname" . }}
  labels:
    {{- include "pedidos.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  {{- /* ★ CLAVE: si el HPA está activo NO emitimos replicas. Si lo emites,
         cada `helm upgrade` devuelve el Deployment al valor del chart y
         deshace el escalado del HPA: la app se cae a 2 réplicas en el pico. */}}
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  minReadySeconds: 10
  selector:
    matchLabels:
      {{- include "pedidos.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      annotations:
        {{- /* ★ Este hash fuerza el reinicio de los pods cuando cambia el
               ConfigMap. Sin él, cambias config, haces upgrade y NADA pasa:
               el Deployment es idéntico, así que no hay rollout. */}}
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
        {{- with .Values.podAnnotations }}
        {{- toYaml . | nindent 8 }}
        {{- end }}
      labels:
        {{- include "pedidos.selectorLabels" . | nindent 8 }}
    spec:
      serviceAccountName: {{ include "pedidos.fullname" . }}
      terminationGracePeriodSeconds: 40
      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: app
          image: {{ include "pedidos.image" . }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          securityContext:
            {{- toYaml .Values.securityContext | nindent 12 }}
          ports:
            - { name: http, containerPort: 8080 }
            - { name: management, containerPort: 8081 }
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: {{ .Values.app.profile | quote }}
            - name: JAVA_TOOL_OPTIONS
              value: >-
                -XX:MaxRAMPercentage={{ .Values.app.jvm.maxRamPercentage }}
                {{ .Values.app.jvm.extraOpts }}
            - name: DB_URL
              value: jdbc:postgresql://{{ .Values.app.db.host }}:{{ .Values.app.db.port }}/{{ .Values.app.db.name }}
            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: {{ .Values.app.db.existingSecret }}
                  key: {{ .Values.app.db.userKey }}
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: {{ .Values.app.db.existingSecret }}
                  key: {{ .Values.app.db.passwordKey }}
          envFrom:
            - configMapRef:
                name: {{ include "pedidos.fullname" . }}
          startupProbe:
            httpGet: { path: /actuator/health/liveness, port: management }
            periodSeconds: 3
            failureThreshold: 40
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: management }
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet: { path: /actuator/health/liveness, port: management }
            periodSeconds: 15
            failureThreshold: 3
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          volumeMounts:
            - { name: tmp, mountPath: /tmp }
      volumes:
        - name: tmp
          emptyDir: { sizeLimit: 512Mi }
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              {{- include "pedidos.selectorLabels" . | nindent 14 }}
Los dos trucos de esa plantilla que se preguntan en entrevista. (1) El checksum/config: sin esa anotación, cambiar un ConfigMap no reinicia nada, porque el Deployment no ha cambiado y Kubernetes no tiene motivo para hacer rollout. (2) El {{- if not .Values.autoscaling.enabled }} alrededor de replicas: si emites replicas: 2 con un HPA activo, cada despliegue tira el escalado por la borda justo cuando más tráfico tienes. Los dos son errores clásicos en charts escritos a mano.

Hooks: el Job de migración antes del upgrade

# templates/job-migracion.yaml
{{- if .Values.migracion.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "pedidos.fullname" . }}-migracion-{{ .Release.Revision }}
  labels:
    {{- include "pedidos.labels" . | nindent 4 }}
  annotations:
    # pre-install: en la primera instalación. pre-upgrade: en cada actualización.
    "helm.sh/hook": pre-install,pre-upgrade
    # El peso ordena varios hooks del mismo tipo (menor primero).
    "helm.sh/hook-weight": "-5"
    # ★ IMPORTANTE: SIN before-hook-creation NO se borra el Job anterior y el
    #   upgrade falla por nombre duplicado. Con hook-succeeded se borra el Job
    #   al terminar bien... y pierdes sus logs. Elige a conciencia:
    #   en producción prefiero conservarlos.
    "helm.sh/hook-delete-policy": before-hook-creation
spec:
  backoffLimit: 2
  activeDeadlineSeconds: 900
  ttlSecondsAfterFinished: 86400
  template:
    metadata:
      labels:
        {{- include "pedidos.selectorLabels" . | nindent 8 }}
        job: migracion
    spec:
      restartPolicy: Never
      serviceAccountName: {{ include "pedidos.fullname" . }}
      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: flyway
          # MISMA imagen que la aplicación: mismas migraciones, misma versión.
          image: {{ include "pedidos.image" . }}
          args:
            - --spring.main.web-application-type=none
            - --spring.flyway.enabled=true
            - --spring.jpa.hibernate.ddl-auto=none
          env:
            - name: DB_URL
              value: jdbc:postgresql://{{ .Values.app.db.host }}:{{ .Values.app.db.port }}/{{ .Values.app.db.name }}
            - name: DB_USER
              valueFrom:
                secretKeyRef: { name: {{ .Values.app.db.existingSecret }}, key: {{ .Values.app.db.userKey }} }
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef: { name: {{ .Values.app.db.existingSecret }}, key: {{ .Values.app.db.passwordKey }} }
          resources:
            requests: { cpu: 200m, memory: 512Mi }
            limits: { memory: 512Mi }
{{- end }}
HookCuándo se ejecutaUso típico en Java
pre-installAntes de crear los objetos, en la primera instalación.Crear el esquema inicial de base de datos.
post-installTras crear los objetos (no espera a que estén listos, salvo con --wait).Registrar el servicio en un catálogo interno.
pre-upgradeAntes de aplicar los cambios de una actualización.Migración Flyway compatible hacia atrás.
post-upgradeDespués de aplicar los cambios.Invalidar una caché, avisar a Slack.
pre-rollback / post-rollbackAlrededor de un helm rollback.Casi nunca: revertir migraciones automáticamente es peligroso.
pre-delete / post-deleteAl desinstalar.Volcar datos antes de borrar, desregistrar del descubrimiento.
testSolo con helm test.Pod que llama a /actuator/health y a un endpoint real.
Los hooks no están dentro de la transacción. Si el pre-upgrade falla, Helm aborta el upgrade… pero lo que el hook ya hizo en la base de datos sigue hecho. Por eso las migraciones deben ser compatibles hacia atrás (sección 11.6): así, aunque el despliegue se aborte y la versión antigua siga corriendo, el esquema nuevo no la rompe. Y por eso helm rollback revierte manifiestos, no datos.

Comandos de Helm que usarás de verdad

## --- Desarrollo del chart ---
# Renderiza sin instalar nada: lo primero que haces al escribir una plantilla.
helm template pedidos ./pedidos-chart -f values-produccion.yaml

# Igual, pero valida contra la API del clúster (detecta apiVersion inexistentes,
# campos mal escritos, CRDs que faltan). Necesita conexión al clúster.
helm template pedidos ./pedidos-chart --validate

# Linter: estructura del chart, values.schema.json, buenas prácticas.
helm lint ./pedidos-chart -f values-produccion.yaml

# Simula la instalación contra el servidor sin persistir nada.
helm install pedidos ./pedidos-chart --dry-run=server -f values-produccion.yaml

# Descargar dependencias declaradas en Chart.yaml -> charts/ y Chart.lock
helm dependency update ./pedidos-chart

## --- Despliegue ---
# EL comando. --install lo hace idempotente: instala si no existe, actualiza si sí.
# Es lo único que debe ejecutar tu pipeline.
helm upgrade --install pedidos ./pedidos-chart \
  --namespace tienda --create-namespace \
  -f values-produccion.yaml \
  --set image.digest=sha256:9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e \
  --atomic \
  --timeout 8m \
  --wait-for-jobs

## Qué hace cada bandera y por qué importa:
#  --atomic         si algo falla o se agota el timeout, hace ROLLBACK automático.
#                   Implica --wait. Es la bandera más valiosa de Helm: sin ella
#                   te quedas a medias, con la mitad de los pods en la versión nueva.
#  --wait           espera a que Deployments/StatefulSets estén Ready (usa readiness).
#  --wait-for-jobs  espera también a los Jobs (la migración) antes de dar por bueno.
#  --timeout        cuánto espera. Ponlo mayor que (arranque de la app x réplicas).
#  --set            sobrescribe un valor. Para el DIGEST de la imagen que sale de CI.
#  --set-string     igual, pero sin interpretar tipos (para "123" o "true" literales).
#  --create-namespace  crea el namespace si no existe.

## --- Operación e incidentes ---
helm list -n tienda                        # releases y su estado (deployed/failed)
helm history pedidos -n tienda             # revisiones: quién, cuándo, qué versión
helm get values pedidos -n tienda          # los values EFECTIVOS que se usaron
helm get manifest pedidos -n tienda        # el YAML final que se aplicó
helm get notes pedidos -n tienda
helm diff upgrade pedidos ./pedidos-chart -f values-produccion.yaml   # plugin helm-diff

# ROLLBACK: a la revisión anterior, o a una concreta. Segundos.
helm rollback pedidos -n tienda            # revisión anterior
helm rollback pedidos 7 -n tienda --wait   # a la revisión 7

# Test post-despliegue (ejecuta los pods de templates/tests/)
helm test pedidos -n tienda --logs

helm uninstall pedidos -n tienda --keep-history
Release en estado pending-upgrade: el atasco clásico. Si el pipeline se cancela a mitad de un helm upgrade (timeout del runner, alguien cancela el job), la release queda en pending-upgrade y todos los intentos siguientes fallan con «another operation is in progress». Solución: helm rollback pedidos -n tienda para volver a la última revisión buena; si eso también falla, helm history para ver la última deployed y helm rollback pedidos N. Prevención: concurrency en el workflow (sección 11) para que no haya dos despliegues del mismo servicio a la vez, y un --timeout menor que el timeout del job de CI.

10.3 Kustomize: bases y overlays sin plantillas

Kustomize parte de una idea distinta: los ficheros de la base son YAML de Kubernetes válido —los puedes aplicar tal cual, los valida tu IDE, los entiende cualquiera— y cada entorno aplica parches encima. No hay lenguaje de plantillas, así que no hay indentación mágica ni {{- if }} anidados. Viene incluido en kubectl (kubectl apply -k).

k8s/
├── base/
│   ├── kustomization.yaml
│   ├── deployment.yaml        ← YAML normal, aplicable tal cual
│   ├── service.yaml
│   ├── serviceaccount.yaml
│   └── configmap.yaml
└── overlays/
    ├── local/
    │   ├── kustomization.yaml
    │   └── parche-recursos.yaml
    ├── staging/
    │   ├── kustomization.yaml
    │   ├── parche-recursos.yaml
    │   └── ingress.yaml
    └── produccion/
        ├── kustomization.yaml
        ├── parche-recursos.yaml
        ├── parche-topologia.yaml
        ├── hpa.yaml
        ├── pdb.yaml
        └── ingress.yaml
# k8s/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - deployment.yaml
  - service.yaml
  - serviceaccount.yaml

# Etiquetas añadidas a TODOS los objetos y a los selectores de los que las usan.
labels:
  - includeSelectors: true
    pairs:
      app.kubernetes.io/name: pedidos
      app.kubernetes.io/part-of: tienda

# Generador de ConfigMap: calcula un SUFIJO HASH con el contenido.
# Consecuencia clave: al cambiar un valor, el ConfigMap pasa a llamarse
# pedidos-config-7f9k2m4t8c y el Deployment que lo referencia cambia -> rollout
# automático. Es el equivalente al checksum/config de Helm, pero gratis.
configMapGenerator:
  - name: pedidos-config
    literals:
      - CATALOGO_TIMEOUT_MS=2000
      - TOMCAT_MAX_THREADS=200
    files:
      - application-extra.yml=config/application-extra.yml

# Los secretos NO se generan aquí a partir de literales (acabarían en Git).
# Se referencian por nombre; los crea External Secrets o Terraform.

images:
  - name: ghcr.io/ejemplo/pedidos
    newTag: 1.4.2
# k8s/overlays/produccion/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

namespace: tienda-prod
namePrefix: prod-

resources:
  - ../../base
  - hpa.yaml            # objetos que SOLO existen en producción
  - pdb.yaml
  - ingress.yaml

# Etiquetas y anotaciones comunes de este entorno
labels:
  - pairs:
      entorno: produccion
commonAnnotations:
  contacto: pedidos@ejemplo.com

replicas:
  - name: pedidos
    count: 6

# ★ La imagen por DIGEST: lo que el pipeline modifica con
#   `kustomize edit set image ghcr.io/ejemplo/pedidos@sha256:...`
images:
  - name: ghcr.io/ejemplo/pedidos
    digest: sha256:9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e

# Parches estratégicos: fragmentos de YAML que se fusionan por nombre/tipo
patches:
  - path: parche-recursos.yaml
  - path: parche-topologia.yaml
  # Parche en línea con JSON 6902 para operaciones precisas (add/replace/remove)
  - target:
      kind: Deployment
      name: pedidos
    patch: |-
      - op: replace
        path: /spec/template/spec/containers/0/env/0/value
        value: produccion
      - op: add
        path: /spec/template/metadata/annotations/prometheus.io~1scrape
        value: "true"

configMapGenerator:
  - name: pedidos-config
    behavior: merge          # merge | replace | create
    literals:
      - CATALOGO_TIMEOUT_MS=1500
      - LOG_LEVEL=INFO
# k8s/overlays/produccion/parche-recursos.yaml
# Parche estratégico: solo los campos que quieres cambiar. Kustomize fusiona
# por nombre dentro de las listas que tienen clave de fusión (containers/name).
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pedidos
spec:
  template:
    spec:
      containers:
        - name: app                # ← la clave de fusión: identifica el elemento
          resources:
            requests:
              cpu: "1"
              memory: 2Gi
            limits:
              memory: 2Gi
          env:
            - name: JAVA_TOOL_OPTIONS
              value: >-
                -XX:MaxRAMPercentage=70
                -XX:+UseG1GC
                -XX:+HeapDumpOnOutOfMemoryError
                -XX:HeapDumpPath=/tmp/heapdump.hprof
# Renderizar y revisar (¡siempre antes de aplicar!)
kubectl kustomize k8s/overlays/produccion
kubectl kustomize k8s/overlays/produccion | kubectl diff -f -

# Aplicar
kubectl apply -k k8s/overlays/produccion

# Lo que hace el pipeline: fijar el digest y confirmar en el repo de manifiestos
cd k8s/overlays/produccion
kustomize edit set image ghcr.io/ejemplo/pedidos@sha256:9f8e7d…

# Comparar dos entornos: revela derivas que nadie recordaba
diff <(kubectl kustomize k8s/overlays/staging) \
     <(kubectl kustomize k8s/overlays/produccion)
El generador de ConfigMap con hash es la mejor idea de Kustomize. Resuelve gratis el problema que en Helm necesita el truco del checksum/config: si cambias un valor de configuración, el nombre del ConfigMap cambia, el Deployment que lo referencia cambia y se produce un rolling update con la garantía de siempre (readiness, maxUnavailable: 0). Además los ConfigMaps antiguos quedan ahí, lo que hace que un rollback del manifiesto también revierta la configuración.

10.4 Helm frente a Kustomize: cuándo cada uno

CriterioHelmKustomize
ModeloPlantillas de texto (Go templates) → YAMLYAML válido + parches estructurados
Legibilidad de la fuenteBaja en charts grandes: mezcla lógica e indentaciónAlta: la base es YAML normal
Condicionales y buclesSí (if, range, with)No. Si necesitas lógica, otro overlay o un generador
InstalaciónBinario aparteIncluido en kubectl
Inventario / borrado de lo que sobra: la release conoce sus objetos; uninstall los borraNo por sí mismo (Argo CD o Flux lo aportan)
Rollback: helm rollback, y --atomic automáticoRevertir el commit y volver a aplicar
Espera y verificación--wait, --atomic, --wait-for-jobskubectl rollout status a mano
Hooks y ordenSí, con pesosNo (usa Jobs con ttlSecondsAfterFinished o Argo CD waves)
Validación de entradasvalues.schema.jsonLo valida el esquema de Kubernetes al aplicar
Distribución a tercerosEl estándar de facto (repos, OCI registries)No pensado para eso
Encaje con GitOpsBueno (Argo/Flux renderizan charts)Excelente: el diff en el PR es legible
CurvaMedia-altaBaja para lo básico, media para parches JSON
La respuesta madura en una entrevista. «No son excluyentes: la combinación más habitual es Helm para el software de terceros y Kustomize para nuestros servicios. Y si hay que elegir uno solo para un equipo pequeño, Kustomize, porque el YAML sigue siendo legible y el diff del pull request se entiende de un vistazo, que es donde se detectan los errores. Helm gana cuando publicas para otros equipos o cuando necesitas condicionales de verdad; y su --atomic es difícil de igualar sin un operador GitOps detrás.» También es válido decir que Argo CD puede renderizar un chart de Helm y aplicarle parches de Kustomize encima, que es la salida pragmática.

10.5 GitOps: Git como única fuente de verdad

En un CD clásico (modelo push) el pipeline tiene credenciales del clúster y ejecuta kubectl apply. Funciona, pero tiene tres problemas: el runner de CI necesita permisos amplios sobre producción; nadie detecta que alguien tocó el clúster a mano; y el estado real no está descrito en ningún sitio consultable.

En GitOps (modelo pull) un agente dentro del clúster observa un repositorio Git y reconcilia continuamente: si el clúster no coincide con Git, lo corrige. El pipeline ya no despliega, solo confirma un cambio en un repositorio.

MODELO PUSH (CD clásico)                MODELO PULL (GitOps)
──────────────────────────              ────────────────────────────────
 GitHub Actions                          GitHub Actions
   │ tiene KUBECONFIG de prod              │ construye la imagen
   │ kubectl apply / helm upgrade          │ commit: digest nuevo en el repo
   ▼                                       ▼      de manifiestos
 ┌───────────┐                          ┌─────────────────┐
 │  clúster  │                          │ repo manifiestos│ (Git = verdad)
 └───────────┘                          └────────┬────────┘
                                                 │ el agente hace PULL
 · CI necesita credenciales de prod              ▼ cada 3 min o por webhook
 · el drift no se detecta                 ┌──────────────┐
 · el estado real no está descrito        │ Argo CD/Flux │  dentro del clúster
                                          └──────┬───────┘
                                                 │ apply + reconciliación
                                                 ▼
                                          ┌───────────┐
                                          │  clúster  │
                                          └───────────┘
 · CI NO tiene credenciales del clúster
 · el drift se detecta y (opcionalmente) se corrige solo
 · el historial de despliegues = historial de Git
 · rollback = git revert
Principio de GitOpsQué significa en la práctica
DeclarativoTodo el sistema se describe con datos (YAML), no con pasos.
Versionado e inmutableGit es la fuente de verdad; cada estado deseado tiene un commit con autor y fecha.
Aplicado automáticamenteEl agente lleva el clúster al estado de Git sin intervención humana.
Reconciliado continuamenteNo es un evento, es un bucle: detecta y corrige la deriva mientras el sistema vive.
# Argo CD: una Application por servicio y entorno.
# Este objeto vive en el clúster y también en Git (patrón "app of apps").
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: pedidos-produccion
  namespace: argocd
  finalizers:
    # Al borrar la Application, borra también sus recursos (cascada).
    - resources-finalizer.argocd.argoproj.io
spec:
  project: tienda

  source:
    repoURL: https://github.com/ejemplo/manifiestos.git
    targetRevision: main            # rama, tag o SHA. Para prod, mejor un tag.
    path: pedidos/overlays/produccion   # ← Kustomize; Argo lo detecta solo

  destination:
    server: https://kubernetes.default.svc
    namespace: tienda-prod

  syncPolicy:
    automated:
      prune: true       # borra del clúster lo que se elimina de Git
      selfHeal: true    # revierte cambios hechos a mano en el clúster
      allowEmpty: false
    syncOptions:
      - CreateNamespace=true
      - ServerSideApply=true
      - PruneLast=true              # borra al final, tras crear lo nuevo
      - RespectIgnoreDifferences=true
    retry:
      limit: 5
      backoff: { duration: 15s, factor: 2, maxDuration: 5m }

  # ★ Sin esto, el HPA y Argo pelean eternamente por el campo replicas:
  #   Argo ve "replicas: 6" en Git, el HPA pone 11, Argo lo devuelve a 6...
  ignoreDifferences:
    - group: apps
      kind: Deployment
      jsonPointers:
        - /spec/replicas

  revisionHistoryLimit: 20
# Alternativa con Helm desde el mismo Argo CD
spec:
  source:
    repoURL: https://github.com/ejemplo/manifiestos.git
    targetRevision: main
    path: charts/pedidos
    helm:
      releaseName: pedidos
      valueFiles:
        - values.yaml
        - values-produccion.yaml
      parameters:
        - name: image.digest
          value: sha256:9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e
      skipCrds: false
# Sync waves: ordenar el despliegue dentro de una Application.
# Se declaran como anotación en cada objeto; Argo espera a que la ola anterior
# esté sana antes de pasar a la siguiente. Sustituye a los hooks de Helm.
#   argocd.argoproj.io/sync-wave: "-1"   → Job de migración Flyway
#   argocd.argoproj.io/sync-wave: "0"    → ConfigMap, Secret, ServiceAccount
#   argocd.argoproj.io/sync-wave: "1"    → Deployment
#   argocd.argoproj.io/sync-wave: "2"    → Service, Ingress, HPA

# Hooks de Argo (equivalentes a los de Helm):
#   argocd.argoproj.io/hook: PreSync | Sync | PostSync | SyncFail
#   argocd.argoproj.io/hook-delete-policy: HookSucceeded | BeforeHookCreation

# CLI útil en incidentes
argocd app list
argocd app get pedidos-produccion
argocd app diff pedidos-produccion            # deriva entre Git y el clúster
argocd app sync pedidos-produccion --prune
argocd app history pedidos-produccion
argocd app rollback pedidos-produccion 42     # a una revisión anterior
argocd app set pedidos-produccion --sync-policy none   # congelar en un incidente
Concepto de Argo CDQué esPor qué te importa
Sync status (Synced / OutOfSync) ¿Coincide el clúster con Git? OutOfSync sin despliegue en curso = alguien tocó el clúster a mano, o hay un controlador que muta objetos.
Health status (Healthy, Degraded, Progressing) ¿Están sanos los objetos? Para un Deployment usa la readiness. Puedes estar Synced y Degraded: el YAML correcto aplicado, pero los pods sin arrancar.
Drift Diferencia entre el estado real y Git. Con selfHeal: true se corrige automáticamente. Efecto secundario: tu kubectl edit de emergencia se deshace en 3 minutos. Para intervenir, primero desactiva la sincronización.
App of apps Una Application que apunta a un directorio con más Application. Añadir un servicio nuevo = añadir un fichero. Escala a decenas de servicios.
ApplicationSet Genera N Application desde una plantilla y un generador (lista, directorios de Git, pull requests, clústeres). Entornos efímeros por PR y despliegue del mismo servicio en varios clústeres o regiones.
AppProject Frontera de permisos: qué repos, qué clústeres, qué tipos de objeto puede tocar un grupo. Evita que el equipo de pedidos despliegue un ClusterRole por accidente.
Dos repositorios, no uno. La práctica recomendada es separar el repositorio de código del de manifiestos. Motivos: (1) si están juntos, el commit automático del pipeline que actualiza el digest dispara otro build, que hace otro commit… bucle infinito (se puede cortar con [skip ci], pero es frágil); (2) el historial de despliegues queda limpio y auditable; (3) puedes dar permisos distintos —quien aprueba un cambio en producción no es necesariamente quien escribe el código; (4) revertir un despliegue no revierte código. El precio es que el cambio se ve en dos PR, y que necesitas trazar de un commit de manifiesto al commit de código (mete la SHA del código en una anotación).

Secretos en Git: las tres opciones reales

GitOps exige que todo el estado deseado esté en Git. Pero los secretos no pueden estar en Git en claro. Las tres soluciones, de menos a más recomendable en 2026:

SoluciónCómo funcionaVentajasInconvenientes
Sealed Secrets Un controlador en el clúster tiene una clave privada. Tú cifras con la pública (kubeseal) y confirmas un SealedSecret en Git; el controlador lo descifra a un Secret normal. Simple, sin dependencias externas, funciona sin nube. Rotar es manual. Si pierdes la clave privada del controlador, pierdes todos los secretos. Cifrado por clúster: no se reutiliza entre clústeres.
SOPS (+ age/KMS) Cifra solo los valores del YAML; las claves siguen legibles. Flux lo descifra nativamente; en Argo CD hace falta un plugin (ksops). El diff del PR sigue siendo útil: ves qué clave cambió. Multiplataforma. Cada quien necesita acceso a la clave para editar. Herramienta extra en el flujo local.
External Secrets Operator En Git va solo una referencia (ExternalSecret). El operador lee el valor de AWS Secrets Manager, Vault, Azure Key Vault… y materializa el Secret en el clúster. Ningún secreto, ni cifrado, pasa por Git. Rotación automática. Auditoría en el gestor de secretos. Autenticación sin claves con IRSA. Dependes del gestor externo (si cae, no puedes crear secretos nuevos; los existentes siguen). Un componente más que operar.
# External Secrets Operator: el patrón recomendado.
# 1) Cómo se autentica el operador contra AWS (sin claves, vía IRSA).
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: aws-secrets
  namespace: tienda-prod
spec:
  provider:
    aws:
      service: SecretsManager
      region: eu-west-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets-sa   # ligado a un rol de IAM por IRSA
---
# 2) Lo ÚNICO que va a Git: una referencia. Cero material sensible.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: pedidos-db
  namespace: tienda-prod
spec:
  refreshInterval: 1h            # relee y actualiza el Secret (rotación)
  secretStoreRef:
    name: aws-secrets
    kind: SecretStore
  target:
    name: pedidos-db             # nombre del Secret que se creará
    creationPolicy: Owner
    template:
      type: Opaque
      # Puedes componer valores: aquí montamos la URL JDBC completa
      data:
        username: "{{ .usuario }}"
        password: "{{ .clave }}"
        jdbc-url: "jdbc:postgresql://{{ .host }}:5432/pedidos?sslmode=require"
  data:
    - secretKey: usuario
      remoteRef: { key: prod/pedidos/db, property: username }
    - secretKey: clave
      remoteRef: { key: prod/pedidos/db, property: password }
    - secretKey: host
      remoteRef: { key: prod/pedidos/db, property: host }
Rotación de contraseña de base de datos sin caída, en Spring Boot. External Secrets actualiza el Secret, pero tu aplicación ya tiene la contraseña vieja en memoria y Hikari no la relee. Tres salidas, de peor a mejor: (1) Reloader reinicia el Deployment cuando cambia el Secret —simple y correcto si el apagado es ordenado; (2) autenticación con token temporal (IAM Database Authentication de RDS) y un DataSource que pida un token nuevo en cada conexión, así no hay contraseña que rotar; (3) dos credenciales válidas a la vez durante la rotación, para que no exista una ventana en la que la vieja ya no vale y la nueva no se ha desplegado. La opción 2 es la que quieres a largo plazo.

10.6 Promoción entre entornos y entornos efímeros

«Promocionar» es hacer que el mismo artefacto que ya funciona en un entorno pase al siguiente. La regla es inflexible: se promociona el digest, no se reconstruye. Si el paso a producción implica un mvn package nuevo, no estás promocionando: estás desplegando algo que nadie ha probado.

manifiestos/                       ← repositorio separado del código
├── pedidos/
│   ├── base/                      ← el YAML común, una sola copia
│   └── overlays/
│       ├── integracion/
│       │   └── kustomization.yaml   digest: sha256:aaa…   ← lo pone CI al mergear
│       ├── staging/
│       │   └── kustomization.yaml   digest: sha256:aaa…   ← PR automático
│       └── produccion/
│           └── kustomization.yaml   digest: sha256:aaa…   ← PR con aprobación
├── catalogo/…
└── applications/                  ← las Application de Argo CD (app of apps)

PROMOCIÓN = un commit que cambia una línea:
-   digest: sha256:bbb…    (versión anterior)
+   digest: sha256:aaa…    (la que ya lleva 3 días bien en staging)

El PR de producción es literalmente esa línea. Se revisa en 10 segundos,
queda registrado quién lo aprobó y `git revert` es el rollback.
# Entornos efímeros por pull request con un ApplicationSet de Argo CD.
# Cada PR abierto con la etiqueta "preview" obtiene su propio namespace,
# su propia base de datos y su propia URL. Al cerrar el PR, todo se borra.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: pedidos-preview
  namespace: argocd
spec:
  goTemplate: true
  generators:
    - pullRequest:
        github:
          owner: ejemplo
          repo: pedidos
          tokenRef: { secretName: github-token, key: token }
          labels: [preview]        # solo PRs etiquetados: no gastas de más
        requeueAfterSeconds: 120
  template:
    metadata:
      name: 'pedidos-pr-{{ .number }}'
    spec:
      project: previews
      source:
        repoURL: https://github.com/ejemplo/pedidos.git
        targetRevision: '{{ .head_sha }}'
        path: k8s/overlays/preview
        kustomize:
          namePrefix: 'pr-{{ .number }}-'
          images:
            - 'ghcr.io/ejemplo/pedidos:pr-{{ .number }}-{{ .head_short_sha }}'
      destination:
        server: https://kubernetes.default.svc
        namespace: 'preview-pr-{{ .number }}'
      syncPolicy:
        automated: { prune: true, selfHeal: true }
        syncOptions: [CreateNamespace=true]
  # Al cerrarse el PR, el generador deja de devolverlo, la Application
  # desaparece y con ella el namespace entero. Cero limpieza manual.
Los entornos efímeros son adictivos y caros. Un preview por PR con Spring Boot, PostgreSQL y Kafka son unos 3 GB de RAM. Con 15 PR abiertos, 45 GB solo en revisiones. Controles imprescindibles: ResourceQuota por namespace de preview, requests pequeñas, TTL automático (borrar tras 3 días sin actividad), etiqueta manual para no crearlos en cada PR, y una base de datos compartida con un esquema por PR en lugar de una instancia por PR. Y en preview no hace falta réplica doble ni PDB.
EntornoQué validaCómo se despliegaDatos
LocalQue compila y que el flujo funcionaDocker Compose (sección 6)Semilla pequeña, se borra sin miedo
Preview por PRRevisión funcional por producto y diseñoAutomático al abrir el PR, se borra al cerrarloEsquema propio con datos sintéticos
IntegraciónTests de contrato y de humo entre serviciosAutomático en cada merge a mainSintéticos, reseteados a diario
StagingPrueba de carga corta, migraciones, configuración realAutomático tras integraciónCopia anonimizada de producción
ProducciónLa realidadPR de promoción con aprobación + canaryLos de verdad
¿Cuántos entornos necesitas? Menos de los que crees. Cada entorno cuesta dinero, atención y deriva. Con previews por PR y un buen conjunto de tests con Testcontainers, muchos equipos viven perfectamente con preview + staging + producción. Un entorno que nadie mira y en el que nadie confía es peor que no tenerlo: da falsa seguridad y frena los despliegues.

11 · Preguntas frecuentes

Batería rápida para comprobar que el módulo quedó asimilado. Si no puedes responder en voz alta, vuelve a la sección citada.

¿Qué idea de este módulo explicaría primero en una entrevista?

La que conecta el problema de negocio con la solución técnica y sus contrapartidas. No recites APIs: cuenta un caso, una decisión y qué descartaste.

¿Cómo sé si lo he entendido de verdad?

Si puedes escribir un ejemplo mínimo de memoria, explicar el fallo típico y decir cuándo no usar la técnica. La checklist del final de cada sección es el listón.

¿Qué debo practicar con teclado y no solo leer?

Todo lo que tenga bloque de código en el módulo: cópialo, rómpelo, mídelo. La lectura sin ejecución no fija el contrato de equals, un plan de ejecución o un probe de Kubernetes.

¿Cómo relaciono este módulo con el proyecto final?

Cada concepto debe aparecer en el repositorio del módulo 13 (Cafetería Tech / MiniShop): un commit, un test o una decisión documentada. Si no aparece, no cuenta como aprendido.

¿Qué preguntas trampa debo anticipar?

Las que piden el por qué y el cuándo no. Prepárate a decir “depende” seguido de dos criterios medibles, no de una preferencia estética.

¿Cuánto tiempo debo dedicarle a este módulo en el plan?

El que indica el badge de la cabecera. Si vas corto de días, prioriza las secciones marcadas como críticas en el índice y los ejercicios numerados; deja el resto para el repaso del fin de semana.

¿Qué hago si un ejemplo no compila con mi versión?

Comprueba Java 21+ y Spring Boot 3.x. Las APIs nuevas (virtual threads, RestClient, ProblemDetail) no están en Java 8 ni en Spring Boot 2. Ajusta o sube versión; no “arregles” degradando el ejemplo.

¿Debo memorizar flags, anotaciones y comandos?

Memoriza el mapa mental y tres ejemplos. Los flags exactos se consultan; lo que se evalúa es saber cuál buscar y por qué lo necesitas.

¿Cómo evito estudiar en modo pasivo?

Cierra el HTML y escribe de memoria: un test, una entidad, un Dockerfile o una respuesta de entrevista de 90 segundos. Luego contrasta. Ese ciclo es el 70 % teclado del plan.

¿Qué enlazo con otros módulos?

Usa el aside y los enlaces internos. Persistencia remite a SQL (06) y a Spring Boot (04); despliegue a microservicios (08) y seguridad (10). No dupliques: profundiza donde el plan te manda.

¿Cómo demuestro esto en el CV o en GitHub?

Con un commit claro, un test que falle sin el arreglo, y una línea en el README del proyecto (“detectamos N+1 / OOMKilled / … y lo medimos”). Evidencia > adjetivos.

Si solo me queda una hora, ¿qué hago?

Lee la sección de errores comunes, responde tres FAQ en voz alta y marca dos ejercicios como hechos solo si los has ejecutado. Mejor poco sólido que mucho subrayado.

12 · Ejercicios y retos

Autoevaluación

13 · Resumen y recursos

Qué debes recordar

  • El por qué manda sobre la lista de APIs.
  • Mide antes de optimizar; los síntomas engañan.
  • Documenta decisiones y contrapartidas en el proyecto.
  • Los tests y la observabilidad cierran el aprendizaje.

Siguiente paso

  • Completa las checklists marcadas arriba.
  • Pasa al módulo siguiente solo con los ejercicios 1–3 hechos.
  • Anota dudas para el simulacro del módulo 12.
Cierre: no intentes dominar todo el módulo de una sentada. Domina el núcleo, demuéstralo con código y vuelve a las secciones avanzadas cuando el proyecto te las exija.