La imagen aurora-api:2.0.0 es correcta, pero la construyes tú, en tu portátil, con tu caché y tu git status. Esta lección quita al humano de en medio: cada git push dispara una cadena que ejecuta las pruebas, construye para dos arquitecturas, escanea, firma, publica y despliega, sin que nadie tenga que acordarse de nada.
Contenido
- CI, delivery y deployment: tres conceptos, dos siglas
- Por qué Docker encaja: el artefacto es la imagen
- Anatomía del pipeline
- Pruebas dentro de contenedores con Compose
- La etapa
pruebasdel Dockerfile y--target - El pipeline de Aurora Libros: disparadores y permisos
- Etiquetado automático con
metadata-action - Construcción multiarquitectura con caché
gha - Escaneo con Trivy que rompe la build
- Firma keyless, SBOM y procedencia
- El pipeline equivalente en GitLab CI
- Docker-in-Docker frente al socket montado
- Gestión de credenciales: OIDC y tokens efímeros
- Versionado automático desde las etiquetas de Git
- El despliegue automatizado
Advertencia. Un runner de CI con acceso a tu registro y a tus servidores es una pieza de infraestructura crítica: quien controle el pipeline puede publicar y desplegar cualquier cosa. Los permisos de los runners, el alcance de los tokens y el acceso SSH a los entornos deben definirse y revisarse con el responsable de infraestructura y de seguridad de tu organización.
- CI, delivery y deployment: tres conceptos, dos siglas
| Concepto | Qué automatiza | Dónde acaba | Intervención humana |
|---|---|---|---|
| Integración continua (CI) | Compilar, analizar y probar cada cambio | Un artefacto validado | Ninguna |
| Entrega continua (continuous delivery) | Todo lo anterior + dejar el artefacto listo para desplegar | El registro, con la imagen publicada | Alguien aprueba el despliegue |
| Despliegue continuo (continuous deployment) | Todo lo anterior + desplegar automáticamente | Producción | Ninguna |
Las dos últimas comparten la abreviatura CD y se confunden constantemente. La diferencia práctica es un botón: en delivery, la imagen está lista y espera un apruebo; en deployment, cada merge a main que pase todas las puertas llega a producción sola. Aurora Libros hará delivery hacia producción y deployment hacia staging: es la combinación más habitual y la más sensata mientras la cobertura de pruebas no dé para confiar a ciegas.
- Por qué Docker encaja: el artefacto es la imagen
Antes de los contenedores, el artefacto de CI era un .jar, un .zip o un tarball, y el entorno donde corría se preparaba por separado: de ahí venía el "en mi máquina funciona", porque el artefacto viajaba y el entorno no. Con Docker, el artefacto incluye su entorno, y eso desbloquea tres propiedades que hacen fiable un pipeline:
- Inmutabilidad. El digest identifica un contenido exacto:
sha256:a1b2...es la misma cosa en CI, enstagingy en producción, para siempre. - Promoción sin reconstruir. Se promociona retiquetando. Reconstruir para producción significa desplegar un artefacto que nadie ha probado.
- Paridad de entornos. El contenedor que pasó las pruebas es, byte a byte, el que atiende a los clientes.
La regla que resume el módulo: construir una vez, desplegar muchas. Un commit produce una imagen, esa imagen tiene un digest, y ese digest es el que se promociona a staging y a producción. Si tu pipeline tiene un docker build por entorno, tiene un fallo de diseño.
- Anatomía del pipeline
flowchart TD
A[checkout] --> B[lint]
B --> C[pruebas en contenedor]
C --> D[build multiarquitectura]
D --> E[escaneo de CVE]
E --> F[firma + SBOM]
F --> G[publicación en el registro]
G --> H{rama?}
H -->|main| I[deploy staging]
H -->|etiqueta v*| J[deploy producción]
| Fase | Qué puede fallar | Cuánto tarda | ¿Rompe la build? |
|---|---|---|---|
| Checkout + lint | Submódulos, estilo, console.log olvidado |
~25 s | Sí |
| Pruebas | Regresión, flaky por dependencias | 1-3 min | Sí |
| Build | Fallo de compilación, caché fría | 40 s - 4 min | Sí |
| Escaneo | CVE crítica en una dependencia | ~30 s | Sí (críticas) |
| Firma / SBOM | Permisos OIDC mal configurados | ~15 s | Sí |
| Publicación | Credenciales, cuota del registro | ~30 s | Sí |
| Despliegue | Red, sondas que no pasan | 1-5 min | Sí, con rollback |
El orden no es arbitrario: lo barato y lo que más falla, primero. Un lint de 20 segundos que detecta el error evita gastar cuatro minutos de build multiarquitectura. Es el mismo principio de la caché de capas de 02-02, aplicado al pipeline.
- Pruebas dentro de contenedores con Compose
Probar contra una base de datos real, y no contra un mock, es lo que distingue una prueba útil de una que aprueba código roto. Con Compose es trivial y, sobre todo, idéntico en tu portátil y en el runner.
# compose.pruebas.yaml — pila efímera, sin volúmenes: se muere y no deja rastro
services:
pruebas:
build: { context: ./api, target: pruebas } # la etapa del Dockerfile de 06-01
environment:
DB_HOST: db-pruebas
DB_USER: aurora
DB_PASSWORD: prueba-ficticia
DB_NAME: aurora_libros_test
REDIS_HOST: cache-pruebas
depends_on:
db-pruebas: { condition: service_healthy }
cache-pruebas: { condition: service_healthy }
db-pruebas:
image: postgres:16-alpine
environment: { POSTGRES_USER: aurora, POSTGRES_PASSWORD: prueba-ficticia, POSTGRES_DB: aurora_libros_test }
volumes: ["./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro"]
tmpfs: ["/var/lib/postgresql/data"] # datos en RAM: más rápido y efímero de verdad
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aurora -d aurora_libros_test"]
interval: 2s
retries: 15
cache-pruebas:
image: redis:7-alpine
healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 2s, retries: 15 }docker compose -f compose.pruebas.yaml up \
--build --abort-on-container-exit --exit-code-from pruebas
docker compose -f compose.pruebas.yaml down -vLas dos banderas son el corazón del asunto y conviene entenderlas bien:
--abort-on-container-exitpara toda la pila en cuanto un contenedor termina. Sin ella, las pruebas acaban y PostgreSQL sigue vivo: el comando nunca retorna y el job se queda colgado hasta el timeout.--exit-code-from pruebashace que el código de salida dedocker composesea el del contenedor de pruebas, no el de la operación de Compose. Sin ella, el comando devuelve0aunque las pruebas fallen, y el pipeline se pone verde con el código roto. Este es, con diferencia, el error más frecuente de esta lección.
El tmpfs sobre el directorio de datos de PostgreSQL merece una nota: en pruebas no necesitas durabilidad, así que poner los datos en RAM elimina la escritura a disco y suele reducir la suite entre un 30 % y un 50 %. Nunca, jamás, en producción.
- La etapa
pruebas del Dockerfile y --target
pruebas del Dockerfile y --targetEl Dockerfile de 06-01 ya tenía la etapa preparada. Ejecutarla sola es un --target:
| Enfoque | Ventaja | Inconveniente |
|---|---|---|
Etapa pruebas con --target |
Mismo Dockerfile, mismas capas cacheadas |
Necesita los servicios aparte |
compose.pruebas.yaml |
Pila completa con BD y caché reales | Un fichero más que mantener |
| Sin contenedor, en el runner | Rapidísimo | El runner deja de ser reproducible |
Aurora Libros usa las dos primeras a la vez: compose.pruebas.yaml construye con target: pruebas. Y hay un beneficio de caché nada menor: la etapa deps que instala node_modules es común a las pruebas y a la imagen final, así que el build de producción reutiliza esas capas y no reinstala nada.
- El pipeline de Aurora Libros: disparadores y permisos
# .github/workflows/ci.yaml
name: CI/CD Aurora Libros
on:
push:
branches: [main]
tags: ["v*.*.*"] # v2.0.0 dispara la publicación de release
pull_request:
branches: [main]
env:
REGISTRO: ghcr.io
IMAGEN: ${{ github.repository_owner }}/aurora-api
# Un push nuevo sobre la misma rama cancela el pipeline anterior: ahorra minutos y cuota
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
pruebas:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Lint y pruebas contra PostgreSQL y Redis reales
run: |
docker compose -f compose.pruebas.yaml up \
--build --abort-on-container-exit --exit-code-from pruebas
- name: Limpiar la pila
if: always() # también si las pruebas fallaron
run: docker compose -f compose.pruebas.yaml down -vSobre los disparadores: los pull requests ejecutan las pruebas y construyen sin publicar, porque una rama de nadie no debe poder subir una imagen al registro. Los push a main publican con la etiqueta edge y despliegan a staging. Las etiquetas v*.*.* producen la versión SemVer publicable.
- Etiquetado automático con
metadata-action
metadata-action publicar:
needs: pruebas # no se construye si las pruebas fallaron
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write # publicar en ghcr.io
id-token: write # OIDC para la firma keyless de Cosign
security-events: write # subir el informe de Trivy a la pestaña Security
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRO }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # token efímero, no una contraseña
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRO }}/${{ env.IMAGEN }}
tags: |
type=semver,pattern={{version}} # v2.0.0 -> 2.0.0
type=semver,pattern={{major}}.{{minor}} # v2.0.0 -> 2.0
type=semver,pattern={{major}} # v2.0.0 -> 2
type=ref,event=branch # main -> main
type=sha,prefix=sha-,format=short # siempre -> sha-a1b2c3d
type=raw,value=latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.title=aurora-api
org.opencontainers.image.vendor=Aurora Libros S.L.Esta acción sustituye el guion de sed y git describe que todo el mundo acaba escribiendo. De la etiqueta v2.0.0 deduce sola las cuatro etiquetas móviles y genera además las etiquetas OCI de 06-01 con la fecha, el commit y la URL del repositorio, sin que tengas que pasarlas como --build-arg.
El type=sha es el más importante de la lista aunque parezca el más aburrido: cada build produce una etiqueta única e irrepetible, así que siempre puedes referirte a una construcción concreta aunque latest y 2.0 se hayan movido diez veces.
- Construcción multiarquitectura con caché
gha
gha - uses: docker/setup-qemu-action@v3 # emulación para arm64 (05-05)
- uses: docker/setup-buildx-action@v3 # builder con driver docker-container
- id: build
uses: docker/build-push-action@v5
with:
context: ./api
target: runtime
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }} # los PR construyen, no publican
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=aurora-api
cache-to: type=gha,scope=aurora-api,mode=max
provenance: mode=max # attestation de procedencia SLSA
sbom: true # SBOM adjunta al índice de la imagen
build-args: |
REVISION=${{ github.sha }}Los dos parámetros de caché son la diferencia entre un pipeline usable y uno insoportable. Sin ellos, cada ejecución arranca con una caché vacía —el runner es una máquina nueva— y npm ci se ejecuta entero dos veces, una por arquitectura. Con type=gha, BuildKit guarda las capas en la caché de GitHub Actions y las recupera en el siguiente build.
| Configuración | Build en frío | Build con caché | Con cambio solo en src/ |
|---|---|---|---|
| Sin caché | 4 min 10 s | 4 min 10 s | 4 min 10 s |
type=gha,mode=min |
4 min 20 s | 1 min 05 s | 55 s |
type=gha,mode=max |
4 min 35 s | 48 s | 41 s |
mode=max guarda también las capas intermedias de las etapas previas —incluidas deps y deps-dev—, así que ocupa más caché pero acierta muchísimo más. El scope evita que dos workflows distintos se pisen las entradas. Y un aviso operativo: la caché de GitHub Actions tiene un límite de 10 GB por repositorio y expulsa por antigüedad, así que un mode=max con muchas ramas puede desalojar entradas útiles.
- Escaneo con Trivy que rompe la build
- name: Escaneo de vulnerabilidades
uses: aquasecurity/[email protected]
with:
image-ref: ${{ env.REGISTRO }}/${{ env.IMAGEN }}@${{ steps.build.outputs.digest }}
format: sarif
output: trivy.sarif
severity: CRITICAL,HIGH
ignore-unfixed: true # sin parche disponible, romper la build no arregla nada
exit-code: "0" # este paso solo informa...
- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: trivy.sarif }
- name: "Puerta de calidad: ninguna crítica corregible"
uses: aquasecurity/[email protected]
with:
image-ref: ${{ env.REGISTRO }}/${{ env.IMAGEN }}@${{ steps.build.outputs.digest }}
severity: CRITICAL
ignore-unfixed: true
exit-code: "1" # ...y este rompe la buildEl escaneo se hace por digest, no por etiqueta: garantiza que analizas exactamente la imagen que acabas de construir y no otra que alguien haya publicado con el mismo nombre entretanto.
La estructura en dos pasos es deliberada. El primero recoge todo (críticas y altas) y lo sube a la pestaña de seguridad para tener visibilidad; el segundo, mucho más estricto, es la puerta: solo rompe ante críticas con parche disponible. Ese ignore-unfixed no es relajación, es pragmatismo: bloquear los despliegues por una CVE que nadie ha corregido todavía no mejora tu seguridad, solo impide que publiques la corrección de otro fallo. Cuál es el umbral aceptable para tu organización lo decide su política de seguridad, no este curso.
- Firma keyless, SBOM y procedencia
- uses: sigstore/cosign-installer@v3
- name: Firmar la imagen (keyless, sin gestionar claves)
run: |
cosign sign --yes \
${{ env.REGISTRO }}/${{ env.IMAGEN }}@${{ steps.build.outputs.digest }}
env:
COSIGN_EXPERIMENTAL: "1"La firma keyless de 05-03 encaja aquí como un guante. No hay clave privada que guardar, rotar ni filtrar: Cosign pide un token OIDC de corta vida a GitHub, obtiene un certificado efímero de Fulcio y registra la firma en el log público de transparencia Rekor. El certificado caduca en minutos; lo que queda es la prueba, verificable por cualquiera:
cosign verify ghcr.io/auroralibros/aurora-api:2.0.0 \
--certificate-identity-regexp '^https://github.com/auroralibros/aurora-libros/.github/workflows/ci.yaml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comFíjate en lo que verifica esa identidad: no dice "está firmada", dice "la construyó ese workflow, de ese repositorio". Una imagen firmada desde el portátil de alguien no pasa esa comprobación. Junto con el provenance: mode=max y el sbom: true del paso anterior, tienes las tres piezas de la cadena de suministro: qué lleva dentro (SBOM), quién y cómo la construyó (procedencia) y que nadie la ha tocado desde entonces (firma).
- El pipeline equivalente en GitLab CI
# .gitlab-ci.yml
stages: [pruebas, build, deploy]
variables:
IMAGEN: $CI_REGISTRY_IMAGE/aurora-api
.docker: &docker # ancla YAML reutilizada por los dos jobs
image: docker:27-cli
services: ["docker:27-dind"] # Docker-in-Docker como servicio del job
variables: { DOCKER_TLS_CERTDIR: "/certs" }
pruebas:
<<: *docker
stage: pruebas
script:
- docker compose -f compose.pruebas.yaml up --abort-on-container-exit --exit-code-from pruebas
build:
<<: *docker
stage: build
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker buildx create --use --driver docker-container
script:
- |
docker buildx build --platform linux/amd64,linux/arm64 \
--cache-from type=registry,ref=$IMAGEN:cache \
--cache-to type=registry,ref=$IMAGEN:cache,mode=max \
--tag $IMAGEN:$CI_COMMIT_REF_SLUG --tag $IMAGEN:sha-$CI_COMMIT_SHORT_SHA --push ./api
rules: [{ if: '$CI_COMMIT_BRANCH == "main"' }, { if: $CI_COMMIT_TAG }]| Concepto | GitHub Actions | GitLab CI |
|---|---|---|
| Unidad ejecutable | job con steps |
job con script |
| Agrupación / orden | needs: entre jobs |
stages: secuenciales |
| Fichero | .github/workflows/*.yaml |
.gitlab-ci.yml |
| Reutilización | uses: (acciones del marketplace) |
include:, extends:, anclas YAML |
| Contenedor del job | container: (opcional) |
image: (habitual) |
| Caché de build | type=gha |
type=registry |
| Registro integrado | ghcr.io | $CI_REGISTRY_IMAGE |
| Condiciones | if: / on: |
rules: / only: |
| Secretos | Repository secrets | CI/CD variables (protegidas/enmascaradas) |
| Identidad sin contraseña | OIDC nativo | OIDC (id_tokens:) |
La caché es la diferencia práctica más visible: GitHub tiene un backend propio, mientras que en GitLab lo habitual es guardar la caché en el propio registro con type=registry, que además funciona en cualquier proveedor.
- Docker-in-Docker frente al socket montado
Para construir imágenes, el runner necesita acceso a un daemon. Hay tres formas, y no son equivalentes en seguridad.
| Opción | Cómo | Riesgo | Rendimiento |
|---|---|---|---|
DinD (docker:dind) |
Daemon anidado dentro del job | Requiere --privileged: escape al host si algo falla |
Caché fría cada vez |
| Socket montado | -v /var/run/docker.sock:... |
Grupo docker = root en el host (05-03) |
Caché caliente compartida |
| BuildKit sin daemon | buildkitd rootless o Kaniko |
El menor: sin privilegios | Buena, con caché remota |
Las dos primeras filas dicen lo mismo con palabras distintas: cualquier job puede tomar el control del runner. Con DinD, porque el contenedor privilegiado tiene todas las capacidades. Con el socket montado, porque quien habla con el daemon puede lanzar docker run -v /:/host --privileged y leer o modificar el disco entero del anfitrión, incluidas las credenciales de los demás pipelines.
En un repositorio privado con colaboradores de confianza, cualquiera de las dos es la práctica común. En cuanto aceptes pull requests de terceros, el socket montado es inaceptable: un PR malicioso que modifique el workflow se lleva tus secretos. Las alternativas son runners efímeros de un solo uso (lo que hacen los runners alojados de GitHub) o construir sin daemon con BuildKit rootless.
- Gestión de credenciales: OIDC y tokens efímeros
| Práctica | Por qué | En Aurora Libros |
|---|---|---|
| Nunca en el repositorio | El historial de Git es para siempre | Todo en secretos del proveedor |
| Token efímero, no contraseña | Caduca solo; robarlo sirve de poco | GITHUB_TOKEN por ejecución |
| Alcance mínimo | Un token de publicación no despliega | permissions: por job |
| OIDC en vez de claves | No hay secreto que rotar ni filtrar | id-token: write para Cosign |
| Rotación y auditoría | Detectar el uso indebido | Registro de accesos del registro |
El GITHUB_TOKEN no es un secreto que hayas creado: GitHub lo genera al empezar la ejecución, con exactamente los permisos del bloque permissions:, y lo invalida al terminar. Robado media hora después, no vale nada.
La regla que nunca se rompe es la de no imprimir un secreto. Los proveedores enmascaran los valores conocidos y muestran ***, pero el enmascarado se salta con facilidad: echo $CLAVE | base64 sale en claro, y un set -x en un script bash imprime cada comando con sus argumentos. Por eso las credenciales se pasan siempre por stdin (--password-stdin) y nunca como argumento de línea de comandos, que además queda visible en la lista de procesos del runner.
- Versionado automático desde las etiquetas de Git
git tag -a v2.0.0 -m "Sondas separadas, apagado ordenado y validación de configuración"
git push origin v2.0.0 # esto es, en la práctica, el botón de publicarmetadata-action traduce esa etiqueta a las cuatro de la imagen sin intervención:
| Etiqueta de Git | Etiquetas de la imagen | Se mueve |
|---|---|---|
v2.0.0 |
2.0.0 |
Nunca |
v2.0.0 |
2.0 |
Con cada parche |
v2.0.0 |
2 |
Con cada versión menor |
v2.0.0 |
latest |
Con cada versión |
| (cualquier build) | sha-a1b2c3d |
Nunca |
Las dos filas que nunca se mueven son las que sirven para producción; las móviles son cómodas para desarrollo y peligrosas en despliegues. El compose.prod.yaml de Aurora Libros fija el digest, con lo que ni siquiera depende de que 2.0.0 siga apuntando a lo mismo.
- El despliegue automatizado
desplegar-staging:
needs: publicar
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
environment: staging # permite exigir aprobación y restringir secretos
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: despliegue
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
set -euo pipefail
cd /opt/aurora-libros
export AURORA_API_DIGEST="${{ needs.publicar.outputs.digest }}"
docker compose -f compose.prod.yaml pull
docker compose -f compose.prod.yaml up -d --wait --wait-timeout 120
docker compose -f compose.prod.yaml ps --format '{{.Name}} {{.Status}}'Cuatro detalles que hacen que esto sea un despliegue y no una ruleta. El --wait (que ya usabas en el onboarding) espera a que los healthcheck pasen y devuelve error si no lo hacen, así que un despliegue roto pone el job en rojo en vez de dejarte un servicio caído en silencio. El set -euo pipefail corta a la primera línea que falle. El usuario despliegue no es root y solo puede tocar ese directorio. Y se despliega por digest, no por etiqueta: el up -d arranca exactamente la imagen que acaba de pasar las pruebas.
Aun así, esto es un despliegue de una sola máquina: durante unos segundos el servicio se recrea y no hay nadie sirviendo. A partir de 06-03 el paso final deja de ser un docker compose up remoto y pasa a ser una instrucción al orquestador, que sustituye las réplicas de una en una sin cortar el servicio.
Errores Comunes y Consejos
- Olvidar
--exit-code-from. El pipeline se pone verde con las pruebas en rojo, que es peor que no tener pruebas: da confianza falsa. Compruébalo a propósito rompiendo una prueba. - Reconstruir la imagen en el job de despliegue. Rompe la trazabilidad y despliega algo que nadie ha probado. Se promociona por digest.
- Construir sin caché remota. Un pipeline de cuatro minutos por cambio hace que la gente deje de hacer push pequeños, y eso empeora todo lo demás.
- Escanear por etiqueta en vez de por digest. Analizas una imagen que puede no ser la tuya. Usa siempre
imagen@sha256:.... exit-code: 1con todas las severidades. El pipeline se rompe cada mañana por una CVE baja sin parche, y la gente aprende a saltarse la puerta. Sé estricto donde importa.- Publicar desde pull requests. Cualquiera que abra un PR puede subir una imagen a tu registro: condiciona
push:al evento. Y nunca pases secretos como argumentos, que aparecen enpsdentro del runner y en muchos logs; siempre--password-stdin. - Consejo: haz el pipeline reproducible en local. Si
docker compose -f compose.pruebas.yaml upes lo mismo que ejecuta CI, depurar un fallo del pipeline no requiere veinte commits de prueba. - Consejo: usa
concurrencyconcancel-in-progress. Diez pushes seguidos no deben lanzar diez builds multiarquitectura; solo importa el último.
Ejercicios
Ejercicio 1. Demuestra el peligro de --exit-code-from: rompe deliberadamente una prueba de aurora-api y ejecuta la pila de pruebas con y sin esa bandera, comparando los códigos de salida. Explica qué habría hecho el pipeline en cada caso.
Ejercicio 2. Mide el efecto de la caché remota: ejecuta el pipeline tres veces (frío, con caché y con caché tras un cambio solo en src/) y construye la tabla de tiempos. Explica por qué el tercer caso es el más rápido.
Ejercicio 3. Verifica la cadena de suministro completa de una imagen publicada: comprueba la firma exigiendo que provenga de tu workflow, extrae el SBOM y localiza el commit exacto que la generó.
Soluciones
Solución 1.
# Se rompe una aserción a propósito en api/test/libros.test.js
docker compose -f compose.pruebas.yaml up --build --abort-on-container-exit
echo "sin --exit-code-from: $?"
docker compose -f compose.pruebas.yaml up --build --abort-on-container-exit --exit-code-from pruebas
echo "con --exit-code-from: $?"pruebas-1 | FAIL test/libros.test.js > devuelve los 9 titulos del catalogo
pruebas-1 | AssertionError: expected 9 to equal 8
pruebas-1 exited with code 1
sin --exit-code-from: 0
con --exit-code-from: 1Los dos códigos de salida, ante exactamente la misma prueba fallida, resumen el ejercicio:
| Ejecución | Código | Qué haría CI |
|---|---|---|
Sin --exit-code-from |
0 | Continúa: construye, firma y publica el código roto |
Con --exit-code-from pruebas |
1 | Se detiene en el job de pruebas; nada se publica |
Lo perverso del primer caso es que el fallo sí aparece en el log, con su AssertionError y todo, pero nadie lo lee: la marca verde dice que todo está bien. Sin la bandera, docker compose up informa de si él pudo levantar la pila —y pudo—, no de si el contenedor de pruebas terminó satisfecho.
De ahí una práctica que merece la pena adoptar: la primera vez que montas un pipeline, rómpelo a propósito. Un pipeline que nunca has visto fallar no es un pipeline verde, es un pipeline sin comprobar.
Solución 2.
gh run list --workflow ci.yaml --limit 3 \
--json displayTitle,conclusion,createdAt,updatedAt \
--jq '.[] | "\(.displayTitle): \((.updatedAt|fromdate) - (.createdAt|fromdate))s"'fix: mensaje de error mas claro (solo src/): 41s
chore: subir version de pino (package.json): 108s
ci: activar cache gha (primera ejecucion): 275s| Ejecución | Qué cambió | Tiempo del job de build | Capas reutilizadas |
|---|---|---|---|
| 1ª (fría) | Todo | 4 min 35 s | 0 |
| 2ª | package.json |
1 min 48 s | Base y sistema |
| 3ª | Solo src/ |
41 s | Base, sistema y npm ci |
El tercer caso es el más rápido por la misma razón que estudiaste en 02-02, ahora aplicada a una máquina distinta cada vez. El Dockerfile de 06-01 copia primero package.json y package-lock.json, ejecuta npm ci, y solo después copia src/. Si únicamente cambia el código fuente, la capa de npm ci sigue siendo válida y BuildKit la trae desde la caché de GitHub en lugar de reinstalar 180 paquetes.
Lo que hace posible que un runner nuevo aproveche el trabajo del anterior es cache-from: type=gha: el runner es efímero, pero la caché no. Sin ella, las tres columnas de la tabla darían 4 min 35 s.
Un matiz importante para no engañarte con el número: los 41 segundos son de dos arquitecturas. La construcción arm64 corre bajo emulación QEMU y es unas tres veces más lenta que la nativa; sin caché, ese solo hecho añadiría más de dos minutos.
Solución 3.
IMG=ghcr.io/auroralibros/aurora-api
DIG=$(docker buildx imagetools inspect $IMG:2.0.0 --format '{{.Manifest.Digest}}')
cosign verify $IMG@$DIG \
--certificate-identity-regexp '^https://github.com/auroralibros/aurora-libros/.github/workflows/ci.yaml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com | jq '.[0].optional'{ "Issuer": "https://token.actions.githubusercontent.com",
"Subject": "https://github.com/auroralibros/aurora-libros/.github/workflows/ci.yaml@refs/tags/v2.0.0",
"githubWorkflowSha": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
"Bundle": { "Payload": { "logIndex": 148392017 } } }cosign download sbom $IMG@$DIG 2>/dev/null | jq -r '.packages[] | "\(.name) \(.versionInfo)"' | head -3
docker buildx imagetools inspect $IMG@$DIG \
--format '{{index .Image.Config.Labels "org.opencontainers.image.revision"}}'
# express 4.21.2
# pg 8.13.1
# ioredis 5.4.1
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0Las comprobaciones responden a preguntas distintas, y juntas cierran el círculo:
| Pregunta | Mecanismo | Evidencia |
|---|---|---|
| ¿La ha tocado alguien? | Firma Cosign | Verificación correcta contra Rekor |
| ¿Quién la construyó? | Identidad del certificado | El workflow ci.yaml en refs/tags/v2.0.0 |
| ¿Qué lleva dentro? | SBOM | 182 paquetes con nombre y versión |
| ¿De qué código sale? | Etiqueta OCI revision |
El commit a1b2c3d… |
El dato decisivo es el Subject, y conviene detenerse en él: no dice "alguien firmó esta imagen", dice qué workflow, de qué repositorio y desde qué referencia de Git la construyó. Si un atacante consiguiera publicar una imagen en tu registro con la etiqueta 2.0.0, la firma no verificaría contra esa identidad y el despliegue debería rechazarla. Esa comprobación es la que se automatiza en el clúster con una política de admisión.
Y el par SBOM + revision es lo que convierte una alerta de seguridad en trabajo de diez minutos: cuando se publique la próxima CVE crítica de una librería, cosign download sbom te dice en segundos si tu imagen la incluye y en qué versión, y la etiqueta revision te lleva al commit exacto sobre el que aplicar la corrección.
Conclusión
El camino del commit a la imagen publicada ya no lo recorres tú. Distingues la integración continua de la entrega y del despliegue continuos —tres conceptos y dos siglas— y sabes por qué Aurora Libros hace deployment a staging y delivery a producción. Has interiorizado la regla que sostiene todo el módulo: construir una vez y desplegar muchas, porque el artefacto es la imagen y su digest es el mismo objeto en todos los entornos; un docker build por entorno es un fallo de diseño, no una comodidad.
Las pruebas corren contra un PostgreSQL y un Redis reales en una pila efímera con los datos en tmpfs, y sabes que --abort-on-container-exit sin --exit-code-from produce el peor resultado posible: un pipeline verde sobre pruebas rojas, que has provocado a propósito para verlo. El workflow completo de GitHub Actions encadena lint y pruebas, metadata-action traduciendo v2.0.0 a cuatro etiquetas más el sha- irrepetible, build-push-action construyendo para amd64 y arm64 con cache-to: type=gha,mode=max —que llevó el build de 4 min 35 s a 41 s cuando solo cambia src/—, Trivy informando de todo y rompiendo solo ante críticas con parche disponible, y la firma keyless de Cosign que no obliga a custodiar ninguna clave privada. Has verificado la cadena entera desde fuera: firma válida, Subject que nombra el workflow y la etiqueta de Git que la produjo, SBOM con las versiones de cada paquete y la etiqueta OCI revision con el commit exacto.
Conoces también el equivalente en GitLab CI con su tabla de correspondencias, el compromiso real entre Docker-in-Docker y el socket montado —donde ambas opciones significan, dicho sin rodeos, que un job puede tomar el control del runner—, y las reglas de credenciales: tokens efímeros con permisos por job, OIDC en lugar de contraseñas estáticas y nada de secretos por línea de comandos. El paso final, por ahora, es un docker compose pull && up -d --wait remoto por SSH desplegando por digest.
Y ahí está el límite. Ese despliegue tiene un hueco de segundos en el que nadie sirve, corre en una sola máquina y, si esa máquina se apaga, Aurora Libros desaparece de internet. En la siguiente lección, Orquestando Contenedores con Docker Swarm, montarás tu primer clúster: varios nodos con managers y workers, redes overlay que conectan contenedores de máquinas distintas, servicios que se reprograman solos cuando un nodo cae, y el compose.yaml que ya conoces desplegado como stack con docker stack deploy.
Docker: De Principiante a Avanzado
Módulo 1: Introducción a Docker
- ¿Qué es Docker?
- Instalando Docker
- Arquitectura de Docker
- Comandos Básicos de Docker
- Entendiendo las Imágenes de Docker
- Creando tu Primer Contenedor Docker
- El Proyecto del Curso: la Plataforma Aurora Libros
Módulo 2: Trabajando con Imágenes Docker
- Docker Hub y Repositorios
- Construyendo Imágenes Docker
- Conceptos Básicos de Dockerfile
- Instrucciones Avanzadas del Dockerfile
- Gestionando Imágenes Docker
- Etiquetado y Publicación de Imágenes
Módulo 3: Contenedores Docker
- Ejecutando Contenedores
- Ciclo de Vida del Contenedor
- Gestionando Contenedores
- Inspección y Depuración de Contenedores
- Redes en Docker
- Persistencia de Datos con Volúmenes
- Límites de Recursos y Políticas de Reinicio
Módulo 4: Docker Compose
- Introducción a Docker Compose
- Definiendo Servicios en Docker Compose
- Comandos de Docker Compose
- Aplicaciones Multi-Contenedor
- Variables de Entorno en Docker Compose
- Perfiles, Overrides y Múltiples Entornos
- Desarrollo Local con Docker Compose
Módulo 5: Conceptos Avanzados de Docker
- Profundización en Redes Docker
- Opciones de Almacenamiento Docker
- Mejores Prácticas de Seguridad en Docker
- Optimizando Imágenes Docker
- Builds Avanzadas con BuildKit y Buildx
- Registro y Monitoreo en Docker
- El Runtime por Dentro: Namespaces, Cgroups y Capas
Módulo 6: Docker en Producción
- Preparar una Imagen para Producción
- CI/CD con Docker
- Orquestando Contenedores con Docker Swarm
- Introducción a Kubernetes
- Desplegando Contenedores Docker en Kubernetes
- Escalado y Balanceo de Carga
- Estrategias de Despliegue y Rollback
