Tu sistema funciona. Lo has visto funcionar. Has creado una reserva, ha llegado a BigQuery, el cuadro de mando la muestra y el certificado es válido.

Y sin embargo no está terminado, porque hay una diferencia enorme entre "lo he visto funcionar" y "sé que funciona". La primera afirmación se basa en una observación puntual, hecha por ti, en el mejor caso posible: sin concurrencia, sin errores, sin que se caiga nada, con datos que tú mismo has metido. La segunda se basa en pruebas.

Esta lección responde a una pregunta concreta: ¿qué significa "terminado" de verdad? Y la respuesta tiene siete partes, cada una de las cuales responde a una pregunta que alguien te va a hacer:

Pregunta La responde
"¿Y si dos personas hacen lo mismo a la vez?" Las pruebas de código
"¿Puedes volver a levantar esto desde cero?" Las pruebas de infraestructura
"¿Está expuesto algo que no debería?" Las pruebas de seguridad
"¿Cuánto tráfico aguanta?" La prueba de carga
"¿Qué pasa si se cae la base de datos?" Las pruebas de fiabilidad
"¿Cómo despliegas sin romper nada?" La estrategia de despliegue
"¿Y si algo sale mal?" El procedimiento de reversión y el guion de incidente

Ninguna de esas siete es opcional en un proyecto que quieras enseñar. Y todas se pueden hacer en un fin de semana largo, con herramientas gratuitas y sin gastar más de un par de euros.

Al terminar, tu proyecto no estará "hecho": estará lanzado, con evidencia de cada afirmación que hagas sobre él.

Contenido

  1. Qué significa "terminado" de verdad
  2. La pirámide de pruebas aplicada a este proyecto
  3. Pruebas de la infraestructura
  4. Pruebas de seguridad
  5. Pruebas de carga
  6. Pruebas de fiabilidad
  7. La estrategia de despliegue
  8. La lista de verificación previa al lanzamiento
  9. El lanzamiento y las primeras 24 horas
  10. Cuando algo sale mal: el guion de incidente
  11. El ejemplo resuelto de RefugioReserva

  1. Qué significa "terminado" de verdad

Hay tres niveles de "terminado", y conviene saber en cuál estás:

Nivel Afirmación Evidencia Puntuación típica
Funciona en mi máquina "Lo he visto funcionar" Una captura de pantalla 40-55
Funciona desplegado "Está en producción y responde" Una URL 55-70
Sé que funciona "Aquí están las pruebas, las medidas y lo que pasa cuando falla" Pruebas automatizadas, números medidos, ensayos hechos 75-95

La diferencia entre el segundo y el tercero es esta lección. Y es donde está la mitad de los puntos que separan un proyecto normal de uno que un entrevistador recuerda.

La definición operativa de "terminado"

Un entregable está terminado cuando puedes marcar las nueve casillas:

  • [ ] Hay al menos una prueba automatizada que falla si rompes la lógica central
  • [ ] La infraestructura se recrea desde cero y el sistema arranca
  • [ ] La lista de comprobación de seguridad pasa entera
  • [ ] Conoces tu límite de capacidad con un número medido
  • [ ] Has apagado una dependencia a propósito y sabes qué pasa
  • [ ] Has restaurado una copia de seguridad y lo has cronometrado
  • [ ] Has provocado la alerta y ha llegado
  • [ ] Has ensayado la reversión y sabes cuánto tarda
  • [ ] La lista previa al lanzamiento está completa

Ninguna requiere herramientas de pago. Todas requieren hacerlas de verdad, no marcarlas.

  1. La pirámide de pruebas aplicada a este proyecto

flowchart TB
    E2E["Extremo a extremo — 3-5 pruebas<br/>Contra el entorno de desarrollo real<br/>Lentas (min), caras, frágiles"]
    INT["Integración — 8-15 pruebas<br/>Contra emuladores o BD efímera<br/>Medias (s)"]
    UNI["Unitarias — 20-40 pruebas<br/>Lógica pura, sin red ni BD<br/>Rápidas (ms), gratis"]

    UNI --> INT --> E2E

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

La regla de proporción para un proyecto de este tamaño: unas 30 unitarias, unas 10 de integración y 3-5 de extremo a extremo. No hace falta más, y perseguir el 100 % de cobertura es tiempo mal invertido en un portafolio.

Qué probar y qué no:

Sí prueba No pierdas tiempo probando
Reglas de negocio (cálculo de aforo, precios, estados) Que FastAPI devuelva 200 en una ruta trivial
Casos límite (cero, negativo, máximo, vacío) Que el ORM sepa hacer un SELECT
Concurrencia en el punto crítico Getters y setters
Validación de entrada Que Terraform sepa crear un bucket
El flujo completo, una vez Cada permutación de la interfaz

2.1 Pruebas unitarias: la lógica sin dependencias

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

class TestDisponibilidad:
    """La regla de negocio central: nunca más reservas que capacidad."""

    def test_refugio_vacio_ofrece_toda_la_capacidad(self):
        assert calcular_disponibilidad(capacidad=40, ocupadas=0) == 40

    def test_descuenta_las_plazas_ocupadas(self):
        assert calcular_disponibilidad(capacidad=40, ocupadas=15) == 25

    def test_refugio_lleno_ofrece_cero(self):
        assert calcular_disponibilidad(capacidad=40, ocupadas=40) == 0

    def test_nunca_devuelve_negativo(self):
        """Caso límite: si por un error hubiera sobreventa en los datos,
        la función debe devolver 0, no un número negativo que la interfaz
        mostraría como '-3 plazas libres'."""
        assert calcular_disponibilidad(capacidad=40, ocupadas=45) == 0

class TestValidacion:
    @pytest.mark.parametrize("plazas", [0, -1, 13, 999])
    def test_rechaza_plazas_fuera_de_rango(self, plazas):
        with pytest.raises(ErrorValidacion):
            validar_reserva(plazas=plazas, fecha=date(2026, 8, 15))

    def test_rechaza_fecha_pasada(self):
        with pytest.raises(ErrorValidacion, match="pasado"):
            validar_reserva(plazas=2, fecha=date(2020, 1, 1))

    @pytest.mark.parametrize("correo", ["", "sin-arroba", "a@", "@b.com"])
    def test_rechaza_correo_invalido(self, correo):
        with pytest.raises(ErrorValidacion):
            validar_reserva(plazas=2, fecha=date(2026, 8, 15), email=correo)

test_nunca_devuelve_negativo es el tipo de prueba que vale la pena. No prueba el caso normal —eso lo prueba el uso—: prueba el caso raro que, cuando ocurra en producción a las tres de la mañana, provocará un comportamiento absurdo en la interfaz.

2.2 Pruebas de integración: contra servicios reales o emuladores

Google publica emuladores locales de Firestore, Pub/Sub, Bigtable y Datastore. Son gratis, arrancan en segundos y se comportan como el servicio real en lo esencial.

# Emuladores locales, gratis y sin tocar la nube
gcloud emulators firestore start --host-port=localhost:8080
gcloud emulators pubsub    start --host-port=localhost:8085

# Las bibliotecas cliente los detectan por variable de entorno
export FIRESTORE_EMULATOR_HOST=localhost:8080
export PUBSUB_EMULATOR_HOST=localhost:8085

Para PostgreSQL no hay emulador, pero hay algo mejor: un contenedor efímero.

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

@pytest.fixture(scope="session")
def bd_efimera():
    """PostgreSQL en un contenedor, creado y destruido por la sesión de pruebas.
    Coste: 0 €. No toca la nube."""
    subprocess.run([
        "docker", "run", "-d", "--name", "pg-pruebas",
        "-e", "POSTGRES_PASSWORD=pruebas",
        "-e", "POSTGRES_DB=reservas",
        "-p", "55432:5432", "postgres:16-alpine",
    ], check=True)

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

    # Aplicar las MISMAS migraciones que en producción
    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-pruebas"], check=True)

Aplicar las mismas migraciones que en producción es lo que da valor a la prueba: si una migración está rota, lo descubres aquí y no al desplegar.

Y la prueba que de verdad importa en RefugioReserva —la concurrencia:

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

def test_no_hay_sobreventa_con_concurrencia(bd_efimera):
    """El caso que rompe los sistemas de reservas mal hechos:
    veinte personas intentan reservar la última plaza a la vez."""
    with psycopg.connect(bd_efimera) as con:
        con.execute("INSERT INTO refugio (nombre, altitud_m, capacidad) "
                    "VALUES ('Refugio Prueba', 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="Ficticio", email="[email protected]")
            return "ok"
        except ErrorSinPlazas:
            return "sin_plazas"

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

    # EXACTAMENTE una debe triunfar. Ni cero, ni dos.
    assert resultados.count("ok") == 1, f"Sobreventa: {resultados.count('ok')} reservas"
    assert resultados.count("sin_plazas") == 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

Esta prueba justifica por sí sola el ADR-002 (elegir Cloud SQL por sus transacciones). Y si la ejecutas contra una implementación ingenua —leer disponibilidad, decidir, insertar, sin bloqueo— falla, que es exactamente lo que debe hacer. La implementación correcta usa SELECT ... FOR UPDATE sobre la fila del refugio:

def crear_reserva(con, refugio_id, fecha, plazas, titular, email):
    with con.transaction():
        # El bloqueo de fila serializa los intentos concurrentes
        cap = con.execute(
            "SELECT capacidad FROM refugio WHERE id = %s FOR UPDATE",
            (refugio_id,)).fetchone()[0]
        ocupadas = 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 ocupadas + plazas > cap:
            raise ErrorSinPlazas()
        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 Pruebas de extremo a extremo

Pocas, lentas y contra el entorno de desarrollo real. Tres o cuatro bastan:

# 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_flujo_completo_reserva_llega_a_analitica():
    """Camino crítico entero: reservar → evento → 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}",          # dato FICTICIO
        "email_titular": f"e2e-{marca}@example.com",
    })
    assert r.status_code == 201
    reserva_id = r.json()["id"]

    # La propagación por Pub/Sub no es instantánea: se sondea con límite
    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):                     # hasta 2 minutos
        n = list(bq.query(consulta, job_config=cfg).result())[0].n
        if n == 1:
            return
        time.sleep(10)
    pytest.fail("El evento no llegó a BigQuery en 2 minutos")

@pytest.mark.e2e
def test_sondas_de_salud_responden():
    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ón en Cloud Build

steps:
  - id: unitarias-e-integracion
    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]

Las unitarias y de integración van antes de construir; las de extremo a extremo después de desplegar, porque necesitan un sistema vivo.

  1. Pruebas de la infraestructura

3.1 Validación estática

terraform fmt -check -recursive        # formato consistente
terraform validate                     # sintaxis y referencias
terraform plan -detailed-exitcode      # 0=sin cambios, 2=hay cambios, 1=error

-detailed-exitcode es útil en CI: te permite fallar el build si el estado desplegado no coincide con el código.

3.2 El plan revisado en el 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 SOLO 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 como comentario
        uses: actions/github-script@v7
        with:
          script: |
            const salida = `#### Terraform plan
            \`\`\`
            ${{ steps.plan.outputs.stdout }}
            \`\`\``;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner, repo: context.repo.repo, body: salida
            });

Que la cuenta del plan sea de solo lectura (roles/viewer + acceso al bucket de estado) es importante: un plan no debe poder modificar nada, y así una pull request maliciosa tampoco.

3.3 Análisis de políticas: tflint y checkov

# tflint: errores de sintaxis, argumentos inválidos, buenas prácticas del proveedor
tflint --init && tflint --recursive

# checkov: comprobaciones de seguridad y cumplimiento sobre el código
checkov -d infra/ --framework terraform --compact

Ejemplos de lo que detectan y que se te pueden haber escapado:

Herramienta Detecta
tflint Tipo de máquina inexistente, atributo obsoleto, variable no usada
checkov Bucket sin acceso uniforme, BD sin SSL forzado, logs desactivados, servicio público sin justificación

No persigas cero hallazgos. Algunos serán falsos positivos para tu contexto (por ejemplo, que Cloud SQL no tenga alta disponibilidad es intencionado en un portafolio). Lo correcto es documentar la excepción:

# checkov:skip=CKV_GCP_79:Alta disponibilidad no aplicable; proyecto de portafolio
# con presupuesto de 12 €/mes. Ver docs/adr/ADR-002.

Añádelo al pipeline como paso informativo, no bloqueante, al principio:

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

3.4 La prueba definitiva: recrear el entorno desde cero

Esta es la prueba que separa un proyecto con Terraform de un proyecto con infraestructura como código de verdad. Vale 4 puntos de la rúbrica y es la que más gente da por supuesta sin haberla hecho nunca.

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

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

echo "=== 1. Estado 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 nada ==="
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. Migraciones y datos ==="
./scripts/migrar.sh "${ENTORNO}"
python data/seed/generar.py --entorno="${ENTORNO}"

echo "=== 6. Desplegar la última imagen conocida ==="
./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 "FALLO: la app no responde"; exit 1; }

echo "=== 8. Prueba de extremo a extremo ==="
URL_DEV="${URL}" python -m pytest app/tests/ -m e2e -q

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

Lo que descubre esta prueba, y que no descubre ninguna otra:

Hallazgo típico Por qué no se ve de otra forma
Un recurso creado a mano en la semana 2 En un entorno que ya existe, nunca lo echas de menos
Dependencias implícitas mal ordenadas En un apply incremental, el recurso previo ya estaba
Un secreto que rellenaste a mano El código lo crea vacío y nadie lo nota
Migraciones que solo funcionan sobre la BD ya existente Nunca se ejecutan desde cero
Un permiso concedido desde la consola La app funciona y no sabes por qué

Hazla al menos dos veces: una a mitad del proyecto (para descubrir los huecos cuando aún es barato arreglarlos) y otra antes del lanzamiento. Y cronometra: el tiempo de reconstrucción es un número que queda muy bien en la presentación.

  1. Pruebas de seguridad

4.1 Escaneo de la imagen

Artifact Registry incluye análisis de vulnerabilidades:

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

# Y para listar los hallazgos
gcloud artifacts docker images list-vulnerabilities <RESULTADO_DEL_SCAN> \
  --format="table(vulnerability.effectiveSeverity, vulnerability.shortDescription)"

Criterio realista: cero vulnerabilidades críticas y cero altas explotables en tu código. Las de la imagen base se arreglan actualizando la base (python:3.12-slim a su última versión) y reconstruyendo. Las medias y bajas de dependencias del sistema que no usas se documentan y se aceptan.

4.2 La lista de comprobación ejecutable

Este script es un entregable en sí mismo. Guárdalo en scripts/auditoria-seguridad.sh, ejecútalo, y guarda la salida en docs/evidencias/:

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

echo "=== AUDITORÍA DE SEGURIDAD: ${PROY} ==="

echo "[1] Roles primitivos en cuentas de servicio"
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 "Ninguna SA con owner/editor" || falla "$N cuentas con rol primitivo"

echo "[2] Claves de cuenta de servicio gestionadas por el usuario"
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 tiene $K clave(s)"
done
[ "$TOTAL" -eq 0 ] && ok "Cero claves JSON" || falla "$TOTAL claves descargables"

echo "[3] Buckets accesibles públicamente"
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 es PÚBLICO"; PUB=$((PUB+1))
  fi
done
[ "$PUB" -eq 0 ] && ok "Ningún bucket público" || falla "$PUB bucket(s) público(s)"

echo "[4] Bases de datos con 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 sin IP pública" || falla "$I TIENE IP PÚBLICA"
done

echo "[5] Secretos en el repositorio"
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 "Posibles secretos en el historial de git — REVISAR"
else
  ok "Sin secretos evidentes en el historial"
fi

echo "[6] Ficheros de credenciales en el árbol"
if find . -name "*.json" -not -path "./node_modules/*" -not -path "./.git/*" \
   -exec grep -l '"type": *"service_account"' {} \; 2>/dev/null | grep -q .; then
  falla "Hay ficheros de clave de cuenta de servicio en el repositorio"
else
  ok "Sin ficheros de credenciales"
fi

echo "[7] Servicios de Cloud Run sin autenticación"
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 es público (correcto si es la web; revisar si no)"
  else
    ok "$S requiere autenticación"
  fi
done

echo "[8] Logs de auditoría de actividad de administrador"
gcloud logging read 'logName:"cloudaudit.googleapis.com%2Factivity"' \
  --project="$PROY" --limit=1 --format="value(timestamp)" | grep -q . \
  && ok "Hay logs de auditoría" || avisa "Sin actividad reciente registrada"

echo
echo "=== RESULTADO: ${FALLOS} fallo(s) ==="
exit $((FALLOS > 0))

4.3 Verificación de TLS y cabeceras

DOMINIO="refugioreserva.example"

# Certificado: emisor y validez
echo | openssl s_client -connect "${DOMINIO}:443" -servername "${DOMINIO}" 2>/dev/null |
  openssl x509 -noout -subject -issuer -dates

# Versiones de TLS: 1.2 y 1.3 sí; 1.0 y 1.1 deben 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 "acepta" || echo "rechaza"
done

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

# HTTP debe redirigir
curl -sI "http://${DOMINIO}" | head -1
Cabecera Valor recomendado Qué evita
Strict-Transport-Security max-age=31536000; includeSubDomains Degradación a HTTP
X-Content-Type-Options nosniff Interpretación errónea de tipos
X-Frame-Options DENY Clickjacking
Content-Security-Policy Al menos default-src 'self' Inyección de scripts
Referrer-Policy strict-origin-when-cross-origin Fuga de URL a terceros

Estas se añaden en un middleware de la aplicación con diez líneas de código, y suman 2 puntos.

4.4 Revisión de permisos efectivos

# ¿Qué puede hacer realmente mi cuenta de servicio de la aplicación?
gcloud projects get-iam-policy refugio-prod --format=json |
  jq -r --arg sa "serviceAccount:[email protected]" \
     '.bindings[] | select(.members[]? == $sa) | .role'

# Comprobar un permiso concreto que NO debería tener
gcloud policy-troubleshoot iam \
  "//cloudresourcemanager.googleapis.com/projects/refugio-prod" \
  --principal-email="[email protected]" \
  --permission="resourcemanager.projects.setIamPolicy"
# Esperado: NOT_GRANTED

Probar que no tiene un permiso peligroso es tan valioso como probar que tiene los que necesita.

  1. Pruebas de carga

5.1 Cómo hacer una prueba honesta y barata

Tres reglas, en orden de importancia:

  1. Contra desarrollo, nunca contra producción, salvo que sea el propio lanzamiento y lo hayas planeado.
  2. Con un tope de gasto: --max-instances limitado y un presupuesto de duración. Una prueba de carga sin tope puede escalar a cien instancias y costarte el presupuesto del mes en veinte minutos.
  3. Empieza pequeña y sube. 10 usuarios, 50, 100. Para cuando algo se rompa: ese es el dato que buscas.

Herramientas gratuitas: hey, k6, locust, wrk. Para este proyecto, k6 es la más completa y hey la más rápida de usar.

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

const erroresNegocio = new Rate('errores_negocio');

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

const BASE = __ENV.URL;

export default function () {
  // 80% consultas, 20% reservas: perfil realista, no todo escrituras
  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 });
    erroresNegocio.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: 'Carga Ficticia',
      email_titular: `carga-${__VU}-${__ITER}@example.com`,
    }), { headers: { 'Content-Type': 'application/json' } });
    // 409 (sin plazas) es correcto, no un error del sistema
    check(r, { 'reserva 201 o 409': (x) => x.status === 201 || x.status === 409 });
    erroresNegocio.add(r.status >= 500);
  }
  sleep(Math.random() * 2);
}

El detalle que hace la prueba honesta: distinguir entre error del sistema (5xx) y respuesta legítima de negocio (409, sin plazas). Una prueba que cuenta los 409 como fallos te dará un porcentaje de error terrorífico y falso.

5.2 Qué medir

Métrica Dónde se mide Qué te dice Umbral razonable
Latencia p50 k6 Experiencia típica <300 ms
Latencia p95 k6 Experiencia mala pero común <1.000 ms
Latencia p99 k6 El peor caso real <2.000 ms
Tasa de error 5xx k6 + Monitoring Fiabilidad <1 %
Instancias activas Monitoring Saturación y escalado Sin llegar al máximo
Arranques en frío Logs de Cloud Run Latencia añadida Visible en el p99
Conexiones a la BD Cloud SQL Insights El cuello de botella habitual <80 % del máximo
Coste de la prueba Facturación Que no te arruine <1 €

No mires solo la media. La media miente: con 99 peticiones de 100 ms y una de 10 segundos, la media es 200 ms y hay un usuario que ha esperado diez segundos. Los percentiles cuentan la verdad.

5.3 Cómo interpretar y qué ajustar

Síntoma Causa probable Ajuste
p99 muy por encima del p95 Arranques en frío min-instances=1 (cuesta dinero) o aceptarlo y documentarlo
Latencia sube con la carga, sin errores Saturación de CPU Subir CPU, o bajar concurrency para que escale antes
Errores 5xx al subir la carga Agotamiento de conexiones a la BD Pool de conexiones más pequeño por instancia, o max-instances menor
Meseta de rendimiento con instancias al máximo Tope de max-instances Subirlo… y recalcular el coste
Errores desde el primer minuto Bug, no capacidad Arreglar antes de volver a probar

La aritmética de las conexiones que pilla a todo el mundo: Cloud SQL db-f1-micro admite unas 25 conexiones. Si tu aplicación abre un pool de 10 conexiones por instancia y Cloud Run escala a 5 instancias, pides 50 conexiones y la mitad falla. La solución no es una base de datos mayor: es un pool de 2-3 conexiones por instancia, porque cada instancia atiende peticiones concurrentes con una sola conexión la mayor parte del tiempo.

  1. Pruebas de fiabilidad

Tres ensayos. Ninguno lleva más de una hora y los tres son oro puro para la presentación.

6.1 Apagar una dependencia y ver qué pasa

# ENSAYO EN DESARROLLO, con la hora anotada
echo "Inicio del ensayo: $(date)" | tee -a docs/evidencias/ensayo-bd.log

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

# Observar el comportamiento
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 "Salud: %{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

Qué comportamiento buscas:

Aspecto ❌ Mal ✅ Bien
Código de estado 500 con traza de Python 503 con cuerpo JSON explicativo
Mensaje al usuario psycopg.OperationalError: could not connect... "El servicio no está disponible temporalmente"
Tiempo de respuesta 30 s (esperando el timeout) <3 s (timeout corto en el cliente de BD)
Partes no afectadas Todo caído Portada y contenido cacheado siguen sirviendo
Sonda de arranque 200 (miente) 503 (Cloud Run deja de enviar tráfico)
Logs Traza de excepción sin contexto ERROR estructurado con motivo y sin datos personales

Si tu sistema se comporta como la columna izquierda, arréglalo: un timeout corto y un manejador de excepciones que devuelva 503 con un mensaje legible son treinta líneas de código y suman en fiabilidad y en la presentación.

6.2 Restaurar la copia de seguridad y cronometrarlo

INICIO=$(date +%s)

# 1. ¿Qué copias tengo?
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 instancia NUEVA (nunca sobre la original en un ensayo)
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 los datos están (esto es lo que casi nadie hace)
./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 medido: $(( (FIN-INICIO)/60 )) minutos"

# 4. LIMPIAR — no dejes la instancia restaurada encendida
gcloud sql instances delete refugio-db-restaurada --project=refugio-dev --quiet

El paso 3 es el que distingue un ensayo real de uno de mentira. Una restauración que "termina correctamente" pero deja una base de datos vacía es un desastre disfrazado de éxito. Y el paso 4 no es opcional: una instancia restaurada olvidada es la forma más común de duplicar la factura sin darse cuenta.

Anota tres números: cuánto tardó, cuánto dato se habría perdido (RPO: la distancia entre la última copia y el momento del fallo) y qué problemas encontraste. AlpinaShop encontró cinco problemas que nadie sospechaba en su ensayo de 47 minutos; tú encontrarás algo parecido.

6.3 Provocar la alerta

Ya lo hiciste en 08-03. Si no, es el momento. Y comprueba las tres cosas:

  1. Que el incidente se abre en Monitoring.
  2. Que el correo (o el canal que sea) llega.
  3. Que el contenido incluye tus primeros pasos y sirve para actuar.

  1. La estrategia de despliegue

7.1 Entornos y promoción

flowchart LR
    DEV["Desarrollo<br/>refugio-dev<br/>Automático en cada push"]
    PRUEBA["Pruebas<br/>e2e + carga<br/>Sobre dev"]
    PROD["Producción<br/>refugio-prod<br/>Con aprobación"]

    DEV --> PRUEBA
    PRUEBA -->|misma imagen<br/>mismo digest| PROD

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

El principio innegociable: la misma imagen, identificada por su digest.

# Obtener el digest EXACTO de la imagen probada en dev
DIGEST=$(gcloud run services describe refugio-web --region=europe-west1 \
         --project=refugio-dev --format="value(spec.template.spec.containers[0].image)")
echo "Promocionando: ${DIGEST}"

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

Referenciar por digest (web@sha256:abc...) y no por etiqueta (web:v1.2) elimina toda ambigüedad: las etiquetas se pueden reasignar; un digest es el contenido.

7.2 El canario, paso a paso

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% durante 10 minutos
gcloud run services update-traffic $SERVICIO --region=$REGION --project=$PROY \
  --to-revisions="${NUEVA}=10"

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

Criterios objetivos de promoción, decididos antes y no durante:

Fase Tráfico Duración mínima Se promociona si… Se revierte si…
1 10 % 10 min 5xx < 0,5 % y p95 < 1,2× la base 5xx > 1 % o p95 > 2× la base
2 50 % 10 min Igual Igual
3 100 % 30 min de vigilancia Igual Igual

Y una regla de oro: si dudas, revierte. Revertir cuesta un minuto; un incidente cuesta una tarde.

7.3 La reversión, ensayada

#!/usr/bin/env bash
# scripts/revertir.sh — probado el 2026-10-28, tarda 38 segundos
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 "Revirtiendo ${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ón: %{http_code}\n" "${URL}/salud/arranque"
echo "✅ Revertido a ${ANTERIOR}"

Ensáyalo de verdad, con cronómetro, en un momento tranquilo. Un procedimiento de reversión que nunca se ha ejecutado es una hipótesis, y el momento de descubrir que no funciona no es durante un incidente.

Lo que la reversión de tráfico NO revierte —y hay que saberlo antes:

Cambio ¿Se revierte con el tráfico? Qué hacer
Código de la aplicación ✅ Sí Nada más
Variables de entorno ✅ Sí (van en la revisión) Nada más
Migración de base de datos ❌ No Migraciones compatibles hacia atrás
Datos ya escritos ❌ No Restaurar copia (mucho más lento)
Cambios de infraestructura ❌ No terraform apply del commit anterior

De ahí la regla que evita el 90 % de los desastres: las migraciones deben ser compatibles hacia atrás. Añade columnas, no las quites. Si hay que borrar una columna, hazlo en un despliegue posterior, cuando ninguna versión en circulación la use.

  1. La lista de verificación previa al lanzamiento

Se pasa entera, con las manos, marcando de verdad.

Técnica

  • [ ] Todas las pruebas pasan en CI (unitarias, integración, extremo a extremo)
  • [ ] terraform plan sobre producción dice No changes
  • [ ] La reconstrucción desde cero se ha ejecutado con éxito y está cronometrada
  • [ ] Las migraciones están aplicadas y son compatibles hacia atrás
  • [ ] La imagen desplegada en producción es la misma que se probó en desarrollo (digest idéntico)
  • [ ] Las sondas de salud responden correctamente
  • [ ] No hay TODO, FIXME ni print() de depuración en la ruta crítica

Seguridad

  • [ ] scripts/auditoria-seguridad.sh sale con 0 fallos
  • [ ] La imagen no tiene vulnerabilidades críticas
  • [ ] TLS válido; TLS 1.0/1.1 rechazados; HTTP redirige a HTTPS
  • [ ] Cabeceras de seguridad presentes
  • [ ] Cero claves JSON; cero roles primitivos en cuentas de servicio
  • [ ] Base de datos sin IP pública; ningún bucket público sin justificación
  • [ ] Los secretos están en Secret Manager y no en el repositorio ni en su historial
  • [ ] Los logs no contienen datos personales

Coste

  • [ ] El presupuesto está creado con alertas al 50/90/100 %
  • [ ] El coste proyectado cabe en el límite fijado en 08-01
  • [ ] max-instances está limitado en todos los servicios (tope de gasto)
  • [ ] No queda nada encendido de las pruebas (instancias restauradas, balanceadores de prueba, jobs de carga)
  • [ ] Los buckets tienen ciclo de vida; Artifact Registry tiene política de limpieza
  • [ ] Las tablas de BigQuery tienen expiración de particiones

Observabilidad

  • [ ] El panel muestra las cuatro señales con datos reales
  • [ ] Al menos una alerta ha notificado de verdad al provocarla
  • [ ] El uptime check está activo y en verde
  • [ ] El SLO calcula presupuesto de error con un valor numérico
  • [ ] Los logs son estructurados y correlacionables por traza
  • [ ] Los canales de notificación están verificados

Documentación

  • [ ] El README permite a otra persona arrancar el proyecto
  • [ ] docs/arquitectura.md refleja el sistema tal como está hoy
  • [ ] Hay ≥3 ADR, y los diagramas coinciden con las decisiones
  • [ ] docs/runbook.md tiene los procedimientos operativos
  • [ ] El diario.md está al día
  • [ ] La deuda técnica conocida está escrita y priorizada

El runbook mínimo

# Manual de operación — RefugioReserva

## Datos de contacto y accesos
Responsable: <yo>. Proyectos: refugio-dev, refugio-prod, refugio-datos.
Consola: https://console.cloud.google.com/home/dashboard?project=refugio-prod

## P1 — La aplicación devuelve errores 5xx
1. `gcloud logging read 'severity>=ERROR' --project=refugio-prod --limit=20 --freshness=15m`
2. ¿Coincide con un despliegue? `gcloud run revisions list --service=refugio-web --limit=3`
3. Si coincide → **revertir**: `./scripts/revertir.sh refugio-web` (38 s)
4. Si no → comprobar la BD: `gcloud sql instances describe refugio-db --format="value(state)"`
5. Escribir lo ocurrido en `docs/diario.md`

## P2 — La aplicación no responde en absoluto
1. `curl -sI https://refugioreserva.example`
2. ¿Servicio vivo? `gcloud run services describe refugio-web --format="value(status.conditions)"`
3. ¿DNS resuelve? `dig +short refugioreserva.example`
4. ¿Certificado válido? `openssl s_client -connect refugioreserva.example:443`

## P3 — Restaurar la base de datos
Ver `scripts/restaurar.sh`. **RTO medido: 22 minutos. RPO: hasta 24 h.**

## P4 — Coste disparado
1. Informe de facturación → agrupar por servicio y por etiqueta `componente`
2. Sospechosos habituales: instancias olvidadas, jobs en bucle, consultas de BigQuery sin filtro de partición
3. Medida inmediata: bajar `max-instances`; en último extremo, `terraform destroy` de dev

## P5 — Desplegar una versión nueva
`git push` a `main` → dev automático → aprobar en Cloud Build → canario 10/50/100

  1. El lanzamiento y las primeras 24 horas

El lanzamiento

Elige un momento en el que puedas estar mirando la pantalla la hora siguiente. No un viernes por la noche, y no antes de irte a dormir.

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

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

# 3. Canario 10%, esperar, verificar
# 4. 50%, esperar, verificar
# 5. 100%
# 6. Marcar el momento
git tag -a v1.0.0 -m "Lanzamiento del proyecto final"
git push --tags

Las primeras 24 horas: qué mirar y en qué orden

Momento Qué miras Qué te haría revertir
0-15 min Errores 5xx en los logs, en tiempo real Cualquier 5xx que no existiera antes
15-60 min Latencia p95, arranques en frío, instancias p95 al doble de la base
1-4 h Panel completo, primeras alertas, uso de conexiones a la BD Alertas disparadas
4-24 h Coste acumulado, tendencia de errores, presupuesto de error del SLO Coste proyectado por encima del límite
24 h Todo lo anterior + una prueba manual del flujo completo —
# El comando que dejas corriendo en una terminal durante la primera hora
gcloud logging tail \
  'resource.type="cloud_run_revision" AND severity>=WARNING' \
  --project=refugio-prod

Cuándo declarar el éxito. No cuando el despliegue termina, sino cuando se cumplen las cinco condiciones:

  1. 24 horas sin errores 5xx anómalos.
  2. Latencia p95 dentro del objetivo del SLO.
  3. Ninguna alerta disparada por causa real.
  4. Coste diario proyectado dentro del límite mensual.
  5. El flujo completo probado a mano por ti, en producción, una vez.

Entonces sí: escribe la fecha en el README, haz la captura del panel en verde y pasa a 08-05.

  1. Cuando algo sale mal: el guion de incidente

Adaptado de 07-06 a un proyecto de una sola persona.

flowchart TD
    A["🚨 Algo va mal"] --> B["1. ESTABILIZAR<br/>¿Puedo revertir? → revierte YA<br/>No investigues primero"]
    B --> C{"¿Se resolvió?"}
    C -- Sí --> D["2. Anotar hora, síntoma<br/>y qué hiciste"]
    C -- No --> E["3. DIAGNOSTICAR<br/>logs → despliegues → dependencias → cuotas"]
    E --> F["4. Mitigar<br/>aunque sea con una chapuza"]
    F --> D
    D --> G["5. POST-MORTEM<br/>en docs/diario.md"]
    G --> H["6. Una acción concreta<br/>para que no se repita"]

La regla número uno: estabilizar antes que entender. El impulso natural es investigar la causa, y es el orden equivocado. Si tienes una reversión que tarda 38 segundos, revierte primero y entiende después, con el sistema funcionando y sin prisa.

El post-mortem de una página

# Incidente 2026-11-04 — Errores 5xx tras el despliegue de v1.1.0

**Duración:** 19:42 → 19:51 (9 minutos)
**Impacto:** ~40 peticiones con error 500. Sin pérdida de datos.
**Detección:** alerta de tasa de errores (llegó a las 19:47, 5 min después del inicio)

## Qué pasó
El despliegue de v1.1.0 introdujo una consulta que usaba una columna
(`opinion.magnitud`) creada por la migración 003, que no se había aplicado
en producción. La aplicación arrancó bien (la sonda de arranque no toca esa
tabla) y falló solo en las peticiones a `/api/opiniones`.

## Cronología
- 19:42 Despliegue canario al 10 %
- 19:44 Primeros 500 en los logs (no los miré: estaba mirando el panel, que
        con el 10 % de tráfico apenas movió la aguja)
- 19:47 Llega la alerta
- 19:49 Ejecuto `./scripts/revertir.sh`
- 19:51 Verificado: 0 errores

## Causa raíz
El pipeline despliega la aplicación pero **no ejecuta las migraciones**.
En desarrollo las aplico a mano, así que allí funcionaba.

## Qué funcionó
- La reversión: 38 segundos, tal como estaba ensayada.
- El canario limitó el impacto al 10 % del tráfico.

## Qué no funcionó
- La alerta tardó 5 minutos. Con el 10 % de tráfico, el umbral del 5 % sobre
  el total del servicio se alcanza tarde.
- No estaba mirando los logs de la revisión canaria en concreto.

## Acciones
1. ✅ Añadir paso de migraciones al pipeline, antes del despliegue.
2. ✅ Alerta adicional por revisión, no solo por servicio.
3. ⬜ Prueba de humo específica sobre `/api/opiniones` tras desplegar.

Un post-mortem en el repositorio vale oro en una entrevista. Demuestra que has tenido un incidente real, que lo resolviste con procedimiento y que aprendiste algo concreto. Un proyecto sin ningún incidente registrado significa una de dos cosas: que no lo has usado, o que no te enteraste.

  1. El ejemplo resuelto de RefugioReserva

Resultados de la prueba de carga

Ejecutada contra desarrollo el 2026-10-27, con max-instances=5 y 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
     errores_negocio................: 0.68%
     iterations.....................: 25065
     vus_max........................: 100

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

Interpretación, punto por punto:

Observación Diagnóstico Acción
p50 = 178 ms La experiencia típica es buena Ninguna
p95 = 712 ms Dentro del objetivo Ninguna
p99 = 2,41 s (umbral 2 s) Arranques en frío al escalar de 1 a 5 instancias Ver abajo
0,68 % de 5xx 172 errores, todos entre los minutos 3 y 4 Investigado ↓
max = 8,9 s Una petición concreta El primer arranque en frío

Los 172 errores: la investigación.

$ 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

Agotamiento de conexiones. La aritmética: pool de 10 conexiones por instancia × 5 instancias = 50 conexiones solicitadas contra un límite de ~25 de db-f1-micro.

La corrección, y por qué esa y no otra:

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

No se subió el tamaño de la base de datos (habría costado dinero y no era el problema): se ajustó el pool. Cada instancia de Cloud Run atiende hasta 80 peticiones concurrentes pero pasa la mayor parte del tiempo esperando; 3 conexiones por instancia × 5 instancias = 15, cómodamente dentro del límite.

Segunda ejecución tras el ajuste:

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

Conclusión documentada: el sistema sostiene 100 usuarios concurrentes con p99 por debajo de 2 segundos y sin errores. El límite práctico está en max-instances=5, que es un tope de coste deliberado, no una limitación técnica. Con max-instances=20 aguantaría unas cuatro veces más, y costaría hasta cuatro veces más en un pico.

Sobre el p99 residual de 1,82 s: son arranques en frío. min-instances=1 los eliminaría, pero costaría unos 12 €/mes —el presupuesto entero del proyecto—. Decisión: se acepta y se documenta. El SLO de latencia se fijó en el p95, precisamente por esto.

Las dos reversiones

Reversión 1 — 2026-11-04, 19:49. La del post-mortem de la sección 10: migración 003 no aplicada en producción. Detectada por alerta a los 5 minutos, revertida en 38 segundos, impacto limitado al 10 % del tráfico por el canario. Acción correctiva: paso de migraciones en el pipeline.

Reversión 2 — 2026-11-11, 22:14. Más interesante, porque no hubo ningún error.

Síntoma: tras desplegar v1.2.0 al 10 %, la latencia p95 de la revisión
canaria pasó de 680 ms a 1.940 ms. Cero errores 5xx. Cero alertas.

Detección: la estaba mirando yo, comparando la revisión canaria con la
estable en el panel, porque el criterio de promoción incluye
"p95 < 1,2 × la base" y 1.940 no cumple.

Causa: v1.2.0 añadía el sentimiento medio a la respuesta de /api/refugios
con una subconsulta correlacionada sobre `opinion`, sin índice.

Decisión: revertir sin investigar más, a las 22:14. La corrección
(un índice y una consulta reescrita) se desplegó dos días después.

Por qué esta reversión vale más que la primera en una presentación: ningún sistema automático la habría detectado. No había errores, no se disparó ninguna alerta, el SLO de disponibilidad seguía perfecto. Se detectó porque existía un criterio objetivo de promoción escrito de antemano y porque alguien lo comparó. Es la demostración práctica de por qué los criterios se escriben antes: en el momento, con la versión nueva desplegada y ganas de terminar, la tentación de decir "1,9 segundos tampoco está tan mal" es enorme.

Resumen de evidencias

Prueba Resultado Evidencia
Unitarias 34 pruebas, 100 % pasan docs/evidencias/pytest.txt
Integración 11 pruebas, incluida la de concurrencia docs/evidencias/pytest.txt
Extremo a extremo 4 pruebas contra dev Salida en Cloud Build
Reconstrucción desde cero ✅ 14 min 20 s docs/evidencias/reconstruccion.log
Auditoría de seguridad 0 fallos, 1 aviso (servicio web público, correcto) docs/evidencias/auditoria.txt
Escaneo de imagen 0 críticas, 2 medias (base) docs/evidencias/scan.txt
TLS 1.2/1.3 sí, 1.0/1.1 no, 5 cabeceras docs/evidencias/tls.txt
Carga 100 VU, p95 634 ms, 0 % error, 0,38 € docs/evidencias/k6.txt
Caída de la BD 503 con mensaje legible en 1,2 s docs/evidencias/ensayo-bd.log
Restauración ✅ 22 minutos, datos verificados docs/evidencias/restauracion.log
Alerta provocada Correo en 6 min docs/evidencias/alerta.png
Reversión ensayada 38 segundos scripts/revertir.sh + log
Incidentes reales 2, con post-mortem docs/diario.md

Errores Comunes y Consejos

Escribir pruebas que nunca fallan. Una prueba que pasa siempre, incluso con el código roto, es peor que no tener prueba: da falsa seguridad. Comprueba cada prueba rompiendo a propósito lo que prueba.

Probar la carga contra producción sin querer. Apunta bien la URL. Y pon max-instances bajo antes de empezar: es tu tope de gasto.

Confundir un 409 con un error. Si tu prueba de carga cuenta las respuestas legítimas de negocio como fallos, tus números no significan nada.

Restaurar una copia y no comprobar los datos. Es el error más peligroso de esta lección, porque produce un ensayo "exitoso" que en realidad demuestra lo contrario.

Dejar recursos encendidos tras las pruebas. La instancia restaurada, el balanceador de prueba, la BD de carga. Ponte un recordatorio y ejecuta un inventario después de cada sesión de pruebas.

Desplegar el viernes por la noche. Clásico por un motivo: si algo va mal, o lo arreglas cansado o lo dejas roto el fin de semana.

Reconstruir la imagen para producción. Es desplegar algo que nunca has probado. Promociona por digest.

Migraciones que no son compatibles hacia atrás. Convierten una reversión de 38 segundos en una restauración de 22 minutos con pérdida de datos.

No tener criterios de promoción escritos. En el momento, siempre parece que "no está tan mal". Escríbelos antes, cuando no tienes prisa.

Consejo: guarda la salida de todo. Un directorio docs/evidencias/ con las salidas de cada prueba es lo que convierte "mi sistema es fiable" en "aquí están los números". En 08-05 vas a agradecerlo enormemente.

Consejo: haz la reconstrucción desde cero a mitad de proyecto. Descubrir en la semana 3 que faltaba un recurso en el código cuesta media hora; descubrirlo la víspera de la entrega cuesta la entrega.

Consejo: un incidente real documentado vale más que cero incidentes. No escondas los fallos: cuéntalos con su post-mortem. Es la señal más fiable de que el sistema es real y de que sabes operarlo.

Ejercicios

Ejercicio 1 — Construye la pirámide de pruebas y prueba tu infraestructura

Escribe para tu proyecto: al menos 15 pruebas unitarias de la lógica de negocio, incluyendo casos límite; al menos 5 de integración contra emuladores o una base de datos efímera en contenedor, aplicando las mismas migraciones que en producción y con una prueba de concurrencia sobre el punto crítico de tu dominio; y 3 de extremo a extremo contra desarrollo. Intégralas en el pipeline en el orden correcto.

Después ejecuta la prueba definitiva de infraestructura: destruye el entorno de desarrollo entero, reconstrúyelo desde cero, siembra los datos, despliega y verifica que funciona. Cronométralo y documenta todo lo que se rompió por el camino.

Ejercicio 2 — Audita la seguridad y mide la capacidad

Escribe y ejecuta tu script de auditoría de seguridad con al menos ocho comprobaciones automatizadas (roles primitivos, claves JSON, buckets públicos, IP pública en la BD, secretos en el historial de git, ficheros de credenciales, servicios sin autenticación, logs de auditoría). Corrige todo lo que salga en rojo. Verifica el TLS, las versiones aceptadas y las cabeceras de seguridad. Escanea tu imagen.

Después, ejecuta una prueba de carga escalonada contra desarrollo con tope de gasto, midiendo latencia p50/p95/p99, tasa de error distinguiendo errores de sistema de respuestas de negocio, instancias activas y el coste de la propia prueba. Interpreta los resultados, aplica al menos un ajuste concreto y vuelve a ejecutarla para demostrar la mejora.

Ejercicio 3 — Ensaya la fiabilidad, despliega y lanza

Ejecuta los tres ensayos de fiabilidad: apaga tu base de datos y documenta el comportamiento exacto (código, mensaje, tiempo, qué sigue funcionando), corrigiendo la degradación si no es elegante; restaura una copia de seguridad sobre una instancia nueva, verifica los datos y cronometra el RTO; y provoca tu alerta comprobando que llega.

Implementa el despliegue canario con criterios objetivos de promoción y reversión escritos de antemano, ensaya la reversión con cronómetro, pasa la lista de verificación previa al lanzamiento entera, y lanza. Documenta las primeras 24 horas.

Soluciones

Solución 1 — Pruebas y reconstrucción de RefugioReserva

La pirámide final: 34 unitarias, 11 de integración y 4 de extremo a extremo. La prueba que justifica el proyecto entero es test_no_hay_sobreventa_con_concurrencia de la sección 2.2, y su historia merece contarse: la primera implementación no la pasaba.

FAILED test_no_hay_sobreventa_con_concurrencia
AssertionError: Sobreventa: 7 reservas

Siete de veinte hilos consiguieron reservar la única plaza. La implementación original leía la disponibilidad, decidía y después insertaba, sin bloqueo: entre la lectura y la inserción, otros seis hilos habían leído lo mismo. Es exactamente el fallo que provocaba las siete sobreventas al año de la federación ficticia — el problema de negocio original, reproducido en el código.

Con SELECT ... FOR UPDATE sobre la fila del refugio, el resultado fue 1 y 19. Esta prueba, en la presentación, ocupa una diapositiva y contesta sola a la pregunta "¿por qué Cloud SQL y no Firestore?".

La reconstrucción desde cero, ejecutada dos veces:

Intento Fecha Resultado Hallazgos
1 2026-10-24 ❌ Falló 4 problemas (abajo)
2 2026-11-02 ✅ 14 min 20 s Ninguno

Los cuatro problemas del primer intento, que es donde está el valor del ejercicio:

  1. El secreto refugio-session-key se creaba vacío. Lo había rellenado a mano en la semana 2 y se me había olvidado por completo. La app arrancaba y fallaba al firmar la primera sesión. Corregido con random_password + secret_version en Terraform.
  2. Faltaba depends_on sobre el peering. Terraform intentaba crear Cloud SQL antes de que el peering estuviera listo. En los apply incrementales nunca se veía, porque el peering ya existía desde el primer día.
  3. El dataset de BigQuery estaba creado a mano. Descubierto porque la función no encontraba la tabla. No tenía la etiqueta gestionado-por, que era justo el detector previsto para esto.
  4. terraform destroy falló en el bucket de fotos porque tenía objetos y force_destroy = false. Correcto en producción, incómodo en desarrollo: se parametrizó force_destroy = var.entorno == "dev".

Ninguno de los cuatro se habría detectado de otra forma. La reconstrucción desde cero no es una prueba más: es la única que valida que tu IaC es real.

Solución 2 — Auditoría y carga de RefugioReserva

Primera ejecución de la auditoría:

=== AUDITORÍA DE SEGURIDAD: refugio-prod ===
[1] Roles primitivos           ✅ Ninguna SA con owner/editor
[2] Claves de cuenta           ✅ Cero claves JSON
[3] Buckets públicos           ❌ 1 bucket público: gs://refugio-fotos-8f2a
[4] BD con IP pública          ✅ refugio-db sin IP pública
[5] Secretos en git            ✅ Sin secretos evidentes
[6] Credenciales en el árbol   ✅ Sin ficheros de credenciales
[7] Cloud Run sin auth         ⚠️  refugio-web es público (correcto)
[8] Logs de auditoría          ✅ Hay logs

=== RESULTADO: 1 fallo ===

El bucket público. Lo había hecho público en la semana 4 para que las miniaturas se sirvieran directamente, "temporalmente". Cuatro semanas después seguía así, y además había dejado allUsers sobre el bucket entero, no solo sobre el prefijo de miniaturas: los originales de las fotos también eran accesibles para cualquiera que adivinara el nombre.

Corrección: se quitó allUsers, se activó public_access_prevention = "enforced" y las miniaturas pasaron a servirse mediante URL firmadas con caducidad de una hora, generadas por la aplicación.

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

Es exactamente el error 3 de 08-01 —dejar la seguridad para el final— manifestándose en su forma más típica: algo "temporal" que se queda. Y lo encontró un script de ocho comprobaciones que tardó veinte segundos en ejecutarse. Anotado en la autoevaluación de 08-05 como deuda pagada, con la fecha en que se introdujo y la fecha en que se detectó: cuatro semanas de exposición.

TLS y cabeceras, tras añadir el middleware:

tls1     rechaza    ✅
tls1_1   rechaza    ✅
tls1_2   acepta     ✅
tls1_3   acepta     ✅
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

Carga: los resultados y su interpretación son los de la sección 11. El resumen en una línea, que es lo que va a la presentación: 100 usuarios concurrentes, p95 de 634 ms, 0 % de errores, 0,38 € de coste de la prueba, y un cuello de botella encontrado y corregido que no era la base de datos sino el tamaño del pool de conexiones.

Solución 3 — Fiabilidad y lanzamiento de RefugioReserva

Ensayo de caída de la base de datos, primera ejecución:

Portada: 200 en 0.31s          ✅ sigue funcionando (contenido estático)
API:     500 en 30.02s         ❌ 30 segundos y una traza de Python
Salud:   200                   ❌ MIENTE: dice que está listo y no lo está

Dos problemas graves. El usuario esperaba 30 segundos para recibir un error incomprensible, y Cloud Run seguía enviando tráfico a una instancia que no podía atenderlo porque la sonda de arranque no comprobaba la base de datos.

Correcciones: connect_timeout=5 en el DSN, manejador global que traduce fallos de conexión a un 503 con cuerpo JSON, y la sonda de arranque haciendo SELECT 1.

Segunda ejecución:

Portada: 200 en 0.28s          ✅
API:     503 en 1.24s          ✅ {"error":"servicio_no_disponible",
                                   "mensaje":"No podemos consultar la disponibilidad
                                   ahora mismo. Inténtalo en unos minutos."}
Salud:   503                   ✅ Cloud Run deja de enviar tráfico
Fotos:   200                   ✅ el bucket es independiente de la BD
Panel:   200                   ✅ Looker Studio lee de BigQuery

Restauración: 22 minutos, con los datos verificados (2.000 reservas, 12 refugios, última reserva de las 18:42 del día anterior). RPO real: hasta 24 horas, porque la copia es diaria. Documentado en el runbook, y anotado como deuda técnica: activar la recuperación a un punto en el tiempo en producción bajaría el RPO a minutos por un coste adicional pequeño.

Reversión ensayada: 38 segundos, medidos tres veces con resultados de 36, 38 y 41 segundos.

El lanzamiento, 2026-11-15 a las 11:00 de un sábado:

Hora Acción Resultado
10:40 Auditoría de seguridad + terraform plan 0 fallos, No changes
10:50 Lista previa al lanzamiento 38/38 casillas
11:02 Canario al 10 % 0 errores en 10 min, p95 611 ms
11:14 50 % 0 errores, p95 598 ms
11:26 100 % 0 errores
11:30 Etiqueta v1.0.0 —
12:30 Primera hora vigilada 0 errores, 47 peticiones
Día +1 24 horas 0 errores 5xx, coste diario 0,34 €

Éxito declarado el 16 de noviembre a las 11:30, con las cinco condiciones cumplidas: 24 horas sin errores anómalos, p95 dentro del SLO, ninguna alerta real, coste proyectado de 10,20 €/mes frente al límite de 12 €, y el flujo completo probado a mano en producción.

Las dos reversiones posteriores —la del 4 y la del 11 de noviembre— están en la sección 11 con su post-mortem. Ninguna de las dos habría sido posible de gestionar en menos de un minuto sin el guion escrito y ensayado antes.

Conclusión

Tu proyecto ya no funciona: sabes que funciona, y tienes las pruebas.

Sabes distinguir los tres niveles de "terminado" y en cuál estás, con nueve casillas operativas que definen el tercero sin ambigüedad.

Tienes la pirámide de pruebas dimensionada para un proyecto de este tamaño —unas 30 unitarias, 10 de integración, 4 de extremo a extremo—, sabes qué merece la pena probar y qué no, y tienes la prueba que vale por todas las demás: la de concurrencia sobre el punto crítico de tu dominio, esa que falla con la implementación ingenua y que justifica sola tu elección de base de datos. Y sabes probar contra emuladores gratuitos y bases de datos efímeras en contenedor, aplicando las mismas migraciones que en producción.

Sabes probar la infraestructura: validación estática, el plan publicado en la pull request con una cuenta de solo lectura, tflint y checkov con sus excepciones documentadas en vez de perseguidas, y sobre todo la prueba definitiva: destruir el entorno y recrearlo desde cero. Es la única que descubre el secreto que rellenaste a mano, el recurso creado desde la consola, la dependencia implícita mal ordenada y la migración que solo funciona sobre una base que ya existe.

Tienes una lista de comprobación de seguridad ejecutable con ocho verificaciones automatizadas que corre en veinte segundos y que en el ejemplo encontró un bucket público que llevaba cuatro semanas expuesto. Y sabes verificar el TLS, las versiones aceptadas, las cinco cabeceras que importan y los permisos efectivos —incluido probar que una cuenta no tiene un permiso peligroso.

Sabes hacer una prueba de carga honesta y barata: contra desarrollo, con tope de gasto, escalonada, distinguiendo errores de sistema de respuestas legítimas de negocio, midiendo percentiles y no medias, e incluyendo el coste de la propia prueba entre las métricas. Y sabes interpretarla: el p99 muy por encima del p95 son arranques en frío; los 5xx al subir la carga suelen ser conexiones a la base de datos, y la solución no es una base mayor sino un pool menor.

Has ensayado la fiabilidad de verdad: apagando la base de datos para descubrir que tu error tardaba treinta segundos y mostraba una traza de Python, restaurando una copia verificando los datos —el paso que convierte un ensayo real en uno de mentira— y cronometrando, y provocando la alerta para comprobar que llega.

Tienes una estrategia de despliegue con la misma imagen promocionada por digest, canario al 10/50/100 con criterios objetivos escritos de antemano, y una reversión ensayada con cronómetro. Y sabes lo que la reversión de tráfico no revierte —migraciones, datos, infraestructura—, de donde sale la regla que evita la mayoría de los desastres: migraciones compatibles hacia atrás, siempre.

Tienes la lista de verificación previa al lanzamiento en sus cinco bloques, el runbook con sus procedimientos numerados, el guion de las primeras 24 horas con qué mirar y en qué orden, las cinco condiciones para declarar el éxito, y el guion de incidente con su regla número uno: estabilizar antes que entender.

Y tienes el ejemplo de RefugioReserva con sus números reales: 14 minutos de reconstrucción, 22 de restauración, 38 segundos de reversión, p95 de 634 ms con 100 usuarios, 0,38 € de coste de prueba, un bucket público encontrado y cerrado, y dos reversiones —una detectada por una alerta y otra detectada solo porque existía un criterio objetivo escrito, sin ningún error, sin ninguna alerta, con el SLO en verde.

En la próxima lección, 08-05, el trabajo cambia de naturaleza: dejas de construir y empiezas a comunicar. Vas a preparar la presentación para tres públicos distintos, estructurar quince minutos diapositiva a diapositiva, montar una demostración cronometrada con plan B grabado, hablar de tus números —porque "esto me cuesta 10 € al mes" vale más que cualquier adjetivo—, escribir la documentación entregable con sus plantillas, autoevaluarte con la rúbrica de 08-01 con honestidad brutal, preparar las respuestas a las preguntas que te van a hacer, y cerrar con la limpieza final: un terraform destroy seguido de un apply que vuelve a funcionar, que es el mejor final posible que puede tener este proyecto.

Curso de Google Cloud Platform (GCP)

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

Módulo 2: Servicios principales de GCP

Módulo 3: Redes y seguridad

Módulo 4: Datos y análisis

Módulo 5: Aprendizaje automático e IA

Módulo 6: DevOps y monitoreo

Módulo 7: Temas avanzados de GCP

Módulo 8: Proyecto final

© Copyright 2026. Todos los derechos reservados