Tienes el plano. Ahora toca construir.
Y aquí es donde la mayoría de proyectos personales se tuercen, por una razón muy concreta: la construcción no falla por dificultad técnica, falla por orden. Quien empieza por la aplicación acaba con una app bonita colgada de una infraestructura creada a mano que no se puede reproducir. Quien empieza por la seguridad al revés —abriendo todo "para que funcione" y prometiéndose cerrarlo luego— nunca lo cierra. Quien no controla el gasto descubre en la semana tres que se ha comido el crédito entero.
Esta lección es un manual de trabajo. No explica qué es Cloud Run ni qué es Terraform: eso ya lo viste en 07-02 y en 06-07. Explica en qué orden se ponen las piezas, por qué ese orden y no otro, y cómo compruebas después de cada paso que lo que has hecho está bien.
Está organizada en nueve fases, de la 0 a la 8. Cada fase tiene su objetivo, sus pasos, sus comandos y —lo más importante para trabajar solo— su criterio de "hecho" verificable: un comando que ejecutas y una salida que esperas. Si el comando no da lo esperado, la fase no está terminada, por mucho que "parezca" que funciona.
Al final de la lección tendrás tu sistema en marcha, reproducible desde cero, con entrega automatizada, seguro y observado. Y sabrás exactamente en qué punto estás en cada momento, que cuando trabajas solo es la mitad de la batalla.
Contenido
- El orden de construcción y por qué es ese
- Fase 0 — Preparación: proyectos, APIs, presupuesto y repositorio
- Fase 1 — La base con Terraform: estado, proveedor, módulos y red
- Fase 2 — Identidad: cuentas de servicio, roles mínimos y federación sin claves
- Fase 3 — Datos: base de datos privada, buckets y migraciones como código
- Fase 4 — La aplicación: contenedor, configuración, secretos, salud y logs
- Fase 5 — La entrega automatizada: pruebas, construcción, despliegue y promoción
- Fase 6 — La capa de datos y de IA
- Fase 7 — Exposición: dominio, TLS y protección
- Fase 8 — Observabilidad: logs, panel, alertas y SLO
- Prácticas transversales durante toda la implementación
- Qué hacer cuando te atascas: el método de cinco pasos
- Registro de progreso y criterios de "hecho"
- El aviso realista: la primera vez todo tarda el doble
- El orden de construcción y por qué es ese
flowchart TD
F0["Fase 0 — Preparación<br/>proyectos · APIs · presupuesto · repo"]
F1["Fase 1 — Base<br/>estado Terraform · red · firewall"]
F2["Fase 2 — Identidad<br/>cuentas de servicio · roles · WIF"]
F3["Fase 3 — Datos<br/>BD privada · buckets · migraciones"]
F4["Fase 4 — Aplicación<br/>contenedor · secretos · salud · logs"]
F5["Fase 5 — Entrega<br/>CI/CD · pruebas · promoción"]
F6["Fase 6 — Datos e IA<br/>eventos · BigQuery · panel · modelo"]
F7["Fase 7 — Exposición<br/>dominio · TLS · protección"]
F8["Fase 8 — Observabilidad<br/>panel · alertas · SLO"]
F0 --> F1 --> F2 --> F3 --> F4 --> F5
F4 --> F6
F5 --> F7
F6 --> F8
F7 --> F8
style F0 fill:#e8f0fe
style F4 fill:#fef7e0
style F8 fill:#e6f4ea
La justificación de cada dependencia, que es lo que hace del orden algo racional y no una costumbre:
| Fase | Depende de | Por qué exactamente |
|---|---|---|
| 0. Preparación | Nada | El projectId es irreversible y las APIs tardan minutos en propagarse. Y el presupuesto tiene que existir antes de que puedas gastar |
| 1. Base | 0 | Terraform necesita un proyecto con la API de Resource Manager activa y un bucket para el estado. La red no se puede reconfigurar sin destruir lo que hay dentro |
| 2. Identidad | 1 | Las cuentas de servicio son de proyecto, pero los permisos sobre recursos concretos (bucket, secreto, topic) requieren que esos recursos existan… o que los crees en el mismo apply. Va antes que los datos porque la BD se crea ya con su usuario y su secreto |
| 3. Datos | 1, 2 | Cloud SQL con IP privada exige la red y el peering de acceso privado a servicios ya creados. Y su contraseña va a Secret Manager, que necesita la identidad que la leerá |
| 4. Aplicación | 3 | La app no arranca sin BD ni sin secretos. Desplegarla antes obliga a desplegarla dos veces |
| 5. Entrega | 4 | No puedes automatizar un despliegue que nunca has hecho a mano. Primero se hace una vez y se entiende; después se automatiza |
| 6. Datos e IA | 4 | Los eventos los emite la aplicación. Sin aplicación no hay eventos que procesar |
| 7. Exposición | 5 | El dominio apunta a un servicio estable. Si la app aún cambia de forma cada día, la propagación de DNS y el certificado son ruido |
| 8. Observabilidad | 6, 7 | Se observa el sistema completo. Un panel montado sobre medio sistema hay que rehacerlo |
La regla general detrás de todo esto: se construye de lo irreversible a lo reversible, y de lo que no depende de nada a lo que depende de todo. El projectId no se cambia; un panel de Monitoring se rehace en diez minutos. Por eso el primero va en la fase 0 y el segundo en la 8.
La excepción legítima: si en algún momento te bloqueas más de una hora en una fase, salta a la siguiente que no dependa de ella y vuelve después. La fase 6 (datos e IA) y la 7 (exposición) son independientes entre sí; la 8 depende de ambas. Documenta el salto en el diario.
- Fase 0 — Preparación
Objetivo: dejar el terreno listo para que Terraform pueda trabajar, con el gasto controlado desde el primer minuto.
Tiempo estimado: 1-2 horas.
2.1 Crear los proyectos con los nombres decididos en 08-02
# Variables del proyecto — ajústalas a lo que decidiste en el diseño
export PROY_BASE="refugio"
export PROY_DEV="${PROY_BASE}-dev"
export PROY_PROD="${PROY_BASE}-prod"
export PROY_DATOS="${PROY_BASE}-datos"
export REGION="europe-west1"
export BILLING_ACCOUNT="0X0X0X-0X0X0X-0X0X0X" # gcloud billing accounts list
# Crear los proyectos
for P in "${PROY_DEV}" "${PROY_PROD}" "${PROY_DATOS}"; do
gcloud projects create "${P}" --name="${P}"
gcloud billing projects link "${P}" --billing-account="${BILLING_ACCOUNT}"
done
# Comprobar
gcloud projects list --filter="projectId:${PROY_BASE}-*" \
--format="table(projectId, name, lifecycleState)"⚠️ Antes de pulsar Enter, lee los nombres en voz alta. Es la última oportunidad. Un
projectIdno se cambia, no se reutiliza ni después de borrarlo, y aparecerá en cada captura de pantalla de tu presentación.
Si gcloud projects create falla con already exists, no es que tú lo tengas: es que alguien en el mundo lo tiene, porque el espacio de nombres es global. Añade un sufijo corto y distintivo, no un -2.
2.2 Activar las APIs
Las APIs tardan de segundos a minutos en propagarse. Actívalas todas de golpe ahora y te ahorras diez interrupciones después:
APIS=(
cloudresourcemanager.googleapis.com # Terraform necesita esta la primera
serviceusage.googleapis.com
iam.googleapis.com
iamcredentials.googleapis.com
compute.googleapis.com # red, direcciones, firewall
servicenetworking.googleapis.com # peering para Cloud SQL privada
vpcaccess.googleapis.com # conector serverless
run.googleapis.com
artifactregistry.googleapis.com
cloudbuild.googleapis.com
sqladmin.googleapis.com
secretmanager.googleapis.com
storage.googleapis.com
pubsub.googleapis.com
bigquery.googleapis.com
cloudfunctions.googleapis.com
eventarc.googleapis.com
cloudscheduler.googleapis.com
monitoring.googleapis.com
logging.googleapis.com
cloudtrace.googleapis.com
language.googleapis.com # sustituye por tu API de IA
)
for P in "${PROY_DEV}" "${PROY_PROD}"; do
gcloud services enable "${APIS[@]}" --project="${P}"
done
gcloud services enable bigquery.googleapis.com storage.googleapis.com \
--project="${PROY_DATOS}"Criterio de "hecho":
2.3 El presupuesto, antes que nada
Ya lo creaste en 08-01. Si no, hazlo ahora, antes de crear el primer recurso de pago:
gcloud billing budgets create \
--billing-account="${BILLING_ACCOUNT}" \
--display-name="Proyecto final ${PROY_BASE}" \
--budget-amount=12EUR \
--threshold-rule=percent=0.5 \
--threshold-rule=percent=0.9 \
--threshold-rule=percent=1.0 \
--filter-projects="projects/${PROY_DEV}","projects/${PROY_PROD}","projects/${PROY_DATOS}"
gcloud billing budgets list --billing-account="${BILLING_ACCOUNT}"Y activa la exportación de facturación a BigQuery desde la consola (Facturación → Exportación de facturación). Tarda hasta 24 horas en empezar a poblar datos, así que cuanto antes se active, antes tendrás histórico para la sección de coste de tu presentación.
2.4 El repositorio y su estructura
Si seguiste el ejercicio 3 de 08-01, ya lo tienes. Ampliado para la implementación:
mi-proyecto/
├── README.md
├── .gitignore
├── cloudbuild.yaml # pipeline de dev
├── cloudbuild-prod.yaml # promoción a prod
├── app/
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── src/
│ │ ├── main.py
│ │ ├── db.py
│ │ ├── eventos.py
│ │ └── observabilidad.py
│ └── tests/
│ ├── test_unitarios.py
│ └── test_integracion.py
├── infra/
│ ├── modules/
│ │ ├── red/
│ │ ├── identidad/
│ │ ├── datos/
│ │ ├── servicio/
│ │ └── observabilidad/
│ ├── envs/
│ │ ├── dev/{main.tf,variables.tf,terraform.tfvars,backend.tf}
│ │ └── prod/{main.tf,variables.tf,terraform.tfvars,backend.tf}
│ └── bootstrap/ # crea el bucket de estado. Se aplica una vez
├── data/
│ ├── seed/generar.py
│ ├── migraciones/
│ │ ├── 001_esquema_inicial.sql
│ │ └── 002_indices.sql
│ └── sql/analitica.sql
├── ml/
│ └── analizar_opiniones.py
└── docs/
├── arquitectura.md
├── runbook.md
├── diario.md
└── adr/El README que sí se lee. Escríbelo ahora, no al final, y con esta estructura:
# RefugioReserva Plataforma de reservas de refugios de montaña. Proyecto final del curso de GCP. **Todos los datos son ficticios.** 🔗 **Demo:** https://refugioreserva.example 📊 **Cuadro de mando:** [Looker Studio](...) 📐 **Arquitectura:** [docs/arquitectura.md](docs/arquitectura.md) ## Qué hace Un excursionista consulta disponibilidad de plazas en 12 refugios y reserva. El guarda ve las reservas del día. La federación consulta indicadores. ## Arquitectura en una línea Cloud Run (Python/FastAPI) → Cloud SQL PostgreSQL privada · fotos en Cloud Storage · eventos por Pub/Sub → BigQuery → Looker Studio · sentimiento de opiniones con la API de Natural Language. Todo en Terraform, desplegado por Cloud Build sin claves (WIF). ## Cómo levantarlo desde cero
cd infra/bootstrap && terraform init && terraform apply cd ../envs/dev && terraform init && terraform apply make seed
## Coste **~10 €/mes.** Ver [docs/adr/ADR-006-sin-balanceador.md](docs/adr/) para la decisión de coste más relevante. ## Estado y deuda técnica conocida Ver la sección "Deuda técnica" más abajo. Sí, la hay, y está priorizada.
Criterio de "hecho" de la fase 0:
| Comprobación | Comando | Resultado esperado |
|---|---|---|
| Proyectos creados y facturables | gcloud billing projects describe ${PROY_PROD} |
billingEnabled: true |
| APIs activas | gcloud services list --enabled --project=${PROY_PROD} |
≥25 líneas |
| Presupuesto creado | gcloud billing budgets list --billing-account=${BILLING_ACCOUNT} |
1 presupuesto |
Repositorio con estructura y README |
git log --oneline |
≥2 commits |
- Fase 1 — La base con Terraform
Objetivo: que a partir de aquí todo se cree con código.
Tiempo estimado: 4-8 horas la primera vez.
3.1 El bucket de estado (bootstrap)
Hay un problema del huevo y la gallina: Terraform guarda su estado en un bucket, pero el bucket también hay que crearlo. La solución estándar es un pequeño módulo bootstrap con estado local que se aplica una sola vez:
# infra/bootstrap/main.tf
terraform {
required_version = ">= 1.9"
required_providers {
google = { source = "hashicorp/google", version = "~> 6.0" }
}
# Sin backend: estado local, se aplica una vez y se sube el .tfstate cifrado
# o simplemente se acepta que este módulo se re-crea a mano si se pierde.
}
provider "google" {
project = var.proyecto_estado
region = var.region
}
resource "google_storage_bucket" "estado" {
name = "${var.prefijo}-terraform-estado"
location = var.region
force_destroy = false
uniform_bucket_level_access = true
public_access_prevention = "enforced"
versioning { enabled = true } # ← imprescindible: permite recuperar un estado corrupto
lifecycle_rule {
condition { num_newer_versions = 20 }
action { type = "Delete" }
}
labels = {
proyecto = var.prefijo
componente = "iac"
gestionado-por = "terraform"
}
}Las tres opciones que no son negociables:
versioning.enabled = true: si el estado se corrompe (y pasa), la versión anterior te salva el proyecto.public_access_prevention = "enforced": el estado de Terraform contiene identificadores de recursos y, a veces, valores sensibles. Nunca público.force_destroy = false: evita que undestroyaccidental se lleve el estado de todo lo demás.
cd infra/bootstrap
terraform init && terraform apply
gcloud storage buckets describe gs://refugio-terraform-estado \
--format="value(versioning.enabled,iamConfiguration.publicAccessPrevention)"
# Esperado: True enforced3.2 El backend remoto y el proveedor fijado
# infra/envs/prod/backend.tf
terraform {
required_version = ">= 1.9"
backend "gcs" {
bucket = "refugio-terraform-estado"
prefix = "envs/prod" # dev usa "envs/dev": estados separados
}
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0" # fijado. NUNCA sin versión
}
random = { source = "hashicorp/random", version = "~> 3.6" }
}
}Por qué prefix distinto por entorno y no dos buckets: un bucket, dos prefijos, dos estados completamente independientes. Es más simple de gestionar y no hay riesgo de que un apply de dev toque prod, porque son ficheros de estado distintos.
Por qué fijar la versión del proveedor: sin version, Terraform coge la última cada vez que haces init. Un martes cualquiera sale la versión 7.0 con cambios incompatibles y tu plan propone destruir media infraestructura. Con ~> 6.0 te quedas en la rama 6 hasta que decidas subir tú, conscientemente y con un commit que lo diga.
3.3 Módulos propios
La regla práctica: crea un módulo cuando vayas a usar la misma cosa en dos entornos. Con dev y prod, eso es casi todo.
# infra/modules/red/main.tf
variable "proyecto" { type = string }
variable "prefijo" { type = string }
variable "region" { type = string }
variable "cidr_app" { type = string }
variable "cidr_conector" { type = string }
variable "cidr_privado" { type = string }
variable "etiquetas" { type = map(string) }
resource "google_compute_network" "vpc" {
project = var.proyecto
name = "${var.prefijo}-vpc"
auto_create_subnetworks = false # ← NUNCA la red default
routing_mode = "REGIONAL"
}
resource "google_compute_subnetwork" "app" {
project = var.proyecto
name = "${var.prefijo}-app-${substr(var.region, 0, 8)}"
network = google_compute_network.vpc.id
region = var.region
ip_cidr_range = var.cidr_app
private_ip_google_access = true # salida a APIs de Google sin IP pública
}
# Conector de acceso serverless: exige un /28 exacto
resource "google_vpc_access_connector" "conector" {
project = var.proyecto
name = "${var.prefijo}-conn"
region = var.region
ip_cidr_range = var.cidr_conector
network = google_compute_network.vpc.name
min_instances = 2
max_instances = 3 # tope de coste
}
# --- Acceso privado a servicios (necesario para Cloud SQL con IP privada) ---
resource "google_compute_global_address" "rango_privado" {
project = var.proyecto
name = "${var.prefijo}-rango-privado"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
prefix_length = 20
address = split("/", var.cidr_privado)[0]
network = google_compute_network.vpc.id
}
resource "google_service_networking_connection" "peering" {
network = google_compute_network.vpc.id
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.rango_privado.name]
}
# --- Firewall: denegación por defecto explícita ---
resource "google_compute_firewall" "denegar_entrada" {
project = var.proyecto
name = "${var.prefijo}-denegar-entrada"
network = google_compute_network.vpc.name
direction = "INGRESS"
priority = 65534
deny { protocol = "all" }
source_ranges = ["0.0.0.0/0"]
log_config { metadata = "INCLUDE_ALL_METADATA" }
}
output "red_id" { value = google_compute_network.vpc.id }
output "red_nombre" { value = google_compute_network.vpc.name }
output "conector_id" { value = google_vpc_access_connector.conector.id }
output "peering_listo" { value = google_service_networking_connection.peering.id }Los dos recursos que la gente olvida están ahí: google_compute_global_address con purpose = "VPC_PEERING" y google_service_networking_connection. Sin ellos, Cloud SQL con IP privada falla con un error que no menciona el peering por ningún lado.
Y el output "peering_listo" no es decorativo: sirve para que el módulo de datos pueda declarar depends_on sobre él y Terraform no intente crear la base de datos antes de que el peering exista.
3.4 Variables por entorno
# infra/envs/prod/terraform.tfvars
proyecto = "refugio-prod"
entorno = "prod"
prefijo = "refugio"
region = "europe-west1"
cidr_app = "10.20.0.0/24"
cidr_conector = "10.20.8.0/28"
cidr_privado = "10.20.16.0/20"
bd_tier = "db-f1-micro"
bd_alta_disp = false
bd_backup_retencion = 7
run_min_instancias = 0
run_max_instancias = 5# infra/envs/dev/terraform.tfvars — mismas claves, valores más pequeños
proyecto = "refugio-dev"
entorno = "dev"
prefijo = "refugio"
region = "europe-west1"
cidr_app = "10.10.0.0/24"
cidr_conector = "10.10.8.0/28"
cidr_privado = "10.10.16.0/20"
bd_tier = "db-f1-micro"
bd_alta_disp = false
bd_backup_retencion = 1
run_min_instancias = 0
run_max_instancias = 2La regla: los dos ficheros tienen exactamente las mismas claves. Si dev tiene una variable que prod no tiene, los entornos han divergido y la promoción deja de ser fiable.
3.5 El primer apply
cd infra/envs/dev
terraform init
terraform fmt -recursive ../.. # formato consistente, gratis
terraform validate # sintaxis y referencias
terraform plan -out=plan.tfplan # LEER LA SALIDA ENTERA
terraform apply plan.tfplanLee el plan entero. Siempre. Es la práctica transversal número uno de la sección 11, y la primera vez es cuando más se aprende: el plan te enseña exactamente qué recursos implica cada bloque que has escrito.
Criterio de "hecho" de la fase 1:
| Comprobación | Comando | Esperado |
|---|---|---|
| Estado remoto | gcloud storage ls gs://refugio-terraform-estado/envs/dev/ |
default.tfstate |
| Red creada, no la default | gcloud compute networks list --project=${PROY_DEV} |
refugio-vpc, sin default |
| Conector activo | gcloud compute networks vpc-access connectors list --region=${REGION} --project=${PROY_DEV} |
estado READY |
| Peering establecido | gcloud services vpc-peerings list --network=refugio-vpc --project=${PROY_DEV} |
1 peering |
| Plan limpio | terraform plan |
No changes. |
Esa última fila es la que importa: un plan que dice No changes significa que el código y la realidad coinciden. Es el criterio que se comprueba en 08-04 y que vale 3 puntos de la rúbrica.
- Fase 2 — Identidad
Objetivo: cada carga de trabajo con su identidad y sus permisos mínimos, y el CI/CD funcionando sin una sola clave descargada.
Tiempo estimado: 3-5 horas (2 de ellas, la primera vez que configuras WIF).
4.1 Cuentas de servicio y roles mínimos
# infra/modules/identidad/main.tf
resource "google_service_account" "web" {
project = var.proyecto
account_id = "sa-${var.prefijo}-web"
display_name = "Cuenta de la aplicación web"
description = "Cloud Run: lee BD, lee 2 secretos, escribe fotos, publica eventos"
}
# --- Roles a nivel de PROYECTO: sólo los que no se pueden acotar más ---
resource "google_project_iam_member" "web_proyecto" {
for_each = toset([
"roles/cloudsql.client",
"roles/logging.logWriter",
"roles/cloudtrace.agent",
"roles/monitoring.metricWriter",
])
project = var.proyecto
role = each.value
member = "serviceAccount:${google_service_account.web.email}"
}
# --- Roles acotados AL RECURSO: así es como se hace bien ---
resource "google_secret_manager_secret_iam_member" "web_secretos" {
for_each = toset(var.secretos_de_la_web) # ["refugio-db-password", "refugio-session-key"]
project = var.proyecto
secret_id = each.value
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.web.email}"
}
resource "google_storage_bucket_iam_member" "web_fotos" {
bucket = var.bucket_fotos
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.web.email}"
}
resource "google_pubsub_topic_iam_member" "web_publica" {
project = var.proyecto
topic = var.topic_eventos
role = "roles/pubsub.publisher"
member = "serviceAccount:${google_service_account.web.email}"
}La diferencia que separa un proyecto bueno de uno mediocre está en los nombres de esos recursos. google_project_iam_member con secretAccessor da acceso a todos los secretos del proyecto, presentes y futuros. google_secret_manager_secret_iam_member lo da a ese secreto. Es la misma cantidad de código y una diferencia enorme de superficie de ataque.
4.2 Workload Identity Federation: CI/CD sin claves
Este es el punto que más te va a diferenciar. La idea, en dos frases: en vez de descargar una clave JSON y guardarla en GitHub, se establece una relación de confianza entre GCP y el proveedor de identidad de tu CI. El CI presenta un token firmado que dice "soy la rama main del repo usuario/refugioreserva", y GCP lo canjea por credenciales temporales.
Cero claves. Cero rotación. Cero riesgo de fuga.
# infra/modules/identidad/wif.tf
resource "google_iam_workload_identity_pool" "github" {
project = var.proyecto
workload_identity_pool_id = "gh-pool"
display_name = "GitHub Actions"
}
resource "google_iam_workload_identity_pool_provider" "github" {
project = var.proyecto
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "gh-provider"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository" = "assertion.repository"
"attribute.ref" = "assertion.ref"
}
# CONDICIÓN OBLIGATORIA: sin esto, CUALQUIER repositorio de GitHub
# del mundo podría autenticarse contra tu proyecto.
attribute_condition = "assertion.repository == '${var.github_repo}'"
oidc { issuer_uri = "https://token.actions.githubusercontent.com" }
}
resource "google_service_account" "deploy" {
project = var.proyecto
account_id = "sa-${var.prefijo}-deploy"
display_name = "Despliegue desde CI"
}
# Sólo la rama main del repositorio puede impersonar la cuenta de despliegue
resource "google_service_account_iam_member" "deploy_wif" {
service_account_id = google_service_account.deploy.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/${var.github_repo}"
}
resource "google_project_iam_member" "deploy_roles" {
for_each = toset([
"roles/run.developer",
"roles/artifactregistry.writer",
])
project = var.proyecto
role = each.value
member = "serviceAccount:${google_service_account.deploy.email}"
}
# EL PERMISO QUE SE OLVIDA SIEMPRE:
# para desplegar un servicio que corre como sa-web, deploy debe poder "actuar como" ella.
resource "google_service_account_iam_member" "deploy_actua_como_web" {
service_account_id = google_service_account.web.name
role = "roles/iam.serviceAccountUser"
member = "serviceAccount:${google_service_account.deploy.email}"
}⚠️ El
attribute_conditionno es opcional. Sin él, el proveedor acepta tokens de cualquier repositorio de GitHub del planeta, y cualquiera que sepa el nombre de tu pool puede desplegar en tu proyecto. Es un fallo de seguridad real y documentado que aparece en muchos tutoriales por omisión.
Y en el flujo de GitHub Actions:
# .github/workflows/deploy.yml
permissions:
contents: read
id-token: write # imprescindible para que GitHub emita el token OIDC
jobs:
desplegar:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/gh-pool/providers/gh-provider
service_account: [email protected]
# A partir de aquí, gcloud está autenticado. Sin ningún secreto en GitHub.Criterio de "hecho" de la fase 2:
# 1. Ninguna cuenta de servicio con rol primitivo
gcloud projects get-iam-policy "${PROY_PROD}" --format=json | \
jq -r '.bindings[] | select(.role | test("roles/(owner|editor)")) |
.members[] | select(startswith("serviceAccount:"))'
# Esperado: vacío
# 2. Cero claves de cuenta de servicio gestionadas por el usuario
for SA in $(gcloud iam service-accounts list --project="${PROY_PROD}" --format="value(email)"); do
echo -n "$SA: "
gcloud iam service-accounts keys list --iam-account="$SA" \
--managed-by=user --format="value(name)" | wc -l
done
# Esperado: 0 en todasGuarda la salida de esos dos comandos: son evidencia directa de 6 puntos del bloque D de la rúbrica.
- Fase 3 — Datos
Objetivo: base de datos gestionada, privada, con su contraseña en Secret Manager, y el esquema como código versionado.
Tiempo estimado: 3-5 horas.
5.1 La base de datos, sin IP pública
# infra/modules/datos/sql.tf
resource "random_password" "bd" {
length = 32
special = true
}
resource "google_secret_manager_secret" "bd_password" {
project = var.proyecto
secret_id = "${var.prefijo}-db-password"
replication { auto {} }
}
resource "google_secret_manager_secret_version" "bd_password" {
secret = google_secret_manager_secret.bd_password.id
secret_data = random_password.bd.result
}
resource "google_sql_database_instance" "principal" {
project = var.proyecto
name = "${var.prefijo}-db"
region = var.region
database_version = "POSTGRES_16"
# El peering debe existir antes. Caso legítimo de depends_on explícito.
depends_on = [var.peering_listo]
settings {
tier = var.bd_tier
availability_type = var.bd_alta_disp ? "REGIONAL" : "ZONAL"
disk_size = 10
disk_type = "PD_HDD" # más barato; suficiente a este volumen
disk_autoresize = true
ip_configuration {
ipv4_enabled = false # ← SIN IP PÚBLICA
private_network = var.red_id
ssl_mode = "ENCRYPTED_ONLY"
}
backup_configuration {
enabled = true
start_time = "03:00"
point_in_time_recovery_enabled = var.entorno == "prod"
backup_retention_settings { retained_backups = var.bd_backup_retencion }
}
maintenance_window { day = 7, hour = 4 } # domingo de madrugada
database_flags {
name = "cloudsql.iam_authentication"
value = "on"
}
user_labels = var.etiquetas
}
# Protección contra el destroy accidental en producción
deletion_protection = var.entorno == "prod"
}
resource "google_sql_database" "app" {
project = var.proyecto
instance = google_sql_database_instance.principal.name
name = "reservas"
}
resource "google_sql_user" "app" {
project = var.proyecto
instance = google_sql_database_instance.principal.name
name = "app"
password = random_password.bd.result
}Cinco detalles que cuentan:
ipv4_enabled = falsees la línea que vale 2 puntos del bloque D y, más importante, la que hace que tu base de datos no sea escaneable desde internet.- La contraseña se genera con
random_passwordy nunca la ves. Va directa a Secret Manager. Nadie la teclea, nadie la copia, nadie la sube por error. depends_onsobre el peering es uno de los pocos casos dondedepends_onexplícito es correcto: la dependencia es real pero Terraform no puede inferirla del grafo.deletion_protectioncondicionada al entorno: dev se destruye los viernes, prod no se destruye por accidente.disk_type = "PD_HDD": para un proyecto de portafolio, el SSD no aporta nada y cuesta más.
5.2 Buckets con ciclo de vida
resource "random_id" "sufijo" { byte_length = 2 }
resource "google_storage_bucket" "fotos" {
project = var.proyecto
name = "${var.prefijo}-fotos-${random_id.sufijo.hex}" # nombre global
location = var.region
uniform_bucket_level_access = true
public_access_prevention = "enforced"
versioning { enabled = true }
lifecycle_rule {
condition { age = 90, matches_storage_class = ["STANDARD"] }
action { type = "SetStorageClass", storage_class = "NEARLINE" }
}
lifecycle_rule {
condition { age = 365, matches_storage_class = ["NEARLINE"] }
action { type = "SetStorageClass", storage_class = "COLDLINE" }
}
lifecycle_rule {
condition { num_newer_versions = 3 } # no acumular versiones antiguas
action { type = "Delete" }
}
cors {
origin = ["https://${var.dominio}"]
method = ["GET", "HEAD"]
response_header = ["Content-Type"]
max_age_seconds = 3600
}
labels = merge(var.etiquetas, { componente = "web" })
}public_access_prevention = "enforced" impide, a nivel de bucket, que nadie pueda hacerlo público ni por error ni a propósito. Es una línea y elimina de raíz el incidente más común de la nube.
5.3 Migraciones de esquema como código
El esquema no se crea a mano en una consola SQL. Se versiona:
data/migraciones/
├── 001_esquema_inicial.sql
├── 002_indice_disponibilidad.sql
└── 003_columnas_sentimiento.sql-- data/migraciones/001_esquema_inicial.sql
-- Idempotente: se puede ejecutar dos veces sin romper nada.
BEGIN;
CREATE TABLE IF NOT EXISTS refugio (
id SERIAL PRIMARY KEY,
nombre TEXT NOT NULL UNIQUE,
altitud_m INTEGER NOT NULL CHECK (altitud_m BETWEEN 500 AND 3500),
capacidad INTEGER NOT NULL CHECK (capacidad > 0),
activo BOOLEAN NOT NULL DEFAULT TRUE,
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS reserva (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
refugio_id INTEGER NOT NULL REFERENCES refugio(id),
fecha DATE NOT NULL,
plazas INTEGER NOT NULL CHECK (plazas BETWEEN 1 AND 12),
nombre_titular TEXT NOT NULL,
email_titular TEXT NOT NULL,
estado TEXT NOT NULL DEFAULT 'confirmada'
CHECK (estado IN ('confirmada','cancelada')),
creada_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Registro de migraciones aplicadas
CREATE TABLE IF NOT EXISTS _migraciones (
version TEXT PRIMARY KEY,
aplicada_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO _migraciones (version) VALUES ('001')
ON CONFLICT (version) DO NOTHING;
COMMIT;Para ejecutarlas contra una BD sin IP pública, usa el proxy de autenticación:
# Descarga cloud-sql-proxy si no lo tienes
./cloud-sql-proxy --port 5432 "${PROY_DEV}:${REGION}:refugio-db" &
PGPASSWORD=$(gcloud secrets versions access latest --secret=refugio-db-password --project="${PROY_DEV}")
export PGPASSWORD
for f in data/migraciones/*.sql; do
echo "→ $f"
psql -h 127.0.0.1 -U app -d reservas -v ON_ERROR_STOP=1 -f "$f"
done
unset PGPASSWORDFíjate en unset PGPASSWORD y en que la contraseña se lee de Secret Manager en el momento: nunca queda escrita en un fichero ni en el historial del shell si usas HISTCONTROL=ignorespace y un espacio delante.
5.4 Sembrar datos ficticios
# data/seed/generar.py — genera datos 100% inventados
import random, uuid
from datetime import date, timedelta
random.seed(42) # reproducible: mismos datos en cada ejecución
REFUGIOS = [
("Refugio de Cotiella", 2100, 40), ("Refugio Peña Blanca", 1850, 28),
("Refugio del Ibón Verde", 2340, 22), ("Refugio de Valdellosa", 1620, 55),
]
NOMBRES = ["Ana", "Luis", "Marta", "Jorge", "Carmen", "Diego", "Elena", "Pablo"]
APELLIDOS = ["Soler", "Ibáñez", "Marín", "Vidal", "Rey", "Castaño", "Lorca"]
def titular_ficticio(i):
nombre = f"{random.choice(NOMBRES)} {random.choice(APELLIDOS)}"
# example.com está reservado por el RFC 2606 justo para esto
correo = f"usuario{i:04d}@example.com"
return nombre, correo
def factor_estacional(d: date) -> float:
"""Julio-agosto x4, fines de semana x2,5, resto normal."""
f = 4.0 if d.month in (7, 8) else (2.0 if d.month in (6, 9) else 1.0)
if d.weekday() >= 5:
f *= 2.5
return f
def generar_reservas(n=2000):
inicio = date.today() - timedelta(days=540)
filas = []
i = 0
while len(filas) < n:
d = inicio + timedelta(days=random.randint(0, 540))
if random.random() > factor_estacional(d) / 10:
continue
i += 1
nombre, correo = titular_ficticio(i)
filas.append((
str(uuid.uuid4()), random.randint(1, len(REFUGIOS)), d.isoformat(),
random.randint(1, 6), nombre, correo,
"cancelada" if random.random() < 0.08 else "confirmada",
))
return filasrandom.seed(42) hace el generador reproducible: si destruyes y recreas el entorno, obtienes exactamente los mismos datos. Eso hace que las capturas del cuadro de mando sigan siendo válidas y que las pruebas de 08-04 sean deterministas.
Criterio de "hecho" de la fase 3:
| Comprobación | Comando | Esperado |
|---|---|---|
| BD sin IP pública | gcloud sql instances describe refugio-db --format="value(settings.ipConfiguration.ipv4Enabled)" |
False |
| Secreto creado con versión | gcloud secrets versions list refugio-db-password |
≥1 versión ENABLED |
| Bucket no público | gcloud storage buckets describe gs://... --format="value(iamConfiguration.publicAccessPrevention)" |
enforced |
| Migraciones aplicadas | psql -c "SELECT version FROM _migraciones ORDER BY version" |
Todas |
| Datos sembrados | psql -c "SELECT count(*) FROM reserva" |
~2.000 |
- Fase 4 — La aplicación
Objetivo: un contenedor que cumple el contrato de Cloud Run, configurado por entorno, con secretos inyectados, sondas de salud y logs correlacionables. Y desplegado a mano una vez, como prueba de vida.
Tiempo estimado: 8-12 horas.
6.1 El contrato de Cloud Run
Cuatro reglas. Incumplir cualquiera hace que el despliegue falle con un error poco informativo:
| Regla | Qué significa | Error si la incumples |
|---|---|---|
Escucha en $PORT |
La variable de entorno PORT, no un puerto fijo |
El contenedor no pasa el chequeo de arranque |
Escucha en 0.0.0.0 |
No en 127.0.0.1 |
Igual: parece arrancado pero no responde |
| Arranca en <4 min | Migraciones o cargas lentas al inicio, no | Timeout de despliegue |
| Sin estado en disco | El sistema de ficheros es efímero y por instancia | Datos que desaparecen sin explicación |
# app/Dockerfile — multietapa, sin root, y con lo mínimo dentro
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
FROM python:3.12-slim
RUN useradd --create-home --uid 1001 app
WORKDIR /app
COPY --from=build /root/.local /home/app/.local
COPY --chown=app:app src/ ./src/
USER app
ENV PATH=/home/app/.local/bin:$PATH \
PYTHONUNBUFFERED=1
# $PORT lo inyecta Cloud Run. El 8080 es sólo el valor por defecto local.
ENV PORT=8080
CMD exec uvicorn src.main:app --host 0.0.0.0 --port ${PORT}USER app vale 1 punto de seguridad y cuesta dos líneas. PYTHONUNBUFFERED=1 hace que los logs salgan inmediatamente en vez de quedarse en el búfer; sin él, los logs de un contenedor que se cae se pierden.
6.2 Configuración y secretos
Configuración no sensible → variables de entorno del servicio. Secretos → Secret Manager, montados por referencia:
resource "google_cloud_run_v2_service" "web" {
project = var.proyecto
name = "${var.prefijo}-web"
location = var.region
ingress = "INGRESS_TRAFFIC_ALL"
template {
service_account = var.sa_web_email
scaling {
min_instance_count = var.run_min_instancias # 0: escala a cero
max_instance_count = var.run_max_instancias # tope de gasto
}
vpc_access {
connector = var.conector_id
egress = "PRIVATE_RANGES_ONLY" # sólo la BD va por la VPC
}
containers {
image = var.imagen
resources {
limits = { cpu = "1", memory = "512Mi" }
cpu_idle = true # no pagar CPU entre peticiones
}
# --- Configuración: en claro, no sensible ---
env { name = "ENTORNO" value = var.entorno }
env { name = "REGION" value = var.region }
env { name = "BD_HOST" value = var.bd_ip_privada }
env { name = "BD_NOMBRE" value = "reservas" }
env { name = "TOPIC_EVENTOS" value = var.topic_eventos }
env { name = "BUCKET_FOTOS" value = var.bucket_fotos }
# --- Secretos: por referencia, nunca por valor ---
env {
name = "BD_PASSWORD"
value_source {
secret_key_ref {
secret = var.secreto_bd_password
version = "latest"
}
}
}
startup_probe {
http_get { path = "/salud/arranque" }
initial_delay_seconds = 3
period_seconds = 3
failure_threshold = 10
}
liveness_probe {
http_get { path = "/salud/vivo" }
period_seconds = 30
}
}
}
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
percent = 100
}
}cpu_idle = true es la opción que hace que Cloud Run no te cobre CPU entre peticiones. En un proyecto de portafolio con tráfico esporádico, es la diferencia entre céntimos y euros.
6.3 Las dos sondas y por qué son distintas
# app/src/main.py (fragmento)
from fastapi import FastAPI, Response
from . import db
app = FastAPI()
@app.get("/salud/arranque")
async def arranque(response: Response):
"""Sonda de arranque: ¿estoy listo para recibir tráfico?
Comprueba dependencias críticas. Si falla, Cloud Run no envía tráfico."""
try:
await db.ping()
return {"estado": "listo"}
except Exception as e:
response.status_code = 503
return {"estado": "no listo", "motivo": str(e)[:200]}
@app.get("/salud/vivo")
async def vivo():
"""Sonda de vida: ¿el proceso responde?
NO comprueba dependencias: si la BD se cae, no queremos que
Cloud Run reinicie la instancia en bucle — no arreglaría nada."""
return {"estado": "vivo"}La distinción es importante y se pregunta en entrevistas: arranque comprueba dependencias, vida no. Si la sonda de vida comprobara la base de datos, una caída de la BD provocaría reinicios continuos de todas las instancias, convirtiendo un problema en una tormenta.
6.4 Logs estructurados con trace_id
De 06-06, en su forma mínima y efectiva:
# app/src/observabilidad.py
import json, os, sys, contextvars
_trace = contextvars.ContextVar("trace", default=None)
PROYECTO = os.environ.get("GOOGLE_CLOUD_PROJECT", "")
def fijar_trace(cabecera: str | None):
"""Cloud Run envía X-Cloud-Trace-Context: TRACE_ID/SPAN_ID;o=1"""
if cabecera:
_trace.set(cabecera.split("/")[0])
def log(severidad: str, mensaje: str, **campos):
entrada = {
"severity": severidad, # nombres que Cloud Logging entiende
"message": mensaje,
**campos,
}
t = _trace.get()
if t and PROYECTO:
# Esta clave exacta es la que enlaza el log con la traza en la consola
entrada["logging.googleapis.com/trace"] = f"projects/{PROYECTO}/traces/{t}"
print(json.dumps(entrada, ensure_ascii=False), file=sys.stdout, flush=True)# En el middleware de la aplicación
@app.middleware("http")
async def correlacion(request, call_next):
fijar_trace(request.headers.get("X-Cloud-Trace-Context"))
respuesta = await call_next(request)
log("INFO", "peticion",
ruta=request.url.path, metodo=request.method,
codigo=respuesta.status_code)
return respuestaLa clave logging.googleapis.com/trace con ese nombre exacto es lo que hace que en la consola puedas pulsar sobre una traza lenta y ver todos los logs de esa petición concreta. Es una línea de código y transforma la depuración.
⚠️ Nunca registres datos personales. Ni correos, ni nombres, ni contenido de formularios, ni tokens. Registra identificadores (
reserva_id), no personas. Los logs se replican, se exportan y se conservan; un dato personal en un log es un dato personal que has perdido de vista.
6.5 El primer despliegue, a mano
Se hace manualmente y una sola vez. El motivo es pedagógico y práctico: entender cada paso antes de automatizarlo, y tener una prueba de vida contra la que comparar cuando el pipeline falle.
export IMAGEN="${REGION}-docker.pkg.dev/${PROY_DEV}/refugio-imagenes/web:manual-1"
gcloud builds submit app/ --tag="${IMAGEN}" --project="${PROY_DEV}"
gcloud run deploy refugio-web \
--image="${IMAGEN}" \
--region="${REGION}" \
--project="${PROY_DEV}" \
--service-account="sa-refugio-web@${PROY_DEV}.iam.gserviceaccount.com" \
--vpc-connector="refugio-conn" \
--vpc-egress=private-ranges-only \
--set-env-vars="ENTORNO=dev,BD_HOST=10.10.16.3,BD_NOMBRE=reservas" \
--set-secrets="BD_PASSWORD=refugio-db-password:latest" \
--min-instances=0 --max-instances=2 \
--no-allow-unauthenticated
URL=$(gcloud run services describe refugio-web --region="${REGION}" \
--project="${PROY_DEV}" --format="value(status.url)")
curl -s -H "Authorization: Bearer $(gcloud auth print-identity-token)" "${URL}/salud/arranque"Después de que funcione, bórralo del historial mental y hazlo desde Terraform. El despliegue manual era la prueba de vida; el estado permanente lo gobierna el código.
Criterio de "hecho" de la fase 4:
| Comprobación | Comando | Esperado |
|---|---|---|
| Servicio desplegado | gcloud run services describe refugio-web --region=$REGION --format="value(status.conditions[0].status)" |
True |
| Sonda de arranque OK | curl .../salud/arranque |
{"estado":"listo"} |
| Lee de la BD | curl .../api/refugios |
Lista con datos |
| Logs estructurados | gcloud logging read 'resource.type="cloud_run_revision"' --limit=1 --format=json |
JSON con jsonPayload y trace |
| No corre como root | docker run --rm $IMAGEN id -u |
1001 |
- Fase 5 — La entrega automatizada
Objetivo: que un git push a main pruebe, construya, publique y despliegue a desarrollo sin que toques nada; y que la promoción a producción sea la misma imagen con una aprobación.
Tiempo estimado: 4-6 horas.
7.1 El pipeline de desarrollo
# cloudbuild.yaml
substitutions:
_REGION: europe-west1
_SERVICIO: refugio-web
_REPO: refugio-imagenes
steps:
# 1. Pruebas ANTES de construir. Si fallan, no hay imagen.
- id: pruebas
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 --tb=short
# 2. Construir con caché de la imagen anterior
- id: construir
name: gcr.io/cloud-builders/docker
args:
- build
- --cache-from=${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/web:latest
- -t=${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/web:$SHORT_SHA
- -t=${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/web:latest
- app/
waitFor: [pruebas]
# 3. Publicar
- id: publicar
name: gcr.io/cloud-builders/docker
args: [push, --all-tags, "${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/web"]
# 4. Desplegar a DESARROLLO
- id: desplegar
name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
entrypoint: gcloud
args:
- run
- deploy
- ${_SERVICIO}
- --image=${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/web:$SHORT_SHA
- --region=${_REGION}
- --revision-suffix=$SHORT_SHA
# 5. Humo: si la revisión recién desplegada no responde, el build falla
- id: humo
name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
entrypoint: bash
args:
- -c
- |
URL=$(gcloud run services describe ${_SERVICIO} --region=${_REGION} --format='value(status.url)')
TOKEN=$(gcloud auth print-identity-token)
CODIGO=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $$TOKEN" "$$URL/salud/arranque")
echo "Código: $$CODIGO"
test "$$CODIGO" = "200"
options:
logging: CLOUD_LOGGING_ONLY
machineType: E2_HIGHCPU_8
timeout: 900sEl orden es la parte importante: pruebas → construir → publicar → desplegar → humo. Las pruebas van antes de construir, porque construir una imagen de código que no pasa las pruebas es tiempo y dinero tirados. Y la prueba de humo va después de desplegar, porque es lo único que distingue "el despliegue terminó" de "el despliegue funcionó".
7.2 La promoción a producción
# cloudbuild-prod.yaml — NO construye. Promociona la imagen ya probada.
substitutions:
_IMAGEN_SHA: "" # se pasa explícitamente: la que ya funciona en dev
steps:
- id: verificar-imagen-existe
name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
entrypoint: bash
args:
- -c
- |
test -n "${_IMAGEN_SHA}" || { echo "Falta _IMAGEN_SHA"; exit 1; }
gcloud artifacts docker images describe \
europe-west1-docker.pkg.dev/refugio-dev/refugio-imagenes/web:${_IMAGEN_SHA}
# Despliegue CANARIO: 10% del tráfico a la revisión nueva
- id: canario
name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
entrypoint: gcloud
args:
- run
- deploy
- refugio-web
- --image=europe-west1-docker.pkg.dev/refugio-dev/refugio-imagenes/web:${_IMAGEN_SHA}
- --region=europe-west1
- --project=refugio-prod
- --no-traffic # se despliega sin recibir tráfico
- --revision-suffix=${_IMAGEN_SHA}
- id: repartir-10
name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
entrypoint: gcloud
args:
- run
- services
- update-traffic
- refugio-web
- --region=europe-west1
- --project=refugio-prod
- --to-revisions=refugio-web-${_IMAGEN_SHA}=10El principio que hay que interiorizar: la misma imagen, sin reconstruir. Reconstruir para producción significa que estás desplegando algo que nunca has probado: el pip install puede resolver una versión distinta, la imagen base puede haber cambiado. La imagen que pasó las pruebas en dev es la que va a producción, byte a byte.
La aprobación manual se configura en el activador de Cloud Build (--require-approval) o, en GitHub Actions, con un environment protegido.
Criterio de "hecho" de la fase 5:
| Comprobación | Cómo | Esperado |
|---|---|---|
| Push despliega solo | git push y mirar gcloud builds list --limit=1 |
SUCCESS en <10 min |
| Una prueba rota impide el despliegue | Rompe una prueba a propósito, haz push | Build en FAILURE, revisión de Cloud Run sin cambiar |
| Sin claves en CI | Revisar secretos del repositorio | Ninguna credencial de GCP |
| Promoción sin reconstruir | Comparar digest en dev y prod | Idéntico |
La segunda fila es la prueba de verdad. Un pipeline que nunca ha fallado no está probado: rompe una prueba a propósito, comprueba que el despliegue se detiene, y guarda la captura. Vale 3 puntos y demuestra que la red de seguridad existe.
- Fase 6 — La capa de datos y de IA
Objetivo: que una acción en la aplicación acabe visible en el cuadro de mando, y que exista un componente de IA que funcione.
Tiempo estimado: 6-9 horas.
8.1 Ingesta: la aplicación publica eventos
# app/src/eventos.py
import json, os
from google.cloud import pubsub_v1
_publisher = pubsub_v1.PublisherClient()
_TOPIC = _publisher.topic_path(os.environ["GOOGLE_CLOUD_PROJECT"],
os.environ["TOPIC_EVENTOS"])
def publicar_reserva(evento: dict) -> None:
"""Publica el evento de negocio. NUNCA incluye datos de contacto."""
carga = {
"evento_id": evento["id"],
"tipo_evento": evento["tipo"], # creada | cancelada
"reserva_id": evento["reserva_id"],
"refugio_id": evento["refugio_id"],
"refugio_nombre": evento["refugio_nombre"],
"fecha_estancia": evento["fecha"],
"plazas": evento["plazas"],
"ocurrido_en": evento["ts"],
}
futuro = _publisher.publish(_TOPIC, json.dumps(carga).encode("utf-8"))
futuro.result(timeout=10)El comentario no es decorativo: el evento no lleva nombre_titular ni email_titular. Es la minimización de datos de 08-02 aplicada en el código, y es la diferencia entre tener datos personales en un sitio o en cuatro.
8.2 Transformación: la función que escribe en BigQuery
# ml/../funcion/main.py
import base64, json, os
from google.cloud import bigquery
_bq = bigquery.Client()
_TABLA = os.environ["TABLA_EVENTOS"]
def procesar(evento, contexto):
"""Suscriptor de Pub/Sub. Inserta el evento en BigQuery."""
datos = json.loads(base64.b64decode(evento["data"]).decode("utf-8"))
errores = _bq.insert_rows_json(_TABLA, [datos], row_ids=[datos["evento_id"]])
if errores:
# Lanzar excepción hace que Pub/Sub reintente; tras N intentos,
# el mensaje va a la cola de mensajes fallidos.
raise RuntimeError(f"Error insertando en BigQuery: {errores}")row_ids con el identificador del evento activa la deduplicación de BigQuery: si Pub/Sub entrega el mismo mensaje dos veces —y lo hará, porque su garantía es "al menos una vez"— la fila no se duplica. Es una línea que evita un cuadro de mando con cifras infladas.
Y configura la cola de mensajes fallidos en Terraform, con su tema y su suscripción, o los mensajes que fallan reintentan eternamente.
8.3 Las tablas analíticas
-- data/sql/analitica.sql
CREATE TABLE IF NOT EXISTS `refugio-datos.refugio_analitica.reservas_eventos` (
evento_id STRING NOT NULL,
ocurrido_en TIMESTAMP NOT NULL,
tipo_evento STRING NOT NULL,
reserva_id STRING NOT NULL,
refugio_id INT64 NOT NULL,
refugio_nombre STRING,
fecha_estancia DATE NOT NULL,
plazas INT64 NOT NULL
)
PARTITION BY DATE(ocurrido_en)
CLUSTER BY refugio_id
OPTIONS (partition_expiration_days = 1095, require_partition_filter = TRUE);
-- Vista agregada: es lo que consume el cuadro de mando.
-- Que el panel consulte una vista y no la tabla base reduce el escaneo.
CREATE OR REPLACE VIEW `refugio-datos.refugio_analitica.v_ocupacion_diaria` AS
SELECT
fecha_estancia,
refugio_id,
ANY_VALUE(refugio_nombre) AS refugio,
SUM(IF(tipo_evento = 'creada', plazas, 0)) AS plazas_reservadas,
SUM(IF(tipo_evento = 'cancelada', plazas, 0)) AS plazas_canceladas,
SUM(IF(tipo_evento = 'creada', plazas, -plazas)) AS plazas_netas
FROM `refugio-datos.refugio_analitica.reservas_eventos`
WHERE DATE(ocurrido_en) >= DATE_SUB(CURRENT_DATE(), INTERVAL 730 DAY)
GROUP BY fecha_estancia, refugio_id;El WHERE DATE(ocurrido_en) >= ... de la vista no es opcional: con require_partition_filter = TRUE, una vista sin filtro de partición fallaría.
8.4 El componente de IA
La recomendación, repetida porque importa: empieza por una API preentrenada.
# ml/analizar_opiniones.py
from google.cloud import language_v2
_cliente = language_v2.LanguageServiceClient()
def analizar(texto: str) -> dict:
"""Sentimiento de una opinión. Texto FICTICIO, sin datos personales."""
documento = language_v2.Document(
content=texto,
type_=language_v2.Document.Type.PLAIN_TEXT,
language_code="es",
)
r = _cliente.analyze_sentiment(request={"document": documento})
return {
"sentimiento": round(r.document_sentiment.score, 3), # -1..1
"magnitud": round(r.document_sentiment.magnitude, 3),
}Y la decisión de arquitectura que ahorra dinero: el análisis se hace una vez, al crear la opinión, y el resultado se guarda en la base de datos. No se llama a la API cada vez que alguien abre el cuadro de mando. Es el mismo razonamiento que llevó a AlpinaShop a las recomendaciones por lotes en DA-002: si el resultado no cambia, no se recalcula.
Criterio de "hecho" de la fase 6:
# Prueba de extremo a extremo: crear una reserva y verla en BigQuery
curl -s -X POST "${URL}/api/reservas" -H 'Content-Type: application/json' \
-d '{"refugio_id":1,"fecha":"2026-12-20","plazas":2,
"nombre_titular":"Prueba Ficticia","email_titular":"[email protected]"}'
sleep 30
bq query --use_legacy_sql=false --project_id="${PROY_DATOS}" \
'SELECT evento_id, tipo_evento, refugio_id, plazas
FROM `refugio-datos.refugio_analitica.reservas_eventos`
WHERE DATE(ocurrido_en) = CURRENT_DATE()
ORDER BY ocurrido_en DESC LIMIT 5'Si esa fila aparece, has cerrado el hito M3 de 08-01: el flujo completo funciona de extremo a extremo. Haz una captura: la vas a necesitar en la presentación.
- Fase 7 — Exposición
Objetivo: que el sistema responda en un dominio propio, con HTTPS válido, y con la protección que hayas decidido en el diseño.
Tiempo estimado: 3-5 horas, más el tiempo de propagación de DNS y de emisión del certificado (de 15 minutos a varias horas: no lo dejes para el último día).
9.1 Las dos rutas posibles
| Dominio personalizado de Cloud Run | Balanceador global HTTPS | |
|---|---|---|
| Coste | 0 € | ~18 €/mes por la regla de reenvío |
| TLS | Gestionado y automático | Gestionado y automático |
| Cloud CDN | ❌ | ✅ |
| Cloud Armor (WAF) | ❌ | ✅ |
| Varios backends (Run + bucket) | ❌ | ✅ |
| Complejidad | 2 recursos | 7-8 recursos |
Si tu límite de coste es ajustado, la primera opción cumple RNF-5 y no cuesta nada. Documenta el porqué en un ADR, como hizo RefugioReserva.
9.2 Ruta económica: dominio personalizado
resource "google_cloud_run_domain_mapping" "web" {
project = var.proyecto
location = var.region
name = var.dominio # "refugioreserva.example"
metadata { namespace = var.proyecto }
spec { route_name = google_cloud_run_v2_service.web.name }
}
output "registros_dns" {
description = "Registros a crear en el registrador"
value = google_cloud_run_domain_mapping.web.status[0].resource_records
}Creas los registros que devuelve ese output en tu registrador (o en Cloud DNS) y Google emite el certificado solo.
9.3 Ruta completa: balanceador, CDN y WAF
Aunque acabes destruyéndolo por coste, escribe el módulo y despliégalo al menos una vez: el conocimiento queda, tienes capturas y puedes contarlo en la presentación.
resource "google_compute_region_network_endpoint_group" "neg" {
project = var.proyecto
name = "${var.prefijo}-neg"
region = var.region
network_endpoint_type = "SERVERLESS"
cloud_run { service = google_cloud_run_v2_service.web.name }
}
resource "google_compute_backend_service" "bs" {
project = var.proyecto
name = "${var.prefijo}-bs"
protocol = "HTTPS"
load_balancing_scheme = "EXTERNAL_MANAGED"
enable_cdn = true
security_policy = google_compute_security_policy.waf.id
backend { group = google_compute_region_network_endpoint_group.neg.id }
cdn_policy {
cache_mode = "CACHE_ALL_STATIC"
default_ttl = 3600
client_ttl = 3600
negative_caching = true
}
log_config { enable = true, sample_rate = 1.0 }
}
resource "google_compute_security_policy" "waf" {
project = var.proyecto
name = "${var.prefijo}-waf"
# Limitación de tasa: protege el bolsillo tanto como la aplicación
rule {
action = "throttle"
priority = 1000
match {
versioned_expr = "SRC_IPS_V1"
config { src_ip_ranges = ["*"] }
}
rate_limit_options {
conform_action = "allow"
exceed_action = "deny(429)"
enforce_on_key = "IP"
rate_limit_threshold { count = 100, interval_sec = 60 }
}
}
rule {
action = "allow"
priority = 2147483647
match {
versioned_expr = "SRC_IPS_V1"
config { src_ip_ranges = ["*"] }
}
description = "Regla por defecto"
}
}La regla de limitación de tasa merece una nota: en un proyecto con presupuesto de 12 €, un bot que haga 100.000 peticiones puede costarte el presupuesto del mes. El límite de 100 peticiones por minuto y por IP protege tanto la aplicación como la factura. Si no usas balanceador, el equivalente es --max-instances, que es un tope duro de gasto.
Criterio de "hecho" de la fase 7:
curl -sI "https://${DOMINIO}" | head -1 # HTTP/2 200
curl -sI "https://${DOMINIO}" | grep -i strict-transport # HSTS presente
echo | openssl s_client -connect "${DOMINIO}:443" -servername "${DOMINIO}" 2>/dev/null \
| openssl x509 -noout -dates -issuer # certificado válido
curl -sI "http://${DOMINIO}" | head -1 # 301 a HTTPS
- Fase 8 — Observabilidad
Objetivo: enterarte de que algo va mal antes de que te lo diga alguien.
Tiempo estimado: 4-6 horas.
10.1 El panel de las cuatro señales
resource "google_monitoring_dashboard" "principal" {
project = var.proyecto
dashboard_json = jsonencode({
displayName = "RefugioReserva — visión general"
gridLayout = { columns = 2, widgets = [
{
title = "Tráfico (peticiones/s)"
xyChart = { dataSets = [{ timeSeriesQuery = { timeSeriesFilter = {
filter = "metric.type=\"run.googleapis.com/request_count\" resource.type=\"cloud_run_revision\""
aggregation = { alignmentPeriod = "60s", perSeriesAligner = "ALIGN_RATE" }
}}}]}
},
{
title = "Errores (5xx/s)"
xyChart = { dataSets = [{ timeSeriesQuery = { timeSeriesFilter = {
filter = "metric.type=\"run.googleapis.com/request_count\" metric.label.response_code_class=\"5xx\""
aggregation = { alignmentPeriod = "60s", perSeriesAligner = "ALIGN_RATE" }
}}}]}
},
{
title = "Latencia p95 (ms)"
xyChart = { dataSets = [{ timeSeriesQuery = { timeSeriesFilter = {
filter = "metric.type=\"run.googleapis.com/request_latencies\""
aggregation = { alignmentPeriod = "60s", perSeriesAligner = "ALIGN_DELTA",
crossSeriesReducer = "REDUCE_PERCENTILE_95" }
}}}]}
},
{
title = "Instancias activas (saturación)"
xyChart = { dataSets = [{ timeSeriesQuery = { timeSeriesFilter = {
filter = "metric.type=\"run.googleapis.com/container/instance_count\""
aggregation = { alignmentPeriod = "60s", perSeriesAligner = "ALIGN_MEAN" }
}}}]}
}
]}
})
}10.2 La alerta que sí notifica
resource "google_monitoring_notification_channel" "correo" {
project = var.proyecto
display_name = "Correo del responsable"
type = "email"
labels = { email_address = var.correo_alertas }
}
resource "google_monitoring_alert_policy" "errores_5xx" {
project = var.proyecto
display_name = "Tasa de errores 5xx > 5%"
combiner = "OR"
conditions {
display_name = "5xx elevados durante 5 minutos"
condition_threshold {
filter = join(" ", [
"metric.type=\"run.googleapis.com/request_count\"",
"resource.type=\"cloud_run_revision\"",
"metric.label.response_code_class=\"5xx\"",
])
comparison = "COMPARISON_GT"
threshold_value = 0.05
duration = "300s"
aggregations {
alignment_period = "60s"
per_series_aligner = "ALIGN_RATE"
}
}
}
notification_channels = [google_monitoring_notification_channel.correo.id]
documentation {
content = <<-EOT
## Errores 5xx elevados
**Primeros pasos** (ver `docs/runbook.md`):
1. `gcloud logging read 'severity>=ERROR' --limit=20 --freshness=15m`
2. ¿Coincide con un despliegue? `gcloud run revisions list --limit=5`
3. Si coincide: revertir con
`gcloud run services update-traffic refugio-web --to-revisions=<anterior>=100`
4. ¿Base de datos accesible? `gcloud sql instances describe refugio-db`
EOT
mime_type = "text/markdown"
}
}El bloque documentation es lo que convierte una alerta en algo útil. Una alerta que sólo dice "algo va mal" te despierta; una que dice qué mirar y cómo revertir te permite arreglarlo. Escribe siempre los primeros pasos ahí.
10.3 El SLO como recurso
resource "google_monitoring_slo" "disponibilidad" {
project = var.proyecto
service = google_monitoring_service.web.service_id
slo_id = "disponibilidad-api"
display_name = "99,5% de peticiones sin error 5xx (30 días)"
goal = 0.995
rolling_period_days = 30
request_based_sli {
good_total_ratio {
total_service_filter = "metric.type=\"run.googleapis.com/request_count\" resource.type=\"cloud_run_revision\""
bad_service_filter = "metric.type=\"run.googleapis.com/request_count\" resource.type=\"cloud_run_revision\" metric.label.response_code_class=\"5xx\""
}
}
}10.4 El uptime check
resource "google_monitoring_uptime_check_config" "web" {
project = var.proyecto
display_name = "RefugioReserva disponible"
timeout = "10s"
period = "300s"
http_check {
path = "/salud/arranque"
port = 443
use_ssl = true
validate_ssl = true
}
monitored_resource {
type = "uptime_url"
labels = { host = var.dominio, project_id = var.proyecto }
}
selected_regions = ["EUROPE", "USA"]
}Criterio de "hecho" de la fase 8 — y aquí hay uno que no se cumple mirando, sino provocando:
| Comprobación | Cómo | Esperado |
|---|---|---|
| Panel existe | gcloud monitoring dashboards list |
1 panel |
| Alerta existe | gcloud alpha monitoring policies list |
≥1 política |
| La alerta notifica de verdad | Provócala (ver abajo) | Correo recibido |
| SLO calculando | Consola → SLO | Presupuesto de error con valor |
| Uptime check | gcloud monitoring uptime list-configs |
1, en estado correcto |
# Provocar la alerta a propósito: despliega una revisión que devuelva 500
# en un endpoint de prueba, genera tráfico, y espera el correo.
for i in $(seq 1 200); do curl -s -o /dev/null "${URL}/api/error-de-prueba"; done
# Espera 5-10 minutos. Si no llega el correo, la alerta no sirve.Una alerta que nunca se ha disparado no es una alerta: es una intención. Provocarla es el único modo de saber que el canal de notificación funciona, que el umbral es alcanzable y que el correo no acaba en spam. Guarda la captura del correo recibido: vale 2 puntos.
- Prácticas transversales durante toda la implementación
Estas seis cosas no son de ninguna fase: son de todas.
11.1 Commits pequeños y frecuentes
Un commit por unidad de trabajo comprensible. Añade módulo de red con conector serverless es un commit; Avances varios no lo es.
git add infra/modules/red/
git commit -m "Añade módulo de red: VPC, subred, conector /28 y peering de servicios"Beneficios concretos: puedes revertir una cosa sin revertir cinco, el historial cuenta la historia del proyecto en la presentación, y git bisect sirve para algo cuando algo se rompe.
11.2 terraform plan revisado siempre
Nunca apply sin haber leído el plan. Y presta atención especial a tres palabras:
| En el plan | Significado | Reacción |
|---|---|---|
will be created |
Recurso nuevo | Normal |
will be updated in-place |
Cambio sin recrear | Normal |
must be replaced |
Se destruye y se crea de nuevo | PARAR y entender por qué |
will be destroyed |
Desaparece | Comprobar que es intencionado |
must be replaced sobre una base de datos significa perder los datos. Sobre un bucket, perder los objetos. Casi siempre lo provoca cambiar un atributo ForceNew (el nombre, la región, un CIDR). Si aparece y no lo esperabas, cancela.
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan | \
jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address'
# Si esto devuelve algo que no esperas, no apliques.11.3 No tocar nada a mano fuera de Terraform
La regla ideal. Y la realidad: lo vas a hacer, con prisa, un martes por la noche.
Qué hacer entonces, en orden:
- Anótalo inmediatamente en
docs/diario.md. El pecado no es el clic; es olvidarlo. - Detecta la deriva:
terraform plante dirá que la realidad no coincide con el código. - Decide: o llevas el cambio al código (lo normal) o revierte el cambio manual.
- Si creaste un recurso a mano, impórtalo en vez de recrearlo:
# Terraform 1.5+: bloque import, versionable en el código
cat >> infra/envs/dev/imports.tf <<'EOF'
import {
to = google_storage_bucket.temporal
id = "refugio-dev/refugio-temporal-a1b2"
}
EOF
terraform plan # genera la configuración que faltaEl detector gratuito: la etiqueta gestionado-por = terraform de 08-02. Cualquier recurso sin ella se creó a mano.
11.4 Documentar al vuelo
Tres líneas por sesión en docs/diario.md y un ADR cada vez que decides algo con alternativas. No cuesta y es la diferencia entre una presentación con contenido y una inventada la noche antes.
11.5 Controlar el gasto cada pocos días
# Alias que conviene tener a mano
gcloud billing accounts list
# Y, cuando la exportación a BigQuery lleve unos días poblando:
bq query --use_legacy_sql=false \
'SELECT service.description AS servicio, ROUND(SUM(cost),2) AS coste_eur
FROM `mi-proyecto.facturacion.gcp_billing_export_v1_XXXX`
WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
GROUP BY servicio ORDER BY coste_eur DESC'Dos veces por semana, treinta segundos. Y destruye dev cuando no lo uses:
terraform -chdir=infra/envs/dev destroy -auto-approve # viernes
terraform -chdir=infra/envs/dev apply -auto-approve # sábadoEsto tiene un doble beneficio que conviene subrayar: ahorras dinero y estás ejecutando cada semana la prueba más dura de RNF-1. Si algún viernes el apply del sábado no reconstruye el entorno, acabas de descubrir un fallo en tu IaC en el momento más barato posible.
11.6 Mantener los entornos alineados
Cada vez que cambies algo en prod, comprueba que dev tiene el equivalente. La deriva entre entornos convierte la promoción en una lotería.
- Qué hacer cuando te atascas: el método de cinco pasos
| Paso | Pregunta | Comando |
|---|---|---|
| 1 | ¿Qué dice el error, entero? | gcloud logging read 'severity>=ERROR' --limit=20 --freshness=1h --format=json |
| 2 | ¿Está la API activada? | gcloud services list --enabled | grep <api> |
| 3 | ¿Es un permiso? | gcloud policy-troubleshoot iam <recurso> --principal-email=<sa> --permission=<permiso> |
| 4 | ¿Es la red? | gcloud network-management connectivity-tests create ... |
| 5 | ¿El recurso es como creo? | gcloud <servicio> describe <recurso> --format=yaml |
Los diez errores que te vas a encontrar, con su causa
| Síntoma | Causa casi segura | Solución |
|---|---|---|
PERMISSION_DENIED al desplegar |
Falta iam.serviceAccountUser sobre la SA de ejecución |
Concédelo sobre esa SA concreta |
API not enabled |
Justo eso, o propagación aún en curso | gcloud services enable ... y espera 2 min |
| Cloud SQL privada no se crea | Falta el peering de acceso privado a servicios | Crea global_address + service_networking_connection |
| Cloud Run no conecta con la BD | Falta el conector, o vpc-egress mal |
--vpc-connector + --vpc-egress=private-ranges-only |
| Contenedor no arranca | No escucha en $PORT o en 0.0.0.0 |
Corrige el CMD |
| Despliegue con timeout | Arranque > 4 min (migraciones al inicio) | Saca las migraciones del arranque |
| Bucket "already exists" | Nombre global, lo tiene otro | Añade sufijo con random_id |
| Conector VPC no se crea | El rango no es un /28 exacto |
Ajusta el CIDR |
| Alerta que no llega | Canal sin verificar, o correo en spam | Verifica el canal y prueba provocándola |
must be replaced inesperado |
Cambiaste un atributo ForceNew |
Revisa el registro de Terraform antes de aplicar |
Y la regla de las dos horas: si llevas dos horas en el mismo error, cambia de fase. Anota en el diario el error exacto y lo que has probado. Volver al día siguiente resuelve más bloqueos que insistir.
- Registro de progreso y criterios de "hecho"
Mantén esta tabla en docs/diario.md y actualízala al terminar cada fase. Cuando trabajas solo, es lo único que te dice objetivamente dónde estás:
| Fase | Entregable | Criterio de "hecho" verificable | Comando de verificación | Estado |
|---|---|---|---|---|
| 0 | Proyectos y presupuesto | 3 proyectos facturables, ≥25 APIs, 1 presupuesto | gcloud billing projects describe |
⬜ |
| 0 | Repositorio | Estructura + README + ≥2 commits |
git log --oneline |
⬜ |
| 1 | Estado remoto | Bucket con versionado y sin acceso público | gcloud storage buckets describe |
⬜ |
| 1 | Red | VPC propia, conector READY, peering activo |
gcloud compute networks list |
⬜ |
| 1 | Plan limpio | terraform plan → No changes |
terraform plan |
⬜ |
| 2 | Identidades | 0 roles primitivos en SA, 0 claves de usuario | Script de la fase 2 | ⬜ |
| 2 | WIF | Build autenticado sin secretos en el CI | gcloud builds list |
⬜ |
| 3 | Base de datos | ipv4Enabled = False, backups activos |
gcloud sql instances describe |
⬜ |
| 3 | Secretos | Contraseña en Secret Manager, no en el repo | git log -p | grep -i password → vacío |
⬜ |
| 3 | Datos ficticios | ~2.000 filas, migraciones registradas | psql -c "SELECT count(*)..." |
⬜ |
| 4 | Aplicación | Servicio Ready, /salud/arranque = 200 |
curl |
⬜ |
| 4 | Logs | Entradas con jsonPayload y campo trace |
gcloud logging read |
⬜ |
| 5 | CI/CD | Push despliega en <10 min | gcloud builds list --limit=1 |
⬜ |
| 5 | Prueba que protege | Prueba rota → despliegue detenido | Romper a propósito | ⬜ |
| 6 | Flujo completo | Reserva → evento → BigQuery en <2 min | bq query |
⬜ |
| 6 | Cuadro de mando | 4 visualizaciones con datos | URL del informe | ⬜ |
| 6 | IA | Opiniones con puntuación de sentimiento | psql -c "SELECT ... WHERE sentimiento IS NOT NULL" |
⬜ |
| 7 | Dominio y TLS | HTTP/2 200 y certificado válido |
curl -sI + openssl |
⬜ |
| 8 | Panel | 4 señales visibles | gcloud monitoring dashboards list |
⬜ |
| 8 | Alerta probada | Correo recibido tras provocarla | Captura del correo | ⬜ |
| 8 | SLO | Presupuesto de error con valor numérico | Consola de Monitoring | ⬜ |
Las tres filas en negrita son las que la gente marca sin comprobar. No lo hagas: son precisamente las que demuestran que el sistema funciona de verdad.
- El aviso realista: la primera vez todo tarda el doble
Merece una sección propia porque es la principal causa de abandono, y porque no es un problema tuyo.
| Tarea | Primera vez | Segunda vez |
|---|---|---|
| Configurar WIF | 2-3 h | 15 min |
| Cloud SQL con IP privada | 2 h | 20 min |
| Primer despliegue en Cloud Run que funciona | 3-4 h | 30 min |
| Pipeline de Cloud Build completo | 4 h | 45 min |
| Panel de Monitoring con 4 gráficas | 2 h | 30 min |
| Módulo de Terraform reutilizable | 3 h | 45 min |
Lo que ocurre la primera vez y no la segunda: leer documentación, entender el modelo mental, equivocarte con el nombre de un campo, esperar propagaciones, descubrir que faltaba un permiso, deshacer y rehacer.
Tres consecuencias prácticas:
- Planifica con el doble. Si crees que la fase 5 son 4 horas, resérvate 8.
- No te midas contra un tutorial. El vídeo de veinte minutos está editado y grabado por alguien que lo ha hecho cincuenta veces.
- El tiempo "perdido" es el aprendizaje. Las dos horas peleándote con
iam.serviceAccountUserson exactamente el motivo por el que la próxima vez tardas quince minutos, y por el que en una entrevista sabrás responder al instante.
Errores Comunes y Consejos
Empezar por la aplicación. Es el error 2 de 08-01 y se manifiesta aquí. Si tu commit número 20 no tiene nada en infra/, has empezado por donde no era.
Crear "solo una cosita" a mano. Nunca es una. A las tres semanas tienes quince recursos huérfanos y un terraform plan lleno de ruido que ya no lees. Anota, importa o revierte, siempre.
Aplicar sin leer el plan. El día que aparezca must be replaced sobre tu base de datos y no lo veas, perderás los datos y el fin de semana.
Poner roles/editor "temporalmente". Ese "temporalmente" dura hasta la presentación. Empieza restrictivo y abre con policy-troubleshoot.
Dejar el dominio para el último día. La propagación de DNS y la emisión del certificado tienen tiempos que no controlas. Hazlo en cuanto la fase 5 esté estable.
Cargar el modelo de IA en cada petición. Analiza una vez y guarda el resultado. Llamar a una API de IA en cada carga de página es caro y lento sin ninguna ventaja.
No probar la alerta. Es la comprobación más rápida de hacer y la más olvidada. Una alerta sin probar tiene un 50 % de probabilidad de no funcionar cuando la necesites.
Consejo: escribe primero el criterio de "hecho", después el código. Antes de empezar una fase, escribe el comando que usarás para verificarla. Te obliga a definir qué significa terminar y evita el "creo que ya está".
Consejo: usa terraform plan como herramienta de aprendizaje. Cada vez que escribas un recurso nuevo, haz plan y lee qué implica. Es la mejor documentación que existe sobre lo que hace cada bloque.
Consejo: guarda las salidas importantes en el repositorio. Un directorio docs/evidencias/ con la salida del script de auditoría de identidades, la captura del primer flujo de extremo a extremo, el correo de la alerta. Vale puntos en 08-05 y es imposible reconstruirlo después.
Consejo: haz git commit al final de cada sesión aunque no funcione. Un commit WIP: conector VPC, el peering aún falla es información útil. El historial es un entregable.
Ejercicios
Ejercicio 1 — Construye la base reproducible (fases 0 a 2)
Completa las fases 0, 1 y 2 en tu proyecto: proyectos creados con los nombres del diseño, APIs activadas, presupuesto con alertas, repositorio con estructura; bucket de estado con versionado y bloqueo de acceso público, backend remoto con prefijo por entorno, proveedor fijado por versión, al menos dos módulos propios (red e identidad) y variables por entorno con las mismas claves; cuentas de servicio por carga de trabajo con roles acotados al recurso, y Workload Identity Federation con condición de repositorio.
Entrega: la salida del script de verificación de identidades (0 roles primitivos, 0 claves) y un terraform plan que diga No changes.
Ejercicio 2 — Datos, aplicación y flujo de extremo a extremo (fases 3, 4 y 6)
Despliega tu base de datos gestionada sin IP pública, con su contraseña generada y guardada en Secret Manager, sus migraciones aplicadas como código y sus datos ficticios sembrados de forma reproducible. Contenedoriza tu aplicación cumpliendo el contrato de Cloud Run —$PORT, 0.0.0.0, usuario no root, arranque rápido—, con configuración por variables de entorno, secretos por referencia, dos sondas de salud distintas y logs estructurados con trace_id. Conecta el flujo de datos hasta el cuadro de mando y añade el componente de IA.
Entrega: la traza completa de una acción en tu aplicación que acaba visible en el cuadro de mando, con los comandos de verificación de cada salto.
Ejercicio 3 — Automatiza, expón y observa (fases 5, 7 y 8)
Monta el pipeline que prueba, construye, publica, despliega a desarrollo y ejecuta una prueba de humo, más la promoción a producción de la misma imagen con aprobación. Rompe una prueba a propósito y demuestra con una captura que el despliegue se detiene. Expón el sistema en tu dominio con HTTPS válido y redirección desde HTTP. Crea el panel de las cuatro señales, la alerta con su documentación de primeros pasos, el uptime check y el SLO.
Provoca la alerta a propósito y guarda el correo recibido. Cierra el ejercicio con la tabla de registro de progreso de la sección 13 completamente marcada y verificada.
Soluciones
Solución 1 — La base de RefugioReserva
Tras completar las tres primeras fases, la verificación da esto:
$ terraform -chdir=infra/envs/dev plan
No changes. Your infrastructure matches the configuration.
$ gcloud projects get-iam-policy refugio-dev --format=json | \
jq -r '.bindings[] | select(.role|test("roles/(owner|editor)")) |
"\(.role): \(.members[])"'
roles/owner: user:[email protected]
# Ninguna cuenta de servicio. Correcto.
$ for SA in $(gcloud iam service-accounts list --project=refugio-dev --format="value(email)"); do
N=$(gcloud iam service-accounts keys list --iam-account="$SA" --managed-by=user \
--format="value(name)" | wc -l)
echo "$SA -> $N claves de usuario"
done
[email protected] -> 0
[email protected] -> 0
[email protected] -> 0
[email protected] -> 0Los tres problemas reales que aparecieron y cuánto costaron:
| Problema | Síntoma | Causa | Tiempo perdido |
|---|---|---|---|
| Conector VPC no se creaba | Invalid IP CIDR range |
Había puesto /27; exige /28 |
25 min |
| WIF autenticaba pero no desplegaba | PERMISSION_DENIED al desplegar |
Faltaba iam.serviceAccountUser de sa-deploy sobre sa-web |
1 h 40 min |
| Bucket de fotos rechazado | already exists |
Nombre global tomado | 10 min → random_id |
El segundo es el clásico del módulo. gcloud policy-troubleshoot lo señaló en dos minutos; el problema fue que tardé hora y media en acordarme de usarlo. Anotado en el diario para no repetirlo.
Solución 2 — El flujo de extremo a extremo de RefugioReserva
# 1. Verificar la base de datos
$ gcloud sql instances describe refugio-db --project=refugio-dev \
--format="value(settings.ipConfiguration.ipv4Enabled, state)"
False RUNNABLE
# 2. Verificar el secreto
$ gcloud secrets versions list refugio-db-password --project=refugio-dev \
--format="value(name,state)"
1 ENABLED
# 3. Datos sembrados
$ psql -h 127.0.0.1 -U app -d reservas -c \
"SELECT count(*) AS reservas, count(DISTINCT refugio_id) AS refugios FROM reserva"
reservas | refugios
----------+----------
2000 | 12
# 4. La aplicación responde y lee de la BD
$ curl -s "${URL}/api/refugios" | jq '.[0]'
{"id":1,"nombre":"Refugio de Cotiella","altitud_m":2100,"capacidad":40}
# 5. Crear una reserva (datos FICTICIOS)
$ curl -s -X POST "${URL}/api/reservas" -H 'Content-Type: application/json' \
-d '{"refugio_id":3,"fecha":"2026-12-20","plazas":2,
"nombre_titular":"Prueba Ficticia","email_titular":"[email protected]"}' | jq
{"id":"7c2e...","estado":"confirmada","plazas_restantes":20}
# 6. El log estructurado, con su traza
$ gcloud logging read 'resource.type="cloud_run_revision" jsonPayload.ruta="/api/reservas"' \
--limit=1 --format="value(jsonPayload.message, jsonPayload.codigo, trace)"
peticion 201 projects/refugio-dev/traces/8a1f...
# 7. El evento llegó a BigQuery (26 segundos después)
$ bq query --use_legacy_sql=false --project_id=refugio-datos \
'SELECT evento_id, tipo_evento, refugio_id, plazas, ocurrido_en
FROM `refugio-datos.refugio_analitica.reservas_eventos`
WHERE DATE(ocurrido_en) = CURRENT_DATE() ORDER BY ocurrido_en DESC LIMIT 1'
+-----------+-------------+------------+--------+---------------------+
| evento_id | tipo_evento | refugio_id | plazas | ocurrido_en |
+-----------+-------------+------------+--------+---------------------+
| 7c2e... | creada | 3 | 2 | 2026-10-14 18:42:11 |
+-----------+-------------+------------+--------+---------------------+
# 8. Y el sentimiento de las opiniones
$ psql -c "SELECT count(*) FILTER (WHERE sentimiento IS NOT NULL) AS analizadas,
round(avg(sentimiento)::numeric,3) AS media FROM opinion"
analizadas | media
------------+--------
400 | 0.412Hito M3 cerrado. Ocho comandos, ocho saltos verificados, 26 segundos de la acción al almacén analítico. Captura guardada en docs/evidencias/.
Un detalle que costó tiempo y merece la pena señalar: los eventos aparecían duplicados en BigQuery. Causa: Pub/Sub garantiza "al menos una vez" y la función se reintentaba. Solución: row_ids=[evento_id] en insert_rows_json, que activa la deduplicación por identificador. Sin eso, el cuadro de mando mostraba un 12 % más de reservas de las reales, y lo peor es que no lo habría notado si no hubiera cuadrado el total contra la base de datos operativa. Lección anotada: contrasta siempre el almacén analítico contra el operativo.
Solución 3 — Entrega, exposición y observabilidad de RefugioReserva
La prueba de que el pipeline protege. Se rompió a propósito la prueba del control de aforo:
# app/tests/test_unitarios.py — cambio temporal
def test_no_sobreventa():
disponible = calcular_disponibilidad(refugio_id=1, fecha="2026-08-15")
assert disponible == 999 # ← valor incorrecto a propósito$ git commit -am "PRUEBA: rompo el test de aforo a propósito" && git push
$ gcloud builds list --limit=1 --format="table(id, status, createTime)"
ID STATUS CREATE_TIME
9f2a-... FAILURE 2026-10-21T19:14:22
$ gcloud run revisions list --service=refugio-web --region=europe-west1 --limit=2 \
--format="table(name, active, createTime)"
NAME ACTIVE CREATE_TIME
refugio-web-a3f81c True 2026-10-21T18:02:11 ← la anterior sigue sirviendoEl despliegue se detuvo en el paso 1. La revisión anterior siguió atendiendo tráfico. La red de seguridad existe y está probada. Captura guardada.
Exposición:
$ curl -sI https://dev.refugioreserva.example | head -3
HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains
content-type: text/html; charset=utf-8
$ echo | openssl s_client -connect dev.refugioreserva.example:443 \
-servername dev.refugioreserva.example 2>/dev/null | \
openssl x509 -noout -dates -issuer
notBefore=Oct 20 09:14:00 2026 GMT
notAfter=Jan 18 09:13:59 2027 GMT
issuer=C = US, O = Google Trust Services, CN = WE1
$ curl -sI http://dev.refugioreserva.example | head -1
HTTP/1.1 301 Moved PermanentlyTras el ADR-006 se usa el dominio personalizado de Cloud Run: certificado gestionado, coste cero, RNF-5 cumplido. El módulo del balanceador quedó escrito y se desplegará una semana en 08-04 para las pruebas de carga.
La alerta, provocada de verdad:
$ for i in $(seq 1 300); do curl -s -o /dev/null "${URL}/api/error-de-prueba"; done
$ # 6 minutos después:
$ gcloud alpha monitoring policies list --format="value(displayName,enabled)"
Tasa de errores 5xx > 5% TrueCorreo recibido a las 20:41, seis minutos después de empezar a generar errores. Contenía los cuatro primeros pasos del bloque documentation. Captura guardada en docs/evidencias/alerta-recibida.png.
Un detalle que no funcionó a la primera: el primer canal de notificación estaba creado pero sin verificar, y las alertas no llegaban. No hay ningún error visible: la política aparece activa y el incidente se abre, pero el correo no sale. Se descubrió sólo porque se provocó la alerta a propósito. Es exactamente el motivo por el que provocarla es obligatorio.
Registro de progreso final:
| Fase | Criterio | Estado | Evidencia |
|---|---|---|---|
| 0 | 3 proyectos, 27 APIs, presupuesto 12 € | ✅ | docs/evidencias/fase0.txt |
| 1 | plan limpio, VPC propia, peering |
✅ | docs/evidencias/plan-limpio.txt |
| 2 | 0 roles primitivos, 0 claves, WIF | ✅ | docs/evidencias/identidades.txt |
| 3 | BD privada, secreto, 2.000 filas | ✅ | docs/evidencias/datos.txt |
| 4 | Servicio Ready, logs con traza |
✅ | docs/evidencias/app.txt |
| 5 | Push despliega; prueba rota lo detiene | ✅ | docs/evidencias/build-fallido.png |
| 6 | Reserva → BigQuery en 26 s; 400 opiniones analizadas | ✅ | docs/evidencias/e2e.txt |
| 7 | HTTP/2 200, HSTS, certificado válido, 301 | ✅ | docs/evidencias/tls.txt |
| 8 | Panel, alerta recibida, SLO, uptime | ✅ | docs/evidencias/alerta-recibida.png |
Tiempo real invertido: 63 horas frente a las 50 estimadas. El desvío se concentró en tres puntos: WIF (2,5 h frente a 1 estimada), el peering de Cloud SQL (2 h frente a 0,5) y la depuración de los eventos duplicados (3 h no previstas). Coincide con el aviso de la sección 14 casi exactamente.
Conclusión
Tu sistema existe, funciona y se reconstruye desde cero.
Sabes por qué el orden de construcción es el que es: de lo irreversible a lo reversible, de lo que no depende de nada a lo que depende de todo. El projectId en la fase 0 y el panel en la 8, porque uno no se cambia nunca y el otro se rehace en diez minutos. Y conoces cada dependencia concreta: por qué la identidad va antes que los datos, por qué la aplicación no se automatiza hasta haberla desplegado a mano una vez, por qué la observabilidad se monta sobre el sistema completo.
Tienes la fase 0 con los proyectos creados con nombres definitivos, las APIs activadas de golpe, el presupuesto existiendo antes de que pudieras gastar, y el repositorio con su estructura y su README escrito al principio y no al final.
Tienes la base con Terraform: el bootstrap que resuelve el problema del huevo y la gallina, el bucket de estado con versionado y sin acceso público, el backend con prefijo por entorno, el proveedor fijado por versión para que una actualización un martes cualquiera no te proponga destruir media infraestructura, módulos propios reutilizados en dev y prod, variables con exactamente las mismas claves en ambos, y los dos recursos del peering de acceso privado a servicios que todo el mundo olvida.
Tienes la identidad hecha bien: una cuenta de servicio por carga, roles acotados al recurso y no al proyecto —google_secret_manager_secret_iam_member, no google_project_iam_member—, Workload Identity Federation con su attribute_condition, que sin él acepta tokens de cualquier repositorio del planeta, y iam.serviceAccountUser previsto para que el primer despliegue automatizado no te robe una tarde.
Tienes los datos con la base sin IP pública, la contraseña generada por Terraform y guardada en Secret Manager sin que nadie la vea nunca, buckets con prevención de acceso público forzada y ciclo de vida, migraciones idempotentes versionadas en el repositorio y un generador de datos ficticios reproducible con semilla fija.
Tienes la aplicación cumpliendo el contrato de Cloud Run —$PORT, 0.0.0.0, arranque rápido, sin estado—, sin root, con configuración por variables y secretos por referencia, con dos sondas que hacen cosas distintas a propósito, con cpu_idle para no pagar entre peticiones, y con logs estructurados cuya clave logging.googleapis.com/trace enlaza cada línea con su traza.
Tienes la entrega automatizada con el orden correcto —pruebas antes de construir, humo después de desplegar— y el principio que la hace fiable: la misma imagen se promociona a producción, sin reconstruir, porque reconstruir es desplegar algo que nunca has probado. Y la has verificado del único modo que vale: rompiendo una prueba a propósito y comprobando que el despliegue se detiene.
Tienes la capa de datos e IA con eventos que no llevan datos personales, deduplicación por row_ids que evita cifras infladas, tablas particionadas con filtro obligatorio y un componente de IA basado en una API preentrenada que analiza una vez y guarda el resultado.
Tienes la exposición con dominio propio y TLS válido, sabiendo elegir entre la ruta de coste cero y la completa con CDN y WAF, y con la limitación de tasa entendida como lo que también es: protección del presupuesto.
Y tienes la observabilidad con el panel de las cuatro señales, la alerta con sus primeros pasos escritos en el propio bloque documentation, el uptime check, el SLO calculando su presupuesto de error, y la alerta probada provocándola, que es la única forma de descubrir un canal de notificación sin verificar antes de necesitarlo.
Sobre todo eso tienes las prácticas transversales: commits pequeños, plan leído siempre —con must be replaced como palabra de alarma—, nada creado a mano y qué hacer cuando lo haces, documentación al vuelo, control del gasto dos veces por semana con el destroy de los viernes que ahorra dinero y prueba tu IaC a la vez, y los entornos alineados.
Y tienes el método de cinco pasos para cuando te atasques, los diez errores más frecuentes con su causa, la regla de las dos horas, la tabla de registro de progreso con criterios verificables y la advertencia más honesta: la primera vez todo tarda el doble, y ese tiempo no es perdido, es exactamente el aprendizaje.
En la próxima lección, 08-04, la pregunta ya no es "¿funciona?" sino "¿cómo lo sé?". Vas a montar la pirámide de pruebas de tu proyecto, probar la infraestructura recreándola desde cero —la prueba definitiva de que tu IaC es real—, ejecutar una lista de comprobación de seguridad, hacer una prueba de carga honesta y barata, apagar una dependencia a propósito para ver qué pasa, cronometrar una restauración, ensayar una reversión, y pasar tu lista de verificación previa al lanzamiento antes de declarar el proyecto en producción.
Curso de Google Cloud Platform (GCP)
Módulo 1: Introducción a Google Cloud Platform
- ¿Qué es Google Cloud Platform?
- Configuración de tu cuenta de GCP
- Descripción general de la consola de GCP
- Proyectos, jerarquía de recursos y facturación
- Regiones, zonas y modelo de responsabilidad compartida
- Cloud Shell y la CLI de gcloud
Módulo 2: Servicios principales de GCP
- Compute Engine: máquinas virtuales en Google Cloud
- Cloud Storage: almacenamiento de objetos
- Cloud SQL: bases de datos relacionales gestionadas
- App Engine: plataforma como servicio
- Google Kubernetes Engine (GKE)
- Bases de datos NoSQL: Firestore, Bigtable y Spanner
- Cómo elegir el servicio de cómputo adecuado
Módulo 3: Redes y seguridad
- Redes VPC
- Balanceo de carga en la nube
- Cloud CDN
- Gestión de identidad y acceso (IAM)
- Cloud Armor
- Secretos y cifrado: Secret Manager y Cloud KMS
- Cloud DNS, certificados TLS y publicación segura de servicios
Módulo 4: Datos y análisis
- BigQuery: el almacén de datos analítico
- Cloud Dataflow: procesamiento de datos por lotes y en streaming
- Cloud Dataproc: Spark y Hadoop gestionados
- Cloud Pub/Sub: mensajería asíncrona
- Cloud Data Fusion: integración de datos sin código
- Orquestación de pipelines con Cloud Composer y Workflows
- Gobierno del dato y cuadros de mando con Dataplex y Looker Studio
Módulo 5: Aprendizaje automático e IA
- Vertex AI: la plataforma de machine learning de GCP
- AutoML: modelos a medida sin escribir código
- TensorFlow en GCP: entrenamiento y servicio de modelos
- API de lenguaje natural
- API de visión
- IA generativa en Vertex AI: modelos Gemini y embeddings
- MLOps: del modelo al producto con Vertex AI Pipelines
Módulo 6: DevOps y monitoreo
- Cloud Build: integración continua en GCP
- Cloud Source Repositories y gestión del código fuente
- Cloud Functions: funciones sin servidor
- Cloud Monitoring (antes Stackdriver): métricas, paneles y alertas
- Cloud Deployment Manager e infraestructura como código nativa
- Cloud Logging y Cloud Trace: logs, trazas y diagnóstico
- Terraform en GCP: infraestructura como código en la práctica
Módulo 7: Temas avanzados de GCP
- Híbrido y multinube con Anthos
- Computación sin servidor con Cloud Run
- Redes avanzadas: VPC compartida, peering y conectividad híbrida
- Mejores prácticas de seguridad
- Gestión y optimización de costos
- Fiabilidad: SLO, alta disponibilidad y recuperación ante desastres
- Gobierno a escala: organización, políticas y auditoría
