# Día 29: Contenedores y Kubernetes / OpenShift para Java Spring Boot

> Docker multi-stage, JRE headless, Pods, Deployments, Services, ConfigMaps, Secrets, Ingress/Routes y Probes

- Plan: [JavaMastery](https://aprende-java.webs-economicas.es/), día 29 de 30
- Fase 8: Contenedores, Kubernetes / OpenShift y Observabilidad LKA / LGTM
- Dedicación: 4.5h
- Profundidad: visión de conjunto
- Revisado: 2026-10-29
- Versión interactiva: https://aprende-java.webs-economicas.es/dias/contenedores-kubernetes-openshift/

## Al terminar este día sabrás

- Construir imágenes de contenedor para Spring Boot rápidas de arrancar y de desplegar.
- Configurar memoria y CPU de un pod Java sin provocar OOMKilled ni throttling.
- Desplegar un servicio con Deployment, Service, ConfigMap y Secret.
- Configurar probes con Actuator y desplegar sin cortes, también en OpenShift.


## Conceptos

### 1. Dockerfile Multi-Stage optimizado para Spring Boot y JVM

Una imagen de contenedor para Java nunca debe incluir Maven ni el código fuente en la imagen final. Se usa una fase de build (con Maven/Gradle y JDK completo) y una fase de ejecución (con JRE mínimo tipo Eclipse Temurin / BellSoft Liberica Alpine/Distroless). Se extraen las capas del jar con `layertools` (`java -Djarmode=layertools -jar app.jar extract`) para aprovechar la caché de capas de Docker.

**Cómo funciona por dentro.**

Además del Dockerfile multi-etapa escrito a mano (día 15), hay dos alternativas sin Dockerfile. Cloud Native Buildpacks, integrados en Spring Boot con `./mvnw spring-boot:build-image`, detectan la aplicación, eligen un JRE, crean capas optimizadas, configuran la memoria de la JVM y generan una imagen reproducible con usuario sin privilegios. Jib, de Google, construye y sube la imagen directamente desde Maven sin necesitar Docker.

Para el arranque, Spring Boot 3.3 facilita usar CDS (Class Data Sharing): en un primer arranque de entrenamiento la JVM vuelca un archivo con las clases ya analizadas, y los siguientes arranques lo reutilizan, recortando el tiempo de arranque de forma notable. Las imágenes nativas con GraalVM arrancan en milisegundos a cambio de un build mucho más lento y algunas restricciones de reflexión.

Sea cual sea la vía, comprueba el resultado: tamaño, usuario, capas (con la herramienta `dive`) y vulnerabilidades (Trivy, Grype).

*Buildpacks y arranque con CDS*

```bash
# Imagen OCI sin Dockerfile (requiere Docker en local)
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=registry.acme.com/taskflow:1.4.0

# Con Jib: construye y publica sin demonio de Docker
./mvnw com.google.cloud.tools:jib-maven-plugin:build -Dimage=registry.acme.com/taskflow:1.4.0

# CDS con Spring Boot 3.3+ (en el Dockerfile, tras extraer el JAR)
java -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh -jar app.jar   # entrenamiento
java -XX:SharedArchiveFile=app.jsa -jar app.jar                                       # arranques posteriores

# Inspeccionar capas y vulnerabilidades
dive registry.acme.com/taskflow:1.4.0
trivy image registry.acme.com/taskflow:1.4.0
```

- **En una línea:** `FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/application/ ./
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "org.springframework.boot.loader.launch.JarLauncher"]`
- **Por qué importa:** Reduce la imagen de ~800MB a menos de 150MB, acelera los despliegues en CI/CD y minimiza vectores de vulnerabilidad (CVEs).
- **Error típico:** Copiar todo el proyecto y ejecutar `mvn spring-boot:run` dentro del contenedor en producción, dejando el JDK y herramientas de compilación expuestas.
- **Java 17 frente a 21:** Idéntico en Java 17 y 21.
- **Se conecta con:** Docker Layers, Alpine / Distroless, Spring Boot Layertools

### 2. Memoria JVM en Contenedores: MaxRAMPercentage y Cgroups v2

Dentro de un Pod de Kubernetes con límites (e.g. `resources.limits.memory: 1Gi`), la JVM debe ser consciente de ese límite. La flag `-XX:MaxRAMPercentage=75.0` indica a la JVM que reserve como máximo el 75% del límite de memoria del Pod para el Heap, dejando el 25% restante para Metaspace, hilos (stack), buffers I/O nativos y Garbage Collector.

**Cómo funciona por dentro.**

En Kubernetes cada contenedor declara `requests` (lo que el planificador le garantiza y usa para elegir nodo) y `limits` (lo máximo que puede usar). Para la memoria, el límite es duro: si el proceso lo supera, el kernel lo mata (OOMKilled, código 137). La JVM lee ese límite a través de cgroups v2 y calcula el heap con `MaxRAMPercentage`.

Para memoria, la recomendación habitual es `requests` igual a `limits`: el pod tiene memoria garantizada y no compite con otros en un nodo saturado. Para CPU es más delicado: un límite de CPU bajo provoca throttling (el proceso se pausa durante cada periodo de 100 ms al agotar su cuota), muy dañino durante el arranque de una aplicación Spring y para el GC. Muchos equipos fijan solo `requests` de CPU, sin límite.

La JVM también usa la CPU disponible para dimensionar hilos del GC y del `ForkJoinPool`. Con límites fraccionarios (500m) puede ver un solo procesador y elegir el recolector Serial; puedes ajustarlo con `-XX:ActiveProcessorCount`.

*Recursos del contenedor y opciones de la JVM*

```yaml
containers:
  - name: taskflow
    image: registry.acme.com/taskflow:1.4.0
    resources:
      requests:
        cpu: "500m"
        memory: "1Gi"
      limits:
        memory: "1Gi"                 # igual que el request: memoria garantizada
        # sin límite de CPU: evita throttling en el arranque y en picos de GC
    env:
      - name: JAVA_TOOL_OPTIONS       # la JVM lo lee siempre, sin tocar el ENTRYPOINT
        value: >-
          -XX:MaxRAMPercentage=70.0
          -XX:ActiveProcessorCount=2
          -XX:+ExitOnOutOfMemoryError
          -Xlog:gc:stdout:time
```

- **En una línea:** `-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0`
- **Por qué importa:** Evita que el kernel de Linux mate el proceso Java por exceso de memoria (OOMKilled - Exit Code 137).
- **Error típico:** Fijar `-Xmx1g` cuando el límite del Pod en Kubernetes es exactamente `1Gi`. Los hilos y el Metaspace desbordan el límite y Kubernetes ejecuta OOMKill instantáneo.
- **Java 17 frente a 21:** Soporte completo de cgroups v2 tanto en 17 como en 21.
- **Se conecta con:** Kubernetes Resource Limits, OOMKilled, Metaspace

### 3. Primitivas de Kubernetes: Pods, Deployments, Services y ConfigMaps/Secrets

Un **Pod** es la unidad mínima ejecutable. Un **Deployment** gestiona el ciclo de vida de réplicas de Pods (rolling updates, auto-recuperación). Un **Service** (ClusterIP) provee una IP y DNS interno estable que balancea peticiones entre Pods. **ConfigMaps** inyectan variables no sensibles y **Secrets** inyectan credenciales cifradas en base64 montadas como variables de entorno o ficheros.

**Cómo funciona por dentro.**

Un Pod es la unidad mínima de despliegue: uno o varios contenedores que comparten red y volúmenes. No se crean a mano. Un Deployment declara cuántas réplicas quieres y de qué imagen, y mantiene ese estado: si un pod muere, crea otro; si cambias la imagen, hace una actualización gradual.

Los pods son efímeros y su IP cambia, así que un Service les da un nombre DNS estable (`taskflow.mi-namespace.svc`) y reparte el tráfico entre los pods que estén listos. Un Ingress (o una Route en OpenShift) expone el Service fuera del clúster con un dominio y TLS.

La configuración se separa de la imagen: un ConfigMap para valores no sensibles y un Secret para credenciales, inyectados como variables de entorno o ficheros. Ojo: un Secret solo está codificado en Base64, no cifrado; hay que restringir el acceso con RBAC, activar el cifrado en reposo de etcd o usar un gestor externo (Vault, External Secrets).

*Deployment, Service, ConfigMap y Secret*

```yaml
apiVersion: v1
kind: ConfigMap
metadata: { name: taskflow-config }
data:
  SPRING_PROFILES_ACTIVE: "prod"
  SPRING_DATASOURCE_URL: "jdbc:postgresql://postgres:5432/taskflow"
---
apiVersion: v1
kind: Secret
metadata: { name: taskflow-db }
type: Opaque
stringData:                           # Kubernetes lo guarda en Base64 (no es cifrado)
  SPRING_DATASOURCE_USERNAME: taskflow
  SPRING_DATASOURCE_PASSWORD: cambiar-en-el-gestor-de-secretos
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: taskflow }
spec:
  replicas: 3
  selector: { matchLabels: { app: taskflow } }
  template:
    metadata: { labels: { app: taskflow } }
    spec:
      containers:
        - name: taskflow
          image: registry.acme.com/taskflow:1.4.0
          ports: [{ containerPort: 8080 }]
          envFrom:
            - configMapRef: { name: taskflow-config }
            - secretRef: { name: taskflow-db }
---
apiVersion: v1
kind: Service
metadata: { name: taskflow }
spec:
  selector: { app: taskflow }
  ports: [{ port: 80, targetPort: 8080 }]
```

- **En una línea:** `env:
  - name: SPRING_DATASOURCE_URL
    valueFrom:
      configMapKeyRef:
        name: app-config
        key: db_url`
- **Por qué importa:** Permite desacoplar el artefacto binario (imagen inmutable) de la configuración específica del entorno (Dev, QA, Prod).
- **Error típico:** Hardcodear cadenas de conexión a base de datos en el `application.yml` empaquetado en la imagen Docker.
- **Java 17 frente a 21:** Independiente de la versión de Java.
- **Se conecta con:** 12-Factor App, DNS interno K8s, Rolling Updates

### 4. Health Probes: Liveness, Readiness y Startup Probes con Spring Boot Actuator

**Startup Probe**: Verifica si la aplicación ha terminado de iniciar (evita matar apps lentas al arrancar). **Readiness Probe**: Verifica si el Pod está listo para recibir tráfico de red (si la BD está conectada); si falla, el Service deja de enviarle peticiones. **Liveness Probe**: Verifica si el contenedor sigue vivo (no en deadlock); si falla, Kubernetes destruye el Pod y crea uno nuevo.

**Cómo funciona por dentro.**

Kubernetes usa tres sondas con propósitos distintos. La de liveness responde a «¿está el proceso vivo o bloqueado sin remedio?»: si falla varias veces, Kubernetes reinicia el contenedor. La de readiness responde a «¿puede atender tráfico ahora?»: si falla, el pod se retira del Service pero no se reinicia. La de startup protege el arranque: mientras no tenga éxito, las otras dos no se evalúan, lo que evita que una aplicación lenta en arrancar se reinicie en bucle.

Spring Boot expone las dos primeras con Actuator: `/actuator/health/liveness` refleja el `LivenessState` (roto solo ante errores internos irrecuperables) y `/actuator/health/readiness` el `ReadinessState`, que pasa a `REFUSING_TRAFFIC` durante el arranque y al iniciar el apagado.

El error más grave es incluir la base de datos en liveness: si PostgreSQL cae, todos los pods fallan liveness, se reinician a la vez en bucle y la recuperación se complica. Las dependencias externas, en todo caso, en readiness.

*Las tres sondas con Actuator*

```yaml
containers:
  - name: taskflow
    ports:
      - { name: http, containerPort: 8080 }
      - { name: management, containerPort: 8081 }
    startupProbe:
      httpGet: { path: /actuator/health/liveness, port: management }
      periodSeconds: 5
      failureThreshold: 30            # hasta 150 s para arrancar
    livenessProbe:
      httpGet: { path: /actuator/health/liveness, port: management }
      periodSeconds: 10
      failureThreshold: 3
    readinessProbe:
      httpGet: { path: /actuator/health/readiness, port: management }
      periodSeconds: 5
      failureThreshold: 2
```

- **En una línea:** `livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080`
- **Por qué importa:** Garantiza despliegues sin caída (Zero-Downtime Deployments) y auto-recuperación ante fallos críticos.
- **Error típico:** Configurar Liveness apuntando a un endpoint que comprueba la base de datos externa. Si la BD cae temporalmente, Kubernetes reiniciará en bucle todos los Pods a la vez (efecto avalancha). Liveness solo debe comprobar la salud interna del proceso.
- **Java 17 frente a 21:** Idéntico en 17 y 21 con Actuator.
- **Se conecta con:** Spring Boot Actuator, Zero-Downtime, Traffic Routing

### 5. OpenShift vs Kubernetes Vanilla: SecurityContextConstraints, Routes y BuildConfig

Red Hat OpenShift es una plataforma empresarial basada en Kubernetes con capas añadidas de seguridad y productividad. Diferencias clave: (1) **Routes** en lugar de Ingress (TLS termination nativa); (2) **Security Context Constraints (SCC)**: OpenShift prohíbe por defecto que los contenedores corran como `root` o con UID fijo (asigna un UID arbitrario aleatorio); (3) **DeploymentConfig / ImageStreams**.

**Cómo funciona por dentro.**

OpenShift es una distribución de Kubernetes de Red Hat con capas adicionales y opciones de seguridad más estrictas. La diferencia que más sorprende: por defecto, la SecurityContextConstraint `restricted-v2` ejecuta cada contenedor con un UID aleatorio de un rango asignado al proyecto, ignorando el `USER` de la imagen, y siempre con el grupo 0 (root group, sin privilegios de root).

Consecuencia práctica: los ficheros y directorios en los que la aplicación necesita escribir deben pertenecer al grupo 0 y tener permisos de grupo iguales a los del propietario (`chgrp -R 0` y `chmod -R g=u`). Las imágenes oficiales de Red Hat (UBI) ya siguen este convenio.

Otras diferencias: las Routes (anteriores a Ingress) exponen servicios con TLS de forma nativa, los ImageStreams versionan imágenes dentro del clúster y disparan despliegues, y BuildConfig con Source-to-Image (S2I) construye imágenes a partir del código fuente dentro del propio clúster. La CLI `oc` es un superconjunto de `kubectl`.

*Imagen compatible con OpenShift y una Route*

```yaml
# Dockerfile: escribible por el grupo 0, sea cual sea el UID asignado
#   FROM eclipse-temurin:21-jre
#   WORKDIR /app
#   COPY --from=build /workspace/extracted/ ./
#   RUN chgrp -R 0 /app && chmod -R g=u /app
#   USER 1001                      # OpenShift lo sustituirá por un UID aleatorio del rango

apiVersion: route.openshift.io/v1
kind: Route
metadata:
  name: taskflow
spec:
  host: taskflow.apps.acme.com
  to:
    kind: Service
    name: taskflow
  port:
    targetPort: 8080
  tls:
    termination: edge                # TLS termina en el router de OpenShift
    insecureEdgeTerminationPolicy: Redirect

# oc new-app registry.acme.com/taskflow:1.4.0 --name taskflow
# oc get scc ; oc describe pod taskflow-xxx | grep scc   (qué SCC se aplicó)
```

- **En una línea:** `RUN chgrp -R 0 /app && chmod -R g=u /app && USER 1001`
- **Por qué importa:** Común en banca, seguros y grandes corporaciones donde la seguridad estricta y cumplimiento normativo son obligatorios.
- **Error típico:** Crear una imagen Docker que asume usuario root o escribe en directorios protegidos como `/root` o `/etc`. En OpenShift fallará con `Permission denied`. Siempre se debe dar permisos al grupo root (GID 0) en el directorio de trabajo: `chgrp -R 0 /app && chmod -R g=u /app`.
- **Java 17 frente a 21:** Independiente de Java.
- **Se conecta con:** Red Hat Enterprise, SCC, Ingress vs Route, Rootless Containers

### 6. Seguridad del Contenedor y Caché de Capas

Una imagen de producción se ejecuta con un usuario sin privilegios (`USER`), parte de una imagen base mínima sin herramientas de compilación y ordena el Dockerfile para que lo que cambia poco (las dependencias) quede en capas que Docker reutiliza entre builds.

**Cómo funciona por dentro.**

Un contenedor no es una máquina virtual: comparte el kernel con el host. Si el proceso corre como root dentro del contenedor y alguien explota una vulnerabilidad de la aplicación, una mala configuración (volúmenes montados, capacidades extra) puede darle acceso al host. Por eso la imagen crea un usuario sin privilegios y lo activa con `USER` antes del `ENTRYPOINT`; Kubernetes lo puede exigir con `runAsNonRoot: true`, y OpenShift lo impone por defecto asignando un UID arbitrario.

La superficie de ataque también se reduce con imágenes base mínimas: un JRE en lugar de un JDK, sin compiladores, gestores de paquetes ni shell cuando es posible (imágenes distroless). Menos paquetes significa menos vulnerabilidades que parchear, y los escáneres de imágenes (Trivy, Grype) las detectan en el pipeline.

Cada instrucción del Dockerfile crea una capa, y Docker reutiliza una capa si ni ella ni las anteriores han cambiado. Por eso el orden importa: primero lo que cambia poco (el `pom.xml` y la descarga de dependencias) y al final lo que cambia en cada commit (el código). Spring Boot puede además partir el JAR en capas (dependencias, cargador, snapshots y aplicación) para que, al cambiar el código, solo se reconstruya y se suba la última.

*Dockerfile ordenado, sin root y con las capas de Spring Boot*

```dockerfile
# Etapa 1: compilar. Las dependencias se descargan en una capa propia
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw -B dependency:go-offline           # se reutiliza mientras no cambie el pom
COPY src ./src
RUN ./mvnw -B package -DskipTests && \
    java -Djarmode=tools -jar target/*.jar extract --layers --launcher --destination extracted

# Etapa 2: ejecutar con JRE y un usuario sin privilegios
FROM eclipse-temurin:21-jre
RUN groupadd --system app && useradd --system --gid app --uid 1001 app
WORKDIR /app
COPY --from=build /src/extracted/dependencies/ ./
COPY --from=build /src/extracted/spring-boot-loader/ ./
COPY --from=build /src/extracted/snapshot-dependencies/ ./
COPY --from=build /src/extracted/application/ ./
USER 1001
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "org.springframework.boot.loader.launch.JarLauncher"]
```

- **En una línea:** `COPY pom.xml .` y `RUN ./mvnw dependency:go-offline` antes de `COPY src ./src`; `USER 1001` antes del `ENTRYPOINT`.
- **Por qué importa:** Si alguien compromete la aplicación, un contenedor como root le da mucho más margen. Y un buen orden de capas convierte builds de varios minutos en segundos.
- **Error típico:** Copiar todo el proyecto (`COPY . .`) antes de descargar las dependencias: cualquier cambio en el código invalida la caché y vuelve a descargarlo todo.
- **Java 17 frente a 21:** Igual en Java 17 y 21; las imágenes base con JRE 21 (por ejemplo `eclipse-temurin:21-jre`) siguen el mismo patrón.
- **Se conecta con:** Dockerfile multi-stage, Escaneo de imágenes, Spring Boot layertools


## Sesión de 90 minutos

### Bloque 1: Dockerfile Multi-Stage y JVM Flags en Contenedores (0 - 25 min)

- **Qué estudiar:** Eclipse Temurin JRE, layertools de Spring Boot, MaxRAMPercentage.
- **Pregunta a responder:** ¿Por qué layertools optimiza la velocidad del CI/CD?
- **Entregable:** Dockerfile multi-stage completo y testeado localmente.

### Bloque 2: Manifiestos de Kubernetes (Deployment, Service, ConfigMap) (25 - 50 min)

- **Qué estudiar:** Estructura YAML de Deployment, Service ClusterIP, variables desde ConfigMap/Secret.
- **Pregunta a responder:** ¿Cómo conectar un microservicio Java con PostgreSQL usando Services internos?
- **Entregable:** Fichero deployment.yaml con health probes configuradas.

### Bloque 3: Sondas de Salud y Despliegue en OpenShift (50 - 80 min)

- **Qué estudiar:** Actuator liveness/readiness groups, permisos OpenShift GID 0, Route vs Ingress.
- **Pregunta a responder:** ¿Qué diferencia hay entre una Route de OpenShift y un Ingress de K8s?
- **Entregable:** Configuración de Graceful Shutdown y probes en application.yml.

### Cierre y Consolidación (80 - 90 min)

- **Qué estudiar:** Checklist de producción para contenedores Java.
- **Pregunta a responder:** ¿Qué 3 errores causan el 90% de los fallos de pods Java en producción?
- **Entregable:** Notas de mejores prácticas guardadas.


## Explícalo desde cero

### Por qué Liveness y Readiness deben ser endpoints separados

- **Qué entender:** Readiness decide si el Pod recibe tráfico. Liveness decide si el Pod debe ser asesinado y reiniciado. Si apuntas Liveness a una base de datos externa y la base de datos se ralentiza, Kubernetes matará todos tus Pods a la vez, creando una caída generalizada del sistema.
- **Qué suele explicarse mal:** Usar un único endpoint `/actuator/health` genérico para ambas sondas.
- **Ejemplo sencillo:** Readiness es como un médico diciéndole al recepcionista: "Estoy atendiendo a un paciente complejo, no me envíes más gente a la sala de espera por 5 minutos". Liveness es comprobar si el médico tiene pulso; si no tiene pulso, llamas a emergencias (reinicio).
- **Señal de alerta:** Si la sonda de liveness comprueba la conexión a Kafka o a PostgreSQL.


## Preguntas

### Nivel 1 (Básico): ¿Por qué ocurre el error OOMKilled (Exit Code 137) en un Pod Java de Kubernetes si configuraste `-Xmx512m` y el Pod tiene 512Mi de límite?

La JVM consume más memoria que solo el Heap: necesita espacio para Metaspace, threads stack (1MB por hilo), buffers I/O nativos y compilador JIT. 512m de heap + 150m de memoria no-heap supera los 512Mi del límite de Kubernetes, activando el OOM Killer del kernel.

**Respuesta razonada:** Porque `-Xmx512m` solo limita el heap, y el proceso Java consume bastante más memoria: metaspace con las clases cargadas, la caché de código del JIT, una pila por cada hilo, buffers directos, estructuras del recolector y memoria nativa de librerías. En una aplicación Spring Boot eso suma fácilmente 200-300 MB. Con 512 MB de heap más ese extra, el proceso supera los 512 Mi del límite y el kernel lo mata: OOMKilled con código 137, sin rastro de `OutOfMemoryError` en los logs.

La solución es dejar margen: no fijar `-Xmx` igual al límite sino usar `-XX:MaxRAMPercentage` (60-75 % según el tamaño), subir el límite del pod, reducir hilos de plataforma (los hilos virtuales ayudan) y medir el consumo real con Native Memory Tracking (`-XX:NativeMemoryTracking=summary` y `jcmd <pid> VM.native_memory summary`).

### Nivel 2 (Intermedio): ¿Por qué un contenedor Docker que funciona en Kubernetes Vanilla puede fallar con `Permission Denied` al desplegarse en OpenShift?

OpenShift aplica SCC (Security Context Constraints) por defecto, ejecutando el contenedor con un UID aleatorio y sin permisos de root. Si la aplicación intenta escribir en directorios sin permisos del GID 0 o asume ser root, fallará.

**Respuesta razonada:** Porque OpenShift, con su SCC `restricted-v2`, no ejecuta el contenedor con el usuario declarado en la imagen sino con un UID aleatorio (por ejemplo, 1000680000) y el grupo 0. Si los ficheros de la imagen pertenecen a `root` o a un usuario concreto como `spring:spring` con permisos solo para el propietario, ese UID aleatorio no puede escribir en ellos, y cualquier escritura (logs, ficheros temporales, caché) falla con `Permission denied`. En Kubernetes estándar funcionaba porque usaba el UID de la imagen.

La solución es preparar la imagen para el grupo 0: `chgrp -R 0 /app && chmod -R g=u /app`, escribir en directorios montados como volúmenes `emptyDir` (`/tmp`) y no depender de un UID fijo ni de entradas en `/etc/passwd`. No se debe resolver dando a la aplicación la SCC `anyuid`, que relaja la seguridad.

### Nivel 3 (Entrevista Senior): ¿Cómo diseñarías una estrategia de Zero-Downtime Deployment para un microservicio Spring Boot con conexiones de base de datos activas en Kubernetes?

Usa `terminationGracePeriodSeconds` (e.g. 30-60s) en el Pod spec, habilita Graceful Shutdown en Spring Boot (`server.shutdown=graceful`), configura un `preStop` hook con `sleep 5` para dar tiempo a que los Services y endpoints actualicen su tabla de enrutamiento iptables/ipvs antes de que el proceso reciba SIGTERM, y asegura que la sonda de readiness marque al Pod como no listo inmediatamente.

**Respuesta razonada:** Combinando varias piezas. En el Deployment, estrategia `RollingUpdate` con `maxUnavailable: 0` y `maxSurge: 1`, para que siempre haya capacidad completa. Una readiness probe que solo dé éxito cuando la aplicación puede atender, de modo que el nuevo pod no recibe tráfico antes de tiempo. Un `PodDisruptionBudget` para que mantenimientos del clúster no tiren demasiados pods a la vez.

En el apagado: `server.shutdown=graceful` y `spring.lifecycle.timeout-per-shutdown-phase` para terminar las peticiones en curso; un `preStop` con una breve espera (`sleep 5`) porque los endpoints del Service tardan unos segundos en dejar de enviar tráfico tras la señal; y `terminationGracePeriodSeconds` mayor que la suma. HikariCP cierra sus conexiones limpiamente al cerrar el contexto. Por último, las migraciones de base de datos deben ser compatibles con la versión anterior (patrón expand/contract), porque durante el despliegue conviven ambas versiones.


## Reto del tutor

**Reto:** Un Pod de Spring Boot tarda 45 segundos en arrancar por la inicialización de cachés y beans. Kubernetes lo mata repetidamente a los 30 segundos porque la sonda de Liveness falla con timeout. ¿Cómo lo resuelves de forma profesional?

- **Lo que está bien:** Menciona ajustar tiempos de espera o usar Startup Probe.
- **Respuesta superficial:** Decir que aumente el initialDelaySeconds de la livenessProbe a 100 segundos.
- **Lo que falta de fondo:** Aumentar simplemente el `initialDelaySeconds` de la liveness retrasa la detección de cuelgues reales en pods que ya estaban vivos. El patrón moderno de Kubernetes es añadir una `startupProbe` dedicada.
- **Respuesta senior:** La solución arquitectónica correcta es implementar una `startupProbe` de Kubernetes que apunte a `/actuator/health/liveness` con `failureThreshold: 30` y `periodSeconds: 2` (dándole hasta 60 segundos para arrancar). Mientras la startupProbe esté evaluándose, Kubernetes deshabilita por completo las sondas de liveness y readiness. Una vez que la startup probe pasa con éxito por primera vez, se activan las comprobaciones de liveness y readiness con intervalos estándar (e.g. periodSeconds: 5).


## Katas

### Kata 29.1: Dockerfile Multi-Stage con Spring Boot Layertools

Escribe un Dockerfile multi-stage que en la etapa 1 compile el jar, en la etapa 2 extraiga las capas con `java -Djarmode=layertools -jar app.jar extract`, y en la etapa final use `eclipse-temurin:21-jre-alpine` copiando dependencias y aplicación por separado.


## Reglas de oro

- Usa siempre -XX:MaxRAMPercentage=75.0 en lugar de -Xmx fijo dentro de contenedores.
- Separa la Startup Probe de la Liveness Probe para aplicaciones con arranque pesado.
- La Liveness Probe NUNCA debe comprobar bases de datos o servicios externos.
- En OpenShift, da permisos de escritura al GID 0 para compatibilidad con SCC.
- Usa layertools de Spring Boot para crear imágenes Docker con capas cacheadas eficientes.
- Ejecuta el contenedor con un usuario sin privilegios (`USER`), nunca como root.


## Proyecto guiado «Pedidos»: Contenedores y Kubernetes

Empaquetar los servicios y desplegarlos en un clúster local.

**Tareas:**

- Escribe un Dockerfile multi-etapa para `pedidos`, con usuario sin privilegios y las capas de Spring Boot.
- Despliega `pedidos` y PostgreSQL en kind o minikube con Deployment, Service y ConfigMap.
- Configura las probes de liveness y readiness con Actuator y `-XX:MaxRAMPercentage`.

**Está listo cuando:**

- Cambiar solo el código reconstruye la imagen en segundos gracias a la caché de capas.
- Si la base de datos cae, el pod deja de recibir tráfico pero no se reinicia en bucle.


## Fuentes oficiales

- [Spring Boot: imágenes de contenedor](https://docs.spring.io/spring-boot/reference/packaging/container-images/index.html)
- [Kubernetes: pods](https://kubernetes.io/docs/concepts/workloads/pods/)
- [Kubernetes: sondas liveness, readiness y startup](https://kubernetes.io/docs/concepts/workloads/pods/probes/)
- [Documentación de Red Hat OpenShift Container Platform](https://docs.redhat.com/en/documentation/openshift_container_platform/)

