Fins ara el pipeline produeix un directori dist/ que es desa set dies i no va enlloc. En aquesta lliçó tanques el circuit: empaquetaràs Mini-Reservalia en una imatge de contenidor immutable, la publicaràs en un registre real, la desplegaràs en dos entorns amb una porta d'aprovació humana entremig, comprovaràs automàticament que el desplegament funciona, promocionaràs a producció exactament el mateix artefacte que vas validar a staging —sense reconstruir-lo— i escriuràs el botó d'emergència que et torna a la versió anterior en menys de dos minuts. I, com mana el mòdul, trencaràs alguna cosa expressament: desplegaràs una versió que falla el seu propi health check per veure la porta tancar-se i el rollback funcionar.
Tot això sense AWS i sense targeta de crèdit. El registre és ghcr.io, inclòs de franc al teu compte de GitHub; el frontend va a GitHub Pages; i l'«amfitrió de producció» és un contenidor Docker que corre al mateix runner o a la teva màquina. Allà on el Reservalia real faria servir ECS, ECR i OIDC contra AWS, hi trobaràs una nota amb l'equivalent exacte. Els conceptes —artefacte immutable, digest, idempotència, promoció, smoke test, rollback— són idèntics; només canvia on aterra el contenidor.
Contingut
- Objectiu, requisits previs i punt de partida
- L'artefacte immutable: Dockerfile multietapa
- Construir i publicar a
ghcr.iodes del pipeline - Per què el digest i no l'etiqueta
- Separar CI de CD amb
workflow_run - Environments:
stagingautomàtic iproduccioamb revisor - El script de desplegament idempotent
- Les tres maneres d'executar-lo (amb amfitrió, sense amfitrió i al runner)
- El smoke test amb reintents
- El
cd.ymlcomplet amb promoció per digest - El frontend a GitHub Pages
- El
rollback.ymli el cronòmetre - Provocar un desplegament dolent
- Verificació final
- Errors Comuns i Consells
- Exercicis
- Conclusió
- Objectiu, requisits previs i punt de partida
Objectiu. En acabar, un merge a main construirà una imatge, la publicarà a ghcr.io, la desplegarà automàticament a staging, la verificarà amb un smoke test, esperarà la teva aprovació i promocionarà el mateix digest a produccio; i tindràs un rollback.yml que reverteix en menys de dos minuts, cronometrat.
Requisits previs.
- Les lliçons 07-01 i 07-02 completades.
- Docker instal·lat a la teva màquina (
docker --version, 24 o superior) idocker compose version. Ara sí que és obligatori. - El repositori a GitHub amb
mainprotegida i la comprovacióCI OK.
Punt de partida. El repositori després de la 07-02: codi amb persistència, tres capes de proves, cobertura amb llindar, matriu i sharding.
- L'artefacte immutable: Dockerfile multietapa
La regla de la 02-06: build once, deploy many. Es construeix un artefacte una sola vegada i aquest mateix artefacte —byte a byte— recorre tots els entorns. Si cada entorn reconstrueix, cada entorn executa una cosa diferent i la validació de staging no diu res sobre producció.
Dockerfile:
# syntax=docker/dockerfile:1.7
# ---------- Etapa 1: dependencies de produccio ----------
# S aïlla perque la capa es guardi a la memoria cau mentre el lockfile no canviï.
FROM node:20-bookworm-slim AS deps
WORKDIR /app
# build-essential i python3 nomes calen SI algun modul natiu
# (better-sqlite3) no troba binari precompilat. Es queden en aquesta
# etapa i no arriben mai a la imatge final.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
# --omit=dev: res d eslint ni d eines de prova en produccio.
RUN npm ci --omit=dev
# ---------- Etapa 2: construccio ----------
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG COMMIT=desconegut
ENV GITHUB_SHA=${COMMIT}
RUN npm run build
# ---------- Etapa 3: imatge final ----------
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
# Usuari sense privilegis. La imatge oficial de Node ja porta l usuari
# `node` (uid 1000); no cal crear-lo.
ENV NODE_ENV=production \
PORT=3000 \
BASE_DADES=sqlite:/dades/mini.db
# Directori de dades amb permisos per a l usuari no-root.
RUN mkdir -p /dades && chown -R node:node /dades
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
USER node
EXPOSE 3000
VOLUME ["/dades"]
# Metadades OCI: qui ha construit aixo, des de quin commit i quan.
# Es llegeixen amb `docker inspect` i son la traçabilitat minima d un artefacte.
ARG COMMIT=desconegut
ARG DATA=desconeguda
LABEL org.opencontainers.image.source="https://github.com/OWNER/mini-reservalia" \
org.opencontainers.image.revision="${COMMIT}" \
org.opencontainers.image.created="${DATA}" \
org.opencontainers.image.title="mini-reservalia" \
org.opencontainers.image.description="Calcul de forats de cita"
# HEALTHCHECK: Docker sondeja el contenidor i en marca l estat.
# `docker inspect --format '{{.State.Health.Status}}'` retorna
# starting -> healthy | unhealthy. L script de desplegament ho fara servir.
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/salut').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENV APP_VERSION=${COMMIT}
CMD ["node", "dist/src/servidor.js"].dockerignore —tan important com el Dockerfile, perquè determina què s'envia al dimoni i què invalida la memòria cau—:
node_modules dist coverage informes .git .github .gitignore *.md *.log .env Dockerfile docker-compose*.yml test
Excloure test/ i .git no és només velocitat: és superfície. Una imatge de producció que porta a dins l'historial de Git i les proves és una imatge que filtra informació i ocupa el triple.
Construeix-la i prova-la en local:
docker build \
--build-arg COMMIT="$(git rev-parse HEAD)" \
--build-arg DATA="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t mini-reservalia:local .
docker run --rm -d --name mini-local -p 3000:3000 mini-reservalia:local
sleep 3
curl -s localhost:3000/salut
# {"estat":"ok","versio":"8f3c1e2...","actiuSeg":3}
# L estat del HEALTHCHECK, que es el que mirara el desplegament:
docker inspect --format '{{.State.Health.Status}}' mini-local
# healthy (pot trigar fins a 15 s a passar de "starting")
# Comprovacions d enduriment basic:
docker exec mini-local whoami # node (NO root)
docker image inspect mini-reservalia:local --format '{{.Size}}' | numfmt --to=iec
# ~230M
docker rm -f mini-localQuè has de veure: estat: ok, Health.Status: healthy i whoami: node. Si whoami retorna root, la línia USER node no hi és o és abans d'un COPY que l'anul·la.
- Construir i publicar a
ghcr.io des del pipeline
ghcr.io des del pipelineghcr.io és el registre de contenidors de GitHub. Per a un repositori públic és gratuït i il·limitat, i el millor: no et cal cap credencial nova. El GITHUB_TOKEN que el runner ja té serveix, sempre que li donis el permís packages: write.
Afegeix al ci.yml un job publicar que depengui de ci-ok:
publicar:
name: Publicar imatge
runs-on: ubuntu-latest
needs: [ci-ok]
# Nomes es publica des de main: un PR no ha de deixar imatges al registre.
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: read
packages: write # necessari per escriure a ghcr.io
outputs:
digest: ${{ steps.construir.outputs.digest }}
imatge: ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Preparar Buildx
uses: docker/setup-buildx-action@v3
- name: Autenticar-se a ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # token efimer de l execucio
- name: Metadades i etiquetes
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,format=long,prefix=sha-
type=raw,value=main,enable={{is_default_branch}}
- name: Construir i publicar
id: construir
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
COMMIT=${{ github.sha }}
DATA=${{ github.event.repository.updated_at }}
# Memoria cau de capes al mateix backend d Actions: redueix molt
# el temps de les construccions seguents (04-04 i 06-05).
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true # attestacio de procedencia (SLSA)
- name: Publicar el digest al resum
run: |
{
echo "## Imatge publicada"
echo ""
echo "| Camp | Valor |"
echo "|---|---|"
echo "| Repositori | \`ghcr.io/${{ github.repository }}\` |"
echo "| Digest | \`${{ steps.construir.outputs.digest }}\` |"
echo "| Commit | \`${{ github.sha }}\` |"
echo ""
echo "Referencia immutable per desplegar:"
echo '```'
echo "ghcr.io/${{ github.repository }}@${{ steps.construir.outputs.digest }}"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"Fes el merge del PR i observa. Què has de veure:
- Al resum de l'execució, la taula amb el digest:
sha256:3f9a.... - A la portada del teu perfil o del repositori, la secció Packages amb
mini-reservalia. - La imatge és privada per defecte encara que el repositori sigui públic. Ves a Package settings → Change visibility → Public si vols poder-la descarregar sense autenticar-te (útil per al laboratori). Alternativament, a Package settings → Manage Actions access, afegeix-hi el repositori amb rol
Write.
Descarrega-la des de la teva màquina per comprovar que existeix de debò:
echo "$GH_TOKEN" | docker login ghcr.io -u EL_TEU_USUARI --password-stdin
docker pull ghcr.io/EL_TEU_USUARI/mini-reservalia@sha256:3f9a...
docker run --rm -p 3000:3000 ghcr.io/EL_TEU_USUARI/mini-reservalia@sha256:3f9a...Equivalent real a Reservalia. El registre és ECR i l'autenticació no fa servir cap contrasenya sinó OIDC: el flux de treball demana un token efímer a AWS amb
aws-actions/configure-aws-credentials@v4irole-to-assume: arn:aws:iam::...:role/reservalia-ci, sense cap clau emmagatzemada a GitHub (03-02 i 04-03). Aquíghcr.ioambGITHUB_TOKENcompleix exactament el mateix principi —credencial efímera, amb abast mínim, que caduca en acabar l'execució—, i per això l'exercici no hi perd res pedagògicament. La 07-05 hi tornarà.
- Per què el digest i no l'etiqueta
Aquest apartat és curt i és el més important de la lliçó.
Una etiqueta (tag) és un punter mutable. ghcr.io/teu/mini-reservalia:main avui apunta a una imatge i demà a una altra. Un digest (sha256:...) és el hash del contingut del manifest: una referència per digest sempre resol als mateixos bytes, per sempre.
| Etiqueta | Digest | |
|---|---|---|
:main, :latest, :v1.2 |
Punter mutable | — |
@sha256:3f9a... |
— | Contingut immutable |
| Pot canviar sota els teus peus? | Sí | No |
| Serveix per promocionar? | No | Sí |
| Serveix per tornar enrere? | Només si ningú no l'ha mogut | Sempre |
| Serveix per parlar amb humans? | Sí | Regular |
L'escenari que arruïna el dia d'algú cada mes: desplegues :main a staging, ho proves, ho aproves, i quan el job de producció fa docker pull, un altre merge ja ha mogut :main. Producció executa codi que ningú no ha validat. No és una fallada teòrica; és la raó per la qual aquest pipeline passa el digest d'un job a un altre i per la qual el job de producció comprova que el digest que desplegarà és el mateix que es va validar.
Comprova-ho tu mateix:
docker buildx imagetools inspect ghcr.io/EL_TEU_USUARI/mini-reservalia:main --format '{{.Manifest.Digest}}'
# sha256:3f9a... <- ara
# fes un altre merge a main i repeteix: el digest ha canviat, l etiqueta noRegla: les etiquetes són per a les persones, els digests per a les màquines. Publica-les totes dues; desplega sempre per digest.
- Separar CI de CD amb
workflow_run
workflow_runCI i CD són dos cicles amb velocitats i permisos diferents. La CI s'executa a cada PR, no necessita credencials de desplegament i ha de ser ràpida. El CD s'executa només després d'un merge a main, necessita permisos elevats i pot trigar minuts esperant una aprovació. Ficar-los al mateix flux de treball obliga a donar a cada PR els permisos del desplegament: exactament el contrari del privilegi mínim.
flowchart LR
P["push a main"] --> CI["ci.yml<br/>qualitat, test, cobertura,<br/>build, publicar imatge"]
CI -->|workflow_run: completed + success| CD["cd.yml"]
CD --> S["desplegar staging<br/>automatic"]
S --> SM1["smoke test"]
SM1 --> G{"Environment<br/>produccio<br/>revisor requerit"}
G -->|aprovat| PR2["desplegar produccio<br/>MATEIX digest"]
PR2 --> SM2["smoke test"]
SM2 -->|falla| RB["rollback.yml"]
El disparador:
on:
workflow_run:
workflows: ['CI'] # el `name:` de l altre flux de treball, no el seu fitxer
types: [completed]
branches: [main]
workflow_dispatch: # per poder rellançar un desplegament a ma
inputs:
digest:
description: 'Digest a desplegar (sha256:...). Buit = ultim de main'
required: falseI la guarda imprescindible al primer job:
# `completed` inclou failure i cancelled. Sense aquesta condicio,
# desplegaries el resultat d una CI en vermell.
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'Dues peculiaritats de workflow_run que confonen tothom la primera vegada:
- El flux de treball ha d'existir a
mainperquè es dispari. Mentre elcd.ymlnomés sigui a la teva branca, no s'executarà mai per molt que la CI passi. Cal fer-ne el merge primer i provar-lo després. És contraintuïtiu i costa mitja tarda a qui no ho sap. - El context és el del flux de treball disparador, no el del commit.
github.shaen unworkflow_runés el SHA de la branca per defecte en el moment del dispar. Si necessites el commit exacte que s'ha construït, llegeix-lo degithub.event.workflow_run.head_sha.
- Environments:
staging automàtic i produccio amb revisor
staging automàtic i produccio amb revisorEls Environments de GitHub són la materialització de la porta entre Delivery i Deployment de la 03-01: l'artefacte està a punt, però algú decideix quan entra.
Crea'ls a Settings → Environments:
staging
- Sense regles de protecció.
- Environment URL: l'adreça del teu staging (o
http://localhost:3001). - Variable d'entorn (pestanya Variables):
PORT_APP = 3001.
produccio
- Required reviewers: afegeix-t'hi tu mateix. Aquest és el punt de l'exercici.
- Wait timer: 0 (o 1 minut si vols veure el temporitzador).
- Deployment branches:
Selected branches→main. Impedeix desplegar producció des d'una branca qualsevol. - Variable:
PORT_APP = 3002.
Amb gh:
gh api --method PUT "repos/{owner}/{repo}/environments/staging"
gh api --method PUT "repos/{owner}/{repo}/environments/produccio" \
-F "wait_timer=0" \
-F "reviewers[][type]=User" \
-F "reviewers[][id]=$(gh api user --jq .id)" \
-F "deployment_branch_policy[protected_branches]=true" \
-F "deployment_branch_policy[custom_branch_policies]=false"Un job s'associa a un entorn amb dues línies:
El que fa GitHub amb això: quan l'execució arriba a aquest job, s'atura, marca l'execució com a Waiting, envia una notificació als revisors i espera. Cap pas del job no s'executa —ni tan sols el checkout— fins que algú aprova. Els secrets i les variables de l'entorn només es materialitzen després de l'aprovació, cosa que significa que un job no aprovat no pot tocar les credencials de producció ni per accident ni expressament. Aquest és el valor de seguretat, a més del de procés.
- El script de desplegament idempotent
Aquí apliquem la regla de la 06-07: la lògica als scripts, el YAML prim. El script ha de poder executar-se des del teu portàtil, des del runner o des d'un servidor per SSH, sense canvis. Si només funciona dins de GitHub Actions, no el pots depurar i no el pots fer servir en una emergència.
scripts/desplegar.sh:
#!/usr/bin/env bash
# Desplega Mini-Reservalia com a contenidor Docker.
#
# IDEMPOTENT: executar-lo N vegades amb la mateixa imatge deixa el sistema en el
# mateix estat que executar-lo una sola vegada. Aquesta propietat es el que permet
# reintentar un desplegament fallit sense por (03-02).
#
# Us:
# ENTORN=staging PORT_AMFITRIO=3001 IMATGE=ghcr.io/x/y@sha256:... ./scripts/desplegar.sh
#
# Variables:
# IMATGE (obligatoria) referencia COMPLETA per digest
# ENTORN (per defecte: staging) sufix del nom del contenidor
# PORT_AMFITRIO (per defecte: 3001) port de l amfitrio
# RETENIR (per defecte: 3) quantes imatges antigues s han de conservar
set -Eeuo pipefail # -E: les trampes s hereten; -e: avorta en fallar;
# -u: variable no definida es error; -o pipefail: falla la canonada sencera
IMATGE="${IMATGE:?Falta IMATGE (referencia per digest)}"
ENTORN="${ENTORN:-staging}"
PORT_AMFITRIO="${PORT_AMFITRIO:-3001}"
RETENIR="${RETENIR:-3}"
CONTENIDOR="mini-reservalia-${ENTORN}"
VOLUM="mini-reservalia-dades-${ENTORN}"
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"; }
# --- 0. Validacions primerenques ----------------------------------------------
if [[ "$IMATGE" != *"@sha256:"* ]]; then
echo "ERROR: IMATGE ha de ser una referencia per DIGEST (@sha256:...), no per etiqueta." >&2
echo " Rebut: $IMATGE" >&2
echo " Una etiqueta es mutable: no garanteix que desplegues el que has validat." >&2
exit 2
fi
command -v docker >/dev/null || { echo "ERROR: docker no esta instal.lat" >&2; exit 3; }
log "Entorn: $ENTORN"
log "Contenidor: $CONTENIDOR"
log "Port: $PORT_AMFITRIO"
log "Imatge: $IMATGE"
# --- 1. Descarregar la imatge ABANS de tocar res -------------------------------
# Si el pull falla, el servei actual continua viu. No paris mai el que funciona
# abans de tenir el que l ha de substituir.
log "Descarregant la imatge..."
docker pull --quiet "$IMATGE"
DIGEST_LOCAL="$(docker image inspect "$IMATGE" --format '{{index .RepoDigests 0}}')"
log "Digest verificat: $DIGEST_LOCAL"
# --- 2. Idempotencia: si ja corre AQUESTA imatge, no facis res -----------------
if docker ps --filter "name=^${CONTENIDOR}$" --format '{{.Names}}' | grep -q .; then
ACTUAL="$(docker inspect "$CONTENIDOR" --format '{{.Image}}')"
NOVA="$(docker image inspect "$IMATGE" --format '{{.Id}}')"
if [[ "$ACTUAL" == "$NOVA" ]]; then
log "El contenidor ja executa aquesta imatge. Res a fer (idempotencia)."
docker ps --filter "name=^${CONTENIDOR}$" --format 'table {{.Names}}\t{{.Status}}'
exit 0
fi
log "La versio en execucio es diferent; se substitueix."
fi
# --- 3. Desar la versio anterior per al rollback -------------------------------
ANTERIOR=""
if docker inspect "$CONTENIDOR" >/dev/null 2>&1; then
ANTERIOR="$(docker inspect "$CONTENIDOR" --format '{{index .Config.Labels "mini.digest"}}' 2>/dev/null || true)"
fi
if [[ -n "$ANTERIOR" ]]; then
log "Versio anterior (per al rollback): $ANTERIOR"
echo "$ANTERIOR" > "/tmp/mini-reservalia-${ENTORN}.anterior"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
echo "digest_anterior=$ANTERIOR" >> "$GITHUB_OUTPUT"
fi
fi
# --- 4. Volum de dades (idempotent per definicio) ------------------------------
docker volume create "$VOLUM" >/dev/null
# --- 5. Parar i esborrar l anterior --------------------------------------------
# `|| true` perque al primer desplegament no existeix, i aixo no es un error.
log "Aturant la versio anterior (si existeix)..."
docker rm -f "$CONTENIDOR" >/dev/null 2>&1 || true
# --- 6. Arrencar la versio nova ------------------------------------------------
log "Arrencant la versio nova..."
docker run -d \
--name "$CONTENIDOR" \
--restart unless-stopped \
-p "${PORT_AMFITRIO}:3000" \
-v "${VOLUM}:/dades" \
-e "APP_VERSION=${APP_VERSION:-$IMATGE}" \
-e "ENTORN=${ENTORN}" \
--label "mini.digest=${IMATGE}" \
--label "mini.entorn=${ENTORN}" \
--label "mini.desplegat=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--health-cmd "node -e \"fetch('http://127.0.0.1:3000/salut').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
--health-interval 5s --health-retries 6 --health-start-period 3s \
"$IMATGE" >/dev/null
# --- 7. Esperar que el HEALTHCHECK passi a healthy -----------------------------
log "Esperant que el contenidor sigui healthy..."
for i in $(seq 1 24); do
ESTAT="$(docker inspect "$CONTENIDOR" --format '{{.State.Health.Status}}' 2>/dev/null || echo 'sense-dades')"
case "$ESTAT" in
healthy) log "Healthy despres de ${i} sondejos."; break ;;
unhealthy) log "ERROR: el contenidor esta unhealthy."; docker logs --tail 50 "$CONTENIDOR"; exit 4 ;;
*) sleep 5 ;;
esac
if [[ "$i" -eq 24 ]]; then
log "ERROR: no ha arribat a healthy en 120 s (estat: $ESTAT)."
docker logs --tail 50 "$CONTENIDOR"
exit 5
fi
done
# --- 8. Neteja: conservar les N ultimes imatges --------------------------------
# Sense aixo, el disc de l amfitrio s omple en unes setmanes. Es la causa numero
# u de "el desplegament ha fallat i no sabem per que" en amfitrions petits.
log "Netejant imatges antigues (conservant-ne $RETENIR)..."
docker image prune -f --filter "until=168h" >/dev/null 2>&1 || true
log "Desplegament completat: $CONTENIDOR al port $PORT_AMFITRIO"
docker ps --filter "name=^${CONTENIDOR}$" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'Les quatre propietats que fan que aquest script sigui utilitzable en producció:
| Propietat | Com s'aconsegueix | Què passa sense ella |
|---|---|---|
| Idempotent | Comprova si ja corre aquesta imatge i surt (pas 2) | Reintentar un desplegament reinicia el servei sense necessitat |
| Falla aviat | pull abans de parar res (pas 1) |
Pares el que funciona i descobreixes que la imatge nova no existeix |
| Verificable | Espera a healthy amb límit de temps (pas 7) |
El script acaba «bé» amb un contenidor que ni tan sols arrenca |
| Rastrejable | Desa el digest anterior en una etiqueta (passos 3 i 6) | No saps a què tornar en un rollback |
Aquest últim punt mereix èmfasi: l'etiqueta mini.digest converteix el mateix contenidor en el registre del que hi ha desplegat. docker inspect mini-reservalia-produccio --format '{{index .Config.Labels "mini.digest"}}' respon la pregunta més urgent d'un incident: què dimonis estem executant?
- Les tres maneres d'executar-lo (amb amfitrió, sense amfitrió i al runner)
El mateix script, tres destinacions. Tria la que puguis.
Opció A (principal, sense cost): la destinació simulada dins del runner
El job de desplegament executa el script contra el Docker del mateix runner. El «servidor» viu els tres minuts del job i es destrueix després. És artificial —no hi ha persistència entre desplegaments— però exercita el 100 % del camí: pull per digest, arrencada, health check, smoke test, rollback. És l'opció per defecte d'aquest laboratori.
- name: Desplegar
run: ./scripts/desplegar.sh
env:
IMATGE: ${{ needs.preparar.outputs.imatge }}
ENTORN: staging
PORT_AMFITRIO: '3001'Opció B: la teva màquina, amb un runner autoallotjat
Si vols persistència real entre desplegaments i veure el contenidor viu al teu portàtil, registra un runner autoallotjat:
mkdir ~/runner && cd ~/runner
# Copia les ordres exactes de Settings > Actions > Runners > New self-hosted runner
./config.sh --url https://github.com/EL_TEU_USUARI/mini-reservalia --token XXXX --labels casa
./run.shI canvia runs-on: ubuntu-latest per runs-on: [self-hosted, casa]. Ara http://localhost:3001 i http://localhost:3002 són els teus dos entorns de debò, sobreviuen als desplegaments i els pots veure amb docker ps.
Avís de seguretat, no opcional. No posis un runner autoallotjat en un repositori públic: qualsevol que obri un PR podria executar codi arbitrari a la teva màquina. La 06-06 ho explicava i la 07-05 hi tornarà. Si vas per l'opció B, fes el repositori privat o fes servir un contenidor exhaurible com a runner.
Opció C: un amfitrió real per SSH
Si tens una màquina accessible (una VM antiga, una Raspberry Pi, un VPS d'un euro), aquest és el camí més semblant a producció:
- name: Desplegar per SSH
env:
IMATGE: ${{ needs.preparar.outputs.imatge }}
run: |
install -m 600 /dev/null clau
echo "${{ secrets.SSH_CLAU_PRIVADA }}" > clau
# StrictHostKeyChecking=accept-new: confia la primera vegada i despres
# detecta canvis d amfitrio. NO facis servir MAI `no`: desactiva la
# proteccio contra suplantacio per sempre.
scp -i clau -o StrictHostKeyChecking=accept-new \
scripts/desplegar.sh "${{ secrets.SSH_USUARI }}@${{ secrets.SSH_HOST }}:/tmp/"
ssh -i clau -o StrictHostKeyChecking=accept-new \
"${{ secrets.SSH_USUARI }}@${{ secrets.SSH_HOST }}" \
"IMATGE='$IMATGE' ENTORN=produccio PORT_AMFITRIO=3002 bash /tmp/desplegar.sh"
shred -u clauAmb docker-compose.yml a l'amfitrió per tenir també el reverse proxy:
# docker-compose.yml - l "amfitrio de produccio" simulat a la teva maquina
services:
api-staging:
image: ${IMATGE_STAGING:-ghcr.io/OWNER/mini-reservalia:main}
container_name: mini-reservalia-staging
restart: unless-stopped
ports: ['3001:3000']
environment:
ENTORN: staging
BASE_DADES: sqlite:/dades/mini.db
volumes: ['dades-staging:/dades']
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/salut').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
api-produccio:
image: ${IMATGE_PRODUCCIO:-ghcr.io/OWNER/mini-reservalia:main}
container_name: mini-reservalia-produccio
restart: unless-stopped
ports: ['3002:3000']
environment:
ENTORN: produccio
BASE_DADES: sqlite:/dades/mini.db
volumes: ['dades-produccio:/dades']
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/salut').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
volumes:
dades-staging:
dades-produccio:Equivalent real a Reservalia. El desplegament és
aws ecs update-serviceamb una definició de tasca nova que apunta al digest, i ECS fa un rolling update respectant el health check del target group de l'ALB (03-02 i 03-04). El scriptdesplegar.shde Reservalia fa el mateix que el teu —comprovar la idempotència, aplicar, esperar l'estabilització, informar— ambaws ecs wait services-stableen lloc del bucle dedocker inspect. La forma del script és idèntica; canvia la primitiva.
- El smoke test amb reintents
Un desplegament que acaba no és un desplegament que funciona. El smoke test és la diferència.
scripts/smoke.sh:
#!/usr/bin/env bash
# Smoke test posterior al desplegament.
# No prova funcionalitat exhaustiva (per a aixo hi ha la suite): comprova que
# el sistema DESPLEGAT respon i fa la seva funcio principal.
#
# Us: BASE=http://localhost:3001 ./scripts/smoke.sh [digest_esperat]
set -Eeuo pipefail
BASE="${BASE:?Falta BASE (p. ex. http://localhost:3001)}"
ESPERAT="${1:-}"
INTENTS="${INTENTS:-20}"
ESPERA="${ESPERA:-3}"
log() { printf '[smoke] %s\n' "$*"; }
fallada() { echo "[smoke] FALLADA: $*" >&2; exit 1; }
# --- 1. Espera d estabilitzacio ------------------------------------------------
# El servei pot trigar a acceptar connexions. Reintentar NO es tapar un
# problema: es reconeixer que l arrencada no es instantania. El que si que seria
# tapar el problema es reintentar SENSE limit o ignorar el resultat final.
log "Esperant que $BASE respongui (max $((INTENTS * ESPERA))s)..."
for i in $(seq 1 "$INTENTS"); do
if curl -fsS --max-time 5 "$BASE/salut" >/dev/null 2>&1; then
log "Respon despres de $((i * ESPERA))s aprox."
break
fi
[[ "$i" -eq "$INTENTS" ]] && fallada "no ha respost a /salut en $((INTENTS * ESPERA))s"
sleep "$ESPERA"
done
# --- 2. /salut retorna estat ok ------------------------------------------------
SALUT="$(curl -fsS --max-time 5 "$BASE/salut")"
log "/salut -> $SALUT"
echo "$SALUT" | grep -q '"estat":"ok"' || fallada "/salut no retorna estat ok"
# --- 3. La versio desplegada es l esperada -------------------------------------
# Aquesta comprovacio es la que caça la fallada mes silenciosa de totes:
# el desplegament "ha funcionat" pero el trafic continua anant a la versio antiga.
if [[ -n "$ESPERAT" ]]; then
VERSIO="$(echo "$SALUT" | sed -n 's/.*"versio":"\([^"]*\)".*/\1/p')"
if [[ "$VERSIO" != *"$ESPERAT"* ]]; then
fallada "versio desplegada '$VERSIO' != esperada '$ESPERAT'"
fi
log "Versio verificada: $VERSIO"
fi
# --- 4. La funcionalitat principal respon --------------------------------------
RESPOSTA="$(curl -fsS --max-time 10 "$BASE/api/forats?data=2026-03-02&duracio=60")"
echo "$RESPOSTA" | grep -q '"forats"' || fallada "/api/forats no retorna forats: $RESPOSTA"
TOTAL="$(echo "$RESPOSTA" | sed -n 's/.*"total":\([0-9]*\).*/\1/p')"
[[ "${TOTAL:-0}" -gt 0 ]] || fallada "/api/forats retorna 0 forats en un dia que n hauria de tenir"
log "/api/forats -> $TOTAL forats"
# --- 5. Els errors continuen sent errors ---------------------------------------
# Una API que respon 200 a tot tambe "passa" un smoke test ingenu.
CODI="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$BASE/api/forats")"
[[ "$CODI" == "400" ]] || fallada "una peticio sense data hauria de donar 400, ha donat $CODI"
log "Validacio d errors OK (400 sense data)"
# --- 6. Latencia raonable ------------------------------------------------------
MS="$(curl -s -o /dev/null -w '%{time_total}' --max-time 10 "$BASE/api/forats?data=2026-03-02" \
| awk '{printf "%d", $1 * 1000}')"
log "Latencia de /api/forats: ${MS} ms"
[[ "$MS" -lt 2000 ]] || fallada "latencia de ${MS} ms, per sobre del llindar de 2000 ms"
log "TOTES LES COMPROVACIONS OK"El pas 5 és el que separa un smoke test útil d'un de decoratiu: comprova que una cosa que ha de fallar, falla. Un servidor mal configurat que retorna 200 amb una pàgina d'error per a qualsevol ruta passaria els passos 1 a 4 sense despentinar-se.
- El
cd.yml complet amb promoció per digest
cd.yml complet amb promoció per digest# .github/workflows/cd.yml
name: CD
on:
workflow_run:
workflows: ['CI']
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
digest:
description: 'Digest a desplegar (sha256:...). Buit = ultim publicat a main'
required: false
type: string
# No cancel.lis MAI un desplegament a mitges: pot deixar el sistema inconsistent.
# S encuen, no es cancel.len. Diferencia clau respecte al `concurrency` de la CI.
concurrency:
group: cd-mini-reservalia
cancel-in-progress: false
permissions:
contents: read
env:
REGISTRE: ghcr.io
IMATGE_BASE: ghcr.io/${{ github.repository }}
jobs:
# ---------------------------------------------------------------
# 1. Resoldre QUE es desplegara. Una sola vegada, per a tothom.
# ---------------------------------------------------------------
preparar:
name: Resoldre artefacte
runs-on: ubuntu-latest
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
permissions:
contents: read
packages: read
outputs:
digest: ${{ steps.resoldre.outputs.digest }}
imatge: ${{ steps.resoldre.outputs.imatge }}
commit: ${{ steps.resoldre.outputs.commit }}
steps:
- name: Autenticar-se al registre
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resoldre el digest a desplegar
id: resoldre
run: |
set -Eeuo pipefail
ENTRADA="${{ inputs.digest }}"
if [[ -n "$ENTRADA" ]]; then
DIGEST="$ENTRADA"
echo "Digest indicat a ma: $DIGEST"
else
# Resolem l etiqueta mobil :main al seu digest UNA sola vegada.
# A partir d aqui, tot el pipeline fa servir el digest fix.
DIGEST=$(docker buildx imagetools inspect "${{ env.IMATGE_BASE }}:main" \
--format '{{.Manifest.Digest}}')
echo "Digest resolt des de :main -> $DIGEST"
fi
IMATGE="${{ env.IMATGE_BASE }}@${DIGEST}"
COMMIT=$(docker buildx imagetools inspect "$IMATGE" --format \
'{{json .Image}}' | grep -o '"org.opencontainers.image.revision":"[^"]*"' \
| cut -d'"' -f4 || echo "${{ github.sha }}")
{
echo "digest=$DIGEST"
echo "imatge=$IMATGE"
echo "commit=$COMMIT"
} >> "$GITHUB_OUTPUT"
{
echo "## Artefacte a desplegar"
echo ""
echo "| Camp | Valor |"
echo "|---|---|"
echo "| Imatge | \`$IMATGE\` |"
echo "| Commit | \`$COMMIT\` |"
echo "| Origen | ${{ github.event_name }} |"
} >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------
# 2. Staging: automatic, sense aprovacio.
# ---------------------------------------------------------------
staging:
name: Desplegar a staging
runs-on: ubuntu-latest
needs: [preparar]
timeout-minutes: 15
environment:
name: staging
url: http://localhost:3001
permissions:
contents: read
packages: read
outputs:
digest_validat: ${{ steps.marcar.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Desplegar
id: desplegar
run: ./scripts/desplegar.sh
env:
IMATGE: ${{ needs.preparar.outputs.imatge }}
ENTORN: staging
PORT_AMFITRIO: ${{ vars.PORT_APP || '3001' }}
APP_VERSION: ${{ needs.preparar.outputs.commit }}
- name: Smoke test
run: ./scripts/smoke.sh "${{ needs.preparar.outputs.commit }}"
env:
BASE: http://localhost:${{ vars.PORT_APP || '3001' }}
- name: Marcar el digest com a validat
id: marcar
run: |
echo "digest=${{ needs.preparar.outputs.digest }}" >> "$GITHUB_OUTPUT"
echo "### ✅ Staging validat: \`${{ needs.preparar.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Diagnostic si alguna cosa falla
if: failure()
run: |
echo "::group::Contenidors"
docker ps -a
echo "::endgroup::"
echo "::group::Logs"
docker logs --tail 100 mini-reservalia-staging || true
echo "::endgroup::"
# ---------------------------------------------------------------
# 3. Produccio: MATEIX digest, amb aprovacio humana.
# ---------------------------------------------------------------
produccio:
name: Desplegar a produccio
runs-on: ubuntu-latest
needs: [preparar, staging]
timeout-minutes: 30
environment:
name: produccio # <- aqui s atura esperant l aprovacio
url: http://localhost:3002
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# LA COMPROVACIO CLAU DE LA PROMOCIO.
# Produccio desplega EXACTAMENT el que staging ha validat. Si per
# qualsevol motiu el digest no coincideix, avortem: mes val no desplegar
# que desplegar una cosa diferent de la validada.
- name: Verificar que es el MATEIX artefacte validat a staging
run: |
set -Eeuo pipefail
VALIDAT="${{ needs.staging.outputs.digest_validat }}"
A_DESPLEGAR="${{ needs.preparar.outputs.digest }}"
echo "Validat a staging: $VALIDAT"
echo "A desplegar a prod: $A_DESPLEGAR"
if [[ "$VALIDAT" != "$A_DESPLEGAR" ]]; then
echo "::error::El digest de produccio NO coincideix amb el validat a staging."
exit 1
fi
echo "✅ Mateix artefacte. No es reconstrueix res." >> "$GITHUB_STEP_SUMMARY"
- name: Desplegar
id: desplegar
run: ./scripts/desplegar.sh
env:
IMATGE: ${{ needs.preparar.outputs.imatge }}
ENTORN: produccio
PORT_AMFITRIO: ${{ vars.PORT_APP || '3002' }}
APP_VERSION: ${{ needs.preparar.outputs.commit }}
- name: Smoke test de produccio
run: ./scripts/smoke.sh "${{ needs.preparar.outputs.commit }}"
env:
BASE: http://localhost:${{ vars.PORT_APP || '3002' }}
- name: Registrar el desplegament
run: |
{
echo "## 🚀 Desplegat a produccio"
echo ""
echo "| Camp | Valor |"
echo "|---|---|"
echo "| Digest | \`${{ needs.preparar.outputs.digest }}\` |"
echo "| Commit | \`${{ needs.preparar.outputs.commit }}\` |"
echo "| Aprovat per | @${{ github.actor }} |"
echo "| Hora (UTC) | $(date -u +%Y-%m-%dT%H:%M:%SZ) |"
echo ""
echo "**Digest anterior (per al rollback):** \`${{ steps.desplegar.outputs.digest_anterior || 'cap' }}\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Diagnostic si alguna cosa falla
if: failure()
run: |
docker ps -a
docker logs --tail 100 mini-reservalia-produccio || true
echo "::error::Desplegament de produccio fallit. Llanca el flux de treball Rollback amb el digest anterior."Fes el PR, fes-ne el merge i observa la seqüència completa:
- El
ci.ymls'executa i publica la imatge. - Uns segons després arrenca el
cd.ymltot sol (fixa't que l'execució diu «triggered by CI»). Resoldre artefacteimprimeix el digest.Desplegar a stagings'executa i el smoke test passa.- L'execució s'atura. El job
Desplegar a produccioapareix amb un avís groc: «Deployment protection rules — Review required» i un botó Review deployments. - Prem el botó, marca
produccio, escriu un comentari i prem Approve and deploy. - El job arrenca, verifica el digest, desplega i publica el resum.
Aquell moment en què el pipeline t'espera és la porta de la 03-01 feta realitat. Val la pena mirar-lo un segon abans d'aprovar.
- El frontend a GitHub Pages
Mini-Reservalia també té web. Crea web/index.html:
<!doctype html>
<html lang="ca">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mini-Reservalia</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
.forat { display: inline-block; padding: .4rem .8rem; margin: .2rem; border: 1px solid #888; border-radius: .4rem; }
#meta { color: #666; font-size: .85rem; margin-top: 2rem; }
</style>
</head>
<body>
<h1>Mini-Reservalia</h1>
<label>Data <input type="date" id="data" value="2026-03-02"></label>
<label>Durada <input type="number" id="duracio" value="60" min="15" step="15"></label>
<button id="cercar">Cercar forats</button>
<div id="resultat"></div>
<div id="meta"></div>
<script src="app.js"></script>
</body>
</html>web/app.js — amb la configuració en temps d'execució, no incrustada al build (05-01):
// web/app.js
// La configuracio es llegeix de config.json EN TEMPS D EXECUCIO.
// Aixi el MATEIX artefacte estatic serveix per a staging i per a produccio:
// l unica cosa que canvia es el config.json que escriu el desplegament.
// Si la URL de l API estigues incrustada al build, cada entorn
// necessitaria el seu propi build i es trenca "build once, deploy many".
let CONFIG = { apiBase: 'http://localhost:3001', entorn: 'desconegut', versio: 'dev' };
async function carregarConfig() {
try {
CONFIG = { ...CONFIG, ...(await (await fetch('config.json', { cache: 'no-store' })).json()) };
} catch {
console.warn('config.json no disponible; es fa servir la configuracio per defecte');
}
document.getElementById('meta').textContent =
`Entorn: ${CONFIG.entorn} · Versio: ${CONFIG.versio} · API: ${CONFIG.apiBase}`;
}
async function cercar() {
const data = document.getElementById('data').value;
const duracio = document.getElementById('duracio').value;
const sortida = document.getElementById('resultat');
sortida.textContent = 'Cercant...';
try {
const resposta = await fetch(`${CONFIG.apiBase}/api/forats?data=${data}&duracio=${duracio}`);
const dades = await resposta.json();
if (!resposta.ok) throw new Error(dades.error ?? 'error desconegut');
sortida.innerHTML = dades.total === 0
? '<p>No queden forats aquell dia.</p>'
: `<p>${dades.total} forats:</p>` +
dades.forats.map((f) => `<span class="forat">${f.inici}–${f.fi}</span>`).join('');
} catch (error) {
sortida.textContent = `Error: ${error.message}`;
}
}
document.getElementById('cercar').addEventListener('click', cercar);
carregarConfig();Job de desplegament de Pages, al cd.yml:
web:
name: Desplegar web (Pages)
runs-on: ubuntu-latest
needs: [preparar, staging]
permissions:
contents: read
pages: write # publicar a Pages
id-token: write # OIDC cap al servei de Pages
environment:
name: github-pages
url: ${{ steps.publicar.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- name: Generar el config.json de l entorn
run: |
cat > web/config.json <<JSON
{
"apiBase": "${{ vars.API_BASE || 'http://localhost:3002' }}",
"entorn": "produccio",
"versio": "${{ needs.preparar.outputs.commit }}",
"digest": "${{ needs.preparar.outputs.digest }}"
}
JSON
cat web/config.json
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: web/
- id: publicar
uses: actions/deploy-pages@v4Activa Pages a Settings → Pages → Source: GitHub Actions. Què has de veure: després del desplegament, https://EL_TEU_USUARI.github.io/mini-reservalia/ amb el formulari i, al peu, la línia Entorn: produccio · Versio: 8f3c1e2 · API: ....
Un detall honest del laboratori: la web pública no podrà cridar la teva API a localhost (i si ho fes, el navegador bloquejaria la petició per CORS). I està bé: el que il·lustra aquest job és el desplegament atòmic d'un artefacte estàtic amb configuració en temps d'execució, que és la part traslladable. A Reservalia, aquest mateix patró puja el dist/ de Vite a S3 i fa una invalidació de CloudFront, amb el mateix config.json generat al desplegament (05-01).
- El
rollback.yml i el cronòmetre
rollback.yml i el cronòmetreTornar enrere no ha de requerir pensar. Un rollback que exigeix recordar ordres és un rollback que no es farà servir a les tres de la matinada.
# .github/workflows/rollback.yml
name: Rollback
on:
workflow_dispatch:
inputs:
entorn:
description: 'Entorn a revertir'
required: true
default: 'produccio'
type: choice
options: [staging, produccio]
digest:
description: 'Digest al qual tornar (sha256:...)'
required: true
type: string
motiu:
description: 'Motiu (queda registrat al resum)'
required: true
type: string
concurrency:
group: cd-mini-reservalia # MATEIX grup que el cd.yml: un rollback i un
cancel-in-progress: false # desplegament no s han de solapar mai
permissions:
contents: read
jobs:
rollback:
name: Revertir ${{ inputs.entorn }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
packages: read
# Compte: perque un rollback sigui RAPID, l entorn de rollback NO ha de
# tenir revisor requerit, o tornaras a esperar una aprovacio en plena
# incidencia. Fem servir un entorn diferent, sense porta.
environment:
name: ${{ inputs.entorn }}-rollback
steps:
- name: Cronometre - inici
id: inici
run: echo "t=$(date +%s)" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Validar el digest rebut
run: |
set -Eeuo pipefail
DIGEST="${{ inputs.digest }}"
[[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || {
echo "::error::'$DIGEST' no es un digest valid (sha256: + 64 hex)"; exit 2; }
# Comprovar que existeix ABANS de tocar produccio.
docker buildx imagetools inspect "ghcr.io/${{ github.repository }}@${DIGEST}" >/dev/null
echo "Digest verificat i disponible al registre."
- name: Executar el rollback
run: ./scripts/desplegar.sh
env:
IMATGE: ghcr.io/${{ github.repository }}@${{ inputs.digest }}
ENTORN: ${{ inputs.entorn }}
PORT_AMFITRIO: ${{ inputs.entorn == 'produccio' && '3002' || '3001' }}
- name: Verificar amb el smoke test
run: ./scripts/smoke.sh
env:
BASE: http://localhost:${{ inputs.entorn == 'produccio' && '3002' || '3001' }}
- name: Cronometre - fi i registre
if: always()
run: |
SEGONS=$(( $(date +%s) - ${{ steps.inici.outputs.t }} ))
{
echo "## ⏪ Rollback de ${{ inputs.entorn }}"
echo ""
echo "| Camp | Valor |"
echo "|---|---|"
echo "| Digest restaurat | \`${{ inputs.digest }}\` |"
echo "| Motiu | ${{ inputs.motiu }} |"
echo "| Executat per | @${{ github.actor }} |"
echo "| **Durada** | **${SEGONS} s** |"
echo "| Resultat | ${{ job.status }} |"
echo ""
echo "> Temps de restauracio del servei (metrica DORA #4)."
} >> "$GITHUB_STEP_SUMMARY"
echo "Rollback completat en ${SEGONS} s"Executa'l:
# Esbrina el digest anterior (el penultim publicat)
gh api "/user/packages/container/mini-reservalia/versions" \
--jq '.[1] | "\(.name) \(.created_at)"'
gh workflow run rollback.yml \
-f entorn=produccio \
-f digest=sha256:ANTERIOR... \
-f motiu="Prova cronometrada del procediment de rollback"
gh run watchQuè has de veure: el resum amb la durada. En aquest laboratori el rollback triga 60-110 segons, la major part al docker pull. Reservalia triga 4 minuts perquè ECS fa un rolling update amb drenatge de connexions. Tots dos estan molt per sota de l'objectiu de 10 minuts, i tots dos estan mesurats, que és el que importa: un procediment de rollback que ningú no ha cronometrat és una suposició.
- Provocar un desplegament dolent
Ara la part divertida: comprovar que la porta tanca.
Edita src/servidor.js i sabota /salut:
if (req.method === 'GET' && url.pathname === '/salut') {
+ // FALLADA DELIBERADA: simula una dependencia critica caiguda
+ if (process.env.ENTORN) {
+ return respondreJson(res, 503, { estat: 'degradat', error: 'base de dades no disponible' });
+ }
return respondreJson(res, 200, {La condició process.env.ENTORN fa que les proves locals i la CI continuïn en verd (no defineixen ENTORN) però que el contenidor desplegat falli. És una simulació bastant fidel de la categoria de bug més perillosa: el que només apareix amb la configuració d'un entorn real.
npm test # verd: les proves NO ho detecten
git commit -am "fix: comprovacio addicional a /salut"
git push -u origin trencar-salut
gh pr create --fill && gh pr merge --squash --delete-branch --autoQuè has de veure, en ordre:
- CI en verd. Les 38 proves passen. La imatge es publica. Aquest és el punt: la CI no ho detecta.
- El
cd.ymlarrenca.Resoldre artefacteOK. Desplegar a stagingfalla. I falla en dos llocs, cosa que és interessant:- Primer, dins de
desplegar.sh, el pas 7: elHEALTHCHECKdel contenidor no arriba mai ahealthy.[10:42:31] Esperant que el contenidor sigui healthy... [10:44:31] ERROR: no ha arribat a healthy en 120 s (estat: unhealthy) - Si el health check fos més laxe, ho caçaria l'
smoke.sh:[smoke] FALLADA: /salut no retorna estat ok
- Primer, dins de
- El pas de diagnòstic bolca els logs del contenidor, gràcies a l'
if: failure(). Desplegar a producciono s'executa. Ni tan sols demana aprovació: el seuneeds: [staging]no s'ha complert. Producció no veu mai aquest codi.
Aquesta és exactament la promesa del desplegament continu ben muntat: un bug que la CI no detecta s'atura al primer entorn real, automàticament, sense que ningú hagi de mirar res.
Ara simula que hagués arribat a producció. Força el desplegament de la versió dolenta a producció i executa el rollback cronometrant-lo:
# 1. Desplegar a ma la versio dolenta a produccio
gh workflow run cd.yml -f digest=sha256:DOLENT...
# aprova-ho al web quan ho demani; el smoke test de produccio fallara
# 2. Rollback, amb el cronometre en marxa
date +%s
gh workflow run rollback.yml \
-f entorn=produccio \
-f digest=sha256:BO... \
-f motiu="Incident: /salut retorna 503 despres del desplegament de sha256:DOLENT"
gh run watch
date +%sApunta els tres números: temps de detecció (quant ha trigat el smoke test a fallar), temps de decisió (quant has trigat a trobar el digest bo) i temps d'execució (el que diu el resum del rollback). En un incident real, el segon acostuma a ser el més gran dels tres, i és el que es redueix anotant el digest anterior al resum de cada desplegament, que és exactament el que fa el nostre cd.yml.
Reverteix el sabotatge:
git checkout main && git pull
git checkout -b arreglar-salut
git revert --no-edit <sha-del-commit-dolent>
git push -u origin arreglar-salut
gh pr create --fill && gh pr merge --squash --delete-branch --auto
- Verificació final
| # | Comprovació | Com | Esperat |
|---|---|---|---|
| 1 | La imatge no corre com a root | docker run --rm IMATGE whoami |
node |
| 2 | El HEALTHCHECK funciona |
docker inspect --format '{{.State.Health.Status}}' |
healthy |
| 3 | La imatge és a ghcr.io |
Pestanya Packages | mini-reservalia amb versions |
| 4 | El desplegament és idempotent | Executar desplegar.sh dues vegades |
La 2a diu «Res a fer» |
| 5 | El script rebutja una etiqueta | IMATGE=ghcr.io/x/y:main ./scripts/desplegar.sh |
Sortida 2, missatge sobre el digest |
| 6 | El CD es dispara sol després de la CI | Merge a main |
Execució de CD «triggered by CI» |
| 7 | Producció espera aprovació | Mirar l'execució | Review required + botó |
| 8 | Es promociona el mateix digest | Log del pas de verificació | Els dos digests idèntics |
| 9 | No es reconstrueix per a producció | Log del job | No hi ha cap docker build |
| 10 | El smoke test detecta la fallada | Sabotejar /salut |
Staging en vermell, producció sense executar |
| 11 | El rollback està cronometrat | Resum de l'execució | Durada en segons, < 10 min |
| 12 | El digest anterior queda registrat | Resum de cada desplegament | Línia «Digest anterior» |
Errors Comuns i Consells
Símptoma: denied: installation not allowed to Create organization package en fer push a ghcr.io.
Causa: falta permissions: packages: write al job, o el repositori té restringit el permís per defecte del GITHUB_TOKEN a només lectura.
Arranjament: afegeix el bloc permissions al job (no n'hi ha prou de tenir-lo a nivell de flux de treball si el job el sobreescriu) i revisa Settings → Actions → General → Workflow permissions.
Símptoma: Error response from daemon: unauthorized en fer docker pull de la teva pròpia imatge des d'un altre lloc.
Causa: el paquet és privat per defecte, encara que el repositori sigui públic.
Arranjament: Packages → mini-reservalia → Package settings → Change visibility → Public, o Manage Actions access per donar accés al repositori.
Símptoma: el cd.yml no es dispara mai, encara que la CI acabi en verd.
Causes possibles, per ordre de freqüència: (1) el fitxer encara no és a main —workflow_run només es dispara amb la versió de la branca per defecte—; (2) el workflows: ['CI'] no coincideix amb el name: de l'altre flux de treball (distingeix majúscules); (3) la CI ha acabat amb conclusion: failure i la guarda de l'if ho ha bloquejat correctament.
Arranjament: fes primer el merge, comprova el name: exacte i mira el conclusion a l'API: gh run list --workflow=ci.yml --json conclusion,name.
Símptoma: desplegar.sh falla amb unbound variable a la línia de GITHUB_OUTPUT.
Causa: set -u amb una variable no definida en executar el script fora d'Actions.
Arranjament: ja està previst amb ${GITHUB_OUTPUT:-}. Si escrius els teus propis scripts, l'expansió amb valor per defecte és obligatòria en tot el que vingui de l'entorn.
Símptoma: el contenidor arrenca i mor immediatament, sense logs útils.
Causa habitual: permisos del volum. L'usuari node (uid 1000) no pot escriure a /dades si el volum es va crear amb propietari root.
Arranjament: el RUN mkdir -p /dades && chown -R node:node /dades del Dockerfile ho resol per a volums nous. Si el volum ja existia amb altres permisos: docker volume rm mini-reservalia-dades-staging i torna a desplegar. Diagnòstic: docker logs mini-reservalia-staging (per això el pas if: failure() els bolca).
Símptoma: el smoke test passa però està provant la versió antiga.
Causa: el contenidor nou no ha arrencat i el reverse proxy continua encaminant al vell, o el port respon una altra cosa.
Arranjament: per això smoke.sh compara la versió retornada per /salut amb l'esperada. Sense aquesta comprovació, un desplegament que no ha fet res passa el smoke test perfectament. És la fallada més silenciosa de totes.
Símptoma: el disc de l'amfitrió s'omple al cap d'unes setmanes.
Causa: cada desplegament deixa una imatge sense fer servir de ~230 MB.
Arranjament: el docker image prune del pas 8. En un amfitrió real, a més, una tasca setmanal de docker system prune -af --filter "until=720h" i una alerta d'espai de disc. La 07-04 posarà aquesta alerta.
Consell — mai latest. docker pull imatge:latest és la manera més eficient de no saber què estàs executant. El nostre script ho rebutja explícitament. Que les teves eines impedeixin l'error és molt millor que documentar que no s'ha de cometre.
Consell — el concurrency del CD és diferent del de la CI. A la CI, cancel-in-progress: true (ningú no vol el resultat d'un commit superat). Al CD, false (cancel·lar un desplegament a mitges deixa el sistema en un estat que ningú no ha dissenyat). I el rollback.yml comparteix grup amb el cd.yml perquè no es trepitgin.
Exercicis
Exercici 1: rollback sense buscar el digest a mà
En un incident, trobar el digest anterior és el pas lent. Fes que el rollback.yml accepti l'input digest buit i, en aquest cas, resolgui automàticament la versió immediatament anterior a la desplegada.
Exercici 2: desplegament canary per percentatge
Implementa un desplegament canary: aixeca la versió nova al costat de l'antiga i envia només el 10 % del trànsit a la nova durant 2 minuts; si el smoke test estès passa, promociona-la al 100 %; si no, retira la canary. Fes servir un contenidor Nginx com a balancejador.
Exercici 3: bloquejar el desplegament fora de l'horari laboral
Afegeix una regla que impedeixi desplegar a producció els divendres a la tarda i els caps de setmana, amb una via d'escapament explícita per a emergències que quedi registrada.
Solucions
Solució 1.
- name: Resoldre el digest anterior si no s ha indicat
id: resoldre
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -Eeuo pipefail
DIGEST="${{ inputs.digest }}"
if [[ -z "$DIGEST" ]]; then
echo "Sense digest indicat: es busca la versio anterior a la desplegada."
# 1. Que corre ARA (l etiqueta que hi ha posat desplegar.sh)
ACTUAL=$(docker inspect "mini-reservalia-${{ inputs.entorn }}" \
--format '{{index .Config.Labels "mini.digest"}}' 2>/dev/null \
| sed 's/.*@//' || echo "")
echo "Desplegat actualment: ${ACTUAL:-(desconegut)}"
# 2. Les versions del paquet, les mes recents primer
mapfile -t VERSIONS < <(
gh api "/user/packages/container/mini-reservalia/versions" \
--jq '.[] | select(.metadata.container.tags | length > 0 or true) | .name' \
| head -20
)
# 3. La primera que NO sigui l actual
for v in "${VERSIONS[@]}"; do
if [[ "$v" != "$ACTUAL" ]]; then DIGEST="$v"; break; fi
done
[[ -n "$DIGEST" ]] || { echo "::error::No s ha trobat cap versio anterior"; exit 1; }
echo "Versio anterior resolta: $DIGEST"
fi
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"I fes servir ${{ steps.resoldre.outputs.digest }} als passos següents. Canvia també l'input a required: false.
Una versió més robusta no consulta el registre sinó un historial de desplegaments que el mateix cd.yml manté: un fitxer desplegaments.jsonl en una branca estat, o l'API de Deployments de GitHub (gh api "repos/{owner}/{repo}/deployments?environment=produccio"), que ja registra cada desplegament amb la seva ref. La diferència importa: el registre et diu quines imatges existeixen; l'historial et diu què s'ha desplegat i en quin ordre, que és la pregunta real.
Solució 2.
nginx-canary.conf:
upstream mini_reservalia {
# El pes reparteix les peticions: 9 de cada 10 a l estable.
server host.docker.internal:3002 weight=9; # estable
server host.docker.internal:3003 weight=1; # canary (10 %)
}
server {
listen 8080;
location / {
proxy_pass http://mini_reservalia;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Si la canary falla, nginx la treu del pool i reintenta a l estable:
# l usuari no veu l error. Es el "circuit breaker" del pobre.
proxy_next_upstream error timeout http_502 http_503;
}
} canary:
name: Desplegament canary
runs-on: ubuntu-latest
needs: [preparar, staging]
environment: produccio
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
- name: 1. Aixecar la canary al costat de l estable
run: ./scripts/desplegar.sh
env:
IMATGE: ${{ needs.preparar.outputs.imatge }}
ENTORN: canary
PORT_AMFITRIO: '3003'
- name: 2. Balancejador al 10 %
run: |
docker rm -f balancejador 2>/dev/null || true
docker run -d --name balancejador -p 8080:8080 \
--add-host host.docker.internal:host-gateway \
-v "$PWD/nginx-canary.conf:/etc/nginx/conf.d/default.conf:ro" \
nginx:alpine
sleep 3
- name: 3. Observar 2 minuts i mesurar la taxa d error
id: observar
run: |
set -Eeuo pipefail
FI=$(( $(date +%s) + 120 ))
TOTAL=0; ERRORS=0
while [ "$(date +%s)" -lt "$FI" ]; do
CODI=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
"http://localhost:8080/api/forats?data=2026-03-02" || echo 000)
TOTAL=$((TOTAL+1))
[[ "$CODI" == "200" ]] || ERRORS=$((ERRORS+1))
sleep 1
done
TAXA=$(awk "BEGIN {printf \"%.2f\", $ERRORS*100/$TOTAL}")
echo "Peticions: $TOTAL · Errors: $ERRORS · Taxa: ${TAXA}%"
echo "taxa=$TAXA" >> "$GITHUB_OUTPUT"
echo "### Canary: ${TAXA}% d error en $TOTAL peticions" >> "$GITHUB_STEP_SUMMARY"
# Llindar de l 1 %: per sobre, no promociona.
awk "BEGIN {exit !($TAXA > 1.0)}" && { echo "::error::Taxa d error per sobre de l 1 %"; exit 1; }
- name: 4a. Promocionar al 100 %
if: success()
run: |
./scripts/desplegar.sh
docker rm -f mini-reservalia-canary balancejador || true
env:
IMATGE: ${{ needs.preparar.outputs.imatge }}
ENTORN: produccio
PORT_AMFITRIO: '3002'
- name: 4b. Retirar la canary
if: failure()
run: |
echo "::warning::Canary retirada. Produccio continua amb la versio anterior."
docker logs --tail 100 mini-reservalia-canary || true
docker rm -f mini-reservalia-canary balancejador || trueEl que ensenya aquest exercici, i que la 03-04 explicava en teoria: el canary no és «desplegar a poc a poc», és «desplegar i mesurar». Sense el pas 3 —una mètrica, un llindar i una decisió automàtica— tens un desplegament lent, no un canary. I fixa't que la fallada del pas 3 deixa producció intacta: el 90 % del trànsit no ha vist mai la versió nova.
Solució 3.
- name: Finestra de desplegament
if: inputs.emergencia != true
run: |
set -Eeuo pipefail
# El runner va en UTC; el convertim a l hora local de l equip.
export TZ='Europe/Madrid'
DIA=$(date +%u) # 1=dilluns ... 7=diumenge
HORA=$(date +%H)
ARA=$(date '+%A %H:%M %Z')
bloquejar() {
{
echo "## ⛔ Desplegament bloquejat per la finestra"
echo ""
echo "**Moment:** $ARA"
echo "**Motiu:** $1"
echo ""
echo "La finestra permesa es **de dilluns a dijous de 09:00 a 17:00** i"
echo "**divendres de 09:00 a 13:00**."
echo ""
echo "Per a una emergencia real, rellanca el flux de treball amb"
echo "\`emergencia: true\` i un motiu. Quedara registrat."
} >> "$GITHUB_STEP_SUMMARY"
echo "::error::Fora de la finestra de desplegament: $1"
exit 1
}
[ "$DIA" -ge 6 ] && bloquejar "cap de setmana"
[ "$DIA" -eq 5 ] && [ "$HORA" -ge 13 ] && bloquejar "divendres a la tarda"
{ [ "$HORA" -lt 9 ] || [ "$HORA" -ge 17 ]; } && bloquejar "fora de l horari laboral"
echo "✅ Dins de la finestra de desplegament ($ARA)." >> "$GITHUB_STEP_SUMMARY"
- name: Registrar l us de la via d escapament
if: inputs.emergencia == true
run: |
{
echo "## 🚨 DESPLEGAMENT D EMERGENCIA"
echo ""
echo "| Camp | Valor |"
echo "|---|---|"
echo "| Autoritzat per | @${{ github.actor }} |"
echo "| Motiu | ${{ inputs.motiu_emergencia }} |"
echo "| Moment (UTC) | $(date -u) |"
echo "| Digest | \`${{ needs.preparar.outputs.digest }}\` |"
echo ""
echo "> Aquest desplegament s ha saltat la finestra. S ha de revisar a la retrospectiva."
} >> "$GITHUB_STEP_SUMMARY"
echo "::warning::Desplegament d emergencia fora de finestra per @${{ github.actor }}"Amb els inputs corresponents:
workflow_dispatch:
inputs:
emergencia:
description: 'Saltar-se la finestra de desplegament (queda registrat)'
type: boolean
default: false
motiu_emergencia:
description: 'Obligatori si emergencia = true'
type: stringLa discussió que obre aquest exercici és més interessant que el codi, i convé tenir-la clara. Les finestres de desplegament són un antipatró en un equip amb bon CD: si fa por desplegar el divendres, el problema no és el divendres, és el desplegament. Amb rollback en 90 segons i smoke tests automàtics, un divendres és un dia com un altre, i les dades de l'informe State of DevOps són consistents en això: els equips d'elit despleguen quan cal.
Dit això, la finestra és una mesura de transició raonable mentre l'equip construeix confiança, i també és un requisit de negoci legítim en alguns contextos (una plataforma de reserves pot no voler tocar res un dissabte al matí). Si la poses, posa-li data de revisió i una via d'escapament registrada com la de dalt: una restricció sense escapament s'acaba saltant de maneres pitjors (desplegar a mà per SSH), i una restricció sense data de revisió es torna permanent per inèrcia.
Repte opcional
Substitueix l'aprovació manual de producció per una porta automàtica basada en dades: que producció es desplegui sola si el smoke test de staging passa i la taxa d'error de staging dels últims 10 minuts és inferior al 0,5 % i el canvi no toca fitxers marcats com a sensibles (migracions, configuració de seguretat); i que demani aprovació humana només en cas contrari. És el pas de «desplegament continu amb porta» a «desplegament continu amb porta condicional», i és el que fan els equips que despleguen 50 vegades al dia sense que ningú aprovi res. Necessitaràs les mètriques de la 07-04, així que guarda-ho per a després.
Què has construït
- Un Dockerfile multietapa amb usuari no-root,
HEALTHCHECK, metadades OCI i.dockerignore. - Publicació a
ghcr.iodes del pipeline amb credencial efímera, memòria cau de capes i attestació de procedència. - Separació CI/CD amb
workflow_runi la guarda que impedeix desplegar una CI en vermell. - Dos Environments —
stagingautomàtic iproduccioamb revisor requerit— i l'experiència de veure el pipeline esperant-te. - Un script de desplegament idempotent, executable en tres destinacions diferents sense canvis, que rebutja les etiquetes, desa el digest anterior i espera a
healthy. - Un smoke test amb reintents que verifica la versió desplegada i comprova que els errors continuen sent errors.
- Promoció per digest amb verificació explícita que producció desplega exactament el que s'ha validat, sense reconstruir.
- Un
rollback.ymlcronometrat que reverteix en menys de dos minuts. - La comprovació que un bug que la CI no detecta s'atura a staging i no arriba mai a producció.
Conclusió
El circuit està tancat: un commit pot arribar a producció sense que ningú executi cap ordre a mà, i pot tornar enrere igual de ràpid. Has vist la peça que fa que tot això sigui segur i que es resumeix en una frase: el mateix artefacte, identificat pel seu contingut, recorre tots els entorns, i cada pas comprova que efectivament és el mateix. Sense aquesta cadena, staging no valida res i el rollback és una loteria.
Però hi ha una pregunta que el teu pipeline encara no sap respondre, i és la que més importa. El smoke test et diu que el servei ha respost correctament durant els trenta segons posteriors al desplegament. I al cap de vint minuts? I quan entra el trànsit de debò? I si la versió nova funciona però és tres vegades més lenta? I quantes vegades has desplegat aquesta setmana, quant ha trigat cada canvi des del commit fins a producció, i quants d'aquests desplegaments han acabat en rollback? Ara mateix l'única resposta honesta és «no ho sé», i un pipeline que no sap si ha millorat les coses és un pipeline que no es pot defensar davant de ningú.
A la 07-04 instrumentes el sistema. Afegiràs a src/servidor.js el mesurament de la latència i el recompte per codi d'estat, exposaràs /metriques en format Prometheus sense dependències, aixecaràs Prometheus i Grafana amb docker compose amb un panell versionat al repositori, definiràs un SLO amb el seu pressupost d'error calculat amb aritmètica explícita, escriuràs una alerta per símptoma i la dispararàs expressament ficant un sleep en un endpoint, marcaràs els desplegaments al panell per veure la correlació entre «hem desplegat» i «ha empitjorat», calcularàs les quatre mètriques DORA del teu propi repositori amb un job programat, i —tancant el bucle que aquesta lliçó deixa obert— faràs que el cd.yml sondegi les mètriques després de desplegar i dispari el rollback sol si l'error supera el llindar.
Curs de CI/CD: Integració i Desplegament Continu
Mòdul 1: Introducció al CI/CD
- Conceptes Bàsics de CI/CD
- Beneficis del CI/CD
- Eines Populars de CI/CD
- El Projecte del Curs: l'Aplicació que Automatitzarem
- Mètriques DORA: Com es Mesura el Lliurament de Programari
Mòdul 2: Integració Contínua (CI)
- Introducció a la Integració Contínua
- Configuració d'un Entorn de CI
- Automatització de la Construcció
- Proves Automatitzades
- Qualitat de Codi i Anàlisi Estàtica
- Artefactes, Versionat i Promoció
- Integració amb el Control de Versions
Mòdul 3: Desplegament Continu (CD)
- Introducció al Desplegament Continu
- Automatització del Desplegament
- Infraestructura com a Codi i Entorns Reproduïbles
- Estratègies de Desplegament
- Feature Flags, Rollback i Recuperació davant Errors
- Monitoratge i Retroalimentació
Mòdul 4: Pràctiques Avançades de CI/CD
- Pipelines de CI/CD
- Gestió de Dependències
- Seguretat en CI/CD
- Escalabilitat i Rendiment
- Pipeline as Code: Plantilles, Reutilització i Proves del Pipeline
- Bases de Dades al Pipeline: Migracions Segures
Mòdul 5: Implementació de CI/CD en Projectes Reals
- Cas d'Estudi: Projecte Web
- Cas d'Estudi: Aplicació Mòbil
- Cas d'Estudi: Microserveis
- Cas d'Estudi: Modernitzar un Projecte Legacy
Mòdul 6: Eines i Tecnologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker i Kubernetes
- GitHub Actions a Fons
- Comparativa i Criteris per Triar Eina
Mòdul 7: Exercicis Pràctics
- Exercici 1: Configuració d'un Pipeline Bàsic
- Exercici 2: Integració de Proves Automatitzades
- Exercici 3: Desplegament en un Entorn de Producció
- Exercici 4: Monitoratge i Retroalimentació
- Exercici 5: Enfortir el Pipeline amb Seguretat i Secrets
- Projecte Final: Pipeline Complet d'Extrem a Extrem
