El teu sistema funciona. L'has vist funcionar. Has creat una reserva, ha arribat a BigQuery, el tauler de control la mostra i el certificat és vàlid.

I tanmateix no està acabat, perquè hi ha una diferència enorme entre «ho he vist funcionar» i «sé que funciona». La primera afirmació parteix d'una observació puntual, feta per tu, en el millor cas possible: sense concurrència, sense errors, sense que caigui res, amb dades que tu mateix has introduït. La segona parteix de proves.

Aquesta lliçó respon a una pregunta concreta: què vol dir «acabat» de debò? I la resposta té set parts, cadascuna de les quals respon a una pregunta que algú et farà:

Pregunta La respon
«I si dues persones fan el mateix alhora?» Les proves de codi
«Pots tornar a aixecar això des de zero?» Les proves d'infraestructura
«Hi ha alguna cosa exposada que no hauria d'estar-ho?» Les proves de seguretat
«Quant trànsit aguanta?» La prova de càrrega
«Què passa si cau la base de dades?» Les proves de fiabilitat
«Com despleges sense trencar res?» L'estratègia de desplegament
«I si alguna cosa surt malament?» El procediment de reversió i el guió d'incident

Cap d'aquestes set no és opcional en un projecte que vulguis ensenyar. I totes es poden fer en un cap de setmana llarg, amb eines gratuïtes i sense gastar més d'un parell d'euros.

En acabar, el teu projecte no estarà «fet»: estarà llançat, amb evidència de cada afirmació que en facis.

Contingut

  1. Què vol dir «acabat» de debò
  2. La piràmide de proves aplicada a aquest projecte
  3. Proves de la infraestructura
  4. Proves de seguretat
  5. Proves de càrrega
  6. Proves de fiabilitat
  7. L'estratègia de desplegament
  8. La llista de verificació prèvia al llançament
  9. El llançament i les primeres 24 hores
  10. Quan alguna cosa surt malament: el guió d'incident
  11. L'exemple resolt de RefugioReserva

  1. Què vol dir «acabat» de debò

Hi ha tres nivells d'«acabat», i convé saber en quin ets:

Nivell Afirmació Evidència Puntuació típica
Funciona a la meva màquina «Ho he vist funcionar» Una captura de pantalla 40-55
Funciona desplegat «És en producció i respon» Una URL 55-70
Sé que funciona «Aquí tens les proves, les mesures i què passa quan falla» Proves automatitzades, xifres mesurades, assajos fets 75-95

La diferència entre el segon i el tercer és aquesta lliçó. I és on hi ha la meitat dels punts que separen un projecte normal d'un que un entrevistador recorda.

La definició operativa d'«acabat»

Un lliurable està acabat quan pots marcar les nou caselles:

  • [ ] Hi ha almenys una prova automatitzada que falla si trenques la lògica central
  • [ ] La infraestructura es recrea des de zero i el sistema arrenca
  • [ ] La llista de comprovació de seguretat passa sencera
  • [ ] Coneixes el teu límit de capacitat amb una xifra mesurada
  • [ ] Has apagat una dependència expressament i saps què passa
  • [ ] Has restaurat una còpia de seguretat i l'has cronometrada
  • [ ] Has provocat l'alerta i ha arribat
  • [ ] Has assajat la reversió i saps quant triga
  • [ ] La llista prèvia al llançament està completa

Cap requereix eines de pagament. Totes requereixen fer-les de debò, no marcar-les.

  1. La piràmide de proves aplicada a aquest projecte

flowchart TB
    E2E["Extrem a extrem — 3-5 proves<br/>Contra l'entorn de desenvolupament real<br/>Lentes (min), cares, fràgils"]
    INT["Integració — 8-15 proves<br/>Contra emuladors o BD efímera<br/>Mitjanes (s)"]
    UNI["Unitàries — 20-40 proves<br/>Lògica pura, sense xarxa ni BD<br/>Ràpides (ms), gratis"]

    UNI --> INT --> E2E

    style UNI fill:#e6f4ea
    style INT fill:#fef7e0
    style E2E fill:#fce8e6

La regla de proporció per a un projecte d'aquesta mida: unes 30 unitàries, unes 10 d'integració i 3-5 d'extrem a extrem. No cal més, i perseguir el 100 % de cobertura és temps mal invertit en un porfolio.

Què provar i què no:

Sí, prova No perdis temps provant
Regles de negoci (càlcul d'aforament, preus, estats) Que FastAPI retorni 200 en una ruta trivial
Casos límit (zero, negatiu, màxim, buit) Que l'ORM sàpiga fer un SELECT
Concurrència al punt crític Getters i setters
Validació d'entrada Que Terraform sàpiga crear un bucket
El flux complet, una vegada Cada permutació de la interfície

2.1 Proves unitàries: la lògica sense dependències

# app/tests/test_unitarios.py
import pytest
from datetime import date
from src.dominio import calcular_disponibilitat, validar_reserva, ErrorValidacio

class TestDisponibilitat:
    """La regla de negoci central: mai més reserves que capacitat."""

    def test_refugi_buit_ofereix_tota_la_capacitat(self):
        assert calcular_disponibilitat(capacitat=40, ocupades=0) == 40

    def test_descompta_les_places_ocupades(self):
        assert calcular_disponibilitat(capacitat=40, ocupades=15) == 25

    def test_refugi_ple_ofereix_zero(self):
        assert calcular_disponibilitat(capacitat=40, ocupades=40) == 0

    def test_mai_retorna_negatiu(self):
        """Cas límit: si per un error hi hagués sobrevenda a les dades,
        la funció ha de retornar 0, no una xifra negativa que la interfície
        mostraria com a '-3 places lliures'."""
        assert calcular_disponibilitat(capacitat=40, ocupades=45) == 0

class TestValidacio:
    @pytest.mark.parametrize("places", [0, -1, 13, 999])
    def test_rebutja_places_fora_de_rang(self, places):
        with pytest.raises(ErrorValidacio):
            validar_reserva(places=places, data=date(2026, 8, 15))

    def test_rebutja_data_passada(self):
        with pytest.raises(ErrorValidacio, match="passat"):
            validar_reserva(places=2, data=date(2020, 1, 1))

    @pytest.mark.parametrize("correu", ["", "sense-arrova", "a@", "@b.com"])
    def test_rebutja_correu_invalid(self, correu):
        with pytest.raises(ErrorValidacio):
            validar_reserva(places=2, data=date(2026, 8, 15), email=correu)

test_mai_retorna_negatiu és el tipus de prova que val la pena. No prova el cas normal —això ho prova l'ús—: prova el cas rar que, quan passi en producció a les tres de la matinada, provocarà un comportament absurd a la interfície.

2.2 Proves d'integració: contra serveis reals o emuladors

Google publica emuladors locals de Firestore, Pub/Sub, Bigtable i Datastore. Són gratis, arrenquen en segons i es comporten com el servei real en allò essencial.

# Emuladors locals, gratis i sense tocar el núvol
gcloud emulators firestore start --host-port=localhost:8080
gcloud emulators pubsub    start --host-port=localhost:8085

# Les biblioteques client els detecten per variable d'entorn
export FIRESTORE_EMULATOR_HOST=localhost:8080
export PUBSUB_EMULATOR_HOST=localhost:8085

Per a PostgreSQL no hi ha emulador, però hi ha una cosa millor: un contenidor efímer.

# app/tests/conftest.py
import pytest, subprocess, time, os
import psycopg

@pytest.fixture(scope="session")
def bd_efimera():
    """PostgreSQL en un contenidor, creat i destruït per la sessió de proves.
    Cost: 0 €. No toca el núvol."""
    subprocess.run([
        "docker", "run", "-d", "--name", "pg-proves",
        "-e", "POSTGRES_PASSWORD=proves",
        "-e", "POSTGRES_DB=reservas",
        "-p", "55432:5432", "postgres:16-alpine",
    ], check=True)

    dsn = "postgresql://postgres:proves@localhost:55432/reservas"
    for _ in range(30):                       # espera que accepti connexions
        try:
            psycopg.connect(dsn).close()
            break
        except Exception:
            time.sleep(1)

    # Aplicar les MATEIXES migracions que en producció
    with psycopg.connect(dsn) as con:
        for f in sorted(os.listdir("data/migraciones")):
            with open(f"data/migraciones/{f}") as fh:
                con.execute(fh.read())
        con.commit()

    yield dsn

    subprocess.run(["docker", "rm", "-f", "pg-proves"], check=True)

Aplicar les mateixes migracions que en producció és el que dona valor a la prova: si una migració està trencada, ho descobreixes aquí i no en desplegar.

I la prova que de debò importa a RefugioReserva —la concurrència:

# app/tests/test_integracion.py
import pytest, psycopg
from concurrent.futures import ThreadPoolExecutor
from src.repositorio import crear_reserva, ErrorSensePlaces

def test_no_hi_ha_sobrevenda_amb_concurrencia(bd_efimera):
    """El cas que trenca els sistemes de reserves mal fets:
    vint persones intenten reservar l'última plaça alhora."""
    with psycopg.connect(bd_efimera) as con:
        con.execute("INSERT INTO refugio (nombre, altitud_m, capacidad) "
                    "VALUES ('Refugi de Prova', 2000, 1)")
        con.commit()

    def intentar():
        try:
            with psycopg.connect(bd_efimera) as c:
                crear_reserva(c, refugio_id=1, fecha="2026-08-15", plazas=1,
                              titular="Fictici", email="[email protected]")
            return "ok"
        except ErrorSensePlaces:
            return "sense_places"

    with ThreadPoolExecutor(max_workers=20) as ex:
        resultats = list(ex.map(lambda _: intentar(), range(20)))

    # EXACTAMENT una ha de reeixir. Ni cap, ni dues.
    assert resultats.count("ok") == 1, f"Sobrevenda: {resultats.count('ok')} reserves"
    assert resultats.count("sense_places") == 19

    with psycopg.connect(bd_efimera) as con:
        total = con.execute("SELECT COALESCE(SUM(plazas),0) FROM reserva "
                            "WHERE refugio_id=1 AND estado='confirmada'").fetchone()[0]
    assert total == 1

Aquesta prova justifica per si sola l'ADR-002 (triar Cloud SQL per les seves transaccions). I si l'executes contra una implementació ingènua —llegir disponibilitat, decidir, inserir, sense bloqueig— falla, que és exactament el que ha de fer. La implementació correcta fa servir SELECT ... FOR UPDATE sobre la fila del refugi:

def crear_reserva(con, refugio_id, fecha, plazas, titular, email):
    with con.transaction():
        # El bloqueig de fila serialitza els intents concurrents
        cap = con.execute(
            "SELECT capacidad FROM refugio WHERE id = %s FOR UPDATE",
            (refugio_id,)).fetchone()[0]
        ocupades = con.execute(
            "SELECT COALESCE(SUM(plazas),0) FROM reserva "
            "WHERE refugio_id=%s AND fecha=%s AND estado='confirmada'",
            (refugio_id, fecha)).fetchone()[0]
        if ocupades + plazas > cap:
            raise ErrorSensePlaces()
        con.execute("INSERT INTO reserva (refugio_id, fecha, plazas, "
                    "nombre_titular, email_titular) VALUES (%s,%s,%s,%s,%s)",
                    (refugio_id, fecha, plazas, titular, email))

2.3 Proves d'extrem a extrem

Poques, lentes i contra l'entorn de desenvolupament real. Tres o quatre en tenen prou:

# app/tests/test_e2e.py
import os, time, uuid, pytest, requests
from google.cloud import bigquery

BASE = os.environ["URL_DEV"]

@pytest.mark.e2e
def test_flux_complet_reserva_arriba_a_analitica():
    """Camí crític sencer: reservar → esdeveniment → BigQuery."""
    marca = str(uuid.uuid4())[:8]
    r = requests.post(f"{BASE}/api/reservas", timeout=30, json={
        "refugio_id": 1, "fecha": "2027-01-15", "plazas": 1,
        "nombre_titular": f"E2E {marca}",          # dada FICTÍCIA
        "email_titular": f"e2e-{marca}@example.com",
    })
    assert r.status_code == 201
    reserva_id = r.json()["id"]

    # La propagació per Pub/Sub no és instantània: se sondeja amb límit
    bq = bigquery.Client()
    consulta = """
      SELECT COUNT(*) AS n
      FROM `refugio-datos.refugio_analitica.reservas_eventos`
      WHERE reserva_id = @rid AND DATE(ocurrido_en) = CURRENT_DATE()
    """
    cfg = bigquery.QueryJobConfig(query_parameters=[
        bigquery.ScalarQueryParameter("rid", "STRING", reserva_id)])

    for _ in range(12):                     # fins a 2 minuts
        n = list(bq.query(consulta, job_config=cfg).result())[0].n
        if n == 1:
            return
        time.sleep(10)
    pytest.fail("L'esdeveniment no ha arribat a BigQuery en 2 minuts")

@pytest.mark.e2e
def test_sondes_de_salut_responen():
    assert requests.get(f"{BASE}/salud/vivo", timeout=10).status_code == 200
    assert requests.get(f"{BASE}/salud/arranque", timeout=10).status_code == 200

2.4 Integració a Cloud Build

steps:
  - id: unitaries-i-integracio
    name: python:3.12-slim
    entrypoint: bash
    args:
      - -c
      - |
        pip install --no-cache-dir -r app/requirements.txt -r app/requirements-dev.txt
        cd app && python -m pytest tests/ -v -m "not e2e" --tb=short \
          --junitxml=/workspace/resultados.xml

  # ... construir, publicar, desplegar ...

  - id: e2e
    name: python:3.12-slim
    entrypoint: bash
    args:
      - -c
      - |
        pip install --no-cache-dir -r app/requirements-dev.txt
        export URL_DEV=$(gcloud run services describe refugio-web \
          --region=europe-west1 --format='value(status.url)')
        cd app && python -m pytest tests/ -v -m e2e
    waitFor: [desplegar]

Les unitàries i les d'integració van abans de construir; les d'extrem a extrem després de desplegar, perquè necessiten un sistema viu.

  1. Proves de la infraestructura

3.1 Validació estàtica

terraform fmt -check -recursive        # format consistent
terraform validate                     # sintaxi i referències
terraform plan -detailed-exitcode      # 0=sense canvis, 2=hi ha canvis, 1=error

-detailed-exitcode és útil a CI: et permet fer fallar el build si l'estat desplegat no coincideix amb el codi.

3.2 El plan revisat a la pull request

# .github/workflows/plan.yml
name: Terraform plan
on: pull_request

permissions:
  contents: read
  id-token: write
  pull-requests: write

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ vars.WIF_PROVIDER }}
          service_account: ${{ vars.SA_PLAN }}      # SA de NOMÉS LECTURA
      - uses: hashicorp/setup-terraform@v3
      - run: terraform -chdir=infra/envs/dev init
      - id: plan
        run: terraform -chdir=infra/envs/dev plan -no-color -out=plan.tfplan
      - name: Publicar el plan com a comentari
        uses: actions/github-script@v7
        with:
          script: |
            const sortida = `#### Terraform plan
            \`\`\`
            ${{ steps.plan.outputs.stdout }}
            \`\`\``;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner, repo: context.repo.repo, body: sortida
            });

Que el compte del plan sigui de només lectura (roles/viewer + accés al bucket d'estat) és important: un plan no ha de poder modificar res, i així una pull request maliciosa tampoc.

3.3 Anàlisi de polítiques: tflint i checkov

# tflint: errors de sintaxi, arguments invàlids, bones pràctiques del proveïdor
tflint --init && tflint --recursive

# checkov: comprovacions de seguretat i compliment sobre el codi
checkov -d infra/ --framework terraform --compact

Exemples del que detecten i que se't poden haver escapat:

Eina Detecta
tflint Tipus de màquina inexistent, atribut obsolet, variable no utilitzada
checkov Bucket sense accés uniforme, BD sense SSL forçat, registres desactivats, servei públic sense justificació

No persegueixis zero troballes. Algunes seran falsos positius per al teu context (per exemple, que Cloud SQL no tingui alta disponibilitat és intencionat en un porfolio). El correcte és documentar l'excepció:

# checkov:skip=CKV_GCP_79:Alta disponibilitat no aplicable; projecte de porfolio
# amb pressupost de 12 €/mes. Vegeu docs/adr/ADR-002.

Afegeix-ho al pipeline com a pas informatiu, no bloquejant, al principi:

  - id: analisi-iac
    name: bridgecrew/checkov:latest
    args: ["-d", "infra/", "--compact", "--soft-fail"]

3.4 La prova definitiva: recrear l'entorn des de zero

Aquesta és la prova que separa un projecte amb Terraform d'un projecte amb infraestructura com a codi de debò. Val 4 punts de la rúbrica i és la que més gent dona per suposada sense haver-la fet mai.

#!/usr/bin/env bash
# scripts/prueba-reconstruccion.sh
set -euo pipefail

ENTORNO="dev"
INICIO=$(date +%s)

echo "=== 1. Estat inicial ==="
terraform -chdir="infra/envs/${ENTORNO}" state list | wc -l

echo "=== 2. DESTRUIR ==="
terraform -chdir="infra/envs/${ENTORNO}" destroy -auto-approve

echo "=== 3. Verificar que no queda res ==="
gcloud run services list      --project="refugio-${ENTORNO}" --format="value(name)"
gcloud sql instances list     --project="refugio-${ENTORNO}" --format="value(name)"
gcloud compute networks list  --project="refugio-${ENTORNO}" --format="value(name)"

echo "=== 4. RECONSTRUIR ==="
terraform -chdir="infra/envs/${ENTORNO}" apply -auto-approve

echo "=== 5. Migracions i dades ==="
./scripts/migrar.sh "${ENTORNO}"
python data/seed/generar.py --entorno="${ENTORNO}"

echo "=== 6. Desplegar l'última imatge coneguda ==="
./scripts/desplegar.sh "${ENTORNO}" "$(git rev-parse --short HEAD)"

echo "=== 7. Verificar que funciona ==="
URL=$(gcloud run services describe refugio-web --region=europe-west1 \
      --project="refugio-${ENTORNO}" --format='value(status.url)')
CODIGO=$(curl -s -o /dev/null -w '%{http_code}' "${URL}/salud/arranque")
test "${CODIGO}" = "200" || { echo "FALLADA: l'app no respon"; exit 1; }

echo "=== 8. Prova d'extrem a extrem ==="
URL_DEV="${URL}" python -m pytest app/tests/ -m e2e -q

FIN=$(date +%s)
echo "✅ RECONSTRUCCIÓ COMPLETA en $(( (FIN-INICIO)/60 )) minuts"

Què descobreix aquesta prova, i que no descobreix cap altra:

Troballa típica Per què no es veu d'una altra manera
Un recurs creat a mà la setmana 2 En un entorn que ja existeix, mai no el trobes a faltar
Dependències implícites mal ordenades En un apply incremental, el recurs previ ja hi era
Un secret que vas emplenar a mà El codi el crea buit i ningú no ho nota
Migracions que només funcionen sobre la BD ja existent Mai no s'executen des de zero
Un permís concedit des de la consola L'app funciona i no saps per què

Fes-la almenys dues vegades: una a mitjan projecte (per descobrir els forats quan encara és barat arreglar-los) i una altra abans del llançament. I cronometra: el temps de reconstrucció és una xifra que queda molt bé a la presentació.

  1. Proves de seguretat

4.1 Escaneig de la imatge

Artifact Registry inclou anàlisi de vulnerabilitats:

gcloud artifacts docker images scan \
  "europe-west1-docker.pkg.dev/refugio-dev/refugio-imagenes/web:latest" \
  --format="value(response.scan)"

# I per llistar les troballes
gcloud artifacts docker images list-vulnerabilities <RESULTAT_DEL_SCAN> \
  --format="table(vulnerability.effectiveSeverity, vulnerability.shortDescription)"

Criteri realista: zero vulnerabilitats crítiques i zero altes explotables al teu codi. Les de la imatge base s'arreglen actualitzant la base (python:3.12-slim a la seva última versió) i reconstruint. Les mitjanes i baixes de dependències del sistema que no fas servir es documenten i s'accepten.

4.2 La llista de comprovació executable

Aquest script és un lliurable en si mateix. Desa'l a scripts/auditoria-seguridad.sh, executa'l i desa la sortida a docs/evidencias/:

#!/usr/bin/env bash
# scripts/auditoria-seguridad.sh
set -uo pipefail
PROY="${1:?Ús: $0 <projecte>}"
FALLOS=0
ok()   { echo "  ✅ $1"; }
falla(){ echo "  ❌ $1"; FALLOS=$((FALLOS+1)); }
avisa(){ echo "  ⚠️  $1"; }

echo "=== AUDITORIA DE SEGURETAT: ${PROY} ==="

echo "[1] Rols primitius en comptes de servei"
N=$(gcloud projects get-iam-policy "$PROY" --format=json |
    jq '[.bindings[] | select(.role|test("roles/(owner|editor)")) |
         .members[] | select(startswith("serviceAccount:"))] | length')
[ "$N" -eq 0 ] && ok "Cap SA amb owner/editor" || falla "$N comptes amb rol primitiu"

echo "[2] Claus de compte de servei gestionades per l'usuari"
TOTAL=0
for SA in $(gcloud iam service-accounts list --project="$PROY" --format="value(email)"); do
  K=$(gcloud iam service-accounts keys list --iam-account="$SA" --managed-by=user \
      --format="value(name)" 2>/dev/null | wc -l)
  TOTAL=$((TOTAL+K))
  [ "$K" -gt 0 ] && echo "     · $SA té $K clau(s)"
done
[ "$TOTAL" -eq 0 ] && ok "Zero claus JSON" || falla "$TOTAL claus descarregables"

echo "[3] Buckets accessibles públicament"
PUB=0
for B in $(gcloud storage buckets list --project="$PROY" --format="value(name)"); do
  if gcloud storage buckets get-iam-policy "gs://$B" --format=json |
     jq -e '.bindings[]?.members[]? | select(. == "allUsers" or . == "allAuthenticatedUsers")' >/dev/null 2>&1; then
    echo "     · gs://$B és PÚBLIC"; PUB=$((PUB+1))
  fi
done
[ "$PUB" -eq 0 ] && ok "Cap bucket públic" || falla "$PUB bucket(s) públic(s)"

echo "[4] Bases de dades amb IP pública"
for I in $(gcloud sql instances list --project="$PROY" --format="value(name)"); do
  IPV4=$(gcloud sql instances describe "$I" --project="$PROY" \
         --format="value(settings.ipConfiguration.ipv4Enabled)")
  [ "$IPV4" = "False" ] && ok "$I sense IP pública" || falla "$I TÉ IP PÚBLICA"
done

echo "[5] Secrets al repositori"
if git log -p --all 2>/dev/null | grep -Ei '(password|api[_-]?key|secret|BEGIN (RSA|PRIVATE))' \
   | grep -v 'secret_id\|secretAccessor\|SecretManager\|secret_key_ref\|refugio-db-password' \
   | head -5 | grep -q .; then
  falla "Possibles secrets a l'historial de git — REVISAR"
else
  ok "Sense secrets evidents a l'historial"
fi

echo "[6] Fitxers de credencials a l'arbre"
if find . -name "*.json" -not -path "./node_modules/*" -not -path "./.git/*" \
   -exec grep -l '"type": *"service_account"' {} \; 2>/dev/null | grep -q .; then
  falla "Hi ha fitxers de clau de compte de servei al repositori"
else
  ok "Sense fitxers de credencials"
fi

echo "[7] Serveis de Cloud Run sense autenticació"
for S in $(gcloud run services list --project="$PROY" --region=europe-west1 --format="value(name)"); do
  if gcloud run services get-iam-policy "$S" --project="$PROY" --region=europe-west1 \
     --format=json | jq -e '.bindings[]?.members[]? | select(. == "allUsers")' >/dev/null 2>&1; then
    avisa "$S és públic (correcte si és la web; revisar si no)"
  else
    ok "$S requereix autenticació"
  fi
done

echo "[8] Registres d'auditoria d'activitat d'administrador"
gcloud logging read 'logName:"cloudaudit.googleapis.com%2Factivity"' \
  --project="$PROY" --limit=1 --format="value(timestamp)" | grep -q . \
  && ok "Hi ha registres d'auditoria" || avisa "Sense activitat recent registrada"

echo
echo "=== RESULTAT: ${FALLOS} fallada(es) ==="
exit $((FALLOS > 0))

4.3 Verificació de TLS i capçaleres

DOMINIO="refugioreserva.example"

# Certificat: emissor i validesa
echo | openssl s_client -connect "${DOMINIO}:443" -servername "${DOMINIO}" 2>/dev/null |
  openssl x509 -noout -subject -issuer -dates

# Versions de TLS: 1.2 i 1.3 sí; 1.0 i 1.1 han de fallar
for V in tls1 tls1_1 tls1_2 tls1_3; do
  printf "%-8s " "$V"
  echo | openssl s_client -"$V" -connect "${DOMINIO}:443" 2>/dev/null | \
    grep -q "Verify return code: 0" && echo "accepta" || echo "rebutja"
done

# Capçaleres de seguretat
curl -sI "https://${DOMINIO}" | grep -iE \
  'strict-transport|x-content-type|x-frame|content-security|referrer-policy'

# HTTP ha de redirigir
curl -sI "http://${DOMINIO}" | head -1
Capçalera Valor recomanat Què evita
Strict-Transport-Security max-age=31536000; includeSubDomains Degradació a HTTP
X-Content-Type-Options nosniff Interpretació errònia de tipus
X-Frame-Options DENY Clickjacking
Content-Security-Policy Almenys default-src 'self' Injecció de scripts
Referrer-Policy strict-origin-when-cross-origin Fuita d'URL a tercers

Aquestes s'afegeixen en un middleware de l'aplicació amb deu línies de codi, i sumen 2 punts.

4.4 Revisió de permisos efectius

# Què pot fer realment el meu compte de servei de l'aplicació?
gcloud projects get-iam-policy refugio-prod --format=json |
  jq -r --arg sa "serviceAccount:[email protected]" \
     '.bindings[] | select(.members[]? == $sa) | .role'

# Comprovar un permís concret que NO hauria de tenir
gcloud policy-troubleshoot iam \
  "//cloudresourcemanager.googleapis.com/projects/refugio-prod" \
  --principal-email="[email protected]" \
  --permission="resourcemanager.projects.setIamPolicy"
# Esperat: NOT_GRANTED

Provar que no té un permís perillós és tan valuós com provar que té els que necessita.

  1. Proves de càrrega

5.1 Com fer una prova honesta i barata

Tres regles, en ordre d'importància:

  1. Contra desenvolupament, mai contra producció, tret que sigui el llançament mateix i ho hagis planificat.
  2. Amb un topall de despesa: --max-instances limitat i un pressupost de durada. Una prova de càrrega sense topall pot escalar a cent instàncies i costar-te el pressupost del mes en vint minuts.
  3. Comença petita i puja. 10 usuaris, 50, 100. Atura't quan alguna cosa es trenqui: aquesta és la dada que busques.

Eines gratuïtes: hey, k6, locust, wrk. Per a aquest projecte, k6 és la més completa i hey la més ràpida de fer servir.

// pruebas/carga.js  —  executar amb: k6 run pruebas/carga.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';

const errorsNegoci = new Rate('errors_negoci');

export const options = {
  stages: [
    { duration: '1m', target: 10 },   // escalfament
    { duration: '2m', target: 50 },   // càrrega esperada
    { duration: '2m', target: 100 },  // el doble
    { duration: '1m', target: 0 },    // refredament
  ],
  thresholds: {
    http_req_duration:  ['p(95)<1000', 'p(99)<2000'],
    http_req_failed:    ['rate<0.01'],
    errors_negoci:      ['rate<0.05'],
  },
};

const BASE = __ENV.URL;

export default function () {
  // 80% consultes, 20% reserves: perfil realista, no tot escriptures
  if (Math.random() < 0.8) {
    const r = http.get(`${BASE}/api/disponibilidad?refugio_id=1&fecha=2027-01-15`);
    check(r, { 'consulta 200': (x) => x.status === 200 });
    errorsNegoci.add(r.status >= 500);
  } else {
    const r = http.post(`${BASE}/api/reservas`, JSON.stringify({
      refugio_id: Math.ceil(Math.random() * 12),
      fecha: '2027-01-15', plazas: 1,
      nombre_titular: 'Càrrega Fictícia',
      email_titular: `carga-${__VU}-${__ITER}@example.com`,
    }), { headers: { 'Content-Type': 'application/json' } });
    // 409 (sense places) és correcte, no un error del sistema
    check(r, { 'reserva 201 o 409': (x) => x.status === 201 || x.status === 409 });
    errorsNegoci.add(r.status >= 500);
  }
  sleep(Math.random() * 2);
}

El detall que fa la prova honesta: distingir entre error del sistema (5xx) i resposta legítima de negoci (409, sense places). Una prova que compta els 409 com a fallades et donarà un percentatge d'error terrorífic i fals.

5.2 Què mesurar

Mètrica On es mesura Què et diu Llindar raonable
Latència p50 k6 Experiència típica <300 ms
Latència p95 k6 Experiència dolenta però comuna <1.000 ms
Latència p99 k6 El pitjor cas real <2.000 ms
Taxa d'error 5xx k6 + Monitoring Fiabilitat <1 %
Instàncies actives Monitoring Saturació i escalat Sense arribar al màxim
Arrencades en fred Registres de Cloud Run Latència afegida Visible al p99
Connexions a la BD Cloud SQL Insights El coll d'ampolla habitual <80 % del màxim
Cost de la prova Facturació Que no t'arruïni <1 €

No miris només la mitjana. La mitjana menteix: amb 99 peticions de 100 ms i una de 10 segons, la mitjana és 200 ms i hi ha un usuari que ha esperat deu segons. Els percentils expliquen la veritat.

5.3 Com interpretar i què ajustar

Símptoma Causa probable Ajust
p99 molt per sobre del p95 Arrencades en fred min-instances=1 (costa diners) o acceptar-ho i documentar-ho
La latència puja amb la càrrega, sense errors Saturació de CPU Pujar CPU, o baixar concurrency perquè escali abans
Errors 5xx en pujar la càrrega Esgotament de connexions a la BD Pool de connexions més petit per instància, o max-instances menor
Altiplà de rendiment amb instàncies al màxim Topall de max-instances Pujar-lo… i recalcular el cost
Errors des del primer minut Bug, no capacitat Arreglar abans de tornar a provar

L'aritmètica de les connexions que enxampa tothom: Cloud SQL db-f1-micro admet unes 25 connexions. Si la teva aplicació obre un pool de 10 connexions per instància i Cloud Run escala a 5 instàncies, demanes 50 connexions i la meitat falla. La solució no és una base de dades més gran: és un pool de 2-3 connexions per instància, perquè cada instància atén peticions concurrents amb una sola connexió la major part del temps.

  1. Proves de fiabilitat

Tres assajos. Cap no dura més d'una hora i tots tres són or pur per a la presentació.

6.1 Apagar una dependència i veure què passa

# ASSAIG EN DESENVOLUPAMENT, amb l'hora anotada
echo "Inici de l'assaig: $(date)" | tee -a docs/evidencias/ensayo-bd.log

# Aturar la base de dades
gcloud sql instances patch refugio-db --project=refugio-dev \
  --activation-policy=NEVER --quiet

# Observar el comportament
curl -s -o /dev/null -w "Portada: %{http_code} en %{time_total}s\n"  "${URL}/"
curl -s -o /dev/null -w "API:     %{http_code} en %{time_total}s\n"  "${URL}/api/refugios"
curl -s -w "Salut: %{http_code}\n" "${URL}/salud/arranque"
curl -s "${URL}/api/refugios" | head -c 300

# Restaurar
gcloud sql instances patch refugio-db --project=refugio-dev \
  --activation-policy=ALWAYS --quiet

Quin comportament busques:

Aspecte ❌ Malament ✅ Bé
Codi d'estat 500 amb traça de Python 503 amb cos JSON explicatiu
Missatge a l'usuari psycopg.OperationalError: could not connect... «El servei no està disponible temporalment»
Temps de resposta 30 s (esperant el timeout) <3 s (timeout curt al client de BD)
Parts no afectades Tot caigut Portada i contingut a la memòria cau continuen servint
Sonda d'arrencada 200 (menteix) 503 (Cloud Run deixa d'enviar trànsit)
Registres Traça d'excepció sense context ERROR estructurat amb motivo i sense dades personals

Si el teu sistema es comporta com la columna esquerra, arregla'l: un timeout curt i un gestor d'excepcions que retorni 503 amb un missatge llegible són trenta línies de codi i sumen en fiabilitat i en la presentació.

6.2 Restaurar la còpia de seguretat i cronometrar-ho

INICIO=$(date +%s)

# 1. Quines còpies tinc?
gcloud sql backups list --instance=refugio-db --project=refugio-dev \
  --format="table(id, windowStartTime, status)"

BACKUP_ID=$(gcloud sql backups list --instance=refugio-db --project=refugio-dev \
            --format="value(id)" --limit=1)

# 2. Restaurar sobre una instància NOVA (mai sobre l'original en un assaig)
gcloud sql instances create refugio-db-restaurada \
  --project=refugio-dev --region=europe-west1 \
  --database-version=POSTGRES_16 --tier=db-f1-micro

gcloud sql backups restore "${BACKUP_ID}" \
  --restore-instance=refugio-db-restaurada \
  --backup-instance=refugio-db --project=refugio-dev --quiet

# 3. VERIFICAR que les dades hi són (això és el que gairebé ningú no fa)
./cloud-sql-proxy --port 55433 "refugio-dev:europe-west1:refugio-db-restaurada" &
psql -h 127.0.0.1 -p 55433 -U app -d reservas -c "
  SELECT (SELECT count(*) FROM reserva) AS reservas,
         (SELECT count(*) FROM refugio) AS refugios,
         (SELECT max(creada_en) FROM reserva) AS ultima;"

FIN=$(date +%s)
echo "RTO mesurat: $(( (FIN-INICIO)/60 )) minuts"

# 4. NETEJAR — no deixis la instància restaurada encesa
gcloud sql instances delete refugio-db-restaurada --project=refugio-dev --quiet

El pas 3 és el que distingeix un assaig real d'un de mentida. Una restauració que «acaba correctament» però deixa una base de dades buida és un desastre disfressat d'èxit. I el pas 4 no és opcional: una instància restaurada oblidada és la manera més comuna de duplicar la factura sense adonar-se'n.

Anota tres xifres: quant va trigar, quantes dades s'haurien perdut (RPO: la distància entre l'última còpia i el moment de la fallada) i quins problemes vas trobar. AlpinaShop va trobar cinc problemes que ningú no sospitava en el seu assaig de 47 minuts; tu trobaràs una cosa semblant.

6.3 Provocar l'alerta

Ja ho vas fer a 08-03. Si no, és el moment. I comprova les tres coses:

  1. Que l'incident s'obre a Monitoring.
  2. Que el correu (o el canal que sigui) arriba.
  3. Que el contingut inclou els teus primers passos i serveix per actuar.

  1. L'estratègia de desplegament

7.1 Entorns i promoció

flowchart LR
    DEV["Desenvolupament<br/>refugio-dev<br/>Automàtic a cada push"]
    PRUEBA["Proves<br/>e2e + càrrega<br/>Sobre dev"]
    PROD["Producció<br/>refugio-prod<br/>Amb aprovació"]

    DEV --> PRUEBA
    PRUEBA -->|mateixa imatge<br/>mateix digest| PROD

    PROD --> CAN["Canari 10%"]
    CAN -->|10 min OK| C50["50%"]
    C50 -->|10 min OK| C100["100%"]
    CAN -.->|error| REV["Reversió<br/>< 60 s"]
    C50 -.->|error| REV

El principi innegociable: la mateixa imatge, identificada pel seu digest.

# Obtenir el digest EXACTE de la imatge provada a dev
DIGEST=$(gcloud run services describe refugio-web --region=europe-west1 \
         --project=refugio-dev --format="value(spec.template.spec.containers[0].image)")
echo "Promocionant: ${DIGEST}"

# Desplegar en producció SENSE trànsit
gcloud run deploy refugio-web --image="${DIGEST}" \
  --region=europe-west1 --project=refugio-prod \
  --no-traffic --tag=candidata

Referenciar per digest (web@sha256:abc...) i no per etiqueta (web:v1.2) elimina tota ambigüitat: les etiquetes es poden reassignar; un digest és el contingut.

7.2 El canari, pas a pas

SERVICIO="refugio-web"; REGION="europe-west1"; PROY="refugio-prod"
NUEVA=$(gcloud run revisions list --service=$SERVICIO --region=$REGION \
        --project=$PROY --limit=1 --format="value(name)")

# Fase 1 — 10% durant 10 minuts
gcloud run services update-traffic $SERVICIO --region=$REGION --project=$PROY \
  --to-revisions="${NUEVA}=10"

# Observar la revisió nova EN CONCRET, no el servei sencer
gcloud logging read \
  "resource.type=cloud_run_revision AND
   resource.labels.revision_name=${NUEVA} AND severity>=ERROR" \
  --project=$PROY --limit=20 --freshness=10m

Criteris objectius de promoció, decidits abans i no durant:

Fase Trànsit Durada mínima Es promociona si… Es reverteix si…
1 10 % 10 min 5xx < 0,5 % i p95 < 1,2× la base 5xx > 1 % o p95 > 2× la base
2 50 % 10 min Igual Igual
3 100 % 30 min de vigilància Igual Igual

I una regla d'or: si dubtes, reverteix. Revertir costa un minut; un incident costa una tarda.

7.3 La reversió, assajada

#!/usr/bin/env bash
# scripts/revertir.sh — provat el 2026-10-28, triga 38 segons
set -euo pipefail
SERVICIO="${1:-refugio-web}"; REGION="europe-west1"; PROY="refugio-prod"

ANTERIOR=$(gcloud run revisions list --service="$SERVICIO" --region="$REGION" \
  --project="$PROY" --format="value(name)" --sort-by="~metadata.creationTimestamp" \
  | sed -n '2p')

echo "Revertint ${SERVICIO} → ${ANTERIOR}"
gcloud run services update-traffic "$SERVICIO" --region="$REGION" --project="$PROY" \
  --to-revisions="${ANTERIOR}=100"

URL=$(gcloud run services describe "$SERVICIO" --region="$REGION" --project="$PROY" \
      --format='value(status.url)')
sleep 5
curl -s -o /dev/null -w "Verificació: %{http_code}\n" "${URL}/salud/arranque"
echo "✅ Revertit a ${ANTERIOR}"

Assaja-la de debò, amb cronòmetre, en un moment tranquil. Un procediment de reversió que no s'ha executat mai és una hipòtesi, i el moment de descobrir que no funciona no és durant un incident.

Què NO reverteix la reversió de trànsit —i cal saber-ho abans:

Canvi Es reverteix amb el trànsit? Què fer
Codi de l'aplicació ✅ Sí Res més
Variables d'entorn ✅ Sí (van a la revisió) Res més
Migració de base de dades ❌ No Migracions compatibles cap enrere
Dades ja escrites ❌ No Restaurar còpia (molt més lent)
Canvis d'infraestructura ❌ No terraform apply del commit anterior

D'aquí surt la regla que evita el 90 % dels desastres: les migracions han de ser compatibles cap enrere. Afegeix columnes, no les treguis. Si cal esborrar una columna, fes-ho en un desplegament posterior, quan cap versió en circulació no la faci servir.

  1. La llista de verificació prèvia al llançament

Es passa sencera, amb les mans, marcant de debò.

Tècnica

  • [ ] Totes les proves passen a CI (unitàries, integració, extrem a extrem)
  • [ ] terraform plan sobre producció diu No changes
  • [ ] La reconstrucció des de zero s'ha executat amb èxit i està cronometrada
  • [ ] Les migracions estan aplicades i són compatibles cap enrere
  • [ ] La imatge desplegada en producció és la mateixa que es va provar a desenvolupament (digest idèntic)
  • [ ] Les sondes de salut responen correctament
  • [ ] No hi ha TODO, FIXME ni print() de depuració a la ruta crítica

Seguretat

  • [ ] scripts/auditoria-seguridad.sh surt amb 0 fallades
  • [ ] La imatge no té vulnerabilitats crítiques
  • [ ] TLS vàlid; TLS 1.0/1.1 rebutjats; HTTP redirigeix a HTTPS
  • [ ] Capçaleres de seguretat presents
  • [ ] Zero claus JSON; zero rols primitius en comptes de servei
  • [ ] Base de dades sense IP pública; cap bucket públic sense justificació
  • [ ] Els secrets són a Secret Manager i no al repositori ni al seu historial
  • [ ] Els registres no contenen dades personals

Cost

  • [ ] El pressupost està creat amb alertes al 50/90/100 %
  • [ ] El cost projectat cap dins el límit fixat a 08-01
  • [ ] max-instances està limitat a tots els serveis (topall de despesa)
  • [ ] No queda res encès de les proves (instàncies restaurades, balancejadors de prova, jobs de càrrega)
  • [ ] Els buckets tenen cicle de vida; Artifact Registry té política de neteja
  • [ ] Les taules de BigQuery tenen expiració de particions

Observabilitat

  • [ ] El tauler mostra els quatre senyals amb dades reals
  • [ ] Almenys una alerta ha notificat de debò en provocar-la
  • [ ] L'uptime check està actiu i en verd
  • [ ] L'SLO calcula pressupost d'error amb un valor numèric
  • [ ] Els registres són estructurats i correlacionables per traça
  • [ ] Els canals de notificació estan verificats

Documentació

  • [ ] El README permet a una altra persona arrencar el projecte
  • [ ] docs/arquitectura.md reflecteix el sistema tal com és avui
  • [ ] Hi ha ≥3 ADR, i els diagrames coincideixen amb les decisions
  • [ ] docs/runbook.md té els procediments operatius
  • [ ] El diario.md està al dia
  • [ ] El deute tècnic conegut està escrit i prioritzat

El runbook mínim

# Manual d'operació — RefugioReserva

## Dades de contacte i accessos
Responsable: <jo>. Projectes: refugio-dev, refugio-prod, refugio-datos.
Consola: https://console.cloud.google.com/home/dashboard?project=refugio-prod

## P1 — L'aplicació retorna errors 5xx
1. `gcloud logging read 'severity>=ERROR' --project=refugio-prod --limit=20 --freshness=15m`
2. Coincideix amb un desplegament? `gcloud run revisions list --service=refugio-web --limit=3`
3. Si coincideix → **revertir**: `./scripts/revertir.sh refugio-web` (38 s)
4. Si no → comprovar la BD: `gcloud sql instances describe refugio-db --format="value(state)"`
5. Escriure el que ha passat a `docs/diario.md`

## P2 — L'aplicació no respon gens
1. `curl -sI https://refugioreserva.example`
2. Servei viu? `gcloud run services describe refugio-web --format="value(status.conditions)"`
3. El DNS resol? `dig +short refugioreserva.example`
4. Certificat vàlid? `openssl s_client -connect refugioreserva.example:443`

## P3 — Restaurar la base de dades
Vegeu `scripts/restaurar.sh`. **RTO mesurat: 22 minuts. RPO: fins a 24 h.**

## P4 — Cost disparat
1. Informe de facturació → agrupar per servei i per etiqueta `componente`
2. Sospitosos habituals: instàncies oblidades, jobs en bucle, consultes de BigQuery sense filtre de partició
3. Mesura immediata: baixar `max-instances`; en últim extrem, `terraform destroy` de dev

## P5 — Desplegar una versió nova
`git push` a `main` → dev automàtic → aprovar a Cloud Build → canari 10/50/100

  1. El llançament i les primeres 24 hores

El llançament

Tria un moment en què puguis estar mirant la pantalla l'hora següent. No un divendres a la nit, i no abans d'anar-te'n a dormir.

# 1. Última verificació
./scripts/auditoria-seguridad.sh refugio-prod
terraform -chdir=infra/envs/prod plan          # No changes

# 2. Promocionar la imatge provada
./scripts/promocionar.sh "$(git rev-parse --short HEAD)"

# 3. Canari 10%, esperar, verificar
# 4. 50%, esperar, verificar
# 5. 100%
# 6. Marcar el moment
git tag -a v1.0.0 -m "Llançament del projecte final"
git push --tags

Les primeres 24 hores: què mirar i en quin ordre

Moment Què mires Què et faria revertir
0-15 min Errors 5xx als registres, en temps real Qualsevol 5xx que no existís abans
15-60 min Latència p95, arrencades en fred, instàncies p95 al doble de la base
1-4 h Tauler complet, primeres alertes, ús de connexions a la BD Alertes disparades
4-24 h Cost acumulat, tendència d'errors, pressupost d'error de l'SLO Cost projectat per sobre del límit
24 h Tot l'anterior + una prova manual del flux complet —
# La comanda que deixes corrent en un terminal durant la primera hora
gcloud logging tail \
  'resource.type="cloud_run_revision" AND severity>=WARNING' \
  --project=refugio-prod

Quan declarar l'èxit. No quan acaba el desplegament, sinó quan es compleixen les cinc condicions:

  1. 24 hores sense errors 5xx anòmals.
  2. Latència p95 dins l'objectiu de l'SLO.
  3. Cap alerta disparada per causa real.
  4. Cost diari projectat dins el límit mensual.
  5. El flux complet provat a mà per tu, en producció, una vegada.

Llavors sí: escriu la data al README, fes la captura del tauler en verd i passa a 08-05.

  1. Quan alguna cosa surt malament: el guió d'incident

Adaptat de 07-06 a un projecte d'una sola persona.

flowchart TD
    A["🚨 Alguna cosa va malament"] --> B["1. ESTABILITZAR<br/>Puc revertir? → reverteix JA<br/>No investiguis primer"]
    B --> C{"S'ha resolt?"}
    C -- Sí --> D["2. Anotar hora, símptoma<br/>i què has fet"]
    C -- No --> E["3. DIAGNOSTICAR<br/>registres → desplegaments → dependències → quotes"]
    E --> F["4. Mitigar<br/>encara que sigui amb un pedaç"]
    F --> D
    D --> G["5. POST MORTEM<br/>a docs/diario.md"]
    G --> H["6. Una acció concreta<br/>perquè no es repeteixi"]

La regla número u: estabilitzar abans que entendre. L'impuls natural és investigar la causa, i és l'ordre equivocat. Si tens una reversió que triga 38 segons, reverteix primer i entén després, amb el sistema funcionant i sense pressa.

El post mortem d'una pàgina

# Incident 2026-11-04 — Errors 5xx després del desplegament de v1.1.0

**Durada:** 19:42 → 19:51 (9 minuts)
**Impacte:** ~40 peticions amb error 500. Sense pèrdua de dades.
**Detecció:** alerta de taxa d'errors (va arribar a les 19:47, 5 min després de l'inici)

## Què va passar
El desplegament de v1.1.0 va introduir una consulta que feia servir una columna
(`opinion.magnitud`) creada per la migració 003, que no s'havia aplicat
en producció. L'aplicació va arrencar bé (la sonda d'arrencada no toca aquesta
taula) i va fallar només a les peticions a `/api/opiniones`.

## Cronologia
- 19:42 Desplegament canari al 10 %
- 19:44 Primers 500 als registres (no els vaig mirar: mirava el tauler, que
        amb el 10 % de trànsit gairebé no va moure l'agulla)
- 19:47 Arriba l'alerta
- 19:49 Executo `./scripts/revertir.sh`
- 19:51 Verificat: 0 errors

## Causa arrel
El pipeline desplega l'aplicació però **no executa les migracions**.
A desenvolupament les aplico a mà, així que allà funcionava.

## Què va funcionar
- La reversió: 38 segons, tal com estava assajada.
- El canari va limitar l'impacte al 10 % del trànsit.

## Què no va funcionar
- L'alerta va trigar 5 minuts. Amb el 10 % de trànsit, el llindar del 5 % sobre
  el total del servei s'assoleix tard.
- No estava mirant els registres de la revisió canària en concret.

## Accions
1. ✅ Afegir pas de migracions al pipeline, abans del desplegament.
2. ✅ Alerta addicional per revisió, no només per servei.
3. ⬜ Prova de fum específica sobre `/api/opiniones` després de desplegar.

Un post mortem al repositori val or en una entrevista. Demostra que has tingut un incident real, que el vas resoldre amb procediment i que en vas aprendre alguna cosa concreta. Un projecte sense cap incident registrat significa una de dues coses: que no l'has fet servir, o que no te'n vas assabentar.

  1. L'exemple resolt de RefugioReserva

Resultats de la prova de càrrega

Executada contra desenvolupament el 2026-10-27, amb max-instances=5 i db-f1-micro:

     ✓ consulta 200
     ✓ reserva 201 o 409

     checks.........................: 99.31% ✓ 24893  ✗ 172
     http_req_duration..............: avg=241ms min=61ms med=178ms max=8.9s
       { expected_response:true }...: avg=228ms
       p(90)=402ms  p(95)=712ms  p(99)=2.41s
     http_req_failed................: 0.68%  ✓ 172    ✗ 25065
     errors_negoci..................: 0.68%
     iterations.....................: 25065
     vus_max........................: 100

     ✗ http_req_duration.............: p(99)<2000  → 2.41s  FALLADA
     ✓ http_req_failed...............: rate<0.01   → 0.0068 OK

Interpretació, punt per punt:

Observació Diagnòstic Acció
p50 = 178 ms L'experiència típica és bona Cap
p95 = 712 ms Dins l'objectiu Cap
p99 = 2,41 s (llindar 2 s) Arrencades en fred en escalar d'1 a 5 instàncies Vegeu més avall
0,68 % de 5xx 172 errors, tots entre els minuts 3 i 4 Investigat ↓
max = 8,9 s Una petició concreta La primera arrencada en fred

Els 172 errors: la investigació.

$ gcloud logging read 'severity>=ERROR' --project=refugio-dev --limit=5 \
    --format="value(jsonPayload.message)"
FATAL: remaining connection slots are reserved for non-replication superuser connections

Esgotament de connexions. L'aritmètica: pool de 10 connexions per instància × 5 instàncies = 50 connexions sol·licitades contra un límit d'unes 25 de db-f1-micro.

La correcció, i per què aquesta i no una altra:

# Abans
pool = ConnectionPool(dsn, min_size=5, max_size=10)
# Després
pool = ConnectionPool(dsn, min_size=1, max_size=3, timeout=5)

No es va pujar la mida de la base de dades (hauria costat diners i no era el problema): es va ajustar el pool. Cada instància de Cloud Run atén fins a 80 peticions concurrents però passa la major part del temps esperant; 3 connexions per instància × 5 instàncies = 15, còmodament dins del límit.

Segona execució després de l'ajust:

     http_req_duration.....: p(95)=634ms  p(99)=1.82s   ✓
     http_req_failed.......: 0.00%  ✓ 0  ✗ 26102        ✓
     Cost de la prova: 0,38 €

Conclusió documentada: el sistema sosté 100 usuaris concurrents amb p99 per sota de 2 segons i sense errors. El límit pràctic és a max-instances=5, que és un topall de cost deliberat, no una limitació tècnica. Amb max-instances=20 aguantaria unes quatre vegades més, i costaria fins a quatre vegades més en un pic.

Sobre el p99 residual d'1,82 s: són arrencades en fred. min-instances=1 les eliminaria, però costaria uns 12 €/mes —el pressupost sencer del projecte—. Decisió: s'accepta i es documenta. L'SLO de latència es va fixar al p95, precisament per això.

Les dues reversions

Reversió 1 — 2026-11-04, 19:49. La del post mortem de la secció 10: migració 003 no aplicada en producció. Detectada per alerta als 5 minuts, revertida en 38 segons, impacte limitat al 10 % del trànsit pel canari. Acció correctiva: pas de migracions al pipeline.

Reversió 2 — 2026-11-11, 22:14. Més interessant, perquè no hi va haver cap error.

Símptoma: després de desplegar v1.2.0 al 10 %, la latència p95 de la revisió
canària va passar de 680 ms a 1.940 ms. Zero errors 5xx. Zero alertes.

Detecció: la mirava jo, comparant la revisió canària amb l'estable
al tauler, perquè el criteri de promoció inclou
«p95 < 1,2 × la base» i 1.940 no el compleix.

Causa: v1.2.0 afegia el sentiment mitjà a la resposta de /api/refugios
amb una subconsulta correlacionada sobre `opinion`, sense índex.

Decisió: revertir sense investigar més, a les 22:14. La correcció
(un índex i una consulta reescrita) es va desplegar dos dies després.

Per què aquesta reversió val més que la primera en una presentació: cap sistema automàtic no l'hauria detectada. No hi havia errors, no es va disparar cap alerta, l'SLO de disponibilitat continuava perfecte. Es va detectar perquè existia un criteri objectiu de promoció escrit per endavant i perquè algú el va comparar. És la demostració pràctica de per què els criteris s'escriuen abans: en aquell moment, amb la versió nova desplegada i ganes d'acabar, la temptació de dir «1,9 segons tampoc no està tan malament» és enorme.

Resum d'evidències

Prova Resultat Evidència
Unitàries 34 proves, 100 % passen docs/evidencias/pytest.txt
Integració 11 proves, inclosa la de concurrència docs/evidencias/pytest.txt
Extrem a extrem 4 proves contra dev Sortida a Cloud Build
Reconstrucció des de zero ✅ 14 min 20 s docs/evidencias/reconstruccion.log
Auditoria de seguretat 0 fallades, 1 avís (servei web públic, correcte) docs/evidencias/auditoria.txt
Escaneig d'imatge 0 crítiques, 2 mitjanes (base) docs/evidencias/scan.txt
TLS 1.2/1.3 sí, 1.0/1.1 no, 5 capçaleres docs/evidencias/tls.txt
Càrrega 100 VU, p95 634 ms, 0 % error, 0,38 € docs/evidencias/k6.txt
Caiguda de la BD 503 amb missatge llegible en 1,2 s docs/evidencias/ensayo-bd.log
Restauració ✅ 22 minuts, dades verificades docs/evidencias/restauracion.log
Alerta provocada Correu en 6 min docs/evidencias/alerta.png
Reversió assajada 38 segons scripts/revertir.sh + registre
Incidents reals 2, amb post mortem docs/diario.md

Errors Habituals i Consells

Escriure proves que no fallen mai. Una prova que passa sempre, fins i tot amb el codi trencat, és pitjor que no tenir prova: dona falsa seguretat. Comprova cada prova trencant expressament allò que prova.

Provar la càrrega contra producció sense voler. Apunta bé la URL. I posa max-instances baix abans de començar: és el teu topall de despesa.

Confondre un 409 amb un error. Si la teva prova de càrrega compta les respostes legítimes de negoci com a fallades, les teves xifres no signifiquen res.

Restaurar una còpia i no comprovar les dades. És l'error més perillós d'aquesta lliçó, perquè produeix un assaig «exitós» que en realitat demostra el contrari.

Deixar recursos encesos després de les proves. La instància restaurada, el balancejador de prova, la BD de càrrega. Posa't un recordatori i executa un inventari després de cada sessió de proves.

Desplegar el divendres a la nit. Clàssic per un motiu: si alguna cosa va malament, o ho arregles cansat o ho deixes trencat el cap de setmana.

Reconstruir la imatge per a producció. És desplegar una cosa que no has provat mai. Promociona per digest.

Migracions que no són compatibles cap enrere. Converteixen una reversió de 38 segons en una restauració de 22 minuts amb pèrdua de dades.

No tenir criteris de promoció escrits. En el moment, sempre sembla que «no està tan malament». Escriu-los abans, quan no tens pressa.

Consell: desa la sortida de tot. Un directori docs/evidencias/ amb les sortides de cada prova és el que converteix «el meu sistema és fiable» en «aquí tens les xifres». A 08-05 ho agrairàs enormement.

Consell: fes la reconstrucció des de zero a mitjan projecte. Descobrir la setmana 3 que faltava un recurs al codi costa mitja hora; descobrir-ho la vigília del lliurament costa el lliurament.

Consell: un incident real documentat val més que zero incidents. No amaguis les fallades: explica-les amb el seu post mortem. És el senyal més fiable que el sistema és real i que el saps operar.

Exercicis

Exercici 1 — Construeix la piràmide de proves i prova la teva infraestructura

Escriu per al teu projecte: almenys 15 proves unitàries de la lògica de negoci, incloent-hi casos límit; almenys 5 d'integració contra emuladors o una base de dades efímera en contenidor, aplicant les mateixes migracions que en producció i amb una prova de concurrència sobre el punt crític del teu domini; i 3 d'extrem a extrem contra desenvolupament. Integra-les al pipeline en l'ordre correcte.

Després executa la prova definitiva d'infraestructura: destrueix l'entorn de desenvolupament sencer, reconstrueix-lo des de zero, sembra les dades, desplega i verifica que funciona. Cronometra-ho i documenta tot el que es va trencar pel camí.

Exercici 2 — Audita la seguretat i mesura la capacitat

Escriu i executa el teu script d'auditoria de seguretat amb almenys vuit comprovacions automatitzades (rols primitius, claus JSON, buckets públics, IP pública a la BD, secrets a l'historial de git, fitxers de credencials, serveis sense autenticació, registres d'auditoria). Corregeix tot el que surti en vermell. Verifica el TLS, les versions acceptades i les capçaleres de seguretat. Escaneja la teva imatge.

Després, executa una prova de càrrega escalonada contra desenvolupament amb topall de despesa, mesurant latència p50/p95/p99, taxa d'error distingint errors de sistema de respostes de negoci, instàncies actives i el cost de la prova mateixa. Interpreta els resultats, aplica almenys un ajust concret i torna a executar-la per demostrar la millora.

Exercici 3 — Assaja la fiabilitat, desplega i llança

Executa els tres assajos de fiabilitat: apaga la teva base de dades i documenta el comportament exacte (codi, missatge, temps, què continua funcionant), corregint la degradació si no és elegant; restaura una còpia de seguretat sobre una instància nova, verifica les dades i cronometra l'RTO; i provoca la teva alerta comprovant que arriba.

Implementa el desplegament canari amb criteris objectius de promoció i reversió escrits per endavant, assaja la reversió amb cronòmetre, passa la llista de verificació prèvia al llançament sencera, i llança. Documenta les primeres 24 hores.

Solucions

Solució 1 — Proves i reconstrucció de RefugioReserva

La piràmide final: 34 unitàries, 11 d'integració i 4 d'extrem a extrem. La prova que justifica el projecte sencer és test_no_hi_ha_sobrevenda_amb_concurrencia de la secció 2.2, i la seva història mereix explicar-se: la primera implementació no la passava.

FAILED test_no_hi_ha_sobrevenda_amb_concurrencia
AssertionError: Sobrevenda: 7 reserves

Set de vint fils van aconseguir reservar l'única plaça. La implementació original llegia la disponibilitat, decidia i després inseria, sense bloqueig: entre la lectura i la inserció, uns altres sis fils havien llegit el mateix. És exactament la fallada que provocava les set sobrevendes a l'any de la federació fictícia — el problema de negoci original, reproduït al codi.

Amb SELECT ... FOR UPDATE sobre la fila del refugi, el resultat va ser 1 i 19. Aquesta prova, a la presentació, ocupa una diapositiva i respon tota sola a la pregunta «per què Cloud SQL i no Firestore?».

La reconstrucció des de zero, executada dues vegades:

Intent Data Resultat Troballes
1 2026-10-24 ❌ Va fallar 4 problemes (a sota)
2 2026-11-02 ✅ 14 min 20 s Cap

Els quatre problemes del primer intent, que és on hi ha el valor de l'exercici:

  1. El secret refugio-session-key es creava buit. L'havia emplenat a mà la setmana 2 i se m'havia oblidat del tot. L'app arrencava i fallava en signar la primera sessió. Corregit amb random_password + secret_version a Terraform.
  2. Faltava depends_on sobre el peering. Terraform intentava crear Cloud SQL abans que el peering estigués llest. Als apply incrementals no es veia mai, perquè el peering ja existia des del primer dia.
  3. El dataset de BigQuery estava creat a mà. Descobert perquè la funció no trobava la taula. No tenia l'etiqueta gestionado-por, que era justament el detector previst per a això.
  4. terraform destroy va fallar al bucket de fotos perquè tenia objectes i force_destroy = false. Correcte en producció, incòmode a desenvolupament: es va parametritzar force_destroy = var.entorno == "dev".

Cap dels quatre no s'hauria detectat d'una altra manera. La reconstrucció des de zero no és una prova més: és l'única que valida que el teu IaC és real.

Solució 2 — Auditoria i càrrega de RefugioReserva

Primera execució de l'auditoria:

=== AUDITORIA DE SEGURETAT: refugio-prod ===
[1] Rols primitius             ✅ Cap SA amb owner/editor
[2] Claus de compte            ✅ Zero claus JSON
[3] Buckets públics            ❌ 1 bucket públic: gs://refugio-fotos-8f2a
[4] BD amb IP pública          ✅ refugio-db sense IP pública
[5] Secrets a git              ✅ Sense secrets evidents
[6] Credencials a l'arbre      ✅ Sense fitxers de credencials
[7] Cloud Run sense auth       ⚠️  refugio-web és públic (correcte)
[8] Registres d'auditoria      ✅ Hi ha registres

=== RESULTAT: 1 fallada ===

El bucket públic. L'havia fet públic la setmana 4 perquè les miniatures se servissin directament, «temporalment». Quatre setmanes després continuava així, i a més havia deixat allUsers sobre el bucket sencer, no només sobre el prefix de miniatures: els originals de les fotos també eren accessibles per a qualsevol que endevinés el nom.

Correcció: es va treure allUsers, es va activar public_access_prevention = "enforced" i les miniatures van passar a servir-se mitjançant URL signades amb caducitat d'una hora, generades per l'aplicació.

def url_signada(nom_objecte: str, minuts: int = 60) -> str:
    blob = _bucket.blob(nom_objecte)
    return blob.generate_signed_url(version="v4",
                                    expiration=timedelta(minutes=minuts),
                                    method="GET")

És exactament l'error 3 de 08-01 —deixar la seguretat per al final— manifestant-se en la seva forma més típica: una cosa «temporal» que es queda. I ho va trobar un script de vuit comprovacions que va trigar vint segons a executar-se. Anotat a l'autoavaluació de 08-05 com a deute pagat, amb la data en què es va introduir i la data en què es va detectar: quatre setmanes d'exposició.

TLS i capçaleres, després d'afegir el middleware:

tls1     rebutja    ✅
tls1_1   rebutja    ✅
tls1_2   accepta    ✅
tls1_3   accepta    ✅
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: DENY
content-security-policy: default-src 'self'; img-src 'self' data: https://storage.googleapis.com
referrer-policy: strict-origin-when-cross-origin

Càrrega: els resultats i la seva interpretació són els de la secció 11. El resum en una línia, que és el que va a la presentació: 100 usuaris concurrents, p95 de 634 ms, 0 % d'errors, 0,38 € de cost de la prova, i un coll d'ampolla trobat i corregit que no era la base de dades sinó la mida del pool de connexions.

Solució 3 — Fiabilitat i llançament de RefugioReserva

Assaig de caiguda de la base de dades, primera execució:

Portada: 200 en 0.31s          ✅ continua funcionant (contingut estàtic)
API:     500 en 30.02s         ❌ 30 segons i una traça de Python
Salut:   200                   ❌ MENT: diu que està a punt i no ho està

Dos problemes greus. L'usuari esperava 30 segons per rebre un error incomprensible, i Cloud Run continuava enviant trànsit a una instància que no el podia atendre perquè la sonda d'arrencada no comprovava la base de dades.

Correccions: connect_timeout=5 al DSN, gestor global que tradueix fallades de connexió a un 503 amb cos JSON, i la sonda d'arrencada fent SELECT 1.

Segona execució:

Portada: 200 en 0.28s          ✅
API:     503 en 1.24s          ✅ {"error":"servei_no_disponible",
                                   "missatge":"No podem consultar la disponibilitat
                                   ara mateix. Torna-ho a provar d'aquí a uns minuts."}
Salut:   503                   ✅ Cloud Run deixa d'enviar trànsit
Fotos:   200                   ✅ el bucket és independent de la BD
Tauler:  200                   ✅ Looker Studio llegeix de BigQuery

Restauració: 22 minuts, amb les dades verificades (2.000 reserves, 12 refugis, última reserva de les 18:42 del dia anterior). RPO real: fins a 24 hores, perquè la còpia és diària. Documentat al runbook, i anotat com a deute tècnic: activar la recuperació a un punt en el temps en producció baixaria l'RPO a minuts per un cost addicional petit.

Reversió assajada: 38 segons, mesurats tres vegades amb resultats de 36, 38 i 41 segons.

El llançament, 2026-11-15 a les 11:00 d'un dissabte:

Hora Acció Resultat
10:40 Auditoria de seguretat + terraform plan 0 fallades, No changes
10:50 Llista prèvia al llançament 38/38 caselles
11:02 Canari al 10 % 0 errors en 10 min, p95 611 ms
11:14 50 % 0 errors, p95 598 ms
11:26 100 % 0 errors
11:30 Etiqueta v1.0.0 —
12:30 Primera hora vigilada 0 errors, 47 peticions
Dia +1 24 hores 0 errors 5xx, cost diari 0,34 €

Èxit declarat el 16 de novembre a les 11:30, amb les cinc condicions complertes: 24 hores sense errors anòmals, p95 dins l'SLO, cap alerta real, cost projectat de 10,20 €/mes davant el límit de 12 €, i el flux complet provat a mà en producció.

Les dues reversions posteriors —la del 4 i la de l'11 de novembre— són a la secció 11 amb el seu post mortem. Cap de les dues no hauria estat possible de gestionar en menys d'un minut sense el guió escrit i assajat abans.

Conclusió

El teu projecte ja no funciona: saps que funciona, i tens les proves.

Saps distingir els tres nivells d'«acabat» i en quin ets, amb nou caselles operatives que defineixen el tercer sense ambigüitat.

Tens la piràmide de proves dimensionada per a un projecte d'aquesta mida —unes 30 unitàries, 10 d'integració, 4 d'extrem a extrem—, saps què val la pena provar i què no, i tens la prova que val per totes les altres: la de concurrència sobre el punt crític del teu domini, aquella que falla amb la implementació ingènua i que justifica tota sola la teva elecció de base de dades. I saps provar contra emuladors gratuïts i bases de dades efímeres en contenidor, aplicant les mateixes migracions que en producció.

Saps provar la infraestructura: validació estàtica, el plan publicat a la pull request amb un compte de només lectura, tflint i checkov amb les seves excepcions documentades en lloc de perseguides, i sobretot la prova definitiva: destruir l'entorn i recrear-lo des de zero. És l'única que descobreix el secret que vas emplenar a mà, el recurs creat des de la consola, la dependència implícita mal ordenada i la migració que només funciona sobre una base que ja existeix.

Tens una llista de comprovació de seguretat executable amb vuit verificacions automatitzades que corre en vint segons i que a l'exemple va trobar un bucket públic que feia quatre setmanes que estava exposat. I saps verificar el TLS, les versions acceptades, les cinc capçaleres que importen i els permisos efectius —inclòs provar que un compte no té un permís perillós.

Saps fer una prova de càrrega honesta i barata: contra desenvolupament, amb topall de despesa, escalonada, distingint errors de sistema de respostes legítimes de negoci, mesurant percentils i no mitjanes, i incloent el cost de la prova mateixa entre les mètriques. I la saps interpretar: el p99 molt per sobre del p95 són arrencades en fred; els 5xx en pujar la càrrega solen ser connexions a la base de dades, i la solució no és una base més gran sinó un pool més petit.

Has assajat la fiabilitat de debò: apagant la base de dades per descobrir que el teu error trigava trenta segons i mostrava una traça de Python, restaurant una còpia verificant les dades —el pas que converteix un assaig real en un de mentida— i cronometrant, i provocant l'alerta per comprovar que arriba.

Tens una estratègia de desplegament amb la mateixa imatge promocionada per digest, canari al 10/50/100 amb criteris objectius escrits per endavant, i una reversió assajada amb cronòmetre. I saps què no reverteix la reversió de trànsit —migracions, dades, infraestructura—, d'on surt la regla que evita la majoria dels desastres: migracions compatibles cap enrere, sempre.

Tens la llista de verificació prèvia al llançament en els seus cinc blocs, el runbook amb els seus procediments numerats, el guió de les primeres 24 hores amb què mirar i en quin ordre, les cinc condicions per declarar l'èxit, i el guió d'incident amb la seva regla número u: estabilitzar abans que entendre.

I tens l'exemple de RefugioReserva amb les seves xifres reals: 14 minuts de reconstrucció, 22 de restauració, 38 segons de reversió, p95 de 634 ms amb 100 usuaris, 0,38 € de cost de prova, un bucket públic trobat i tancat, i dues reversions —una detectada per una alerta i una altra detectada només perquè existia un criteri objectiu escrit, sense cap error, sense cap alerta, amb l'SLO en verd.

A la propera lliçó, 08-05, la feina canvia de naturalesa: deixes de construir i comences a comunicar. Prepararàs la presentació per a tres públics diferents, estructuraràs quinze minuts diapositiva a diapositiva, muntaràs una demostració cronometrada amb pla B gravat, parlaràs de les teves xifres —perquè «això em costa 10 € al mes» val més que qualsevol adjectiu—, escriuràs la documentació lliurable amb les seves plantilles, t'autoavaluaràs amb la rúbrica de 08-01 amb honestedat brutal, prepararàs les respostes a les preguntes que et faran, i tancaràs amb la neteja final: un terraform destroy seguit d'un apply que torna a funcionar, que és el millor final possible que pot tenir aquest projecte.

Curs de Google Cloud Platform (GCP)

Mòdul 1: Introducció a Google Cloud Platform

Mòdul 2: Serveis principals de GCP

Mòdul 3: Xarxes i seguretat

Mòdul 4: Dades i anàlisi

Mòdul 5: Aprenentatge automàtic i IA

Mòdul 6: DevOps i monitoratge

Mòdul 7: Temes avançats de GCP

Mòdul 8: Projecte final

© Copyright 2026. Tots els drets reservats