En las dos lecciones anteriores dejamos tienda-web, api-reservas y postgres-reservas funcionando en producción. Los manifiestos son correctos, las sondas responden, las copias se restauran. Falta la pregunta que hace que todo esto sea sostenible: ¿cómo llega ahí el código que escribe un desarrollador un martes por la mañana?
Esta lección recorre esa distancia completa: desde el git push hasta el pod que atiende peticiones en rutas-norte-pro. No es una lección sobre GitOps, que ya vimos en 10-05, ni sobre firma de imágenes, que vimos en 08-05: es la lección donde todas esas piezas se conectan en una canalización que funciona, y donde se explica por qué la canalización de Rutas Norte nunca ejecuta kubectl apply.
Al final mediremos si todo esto sirve para algo, porque una canalización elaborada que no reduce el tiempo entre escribir código y verlo funcionar es un proyecto de ingeniería interna disfrazado de mejora.
Contenido
- El flujo completo de Rutas Norte
- Integración continua y entrega continua: por qué separarlas
- La canalización de integración, paso a paso
- Pruebas de integración contra un clúster kind efímero
- La entrega: actualizar el repositorio de manifiestos
- Promoción a
prey aprocon aprobación humana - Entornos efímeros por petición de cambio
- Credenciales sin secretos de larga vida
- Verificación posterior al despliegue y reversión automática
- Métricas DORA: los números de Rutas Norte antes y después
- El flujo completo de Rutas Norte
Hay dos repositorios, y esa separación es el eje de todo lo demás:
| Repositorio | Contiene | Quién lo modifica |
|---|---|---|
rutasnorte/api-reservas |
Código fuente, Dockerfile, pruebas, definición de la canalización |
Desarrollo, a mano |
rutasnorte/manifiestos |
Chart de Helm, superposiciones de Kustomize, Application de Argo CD |
La canalización (dev) y personas (pre/pro) |
graph TB
DEV[Desarrollador<br/>git push a rama] --> PR[Petición de cambio]
PR --> CI
subgraph CI["Integración continua · GitHub Actions"]
T1[1 Lint + pruebas unitarias]
T2[2 Construcción multietapa<br/>con caché de capas]
T3[3 Trivy: falla si CRITICAL]
T4[4 Push al registro<br/>etiqueta = SHA del commit]
T5[5 Cosign: firma sin claves]
T6[6 Obtener digest]
T7[7 Pruebas en clúster kind]
T1 --> T2 --> T3 --> T4 --> T5 --> T6 --> T7
end
CI --> EF[Entorno efímero<br/>ns rutas-norte-pr-1842]
T7 --> MERGE{Fusión a main}
MERGE --> BOT[Petición de cambio automática<br/>en rutasnorte/manifiestos<br/>digest en overlays/dev]
BOT --> AUTO[Fusión automática]
AUTO --> ACD[Argo CD]
ACD --> DEVENV[rutas-norte-dev]
DEVENV --> HUMO1[Pruebas de humo]
HUMO1 --> PROMO_PRE[PC de promoción a pre<br/>aprobación: desarrollo]
PROMO_PRE --> ACD2[Argo CD] --> PREENV[rutas-norte-pre]
PREENV --> CARGA[k6 + pruebas de regresión]
CARGA --> PROMO_PRO[PC de promoción a pro<br/>aprobación: plataforma + producto]
PROMO_PRO --> ACD3[Argo CD] --> PROENV[rutas-norte-pro]
PROENV --> VERIF[Verificación posterior<br/>reversión automática si falla]
Fíjate en algo importante: la flecha que entra en los namespaces siempre sale de Argo CD, nunca de la canalización. La canalización llega hasta el repositorio de manifiestos y ahí se detiene.
- Integración continua y entrega continua: por qué separarlas
Se nombran juntas y se confunden constantemente, pero responden a preguntas distintas y fallan de formas distintas.
| Integración continua | Entrega continua | |
|---|---|---|
| Pregunta que responde | ¿Este cambio es correcto? | ¿Está este artefacto en los entornos? |
| Entrada | Un commit | Un artefacto verificado (digest) |
| Salida | Una imagen firmada y su digest | Un estado del clúster |
| Dónde vive la lógica | GitHub Actions | Repositorio de manifiestos + Argo CD |
| Naturaleza | Imperativa: pasos en orden | Declarativa: estado deseado |
| Si falla | No sale artefacto; nadie se entera fuera del equipo | Los entornos divergen; sí se nota |
| Frecuencia | Cada push | Cada cambio del repositorio de manifiestos |
2.1. Por qué la canalización no ejecuta kubectl apply
Es la decisión de diseño más importante de esta lección, y conviene entenderla bien porque contradice lo que hace mucha gente.
Con kubectl apply desde la canalización:
- El corredor de la canalización necesita credenciales de escritura sobre producción. Cualquiera que pueda modificar el fichero de la canalización puede modificar producción.
- El estado real del clúster depende de qué ejecuciones han pasado y en qué orden. Si dos se solapan, el resultado es indeterminado.
- Un cambio manual con
kubectl edita las tres de la mañana durante un incidente no se revierte solo, y nadie se entera de que el clúster ya no coincide con Git. - Saber qué hay desplegado exige mirar el clúster, no el repositorio.
- Reconstruir el entorno tras un desastre significa volver a ejecutar canalizaciones antiguas, si es que aún funcionan.
Con GitOps (10-05):
- La canalización solo necesita permiso para abrir una petición de cambio en un repositorio de Git. Cero credenciales de clúster.
- El estado deseado es una revisión de Git: reproducible, revisable, con historial y con autor.
- La deriva se corrige sola gracias a
selfHeal, y queda visible como un evento de desincronización. - La reversión es
git revert. - La auditoría de "quién cambió qué en producción y cuándo" es el registro de Git, no un rastro disperso de trabajos de CI.
La regla, en una frase: la integración produce un digest; la entrega decide en qué entorno vive ese digest; solo Git une ambas cosas.
- La canalización de integración, paso a paso
Fichero .github/workflows/integracion.yml del repositorio rutasnorte/api-reservas, completo y comentado.
name: Integración continua
on:
push:
branches: [main]
pull_request:
# Sin secretos de larga vida: id-token permite pedir un token OIDC
# efímero para autenticarse contra la nube y firmar con Cosign.
permissions:
contents: read
packages: write
id-token: write
pull-requests: write
env:
REGISTRO: registry.rutasnorte.example
IMAGEN: rutasnorte/api-reservas
# Cancela ejecuciones antiguas de la misma rama: no gastamos corredores
# validando commits que ya han quedado obsoletos.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# ---------------------------------------------------------------
# 1. Pruebas unitarias: rápidas, sin red, sin base de datos real.
# ---------------------------------------------------------------
pruebas:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Instalar dependencias exactas
# npm ci respeta el fichero de bloqueo: reproducible, a diferencia de npm install.
run: npm ci
- name: Análisis estático
run: npm run lint
- name: Pruebas unitarias con cobertura
run: npm test -- --coverage --coverageThreshold='{"global":{"lines":80}}'
- name: Auditoría de dependencias
run: npm audit --audit-level=high
# ---------------------------------------------------------------
# 2-6. Construcción, escaneo, publicación y firma.
# ---------------------------------------------------------------
imagen:
needs: pruebas
runs-on: ubuntu-latest
outputs:
# El digest es la salida que consumen los trabajos siguientes
# y, finalmente, el repositorio de manifiestos.
digest: ${{ steps.construir.outputs.digest }}
etiqueta: ${{ steps.meta.outputs.etiqueta }}
steps:
- uses: actions/checkout@v4
- name: Calcular etiqueta a partir del commit
id: meta
run: |
# SHA corto: identificador único, trazable y ordenable.
ETIQUETA="$(git rev-parse --short=12 HEAD)"
echo "etiqueta=${ETIQUETA}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- name: Autenticarse en el registro por OIDC
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRO }}
username: ${{ github.actor }}
# Token efímero de la propia ejecución, no una credencial guardada.
password: ${{ secrets.GITHUB_TOKEN }}
- name: Construir imagen multietapa
id: construir
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.REGISTRO }}/${{ env.IMAGEN }}:${{ steps.meta.outputs.etiqueta }}
# Caché de capas en el propio registro: una construcción típica
# baja de 4 min a unos 50 s reutilizando node_modules.
cache-from: type=registry,ref=${{ env.REGISTRO }}/${{ env.IMAGEN }}:cache
cache-to: type=registry,ref=${{ env.REGISTRO }}/${{ env.IMAGEN }}:cache,mode=max
provenance: true # atestación de procedencia SLSA
sbom: true # inventario de componentes
- name: Escaneo con Trivy
uses: aquasecurity/[email protected]
with:
image-ref: ${{ env.REGISTRO }}/${{ env.IMAGEN }}@${{ steps.construir.outputs.digest }}
format: table
# Rompe la construcción solo con CRITICAL. Las HIGH se registran
# y se revisan semanalmente: si rompes con HIGH, el equipo aprende
# a saltarse el control en vez de a arreglarlo.
severity: CRITICAL
exit-code: '1'
ignore-unfixed: true # sin parche disponible no hay nada que hacer hoy
- name: Instalar Cosign
uses: sigstore/cosign-installer@v3
- name: Firmar sin claves con la identidad del flujo de trabajo
# No hay clave privada en ningún sitio: Cosign obtiene un certificado
# efímero de Fulcio a partir del token OIDC de esta ejecución y
# registra la firma en el log público Rekor. La identidad firmante es
# el propio flujo de trabajo, verificable en la política de admisión.
run: |
cosign sign --yes \
"${REGISTRO}/${IMAGEN}@${{ steps.construir.outputs.digest }}"
- name: Resumen de la ejecución
run: |
{
echo "### Imagen publicada"
echo "- Etiqueta: \`${{ steps.meta.outputs.etiqueta }}\`"
echo "- Digest: \`${{ steps.construir.outputs.digest }}\`"
echo "- Firmada por: \`${{ github.workflow_ref }}\`"
} >> "$GITHUB_STEP_SUMMARY"3.1. Las decisiones que importan
Etiquetado por hash de commit y no por versión semántica. La etiqueta a3f91c2b8e04 es única, inmutable y responde a "¿qué código exacto es esto?" sin ambigüedad. Las versiones semánticas se reservan para las publicaciones anunciadas al negocio, que se crean como etiqueta adicional sobre un digest ya existente.
El digest como salida real. La etiqueta sirve para las personas. Lo que viaja al repositorio de manifiestos es el digest, porque una etiqueta se puede reescribir y un digest no. Es lo que sostiene la referencia por digest de los Deployments de 11-01.
Trivy con severity: CRITICAL e ignore-unfixed: true. Es una decisión calibrada. Romper con HIGH genera tantas falsas alarmas que el equipo acaba añadiendo excepciones por sistema; y fallar por una vulnerabilidad sin parche disponible no aporta nada, porque no hay acción posible. Las HIGH se revisan en la reunión semanal de plataforma.
Firma sin claves. No existe ninguna clave privada que rotar, custodiar o filtrar. El certificado es efímero y la identidad firmante es el flujo de trabajo concreto. La política de admisión de 08-05 exige exactamente esa identidad:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: exigir-firma-rutasnorte
spec:
validationFailureAction: Enforce
rules:
- name: verificar-firma
match:
any:
- resources:
kinds: [Pod]
namespaces: ["rutas-norte-*"]
verifyImages:
- imageReferences: ["registry.rutasnorte.example/rutasnorte/*"]
attestors:
- entries:
- keyless:
subject: "https://github.com/rutasnorte/*/.github/workflows/integracion.yml@refs/heads/main"
issuer: "https://token.actions.githubusercontent.com"El efecto práctico es contundente: una imagen construida en el portátil de alguien, por muy correcta que sea, no puede ejecutarse en ningún namespace de Rutas Norte.
3.2. La construcción multietapa con caché
# syntax=docker/dockerfile:1.7
# --- Etapa 1: dependencias --------------------------------------
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# La caché de npm se monta, no se copia: no engorda ninguna capa.
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# --- Etapa 2: construcción --------------------------------------
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
# --- Etapa 3: imagen final --------------------------------------
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
# Solo lo imprescindible: sin compiladores, sin shell, sin gestor de paquetes.
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER 10001:10001
EXPOSE 8080 9090
CMD ["dist/server.js"]Copiar primero package.json y package-lock.json y después el resto del código no es un capricho de estilo: hace que la capa de dependencias solo se invalide cuando cambian las dependencias. Como el 95 % de los commits solo tocan código, la caché acierta casi siempre. La imagen final resultante pesa 118 MB frente a los 1,1 GB de una construcción de una sola etapa sobre node:22, y no contiene shell, lo que reduce drásticamente lo que un atacante puede hacer dentro del contenedor.
- Pruebas de integración contra un clúster kind efímero
Las pruebas unitarias no detectan lo que rompe de verdad: un ConfigMap con una clave mal escrita, una sonda que apunta a un puerto que no existe, un chart de Helm con un valor obligatorio sin definir. Eso solo lo detecta desplegar de verdad.
La canalización levanta un clúster kind (10-01) dentro del propio corredor, despliega el chart y ejecuta pruebas de humo.
integracion:
needs: imagen
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Levantar clúster kind
uses: helm/kind-action@v1
with:
cluster_name: ci-rutasnorte
# Un nodo basta: probamos correcto funcionamiento, no alta disponibilidad.
config: .github/kind/cluster.yaml
wait: 120s
- name: Desplegar dependencias reales
# PostgreSQL y Redis de verdad, no simuladores: queremos detectar
# errores de SQL y de conexión, que es donde fallan estas cosas.
run: |
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install pg bitnami/postgresql \
--set auth.database=reservas \
--set auth.username=app_reservas \
--set auth.password=pruebas \
--set primary.persistence.enabled=false \
--wait --timeout 5m
helm install redis bitnami/redis \
--set auth.enabled=false \
--set master.persistence.enabled=false \
--wait --timeout 5m
- name: Cargar la imagen recién construida en kind
run: |
IMG="${REGISTRO}/${IMAGEN}@${{ needs.imagen.outputs.digest }}"
docker pull "$IMG"
kind load docker-image "$IMG" --name ci-rutasnorte
- name: Desplegar el chart de Rutas Norte
run: |
helm upgrade --install api-reservas ./chart \
--values ./chart/valores-ci.yaml \
--set image.digest='${{ needs.imagen.outputs.digest }}' \
--set postgres.host=pg-postgresql \
--set redis.url=redis://redis-master:6379 \
--wait --timeout 5m
- name: Migraciones de esquema
run: kubectl wait --for=condition=complete job/api-reservas-migraciones --timeout=3m
- name: Pruebas de humo
run: |
kubectl port-forward svc/api-reservas 8080:80 &
sleep 5
npm run pruebas:humo -- --base-url http://localhost:8080
- name: Diagnóstico si algo falla
if: failure()
run: |
kubectl get pods -o wide
kubectl describe pods -l app.kubernetes.io/name=api-reservas
kubectl logs -l app.kubernetes.io/name=api-reservas --tail=200 --all-containers
kubectl get events --sort-by=.lastTimestamp | tail -40Las pruebas de humo son deliberadamente pocas y muy significativas:
// pruebas/humo.spec.js — se ejecutan en kind y también tras cada despliegue real
describe('humo api-reservas', () => {
test('responde a la sonda de preparación', async () => {
const r = await fetch(`${base}/preparado`);
expect(r.status).toBe(200);
});
test('expone métricas en formato Prometheus', async () => {
const t = await (await fetch(`${base}/metricas`)).text();
expect(t).toMatch(/^api_reservas_peticiones_total/m);
});
test('consulta horarios contra la base de datos real', async () => {
const r = await fetch(`${base}/horarios?linea=BIL-SAN&fecha=2026-08-14`);
expect(r.status).toBe(200);
expect(Array.isArray((await r.json()).salidas)).toBe(true);
});
test('crea una reserva y es idempotente', async () => {
const cuerpo = { linea: 'BIL-SAN', fecha: '2026-08-14', plazas: 2 };
const cab = { 'Idempotency-Key': 'prueba-humo-001', 'Content-Type': 'application/json' };
const r1 = await fetch(`${base}/reservas`, { method: 'POST', headers: cab, body: JSON.stringify(cuerpo) });
const r2 = await fetch(`${base}/reservas`, { method: 'POST', headers: cab, body: JSON.stringify(cuerpo) });
expect(r1.status).toBe(201);
expect((await r2.json()).id).toBe((await r1.json()).id); // no duplica
});
});El coste de este trabajo es de unos tres minutos por ejecución. A cambio, en el último año ha detectado antes de fusionar: dos claves de ConfigMap renombradas sin actualizar el código, una migración que no era reversible, un targetPort erróneo tras un cambio de puerto, y una sonda de preparación que devolvía 200 antes de que el pool de base de datos estuviera listo. Cualquiera de esas cuatro habría sido un incidente en dev como mínimo, y la última podría haber llegado a producción.
- La entrega: actualizar el repositorio de manifiestos
Cuando la fusión a main termina con todo en verde, el último trabajo abre una petición de cambio en rutasnorte/manifiestos.
actualizar-manifiestos:
needs: [imagen, integracion]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Clonar el repositorio de manifiestos
uses: actions/checkout@v4
with:
repository: rutasnorte/manifiestos
# Token de una app de GitHub limitada a este repositorio,
# no un token personal con acceso a toda la organización.
token: ${{ secrets.TOKEN_MANIFIESTOS }}
- name: Escribir el nuevo digest en la superposición de dev
run: |
cd overlays/dev
kustomize edit set image \
"api-reservas=${REGISTRO}/${IMAGEN}@${{ needs.imagen.outputs.digest }}"
- name: Abrir petición de cambio y fusionarla automáticamente
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.TOKEN_MANIFIESTOS }}
branch: auto/api-reservas-${{ needs.imagen.outputs.etiqueta }}
commit-message: "dev: api-reservas ${{ needs.imagen.outputs.etiqueta }}"
title: "dev: api-reservas ${{ needs.imagen.outputs.etiqueta }}"
body: |
Actualización automática desde `rutasnorte/api-reservas`.
- Commit: ${{ github.sha }}
- Digest: `${{ needs.imagen.outputs.digest }}`
- Trivy: sin vulnerabilidades críticas
- Firma: verificada (sin claves)
- Pruebas de integración en kind: correctas
labels: automatico,devEn dev, esa petición se fusiona sola porque una regla del repositorio lo permite cuando el único cambio afecta a overlays/dev/kustomization.yaml. En cuanto se fusiona, Argo CD detecta el cambio y sincroniza. Entre el git push del desarrollador y el pod nuevo corriendo en rutas-norte-dev pasan unos once minutos.
Por qué una petición de cambio y no un commit directo: deja rastro revisable, permite que las reglas de protección de rama se apliquen igual, y hace que revertir sea un git revert con contexto en lugar de un commit huérfano de un bot.
- Promoción a
pre y a pro con aprobación humana
pre y a pro con aprobación humanaLa promoción es cambiar el mismo digest de superposición. No se reconstruye nada: el artefacto que se validó en dev es exactamente el que llega a producción, byte a byte.
# Ejecutado por una persona (o por un botón que hace esto mismo)
cd manifiestos
DIGEST=$(yq '.images[] | select(.name=="api-reservas") | .digest' overlays/dev/kustomization.yaml)
git switch -c promocion/api-reservas-pre
cd overlays/pre
kustomize edit set image "api-reservas=registry.rutasnorte.example/rutasnorte/api-reservas@${DIGEST}"
git commit -am "pre: promocionar api-reservas ${DIGEST:0:19}"
gh pr create --title "pre: promocionar api-reservas" --body-file ../.github/plantillas/promocion.mdCada puerta comprueba cosas distintas, y esto es lo que hace que la promoción sea algo más que pulsar un botón:
| Puerta | Quién aprueba | Qué debe comprobar antes |
|---|---|---|
dev → pre |
Un miembro del equipo desarrollo |
Aplicación desplegada en dev sin reinicios durante 30 min; pruebas de humo en verde; sin alertas nuevas; migraciones aplicadas sin error |
pre → pro |
Un miembro de plataforma y uno de producto |
Pruebas de regresión completas en pre; prueba de carga k6 con el perfil del puente de mayo dentro de los umbrales; migraciones probadas contra una copia restaurada de producción; plan de reversión escrito; ventana de cambio abierta (no hay congelación activa) |
La plantilla de la petición de cambio de promoción a pro obliga a rellenar:
## Promoción a producción — api-reservas
- **Digest:** `sha256:...`
- **Cambios incluidos:** (enlaces a las peticiones de cambio de código)
- **¿Incluye migración de esquema?** Sí / No — si sí, ¿es compatible hacia atrás?
- **Resultado de k6 en pre:** p95 = ___ ms (umbral 300 ms), errores = ___ % (umbral 0,1 %)
- **Riesgo estimado:** bajo / medio / alto — justificación:
- **Plan de reversión:** `git revert <sha>` + tiempo estimado ___ min
- **Ventana:** ¿hay congelación activa? Sí / No
- **Aprobaciones:** plataforma @____ · producto @____La pregunta sobre compatibilidad hacia atrás de la migración no es retórica: es la que hace posible la reversión. Una migración que borra una columna hace que revertir el código sea imposible sin restaurar la base de datos. En 11-04 veremos el patrón de expandir y contraer que lo resuelve.
- Entornos efímeros por petición de cambio
Cada petición de cambio abierta en rutasnorte/api-reservas obtiene un namespace propio, con su base de datos, su Ingress y su URL, y desaparece al cerrarla.
entorno-efimero:
if: github.event_name == 'pull_request'
needs: imagen
runs-on: ubuntu-latest
environment:
name: pr-${{ github.event.number }}
url: https://pr-${{ github.event.number }}.dev.rutasnorte.example
steps:
- uses: actions/checkout@v4
- name: Autenticarse en el clúster de desarrollo por OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/ci-rutasnorte-efimeros
aws-region: eu-west-1
- run: aws eks update-kubeconfig --name rutasnorte-dev
- name: Desplegar el entorno
env:
NS: rutas-norte-pr-${{ github.event.number }}
run: |
kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f -
# Etiquetas que gobiernan cuotas, políticas y limpieza automática.
kubectl label namespace "$NS" --overwrite \
rutasnorte.example/efimero=true \
rutasnorte.example/pr=${{ github.event.number }} \
rutasnorte.example/creado=$(date +%Y-%m-%d) \
pod-security.kubernetes.io/enforce=restricted
helm upgrade --install api-reservas ./chart \
--namespace "$NS" \
--values ./chart/valores-efimero.yaml \
--set image.digest='${{ needs.imagen.outputs.digest }}' \
--set ingress.host=pr-${{ github.event.number }}.dev.rutasnorte.example \
--wait --timeout 8m
- name: Comentar la URL en la petición de cambio
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.number }}
body: |
Entorno de pruebas listo: https://pr-${{ github.event.number }}.dev.rutasnorte.example
Namespace: `rutas-norte-pr-${{ github.event.number }}`
limpiar-efimero:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- run: kubectl delete namespace "rutas-norte-pr-${{ github.event.number }}" --ignore-not-foundEste es el único punto de toda la canalización donde se ejecuta kubectl contra un clúster, y está acotado deliberadamente: solo el clúster de desarrollo, solo namespaces con el prefijo rutas-norte-pr-, y con un rol que no puede tocar nada más.
La red de seguridad: un CronJob nocturno borra los namespaces efímeros de más de siete días, porque siempre hay peticiones de cambio que se quedan abiertas y trabajos de limpieza que fallan.
apiVersion: batch/v1
kind: CronJob
metadata:
name: limpiar-namespaces-efimeros
namespace: plataforma
spec:
schedule: "0 2 * * *"
jobTemplate:
spec:
template:
spec:
serviceAccountName: limpiador-efimeros
restartPolicy: OnFailure
containers:
- name: limpiar
image: registry.rutasnorte.example/utiles/kubectl:1.30
command:
- /bin/sh
- -c
- |
LIMITE=$(date -d '7 days ago' +%Y-%m-%d)
kubectl get ns -l rutasnorte.example/efimero=true \
-o jsonpath='{range .items[*]}{.metadata.name} {.metadata.labels.rutasnorte\.example/creado}{"\n"}{end}' |
while read -r NS CREADO; do
[ "$CREADO" \< "$LIMITE" ] && kubectl delete ns "$NS"
donePor qué son la mejor inversión de un equipo. Cuesta unos dos días montar esto bien, y cambia la dinámica de trabajo entera:
- La revisión de código pasa de leer un diff a usar la funcionalidad. Producto y diseño opinan sobre algo real, no sobre una captura.
- Los errores de integración se detectan en la petición de cambio, no en
devdespués de fusionar. - Desaparece la cola por el entorno compartido: cinco cambios en paralelo tienen cinco entornos.
- Se prueban los manifiestos, no solo el código. Un chart mal parametrizado falla aquí.
El coste hay que acotarlo: cuota estricta por namespace (03-04), una sola réplica de cada cosa, base de datos pequeña sin persistencia, y el borrado automático. En Rutas Norte, con una media de seis entornos efímeros vivos, esto supone unos 130 euros al mes: menos de lo que cuesta media hora de reunión para coordinar quién usa el entorno de pruebas.
- Credenciales sin secretos de larga vida
El objetivo es que no exista ninguna credencial permanente en los secretos de la canalización. Cada acceso se autentica con un token efímero emitido para esa ejecución concreta.
sequenceDiagram participant W as Flujo de trabajo participant G as Emisor OIDC de GitHub participant A as AWS STS participant K as Clúster EKS dev W->>G: Solicita token OIDC (permissions.id-token) G-->>W: JWT con sub=repo:rutasnorte/api-reservas:ref:refs/heads/main W->>A: AssumeRoleWithWebIdentity(JWT) A->>A: Valida emisor, audiencia y condición sobre sub A-->>W: Credenciales temporales (1 h) W->>K: kubectl con esas credenciales K->>K: RBAC del rol mapeado: solo ns rutas-norte-pr-*
La política de confianza del rol es donde está la seguridad real:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:rutasnorte/api-reservas:pull_request"
}
}
}]
}La condición sobre sub es lo que impide que otro repositorio de la organización, o una rama cualquiera, asuma este rol. Escribirla como repo:rutasnorte/*:* —error muy frecuente— equivale a dar el rol a toda la organización.
Y el RBAC del lado del clúster, mínimo (08-01):
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: ci-efimeros
rules:
# Puede crear y borrar namespaces (necesario para los efímeros)...
- apiGroups: [""]
resources: [namespaces]
verbs: [get, list, create, delete, patch]
# ...y gestionar cargas dentro de ellos.
- apiGroups: ["", apps, networking.k8s.io, batch, autoscaling]
resources: ["*"]
verbs: [get, list, create, update, patch, delete]
# Explícitamente NO: nodes, clusterroles, clusterrolebindings,
# persistentvolumes ni secretos de otros namespaces.Comparación de la superficie de riesgo:
| Enfoque | Qué se filtra si se compromete el corredor | Caducidad |
|---|---|---|
| Kubeconfig guardado como secreto | Acceso permanente al clúster | Ninguna |
| Clave de acceso de la nube guardada | Acceso permanente a la cuenta | Ninguna |
| Token personal de un desarrollador | Todo lo que esa persona puede hacer | Meses |
| OIDC federado | Un token de 1 hora, acotado por la condición sub |
1 hora |
- Verificación posterior al despliegue y reversión automática
Que Argo CD diga Synced y Healthy significa que los pods arrancaron, no que la aplicación funcione. La verificación posterior es la capa que falta.
# Hook de sincronización de Argo CD: se ejecuta DESPUÉS de sincronizar.
apiVersion: batch/v1
kind: Job
metadata:
name: humo-api-reservas
namespace: rutas-norte-pro
annotations:
argocd.argoproj.io/hook: PostSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
backoffLimit: 2
activeDeadlineSeconds: 300
template:
spec:
restartPolicy: Never
containers:
- name: humo
image: registry.rutasnorte.example/rutasnorte/pruebas-humo@sha256:e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4
env:
- name: BASE_URL
value: https://api.rutasnorte.example
- name: MODO
value: solo-lectura # en pro no creamos reservas realesSi el Job falla, Argo CD marca la sincronización como fallida y, con esta política, revierte sola:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: api-reservas-pro
namespace: argocd
spec:
source:
repoURL: https://github.com/rutasnorte/manifiestos
path: overlays/pro
targetRevision: main
destination:
server: https://kubernetes.default.svc
namespace: rutas-norte-pro
syncPolicy:
automated:
prune: true
selfHeal: true
retry:
limit: 2
backoff: { duration: 30s, factor: 2, maxDuration: 3m }
revisionHistoryLimit: 20La reversión automática tiene dos niveles y conviene distinguirlos:
- Reversión de la carga:
kubectl rollout undosobre el Deployment. Es inmediata pero deja el clúster desincronizado con Git, así que Argo CD conselfHealvolvería a poner la versión mala. Solo sirve como parche de emergencia acompañado del paso 2. - Reversión en Git:
git revertdel commit que cambió el digest. Es la correcta, tarda lo que tarde la sincronización (segundos si se fuerza) y deja el sistema coherente.
Automatización en Rutas Norte: si el hook de verificación falla en pro, un flujo de trabajo abre y fusiona automáticamente el git revert del último commit de overlays/pro, y avisa al canal de guardia. El tiempo medio desde el fallo hasta la versión anterior corriendo es de 3 minutos y 40 segundos.
# El flujo de reversión automática, en esencia
gh workflow run revertir.yml -f entorno=pro -f motivo="humo PostSync fallido"
# ...que hace:
git revert --no-edit "$(git log -1 --format=%H -- overlays/pro)"
git push origin main
argocd app sync api-reservas-pro --prune
argocd app wait api-reservas-pro --health --timeout 300Un matiz importante: la reversión automática solo se activa si la petición de cambio de promoción declaró que no hay migración de esquema incompatible. Si la hay, la reversión debe ser humana, porque revertir código sobre un esquema ya migrado puede corromper datos.
- Métricas DORA: los números de Rutas Norte antes y después
Toda esta ingeniería debe justificarse con números, o es afición. Las cuatro métricas DORA son la forma estándar de medirlo.
| Métrica | Qué mide | Cómo se obtiene en Rutas Norte |
|---|---|---|
| Frecuencia de despliegue | Cuántas veces se lleva algo a producción | Commits en overlays/pro por semana |
| Plazo de entrega del cambio | Del commit fusionado a estar en producción | Marca de tiempo del commit → evento de sincronización de Argo CD |
| Tasa de fallo de los cambios | Qué porcentaje de despliegues causa un problema | Despliegues seguidos de reversión o incidente / total |
| Tiempo de restauración del servicio | Cuánto se tarda en recuperar tras un fallo | Apertura del incidente → resolución |
10.1. Los números
Situación de partida, en noviembre de 2025: construcción manual de imágenes desde los portátiles, etiqueta latest, kubectl apply desde el equipo de la persona que desplegaba, despliegues los jueves por la tarde "cuando toca".
| Métrica | Antes (nov. 2025) | Después (jul. 2026) | Referencia de alto rendimiento |
|---|---|---|---|
Frecuencia de despliegue a pro |
1 cada 2 semanas | 4-6 por semana | Bajo demanda |
Plazo de entrega (fusión → pro) |
9 días | 4 h 20 min | < 1 día |
| Tasa de fallo de los cambios | 31 % | 7 % | 0-15 % |
| Tiempo de restauración | 3 h 40 min | 12 min | < 1 hora |
10.2. Qué movió cada número
- Frecuencia: subió por los entornos efímeros y por la promoción automática a
dev. Cuando desplegar deja de doler, se despliega más. - Plazo de entrega: de nueve días a poco más de cuatro horas. El grueso de esos nueve días no era técnico: era esperar a que se liberara el entorno compartido y a que hubiera hueco en la ventana del jueves.
- Tasa de fallo: bajó del 31 % al 7 % principalmente por dos cosas, las pruebas de integración en kind y el hecho de que lo que se prueba en
prees exactamente el mismo digest que va apro. La mayoría de los fallos anteriores eran diferencias entre lo probado y lo desplegado. - Tiempo de restauración: de casi cuatro horas a doce minutos, gracias a la reversión automática y a que revertir es
git reverten lugar de reconstruir una imagen antigua que ya nadie sabe cómo se construía.
10.3. Cómo medirlas sin montar un proyecto
# Frecuencia de despliegue a pro en los últimos 30 días
git log --since='30 days ago' --oneline -- overlays/pro | wc -l
# Plazo de entrega: diferencia entre el commit de código y el de promoción
git log --since='30 days ago' --format='%H %ct' -- overlays/proLas dos primeras salen de Git. La tasa de fallo y el tiempo de restauración salen del registro de incidencias, que veremos en 11-06. La trampa clásica es medir solo las dos fáciles: la velocidad sin estabilidad no es rendimiento, es riesgo acumulándose.
Errores Comunes y Consejos
- Ejecutar
kubectl applydesde la canalización. Obliga a guardar credenciales de producción en el sistema de CI, hace el estado dependiente del orden de ejecuciones y elimina la trazabilidad. Con GitOps la canalización solo escribe en Git. - Desplegar por etiqueta móvil en lugar de por digest. El artefacto probado y el desplegado dejan de ser el mismo, y toda la cadena de verificación pierde sentido.
- Reconstruir la imagen al promocionar de
preapro. Es el error conceptual más caro: se promociona un artefacto, no un commit. Reconstruir invalida todas las pruebas anteriores. - Romper la construcción con
HIGHo con vulnerabilidades sin parche. El equipo aprende a añadir excepciones por rutina y el control deja de detectar nada. - Condición OIDC demasiado amplia (
repo:org/*:*). Concede el rol a toda la organización. La condición debe fijar repositorio y, cuando sea posible, rama o tipo de evento. - Entornos efímeros sin cuota ni borrado automático. Se convierten en la partida de coste que nadie entiende. Cuota, réplica única, sin persistencia y limpieza nocturna.
- Reversión automática con migraciones incompatibles. Puede corromper datos. Toda migración debe declararse compatible hacia atrás o bloquear la reversión automática.
- Consejo: haz que las pruebas de humo se ejecuten en kind y también tras cada despliegue real. El mismo código, dos momentos: valida antes de fusionar y verifica después de desplegar.
- Consejo: publica las cuatro métricas DORA en un panel visible. Si solo se miran las de velocidad, la calidad se degrada sin que nadie lo note hasta el incidente.
Ejercicios
Ejercicio 1: encontrar los fallos de una canalización
Un equipo de Rutas Norte propone esta canalización simplificada para un servicio nuevo:
on: [push]
permissions: write-all
jobs:
desplegar:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t registry.rutasnorte.example/rutasnorte/promos:latest .
- run: docker push registry.rutasnorte.example/rutasnorte/promos:latest
- run: echo "${{ secrets.KUBECONFIG_PRO }}" > /tmp/kc
- run: KUBECONFIG=/tmp/kc kubectl -n rutas-norte-pro apply -f k8s/
- run: KUBECONFIG=/tmp/kc kubectl -n rutas-norte-pro rollout restart deploy/promosIdentifica al menos cinco problemas serios y propón la corrección de cada uno.
Ejercicio 2: diseñar la puerta de promoción
api-reservas incorpora un cambio que añade la columna codigo_promocional a la tabla reservas y empieza a usarla. Diseña la secuencia de promoción a producción indicando: en cuántos despliegues se divide, qué contiene cada uno, en qué orden se aplican migración y código, y qué permite que la reversión sea posible en cada punto.
Ejercicio 3: interpretar las métricas DORA
Tres meses después de las mejoras, Rutas Norte mide: frecuencia de despliegue 11 por semana (subió), plazo de entrega 2 h 10 min (bajó), tasa de fallo 22 % (subió desde el 7 %) y tiempo de restauración 14 min (estable). Interpreta el conjunto, propón dos hipótesis sobre la causa y di qué medirías para distinguirlas.
Soluciones
Solución 1. Problemas y correcciones:
| # | Problema | Corrección |
|---|---|---|
| 1 | permissions: write-all |
Permisos mínimos: contents: read, packages: write, id-token: write |
| 2 | Etiqueta latest |
Etiquetar por SHA corto y desplegar por digest |
| 3 | Sin pruebas, sin lint, sin escaneo, sin firma | Añadir trabajo de pruebas, Trivy con CRITICAL y firma Cosign sin claves |
| 4 | Kubeconfig de producción guardado como secreto | Eliminarlo; la canalización no debe tocar pro. Sustituir por petición de cambio al repositorio de manifiestos |
| 5 | kubectl apply directo a pro desde cada push a cualquier rama |
GitOps: escribir el digest en overlays/dev; pre y pro por promoción aprobada |
| 6 | rollout restart como mecanismo de despliegue |
Innecesario si el digest cambia; el cambio de digest ya provoca el despliegue |
| 7 | on: [push] sin filtro de rama |
Restringir a main lo que publica, y usar pull_request para validar |
Solución 2. Tres despliegues, aplicando expandir y contraer:
- Despliegue 1 (expandir): migración que añade
codigo_promocionalcomo columna anulable con valor por defecto nulo. El código desplegado la ignora por completo. Reversión posible: revertir el código no rompe nada, la columna sobra pero es inocua. - Despliegue 2 (usar): código nuevo que escribe y lee la columna, con lectura tolerante al valor nulo para las filas antiguas. Sin cambios de esquema. Reversión posible: la versión anterior sigue funcionando porque ignora la columna.
- Despliegue 3 (contraer), semanas después y solo si hace falta: añadir restricción
NOT NULL, índices o eliminar columnas antiguas sustituidas. A partir de aquí la reversión a la versión 1 ya no es segura, por eso se separa en el tiempo y se hace cuando hay confianza plena.
Orden general: la migración se aplica antes que el código que la necesita, y siempre de forma que la versión anterior del código siga funcionando con el esquema nuevo. Eso es lo que hace posible el RollingUpdate (dos versiones conviviendo) y la reversión.
Solución 3. Interpretación: la velocidad ha mejorado pero la estabilidad se ha degradado seriamente; casi uno de cada cuatro despliegues causa problema. El tiempo de restauración estable indica que la reversión automática sigue funcionando bien, lo que amortigua el impacto pero no lo elimina. El conjunto sugiere que se está desplegando más deprisa de lo que la verificación puede validar.
Hipótesis A: se han relajado las puertas de calidad (menos pruebas de regresión en pre, aprobaciones por rutina, umbrales de k6 no revisados). Hipótesis B: el aumento de frecuencia viene de cambios más pequeños pero más numerosos en zonas del sistema mal cubiertas por pruebas, o de un componente nuevo con menos madurez.
Para distinguirlas: (a) desglosar la tasa de fallo por componente —si se concentra en uno, es la hipótesis B; si está repartida, la A—; (b) medir la cobertura y el tiempo de las pruebas de pre en los últimos tres meses y comprobar si alguna puerta se ha desactivado; (c) revisar las peticiones de cambio de promoción fallidas y clasificar la causa raíz de cada una. Acción probable: no reducir la frecuencia, sino reforzar la verificación que se saltó, y considerar el despliegue canario de 11-04 para que un cambio defectuoso solo afecte a un porcentaje pequeño de usuarios.
Conclusión
Hemos recorrido la canalización completa de Rutas Norte, del git push a producción. Vimos por qué la integración y la entrega se separan, y por qué con GitOps la canalización nunca ejecuta kubectl apply: produce un digest firmado y verificado, y escribe ese digest en el repositorio de manifiestos, dejando que Argo CD haga el resto. Recorrimos el fichero de integración completo —pruebas, construcción multietapa con caché, etiquetado por commit, Trivy, firma sin claves, digest—, las pruebas de integración reales contra un clúster kind levantado dentro de la propia canalización, la promoción por entornos con puertas que comprueban cosas distintas, los entornos efímeros por petición de cambio como la mejor inversión que puede hacer un equipo, las credenciales federadas sin secretos de larga vida, la verificación posterior al despliegue con reversión automática en menos de cuatro minutos, y las métricas DORA que demuestran que todo esto sirvió para algo real.
La idea central: se promociona un artefacto, no un commit. El mismo digest que pasó las pruebas en dev y la carga en pre es el que atiende a los clientes en pro, sin reconstruir nada por el camino.
Aun con todo esto, cada despliegue a producción sigue siendo un salto: la versión nueva sustituye a la anterior en todos los pods y, si algo se escapó a las pruebas, lo descubren todos los usuarios a la vez. En la siguiente lección, Estrategias de Despliegue: Blue-Green y Canary, veremos cómo eliminar ese salto: desplegar sin comprometerse, validar con tráfico real, exponer la versión nueva a un porcentaje pequeño de usuarios y dejar que Prometheus decida automáticamente si se promociona o se aborta.
Curso de Kubernetes
Módulo 1: Introducción a Kubernetes
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
