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.
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.
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:
- No era reproducible. El servidor acumulaba parches manuales, variables de entorno olvidadas y versiones de JDK que nadie recordaba haber cambiado. Cuando había que crear un servidor nuevo, nadie sabía exactamente qué tenía el viejo. Es el clásico «servidor mascota» frente a «servidor ganado» (pets vs cattle).
- No era atómico. Durante el reinicio el servicio no respondía. La ventana de mantenimiento nocturna existía precisamente porque el despliegue implicaba caída.
- No era reversible. Volver atrás significaba recuperar el
.waranterior de algún sitio y esperar que la base de datos siguiera siendo compatible.
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ón | Consecuencia técnica | Qué 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». |
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.
| # | Pieza | Qué es | Herramientas típicas | Fallo 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) │
└──────────────────────────────────────────────────────────────────────┘
: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:
- Herramientas del sistema. Código que llama a
Runtime.exec("pdftoppm")o aconvertde ImageMagick. Si el binario no está en la imagen, revienta en producción. La dependencia debe estar en elDockerfile, declarada y con versión. - Zona horaria y locale del host.
LocalDateTime.now()devuelve una hora distinta según la zona del contenedor. FijaTZ=UTCy-Duser.language=es -Duser.country=ESsi el formato importa. - Codificación por defecto. Desde Java 18 el
file.encodingpor defecto es UTF-8, lo que elimina la clase de bug más tonta de la historia de Java. Si estás en 17 o anterior, fíjalo explícitamente. - Certificados. Si llamas a un servicio con certificado corporativo, el truststore es una dependencia. Móntalo, no lo metas en la imagen.
# 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.
| Prioridad | Fuente de configuración | Uso recomendado |
|---|---|---|
| 1 (gana) | Argumentos de línea de comandos (--server.port=9090) | Depuración puntual y Jobs que necesitan un parámetro. |
| 2 | SPRING_APPLICATION_JSON | Inyectar un bloque entero de configuración desde una sola variable. |
| 3 | Variables de entorno del sistema | El mecanismo principal en Kubernetes. Secretos y valores por entorno. |
| 4 | Propiedades del sistema (-D) | Ajustes de la JVM y de librerías que solo leen System.getProperty. |
| 5 | application-{perfil}.yml externo (junto al jar o en /config) | Ficheros montados desde un ConfigMap. |
| 6 | application-{perfil}.yml empaquetado | Valores por defecto por tipo de entorno, nunca secretos. |
| 7 (pierde) | application.yml empaquetado | Los valores por defecto sensatos para que la app arranque en local. |
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
| Etapa | Entrada | Salida | Quién la hace | Es inmutable |
|---|---|---|---|---|
| Build | Commit + dependencias | Imagen con digest | CI | Sí, para siempre |
| Release | Imagen + configuración del entorno | Una versión desplegable identificada (v42) | CD / GitOps | Sí; un cambio de config crea una release nueva |
| Run | La release | Procesos en ejecución | Orquestador | No 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 escondido | Por qué falla al escalar | Solució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:
- El puerto se configura por variable de entorno (
SERVER_PORT), aunque en Kubernetes lo normal es dejarlo en 8080 y que el Service haga el mapeo. - Expón el puerto de gestión (Actuator) en un puerto distinto si quieres que
/actuator/**no sea alcanzable desde el ingress:management.server.port=8081. Es la forma más simple y robusta de proteger Actuator. - No escuches solo en
localhost: dentro de un contenedor eso lo hace inalcanzable desde fuera. Spring Boot escucha en todas las interfaces por defecto; no lo cambies «por seguridad».
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.
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 tú 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:
- Arranque rápido: cuanto más tarda tu aplicación en estar lista, más lento es el escalado y el despliegue, y más difícil es reaccionar a un pico. Un Spring Boot típico arranca en 3–8 segundos; con lazy initialization, AppCDS o CRaC baja mucho (sección 5).
- Apagado ordenado: al recibir
SIGTERM, la aplicación debe dejar de aceptar peticiones nuevas, terminar las que tiene en curso y cerrar recursos. En Spring Boot son dos líneas de configuración, y sin ellas pierdes peticiones en cada despliegue (sección 9).
# 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).
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.
| Factor | Autotest 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());
};
}
}
/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 antiguo | Consecuencia | Modelo 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:
- Sabes qué pasa con tu código después del
git push. Puedes describir el pipeline, dónde está el gate de calidad y cómo llega la imagen al clúster. - Sabes leer un Dockerfile y detectar sus problemas. No hace falta que lo escribas de memoria, pero sí que veas de un vistazo que falta el usuario no root o que el orden de las capas destruye la caché.
- Sabes diagnosticar un pod. Ante un
CrashLoopBackOffsabes qué tres comandos ejecutar y en qué orden, y qué significa el código de salida 137. - Configuras la memoria de la JVM en un contenedor con criterio y sabes explicar por qué el pod puede morir por OOM con el heap medio vacío.
- Distingues liveness de readiness y entiendes por qué poner la base de datos en liveness convierte una caída de la base de datos en una caída total del servicio.
- Piensas en el coste. Sabes que el 90% de la factura de la nube son cuatro conceptos y puedes estimar el orden de magnitud de tu arquitectura.
- Automatizas en lugar de documentar un procedimiento manual. Un runbook con 14 pasos manuales es un script sin escribir.
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.
| Fase | Qué hace | Plugin que la implementa | Cuándo la invocas tú |
|---|---|---|---|
validate | Comprueba que el proyecto es correcto y están las dependencias. | — | Casi nunca. |
compile | Compila src/main/java a target/classes. | maven-compiler-plugin | Para ver rápido si compila. |
test | Ejecuta los tests unitarios (*Test.java). | maven-surefire-plugin | Bucle de desarrollo. |
package | Empaqueta en target/*.jar. Con Spring Boot, además repackage a jar ejecutable. | maven-jar-plugin + spring-boot-maven-plugin | Cuando quieres el jar sin tests de integración. |
verify | Ejecuta los tests de integración (*IT.java) y las comprobaciones de calidad. | maven-failsafe-plugin, jacoco | Esta es la que va en CI. |
install | Copia el artefacto al repositorio local ~/.m2. | maven-install-plugin | Solo para consumirlo desde otro proyecto local. |
deploy | Publica el artefacto en un repositorio remoto (Nexus, Artifactory). | maven-deploy-plugin | Solo para librerías compartidas, no para servicios. |
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
-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>
<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ón | Solución correcta | Por 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. |
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:
| Necesidad | Herramienta correcta |
|---|---|
| Otra URL de base de datos en producción | Variable de entorno (perfil de Spring como mucho). Nunca un perfil de Maven. |
| Compilar la imagen nativa de GraalVM solo cuando se pida | Perfil de Maven native. Correcto: cambia el build. |
| Saltar los tests lentos en el bucle local | Etiquetas de JUnit 5 (@Tag) + -Dgroups. Mejor que un perfil. |
| Firmar el artefacto solo al publicar | Perfil 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
}
| Concepto | Maven | Gradle |
|---|---|---|
| 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 dependencias | dependency:tree | dependencies --configuration runtimeClasspath |
| Por qué está esta versión | dependency:tree -Dverbose | dependencyInsight --dependency jackson-databind |
| Ámbito «no propagar» | no existe (todo es compile) | implementation |
| Resolución de conflicto | El más cercano gana | La versión más alta gana (¡ojo, es al revés!) |
| Versiones bloqueadas | dependency:go-offline + BOM | dependencyLocking con gradle.lockfile |
| Construir imagen | spring-boot:build-image | bootBuildImage |
| Caché de build remota | Extensión de caché de build de Maven | Nativa (--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.
| Aspecto | Local | CI | Consecuencia 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:
| Orden | Capa | Contenido | Tamaño típico | Frecuencia de cambio |
|---|---|---|---|---|
| 1 | dependencies | Todas las dependencias sin SNAPSHOT | 55 MB | Cuando subes el BOM: una vez al mes |
| 2 | spring-boot-loader | Las clases del launcher | 450 KB | Solo al subir Spring Boot |
| 3 | snapshot-dependencies | Dependencias -SNAPSHOT | 0–2 MB | A menudo (y no deberías tenerlas) |
| 4 | application | Tu código y tus recursos | 300 KB | En 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:
| Estrategia | Capas que se invalidan | Bytes a subir al registro | Tiempo de build incremental |
|---|---|---|---|
COPY target/*.jar app.jar (una capa) | 1 capa de 61 MB | 61 MB | ~75 s |
Capas del jar con extract --layers | 1 capa de 0,3 MB | 0,3 MB | ~6 s |
| Jib o buildpacks (capas equivalentes) | 1 capa de 0,3 MB | 0,3 MB | ~8 s |
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 buildpacks | Inconvenientes |
|---|---|
| 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
| Criterio | Dockerfile multi-stage | Buildpacks | Jib | docker init |
|---|---|---|---|---|
| Control sobre la imagen | Total | Bajo (hay que extender) | Medio | Total (genera un Dockerfile) |
| Necesita demonio Docker | Sí (o buildkit/podman) | Sí (o modo publish) | No | Sí |
| Tamaño típico (Spring Boot web + JPA) | 190–240 MB | 310–360 MB | 220–260 MB | 250–300 MB |
| Capas óptimas «gratis» | No: hay que hacerlo bien a mano | Sí | Sí | Sí (la plantilla las usa) |
| Velocidad del build incremental | Rápida con caché | Media | Muy rápida | Rápida |
| Instalar paquetes del sistema | Trivial (apt-get) | Requiere buildpack propio | Difícil (cambiar la base) | Trivial |
| SBOM automático | No (lo añades con Syft) | Sí | Parcial | No |
| Rebase para parchear CVEs | No: reconstruir | Sí | No: reconstruir (pero es rápido) | No |
| Curva de aprendizaje | Media: hay que saber Docker | Baja al empezar, alta al personalizar | Baja | Muy baja |
| Cuándo elegirlo | Necesitas 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 builder | CI sin Docker, o quieres velocidad máxima sin aprender Docker | Punto de partida para aprender: genera Dockerfile + compose y los editas |
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:
- Responder en minutos a «¿nos afecta este CVE?». Cuando salió Log4Shell, las empresas con SBOM contestaron con una consulta; las demás tardaron semanas.
- Auditar licencias. Descubrir una GPL en un producto propietario antes de firmar el contrato, no después.
- Cumplimiento normativo. La Cyber Resilience Act europea y las órdenes ejecutivas de EEUU lo exigen para software vendido a administraciones públicas.
# 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
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
rmen unRUNposterior 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'
| Aspecto | Máquina virtual | Contenedor | Consecuencia 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. |
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 contenedores | Equivalente en Java | Detalle |
|---|---|---|
| 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. |
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… | Comando | Por qué ese y no otro |
|---|---|---|
| Saber por qué murió un contenedor | docker inspect -f '{{.State}}' X y docker logs X | ps -a te da el código de salida, pero inspect te dice si fue OOM. |
| Ver si un fichero se está escribiendo donde no debe | docker diff X | Revela escrituras en la capa efímera: logs a fichero, cachés locales, subidas. |
| Comprobar el consumo real de memoria | docker stats + jcmd 1 VM.native_memory | stats da el RSS total del contenedor, que es lo que mira el OOM killer; jcmd lo desglosa. |
| Entender por qué la imagen pesa 800 MB | docker history y luego dive | history te da el culpable por capa en 2 segundos. |
| Reproducir un problema de producción en local | docker run con la imagen por digest y las mismas variables | Con la etiqueta puede que descargues otra imagen distinta. |
| Liberar 40 GB de disco | docker builder prune -af | La 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.
| Tipo | Sintaxis | Dónde vive | Cuándo usarlo | Trampas |
|---|---|---|---|---|
| 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
--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
| Driver | Qué hace | DNS entre contenedores | Cuándo |
|---|---|---|---|
bridge (por defecto) |
Red virtual privada con NAT hacia el exterior. | No en la red bridge por defecto; sí 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). | Sí | 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'
-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
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
| Herramienta | Qué es | Por 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
| # | Problema | Consecuencia concreta y medible | Solució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. |
.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 Dockerfile | El «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 base | Tamaño (JRE 21) | libc | Shell | Ventajas | Inconvenientes | Cuá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 | Sí | 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 | Sí | 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 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(comondotstal 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
perfo 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.
# 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.
4.4 Reducir el runtime con jlink y jdeps
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
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.
| Orden | Instrucción | Cambia… |
|---|---|---|
| 1 | FROM | cada varios meses |
| 2 | RUN apt-get install … | cada varios meses |
| 3 | RUN useradd … | nunca |
| 4 | COPY pom.xml + resolución de dependencias | cuando tocas dependencias: semanas |
| 5 | COPY capa dependencies | semanas |
| 6 | COPY capa application (tu código) | cada commit |
| 7 | ENV, USER, ENTRYPOINT, LABEL con la versión | casi 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
| Forma | Sintaxis | Có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. |
Sí |
El PID 1 tiene dos peculiaridades del kernel de Linux que hay que conocer:
- 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 paraSIGTERM, así que como PID 1 se comporta bien. Peroshno reenvía la señal a sus hijos, y ahí está el problema. - Es responsable de adoptar y recolectar procesos huérfanos (zombies). La JVM
no lo hace. Si tu aplicación lanza subprocesos con
ProcessBuildery 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]'
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:
| Estrategia | Tamaño | Transferido | Capa por commit | CVE HIGH/CRIT típicos | Arranque | RSS en reposo |
|---|---|---|---|---|---|---|
openjdk:latest + jar, una capa | ~830 MB | ~340 MB | 61 MB | 30–60 | 4,5 s | 420 MB |
temurin:21-jdk + jar, una capa | ~510 MB | ~210 MB | 61 MB | 8–20 | 4,2 s | 410 MB |
temurin:21-jre + jar, una capa | ~330 MB | ~135 MB | 61 MB | 6–15 | 4,0 s | 400 MB |
temurin:21-jre + capas del jar | ~265 MB | ~110 MB | 0,3 MB | 6–15 | 3,8 s | 400 MB |
| Buildpacks (Paketo) | ~340 MB | ~140 MB | 0,3 MB | 4–10 | 3,6 s | 390 MB |
| Jib + Temurin JRE | ~250 MB | ~105 MB | 0,3 MB | 6–15 | 3,8 s | 400 MB |
temurin:21-jre-alpine + capas | ~175 MB | ~72 MB | 0,3 MB | 1–4 | 4,1 s | 410 MB |
| distroless java21 + capas | ~230 MB | ~95 MB | 0,3 MB | 0–3 | 3,8 s | 400 MB |
| Chainguard JRE + capas | ~150 MB | ~62 MB | 0,3 MB | 0 | 3,8 s | 400 MB |
debian:12-slim + jlink + capas | ~150 MB | ~60 MB | 0,3 MB | 1–5 | 3,5 s | 380 MB |
| GraalVM native image | ~95 MB | ~40 MB | 80 MB (¡todo!) | 0–2 | 0,06 s | 75 MB |
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ón | Qué hacer |
|---|---|
| CVE en un paquete del sistema base con parche disponible | Reconstruir 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 BOM | Subir 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 nadie | Sustituirla. Es un problema de deuda técnica, no de seguridad, y el escáner solo es el mensajero. |
| 200 CVE en la imagen base | Cambiar 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 JVM | Fichero de cgroup v2 que lee | Valor 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
5.2 MaxRAMPercentage frente a -Xmx: qué poner y por qué
| Opción | Ventaja | Inconveniente | Veredicto |
|---|---|---|---|
-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 contenedor | MaxRAMPercentage | Heap resultante | Fuera del heap | Comentario |
|---|---|---|---|---|
| 256 Mi | — | — | — | No lo intentes con Spring Boot y JVM. Cabe con imagen nativa. |
| 512 Mi | 50–55 | ~270 Mi | ~240 Mi | Mínimo viable para un servicio pequeño. Ajustado; con SerialGC. |
| 768 Mi | 60 | ~460 Mi | ~300 Mi | Cómodo para un CRUD con JPA. |
| 1 Gi | 65 | ~665 Mi | ~360 Mi | El punto dulce para la mayoría de los servicios Spring Boot. |
| 2 Gi | 70–75 | ~1,4–1,5 Gi | ~500–600 Mi | Servicios con caché en memoria o procesamiento de lotes. |
| 4 Gi o más | 75–80 | 3–3,2 Gi | ~800 Mi–1 Gi | A 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)
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íntoma | Causa probable | Cómo confirmarlo | Arreglo |
|---|---|---|---|
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.
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
| Recolector | Bandera | Hilos | Pausas típicas | Sobrecoste de memoria | Cuá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:
| Memoria | CPU | |
|---|---|---|
| Tipo de recurso | Incompresible: o la tienes o no | Compresible: se puede repartir en el tiempo |
| Al superar el límite | El kernel mata el proceso (exit 137) | El kernel frena el proceso (throttling) |
| Cómo se manifiesta | Reinicio del pod, evidente en los eventos | Latencia p99 pésima, sin ningún error: invisible si no lo mides |
| Recomendación | requests == 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).
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órmula | Con 8 CPU detectadas | Problema si en realidad tienes 0,5 |
|---|---|---|---|
| Hilos de GC paralelo | ParallelGCThreads ≈ 5/8 × n | 5 hilos | 5 hilos peleándose por media CPU: pausas de GC larguísimas |
| Hilos del compilador JIT | CICompilerCount | 3–4 | La compilación compite con tu aplicación |
ForkJoinPool.commonPool() | n − 1 | 7 | parallelStream() más lento que el secuencial |
| Bucles de eventos de Netty / Reactor | 2 × n | 16 | Cambios de contexto constantes, cero paralelismo real |
| Pool por defecto de HikariCP en algunas guías | 2 × n + 1 | 17 conexiones | Conexiones que no se usan pero cuentan en max_connections |
| Programador de hilos virtuales (Java 21) | n | 8 hilos portadores | Menos 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
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écnica | Mejora típica | Coste | Riesgo | Recomendació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
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ón | Qué hace | Cuándo usarla en Java |
|---|---|---|
sync | Copia los ficheros al contenedor en marcha, sin reiniciar. | Plantillas, ficheros estáticos, application.yml si usas refresh. |
sync+restart | Copia y reinicia el contenedor. | Clases compiladas: con mvn compile en un terminal y esto en otro, tienes recarga en ~4 s. |
rebuild | Reconstruye la imagen y recrea el contenedor. | Cambios en pom.xml o en el Dockerfile. |
sync+exec | Copia y ejecuta un comando dentro. | Aplicar una migración nueva sin reiniciar la aplicación. |
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 |
|---|---|
postgres | spring.datasource.url, username, password (leídos de las variables del contenedor) |
redis | spring.data.redis.host, port |
mongo | spring.data.mongodb.uri |
rabbitmq | spring.rabbitmq.* |
kafka / confluentinc/cp-kafka | spring.kafka.bootstrap-servers |
elasticsearch | spring.elasticsearch.uris |
otel/opentelemetry-collector | management.otlp.* |
| Cualquiera, con anotación explícita | labels: org.springframework.boot.service-connection: postgres |
| Docker Compose support | Testcontainers | |
|---|---|---|
| Para qué sirve | Desarrollo interactivo: arrancar la app y trabajar | Tests automatizados y reproducibles |
| Ciclo de vida | Los contenedores sobreviven entre arranques (los datos persisten) | Contenedor limpio por test o por clase |
| Definición | compose.yaml, compartido con el equipo | Código Java, versionado con los tests |
| Velocidad | Muy rápida tras el primer arranque | Segundos 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
| Aspecto | Compose (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).
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:
| Pregunta | Sin orquestador | Con 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.
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] │ ║
║ └────────────────────────────────┘ │ └──────────────────────────────────┘ ║
╚═══════════════════════════════════════════════════════════════════════════════╝
| Componente | Si se cae… | Te afecta como desarrollador porque… |
|---|---|---|
kube-apiserver | No puedes desplegar ni consultar nada; lo que corre sigue corriendo. | Tus kubectl fallan con timeout. Los pods no se recrean si mueren. |
etcd | Igual 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-scheduler | Los pods nuevos se quedan en Pending para siempre. | Si ves Pending sin eventos de FailedScheduling, sospecha del scheduler. |
controller-manager | No se crean ReplicaSets, ni Jobs, ni se actualizan endpoints. | Un despliegue se queda a medias sin explicación. |
kubelet de un nodo | El nodo pasa a NotReady y sus pods se recrean en otro (tras ~5 min). | Explica los reinicios «espontáneos» de tus pods. |
kube-proxy / CNI | La red del nodo deja de funcionar: los Services no llegan. | Errores de conexión intermitentes que parecen bugs de tu código. |
| CoreDNS | La 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:
spec: lo que tú quieres. Lo escribes tú.status: lo que hay ahora mismo. Lo escribe el sistema.
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».
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
| Enfoque | Comando | Cuándo | Problema |
|---|---|---|---|
| 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íntoma | Comando exacto, en este orden |
|---|---|
| «No arranca» | describe pod (eventos) → logs --previous → get events |
| «Está arrancado pero no responde» | get endpointslices → describe pod (readiness) → port-forward y probar directo |
| «Va lento» | top pods → cpu.stat (throttling) → métricas de la app → jcmd Thread.print |
| «Se reinicia solo» | describe pod → Last State y Reason → si es OOMKilled, sección 5.3 |
| «El despliegue no termina» | rollout status → get rs → describe del RS nuevo |
| «No encuentro por qué se aplicó este cambio» | rollout history → get 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 namespace | Qué 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 namespace | El 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
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ón | Quién la lee | Para qué |
|---|---|---|
kubernetes.io/change-cause | kubectl rollout history | Explicar por qué se hizo un despliegue. Ponla siempre, con el número del PR. |
prometheus.io/scrape, /port, /path | Prometheus (con descubrimiento por anotaciones) | Que se recojan tus métricas sin tocar la configuración de Prometheus. |
eks.amazonaws.com/role-arn | El webhook de IRSA en EKS | Dar permisos de AWS a la ServiceAccount sin claves. Sección 12. |
nginx.ingress.kubernetes.io/* | El controlador de ingress | Timeouts, tamaño de cuerpo, reescritura de rutas, límites de tasa. |
checksum/config | Nada: es un truco | Un 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-wave | Argo CD | Ordenar la aplicación de recursos (primero migraciones, después la app). |
kubectl.kubernetes.io/last-applied-configuration | kubectl apply (modo cliente) | Cómo apply sabía qué campos gestionaba antes de Server-Side Apply. |
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 contenedores | Para qué | Ejemplo en un servicio Java |
|---|---|---|
| Sidecar | Un 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. |
| Ambassador | Un proxy local que simplifica el acceso a un servicio externo. | PgBouncer local en el pod para agrupar conexiones a PostgreSQL. |
| Adapter | Traduce la salida de la aplicación a un formato estándar. | Un exportador que convierte métricas propietarias a formato Prometheus. |
| Init container | Corre 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ón | Comportamiento | Cuá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.
| Tipo | Qué crea | Accesible desde | Cuá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.
| Controlador | Puntos fuertes | A tener en cuenta |
|---|---|---|
| ingress-nginx | El 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). |
| Traefik | Configuración dinámica sin recargas, buen soporte de Gateway API, panel web. | Sus CRDs propios (IngressRoute) atan más. |
| AWS Load Balancer Controller | Crea 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 }
| Necesidad | Ingress | Gateway API |
|---|---|---|
| Enrutar por host y ruta | Sí | Sí |
| Reparto por peso (canary) | Solo con anotaciones propietarias | Nativo y portable |
| Enrutar por cabecera o parámetro | Anotaciones | Nativo |
| Timeouts y reintentos | Anotaciones | Nativo |
| TCP, UDP, gRPC, TLS pass-through | No | Sí (TCPRoute, GRPCRoute…) |
| Separación de roles y delegación segura | No | Sí (allowedRoutes, ReferenceGrant) |
| Madurez del ecosistema | Total | Buena y creciendo rápido |
| Qué usar hoy | Lo que ya tienes: no migres por gusto | Para 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_…"
- Activar cifrado en reposo en etcd (
EncryptionConfigurationcon un proveedor KMS). En las nubes gestionadas es una casilla que hay que marcar; compruébalo, porque no siempre viene activada. - RBAC estricto: el permiso
get secretsen un namespace equivale a conocer todas sus credenciales. Casi nadie debería tenerlo. - 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 }
| Criterio | Variables de entorno | Ficheros montados |
|---|---|---|
| Se actualizan sin reiniciar | No, nunca | Sí (el kubelet las sincroniza, ~1 min) |
Visibles en describe pod | Sí (¡también los Secrets referenciados!) | No |
Visibles en /proc/1/environ | Sí: cualquier proceso del pod las lee | No |
| Riesgo de aparecer en un log de error | Alto (muchos frameworks vuelcan el entorno) | Bajo |
| Facilidad con Spring Boot | Máxima (relaxed binding) | Alta (configtree) |
| Límite de tamaño | Práctico: unos KB | 1 MiB por ConfigMap/Secret |
| Recomendación | Configuración no sensible | Secretos, siempre |
Recargar configuración sin reiniciar: las cuatro opciones honestas
| Opción | Cómo funciona | Veredicto |
|---|---|---|
| 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:
- Identidad estable: los pods se llaman
nombre-0,nombre-1… y ese nombre no cambia entre reinicios. Con un Service headless, cada uno tiene su propio DNS. - Almacenamiento estable: cada pod tiene su propio PVC, creado por
volumeClaimTemplates, que sobrevive al borrado del pod y se le vuelve a montar al recrearse. - Orden garantizado: se crean 0, 1, 2… esperando a que cada uno esté listo, y se borran en orden inverso. Es lo que permite que un clúster de réplicas se inicialice bien.
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 acceso | Significado | Quién lo soporta | Uso 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. |
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 programada | Con @Scheduled de Spring | Con CronJob de Kubernetes |
|---|---|---|
| Con 3 réplicas | Se ejecuta 3 veces. Hace falta ShedLock o elección de líder. | Una vez. Garantizado por diseño. |
| Consumo de recursos | Ocupa 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 pesada | Compite 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 pausarla | Requiere redespliegue (o configuración dinámica). | kubectl patch cronjob … suspend=true. Inmediato. |
| Reejecutar la de ayer | Imposible sin un endpoint expuesto a propósito. | kubectl create job --from=cronjob/X manual-1 |
| Visibilidad de fallos | Un ERROR en el log, entre otros mil. | Un Job en estado Failed, alertable con una regla trivial. |
| Cuándo usar cada uno | Tareas 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
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ípico | Ejemplo | Por qué debe estar en todos los nodos |
|---|---|---|
| Recogida de logs | Fluent Bit, Vector, Promtail | Los ficheros de log de los contenedores están en el disco de cada nodo. |
| Métricas del nodo | node-exporter | Mide CPU, disco y red de ese nodo. |
| Red | Calico, Cilium, kube-proxy | Programa las reglas de red del nodo. |
| Almacenamiento | Controladores CSI | Monta volúmenes en el nodo. |
| Seguridad | Falco, agentes EDR | Vigila 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 HPA | Causa | Solució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 |
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éplicas | PDB recomendado | Efecto al drenar un nodo |
|---|---|---|
| 1 | Ninguno (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–3 | maxUnavailable: 1 | Se desaloja de uno en uno, esperando a que el sustituto esté listo. |
| ≥ 4 | minAvailable: 75% | Escala automáticamente con el número de réplicas. |
| Base de datos (3 miembros con quórum) | maxUnavailable: 1 | Nunca 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 }
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
| ResourceQuota | LimitRange | |
|---|---|---|
| Ámbito | El total del namespace | Cada 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 superarlo | La creación del pod es rechazada por el apiserver | Se aplica el valor por defecto, o se rechaza si viola el máximo |
| Valor principal | Evitar que un equipo se coma el clúster | Evitar 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… | Mecanismo | Aviso |
|---|---|---|
| Que mis 3 réplicas no estén en el mismo nodo | topologySpreadConstraints por hostname con ScheduleAnyway | Con DoNotSchedule y menos nodos que réplicas, los pods se quedan en Pending. |
| Sobrevivir a la caída de una zona completa | Reparto por zone + minReplicas: 3 + PDB | Con 2 réplicas en 2 zonas, perder una zona te deja al 50% de capacidad. |
| Correr en nodos con más memoria | nodeAffinity o nodeSelector | Si esos nodos se llenan, tus pods esperan en lugar de ir a otros. |
| Reservar nodos para un equipo | Taint en el nodo + toleration en el pod | La toleration permite pero no obliga: añade también nodeAffinity, o tus pods irán a otros nodos. |
| Ahorrar con instancias spot | nodeAffinity preferida + toleration + PDB | Nunca 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 importante | PriorityClass | Los 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ásico | Sidecar nativo (1.29+ estable) | Contenedor normal como sidecar | |
|---|---|---|---|
| Cómo se declara | initContainers | initContainers con restartPolicy: Always | containers |
| Cuándo arranca | Antes que todo, en orden, uno a uno | Antes que los contenedores principales, y sigue corriendo | En paralelo con los demás |
| Cuándo termina | Debe terminar para que siga el pod | Al final, después de los principales | Cuando le toque |
| ¿Funciona en un Job? | Sí | Sí: el Job termina cuando acaban los principales | No: el Job nunca termina porque el sidecar sigue vivo |
| Problema que resuelve | Preparar el terreno | El 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 cerrar | Lo 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
| Probe | Pregunta que responde | Si 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. |
/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:
- La liveness falla en las 10 réplicas a la vez.
- Kubernetes mata y reinicia los 10 pods.
- 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.
- La base de datos, ahora sí, cae del todo. La liveness sigue fallando. Vuelta al paso 2.
# ── 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.
/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 probe | Sintaxis | Cuándo |
|---|---|---|
httpGet | httpGet: { path: /…, port: 8081 } | Lo normal. No necesita nada dentro del contenedor: la ejecuta el kubelet. Éxito = código 200–399. |
tcpSocket | tcpSocket: { port: 5432 } | Servicios no HTTP. Solo comprueba que el puerto acepta conexiones, que es poco. |
exec | exec: { 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). |
grpc | grpc: { 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
| Campo | Qué hace exactamente | Quié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 QoS | Condición | Prioridad al desalojar | Cuá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). |
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íntoma | Qué significa | Causas por probabilidad | Comando 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 --previous ← el 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 pod → Last 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 node → MemoryPressure. |
| 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=pedidos ← si 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).
| Comando | Semántica | Cuá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. |
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.
| Enfoque | Có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. |
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 }}
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 }}
| Hook | Cuándo se ejecuta | Uso típico en Java |
|---|---|---|
pre-install | Antes de crear los objetos, en la primera instalación. | Crear el esquema inicial de base de datos. |
post-install | Tras crear los objetos (no espera a que estén listos, salvo con --wait). | Registrar el servicio en un catálogo interno. |
pre-upgrade | Antes de aplicar los cambios de una actualización. | Migración Flyway compatible hacia atrás. |
post-upgrade | Después de aplicar los cambios. | Invalidar una caché, avisar a Slack. |
pre-rollback / post-rollback | Alrededor de un helm rollback. | Casi nunca: revertir migraciones automáticamente es peligroso. |
pre-delete / post-delete | Al desinstalar. | Volcar datos antes de borrar, desregistrar del descubrimiento. |
test | Solo con helm test. | Pod que llama a /actuator/health y a un endpoint real. |
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
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)
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
| Criterio | Helm | Kustomize |
|---|---|---|
| Modelo | Plantillas de texto (Go templates) → YAML | YAML válido + parches estructurados |
| Legibilidad de la fuente | Baja en charts grandes: mezcla lógica e indentación | Alta: la base es YAML normal |
| Condicionales y bucles | Sí (if, range, with) | No. Si necesitas lógica, otro overlay o un generador |
| Instalación | Binario aparte | Incluido en kubectl |
| Inventario / borrado de lo que sobra | Sí: la release conoce sus objetos; uninstall los borra | No por sí mismo (Argo CD o Flux lo aportan) |
| Rollback | Sí: helm rollback, y --atomic automático | Revertir el commit y volver a aplicar |
| Espera y verificación | --wait, --atomic, --wait-for-jobs | kubectl rollout status a mano |
| Hooks y orden | Sí, con pesos | No (usa Jobs con ttlSecondsAfterFinished o Argo CD waves) |
| Validación de entradas | values.schema.json | Lo valida el esquema de Kubernetes al aplicar |
| Distribución a terceros | El estándar de facto (repos, OCI registries) | No pensado para eso |
| Encaje con GitOps | Bueno (Argo/Flux renderizan charts) | Excelente: el diff en el PR es legible |
| Curva | Media-alta | Baja para lo básico, media para parches JSON |
--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 GitOps | Qué significa en la práctica |
|---|---|
| Declarativo | Todo el sistema se describe con datos (YAML), no con pasos. |
| Versionado e inmutable | Git es la fuente de verdad; cada estado deseado tiene un commit con autor y fecha. |
| Aplicado automáticamente | El agente lleva el clúster al estado de Git sin intervención humana. |
| Reconciliado continuamente | No 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 CD | Qué es | Por 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. |
[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ón | Cómo funciona | Ventajas | Inconvenientes |
|---|---|---|---|
| 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 }
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.
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.
| Entorno | Qué valida | Cómo se despliega | Datos |
|---|---|---|---|
| Local | Que compila y que el flujo funciona | Docker Compose (sección 6) | Semilla pequeña, se borra sin miedo |
| Preview por PR | Revisión funcional por producto y diseño | Automático al abrir el PR, se borra al cerrarlo | Esquema propio con datos sintéticos |
| Integración | Tests de contrato y de humo entre servicios | Automático en cada merge a main | Sintéticos, reseteados a diario |
| Staging | Prueba de carga corta, migraciones, configuración real | Automático tras integración | Copia anonimizada de producción |
| Producción | La realidad | PR de promoción con aprobación + canary | Los de verdad |
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.