Tens el plànol. Ara toca construir.
I aquí és on la majoria de projectes personals es torcen, per una raó molt concreta: la construcció no falla per dificultat tècnica, falla per ordre. Qui comença per l'aplicació acaba amb una app bonica penjada d'una infraestructura creada a mà que no es pot reproduir. Qui comença per la seguretat a l'inrevés —obrint-ho tot «perquè funcioni» i prometent-se tancar-ho després— no ho tanca mai. Qui no controla la despesa descobreix a la setmana tres que s'ha menjat el crèdit sencer.
Aquesta lliçó és un manual de treball. No explica què és Cloud Run ni què és Terraform: això ja ho vas veure a 07-02 i a 06-07. Explica en quin ordre es posen les peces, per què aquest ordre i no un altre, i com comproves després de cada pas que el que has fet està bé.
Està organitzada en nou fases, de la 0 a la 8. Cada fase té el seu objectiu, els seus passos, les seves comandes i —el més important per treballar sol— el seu criteri de «fet» verificable: una comanda que executes i una sortida que esperes. Si la comanda no dona l'esperat, la fase no està acabada, per molt que «sembli» que funciona.
Al final de la lliçó tindràs el teu sistema en marxa, reproduïble des de zero, amb lliurament automatitzat, segur i observat. I sabràs exactament en quin punt ets a cada moment, que quan treballes sol és la meitat de la batalla.
Contingut
- L'ordre de construcció i per què és aquest
- Fase 0 — Preparació: projectes, APIs, pressupost i repositori
- Fase 1 — La base amb Terraform: estat, proveïdor, mòduls i xarxa
- Fase 2 — Identitat: comptes de servei, rols mínims i federació sense claus
- Fase 3 — Dades: base de dades privada, buckets i migracions com a codi
- Fase 4 — L'aplicació: contenidor, configuració, secrets, salut i registres
- Fase 5 — El lliurament automatitzat: proves, construcció, desplegament i promoció
- Fase 6 — La capa de dades i d'IA
- Fase 7 — Exposició: domini, TLS i protecció
- Fase 8 — Observabilitat: registres, tauler, alertes i SLO
- Pràctiques transversals durant tota la implementació
- Què fer quan t'encalles: el mètode de cinc passos
- Registre de progrés i criteris de «fet»
- L'avís realista: la primera vegada tot triga el doble
- L'ordre de construcció i per què és aquest
flowchart TD
F0["Fase 0 — Preparació<br/>projectes · APIs · pressupost · repo"]
F1["Fase 1 — Base<br/>estat Terraform · xarxa · tallafoc"]
F2["Fase 2 — Identitat<br/>comptes de servei · rols · WIF"]
F3["Fase 3 — Dades<br/>BD privada · buckets · migracions"]
F4["Fase 4 — Aplicació<br/>contenidor · secrets · salut · registres"]
F5["Fase 5 — Lliurament<br/>CI/CD · proves · promoció"]
F6["Fase 6 — Dades i IA<br/>esdeveniments · BigQuery · tauler · model"]
F7["Fase 7 — Exposició<br/>domini · TLS · protecció"]
F8["Fase 8 — Observabilitat<br/>tauler · alertes · 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ó de cada dependència, que és el que fa de l'ordre una cosa racional i no un costum:
| Fase | Depèn de | Per què exactament |
|---|---|---|
| 0. Preparació | Res | El projectId és irreversible i les APIs triguen minuts a propagar-se. I el pressupost ha d'existir abans que puguis gastar |
| 1. Base | 0 | Terraform necessita un projecte amb l'API de Resource Manager activa i un bucket per a l'estat. La xarxa no es pot reconfigurar sense destruir el que hi ha dins |
| 2. Identitat | 1 | Els comptes de servei són de projecte, però els permisos sobre recursos concrets (bucket, secret, topic) requereixen que aquests recursos existeixin… o que els creïs al mateix apply. Va abans que les dades perquè la BD es crea ja amb el seu usuari i el seu secret |
| 3. Dades | 1, 2 | Cloud SQL amb IP privada exigeix la xarxa i el peering d'accés privat a serveis ja creats. I la seva contrasenya va a Secret Manager, que necessita la identitat que la llegirà |
| 4. Aplicació | 3 | L'app no arrenca sense BD ni sense secrets. Desplegar-la abans obliga a desplegar-la dues vegades |
| 5. Lliurament | 4 | No pots automatitzar un desplegament que no has fet mai a mà. Primer es fa una vegada i s'entén; després s'automatitza |
| 6. Dades i IA | 4 | Els esdeveniments els emet l'aplicació. Sense aplicació no hi ha esdeveniments per processar |
| 7. Exposició | 5 | El domini apunta a un servei estable. Si l'app encara canvia de forma cada dia, la propagació de DNS i el certificat són soroll |
| 8. Observabilitat | 6, 7 | S'observa el sistema complet. Un tauler muntat sobre mig sistema s'ha de refer |
La regla general al darrere de tot això: es construeix del que és irreversible al que és reversible, i del que no depèn de res al que depèn de tot. El projectId no es canvia; un tauler de Monitoring es refà en deu minuts. Per això el primer va a la fase 0 i el segon a la 8.
L'excepció legítima: si en algun moment t'encalles més d'una hora en una fase, salta a la següent que no en depengui i torna-hi després. La fase 6 (dades i IA) i la 7 (exposició) són independents entre si; la 8 depèn de totes dues. Documenta el salt al diari.
- Fase 0 — Preparació
Objectiu: deixar el terreny a punt perquè Terraform pugui treballar, amb la despesa controlada des del primer minut.
Temps estimat: 1-2 hores.
2.1 Crear els projectes amb els noms decidits a 08-02
# Variables del projecte — ajusta-les al que vas decidir al disseny
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 els projectes
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
# Comprovar
gcloud projects list --filter="projectId:${PROY_BASE}-*" \
--format="table(projectId, name, lifecycleState)"⚠️ Abans de prémer Enter, llegeix els noms en veu alta. És l'última oportunitat. Un
projectIdno es canvia, no es reutilitza ni després d'esborrar-lo, i apareixerà a cada captura de pantalla de la teva presentació.
Si gcloud projects create falla amb already exists, no és que el tinguis tu: és que algú al món el té, perquè l'espai de noms és global. Afegeix-hi un sufix curt i distintiu, no un -2.
2.2 Activar les APIs
Les APIs triguen de segons a minuts a propagar-se. Activa-les totes de cop ara i t'estalvies deu interrupcions després:
APIS=(
cloudresourcemanager.googleapis.com # Terraform necessita aquesta la primera
serviceusage.googleapis.com
iam.googleapis.com
iamcredentials.googleapis.com
compute.googleapis.com # xarxa, adreces, tallafoc
servicenetworking.googleapis.com # peering per a Cloud SQL privada
vpcaccess.googleapis.com # connector 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 # substitueix per la teva API d'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}"Criteri de «fet»:
2.3 El pressupost, abans que res
Ja el vas crear a 08-01. Si no, fes-ho ara, abans de crear el primer recurs de pagament:
gcloud billing budgets create \
--billing-account="${BILLING_ACCOUNT}" \
--display-name="Projecte 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}"I activa l'exportació de facturació a BigQuery des de la consola (Facturació → Exportació de facturació). Triga fins a 24 hores a començar a poblar dades, així que com abans s'activi, abans tindràs històric per a la secció de cost de la teva presentació.
2.4 El repositori i la seva estructura
Si vas seguir l'exercici 3 de 08-01, ja el tens. Ampliat per a la implementació:
mi-proyecto/
├── README.md
├── .gitignore
├── cloudbuild.yaml # pipeline de dev
├── cloudbuild-prod.yaml # promoció 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 d'estat. S'aplica una vegada
├── 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í que es llegeix. Escriu-lo ara, no al final, i amb aquesta estructura:
# RefugioReserva Plataforma de reserves de refugis de muntanya. Projecte final del curs de GCP. **Totes les dades són fictícies.** 🔗 **Demo:** https://refugioreserva.example 📊 **Tauler de control:** [Looker Studio](...) 📐 **Arquitectura:** [docs/arquitectura.md](docs/arquitectura.md) ## Què fa Un excursionista consulta disponibilitat de places en 12 refugis i reserva. El guarda veu les reserves del dia. La federació consulta indicadors. ## Arquitectura en una línia Cloud Run (Python/FastAPI) → Cloud SQL PostgreSQL privada · fotos a Cloud Storage · esdeveniments per Pub/Sub → BigQuery → Looker Studio · sentiment de les opinions amb l'API de Natural Language. Tot en Terraform, desplegat per Cloud Build sense claus (WIF). ## Com aixecar-ho des de zero
cd infra/bootstrap && terraform init && terraform apply cd ../envs/dev && terraform init && terraform apply make seed
## Cost **~10 €/mes.** Vegeu [docs/adr/ADR-006-sin-balanceador.md](docs/adr/) per a la decisió de cost més rellevant. ## Estat i deute tècnic conegut Vegeu la secció «Deute tècnic» més avall. Sí, n'hi ha, i està prioritzat.
Criteri de «fet» de la fase 0:
| Comprovació | Comanda | Resultat esperat |
|---|---|---|
| Projectes creats i facturables | gcloud billing projects describe ${PROY_PROD} |
billingEnabled: true |
| APIs actives | gcloud services list --enabled --project=${PROY_PROD} |
≥25 línies |
| Pressupost creat | gcloud billing budgets list --billing-account=${BILLING_ACCOUNT} |
1 pressupost |
Repositori amb estructura i README |
git log --oneline |
≥2 commits |
- Fase 1 — La base amb Terraform
Objectiu: que a partir d'aquí tot es creï amb codi.
Temps estimat: 4-8 hores la primera vegada.
3.1 El bucket d'estat (bootstrap)
Hi ha un problema de l'ou i la gallina: Terraform desa el seu estat en un bucket, però el bucket també s'ha de crear. La solució estàndard és un petit mòdul bootstrap amb estat local que s'aplica una sola vegada:
# infra/bootstrap/main.tf
terraform {
required_version = ">= 1.9"
required_providers {
google = { source = "hashicorp/google", version = "~> 6.0" }
}
# Sense backend: estat local, s'aplica una vegada i es puja el .tfstate xifrat
# o simplement s'accepta que aquest mòdul es torna a crear a mà si es perd.
}
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: permet recuperar un estat corrupte
lifecycle_rule {
condition { num_newer_versions = 20 }
action { type = "Delete" }
}
labels = {
proyecto = var.prefijo
componente = "iac"
gestionado-por = "terraform"
}
}Les tres opcions que no són negociables:
versioning.enabled = true: si l'estat es corromp (i passa), la versió anterior et salva el projecte.public_access_prevention = "enforced": l'estat de Terraform conté identificadors de recursos i, de vegades, valors sensibles. Mai públic.force_destroy = false: evita que undestroyaccidental s'endugui l'estat de tota la resta.
cd infra/bootstrap
terraform init && terraform apply
gcloud storage buckets describe gs://refugio-terraform-estado \
--format="value(versioning.enabled,iamConfiguration.publicAccessPrevention)"
# Esperat: True enforced3.2 El backend remot i el proveïdor fixat
# infra/envs/prod/backend.tf
terraform {
required_version = ">= 1.9"
backend "gcs" {
bucket = "refugio-terraform-estado"
prefix = "envs/prod" # dev fa servir "envs/dev": estats separats
}
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0" # fixat. MAI sense versió
}
random = { source = "hashicorp/random", version = "~> 3.6" }
}
}Per què prefix diferent per entorn i no dos buckets: un bucket, dos prefixos, dos estats completament independents. És més simple de gestionar i no hi ha risc que un apply de dev toqui prod, perquè són fitxers d'estat diferents.
Per què fixar la versió del proveïdor: sense version, Terraform agafa l'última cada vegada que fas init. Un dimarts qualsevol surt la versió 7.0 amb canvis incompatibles i el teu plan proposa destruir mitja infraestructura. Amb ~> 6.0 et quedes a la branca 6 fins que decideixis pujar tu, conscientment i amb un commit que ho digui.
3.3 Mòduls propis
La regla pràctica: crea un mòdul quan facis servir la mateixa cosa en dos entorns. Amb dev i prod, això és gairebé tot.
# 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 # ← MAI la xarxa 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 # sortida a APIs de Google sense IP pública
}
# Connector d'accés serverless: exigeix un /28 exacte
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 # topall de cost
}
# --- Accés privat a serveis (necessari per a Cloud SQL amb 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]
}
# --- Tallafoc: denegació per defecte 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 }Els dos recursos que la gent oblida hi són: google_compute_global_address amb purpose = "VPC_PEERING" i google_service_networking_connection. Sense ells, Cloud SQL amb IP privada falla amb un error que no esmenta el peering enlloc.
I l'output "peering_listo" no és decoratiu: serveix perquè el mòdul de dades pugui declarar depends_on sobre ell i Terraform no intenti crear la base de dades abans que el peering existeixi.
3.4 Variables per entorn
# 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 — mateixes claus, valors més petits
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: els dos fitxers tenen exactament les mateixes claus. Si dev té una variable que prod no té, els entorns han divergit i la promoció deixa de ser fiable.
3.5 El primer apply
cd infra/envs/dev
terraform init
terraform fmt -recursive ../.. # format consistent, gratis
terraform validate # sintaxi i referències
terraform plan -out=plan.tfplan # LLEGIR LA SORTIDA SENCERA
terraform apply plan.tfplanLlegeix el pla sencer. Sempre. És la pràctica transversal número u de l'apartat 11, i la primera vegada és quan més s'aprèn: el pla t'ensenya exactament quins recursos implica cada bloc que has escrit.
Criteri de «fet» de la fase 1:
| Comprovació | Comanda | Esperat |
|---|---|---|
| Estat remot | gcloud storage ls gs://refugio-terraform-estado/envs/dev/ |
default.tfstate |
| Xarxa creada, no la default | gcloud compute networks list --project=${PROY_DEV} |
refugio-vpc, sense default |
| Connector actiu | gcloud compute networks vpc-access connectors list --region=${REGION} --project=${PROY_DEV} |
estat READY |
| Peering establert | gcloud services vpc-peerings list --network=refugio-vpc --project=${PROY_DEV} |
1 peering |
| Pla net | terraform plan |
No changes. |
Aquesta última fila és la que importa: un plan que diu No changes significa que el codi i la realitat coincideixen. És el criteri que es comprova a 08-04 i que val 3 punts de la rúbrica.
- Fase 2 — Identitat
Objectiu: cada càrrega de treball amb la seva identitat i els seus permisos mínims, i el CI/CD funcionant sense una sola clau descarregada.
Temps estimat: 3-5 hores (2 d'elles, la primera vegada que configures WIF).
4.1 Comptes de servei i rols mínims
# infra/modules/identidad/main.tf
resource "google_service_account" "web" {
project = var.proyecto
account_id = "sa-${var.prefijo}-web"
display_name = "Compte de l'aplicació web"
description = "Cloud Run: llegeix BD, llegeix 2 secrets, escriu fotos, publica esdeveniments"
}
# --- Rols a nivell de PROJECTE: només els que no es poden 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}"
}
# --- Rols acotats AL RECURS: així és com es fa bé ---
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 diferència que separa un projecte bo d'un de mediocre és als noms d'aquests recursos. google_project_iam_member amb secretAccessor dona accés a tots els secrets del projecte, presents i futurs. google_secret_manager_secret_iam_member el dona a aquell secret. És la mateixa quantitat de codi i una diferència enorme de superfície d'atac.
4.2 Workload Identity Federation: CI/CD sense claus
Aquest és el punt que més et diferenciarà. La idea, en dues frases: en comptes de descarregar una clau JSON i desar-la a GitHub, s'estableix una relació de confiança entre GCP i el proveïdor d'identitat del teu CI. El CI presenta un testimoni signat que diu «soc la branca main del repositori usuario/refugioreserva», i GCP el bescanvia per credencials temporals.
Zero claus. Zero rotació. Zero risc de fuita.
# 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Ó OBLIGATÒRIA: sense això, QUALSEVOL repositori de GitHub
# del món podria autenticar-se contra el teu projecte.
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 = "Desplegament des de CI"
}
# Només la branca main del repositori pot impersonar el compte de desplegament
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 PERMÍS QUE S'OBLIDA SEMPRE:
# per desplegar un servei que corre com sa-web, deploy ha de poder «actuar com» 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}"
}⚠️ L'
attribute_conditionno és opcional. Sense ell, el proveïdor accepta testimonis de qualsevol repositori de GitHub del planeta, i qualsevol que sàpiga el nom del teu pool pot desplegar al teu projecte. És una fallada de seguretat real i documentada que apareix en molts tutorials per omissió.
I al flux de GitHub Actions:
# .github/workflows/deploy.yml
permissions:
contents: read
id-token: write # imprescindible perquè GitHub emeti el testimoni 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 d'aquí, gcloud està autenticat. Sense cap secret a GitHub.Criteri de «fet» de la fase 2:
# 1. Cap compte de servei amb rol primitiu
gcloud projects get-iam-policy "${PROY_PROD}" --format=json | \
jq -r '.bindings[] | select(.role | test("roles/(owner|editor)")) |
.members[] | select(startswith("serviceAccount:"))'
# Esperat: buit
# 2. Zero claus de compte de servei gestionades per l'usuari
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
# Esperat: 0 a totesDesa la sortida d'aquestes dues comandes: són evidència directa de 6 punts del bloc D de la rúbrica.
- Fase 3 — Dades
Objectiu: base de dades gestionada, privada, amb la seva contrasenya a Secret Manager, i l'esquema com a codi versionat.
Temps estimat: 3-5 hores.
5.1 La base de dades, sense 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 ha d'existir abans. Cas legítim de depends_on explícit.
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 barat; suficient a aquest volum
disk_autoresize = true
ip_configuration {
ipv4_enabled = false # ← SENSE 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 } # diumenge de matinada
database_flags {
name = "cloudsql.iam_authentication"
value = "on"
}
user_labels = var.etiquetas
}
# Protecció contra el destroy accidental en producció
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
}Cinc detalls que compten:
ipv4_enabled = falseés la línia que val 2 punts del bloc D i, més important, la que fa que la teva base de dades no sigui escanejable des d'internet.- La contrasenya es genera amb
random_passwordi no la veus mai. Va directa a Secret Manager. Ningú no la tecleja, ningú no la copia, ningú no la puja per error. depends_onsobre el peering és un dels pocs casos ondepends_onexplícit és correcte: la dependència és real però Terraform no la pot inferir del graf.deletion_protectioncondicionada a l'entorn: dev es destrueix els divendres, prod no es destrueix per accident.disk_type = "PD_HDD": per a un projecte de porfolio, l'SSD no aporta res i costa més.
5.2 Buckets amb cicle 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}" # nom 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 versions antigues
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" impedeix, a nivell de bucket, que ningú el pugui fer públic ni per error ni expressament. És una línia i elimina d'arrel l'incident més habitual del núvol.
5.3 Migracions d'esquema com a codi
L'esquema no es crea a mà en una consola SQL. Es versiona:
data/migraciones/
├── 001_esquema_inicial.sql
├── 002_indice_disponibilidad.sql
└── 003_columnas_sentimiento.sql-- data/migraciones/001_esquema_inicial.sql
-- Idempotent: es pot executar dues vegades sense trencar res.
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()
);
-- Registre de migracions aplicades
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;Per executar-les contra una BD sense IP pública, fes servir el proxy d'autenticació:
# Descarrega cloud-sql-proxy si no el tens
./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 PGPASSWORDFixa't en unset PGPASSWORD i que la contrasenya es llegeix de Secret Manager en el moment: mai no queda escrita en un fitxer ni a l'historial del shell si fas servir HISTCONTROL=ignorespace i un espai al davant.
5.4 Sembrar dades fictícies
# data/seed/generar.py — genera dades 100% inventades
import random, uuid
from datetime import date, timedelta
random.seed(42) # reproduïble: mateixes dades a cada execució
REFUGIS = [
("Refugio de Cotiella", 2100, 40), ("Refugio Peña Blanca", 1850, 28),
("Refugio del Ibón Verde", 2340, 22), ("Refugio de Valdellosa", 1620, 55),
]
NOMS = ["Ana", "Luis", "Marta", "Jorge", "Carmen", "Diego", "Elena", "Pablo"]
COGNOMS = ["Soler", "Ibáñez", "Marín", "Vidal", "Rey", "Castaño", "Lorca"]
def titular_fictici(i):
nom = f"{random.choice(NOMS)} {random.choice(COGNOMS)}"
# example.com està reservat per l'RFC 2606 just per a això
correu = f"usuario{i:04d}@example.com"
return nom, correu
def factor_estacional(d: date) -> float:
"""Juliol-agost x4, caps de setmana x2,5, la resta 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_reserves(n=2000):
inici = date.today() - timedelta(days=540)
files = []
i = 0
while len(files) < n:
d = inici + timedelta(days=random.randint(0, 540))
if random.random() > factor_estacional(d) / 10:
continue
i += 1
nom, correu = titular_fictici(i)
files.append((
str(uuid.uuid4()), random.randint(1, len(REFUGIS)), d.isoformat(),
random.randint(1, 6), nom, correu,
"cancelada" if random.random() < 0.08 else "confirmada",
))
return filesrandom.seed(42) fa el generador reproduïble: si destrueixes i recrees l'entorn, obtens exactament les mateixes dades. Això fa que les captures del tauler de control continuïn sent vàlides i que les proves de 08-04 siguin deterministes.
Criteri de «fet» de la fase 3:
| Comprovació | Comanda | Esperat |
|---|---|---|
| BD sense IP pública | gcloud sql instances describe refugio-db --format="value(settings.ipConfiguration.ipv4Enabled)" |
False |
| Secret creat amb versió | gcloud secrets versions list refugio-db-password |
≥1 versió ENABLED |
| Bucket no públic | gcloud storage buckets describe gs://... --format="value(iamConfiguration.publicAccessPrevention)" |
enforced |
| Migracions aplicades | psql -c "SELECT version FROM _migraciones ORDER BY version" |
Totes |
| Dades sembrades | psql -c "SELECT count(*) FROM reserva" |
~2.000 |
- Fase 4 — L'aplicació
Objectiu: un contenidor que compleix el contracte de Cloud Run, configurat per entorn, amb secrets injectats, sondes de salut i registres correlacionables. I desplegat a mà una vegada, com a prova de vida.
Temps estimat: 8-12 hores.
6.1 El contracte de Cloud Run
Quatre regles. Incomplir-ne qualsevol fa que el desplegament falli amb un error poc informatiu:
| Regla | Què significa | Error si la incompleixes |
|---|---|---|
Escolta a $PORT |
La variable d'entorn PORT, no un port fix |
El contenidor no passa la comprovació d'arrencada |
Escolta a 0.0.0.0 |
No a 127.0.0.1 |
Igual: sembla arrencat però no respon |
| Arrenca en <4 min | Migracions o càrregues lentes a l'inici, no | Timeout de desplegament |
| Sense estat en disc | El sistema de fitxers és efímer i per instància | Dades que desapareixen sense explicació |
# app/Dockerfile — multietapa, sense root, i amb el mínim a dins
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 l'injecta Cloud Run. El 8080 és només el valor per defecte local.
ENV PORT=8080
CMD exec uvicorn src.main:app --host 0.0.0.0 --port ${PORT}USER app val 1 punt de seguretat i costa dues línies. PYTHONUNBUFFERED=1 fa que els registres surtin immediatament en comptes de quedar-se a la memòria intermèdia; sense això, els registres d'un contenidor que cau es perden.
6.2 Configuració i secrets
Configuració no sensible → variables d'entorn del servei. Secrets → Secret Manager, muntats per referència:
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 zero
max_instance_count = var.run_max_instancias # topall de despesa
}
vpc_access {
connector = var.conector_id
egress = "PRIVATE_RANGES_ONLY" # només la BD va per la VPC
}
containers {
image = var.imagen
resources {
limits = { cpu = "1", memory = "512Mi" }
cpu_idle = true # no pagar CPU entre peticions
}
# --- Configuració: en clar, 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 }
# --- Secrets: per referència, mai per 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 és l'opció que fa que Cloud Run no et cobri CPU entre peticions. En un projecte de porfolio amb trànsit esporàdic, és la diferència entre cèntims i euros.
6.3 Les dues sondes i per què són diferents
# app/src/main.py (fragment)
from fastapi import FastAPI, Response
from . import db
app = FastAPI()
@app.get("/salud/arranque")
async def arrencada(response: Response):
"""Sonda d'arrencada: estic a punt per rebre trànsit?
Comprova dependències crítiques. Si falla, Cloud Run no envia trànsit."""
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 viu():
"""Sonda de vida: el procés respon?
NO comprova dependències: si la BD cau, no volem que
Cloud Run reiniciï la instància en bucle — no arreglaria res."""
return {"estado": "vivo"}La distinció és important i es pregunta en entrevistes: arrencada comprova dependències, vida no. Si la sonda de vida comprovés la base de dades, una caiguda de la BD provocaria reinicis continus de totes les instàncies, convertint un problema en una tempesta.
6.4 Registres estructurats amb trace_id
De 06-06, en la seva forma mínima i efectiva:
# app/src/observabilidad.py
import json, os, sys, contextvars
_trace = contextvars.ContextVar("trace", default=None)
PROJECTE = os.environ.get("GOOGLE_CLOUD_PROJECT", "")
def fixar_trace(capcalera: str | None):
"""Cloud Run envia X-Cloud-Trace-Context: TRACE_ID/SPAN_ID;o=1"""
if capcalera:
_trace.set(capcalera.split("/")[0])
def log(severitat: str, missatge: str, **camps):
entrada = {
"severity": severitat, # noms que Cloud Logging entén
"message": missatge,
**camps,
}
t = _trace.get()
if t and PROJECTE:
# Aquesta clau exacta és la que enllaça el registre amb la traça a la consola
entrada["logging.googleapis.com/trace"] = f"projects/{PROJECTE}/traces/{t}"
print(json.dumps(entrada, ensure_ascii=False), file=sys.stdout, flush=True)# Al middleware de l'aplicació
@app.middleware("http")
async def correlacio(request, call_next):
fixar_trace(request.headers.get("X-Cloud-Trace-Context"))
resposta = await call_next(request)
log("INFO", "peticion",
ruta=request.url.path, metodo=request.method,
codigo=resposta.status_code)
return respostaLa clau logging.googleapis.com/trace amb aquest nom exacte és el que fa que a la consola puguis prémer sobre una traça lenta i veure tots els registres d'aquella petició concreta. És una línia de codi i transforma la depuració.
⚠️ No registris mai dades personals. Ni correus, ni noms, ni contingut de formularis, ni testimonis. Registra identificadors (
reserva_id), no persones. Els registres es repliquen, s'exporten i es conserven; una dada personal en un registre és una dada personal que has perdut de vista.
6.5 El primer desplegament, a mà
Es fa manualment i una sola vegada. El motiu és pedagògic i pràctic: entendre cada pas abans d'automatitzar-lo, i tenir una prova de vida contra la qual comparar quan el pipeline falli.
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"Després que funcioni, esborra-ho de l'historial mental i fes-ho des de Terraform. El desplegament manual era la prova de vida; l'estat permanent el governa el codi.
Criteri de «fet» de la fase 4:
| Comprovació | Comanda | Esperat |
|---|---|---|
| Servei desplegat | gcloud run services describe refugio-web --region=$REGION --format="value(status.conditions[0].status)" |
True |
| Sonda d'arrencada OK | curl .../salud/arranque |
{"estado":"listo"} |
| Llegeix de la BD | curl .../api/refugios |
Llista amb dades |
| Registres estructurats | gcloud logging read 'resource.type="cloud_run_revision"' --limit=1 --format=json |
JSON amb jsonPayload i trace |
| No corre com a root | docker run --rm $IMAGEN id -u |
1001 |
- Fase 5 — El lliurament automatitzat
Objectiu: que un git push a main provi, construeixi, publiqui i desplegui a desenvolupament sense que toquis res; i que la promoció a producció sigui la mateixa imatge amb una aprovació.
Temps estimat: 4-6 hores.
7.1 El pipeline de desenvolupament
# cloudbuild.yaml
substitutions:
_REGION: europe-west1
_SERVICIO: refugio-web
_REPO: refugio-imagenes
steps:
# 1. Proves ABANS de construir. Si fallen, no hi ha imatge.
- 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 amb memòria cau de la imatge 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 DESENVOLUPAMENT
- 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. Fum: si la revisió acabada de desplegar no respon, 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 "Codi: $$CODIGO"
test "$$CODIGO" = "200"
options:
logging: CLOUD_LOGGING_ONLY
machineType: E2_HIGHCPU_8
timeout: 900sL'ordre és la part important: proves → construir → publicar → desplegar → fum. Les proves van abans de construir, perquè construir una imatge de codi que no passa les proves és temps i diners llençats. I la prova de fum va després de desplegar, perquè és l'única cosa que distingeix «el desplegament ha acabat» de «el desplegament ha funcionat».
7.2 La promoció a producció
# cloudbuild-prod.yaml — NO construeix. Promociona la imatge ja provada.
substitutions:
_IMAGEN_SHA: "" # es passa explícitament: la que ja 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}
# Desplegament CANARI: 10% del trànsit a la revisió nova
- 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 # es desplega sense rebre trànsit
- --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 principi que cal interioritzar: la mateixa imatge, sense reconstruir. Reconstruir per a producció significa que estàs desplegant una cosa que no has provat mai: el pip install pot resoldre una versió diferent, la imatge base pot haver canviat. La imatge que va passar les proves en dev és la que va a producció, byte a byte.
L'aprovació manual es configura a l'activador de Cloud Build (--require-approval) o, a GitHub Actions, amb un environment protegit.
Criteri de «fet» de la fase 5:
| Comprovació | Com | Esperat |
|---|---|---|
| El push desplega sol | git push i mirar gcloud builds list --limit=1 |
SUCCESS en <10 min |
| Una prova trencada impedeix el desplegament | Trenca una prova expressament, fes push | Build en FAILURE, revisió de Cloud Run sense canviar |
| Sense claus a CI | Revisar secrets del repositori | Cap credencial de GCP |
| Promoció sense reconstruir | Comparar digest en dev i prod | Idèntic |
La segona fila és la prova de debò. Un pipeline que no ha fallat mai no està provat: trenca una prova expressament, comprova que el desplegament s'atura, i desa la captura. Val 3 punts i demostra que la xarxa de seguretat existeix.
- Fase 6 — La capa de dades i d'IA
Objectiu: que una acció a l'aplicació acabi visible al tauler de control, i que existeixi un component d'IA que funcioni.
Temps estimat: 6-9 hores.
8.1 Ingesta: l'aplicació publica esdeveniments
# 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(esdeveniment: dict) -> None:
"""Publica l'esdeveniment de negoci. MAI inclou dades de contacte."""
carrega = {
"evento_id": esdeveniment["id"],
"tipo_evento": esdeveniment["tipo"], # creada | cancelada
"reserva_id": esdeveniment["reserva_id"],
"refugio_id": esdeveniment["refugio_id"],
"refugio_nombre": esdeveniment["refugio_nombre"],
"fecha_estancia": esdeveniment["fecha"],
"plazas": esdeveniment["plazas"],
"ocurrido_en": esdeveniment["ts"],
}
futur = _publisher.publish(_TOPIC, json.dumps(carrega).encode("utf-8"))
futur.result(timeout=10)El comentari no és decoratiu: l'esdeveniment no porta nombre_titular ni email_titular. És la minimització de dades de 08-02 aplicada al codi, i és la diferència entre tenir dades personals en un lloc o en quatre.
8.2 Transformació: la funció que escriu a BigQuery
# ml/../funcion/main.py
import base64, json, os
from google.cloud import bigquery
_bq = bigquery.Client()
_TAULA = os.environ["TABLA_EVENTOS"]
def processar(esdeveniment, context):
"""Subscriptor de Pub/Sub. Insereix l'esdeveniment a BigQuery."""
dades = json.loads(base64.b64decode(esdeveniment["data"]).decode("utf-8"))
errors = _bq.insert_rows_json(_TAULA, [dades], row_ids=[dades["evento_id"]])
if errors:
# Llançar excepció fa que Pub/Sub reintenti; després de N intents,
# el missatge va a la cua de missatges fallits.
raise RuntimeError(f"Error inserint a BigQuery: {errors}")row_ids amb l'identificador de l'esdeveniment activa la deduplicació de BigQuery: si Pub/Sub lliura el mateix missatge dues vegades —i ho farà, perquè la seva garantia és «almenys una vegada»— la fila no es duplica. És una línia que evita un tauler de control amb xifres inflades.
I configura la cua de missatges fallits a Terraform, amb el seu tema i la seva subscripció, o els missatges que fallen reintenten eternament.
8.3 Les taules analítiques
-- 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: és el que consumeix el tauler de control.
-- Que el tauler consulti una vista i no la taula base redueix l'escaneig.
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 és opcional: amb require_partition_filter = TRUE, una vista sense filtre de partició fallaria.
8.4 El component d'IA
La recomanació, repetida perquè importa: comença per una API preentrenada.
# ml/analizar_opiniones.py
from google.cloud import language_v2
_client = language_v2.LanguageServiceClient()
def analitzar(text: str) -> dict:
"""Sentiment d'una opinió. Text FICTICI, sense dades personals."""
document = language_v2.Document(
content=text,
type_=language_v2.Document.Type.PLAIN_TEXT,
language_code="es",
)
r = _client.analyze_sentiment(request={"document": document})
return {
"sentimiento": round(r.document_sentiment.score, 3), # -1..1
"magnitud": round(r.document_sentiment.magnitude, 3),
}I la decisió d'arquitectura que estalvia diners: l'anàlisi es fa una vegada, en crear l'opinió, i el resultat es desa a la base de dades. No es crida l'API cada vegada que algú obre el tauler de control. És el mateix raonament que va portar AlpinaShop a les recomanacions per lots a DA-002: si el resultat no canvia, no es recalcula.
Criteri de «fet» de la fase 6:
# Prova d'extrem a extrem: crear una reserva i veure-la a 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":"Prova Fictícia","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 aquesta fila apareix, has tancat la fita M3 de 08-01: el flux complet funciona d'extrem a extrem. Fes una captura: la necessitaràs a la presentació.
- Fase 7 — Exposició
Objectiu: que el sistema respongui en un domini propi, amb HTTPS vàlid, i amb la protecció que hagis decidit al disseny.
Temps estimat: 3-5 hores, més el temps de propagació de DNS i d'emissió del certificat (de 15 minuts a diverses hores: no ho deixis per a l'últim dia).
9.1 Les dues rutes possibles
| Domini personalitzat de Cloud Run | Balancejador global HTTPS | |
|---|---|---|
| Cost | 0 € | ~18 €/mes per la regla de reenviament |
| TLS | Gestionat i automàtic | Gestionat i automàtic |
| Cloud CDN | ❌ | ✅ |
| Cloud Armor (WAF) | ❌ | ✅ |
| Diversos backends (Run + bucket) | ❌ | ✅ |
| Complexitat | 2 recursos | 7-8 recursos |
Si el teu límit de cost és ajustat, la primera opció compleix RNF-5 i no costa res. Documenta el perquè en un ADR, com va fer RefugioReserva.
9.2 Ruta econòmica: domini personalitzat
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 = "Registres a crear al registrador"
value = google_cloud_run_domain_mapping.web.status[0].resource_records
}Crees els registres que retorna aquest output al teu registrador (o a Cloud DNS) i Google emet el certificat sol.
9.3 Ruta completa: balancejador, CDN i WAF
Encara que acabis destruint-lo per cost, escriu el mòdul i desplega'l almenys una vegada: el coneixement queda, tens captures i ho pots explicar a la presentació.
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ó de taxa: protegeix la butxaca tant com l'aplicació
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 per defecte"
}
}La regla de limitació de taxa mereix una nota: en un projecte amb pressupost de 12 €, un bot que faci 100.000 peticions pot costar-te el pressupost del mes. El límit de 100 peticions per minut i per IP protegeix tant l'aplicació com la factura. Si no fas servir balancejador, l'equivalent és --max-instances, que és un topall dur de despesa.
Criteri de «fet» de la fase 7:
curl -sI "https://${DOMINIO}" | head -1 # HTTP/2 200
curl -sI "https://${DOMINIO}" | grep -i strict-transport # HSTS present
echo | openssl s_client -connect "${DOMINIO}:443" -servername "${DOMINIO}" 2>/dev/null \
| openssl x509 -noout -dates -issuer # certificat vàlid
curl -sI "http://${DOMINIO}" | head -1 # 301 a HTTPS
- Fase 8 — Observabilitat
Objectiu: assabentar-te que alguna cosa va malament abans que t'ho digui algú.
Temps estimat: 4-6 hores.
10.1 El tauler dels quatre senyals
resource "google_monitoring_dashboard" "principal" {
project = var.proyecto
dashboard_json = jsonencode({
displayName = "RefugioReserva — visió general"
gridLayout = { columns = 2, widgets = [
{
title = "Trànsit (peticions/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 = "Errors (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 = "Latència 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 = "Instàncies actives (saturació)"
xyChart = { dataSets = [{ timeSeriesQuery = { timeSeriesFilter = {
filter = "metric.type=\"run.googleapis.com/container/instance_count\""
aggregation = { alignmentPeriod = "60s", perSeriesAligner = "ALIGN_MEAN" }
}}}]}
}
]}
})
}10.2 L'alerta que sí que notifica
resource "google_monitoring_notification_channel" "correo" {
project = var.proyecto
display_name = "Correu del responsable"
type = "email"
labels = { email_address = var.correo_alertas }
}
resource "google_monitoring_alert_policy" "errores_5xx" {
project = var.proyecto
display_name = "Taxa d'errors 5xx > 5%"
combiner = "OR"
conditions {
display_name = "5xx elevats durant 5 minuts"
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
## Errors 5xx elevats
**Primers passos** (vegeu `docs/runbook.md`):
1. `gcloud logging read 'severity>=ERROR' --limit=20 --freshness=15m`
2. Coincideix amb un desplegament? `gcloud run revisions list --limit=5`
3. Si coincideix: revertir amb
`gcloud run services update-traffic refugio-web --to-revisions=<anterior>=100`
4. Base de dades accessible? `gcloud sql instances describe refugio-db`
EOT
mime_type = "text/markdown"
}
}El bloc documentation és el que converteix una alerta en una cosa útil. Una alerta que només diu «alguna cosa va malament» et desperta; una que diu què mirar i com revertir et permet arreglar-ho. Escriu-hi sempre els primers passos.
10.3 L'SLO com a recurs
resource "google_monitoring_slo" "disponibilidad" {
project = var.proyecto
service = google_monitoring_service.web.service_id
slo_id = "disponibilidad-api"
display_name = "99,5% de peticions sense error 5xx (30 dies)"
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 L'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"]
}Criteri de «fet» de la fase 8 — i aquí n'hi ha un que no es compleix mirant, sinó provocant:
| Comprovació | Com | Esperat |
|---|---|---|
| El tauler existeix | gcloud monitoring dashboards list |
1 tauler |
| L'alerta existeix | gcloud alpha monitoring policies list |
≥1 política |
| L'alerta notifica de debò | Provoca-la (vegeu més avall) | Correu rebut |
| SLO calculant | Consola → SLO | Pressupost d'error amb valor |
| Uptime check | gcloud monitoring uptime list-configs |
1, en estat correcte |
# Provocar l'alerta expressament: desplega una revisió que retorni 500
# en un endpoint de prova, genera trànsit, i espera el correu.
for i in $(seq 1 200); do curl -s -o /dev/null "${URL}/api/error-de-prueba"; done
# Espera 5-10 minuts. Si no arriba el correu, l'alerta no serveix.Una alerta que no s'ha disparat mai no és una alerta: és una intenció. Provocar-la és l'única manera de saber que el canal de notificació funciona, que el llindar és assolible i que el correu no acaba a la carpeta de brossa. Desa la captura del correu rebut: val 2 punts.
- Pràctiques transversals durant tota la implementació
Aquestes sis coses no són de cap fase: són de totes.
11.1 Commits petits i freqüents
Un commit per unitat de treball comprensible. Afegeix mòdul de xarxa amb connector serverless és un commit; Avenços diversos no ho és.
git add infra/modules/red/
git commit -m "Afegeix mòdul de xarxa: VPC, subxarxa, connector /28 i peering de serveis"Beneficis concrets: pots revertir una cosa sense revertir-ne cinc, l'historial explica la història del projecte a la presentació, i git bisect serveix per a alguna cosa quan alguna cosa es trenca.
11.2 terraform plan revisat sempre
Mai apply sense haver llegit el plan. I para atenció especial a tres paraules:
| Al pla | Significat | Reacció |
|---|---|---|
will be created |
Recurs nou | Normal |
will be updated in-place |
Canvi sense recrear | Normal |
must be replaced |
Es destrueix i es crea de nou | PARAR i entendre per què |
will be destroyed |
Desapareix | Comprovar que és intencionat |
must be replaced sobre una base de dades significa perdre les dades. Sobre un bucket, perdre els objectes. Gairebé sempre ho provoca canviar un atribut ForceNew (el nom, la regió, un CIDR). Si apareix i no ho esperaves, cancel·la.
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan | \
jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address'
# Si això retorna alguna cosa que no esperes, no apliquis.11.3 No tocar res a mà fora de Terraform
La regla ideal. I la realitat: ho faràs, amb pressa, un dimarts a la nit.
Què fer aleshores, per ordre:
- Anota-ho immediatament a
docs/diario.md. El pecat no és el clic; és oblidar-lo. - Detecta la deriva:
terraform planet dirà que la realitat no coincideix amb el codi. - Decideix: o portes el canvi al codi (el normal) o reverteixes el canvi manual.
- Si vas crear un recurs a mà, importa'l en comptes de recrear-lo:
# Terraform 1.5+: bloc import, versionable al codi
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ó que faltaEl detector gratuït: l'etiqueta gestionado-por = terraform de 08-02. Qualsevol recurs sense ella es va crear a mà.
11.4 Documentar al vol
Tres línies per sessió a docs/diario.md i un ADR cada vegada que decideixes alguna cosa amb alternatives. No costa i és la diferència entre una presentació amb contingut i una d'inventada la nit abans.
11.5 Controlar la despesa cada pocs dies
# Àlies que convé tenir a mà
gcloud billing accounts list
# I, quan l'exportació a BigQuery porti uns dies poblant:
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'Dues vegades per setmana, trenta segons. I destrueix dev quan no el facis servir:
terraform -chdir=infra/envs/dev destroy -auto-approve # divendres
terraform -chdir=infra/envs/dev apply -auto-approve # dissabteAixò té un doble benefici que convé subratllar: estalvies diners i estàs executant cada setmana la prova més dura de RNF-1. Si algun divendres l'apply del dissabte no reconstrueix l'entorn, acabes de descobrir una fallada al teu IaC en el moment més barat possible.
11.6 Mantenir els entorns alineats
Cada vegada que canviïs alguna cosa a prod, comprova que dev té l'equivalent. La deriva entre entorns converteix la promoció en una loteria.
- Què fer quan t'encalles: el mètode de cinc passos
| Pas | Pregunta | Comanda |
|---|---|---|
| 1 | Què diu l'error, sencer? | gcloud logging read 'severity>=ERROR' --limit=20 --freshness=1h --format=json |
| 2 | Està activada l'API? | gcloud services list --enabled | grep <api> |
| 3 | És un permís? | gcloud policy-troubleshoot iam <recurso> --principal-email=<sa> --permission=<permiso> |
| 4 | És la xarxa? | gcloud network-management connectivity-tests create ... |
| 5 | El recurs és com em penso? | gcloud <servicio> describe <recurso> --format=yaml |
Els deu errors que trobaràs, amb la seva causa
| Símptoma | Causa gairebé segura | Solució |
|---|---|---|
PERMISSION_DENIED en desplegar |
Falta iam.serviceAccountUser sobre la SA d'execució |
Concedeix-lo sobre aquella SA concreta |
API not enabled |
Justament això, o propagació encara en curs | gcloud services enable ... i espera 2 min |
| Cloud SQL privada no es crea | Falta el peering d'accés privat a serveis | Crea global_address + service_networking_connection |
| Cloud Run no connecta amb la BD | Falta el connector, o vpc-egress malament |
--vpc-connector + --vpc-egress=private-ranges-only |
| El contenidor no arrenca | No escolta a $PORT o a 0.0.0.0 |
Corregeix el CMD |
| Desplegament amb timeout | Arrencada > 4 min (migracions a l'inici) | Treu les migracions de l'arrencada |
| Bucket «already exists» | Nom global, el té un altre | Afegeix sufix amb random_id |
| El connector VPC no es crea | El rang no és un /28 exacte |
Ajusta el CIDR |
| Alerta que no arriba | Canal sense verificar, o correu a la brossa | Verifica el canal i prova provocant-la |
must be replaced inesperat |
Vas canviar un atribut ForceNew |
Revisa el registre de Terraform abans d'aplicar |
I la regla de les dues hores: si portes dues hores en el mateix error, canvia de fase. Anota al diari l'error exacte i el que has provat. Tornar-hi l'endemà resol més bloquejos que insistir.
- Registre de progrés i criteris de «fet»
Mantén aquesta taula a docs/diario.md i actualitza-la en acabar cada fase. Quan treballes sol, és l'única cosa que et diu objectivament on ets:
| Fase | Lliurable | Criteri de «fet» verificable | Comanda de verificació | Estat |
|---|---|---|---|---|
| 0 | Projectes i pressupost | 3 projectes facturables, ≥25 APIs, 1 pressupost | gcloud billing projects describe |
⬜ |
| 0 | Repositori | Estructura + README + ≥2 commits |
git log --oneline |
⬜ |
| 1 | Estat remot | Bucket amb versionatge i sense accés públic | gcloud storage buckets describe |
⬜ |
| 1 | Xarxa | VPC pròpia, connector READY, peering actiu |
gcloud compute networks list |
⬜ |
| 1 | Pla net | terraform plan → No changes |
terraform plan |
⬜ |
| 2 | Identitats | 0 rols primitius en SA, 0 claus d'usuari | Script de la fase 2 | ⬜ |
| 2 | WIF | Build autenticat sense secrets al CI | gcloud builds list |
⬜ |
| 3 | Base de dades | ipv4Enabled = False, còpies actives |
gcloud sql instances describe |
⬜ |
| 3 | Secrets | Contrasenya a Secret Manager, no al repositori | git log -p | grep -i password → buit |
⬜ |
| 3 | Dades fictícies | ~2.000 files, migracions registrades | psql -c "SELECT count(*)..." |
⬜ |
| 4 | Aplicació | Servei Ready, /salud/arranque = 200 |
curl |
⬜ |
| 4 | Registres | Entrades amb jsonPayload i camp trace |
gcloud logging read |
⬜ |
| 5 | CI/CD | El push desplega en <10 min | gcloud builds list --limit=1 |
⬜ |
| 5 | Prova que protegeix | Prova trencada → desplegament aturat | Trencar expressament | ⬜ |
| 6 | Flux complet | Reserva → esdeveniment → BigQuery en <2 min | bq query |
⬜ |
| 6 | Tauler de control | 4 visualitzacions amb dades | URL de l'informe | ⬜ |
| 6 | IA | Opinions amb puntuació de sentiment | psql -c "SELECT ... WHERE sentimiento IS NOT NULL" |
⬜ |
| 7 | Domini i TLS | HTTP/2 200 i certificat vàlid |
curl -sI + openssl |
⬜ |
| 8 | Tauler | 4 senyals visibles | gcloud monitoring dashboards list |
⬜ |
| 8 | Alerta provada | Correu rebut després de provocar-la | Captura del correu | ⬜ |
| 8 | SLO | Pressupost d'error amb valor numèric | Consola de Monitoring | ⬜ |
Les tres files en negreta són les que la gent marca sense comprovar. No ho facis: són precisament les que demostren que el sistema funciona de debò.
- L'avís realista: la primera vegada tot triga el doble
Mereix un apartat propi perquè és la principal causa d'abandonament, i perquè no és un problema teu.
| Tasca | Primera vegada | Segona vegada |
|---|---|---|
| Configurar WIF | 2-3 h | 15 min |
| Cloud SQL amb IP privada | 2 h | 20 min |
| Primer desplegament a Cloud Run que funciona | 3-4 h | 30 min |
| Pipeline de Cloud Build complet | 4 h | 45 min |
| Tauler de Monitoring amb 4 gràfiques | 2 h | 30 min |
| Mòdul de Terraform reutilitzable | 3 h | 45 min |
El que passa la primera vegada i no la segona: llegir documentació, entendre el model mental, equivocar-te amb el nom d'un camp, esperar propagacions, descobrir que faltava un permís, desfer i refer.
Tres conseqüències pràctiques:
- Planifica amb el doble. Si creus que la fase 5 són 4 hores, reserva't-ne 8.
- No et mesuris contra un tutorial. El vídeo de vint minuts està editat i gravat per algú que ho ha fet cinquanta vegades.
- El temps «perdut» és l'aprenentatge. Les dues hores barallant-te amb
iam.serviceAccountUsersón exactament el motiu pel qual la propera vegada trigues quinze minuts, i pel qual en una entrevista sabràs respondre a l'instant.
Errors Habituals i Consells
Començar per l'aplicació. És l'error 2 de 08-01 i es manifesta aquí. Si el teu commit número 20 no té res a infra/, has començat per on no era.
Crear «només una coseta» a mà. Mai no és una. Al cap de tres setmanes tens quinze recursos orfes i un terraform plan ple de soroll que ja no llegeixes. Anota, importa o reverteix, sempre.
Aplicar sense llegir el pla. El dia que aparegui must be replaced sobre la teva base de dades i no ho vegis, perdràs les dades i el cap de setmana.
Posar roles/editor «temporalment». Aquest «temporalment» dura fins a la presentació. Comença restrictiu i obre amb policy-troubleshoot.
Deixar el domini per a l'últim dia. La propagació de DNS i l'emissió del certificat tenen temps que no controles. Fes-ho així que la fase 5 sigui estable.
Carregar el model d'IA a cada petició. Analitza una vegada i desa el resultat. Cridar una API d'IA a cada càrrega de pàgina és car i lent sense cap avantatge.
No provar l'alerta. És la comprovació més ràpida de fer i la més oblidada. Una alerta sense provar té un 50 % de probabilitat de no funcionar quan la necessitis.
Consell: escriu primer el criteri de «fet», després el codi. Abans de començar una fase, escriu la comanda que faràs servir per verificar-la. T'obliga a definir què significa acabar i evita el «em sembla que ja està».
Consell: fes servir terraform plan com a eina d'aprenentatge. Cada vegada que escriguis un recurs nou, fes plan i llegeix què implica. És la millor documentació que existeix sobre què fa cada bloc.
Consell: desa les sortides importants al repositori. Un directori docs/evidencias/ amb la sortida de l'script d'auditoria d'identitats, la captura del primer flux d'extrem a extrem, el correu de l'alerta. Val punts a 08-05 i és impossible reconstruir-ho després.
Consell: fes git commit al final de cada sessió encara que no funcioni. Un commit WIP: connector VPC, el peering encara falla és informació útil. L'historial és un lliurable.
Exercicis
Exercici 1 — Construeix la base reproduïble (fases 0 a 2)
Completa les fases 0, 1 i 2 al teu projecte: projectes creats amb els noms del disseny, APIs activades, pressupost amb alertes, repositori amb estructura; bucket d'estat amb versionatge i bloqueig d'accés públic, backend remot amb prefix per entorn, proveïdor fixat per versió, almenys dos mòduls propis (xarxa i identitat) i variables per entorn amb les mateixes claus; comptes de servei per càrrega de treball amb rols acotats al recurs, i Workload Identity Federation amb condició de repositori.
Lliurament: la sortida de l'script de verificació d'identitats (0 rols primitius, 0 claus) i un terraform plan que digui No changes.
Exercici 2 — Dades, aplicació i flux d'extrem a extrem (fases 3, 4 i 6)
Desplega la teva base de dades gestionada sense IP pública, amb la seva contrasenya generada i desada a Secret Manager, les seves migracions aplicades com a codi i les seves dades fictícies sembrades de manera reproduïble. Contenidoritza la teva aplicació complint el contracte de Cloud Run —$PORT, 0.0.0.0, usuari no root, arrencada ràpida—, amb configuració per variables d'entorn, secrets per referència, dues sondes de salut diferents i registres estructurats amb trace_id. Connecta el flux de dades fins al tauler de control i afegeix el component d'IA.
Lliurament: la traça completa d'una acció a la teva aplicació que acaba visible al tauler de control, amb les comandes de verificació de cada salt.
Exercici 3 — Automatitza, exposa i observa (fases 5, 7 i 8)
Munta el pipeline que prova, construeix, publica, desplega a desenvolupament i executa una prova de fum, més la promoció a producció de la mateixa imatge amb aprovació. Trenca una prova expressament i demostra amb una captura que el desplegament s'atura. Exposa el sistema al teu domini amb HTTPS vàlid i redirecció des d'HTTP. Crea el tauler dels quatre senyals, l'alerta amb la seva documentació de primers passos, l'uptime check i l'SLO.
Provoca l'alerta expressament i desa el correu rebut. Tanca l'exercici amb la taula de registre de progrés de l'apartat 13 completament marcada i verificada.
Solucions
Solució 1 — La base de RefugioReserva
Després de completar les tres primeres fases, la verificació dona això:
$ 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]
# Cap compte de servei. Correcte.
$ 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 claus d'usuari"
done
[email protected] -> 0
[email protected] -> 0
[email protected] -> 0
[email protected] -> 0Els tres problemes reals que van aparèixer i quant van costar:
| Problema | Símptoma | Causa | Temps perdut |
|---|---|---|---|
| El connector VPC no es creava | Invalid IP CIDR range |
Hi havia posat /27; exigeix /28 |
25 min |
| WIF autenticava però no desplegava | PERMISSION_DENIED en desplegar |
Faltava iam.serviceAccountUser de sa-deploy sobre sa-web |
1 h 40 min |
| Bucket de fotos rebutjat | already exists |
Nom global agafat | 10 min → random_id |
El segon és el clàssic del mòdul. gcloud policy-troubleshoot el va assenyalar en dos minuts; el problema va ser que vaig trigar una hora i mitja a recordar-me de fer-lo servir. Anotat al diari per no repetir-ho.
Solució 2 — El flux d'extrem a extrem de RefugioReserva
# 1. Verificar la base de dades
$ gcloud sql instances describe refugio-db --project=refugio-dev \
--format="value(settings.ipConfiguration.ipv4Enabled, state)"
False RUNNABLE
# 2. Verificar el secret
$ gcloud secrets versions list refugio-db-password --project=refugio-dev \
--format="value(name,state)"
1 ENABLED
# 3. Dades sembrades
$ 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. L'aplicació respon i llegeix 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 (dades FICTÍCIES)
$ curl -s -X POST "${URL}/api/reservas" -H 'Content-Type: application/json' \
-d '{"refugio_id":3,"fecha":"2026-12-20","plazas":2,
"nombre_titular":"Prova Fictícia","email_titular":"[email protected]"}' | jq
{"id":"7c2e...","estado":"confirmada","plazas_restantes":20}
# 6. El registre estructurat, amb la seva traça
$ 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. L'esdeveniment va arribar a BigQuery (26 segons despré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. I el sentiment de les opinions
$ 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.412Fita M3 tancada. Vuit comandes, vuit salts verificats, 26 segons de l'acció al magatzem analític. Captura desada a docs/evidencias/.
Un detall que va costar temps i val la pena assenyalar: els esdeveniments apareixien duplicats a BigQuery. Causa: Pub/Sub garanteix «almenys una vegada» i la funció es reintentava. Solució: row_ids=[evento_id] a insert_rows_json, que activa la deduplicació per identificador. Sense això, el tauler de control mostrava un 12 % més de reserves de les reals, i el pitjor és que no ho hauria notat si no hagués quadrat el total contra la base de dades operativa. Lliçó anotada: contrasta sempre el magatzem analític contra l'operatiu.
Solució 3 — Lliurament, exposició i observabilitat de RefugioReserva
La prova que el pipeline protegeix. Es va trencar expressament la prova del control d'aforament:
# app/tests/test_unitarios.py — canvi temporal
def test_no_sobreventa():
disponible = calcular_disponibilidad(refugio_id=1, fecha="2026-08-15")
assert disponible == 999 # ← valor incorrecte expressament$ git commit -am "PROVA: trenco el test d'aforament expressament" && 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 ← l'anterior continua servintEl desplegament es va aturar al pas 1. La revisió anterior va continuar atenent trànsit. La xarxa de seguretat existeix i està provada. Captura desada.
Exposició:
$ 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 PermanentlyDesprés de l'ADR-006 es fa servir el domini personalitzat de Cloud Run: certificat gestionat, cost zero, RNF-5 complert. El mòdul del balancejador va quedar escrit i es desplegarà una setmana a 08-04 per a les proves de càrrega.
L'alerta, provocada de debò:
$ for i in $(seq 1 300); do curl -s -o /dev/null "${URL}/api/error-de-prueba"; done
$ # 6 minuts després:
$ gcloud alpha monitoring policies list --format="value(displayName,enabled)"
Tasa de errores 5xx > 5% TrueCorreu rebut a les 20:41, sis minuts després de començar a generar errors. Contenia els quatre primers passos del bloc documentation. Captura desada a docs/evidencias/alerta-recibida.png.
Un detall que no va funcionar a la primera: el primer canal de notificació estava creat però sense verificar, i les alertes no arribaven. No hi ha cap error visible: la política apareix activa i l'incident s'obre, però el correu no surt. Es va descobrir només perquè es va provocar l'alerta expressament. És exactament el motiu pel qual provocar-la és obligatori.
Registre de progrés final:
| Fase | Criteri | Estat | Evidència |
|---|---|---|---|
| 0 | 3 projectes, 27 APIs, pressupost 12 € | ✅ | docs/evidencias/fase0.txt |
| 1 | plan net, VPC pròpia, peering |
✅ | docs/evidencias/plan-limpio.txt |
| 2 | 0 rols primitius, 0 claus, WIF | ✅ | docs/evidencias/identidades.txt |
| 3 | BD privada, secret, 2.000 files | ✅ | docs/evidencias/datos.txt |
| 4 | Servei Ready, registres amb traça |
✅ | docs/evidencias/app.txt |
| 5 | El push desplega; la prova trencada l'atura | ✅ | docs/evidencias/build-fallido.png |
| 6 | Reserva → BigQuery en 26 s; 400 opinions analitzades | ✅ | docs/evidencias/e2e.txt |
| 7 | HTTP/2 200, HSTS, certificat vàlid, 301 | ✅ | docs/evidencias/tls.txt |
| 8 | Tauler, alerta rebuda, SLO, uptime | ✅ | docs/evidencias/alerta-recibida.png |
Temps real invertit: 63 hores enfront de les 50 estimades. La desviació es va concentrar en tres punts: WIF (2,5 h enfront d'1 d'estimada), el peering de Cloud SQL (2 h enfront de 0,5) i la depuració dels esdeveniments duplicats (3 h no previstes). Coincideix amb l'avís de l'apartat 14 gairebé exactament.
Conclusió
El teu sistema existeix, funciona i es reconstrueix des de zero.
Saps per què l'ordre de construcció és el que és: del que és irreversible al que és reversible, del que no depèn de res al que depèn de tot. El projectId a la fase 0 i el tauler a la 8, perquè un no es canvia mai i l'altre es refà en deu minuts. I coneixes cada dependència concreta: per què la identitat va abans que les dades, per què l'aplicació no s'automatitza fins a haver-la desplegat a mà una vegada, per què l'observabilitat es munta sobre el sistema complet.
Tens la fase 0 amb els projectes creats amb noms definitius, les APIs activades de cop, el pressupost existint abans que poguessis gastar, i el repositori amb la seva estructura i el seu README escrit al principi i no al final.
Tens la base amb Terraform: el bootstrap que resol el problema de l'ou i la gallina, el bucket d'estat amb versionatge i sense accés públic, el backend amb prefix per entorn, el proveïdor fixat per versió perquè una actualització un dimarts qualsevol no et proposi destruir mitja infraestructura, mòduls propis reutilitzats en dev i prod, variables amb exactament les mateixes claus en tots dos, i els dos recursos del peering d'accés privat a serveis que tothom oblida.
Tens la identitat feta bé: un compte de servei per càrrega, rols acotats al recurs i no al projecte —google_secret_manager_secret_iam_member, no google_project_iam_member—, Workload Identity Federation amb el seu attribute_condition, que sense ell accepta testimonis de qualsevol repositori del planeta, i iam.serviceAccountUser previst perquè el primer desplegament automatitzat no et robi una tarda.
Tens les dades amb la base sense IP pública, la contrasenya generada per Terraform i desada a Secret Manager sense que ningú la vegi mai, buckets amb prevenció d'accés públic forçada i cicle de vida, migracions idempotents versionades al repositori i un generador de dades fictícies reproduïble amb llavor fixa.
Tens l'aplicació complint el contracte de Cloud Run —$PORT, 0.0.0.0, arrencada ràpida, sense estat—, sense root, amb configuració per variables i secrets per referència, amb dues sondes que fan coses diferents expressament, amb cpu_idle per no pagar entre peticions, i amb registres estructurats la clau dels quals logging.googleapis.com/trace enllaça cada línia amb la seva traça.
Tens el lliurament automatitzat amb l'ordre correcte —proves abans de construir, fum després de desplegar— i el principi que el fa fiable: la mateixa imatge es promociona a producció, sense reconstruir, perquè reconstruir és desplegar una cosa que no has provat mai. I l'has verificat de l'única manera que val: trencant una prova expressament i comprovant que el desplegament s'atura.
Tens la capa de dades i IA amb esdeveniments que no porten dades personals, deduplicació per row_ids que evita xifres inflades, taules particionades amb filtre obligatori i un component d'IA basat en una API preentrenada que analitza una vegada i desa el resultat.
Tens l'exposició amb domini propi i TLS vàlid, sabent triar entre la ruta de cost zero i la completa amb CDN i WAF, i amb la limitació de taxa entesa com el que també és: protecció del pressupost.
I tens l'observabilitat amb el tauler dels quatre senyals, l'alerta amb els seus primers passos escrits al mateix bloc documentation, l'uptime check, l'SLO calculant el seu pressupost d'error, i l'alerta provada provocant-la, que és l'única manera de descobrir un canal de notificació sense verificar abans de necessitar-lo.
Sobre tot això tens les pràctiques transversals: commits petits, plan llegit sempre —amb must be replaced com a paraula d'alarma—, res creat a mà i què fer quan ho fas, documentació al vol, control de la despesa dues vegades per setmana amb el destroy dels divendres que estalvia diners i prova el teu IaC alhora, i els entorns alineats.
I tens el mètode de cinc passos per quan t'encallis, els deu errors més freqüents amb la seva causa, la regla de les dues hores, la taula de registre de progrés amb criteris verificables i l'advertiment més honest: la primera vegada tot triga el doble, i aquest temps no és perdut, és exactament l'aprenentatge.
A la propera lliçó, 08-04, la pregunta ja no és «funciona?» sinó «com ho sé?». Muntaràs la piràmide de proves del teu projecte, provaràs la infraestructura recreant-la des de zero —la prova definitiva que el teu IaC és real—, executaràs una llista de comprovació de seguretat, faràs una prova de càrrega honesta i barata, apagaràs una dependència expressament per veure què passa, cronometraràs una restauració, assajaràs una reversió, i passaràs la teva llista de verificació prèvia al llançament abans de declarar el projecte en producció.
Curs de Google Cloud Platform (GCP)
Mòdul 1: Introducció a Google Cloud Platform
- Què és Google Cloud Platform?
- Configuració del teu compte de GCP
- Descripció general de la consola de GCP
- Projectes, jerarquia de recursos i facturació
- Regions, zones i model de responsabilitat compartida
- Cloud Shell i la CLI de gcloud
Mòdul 2: Serveis principals de GCP
- Compute Engine: màquines virtuals a Google Cloud
- Cloud Storage: emmagatzematge d'objectes
- Cloud SQL: bases de dades relacionals gestionades
- App Engine: plataforma com a servei
- Google Kubernetes Engine (GKE)
- Bases de dades NoSQL: Firestore, Bigtable i Spanner
- Com triar el servei de còmput adequat
Mòdul 3: Xarxes i seguretat
- Xarxes VPC
- Balanceig de càrrega al núvol
- Cloud CDN
- Gestió d'identitat i accés (IAM)
- Cloud Armor
- Secrets i xifratge: Secret Manager i Cloud KMS
- Cloud DNS, certificats TLS i publicació segura de serveis
Mòdul 4: Dades i anàlisi
- BigQuery: el magatzem de dades analític
- Cloud Dataflow: processament de dades per lots i en temps real
- Cloud Dataproc: Spark i Hadoop gestionats
- Cloud Pub/Sub: missatgeria asíncrona
- Cloud Data Fusion: integració de dades sense codi
- Orquestració de pipelines amb Cloud Composer i Workflows
- Govern de les dades i taulers amb Dataplex i Looker Studio
Mòdul 5: Aprenentatge automàtic i IA
- Vertex AI: la plataforma d'aprenentatge automàtic de GCP
- AutoML: models a mida sense escriure codi
- TensorFlow a GCP: entrenament i servei de models
- API de llenguatge natural
- API de visió
- IA generativa a Vertex AI: models Gemini i incrustacions
- MLOps: del model al producte amb Vertex AI Pipelines
Mòdul 6: DevOps i monitoratge
- Cloud Build: integració contínua a GCP
- Cloud Source Repositories i gestió del codi font
- Cloud Functions: funcions sense servidor
- Cloud Monitoring (abans Stackdriver): mètriques, taulers i alertes
- Cloud Deployment Manager i infraestructura com a codi nativa
- Cloud Logging i Cloud Trace: registres, traces i diagnòstic
- Terraform a GCP: infraestructura com a codi a la pràctica
Mòdul 7: Temes avançats de GCP
- Híbrid i multinúvol amb Anthos
- Computació sense servidor amb Cloud Run
- Xarxes avançades: VPC compartida, aparellament i connectivitat híbrida
- Bones pràctiques de seguretat
- Gestió i optimització de costos
- Fiabilitat: SLO, alta disponibilitat i recuperació de desastres
- Govern a escala: organització, polítiques i auditoria
