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

  1. El flujo completo de Rutas Norte
  2. Integración continua y entrega continua: por qué separarlas
  3. La canalización de integración, paso a paso
  4. Pruebas de integración contra un clúster kind efímero
  5. La entrega: actualizar el repositorio de manifiestos
  6. Promoción a pre y a pro con aprobación humana
  7. Entornos efímeros por petición de cambio
  8. Credenciales sin secretos de larga vida
  9. Verificación posterior al despliegue y reversión automática
  10. Métricas DORA: los números de Rutas Norte antes y después

  1. 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.

  1. 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 edit a 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.

  1. 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.

  1. 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 -40

Las 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.

  1. 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,dev

En 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.

  1. Promoción a pre y a pro con aprobación humana

La 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.md

Cada 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
devpre 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
prepro 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.

  1. 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-found

Este 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"
                  done

Por 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 dev despué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.

  1. 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

  1. 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 reales

Si 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: 20

La reversión automática tiene dos niveles y conviene distinguirlos:

  1. Reversión de la carga: kubectl rollout undo sobre el Deployment. Es inmediata pero deja el clúster desincronizado con Git, así que Argo CD con selfHeal volvería a poner la versión mala. Solo sirve como parche de emergencia acompañado del paso 2.
  2. Reversión en Git: git revert del 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 300

Un 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.

  1. 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 pre es exactamente el mismo digest que va a pro. 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 revert en 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/pro

Las 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 apply desde 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 pre a pro. 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 HIGH o 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/promos

Identifica 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:

  1. Despliegue 1 (expandir): migración que añade codigo_promocional como 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.
  2. 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.
  3. 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

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados