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
- Què vol dir «acabat» de debò
- La piràmide de proves aplicada a aquest projecte
- Proves de la infraestructura
- Proves de seguretat
- Proves de càrrega
- Proves de fiabilitat
- L'estratègia de desplegament
- La llista de verificació prèvia al llançament
- El llançament i les primeres 24 hores
- Quan alguna cosa surt malament: el guió d'incident
- L'exemple resolt de RefugioReserva
- 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.
- 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:8085Per 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 == 1Aquesta 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 == 2002.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.
- 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 --compactExemples 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ó.
- 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_GRANTEDProvar que no té un permís perillós és tan valuós com provar que té els que necessita.
- Proves de càrrega
5.1 Com fer una prova honesta i barata
Tres regles, en ordre d'importància:
- Contra desenvolupament, mai contra producció, tret que sigui el llançament mateix i ho hagis planificat.
- Amb un topall de despesa:
--max-instanceslimitat 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. - 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.
- 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 --quietQuin 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 --quietEl 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:
- Que l'incident s'obre a Monitoring.
- Que el correu (o el canal que sigui) arriba.
- Que el contingut inclou els teus primers passos i serveix per actuar.
- 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=candidataReferenciar 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=10mCriteris 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.
- 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 plansobre producció diuNo 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,FIXMEniprint()de depuració a la ruta crítica
Seguretat
- [ ]
scripts/auditoria-seguridad.shsurt 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-instancesestà 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
READMEpermet a una altra persona arrencar el projecte - [ ]
docs/arquitectura.mdreflecteix el sistema tal com és avui - [ ] Hi ha ≥3 ADR, i els diagrames coincideixen amb les decisions
- [ ]
docs/runbook.mdté els procediments operatius - [ ] El
diario.mdestà 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
- 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 --tagsLes 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-prodQuan declarar l'èxit. No quan acaba el desplegament, sinó quan es compleixen les cinc condicions:
- 24 hores sense errors 5xx anòmals.
- Latència p95 dins l'objectiu de l'SLO.
- Cap alerta disparada per causa real.
- Cost diari projectat dins el límit mensual.
- 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.
- 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.
- 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 OKInterpretació, 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 connectionsEsgotament 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.
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:
- El secret
refugio-session-keyes 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 ambrandom_password+secret_versiona Terraform. - Faltava
depends_onsobre el peering. Terraform intentava crear Cloud SQL abans que el peering estigués llest. Alsapplyincrementals no es veia mai, perquè el peering ja existia des del primer dia. - 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ò. terraform destroyva fallar al bucket de fotos perquè tenia objectes iforce_destroy = false. Correcte en producció, incòmode a desenvolupament: es va parametritzarforce_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-originCà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 BigQueryRestauració: 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
- Què és Google Cloud Platform?
- Configuració del teu compte de GCP
- Descripció general de la consola de GCP
- Projectes, jerarquia de recursos i facturació
- Regions, zones i model de responsabilitat compartida
- Cloud Shell i la CLI de gcloud
Mòdul 2: Serveis principals de GCP
- Compute Engine: màquines virtuals a Google Cloud
- Cloud Storage: emmagatzematge d'objectes
- Cloud SQL: bases de dades relacionals gestionades
- App Engine: plataforma com a servei
- Google Kubernetes Engine (GKE)
- Bases de dades NoSQL: Firestore, Bigtable i Spanner
- Com triar el servei de còmput adequat
Mòdul 3: Xarxes i seguretat
- Xarxes VPC
- Balanceig de càrrega al núvol
- Cloud CDN
- Gestió d'identitat i accés (IAM)
- Cloud Armor
- Secrets i xifratge: Secret Manager i Cloud KMS
- Cloud DNS, certificats TLS i publicació segura de serveis
Mòdul 4: Dades i anàlisi
- BigQuery: el magatzem de dades analític
- Cloud Dataflow: processament de dades per lots i en temps real
- Cloud Dataproc: Spark i Hadoop gestionats
- Cloud Pub/Sub: missatgeria asíncrona
- Cloud Data Fusion: integració de dades sense codi
- Orquestració de pipelines amb Cloud Composer i Workflows
- Govern de les dades i taulers amb Dataplex i Looker Studio
Mòdul 5: Aprenentatge automàtic i IA
- Vertex AI: la plataforma d'aprenentatge automàtic de GCP
- AutoML: models a mida sense escriure codi
- TensorFlow a GCP: entrenament i servei de models
- API de llenguatge natural
- API de visió
- IA generativa a Vertex AI: models Gemini i incrustacions
- MLOps: del model al producte amb Vertex AI Pipelines
Mòdul 6: DevOps i monitoratge
- Cloud Build: integració contínua a GCP
- Cloud Source Repositories i gestió del codi font
- Cloud Functions: funcions sense servidor
- Cloud Monitoring (abans Stackdriver): mètriques, taulers i alertes
- Cloud Deployment Manager i infraestructura com a codi nativa
- Cloud Logging i Cloud Trace: registres, traces i diagnòstic
- Terraform a GCP: infraestructura com a codi a la pràctica
Mòdul 7: Temes avançats de GCP
- Híbrid i multinúvol amb Anthos
- Computació sense servidor amb Cloud Run
- Xarxes avançades: VPC compartida, aparellament i connectivitat híbrida
- Bones pràctiques de seguretat
- Gestió i optimització de costos
- Fiabilitat: SLO, alta disponibilitat i recuperació de desastres
- Govern a escala: organització, polítiques i auditoria
