Las cuatro lecciones anteriores comparaban herramientas de CI/CD entre sí. Esta no, porque Docker y Kubernetes no son herramientas de CI/CD: son el sustrato. Docker aparece dos veces en cualquier pipeline moderno y con papeles completamente distintos —es el entorno donde corre el pipeline y es el formato del artefacto que se despliega—, y confundir esos dos papeles produce imágenes de producción de 1,2 GB que contienen el compilador, los tests y las credenciales del build. Kubernetes, por su parte, es el destino más común hoy y cambia la forma del CD: el modelo de push que usa Reservalia contra ECS tiene un equivalente natural en Kubernetes, pero la comunidad se movió mayoritariamente hacia GitOps, que invierte quién inicia el despliegue. El curso ha usado ambos desde el módulo 2 sin bajar al detalle; esta lección baja. Y termina con la pregunta que casi nunca se hace en voz alta: cuándo NO necesitas Kubernetes.

Contenido

  1. Los dos papeles de Docker en un pipeline
  2. BuildKit: cachés de montaje, multi-etapa y secretos
  3. Caché de capas en el registro y builds multi-plataforma
  4. Imágenes mínimas, no-root, HEALTHCHECK y etiquetas OCI
  5. Por qué el digest manda sobre el tag
  6. Construir imágenes dentro del pipeline: DinD, socket y constructores sin daemon
  7. Kubernetes como destino: los objetos mínimos
  8. Sondas y su papel en el rolling update
  9. Rollback: kubectl rollout status y undo
  10. Parametrizar por entorno: Helm y Kustomize
  11. Push frente a pull: por qué en Kubernetes triunfa GitOps
  12. Reservalia en Kubernetes, contrastado con ECS
  13. Kubernetes como plataforma para los propios runners
  14. Cuándo NO necesitas Kubernetes
  15. Errores Comunes y Consejos
  16. Ejercicios
  17. Conclusión

  1. Los dos papeles de Docker en un pipeline

flowchart LR
    subgraph P["Papel A · entorno del pipeline"]
      R1["Job calidad<br/>contenedor node:22"]
      R2["Job test<br/>contenedor node:22 + postgres"]
      R3["Job build<br/>contenedor con buildx"]
    end
    subgraph A["Papel B · formato del artefacto"]
      IM["Imagen de runtime<br/>distroless, no-root, 90 MB"]
      REG["Registro ECR<br/>identificada por digest"]
      DEST["ECS / Kubernetes"]
    end
    R3 -->|"docker build --push"| IM --> REG --> DEST
Papel A: entorno del pipeline Papel B: artefacto desplegable
Qué es La imagen donde se ejecutan los steps La imagen que se despliega en producción
Quién la elige El image: del job, el container: o el executor Tu Dockerfile
Qué contiene Compilador, gestor de paquetes, herramientas, CLIs Solo lo necesario para ejecutar
Tamaño razonable 500 MB - 1,5 GB, da igual Lo más pequeño posible
Ciclo de vida Muere con el job Vive meses en producción
Criterio de seguridad Aislamiento del job Superficie de ataque mínima
Objetivo Reproducibilidad del build Reproducibilidad del despliegue + seguridad

El papel A es el que da reproducibilidad al pipeline: un job que corre en node:22.11-bookworm se comporta igual hoy que dentro de seis meses, independientemente de qué tenga instalado la máquina que lo ejecuta. Es la respuesta al agente contaminado de la 06-01, y por eso todas las herramientas modernas lo usan por defecto.

El papel B es el artefacto inmutable de la 02-06. La confusión entre ambos produce el antipatrón más común de todo el tema: usar la misma imagen para construir y para ejecutar. El resultado es una imagen de producción que incluye el compilador de TypeScript, devDependencies, el historial de Git y, con suerte, ningún fichero de credenciales del build. La separación se hace con multi-etapa, que el curso introdujo en la 02-03 y que aquí llevamos al detalle.

  1. BuildKit: cachés de montaje, multi-etapa y secretos

BuildKit es el motor de construcción moderno (por defecto en versiones recientes de Docker y en buildx). Aporta tres cosas que cambian de verdad un pipeline: construcción paralela del grafo de etapas, cachés de montaje y secretos que no quedan en la imagen.

# syntax=docker/dockerfile:1.7                        # 1 · habilita la sintaxis de BuildKit
# apps/api/Dockerfile — Reservalia

# ---------- etapa de dependencias ----------
FROM node:22.11-bookworm AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/
COPY packages/compartido/package.json packages/compartido/
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
    npm ci --prefer-offline                            # 2 · caché persistente entre builds

# ---------- etapa de construcción ----------
FROM deps AS build
COPY . .
RUN --mount=type=cache,target=/app/.tsbuildinfo \
    npm run build --workspace apps/api
RUN npm ci --omit=dev --prefer-offline                 # 3 · deja solo dependencias de producción

# ---------- etapa de runtime ----------
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime   # 4
WORKDIR /app
COPY --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/apps/api/dist ./dist

USER nonroot                                            # 5
EXPOSE 3000
ENV NODE_ENV=production

LABEL org.opencontainers.image.source="https://github.com/reservalia/reservalia" \
      org.opencontainers.image.revision="${GIT_SHA}" \
      org.opencontainers.image.licenses="UNLICENSED"    # 6

ENTRYPOINT ["/nodejs/bin/node", "dist/servidor.js"]
  1. La línea # syntax= no es un comentario: le dice a BuildKit qué versión del frontend usar, y es lo que habilita --mount. Sin ella, esas líneas fallan.
  2. --mount=type=cache es la mejora más rentable y la menos conocida. La caché de npm se monta durante el RUN y no queda en la imagen, pero persiste entre builds en el constructor. Diferencia respecto al truco clásico de "copiar solo package.json primero para aprovechar la caché de capas": este funciona aunque el lockfile cambie, porque no depende de invalidar una capa sino de tener los tarballs ya descargados. sharing=locked evita corrupción con builds concurrentes.
  3. npm ci --omit=dev después de construir es lo que evita llevar devDependencies al runtime. Es un ahorro habitual de cientos de megabytes.
  4. Imagen de runtime distinta y mínima: apartado 4.
  5. USER nonroot: apartado 4.
  6. Etiquetas OCI: apartado 4.

Y los secretos en tiempo de construcción, que es donde más gente se equivoca:

# MAL: el token queda en el historial de la imagen para siempre
ARG NPM_TOKEN
RUN echo "//registro.example/:_authToken=${NPM_TOKEN}" > .npmrc && npm ci && rm .npmrc
#   ↑ borrar el fichero después NO lo elimina: la capa anterior lo contiene

# BIEN: montaje de secreto, no persiste en ninguna capa
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --prefer-offline
docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t reservalia/api:$SHA .

La regla es la de la 04-03 con un mecanismo concreto: ARG y ENV con secretos son visibles con docker history para cualquiera que tenga la imagen. Borrar el fichero en un RUN posterior no sirve: las capas son inmutables y acumulativas. --mount=type=secret es la única forma correcta.

  1. Caché de capas en el registro y builds multi-plataforma

En un runner efímero no hay caché local de capas: cada build empieza de cero. La solución es guardar la caché en el registro, que es lo que el ci.yml de Reservalia ya hacía y aquí explicamos.

docker buildx build \
  --file apps/api/Dockerfile \
  --cache-from type=registry,ref=$ECR/reservalia/api:cache \
  --cache-to   type=registry,ref=$ECR/reservalia/api:cache,mode=max \
  --tag $ECR/reservalia/api:$GIT_SHA \
  --push .
Modo de caché Qué guarda Cuándo
mode=min (por defecto) Solo las capas de la imagen final Poco útil con multi-etapa: pierde las etapas intermedias
mode=max Capas de todas las etapas, incluidas deps y build Lo que quieres casi siempre; ocupa más en el registro
type=gha El almacén de caché de GitHub Actions Cómodo en Actions; sujeto a sus límites de tamaño y desalojo
type=inline La caché va dentro de la propia imagen Simple; solo mode=min en la práctica

Dos avisos. La caché en el registro también hay que limpiarla: es una etiqueta más que crece, y con mode=max crece bastante. Y una caché de capas envenenada es un vector real: si alguien con acceso de escritura al registro sustituye la caché, tus builds pueden incorporar capas ajenas. Los permisos de escritura sobre el repositorio de caché deben ser los mismos que sobre las imágenes (04-03).

Multi-plataforma con buildx, relevante desde que los runners ARM son más baratos y los portátiles de desarrollo son ARM:

docker buildx create --name multi --driver docker-container --use
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag $ECR/reservalia/api:$GIT_SHA \
  --push .

Esto produce una lista de manifiestos: un único tag que apunta a dos imágenes, y el destino descarga la que corresponde a su arquitectura. Contrapartidas honestas: si el runner es amd64, la variante arm64 se construye por emulación con QEMU y puede tardar entre tres y diez veces más; la alternativa es construir cada arquitectura en un runner nativo y unir los manifiestos con docker buildx imagetools create, que es más rápido y más complejo. Y si tu aplicación tiene dependencias con binarios nativos, hay que probar de verdad en las dos arquitecturas: compilar no es funcionar.

  1. Imágenes mínimas, no-root, HEALTHCHECK y etiquetas OCI

Base Tamaño típico Tiene shell Superficie Cuándo
node:22 (Debian completa) ~1,1 GB Alta Solo para construir
node:22-slim ~200 MB Media Runtime aceptable, fácil de depurar
node:22-alpine ~130 MB Sí (ash) Baja Runtime pequeño; ojo con musl frente a glibc
gcr.io/distroless/nodejs22 ~110 MB No Muy baja Runtime de producción
scratch 0 No Mínima Binarios estáticos (Go, Rust)

La diferencia importante no es el tamaño, es qué hay dentro. Una imagen distroless no tiene shell, ni gestor de paquetes, ni curl, ni utilidades: si alguien consigue ejecución en el contenedor, no tiene con qué trabajar. También reduce drásticamente el ruido de los escáneres de vulnerabilidades: la mayoría de los CVE que aparecen en una imagen Debian completa están en paquetes que tu aplicación nunca usa, y ese ruido es lo que hace que los equipos dejen de mirar los informes de la 04-03.

Contrapartida real y hay que decirla: depurar en distroless es incómodo. No puedes hacer kubectl exec -it ... -- sh porque no hay sh. Las respuestas: contenedores efímeros de depuración (kubectl debug --image=busybox --target=api) que se adjuntan al Pod sin modificar la imagen, y buena observabilidad (03-06) para no necesitar entrar. Muchos equipos usan -slim en staging y distroless en producción, lo cual es un compromiso defendible aunque rompe ligeramente la regla de "el mismo artefacto en todos los entornos".

Usuario no-root: por defecto un contenedor corre como root, y aunque el aislamiento del contenedor limita el daño, un proceso root que escape tiene mucho más margen. En Kubernetes esto se refuerza con la política del Pod:

securityContext:
  runAsNonRoot: true                # el Pod no arranca si la imagen corre como root
  runAsUser: 65532
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true      # obliga a declarar volúmenes para lo escribible
  capabilities: { drop: ["ALL"] }

HEALTHCHECK en el Dockerfile es útil con Docker o Compose; en Kubernetes lo ignora el orquestador, que usa sus propias sondas (apartado 8). Ponerlo no sobra —documenta cómo se comprueba que el proceso está sano— pero no confíes en él como mecanismo en un clúster.

Etiquetas OCI: son metadatos estándar que hacen trazable la imagen. org.opencontainers.image.source conecta la imagen con su repositorio y revision con el commit exacto. La pregunta "¿de qué commit salió lo que está en producción?" —primera pregunta de cualquier incidente, según la 03-05— se responde con docker inspect en vez de con arqueología.

  1. Por qué el digest manda sobre el tag

Ya se estableció en la 02-06; aquí el mecanismo:

# Un tag es un puntero MUTABLE
docker push reservalia/api:v1.4.0          # apunta a sha256:aaa...
docker push reservalia/api:v1.4.0          # ahora apunta a sha256:bbb... y nadie se entera

# Un digest es el hash del manifiesto: INMUTABLE por construcción
docker pull reservalia/api@sha256:aaa1b2c3...

Consecuencias prácticas que hay que interiorizar:

  • FROM node:22 en tu Dockerfile no es reproducible. Ese tag apunta a imágenes distintas con el tiempo. Para builds verdaderamente reproducibles: FROM node:22.11-bookworm@sha256:.... Contrapartida: hay que actualizarlo, y ahí entra la automatización de dependencias de la 04-02, que también renueva digests de imágenes base.
  • Desplegar por tag introduce una carrera: entre que el pipeline construye :v1.4.0 y el orquestador lo descarga, ese tag puede apuntar a otra cosa. Por eso el cd.yml de Reservalia despliega por digest.
  • La promoción entre entornos es mover un digest, no reconstruir. Staging y producción ejecutan el mismo sha256: exacto, y eso es lo que hace que "funcionaba en staging" signifique algo.
  • Los registros permiten tags inmutables (ECR: tag immutability), que impide sobrescribir un tag existente. Actívalo: cierra la puerta a un error humano con consecuencias graves.

  1. Construir imágenes dentro del pipeline: DinD, socket y constructores sin daemon

Construir una imagen desde un job que ya corre en un contenedor es un problema real con tres soluciones y tres perfiles de riesgo distintos. Es una de las decisiones de seguridad más importantes de un pipeline y casi siempre se toma por inercia.

Enfoque Cómo funciona Riesgo Rendimiento
Docker-in-Docker (DinD) Un daemon Docker completo dentro del contenedor del job, en modo privilegiado Alto: --privileged equivale a acceso al kernel del host; escapar del contenedor es factible Caché fría cada vez salvo montaje de volumen
Socket del host montado Se monta /var/run/docker.sock del host Muy alto: acceso al daemon del host = root en el host, y visibilidad de los contenedores de otros jobs Bueno: caché compartida
Kaniko Construye en espacio de usuario, sin daemon Bajo Aceptable; caché en registro
Buildah Constructor sin daemon, puede correr rootless Bajo Bueno
BuildKit rootless Daemon de BuildKit sin privilegios Bajo Muy bueno; soporta --mount=type=cache

El razonamiento sobre por qué esto importa: en un runner compartido, un job con socket del host montado puede listar y manipular los contenedores de otros jobs, incluidos los que tienen credenciales de producción en su entorno. No hace falta un atacante externo: basta un .gitlab-ci.yml o un workflow modificado en un PR. Es la escalada de privilegios más accesible en un CI mal configurado.

# Kaniko en un job de GitLab CI: sin daemon, sin privilegios
build:
  image:
    name: gcr.io/kaniko-project/executor:v1.23.2-debug
    entrypoint: [""]
  script:
    - /kaniko/executor
        --context "${CI_PROJECT_DIR}"
        --dockerfile "${CI_PROJECT_DIR}/apps/api/Dockerfile"
        --destination "${CI_REGISTRY_IMAGE}/api:${CI_COMMIT_SHA}"
        --cache=true
        --cache-repo "${CI_REGISTRY_IMAGE}/cache"
        --image-name-with-digest-file /tmp/digest.txt   # el digest, para promocionar
  artifacts:
    paths: [ /tmp/digest.txt ]
# BuildKit rootless como contenedor secundario en un Pod de agente
containers:
  - name: buildkit
    image: moby/buildkit:v0.16.0-rootless
    args: ["--addr", "unix:///run/user/1000/buildkit/buildkitd.sock", "--oci-worker-no-process-sandbox"]
    securityContext:
      runAsUser: 1000
      seccompProfile: { type: Unconfined }     # requisito de rootless, no privilegio total

Recomendación práctica: si el runner es compartido, no uses DinD privilegiado ni montes el socket del host. Kaniko o BuildKit rootless cubren el caso normal. DinD es aceptable en runners efímeros de un solo uso y de un solo equipo, donde el radio del compromiso es el propio job. Y en GitHub Actions con runners alojados el problema no se plantea igual, porque la máquina es efímera y de un solo job —lo que es, por cierto, una ventaja de diseño de los runners alojados que no siempre se aprecia—.

  1. Kubernetes como destino: los objetos mínimos

No hace falta saber Kubernetes entero para desplegar en él. Estos son los objetos que sí hay que entender:

flowchart TD
    D["Deployment<br/>estado deseado: 4 replicas, imagen X"] --> RS["ReplicaSet<br/>uno por version"]
    RS --> P1["Pod"]
    RS --> P2["Pod"]
    SVC["Service<br/>IP estable + balanceo"] --> P1
    SVC --> P2
    ING["Ingress<br/>enrutado HTTP externo"] --> SVC
    CM["ConfigMap<br/>configuracion no sensible"] -.-> P1
    SEC["Secret<br/>credenciales"] -.-> P1
Objeto Qué es Equivalente mental en ECS
Pod Una o varias contenedores que comparten red y ciclo de vida Una tarea
ReplicaSet Mantiene N Pods idénticos vivos — (interno)
Deployment Declara el estado deseado y gestiona el reemplazo creando ReplicaSets Servicio ECS
Service IP y DNS estables con balanceo hacia los Pods sanos Target group
Ingress Enrutado HTTP externo, TLS, hosts y rutas ALB con reglas
ConfigMap Configuración no sensible Variables de entorno de la definición de tarea
Secret Datos sensibles (codificados en base64, no cifrados por defecto) Secrets Manager / SSM
# k8s/deployment.yaml — Reservalia API
apiVersion: apps/v1
kind: Deployment
metadata:
  name: reservalia-api
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0          # 1 · nunca bajar de la capacidad actual
      maxSurge: 1                # 1 Pod extra durante el reemplazo
  selector:
    matchLabels: { app: reservalia-api }
  template:
    metadata:
      labels: { app: reservalia-api }
    spec:
      securityContext:
        runAsNonRoot: true
        seccompProfile: { type: RuntimeDefault }
      containers:
        - name: api
          # 2 · por digest, no por tag
          image: 123456789012.dkr.ecr.eu-west-1.amazonaws.com/reservalia/api@sha256:aaa1b2c3...
          ports: [ { containerPort: 3000 } ]
          envFrom:
            - configMapRef: { name: reservalia-api-config }
            - secretRef:    { name: reservalia-api-secretos }
          resources:
            requests: { cpu: "250m", memory: "256Mi" }   # 3
            limits:   { cpu: "1",    memory: "512Mi" }
          startupProbe:                                   # 4
            httpGet: { path: /salud/vivo, port: 3000 }
            failureThreshold: 30
            periodSeconds: 2
          readinessProbe:
            httpGet: { path: /salud/listo, port: 3000 }
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet: { path: /salud/vivo, port: 3000 }
            periodSeconds: 10
            failureThreshold: 3
          lifecycle:
            preStop:                                      # 5
              exec: { command: ["sleep", "5"] }
      terminationGracePeriodSeconds: 30
  1. maxUnavailable: 0 con maxSurge: 1 es el ajuste correcto para no perder capacidad durante el despliegue: primero se crea el Pod nuevo, se espera a que esté listo y solo entonces se retira uno viejo. Con maxUnavailable: 1 (el valor por defecto reparte de otra forma) puedes quedarte por debajo de la capacidad necesaria en hora punta.
  2. Imagen por digest, por lo dicho en el apartado 5. Además, con digest no hace falta imagePullPolicy: Always: el identificador ya es único.
  3. requests y limits no son opcionales. Sin requests, el planificador no sabe qué reservar y coloca mal; sin limits, un Pod con una fuga de memoria puede tumbar el nodo. Y un detalle que muerde: superar el límite de memoria mata el contenedor (OOMKilled) sin aviso, mientras que superar el de CPU solo lo ralentiza.
  4. Sondas: apartado 8.
  5. preStop con una pausa corta resuelve una carrera real: cuando un Pod entra en terminación, Kubernetes le envía SIGTERM a la vez que lo saca de los endpoints del Service, y esas dos cosas no están sincronizadas. Sin la pausa, algunas peticiones llegan a un Pod que ya está cerrando. Cinco segundos eliminan la mayoría de los 502 durante los despliegues.

  1. Sondas y su papel en el rolling update

Las tres sondas se confunden constantemente y sus efectos son muy distintos:

Sonda Pregunta que responde Si falla Efecto en el rolling update
startupProbe ¿Ha terminado de arrancar? Reinicia el contenedor Suspende las otras dos mientras corre: protege arranques lentos
readinessProbe ¿Puede atender tráfico ahora? Lo saca del Service, sin reiniciar Decisiva: el rollout no avanza hasta que el Pod nuevo está listo
livenessProbe ¿Sigue vivo o está colgado? Reinicia el contenedor Puede provocar reinicios en bucle si está mal configurada

La sonda de disponibilidad es la que gobierna el despliegue y es la que hace que el rolling update de la 03-04 sea seguro: mientras el Pod nuevo no responda 200 en /salud/listo, Kubernetes no retira ningún Pod viejo. Si la aplicación arranca rota, el rollout se queda parado con la versión anterior sirviendo: el fallo se contiene solo.

Dos errores de configuración con consecuencias graves:

La sonda de vida que comprueba dependencias. Si /salud/vivo consulta la base de datos, una caída de la base de datos hace fallar la sonda en todos los Pods, Kubernetes los reinicia todos a la vez, y a una incidencia de base de datos le sumas una caída total de la aplicación con reinicios en bucle. Regla: vida = "el proceso responde"; disponibilidad = "puedo atender peticiones, incluidas mis dependencias".

Sonda de vida sin sonda de arranque en aplicaciones lentas. Si la aplicación tarda 40 s en arrancar y la sonda de vida empieza a los 10 s con umbral de 3 fallos, el contenedor se reinicia antes de terminar de arrancar, para siempre. La startupProbe existe exactamente para esto.

  1. Rollback: kubectl rollout status y undo

# Desplegar: cambiar la imagen del Deployment (por digest)
kubectl set image deployment/reservalia-api \
  api=$ECR/reservalia/api@sha256:aaa1b2c3... --record

# Esperar y FALLAR si no converge: esto es lo que hace que el job de CD sea honesto
kubectl rollout status deployment/reservalia-api --timeout=5m

# Historial y vuelta atrás
kubectl rollout history deployment/reservalia-api
kubectl rollout undo deployment/reservalia-api               # a la revisión anterior
kubectl rollout undo deployment/reservalia-api --to-revision=7

kubectl rollout status con --timeout es la línea que convierte un despliegue en una verificación: si los Pods nuevos no llegan a estar listos, el comando devuelve error y el job de CD falla, en lugar de dar verde y dejar un rollout atascado que nadie mira. Es exactamente lo que la 03-02 pedía del despliegue idempotente y verificado.

Y el rollback aquí es más rápido que en ECS: kubectl rollout undo restaura el ReplicaSet anterior, cuyas imágenes ya están descargadas en los nodos, así que en segundos hay Pods viejos sirviendo. Los 4 minutos de rollback por digest de Reservalia (03-05) bajarían bastante.

Dos matices honestos. undo revierte la plantilla del Pod, no el estado del mundo: si el despliegue incluyó una migración de base de datos, revertir la imagen no revierte la migración, y ahí sigue mandando expand and contract (04-06). Y --record está en desuso; en un flujo GitOps el historial real está en Git, que es mejor sitio que las anotaciones del objeto.

  1. Parametrizar por entorno: Helm y Kustomize

El mismo Deployment tiene que existir en dev, staging y producción con distinto número de réplicas, distintos recursos y distinta configuración. Dos enfoques dominantes.

Helm — plantillas con variables:

# helm/reservalia-api/templates/deployment.yaml
spec:
  replicas: {{ .Values.replicas }}
  template:
    spec:
      containers:
        - name: api
          image: "{{ .Values.imagen.repositorio }}@{{ .Values.imagen.digest }}"
          resources:
            {{- toYaml .Values.recursos | nindent 12 }}
# helm/reservalia-api/values-produccion.yaml
replicas: 6
recursos:
  requests: { cpu: "500m", memory: "512Mi" }
  limits:   { cpu: "2",    memory: "1Gi" }
helm upgrade --install reservalia-api ./helm/reservalia-api \
  --namespace produccion \
  --values helm/reservalia-api/values-produccion.yaml \
  --set imagen.digest="sha256:aaa1b2c3..." \
  --atomic \            # si falla, revierte automáticamente al estado anterior
  --wait \              # espera a que los recursos estén listos
  --timeout 10m

--atomic --wait es la combinación que convierte helm upgrade en un despliegue con rollback automático incorporado: si los Pods no llegan a estar listos en el plazo, Helm deshace el cambio. Es la línea más valiosa del comando.

Kustomize — superposiciones sobre una base, sin plantillas:

# k8s/overlays/produccion/kustomization.yaml
resources: [ ../../base ]
namespace: produccion
replicas:
  - name: reservalia-api
    count: 6
images:
  - name: reservalia/api
    digest: sha256:aaa1b2c3...
patches:
  - path: recursos.yaml
    target: { kind: Deployment, name: reservalia-api }
configMapGenerator:
  - name: reservalia-api-config
    literals: [ "LOG_LEVEL=info", "ENTORNO=produccion" ]
kubectl apply -k k8s/overlays/produccion
Helm Kustomize
Mecanismo Plantillas Go + valores Parcheo declarativo de YAML
Los ficheros base son… Plantillas: no son YAML válido YAML válido y aplicable tal cual
Curva de aprendizaje Media-alta (funciones, nindent, condicionales) Baja
Distribuir a terceros Excelente: repositorios de charts, versionado Pobre
Instalar software de terceros Estándar de facto Poco práctico
Gestión de estado Guarda releases e historial; helm rollback Ninguna: el estado es lo que hay en el clúster
Legibilidad al crecer Empeora: lógica en plantillas Buena, hasta que hay overlays de overlays
Integrado en kubectl No Sí (-k)

En la práctica, muchos equipos usan los dos: Helm para instalar software de terceros (ingress, monitorización, operadores) y Kustomize para sus propias aplicaciones. Es una combinación defendible y muy común. El antipatrón a evitar es un chart propio con tantas condicionales que hay que leer plantillas Go para saber qué se despliega.

  1. Push frente a pull: por qué en Kubernetes triunfa GitOps

El cd.yml de Reservalia usa push: el pipeline tiene credenciales del entorno y ejecuta el despliegue.

flowchart LR
    subgraph PUSH["Modelo push · el cd.yml de Reservalia"]
      CI1["Pipeline"] -->|"credenciales de prod"| K1["Cluster / ECS"]
    end
    subgraph PULL["Modelo pull · GitOps"]
      CI2["Pipeline"] -->|"commit: nuevo digest"| G["Repositorio de despliegues"]
      AG["Agente en el cluster<br/>Argo CD / Flux"] -->|"lee cada 3 min"| G
      AG -->|"aplica desde dentro"| K2["Cluster"]
    end
Push Pull (GitOps)
Quién inicia El pipeline Un agente dentro del clúster
Credenciales del clúster En el CI Nunca salen del clúster
Estado deseado Implícito en el último despliegue Explícito en Git
Deriva de configuración Invisible Detectada y corregida
Rollback Volver a ejecutar con el digest anterior git revert
Auditoría Logs del pipeline Historial de Git
Multi-clúster Un job por clúster Un agente por clúster, misma fuente
Complejidad Baja Media: otro componente que operar
Latencia del despliegue Inmediata Segundos a minutos (o inmediata con webhook)

Las dos razones por las que GitOps ganó en Kubernetes, y ninguna es moda:

1. Las credenciales de administrador de un clúster son demasiado poderosas para dejarlas en el CI. kubectl apply requiere permisos amplios; si el CI los tiene, comprometer el CI es comprometer el clúster. Con GitOps el pipeline solo necesita permiso para hacer commit en un repositorio, que es un privilegio mucho menor.

2. Kubernetes es declarativo, y eso encaja de forma natural con Git. El estado deseado es un conjunto de manifiestos; ponerlos en Git y que un agente los reconcilie continuamente es la extensión obvia. Y trae una capacidad que el push no tiene: detección de deriva. Si alguien hace kubectl edit a las tres de la mañana durante una incidencia, el agente lo detecta y lo revierte o lo señala. El "¿por qué producción no es lo que dice el repositorio?" deja de existir.

# El pipeline en GitOps: no despliega, escribe el estado deseado
actualizar-manifiestos:
  needs: [publicar]
  script:
    - git clone https://github.com/reservalia/despliegues.git && cd despliegues
    - |
      cd overlays/produccion
      kustomize edit set image reservalia/api@${DIGEST}
    - git commit -am "api: ${DIGEST} (desde ${CI_COMMIT_SHA})"
    - git push
    # Aquí termina el pipeline. Argo CD detecta el commit y aplica.
# La Application de Argo CD, que vive en el clúster
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: { name: reservalia-api-produccion }
spec:
  source:
    repoURL: https://github.com/reservalia/despliegues.git
    path: overlays/produccion
    targetRevision: main
  destination: { server: https://kubernetes.default.svc, namespace: produccion }
  syncPolicy:
    automated: { prune: true, selfHeal: true }   # selfHeal: corrige la deriva
    retry: { limit: 3 }

Contrapartidas honestas de GitOps: hay un componente más que operar y actualizar; el despliegue deja de ser inmediato salvo que configures un webhook; el rastro de un cambio atraviesa dos repositorios, lo que complica responder "¿qué commit de la aplicación está en producción?" si no se automatiza bien; y no resuelve las estrategias de despliegue por sí solo —para canary o blue-green hacen falta Argo Rollouts o Flagger, que es otra pieza más—. La 05-03 ya lo introdujo en el contexto de microservicios; aquí queda el porqué.

  1. Reservalia en Kubernetes, contrastado con ECS

Ejercicio de contraste, no de migración: Reservalia funciona bien en ECS y la 06-07 dirá por qué eso importa.

Aspecto ECS Fargate (hoy) Kubernetes (hipotético)
Definición del servicio Task definition (JSON) + Service Deployment + Service + Ingress
Quién opera el plano de control AWS, invisible AWS (EKS) o tú; visible en cualquier caso
Rolling update Nativo del servicio ECS Nativo del Deployment
Canary al 10 % Pesos en el listener del ALB Argo Rollouts / Flagger / mallas de servicio
Rollback Volver a desplegar el digest anterior: ~4 min kubectl rollout undo: segundos
Configuración SSM Parameter Store en la task definition ConfigMap + Secret (o operador de secretos externos)
Autoescalado Application Auto Scaling HPA (+ Karpenter o Cluster Autoscaler para nodos)
Coste de operación Bajo Medio-alto: actualizaciones del clúster, complementos, CRDs
Conocimiento necesario en el equipo Bajo Alto
Portabilidad entre nubes Nula Alta (con matices: los complementos suelen ser específicos)

Lo que Reservalia ganaría: rollback casi instantáneo, portabilidad, un ecosistema enorme de operadores, mejores herramientas de despliegue progresivo, y GitOps con la reducción de privilegios del CI que conlleva.

Lo que pagaría: un clúster que hay que actualizar varias veces al año, complementos que hay que mantener (ingress, gestor de certificados, escalado, observabilidad), la operación de Argo CD, y una curva de aprendizaje real para un equipo de tres personas. Diego preguntaría cuánto cuesta y tendría razón: con 340 negocios y ~9.000 citas al mes, la respuesta honesta es que el rollback de 4 minutos que ya tienen no es el cuello de botella de nada.

Cuándo cambiaría la respuesta: si aparecieran los cinco servicios de la 05-03 con equipos independientes, si hubiera un requisito de multi-nube, o si el equipo de plataforma creciera lo bastante para que operar el clúster no salga del tiempo de producto.

  1. Kubernetes como plataforma para los propios runners

Un uso que a veces se olvida: el clúster puede alojar los agentes de CI, no solo la aplicación. Ya apareció con el plugin de Kubernetes de Jenkins (06-01) y con el executor kubernetes de GitLab (06-02); en GitHub Actions es ARC (Actions Runner Controller), que la 06-06 detalla.

# Un agente efímero: se crea al empezar el job, se destruye al terminar
apiVersion: v1
kind: Pod
spec:
  restartPolicy: Never
  containers:
    - name: runner
      image: node:22-bookworm
      resources:
        requests: { cpu: "1", memory: "2Gi" }
        limits:   { cpu: "2", memory: "4Gi" }
    - name: buildkit
      image: moby/buildkit:v0.16.0-rootless
      securityContext: { runAsUser: 1000 }

Qué aporta: entorno limpio por job —adiós al agente contaminado—, autoescalado gratis (lo hace el clúster), aprovechamiento de capacidad ociosa, y aislamiento por espacio de nombres entre equipos.

Qué cuesta, con honestidad: un Pod tarda decenas de segundos en arrancar y descargar imágenes, lo que empeora el tiempo de cola si no se cuida (imágenes precargadas en los nodos, o un pequeño grupo de Pods calientes); la caché ya no persiste por definición, así que hay que apoyarse en cachés remotas; y sigue siendo un clúster que operar. Nunca ejecutes agentes de CI en el mismo clúster que producción sin aislamiento fuerte: un job es código arbitrario, y compartir nodos con producción convierte cualquier escape en un incidente grave.

  1. Cuándo NO necesitas Kubernetes

Este apartado es tan importante como los anteriores y se omite demasiadas veces.

Probablemente no lo necesitas si:

  • Tienes menos de cinco servicios. Kubernetes resuelve la coordinación de muchos servicios; con pocos, aporta sobre todo complejidad. Reservalia es el ejemplo.
  • Tu equipo no tiene a alguien que sepa operarlo. Un clúster mal operado es peor que ECS o que máquinas virtuales bien operadas. Y "sabe usar kubectl" no es lo mismo que "sabe operar un clúster": actualizar versiones, gestionar CRDs, diagnosticar red y almacenamiento, revisar políticas de seguridad.
  • Tu carga es predecible y modesta. El autoescalado fino de Kubernetes brilla con picos; con carga estable, un servicio gestionado hace lo mismo con menos piezas.
  • Ya tienes un servicio gestionado que funciona (ECS, Cloud Run, App Runner, App Service). Migrar cuesta meses y hay que poder nombrar qué problema concreto resuelve.
  • La razón principal es "portabilidad entre nubes". Es real pero suele ser teórica: el clúster es portable, pero la base de datos gestionada, el balanceador, el almacenamiento de objetos, la gestión de identidades y los complementos específicos no lo son. La portabilidad que se consigue es parcial y se paga cara.

Sí lo necesitas cuando: tienes muchos servicios y varios equipos que necesitan autonomía (05-03); necesitas despliegue progresivo sofisticado y capacidades que solo existen en ese ecosistema; tu carga es muy variable y el empaquetado eficiente de contenedores ahorra dinero de verdad; tienes requisitos de on-premise o multi-nube reales y presupuestados; o ya tienes la plataforma y el equipo, en cuyo caso el coste marginal de una aplicación más es bajo.

La regla, que es la misma que cerró el módulo 5: la complejidad se justifica con un problema concreto que resuelve, no con el hecho de que otros la usen.

Errores Comunes y Consejos

Usar la misma imagen para construir y para ejecutar. Produce imágenes enormes con compiladores y credenciales. Multi-etapa siempre.

Secretos con ARG o ENV. Quedan en el historial de la imagen aunque borres el fichero después. --mount=type=secret.

FROM node:22 sin fijar. El build no es reproducible. Fija versión y, en entornos sensibles, digest, con automatización que lo actualice.

mode=min en la caché de registro con multi-etapa. Pierde las etapas intermedias, que son justo las caras. mode=max.

Montar el socket de Docker del host en jobs de CI. Equivale a dar root del host a cualquier PR. Kaniko o BuildKit rootless.

Sonda de vida que comprueba la base de datos. Convierte una incidencia de base de datos en una caída total con reinicios en bucle. Vida = proceso; disponibilidad = dependencias.

Sin startupProbe en aplicaciones lentas. Bucle de reinicios permanente que parece un bug de la aplicación.

Sin requests ni limits. Planificación mala, y una fuga de memoria tumba el nodo. Recuerda que superar el límite de memoria mata el contenedor sin aviso.

Desplegar por tag en lugar de por digest. Introduce una carrera y rompe la garantía de "el mismo artefacto en todos los entornos".

kubectl apply sin kubectl rollout status --timeout. El job da verde con el rollout atascado. Verde falso (04-04) en su versión más cara.

Secrets de Kubernetes tratados como cifrados. Están en base64, no cifrados. Activa el cifrado en reposo de etcd y usa un operador de secretos externos.

Adoptar Kubernetes por defecto. Es la decisión de arquitectura más cara que se toma con menos análisis.

Ejercicios

Ejercicio 1. La imagen de apps/api pesa 1,3 GB, tarda 6 minutos en construirse en CI, el escáner reporta 180 CVE y el equipo descubrió un token de npm visible con docker history. Reescribe el Dockerfile explicando qué corrige cada cambio y qué mejora esperas en tamaño, tiempo y hallazgos.

Ejercicio 2. Reservalia despliega en Kubernetes con kubectl set image desde el cd.yml, con un kubeconfig de administrador guardado como secreto del CI. Diseña la migración a GitOps con Argo CD: estructura de repositorios, qué hace el pipeline después, qué permisos quedan en el CI, cómo se hace el rollback, y qué se pierde respecto al modelo actual.

Ejercicio 3. Una startup de cuatro personas con un monolito Node y una base de datos PostgreSQL, ~200 usuarios y crecimiento lento, propone migrar a Kubernetes "para estar preparados para escalar". Escribe la respuesta técnica: qué preguntas hay que hacer antes, qué costes reales tiene, qué alternativas hay y bajo qué condiciones cambiaría la recomendación.

Soluciones

Solución 1.

# syntax=docker/dockerfile:1.7

FROM node:22.11-bookworm AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/
COPY packages/compartido/package.json packages/compartido/
# El token va montado: NO queda en ninguna capa
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    --mount=type=cache,target=/root/.npm,sharing=locked \
    npm ci --prefer-offline

FROM deps AS build
COPY . .
RUN npm run build --workspace apps/api
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
    npm ci --omit=dev --prefer-offline

FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/apps/api/dist ./dist
USER nonroot
ENV NODE_ENV=production
EXPOSE 3000
LABEL org.opencontainers.image.source="https://github.com/reservalia/reservalia" \
      org.opencontainers.image.revision="${GIT_SHA}"
ENTRYPOINT ["/nodejs/bin/node", "dist/servidor.js"]
Cambio Qué corrige Efecto esperado
Etapa runtime distroless separada La imagen final llevaba compilador, devDependencies y fuentes 1,3 GB → ~120 MB
npm ci --omit=dev antes de copiar devDependencies en producción Parte del ahorro anterior
--mount=type=secret para .npmrc Token visible en docker history Desaparece de la imagen
--mount=type=cache para ~/.npm Descarga completa en cada build 6 min → 2-3 min con caché caliente
COPY de manifiestos antes que el código Cualquier cambio invalidaba la instalación Capa de dependencias reutilizada entre commits
Base distroless + USER nonroot 180 CVE, mayoría en paquetes del sistema sin usar ~180 → 5-15, y esos sí accionables
Etiquetas OCI Sin trazabilidad imagen → commit docker inspect responde "¿de dónde salió esto?"

Acción imprescindible que no está en el Dockerfile: rotar el token de npm. Estuvo en el historial de todas las imágenes publicadas y cualquiera con acceso al registro pudo leerlo. Cambiar el Dockerfile evita repetirlo; no repara lo ocurrido. Es exactamente la lección de la 06-04, y la respuesta de fondo es la misma: si el token puede sustituirse por publicación federada, mejor que rotarlo.

Advertencia sobre distroless: no hay shell, así que el equipo necesita kubectl debug y buena observabilidad antes de adoptarlo, o el primer incidente será desagradable.

Solución 2.

Estructura de repositorios — dos, deliberadamente separados:

reservalia/reservalia          → código de la aplicación, CI, Dockerfile
reservalia/despliegues         → estado deseado del clúster
  base/
    deployment.yaml  service.yaml  ingress.yaml  kustomization.yaml
  overlays/
    staging/    kustomization.yaml  recursos.yaml
    produccion/ kustomization.yaml  recursos.yaml

Se separan porque tienen ciclos de vida y permisos distintos: en despliegues se pueden exigir revisores del equipo de plataforma para overlays/produccion sin bloquear el desarrollo diario.

Qué hace el pipeline después:

promocionar:
  needs: [publicar]
  if: github.ref == 'refs/heads/main'
  steps:
    - uses: actions/checkout@v4
      with:
        repository: reservalia/despliegues
        token: ${{ secrets.TOKEN_DESPLIEGUES }}    # único permiso que queda
    - run: |
        cd overlays/staging
        kustomize edit set image reservalia/api=$ECR/reservalia/api@${{ needs.publicar.outputs.digest }}
        git commit -am "staging: api ${{ github.sha }}" && git push

Producción se promociona con un PR desde overlays/staging a overlays/produccion —revisado y aprobado—, que sustituye a la aprobación del Environment y deja el mismo rastro de auditoría, pero en Git.

Permisos que quedan en el CI: solo un token con permiso de escritura sobre el repositorio despliegues. El kubeconfig de administrador desaparece del CI, que es la ganancia principal: comprometer el CI ya no equivale a comprometer el clúster, solo a poder proponer un cambio que además pasa por revisión en producción.

Rollback: git revert del commit en despliegues y Argo CD reconcilia en el intervalo de sondeo (o de inmediato con webhook). Ventaja añadida: el rollback queda registrado como un commit con autor y motivo, en vez de como un job relanzado que nadie recuerda. Para emergencias se conserva kubectl rollout undo como salida manual, con selfHeal desactivado temporalmente si hace falta —y con la disciplina de que después hay que arreglar Git, o el agente revertirá tu arreglo—.

Qué se pierde: la inmediatez (segundos a minutos, salvo webhook); la simplicidad de un solo repositorio, y con ella la respuesta directa a "¿qué commit está en producción?", que ahora exige mirar dos historiales y por tanto conviene automatizar con una anotación en el manifiesto; un componente más que operar y actualizar; y una curva de aprendizaje para el equipo. La cuenta sale a favor sobre todo por el punto de las credenciales, no por elegancia.

Solución 3.

Preguntas antes de responder nada:

  1. ¿Qué problema concreto tenéis hoy? Si la respuesta es "ninguno, es por si acaso", no hay caso. Si es "los despliegues nos dan miedo" o "no sabemos escalar en picos", esos problemas tienen soluciones más baratas.
  2. ¿Cuál es la carga real y su variabilidad? Con 200 usuarios y crecimiento lento, el autoescalado no aporta.
  3. ¿Quién opera el clúster y qué deja de hacer mientras tanto? Con cuatro personas, es entre media y una persona equivalente.
  4. ¿Cuál es el objetivo de disponibilidad? Si no hay SLO escrito, no hay forma de justificar complejidad por disponibilidad.
  5. ¿Qué hay hoy y qué falla exactamente?

Costes reales, que casi nunca se contabilizan:

Coste Detalle
Plano de control Coste fijo mensual (EKS/GKE/AKS) aunque no despliegues nada
Nodos infrautilizados Un clúster mínimo con redundancia son varias máquinas siempre encendidas
Complementos Ingress, certificados, monitorización, escalado: todos hay que instalar y actualizar
Actualizaciones Varias versiones al año, con APIs que se retiran y manifiestos que hay que tocar
Aprendizaje Semanas hasta operar con soltura; meses hasta diagnosticar bien
Coste de oportunidad El más grande: lo que esas semanas no se dedicaron al producto

Alternativas, ordenadas por lo que resuelven:

Si el problema es… Solución proporcionada
Despliegues manuales y con miedo CI/CD sobre lo que ya hay: el módulo 3 entero
Entornos no reproducibles Contenedores + IaC (03-03), sin orquestador complejo
Necesidad de escalar a veces Servicio gestionado de contenedores: ECS Fargate, Cloud Run, App Runner
Caídas al desplegar Rolling update con health checks, que esos servicios ya traen
"Queremos aprender Kubernetes" Formación deliberada, no la producción de la empresa

Recomendación: no migrar ahora. Contenerizar la aplicación —eso sí, porque da reproducibilidad y mantiene la puerta abierta: una aplicación en contenedor con IaC se mueve a Kubernetes más adelante en semanas, no en meses— y desplegar en un servicio gestionado. Escribir la decisión con la fecha y con las condiciones que la revisarían, para que no se replantee cada trimestre.

Condiciones bajo las que cambiaría: llegar a cinco o más servicios con equipos distintos; un requisito contractual de on-premise o multi-nube; una carga con picos de más de un orden de magnitud donde el empaquetado ahorre dinero medible; contratar a alguien con experiencia real de operación; o necesitar capacidades del ecosistema (operadores, despliegue progresivo avanzado) que el servicio gestionado no ofrezca.

Y el argumento que suele cerrar la conversación: "estar preparados para escalar" es la justificación más cara de la ingeniería. Con 200 usuarios, el riesgo real no es no poder escalar —eso se arregla cuando pasa, y con dinero—, es gastar tres meses del equipo en infraestructura mientras el producto no avanza. La preparación efectiva es contenerizar y tener infraestructura como código; el orquestador se elige cuando el problema exista.

Conclusión

Docker y Kubernetes no compiten con las herramientas de las lecciones anteriores: están debajo de ellas y delante de ellas. Docker aparece en dos papeles que hay que mantener separados —entorno reproducible del pipeline y artefacto inmutable que se despliega—, y la separación se materializa con multi-etapa, imágenes de runtime mínimas, usuario no-root y secretos montados que no dejan rastro en el historial. BuildKit añade lo que de verdad acelera un pipeline en runners efímeros: cachés de montaje que sobreviven a los cambios de lockfile y caché de capas en el registro con mode=max. Y la decisión de cómo construir imágenes dentro de un contenedor —DinD privilegiado, socket del host, o constructores sin daemon— es una decisión de seguridad de primer orden que se suele tomar por inercia.

En Kubernetes, lo que hay que dominar para desplegar es un conjunto pequeño: Deployment, Service, Ingress, ConfigMap, Secret, y sobre todo las sondas, porque la de disponibilidad es la que hace que el rolling update de la 03-04 sea seguro y la de vida mal configurada es la que convierte una incidencia en una caída total. kubectl rollout status --timeout es la línea que impide el verde falso, y undo da el rollback más rápido del curso. Para parametrizar por entorno, Helm y Kustomize resuelven lo mismo de forma distinta y conviven bien. Y GitOps ganó por una razón concreta y no por moda: saca las credenciales del clúster fuera del CI y convierte el estado deseado en algo explícito, versionado y con detección de deriva.

El apartado que conviene recordar más tiempo es el último: Kubernetes es la decisión de arquitectura más cara que se toma con menos análisis. Reservalia está mejor en ECS hoy, y saber justificar eso es tan valioso como saber escribir un Deployment.

La última herramienta del recorrido es la que el alumno lleva cinco módulos usando. GitHub Actions a fondo cierra los huecos que quedaron: contextos y expresiones, todos los disparadores que faltan y la diferencia crítica entre pull_request y pull_request_target, los permisos del GITHUB_TOKEN y OIDC, los tres tipos de action con el código completo de una escrita en JavaScript, matrices dinámicas, runners autoalojados con autoescalado, los límites reales del servicio y cómo depurarlo. Después, la 06-07 pondrá las seis herramientas en la misma tabla y dará los criterios para elegir.

Curso de CI/CD: Integración y Despliegue Continuo

Módulo 1: Introducción a CI/CD

Módulo 2: Integración Continua (CI)

Módulo 3: Despliegue Continuo (CD)

Módulo 4: Prácticas Avanzadas de CI/CD

Módulo 5: Implementación de CI/CD en Proyectos Reales

Módulo 6: Herramientas y Tecnologías

Módulo 7: Ejercicios Prácticos

Módulo 8: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados