A 06-05 va quedar tot dit tret de l'essencial. Saps quin és el problema —la infraestructura d'AlpinaShop existeix només perquè la Marta va executar les comandes correctes, i ningú no sabria reproduir-la—, saps què és la infraestructura com a codi, entens l'estat i coneixes el procediment de migració pas a pas. El que falta és l'eina amb què fer-ho.

Aquesta lliçó la porta, i amb ella es tanca el mòdul. En acabar, la VPC d'AlpinaShop, les seves subxarxes, les seves regles de tallafoc i els seus buckets estaran escrits en fitxers que es revisen en un pull request, s'apliquen des d'un pipeline i es poden reproduir en una altra regió canviant una variable. I la resposta a «quant de temps es triga a reconstruir l'entorn?» passarà de «ningú no ho sap» a «uns vint minuts».

Un avís sobre l'abast: aquesta lliçó ensenya Terraform prou per gestionar la infraestructura d'AlpinaShop amb criteri. Terraform dona per a un curs sencer, i aquí es cobreix el que es fa servir el 95 % del temps, assenyalant el que queda fora.

Contingut

  1. Per què Terraform va guanyar
  2. Els conceptes: proveïdor, recurs, font de dades, variable, sortida i dependències
  3. L'estat: què conté i per què no va mai a Git
  4. El backend remot a Cloud Storage
  5. El flux de treball: init, validate, plan, apply, destroy
  6. Com es llegeix un pla
  7. Codi real d'AlpinaShop: la xarxa i el bucket
  8. Variables, tfvars i entorns
  9. Mòduls: escriure el mòdul red-alpinashop
  10. Mòduls del registre públic, amb criteri
  11. Importar el que ja existeix
  12. Terraform a CI/CD: plan a cada pull request
  13. Què NO ha de gestionar Terraform
  14. prevent_destroy i el perill de terraform destroy
  15. Infrastructure Manager i alternatives
  16. L'estat final d'AlpinaShop

  1. Per què Terraform va guanyar

A 06-05 es va veure per què Deployment Manager va perdre. L'altra cara és per què va guanyar Terraform, i no és només mèrit tècnic:

Raó Detall
Multiproveïdor GCP, AWS, Azure, Kubernetes, GitHub, Cloudflare, Datadog, PostgreSQL… amb un mateix llenguatge
Ecosistema de mòduls Milers de mòduls públics revisats i mantinguts, molts per Google
Maduresa Des del 2014, amb els casos estranys ja resolts i documentats
Comunitat Exemples, llibres, cursos i —molt important— professionals que ja el coneixen
Adoptat pel mateix Google Infrastructure Manager és Terraform gestionat
Llenguatge llegible HCL és més clar que YAML imbricat o que JSON

La primera fila importa més del que sembla fins i tot per a qui només fa servir GCP. La infraestructura real d'AlpinaShop no és només GCP: hi ha un registre DNS al registrador del domini, repositoris i proteccions de branca a GitHub, potser demà una CDN externa. Amb Terraform, tot això es declara al mateix lloc i amb el mateix flux, i les dependències entre proveïdors funcionen igual que dins d'un.

Una nota sobre la llicència, perquè cal ser honest: el 2023 HashiCorp va canviar Terraform d'una llicència de codi obert a la Business Source License, cosa que va provocar l'aparició d'OpenTofu, una bifurcació sota llicència oberta gestionada per una fundació. Per a l'ús normal —una empresa gestionant la seva pròpia infraestructura— la BSL no imposa cap restricció pràctica, i totes dues eines són compatibles a nivell de codi i d'estat. És una cosa que convé conèixer; per a AlpinaShop no canvia res.

  1. Els conceptes: proveïdor, recurs, font de dades, variable, sortida i dependències

Terraform s'escriu en HCL (HashiCorp Configuration Language), i té pocs tipus de bloc. Amb aquests sis es fa gairebé tot.

Proveïdor (provider): el complement que sap parlar amb una API. Es declara amb la versió fixada:

terraform {
  required_version = ">= 1.9"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 6.0"     # permet 6.x, no salta a 7.x
    }
  }
}

provider "google" {
  project = var.proyecto
  region  = var.region
}

Fixar la versió del proveïdor no és opcional. Sense version, Terraform descarrega l'última disponible, i una versió major pot canviar el comportament de recursos existents. La sintaxi ~> 6.0 permet actualitzacions menors i de pedaç però no el salt a la 7, que és on hi ha els canvis incompatibles.

Recurs (resource): alguna cosa que Terraform crea i gestiona.

resource "google_compute_network" "principal" {
  #        └── tipus                 └── nom LOCAL, només dins de Terraform
  name                    = "alpinashop-vpc"   # el nom real a GCP
  auto_create_subnetworks = false
}

La distinció entre el nom local (principal) i l'atribut name (alpinashop-vpc) confon al principi. El local és l'etiqueta amb què altres parts del codi referencien el recurs; l'atribut name és el que apareix a la consola de GCP.

Font de dades (data): consulta alguna cosa que existeix però que Terraform no gestiona.

data "google_project" "actual" {}

data "google_compute_image" "debian" {
  family  = "debian-12"
  project = "debian-cloud"     # imatge pública de Google
}

La diferència amb un resource és total: un data només llegeix, mai no crea ni modifica. Serveix per obtenir el número de projecte, l'última imatge d'un sistema operatiu o un recurs creat per un altre equip.

Variable (variable): entrada parametritzable.

variable "entorno" {
  description = "Entorn de desplegament"
  type        = string

  validation {
    condition     = contains(["dev", "prod"], var.entorno)
    error_message = "L'entorn ha de ser 'dev' o 'prod'."
  }
}

variable "cidr_web" {
  description = "Rang CIDR de la subxarxa web"
  type        = string
  default     = "10.10.0.0/24"
}

El bloc validation és l'equivalent als esquemes de Deployment Manager de 06-05, i produeix un error clar abans de tocar res.

Sortida (output): valor que s'exposa en acabar.

output "id_red" {
  description = "Identificador de la VPC principal"
  value       = google_compute_network.principal.id
}

output "password_bd" {
  value     = google_sql_user.app.password
  sensitive = true      # no s'imprimeix a la consola
}

sensitive = true evita que el valor aparegui a la sortida. No el xifra ni l'amaga de l'estat, que és una distinció important i que reprenem a l'apartat 3.

Dependències. La majoria són implícites: neixen de referenciar un recurs des d'un altre.

resource "google_compute_subnetwork" "web" {
  name          = "sn-web-euw1"
  ip_cidr_range = var.cidr_web
  region        = var.region
  # Aquesta referència DECLARA que la subxarxa depèn de la xarxa
  network       = google_compute_network.principal.id
}

Terraform construeix un graf, crea primer la xarxa i després la subxarxa, i paral·lelitza tot el que no té dependències entre si. Les explícites amb depends_on són l'últim recurs, per a dependències que no s'expressen mitjançant dades:

resource "google_compute_instance" "app" {
  # ...
  # L'API ha d'estar habilitada abans, però això no es reflecteix en cap atribut
  depends_on = [google_project_service.compute]
}

Consell: fes servir depends_on el mínim possible. Un depends_on que es pot substituir per una referència és una oportunitat perduda que el codi expressi la relació real.

  1. L'estat: què conté i per què no va mai a Git

A 06-05 es va explicar què és l'estat conceptualment. Aquí toca la part pràctica, que és on hi ha els problemes reals.

El fitxer terraform.tfstate és un JSON que conté, per a cada recurs gestionat: el seu nom local, el seu tipus, el seu identificador real a GCP i tots els seus atributs tal com estaven a l'última operació.

{
  "version": 4,
  "terraform_version": "1.9.5",
  "serial": 42,
  "lineage": "8f3e...",
  "resources": [
    {
      "mode": "managed",
      "type": "google_sql_user",
      "name": "app",
      "instances": [{
        "attributes": {
          "name": "catalogo",
          "instance": "alpinashop-pedidos",
          "password": "P4ssw0rdEnClaro..."
        }
      }]
    }
  ]
}

Mira l'última línia. La contrasenya és en clar al fitxer d'estat. No és un error de configuració: és el funcionament normal. Terraform desa tots els atributs de cada recurs, i alguns atributs són secrets. El mateix passa amb claus generades, testimonis i certificats.

D'aquí les quatre regles de l'estat:

Regla Motiu
Mai a Git Conté secrets en clar, i Git no oblida (06-02)
Backend remot compartit Si cadascú té la seva còpia local, l'equip es trepitja
Amb bloqueig Dos apply simultanis corrompen l'estat
Amb versionatge Un estat corrupte o esborrat es recupera d'una versió anterior

I una conseqüència important que es deriva de tot això: l'accés a l'estat és tan sensible com l'accés a producció. Qui pot llegir el bucket de l'estat pot llegir les contrasenyes que Terraform gestioni. Per això l'apartat 13 insisteix que Terraform no ha de gestionar valors de secrets.

El .gitignore no és negociable:

*.tfstate
*.tfstate.*
*.tfstate.backup
.terraform/
.terraform.lock.hcl.bak
crash.log
*.tfvars.secret
override.tf

Amb una excepció deliberada: .terraform.lock.hcl SÍ que va a Git. Aquest fitxer fixa les versions exactes dels proveïdors i les seves sumes de verificació, i versionar-lo garanteix que tot l'equip i el pipeline usen exactament el mateix. És l'equivalent a un package-lock.json.

  1. El backend remot a Cloud Storage

El backend defineix on viu l'estat. Per a GCP, Cloud Storage.

Primer es crea el bucket, i això és l'únic que es fa a mà en tot el procés —problema de l'ou i la gallina—:

gcloud storage buckets create gs://alpinashop-terraform-estado \
  --project=alpinashop-cicd \
  --location=europe-west1 \
  --uniform-bucket-level-access \
  --public-access-prevention

# VERSIONATGE: imprescindible, permet recuperar un estat corrupte
gcloud storage buckets update gs://alpinashop-terraform-estado --versioning

# Accés restringit: qui llegeixi això llegeix secrets
gcloud storage buckets add-iam-policy-binding gs://alpinashop-terraform-estado \
  --member='group:[email protected]' \
  --role=roles/storage.objectAdmin \
  --project=alpinashop-cicd

I es declara al codi:

terraform {
  backend "gcs" {
    bucket = "alpinashop-terraform-estado"
    prefix = "prod/red"       # una ruta diferent per entorn i component
  }
}

El prefix és més important del que sembla: separa estats. prod/red, prod/datos, dev/red són estats independents, i aquesta separació limita el radi de dany: un error operant la xarxa no pot afectar l'estat de la base de dades.

El bloqueig funciona sol, sense configuració. Terraform crea un objecte .tflock mentre opera; si una altra persona llança apply alhora, obté un error clar en lloc de corrompre l'estat:

Error: Error acquiring the state lock
Lock Info:
  ID:        b3d4e5f6
  Who:       [email protected]
  Created:   2026-08-05 18:42:11 UTC

Si un procés mor deixant el bloqueig posat —una compilació cancel·lada, per exemple—, s'allibera amb terraform force-unlock ID. És una comanda que cal fer servir amb compte: només quan estiguis segur que no hi ha cap operació en curs.

  1. El flux de treball: init, validate, plan, apply, destroy

flowchart LR
    A[init<br/>descarregar proveïdors<br/>configurar backend] --> B[validate<br/>sintaxi i tipus]
    B --> C[plan<br/>QUÈ PASSARÀ]
    C --> D{El pla<br/>és correcte?}
    D -->|No| E[Corregir el codi]
    E --> C
    D -->|Sí| F[apply<br/>executar]
    F --> G[Infraestructura<br/>actualitzada]
# 1. init: descarrega proveïdors i configura el backend. S'executa en començar
#    i cada vegada que canvien proveïdors o mòduls.
terraform init

# 2. fmt i validate: formatatge canònic i comprovació de sintaxi i tipus.
#    Ràpids, sense tocar res, ideals per a un ganxo de pre-commit (06-02).
terraform fmt -recursive
terraform validate

# 3. plan: L'OPERACIÓ MÉS IMPORTANT. Compara codi, estat i realitat.
terraform plan -out=plan.tfplan

# 4. apply: executa el pla desat. Amb el fitxer, aplica EXACTAMENT
#    el que has revisat; sense ell, recalcula i podria fer una cosa diferent.
terraform apply plan.tfplan

# Utilitats
terraform show                 # veure l'estat en format llegible
terraform state list           # llistar recursos gestionats
terraform output id_red        # consultar una sortida

El detall de -out=plan.tfplan mereix èmfasi perquè és la diferència entre revisar i confiar. Sense ell, terraform apply recalcula el pla en aquell moment; si alguna cosa va canviar entremig, aplica una cosa diferent de la que es va revisar. Amb el fitxer, aplica exactament l'aprovat. En un pipeline és obligatori.

I terraform destroy destrueix tot el de l'estat. El seu ús legítim és un entorn efímer de proves. En producció és la comanda més perillosa de l'eina, i l'apartat 14 explica com protegir-se'n.

  1. Com es llegeix un pla

Saber llegir un pla és l'habilitat més important d'aquesta lliçó. Un apply mal revisat és un incident.

Els quatre símbols:

Símbol Significat Nivell d'atenció
+ Crear un recurs nou Comprovar que és el que esperes
~ Modificar al lloc Llegir quin camp canvia
- Destruir Alta: per què desapareix?
-/+ Destruir i recrear MÀXIMA: para i entén per què

I la línia resum al final:

Plan: 3 to add, 2 to change, 1 to destroy.

La regla d'or és la del -/+. Un reemplaçament passa quan canvia un atribut que l'API de GCP no permet modificar en calent. Terraform, sense més opcions, destrueix i crea. I en alguns recursos això és catastròfic:

  # google_sql_database_instance.pedidos must be replaced
-/+ resource "google_sql_database_instance" "pedidos" {
      ~ region = "europe-west1" -> "europe-west4" # forces replacement
    }

Aquest pla destruiria la base de dades de comandes d'AlpinaShop amb totes les seves dades. Terraform ho diu amb claredat —# forces replacement— però un apply automàtic o distret l'executa sense dubtar.

Recurs Un -/+ és acceptable? Per què
Regla de tallafoc Es recrea en segons
Plantilla d'instància Són immutables per disseny
Subxarxa No Desconnecta tot el que hi ha a dins
Cloud SQL MAI sense pla explícit Pèrdua de dades
Bucket amb contingut MAI Pèrdua d'objectes
Clúster de GKE No Aturada completa del servei

Què fer davant d'un -/+ inesperat, en aquest ordre: llegir quin atribut el força —Terraform ho marca amb # forces replacement—; decidir si aquest canvi és realment necessari; si ho és, buscar una via sense destruir —crear el recurs nou, migrar i esborrar el vell—; i si no ho és, corregir el codi. Mai aplicar sense haver entès la causa.

Un altre cas que confon: els plans que no surten mai buits encara que no canviïs res. Solen deure's a un atribut que GCP normalitza —una llista que reordena, un valor per defecte que emplena— i es resolen o bé escrivint el valor real al codi, o bé, quan el proveïdor té un comportament incorrecte, amb lifecycle { ignore_changes = [...] }. Aquesta segona opció és un pedaç i convé fer-la servir amb moderació: cada ignore_changes és una part de la infraestructura que Terraform deixa de vigilar.

  1. Codi real d'AlpinaShop: la xarxa i el bucket

Ara el codi de veritat, explicat línia a línia.

# ============================================================
# red.tf — Xarxa d'AlpinaShop
# ============================================================

resource "google_compute_network" "principal" {
  name = "alpinashop-vpc"

  # false = subxarxes creades per nosaltres, amb els rangs que decidim.
  # Amb true, GCP crearia una subxarxa automàtica a CADA regió (03-01).
  auto_create_subnetworks = false

  # REGIONAL: les rutes només es propaguen dins de la regió. Suficient
  # per a AlpinaShop i amb menys superfície que GLOBAL.
  routing_mode = "REGIONAL"

  description = "VPC principal d'AlpinaShop - gestionada amb Terraform"
}

resource "google_compute_subnetwork" "web" {
  name          = "sn-web-euw1"
  ip_cidr_range = var.cidr_web
  region        = var.region

  # Referència: crea la dependència implícita amb la xarxa
  network = google_compute_network.principal.id

  # CRÍTIC: permet que les VM SENSE IP pública arribin a les APIs de Google
  # (Cloud Storage, Secret Manager, Logging). Sense això, es trenca mig mòdul 3.
  private_ip_google_access = true

  # Registres de flux amb mostreig: diagnòstic de xarxa sense cost desbocat (06-06)
  log_config {
    aggregation_interval = "INTERVAL_5_SEC"
    flow_sampling        = 0.25
    metadata             = "INCLUDE_ALL_METADATA"
  }
}

resource "google_compute_subnetwork" "datos" {
  name                     = "sn-datos-euw1"
  ip_cidr_range            = var.cidr_datos
  region                   = var.region
  network                  = google_compute_network.principal.id
  private_ip_google_access = true
}
# ============================================================
# firewall.tf — Regles de tallafoc
# ============================================================

resource "google_compute_firewall" "permitir_salud" {
  name    = "fw-permitir-salud"
  network = google_compute_network.principal.name

  allow {
    protocol = "tcp"
    ports    = ["8080"]
  }

  # Rangs FIXOS de les sondes de comprovació d'estat de Google (03-02).
  # No són adreces arbitràries: si es treuen, hc-catalogo marca totes
  # les instàncies com a no sanes i el balancejador deixa d'enviar trànsit.
  source_ranges = ["35.191.0.0/16", "130.211.0.0/22"]
  target_tags   = ["catalogo-web"]

  description = "Permet les sondes de hc-catalogo cap a /salud al 8080"
}

resource "google_compute_firewall" "permitir_sql_interno" {
  name    = "fw-permitir-sql-interno"
  network = google_compute_network.principal.name

  allow {
    protocol = "tcp"
    ports    = ["5432"]
  }

  # Només des de la subxarxa web: la base de dades no s'exposa a res més
  source_ranges = [var.cidr_web]
  target_tags   = ["base-datos"]

  description = "PostgreSQL accessible únicament des de la subxarxa web"
}

# Denegar i REGISTRAR la resta del trànsit entrant.
# Prioritat 65000: s'avalua l'última, després de totes les permissives.
resource "google_compute_firewall" "denegar_resto" {
  name     = "fw-denegar-resto"
  network  = google_compute_network.principal.name
  priority = 65000
  deny { protocol = "all" }
  source_ranges = ["0.0.0.0/0"]

  # Els intents denegats es registren: base per investigar (06-06)
  log_config { metadata = "INCLUDE_ALL_METADATA" }
}
# ============================================================
# almacenamiento.tf — Bucket del catàleg
# ============================================================

resource "google_storage_bucket" "catalogo" {
  name     = "alpinashop-catalogo"
  location = var.region

  # Accés uniforme: només IAM, sense ACL per objecte. Molt més simple d'auditar.
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  versioning { enabled = true }

  # Cicle de vida: les versions antigues baixen de classe i s'esborren (02-02)
  lifecycle_rule {
    condition {
      age                = 30
      with_state         = "ARCHIVED"
      num_newer_versions = 3
    }
    action { type = "Delete" }
  }

  lifecycle_rule {
    condition { age = 90 }
    action {
      type          = "SetStorageClass"
      storage_class = "NEARLINE"
    }
  }

  labels = {
    entorno      = var.entorno
    equipo       = "infraestructura"
    centro-coste = "tienda"
    aplicacion   = "catalogo"
  }

  # Protecció: impedeix que un 'terraform destroy' esborri el bucket (apartat 14)
  lifecycle {
    prevent_destroy = true
  }
}

Fixa't en dues coses d'aquest codi que són la raó de ser de l'exercici. Primer, els comentaris expliquen el perquè, no el què: que 35.191.0.0/16 són les sondes de Google, que private_ip_google_access trenca mig mòdul 3 si falta. Aquest coneixement vivia al cap de la Marta i ara viu al repositori. I segon, les etiquetes entorno, equipo, centro-coste i aplicacion són les de tot el curs: en ser codi, deixen d'aplicar-se «quan algú se'n recorda» i s'apliquen sempre.

  1. Variables, tfvars i entorns

L'objectiu és que alpinashop-dev i alpinashop-prod comparteixin el mateix codi amb valors diferents, resolent la deriva de configuració de 06-05.

# variables.tf — el mateix per als dos entorns
variable "proyecto" {
  description = "ID del projecte de GCP"
  type        = string
}

variable "entorno" {
  description = "Entorn: dev o prod"
  type        = string
  validation {
    condition     = contains(["dev", "prod"], var.entorno)
    error_message = "L'entorn ha de ser 'dev' o 'prod'."
  }
}

variable "region" {
  type    = string
  default = "europe-west1"
}

variable "cidr_web" {
  type    = string
  default = "10.10.0.0/24"
}

variable "cidr_datos" {
  type    = string
  default = "10.20.0.0/24"
}

variable "tamano_instancia_sql" {
  description = "Tipus de màquina de Cloud SQL"
  type        = string
  default     = "db-g1-small"     # el valor barat com a defecte
}
# entornos/dev.tfvars
proyecto             = "alpinashop-dev"
entorno              = "dev"
cidr_web             = "10.110.0.0/24"     # rangs diferents: evita solapaments
cidr_datos           = "10.120.0.0/24"
tamano_instancia_sql = "db-g1-small"
# entornos/prod.tfvars
proyecto             = "alpinashop-prod"
entorno              = "prod"
cidr_web             = "10.10.0.0/24"
cidr_datos           = "10.20.0.0/24"
tamano_instancia_sql = "db-custom-4-15360"
terraform plan -var-file=entornos/dev.tfvars -out=dev.tfplan
terraform apply dev.tfplan

Carpetes per entorn davant de workspaces, que és la decisió estructural que cal prendre:

Aspecte Carpetes per entorn Workspaces
Estructura entornos/dev/, entornos/prod/ Un directori, diversos estats
Estat Backend diferent per carpeta Mateix backend, prefixos diferents
Divergència entre entorns Possible i visible Difícil
Risc d'equivocar-se d'entorn Baix: ets en una altra carpeta Alt: és una comanda
Duplicació de codi Alguna, es mitiga amb mòduls Cap
Recomanació Per a prod i dev Entorns efímers i molt iguals

AlpinaShop tria carpetes per entorn, i la raó principal és de seguretat operativa: amb workspaces, l'única diferència entre aplicar en desenvolupament i aplicar en producció és haver executat terraform workspace select prod abans, i és massa fàcil oblidar-ho. Amb carpetes, cadascuna té el seu propi backend i el seu propi provider, i aplicar en producció exigeix ser físicament al directori de producció. És una barrera petita que evita l'error més car.

L'estructura resultant del repositori alpinashop-infra:

alpinashop-infra/
├── modulos/
│   └── red-alpinashop/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── entornos/
│   ├── dev/
│   │   ├── main.tf          # invoca els mòduls
│   │   ├── backend.tf       # prefix = "dev/"
│   │   └── dev.tfvars
│   └── prod/
│       ├── main.tf
│       ├── backend.tf       # prefix = "prod/"
│       └── prod.tfvars
└── paneles/                 # els JSON de 06-04

  1. Mòduls: escriure el mòdul red-alpinashop

Un mòdul és un directori amb codi Terraform que s'invoca amb paràmetres. És el mecanisme de reutilització.

# modulos/red-alpinashop/variables.tf
variable "nombre_red"  { type = string }
variable "region"      { type = string }
variable "cidr_web"    { type = string }
variable "cidr_datos"  { type = string }
variable "entorno"     { type = string }

variable "habilitar_nat" {
  description = "Crear Cloud NAT per a sortida sense IP pública"
  type        = bool
  default     = true
}
# modulos/red-alpinashop/main.tf
resource "google_compute_network" "esta" {
  name                    = var.nombre_red
  auto_create_subnetworks = false
  routing_mode            = "REGIONAL"
}

resource "google_compute_subnetwork" "web" {
  name                     = "sn-web-${substr(var.region, 0, 8)}"
  ip_cidr_range            = var.cidr_web
  region                   = var.region
  network                  = google_compute_network.esta.id
  private_ip_google_access = true
}

resource "google_compute_subnetwork" "datos" {
  name                     = "sn-datos-${substr(var.region, 0, 8)}"
  ip_cidr_range            = var.cidr_datos
  region                   = var.region
  network                  = google_compute_network.esta.id
  private_ip_google_access = true
}

# El router i el NAT només es creen si es demanen: count amb una condició
resource "google_compute_router" "router" {
  count   = var.habilitar_nat ? 1 : 0
  name    = "${var.nombre_red}-router"
  region  = var.region
  network = google_compute_network.esta.id
}

resource "google_compute_router_nat" "nat" {
  count  = var.habilitar_nat ? 1 : 0
  name   = "${var.nombre_red}-nat"
  router = google_compute_router.router[0].name
  region = var.region

  nat_ip_allocate_option             = "AUTO_ONLY"
  source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"

  log_config {
    enable = true
    filter = "ERRORS_ONLY"     # només errors: el volum complet és enorme
  }
}
# modulos/red-alpinashop/outputs.tf
output "id_red"      { value = google_compute_network.esta.id }
output "nombre_red"  { value = google_compute_network.esta.name }
output "id_sn_web"   { value = google_compute_subnetwork.web.id }
output "id_sn_datos" { value = google_compute_subnetwork.datos.id }

I el seu ús des de cada entorn:

# entornos/prod/main.tf
module "red" {
  source = "../../modulos/red-alpinashop"

  nombre_red    = "alpinashop-vpc"
  region        = var.region
  cidr_web      = var.cidr_web
  cidr_datos    = var.cidr_datos
  entorno       = "prod"
  habilitar_nat = true
}

# Les sortides del mòdul es consumeixen com qualsevol altre valor
resource "google_compute_firewall" "permitir_salud" {
  name          = "fw-permitir-salud"
  network       = module.red.nombre_red
  source_ranges = ["35.191.0.0/16", "130.211.0.0/22"]
  target_tags   = ["catalogo-web"]
  allow {
    protocol = "tcp"
    ports    = ["8080"]
  }
}

Les tres regles d'un bon mòdul:

  1. Una responsabilitat clara. red-alpinashop fa la xarxa. Un mòdul que crea la xarxa, la base de dades i el balancejador és un monolit amb un altre nom.
  2. Parametritzar el que varia, fixar el que no. private_ip_google_access = true està fixat a propòsit: no és una decisió que cada entorn hagi de prendre, és una regla d'AlpinaShop.
  3. Exposar les sortides que altres necessiten. Un mòdul les sortides del qual no basten obliga a trencar-ne l'encapsulació.

I un advertiment sobre l'excés d'abstracció, que és l'error més comú en descobrir els mòduls: un mòdul amb vint variables per cobrir tots els casos imaginables és més difícil d'usar que escriure el recurs directament. Comença amb codi pla, extreu un mòdul quan el repeteixis per segona vegada, i no abans.

  1. Mòduls del registre públic, amb criteri

El Terraform Registry allotja milers de mòduls públics, i Google manté una col·lecció de mòduls per a GCP de qualitat notable.

module "red" {
  source  = "terraform-google-modules/network/google"
  version = "~> 9.0"        # SEMPRE fixar la versió

  project_id   = var.proyecto
  network_name = "alpinashop-vpc"

  subnets = [
    {
      subnet_name           = "sn-web-euw1"
      subnet_ip             = "10.10.0.0/24"
      subnet_region         = "europe-west1"
      subnet_private_access = "true"
    },
  ]
}

Els criteris per decidir si usar un mòdul públic o escriure el teu:

Criteri A favor d'usar-lo En contra
Qui el manté Google, HashiCorp, organització coneguda Una persona anònima
Activitat Commits recents, incidències ateses Sense actualitzar en dos anys
Complexitat que estalvia Recursos amb moltes peces interrelacionades Un recurs trivial
Ajust al teu cas Cobreix el que necessites T'obliga a contorsionar-te
Llegibilitat Pots llegir-ne el codi Cinc nivells d'abstracció

La regla que faig servir: com més complex és el recurs, més compensa el mòdul públic. Un mòdul per crear una VPC amb dues subxarxes estalvia poc i afegeix una dependència externa; un mòdul per muntar una VPC compartida amb regles jeràrquiques, o un clúster de GKE endurit amb totes les seves bones pràctiques, estalvia setmanes de feina i d'errors.

I dos advertiments seriosos. Fixa sempre la versió amb version = "~> X.Y": sense això, un terraform init al pipeline pot portar una versió nova del mòdul que canviï recursos de producció sense que ningú ho hagi decidit. I un mòdul públic és codi de tercers amb permisos sobre la teva infraestructura: per a mòduls d'organitzacions no conegudes, llegeix-lo abans, o bifurca'l al teu propi repositori. És exactament el mateix raonament de la cadena de subministrament de programari de 06-01.

  1. Importar el que ja existeix

Aquest és el moment d'aplicar el procediment de 06-05 amb l'eina real. Tot el que la Marta va crear a mà als mòduls 2 i 3 ha de passar a estar gestionat sense destruir ni recrear res.

La forma moderna, amb blocs import declaratius:

# importaciones.tf — fitxer temporal, s'esborra en acabar la migració
import {
  to = google_compute_network.principal
  id = "projects/alpinashop-prod/global/networks/alpinashop-vpc"
}

import {
  to = google_compute_subnetwork.web
  id = "projects/alpinashop-prod/regions/europe-west1/subnetworks/sn-web-euw1"
}

import {
  to = google_compute_firewall.permitir_salud
  id = "projects/alpinashop-prod/global/firewalls/fw-permitir-salud"
}

import {
  to = google_storage_bucket.catalogo
  id = "alpinashop-prod/alpinashop-catalogo"
}

I el truc que estalvia la major part de la feina manual:

# Genera l'HCL corresponent als recursos importats
terraform plan -generate-config-out=generado.tf

Terraform escriu a generado.tf la configuració dels recursos declarats als blocs import, llegida de la realitat. Després toca netejar-la —treure camps calculats, substituir literals per referències, afegir comentaris— exactament com es va explicar a 06-05.

El procediment complet, amb el cicle que cal respectar:

# 1. Importar i generar
terraform plan -generate-config-out=generado.tf

# 2. Revisar i netejar generado.tf, integrar-lo als fitxers definitius

# 3. Aplicar: registra els recursos a l'estat SENSE modificar-los
terraform apply

# 4. EL CRITERI D'ÈXIT
terraform plan
# → "No changes. Your infrastructure matches the configuration."

# 5. Esborrar importaciones.tf, que ja no cal

Consells perquè la migració no s'entravessi:

  • De tres en tres, no de cinquanta en cinquanta. Un pla amb cinquanta recursos importats té centenars de línies i les diferències importants es perden. Importa un grup petit, verifica pla buit, avança.
  • Comença pel que és inofensiu. Regles de tallafoc i buckets buits primer; Cloud SQL, l'últim.
  • Tot primer a alpinashop-dev. Un error allà no costa res.
  • prevent_destroy abans del primer apply en qualsevol recurs amb dades.
  • Un -/+ t'atura sempre. La regla de l'apartat 6, aplicada amb més raó durant una importació.

El format de l'identificador varia per recurs i està documentat al final de la pàgina de cadascun al proveïdor google. És el detall més tediós, i no hi ha drecera.

  1. Terraform a CI/CD: plan a cada pull request

Aquí convergeix tot el mòdul: el repositori de 06-02, el pipeline de 06-01 i la infraestructura com a codi de 06-05.

El flux que AlpinaShop implanta:

flowchart TD
    A[La Marta obre PR<br/>a alpinashop-infra] --> B[Cloud Build:<br/>fmt, validate, plan]
    B --> C[El pla es publica<br/>com a comentari del PR]
    C --> D{Revisió:<br/>CODEOWNERS de seguretat}
    D -->|Canvis demanats| A
    D -->|Aprovat| E[Merge a main]
    E --> F[Disparador d apply<br/>amb --require-approval]
    F --> G{La Marta aprova}
    G -->|Sí| H[terraform apply]
    G -->|No| I[No s aplica]

El pipeline de plan, que corre a cada pull request:

# cloudbuild-plan.yaml
steps:
  - name: 'hashicorp/terraform:1.9'
    id: 'formato-y-validacion'
    entrypoint: 'sh'
    dir: 'entornos/prod'
    args:
      - '-c'
      - |
        terraform fmt -check -recursive -diff || {
          echo "El codi no esta formatat. Executa: terraform fmt -recursive"
          exit 1
        }
        terraform init -input=false
        terraform validate

  - name: 'hashicorp/terraform:1.9'
    id: 'plan'
    waitFor: ['formato-y-validacion']
    entrypoint: 'sh'
    dir: 'entornos/prod'
    args:
      - '-c'
      - |
        terraform plan -input=false -no-color \
          -var-file=prod.tfvars -out=/workspace/prod.tfplan | tee /workspace/plan.txt

        # Guarda de seguretat: avisar si el pla destrueix alguna cosa
        if grep -qE '^Plan:.*to destroy' /workspace/plan.txt; then
          echo "=========================================="
          echo "ATENCIO: aquest pla DESTRUEIX recursos."
          echo "Revisio obligatoria de gcp-seguridad@."
          echo "=========================================="
          grep -E '^\s+#.*(destroyed|must be replaced)' /workspace/plan.txt
        fi

  - name: 'gcr.io/cloud-builders/curl'
    id: 'publicar-en-pr'
    waitFor: ['plan']
    entrypoint: 'bash'
    args: ['-c', 'scripts/comentar-pr.sh /workspace/plan.txt']

artifacts:
  objects:
    location: 'gs://alpinashop-artefactos/planes/$BUILD_ID/'
    paths: ['prod.tfplan', 'plan.txt']

options:
  logging: CLOUD_LOGGING_ONLY

El pas que publica el pla com a comentari del pull request és el que canvia la cultura de l'equip. Sense ell, revisar un canvi d'infraestructura obliga a llegir HCL i imaginar-ne l'efecte. Amb ell, el revisor veu exactament quins recursos es creen, es modifiquen i es destrueixen, sense executar res. És l'aplicació literal del principi de 06-05: revisar la infraestructura com es revisa el codi.

El pipeline d'apply, amb aprovació manual:

# cloudbuild-apply.yaml
steps:
  - name: 'hashicorp/terraform:1.9'
    entrypoint: 'sh'
    dir: 'entornos/prod'
    args:
      - '-c'
      - |
        terraform init -input=false
        terraform apply -input=false -auto-approve /workspace/prod.tfplan
timeout: '3600s'

Fixa't que aplica el fitxer de pla generat al PR, no un de recalculat. És el que garanteix que s'executa exactament l'aprovat. I el -auto-approve no és perillós aquí precisament per això: l'aprovació humana ja va passar, al disparador --require-approval de 06-01.

I sense cap clau. El compte de servei del pipeline s'autentica amb la identitat de Cloud Build; si l'apply corregués a GitHub Actions, seria amb Workload Identity Federation de 06-02. En cap punt d'aquest flux existeix un fitxer JSON de credencials.

Els permisos del compte de servei d'apply mereixen una nota: és el compte amb més poder de tota la infraestructura, perquè pot modificar xarxes, IAM i bases de dades. Ha d'estar acotat als recursos que gestiona, no ser Editor del projecte, i el seu ús ha d'estar auditat. És el mateix advertiment de 06-01 elevat un nivell.

  1. Què NO ha de gestionar Terraform

Tan important com saber què gestionar és saber què deixar fora. La regla, ja enunciada a 06-05: Terraform gestiona la forma, no el contingut.

No gestionar Per què Qui ho gestiona
Valors de secrets Acabarien en clar a l'estat Secret Manager, valor creat fora (03-06)
Objectes d'un bucket Són dades, canvien constantment L'aplicació
Files i esquema de la BD Migracions versionades El pipeline de l'app (06-01)
Instàncies d'un MIG Les gestiona el mateix MIG L'autoescalador
Pods i Deployments Cicle de vida diferent Manifests i GKE (02-05)
Taules creades per pipelines Neixen i moren soles Dataflow, BigQuery (04-02)
Models i artefactes d'ML Els produeix el pipeline Vertex AI (05-07)

La primera fila és la més important i la més incomplerta. Mira la diferència:

# MALAMENT: la contrasenya acaba en clar al fitxer d'estat
resource "google_sql_user" "app" {
  name     = "catalogo"
  instance = google_sql_database_instance.pedidos.name
  password = "P4ssw0rd-real"     # ← al .tfstate, en clar
}

# BÉ: Terraform crea el CONTENIDOR del secret; el valor es posa fora
resource "google_secret_manager_secret" "db_password" {
  secret_id = "db-password-catalogo"
  replication {
    user_managed {
      replicas { location = "europe-west1" }
    }
  }
}
# I el valor s'estableix amb gcloud o des de l'aplicació:
#   gcloud secrets versions add db-password-catalogo --data-file=-

I un cas intermedi que convé resoldre bé: una contrasenya generada aleatòriament. random_password també queda a l'estat, així que la solució correcta és que la generi el proveïdor —Cloud SQL pot fer-ho— o un procés extern, i que Terraform només creï el contenidor.

La pregunta que resol els dubtes: això canvia per si sol, o canvia perquè algú ho decideix? El que canvia sol —dades, instàncies autoescalades, taules de pipelines— no és de Terraform. El que canvia perquè algú ho decideix, sí.

  1. prevent_destroy i el perill de terraform destroy

terraform destroy destrueix tot el que hi ha a l'estat. És una operació legítima per a entorns efímers i una catàstrofe en producció, i hi ha tres maneres d'executar-la sense voler: escriure-la al directori equivocat, un apply amb un -/+ no revisat, i esborrar recursos del codi sense adonar-se que esborrar-los significa destruir-los.

La protecció de primer nivell, prevent_destroy, en tot el que contingui dades:

resource "google_sql_database_instance" "pedidos" {
  name             = "alpinashop-pedidos"
  database_version = "POSTGRES_15"
  region           = var.region

  settings {
    tier              = var.tamano_instancia_sql
    availability_type = var.entorno == "prod" ? "REGIONAL" : "ZONAL"

    backup_configuration {
      enabled                        = true
      point_in_time_recovery_enabled = true
      start_time                     = "03:00"
    }
  }

  # Protecció pròpia de l'API de Cloud SQL, independent de Terraform
  deletion_protection = true

  lifecycle {
    # Qualsevol pla que destrueixi aquest recurs FALLA amb un error explícit
    prevent_destroy = true
  }
}

Amb prevent_destroy = true, un pla que intenti destruir el recurs no s'executa:

Error: Instance cannot be destroyed
Resource google_sql_database_instance.pedidos has lifecycle.prevent_destroy set,
but the plan calls for this resource to be destroyed.

És un error de l'eina, no un avís. Per destruir-lo de veritat cal editar el codi, treure la protecció i tornar a aplicar: dos passos deliberats en lloc d'un d'accidental.

Les cinc capes de protecció d'AlpinaShop, defensa en profunditat:

Capa Què protegeix Contra què
prevent_destroy Recursos amb dades destroy accidental i -/+
deletion_protection Cloud SQL i GKE Esborrat per qualsevol via, inclosa la consola
Revisió del pla al PR Tot Errors humans, amb un altre parell d'ulls
Aprovació manual de l'apply Producció Aplicar alguna cosa no revisada
IAM restrictiu Producció Que qui no ha de poder aplicar, apliqui

I una precisió sobre l'abast de prevent_destroy: protegeix el recurs, no l'estat. Si algú esborra el bloc del codi, Terraform no ho veu i no protegeix res. La protecció real la donen les capes 3, 4 i 5 juntes.

  1. Infrastructure Manager i alternatives

Executar Terraform tu mateix implica gestionar el bucket de l'estat, el bloqueig, les versions i els permisos del pipeline. Infrastructure Manager és el servei gestionat de Google que fa tot això executant Terraform per sota:

gcloud infra-manager deployments apply \
  projects/alpinashop-prod/locations/europe-west1/deployments/red-prod \
  --service-account=projects/alpinashop-prod/serviceAccounts/[email protected] \
  --git-source-repo=https://github.com/alpinashop/alpinashop-infra \
  --git-source-directory=entornos/prod \
  --git-source-ref=main \
  --input-values=entorno=prod
Aspecte Terraform propi Infrastructure Manager
Estat El teu bucket, la teva responsabilitat Gestionat
Bloqueig Automàtic a GCS Gestionat
Autenticació Compte de servei del pipeline Nativa de GCP
Historial A Cloud Build Al mateix servei
Multiproveïdor Sí, però pensat per a GCP
Flexibilitat del pipeline Total Menor

Per a AlpinaShop, que ja té el pipeline de 06-01 funcionant i vol control total sobre el flux, Terraform propi és l'elecció. Infrastructure Manager és excel·lent per a equips que prefereixen no gestionar l'estat i viuen exclusivament a GCP.

Les alternatives al Terraform propi, per tenir el mapa complet:

Eina Model Forta en Feble en
Terraform / OpenTofu HCL declaratiu Estàndard, ecosistema, multiproveïdor Estat propi, HCL limitat com a llenguatge
Pulumi Python, TypeScript, Go Llenguatges reals, bucles i tipus Ecosistema menor, risc de codi massa llest
Config Connector CRD de Kubernetes Reconciliació contínua, GitOps Requereix clúster; només GCP
Crossplane CRD de Kubernetes Multiproveïdor, plataformes internes Complex, per a organitzacions grans
CDK for Terraform Llenguatges sobre Terraform Combina tots dos mons Capa addicional d'indirecció

Recomanació per a AlpinaShop: Terraform, per la raó que obre la lliçó —l'ecosistema i la gent que ja el coneix—. I una observació sobre Pulumi que mereix dir-se: escriure infraestructura en un llenguatge de propòsit general és temptador i té un risc real, perquè és fàcil escriure infraestructura massa intel·ligent. La limitació d'HCL és en part una virtut: obliga que la infraestructura sigui llegible per algú que no va escriure el codi.

  1. L'estat final d'AlpinaShop

Val la pena mirar l'abans i el després, perquè el recorregut ha estat llarg:

Aspecte Abans del mòdul 6 Ara
Codi Portàtil del Dani i Drive GitHub, revisat, amb protecció de branques
Desplegament docker build a mà, etiqueta latest Cloud Build, imatge etiquetada amb el SHA
Proves «Opcionals segons les presses» Obligatòries, bloquegen el merge
Producció kubectl set image des d'un portàtil Promoció amb aprovació manual
Esdeveniments Sense implementar Cloud Functions, amb idempotència i DLQ
Saber si funciona Correu d'un client Alertes en 5 minuts, tauler, uptime checks
Diagnosticar Cercar text als registres Registres estructurats, traces, 17 minuts a la causa
Infraestructura Historial del terminal de la Marta HCL versionat, revisat i reproduïble
Reconstruir l'entorn Ningú no ho sap terraform apply, uns vint minuts
Deriva entre entorns Real i desconeguda El mateix codi, diferents tfvars
Qui pot desplegar Només el Dani Qualsevol obre un PR; aprova qui ha d'aprovar

Aquest canvi de l'última fila és el resum cultural del mòdul. El coneixement ha sortit dels caps i ha entrat al repositori.

Errors Habituals i Consells

Pujar el .tfstate a Git. Conté secrets en clar i Git no oblida. Backend remot a Cloud Storage amb versionatge, i .gitignore des del primer commit.

No fixar la versió del proveïdor ni dels mòduls. Un init al pipeline pot portar una versió nova que canviï recursos de producció sense que ningú ho hagi decidit. version = "~> 6.0" sempre.

Executar apply sense fitxer de pla. Recalcula el pla i pot aplicar una cosa diferent de la revisada. plan -out=fitxer i apply fitxer.

Ignorar un -/+. És l'error més car de l'eina. Un reemplaçament sobre Cloud SQL o sobre un bucket amb contingut destrueix dades. Atura't sempre i entén quin atribut el força.

Gestionar secrets amb Terraform. El valor acaba en clar a l'estat. Terraform crea el contenidor a Secret Manager; el valor s'estableix fora.

Esborrar recursos del codi pensant que «deixen de gestionar-se». Esborrar-los del codi significa destruir-los. Per deixar de gestionar sense destruir: terraform state rm.

Usar workspaces per separar producció de desenvolupament. L'única barrera és recordar-se de workspace select, i algun dia algú no se'n recordarà. Carpetes per entorn.

Crear mòduls massa aviat. Un mòdul amb vint variables per cobrir tots els casos és pitjor que el codi pla. Extreu un mòdul la segona vegada que repeteixis alguna cosa.

Importar cinquanta recursos de cop. El pla resultant és il·legible i les diferències perilloses es perden. De tres en tres, verificant pla buit.

Oblidar prevent_destroy en recursos amb dades. Són dues línies i poden salvar la base de dades.

Continuar tocant coses a mà. El pecat original de la infraestructura com a codi. Tan bon punt el codi i la realitat divergeixen, l'eina deixa de ser fiable i tornes al punt de partida.

Consell final: comença pel que és nou. Si la migració de tot el que ja existeix sembla inabordable, adopta la regla que a partir d'avui, tot el que és nou es crea amb Terraform, i ves important el vell quan el toquis. En uns mesos, la major part estarà gestionada sense haver fet mai un projecte de migració.

Exercicis

Exercici 1: escriure un mòdul reutilitzable

Escriu un mòdul bucket-alpinashop que creï un bucket de Cloud Storage amb les convencions d'AlpinaShop: accés uniforme, prevenció d'accés públic, versionatge configurable, les quatre etiquetes obligatòries (entorno, equipo, centro-coste, aplicacion), una regla de cicle de vida parametritzable i prevent_destroy per a producció. Escriu-ne les variables amb validació, les sortides, i el bloc que l'invoca per crear alpinashop-datalake en producció. Explica què vas decidir parametritzar i què vas decidir fixar, i per què.

Exercici 2: analitzar un pla perillós

Un pull request d'alpinashop-infra genera aquest pla:

  # google_compute_firewall.permitir_ssh will be destroyed
  - resource "google_compute_firewall" "permitir_ssh" { ... }

  # google_compute_subnetwork.datos must be replaced
-/+ resource "google_compute_subnetwork" "datos" {
      ~ ip_cidr_range = "10.20.0.0/24" -> "10.20.0.0/22" # forces replacement
    }

  # google_sql_database_instance.pedidos will be updated in-place
  ~ resource "google_sql_database_instance" "pedidos" {
      ~ settings {
          ~ tier = "db-custom-4-15360" -> "db-custom-2-7680"
        }
    }

  # google_storage_bucket.datalake will be created
  + resource "google_storage_bucket" "datalake" { ... }

Plan: 1 to add, 1 to change, 1 to destroy, 1 to replace.

Analitza cada canvi, classifica'l per risc, digues si aprovaries el PR i què demanaries a l'autor. Indica quin és el més perillós i per què.

Exercici 3: dissenyar el flux complet d'infraestructura com a codi

La Marta et demana dissenyar de principi a fi el flux de treball d'infraestructura d'AlpinaShop, donant per fet que la migració de 06-05 ja està completa. Defineix: l'estructura del repositori, la configuració del backend i la separació d'estats, els pipelines de Cloud Build necessaris, els permisos IAM de cada compte de servei implicat, les proteccions de branca i CODEOWNERS, i el procediment d'emergència per a quan calgui canviar alguna cosa en producció a les 3 de la matinada amb el pipeline caigut. Justifica les decisions de seguretat.

Solucions

Solució 1

# modulos/bucket-alpinashop/variables.tf
variable "nombre" {
  description = "Nom del bucket, únic globalment"
  type        = string
  validation {
    condition     = can(regex("^alpinashop-[a-z0-9-]+$", var.nombre))
    error_message = "El nom ha de començar per 'alpinashop-' i usar minúscules."
  }
}

variable "entorno" {
  type = string
  validation {
    condition     = contains(["dev", "prod"], var.entorno)
    error_message = "L'entorn ha de ser 'dev' o 'prod'."
  }
}

variable "equipo"       { type = string }
variable "centro_coste" { type = string }
variable "aplicacion"   { type = string }

variable "region" {
  type    = string
  default = "europe-west1"
}

variable "versionado" {
  description = "Activar el versionatge d'objectes"
  type        = bool
  default     = true
}

variable "dias_a_nearline" {
  description = "Dies després dels quals passar a NEARLINE; 0 desactiva la regla"
  type        = number
  default     = 90
  validation {
    condition     = var.dias_a_nearline >= 0
    error_message = "Ha de ser 0 o un nombre positiu de dies."
  }
}

variable "dias_borrado" {
  description = "Dies després dels quals esborrar; 0 = no esborrar mai"
  type        = number
  default     = 0
}
# modulos/bucket-alpinashop/main.tf
resource "google_storage_bucket" "esta" {
  name     = var.nombre
  location = var.region

  # FIXOS: són política d'AlpinaShop, no decisions per bucket
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  versioning { enabled = var.versionado }

  dynamic "lifecycle_rule" {
    for_each = var.dias_a_nearline > 0 ? [1] : []
    content {
      condition { age = var.dias_a_nearline }
      action {
        type          = "SetStorageClass"
        storage_class = "NEARLINE"
      }
    }
  }

  dynamic "lifecycle_rule" {
    for_each = var.dias_borrado > 0 ? [1] : []
    content {
      condition { age = var.dias_borrado }
      action { type = "Delete" }
    }
  }

  # Neteja de versions antigues si hi ha versionatge
  dynamic "lifecycle_rule" {
    for_each = var.versionado ? [1] : []
    content {
      condition {
        with_state         = "ARCHIVED"
        num_newer_versions = 3
        age                = 30
      }
      action { type = "Delete" }
    }
  }

  labels = {
    entorno      = var.entorno
    equipo       = var.equipo
    centro-coste = var.centro_coste
    aplicacion   = var.aplicacion
  }

  lifecycle {
    prevent_destroy = true
  }
}
# modulos/bucket-alpinashop/outputs.tf
output "nombre" { value = google_storage_bucket.esta.name }
output "url"    { value = google_storage_bucket.esta.url }
output "id"     { value = google_storage_bucket.esta.id }
# entornos/prod/almacenamiento.tf
module "bucket_datalake" {
  source = "../../modulos/bucket-alpinashop"

  nombre          = "alpinashop-datalake"
  entorno         = "prod"
  equipo          = "datos"
  centro_coste    = "analitica"
  aplicacion      = "datalake"
  versionado      = true
  dias_a_nearline = 60
  dias_borrado    = 0        # el data lake no s'esborra sol
}

Què vaig parametritzar i per què:

Element Decisió Raó
Nom, etiquetes Paràmetre Diferent a cada bucket, òbviament
Versionatge Paràmetre Un bucket de registres efímers no el necessita
Dies de cicle de vida Paràmetre El data lake i les imatges tenen patrons diferents
Regió Paràmetre amb defecte Gairebé sempre europe-west1, però pot variar
Accés uniforme FIX Política de seguretat, no una opció
Prevenció d'accés públic FIX Igual: mai no hi ha motiu per desactivar-la
prevent_destroy FIX Vegeu la discussió de sota

El principi de disseny: es parametritza el que varia legítimament entre casos; es fixa el que és una decisió de l'organització. Convertir public_access_prevention en variable seria obrir la porta al fet que algú, amb presses, creï un bucket públic «només per a una prova». Si en algun moment calgués un bucket públic de veritat —un lloc estàtic—, es declara amb google_storage_bucket directament i aquest cas excepcional rep una revisió específica. Un mòdul també serveix per fer difícil el que no s'hauria de fer.

Sobre prevent_destroy, una decisió discutible que convé raonar. L'he fixat a true sempre, en lloc de parametritzar-lo per entorn. El motiu és que prevent_destroy no admet expressions: el bloc lifecycle no pot usar variables, així que prevent_destroy = var.entorno == "prod" dona error de sintaxi. És una limitació real de Terraform. Les opcions són fixar-lo a true per a tots —cosa que obliga a un pas manual per esborrar un bucket de proves, molest però segur— o crear dos mòduls. He triat el primer: la molèstia d'esborrar a mà un bucket de desenvolupament és molt menor que el risc de destruir-ne un de producció.

I un advertiment sobre dynamic: els blocs dinàmics són potents i fan el codi més difícil de llegir. Amb tres regles de cicle de vida condicionals, el mòdul comença a acostar-se al límit del raonable. Si creixessin a vuit, seria millor exposar una variable de llista de regles i deixar que qui l'usa les declari explícitament.

Solució 2

Anàlisi canvi a canvi:

# Canvi Risc Diagnòstic
1 - destruir fw-permitir-ssh Mitjà Pot ser intencionat i bo, o un descuit
2 -/+ reemplaçar sn-datos CRÍTIC Destrueix la subxarxa de la base de dades
3 ~ reduir el tier de Cloud SQL Alt No destrueix dades, però degrada producció
4 + crear el bucket datalake Baix Un recurs nou, sense efectes col·laterals

Canvi 1 — destruir la regla d'SSH. És l'únic canvi que podria ser una millora: si aquesta regla permetia SSH des de rangs amplis, eliminar-la és exactament el que recomana 03-01. Però un - destroy en un pla sempre exigeix preguntar per què desapareix. Hi ha dues causes possibles amb implicacions oposades: que l'autor l'hagi esborrat deliberadament del codi, o que l'hagi esborrat per accident en editar el fitxer. El que cal demanar: que la descripció del pull request ho expliqui, i confirmació que ningú no depèn d'aquest accés —un proveïdor, un procés de còpies, un accés de suport—. Si ningú no ho sap, l'alternativa segura és restringir l'origen en lloc d'eliminar la regla, i observar durant una setmana amb els registres de tallafoc de 06-06 abans de retirar-la.

Canvi 2 — el reemplaçament de la subxarxa. Aquest és el que fa que el PR es rebutgi.

Ampliar 10.20.0.0/24 a 10.20.0.0/22 és una operació aparentment raonable —més adreces disponibles— i l'API de GCP no permet canviar el rang d'una subxarxa en calent quan hi ha recursos a dins. Terraform, sense més opcions, proposa destruir-la i crear-la.

El que passaria en aplicar: la destrucció de sn-datos-euw1 afecta tot el que hi viu, començant per la instància de Cloud SQL alpinashop-pedidos amb la seva IP privada. En el millor cas, l'operació falla a mitges perquè GCP es nega a esborrar una subxarxa amb recursos, i l'estat queda incoherent —Terraform creu haver començat una operació que no pot acabar—. En el pitjor, si alguna cosa hagués pogut esborrar-se, les IP privades canviarien i tot el que les referencia deixaria de funcionar. En cap dels dos casos el resultat és acceptable.

I hi ha una alternativa que fa el reemplaçament completament innecessari, i és el que cal proposar a l'autor: GCP permet ampliar el rang d'una subxarxa existent sense recrear-la, mitjançant gcloud compute networks subnets expand-ip-range. L'operació és no destructiva i només admet ampliar, mai reduir:

gcloud compute networks subnets expand-ip-range sn-datos-euw1 \
  --region=europe-west1 --prefix-length=22 --project=alpinashop-prod

Després s'actualitza l'ip_cidr_range al codi i terraform plan surt buit, perquè la realitat ja coincideix. És un cas on l'operació correcta es fa fora de Terraform i el codi es limita a reflectir-la, i convé reconèixer-ho: forçar l'eina a fer una cosa que l'API no suporta bé és pitjor que fer una excepció documentada.

Canvi 3 — reduir el tier de Cloud SQL. No destrueix dades i per això molta gent el donaria per bo. Però passar de db-custom-4-15360 a db-custom-2-7680 redueix a la meitat la CPU i la memòria de la base de dades de producció, i té dues conseqüències immediates: un reinici de la instància, és a dir, tall de servei d'uns minuts, i una capacitat sensiblement menor per al mateix trànsit.

Les preguntes a l'autor: és intencionat o és un tfvars equivocat? I si és intencionat, està justificat amb dades? Aquí és on 06-04 entra en joc: si la CPU de Cloud SQL porta mesos per sota del 20 %, és un estalvi perfectament raonable. Si està al 60 %, reduir a la meitat la capacitat és provocar el proper incident. Un canvi de dimensionament sense la mètrica que el sustenti és una conjectura. I en qualsevol cas, s'ha d'aplicar en una finestra de manteniment, no un dimarts a les onze del matí.

Una sospita raonable, dit sigui de passada: que aquest canvi provingui d'haver aplicat dev.tfvars sobre producció per error, perquè db-custom-2-7680 té tota la pinta de ser el valor de desenvolupament. Val la pena comprovar-ho.

Canvi 4 — crear el bucket. L'únic inofensiu. Es revisa que tingui accés uniforme, prevenció d'accés públic, les quatre etiquetes i el seu cicle de vida, i ja està.

Aprovaria el PR? No. I el que demanaria, en aquest ordre:

  1. Dividir el pull request en tres. Aquest PR barreja una millora de seguretat, un canvi de xarxa, un canvi de dimensionament i un recurs nou. Són quatre intencions diferents, amb riscos i moments d'aplicació diferents. Un PR amb una sola intenció es revisa bé, s'aprova ràpid i es reverteix netament si surt malament.
  2. Treure l'ampliació de la subxarxa de Terraform, fer-la amb expand-ip-range i actualitzar el codi després.
  3. Justificar el canvi de tier amb la mètrica de CPU dels últims dos mesos, i programar-lo en una finestra de manteniment.
  4. Explicar a la descripció per què desapareix la regla d'SSH i confirmar que ningú no en depèn.

El més perillós és el canvi 2, sense discussió, i per una raó que convé explicitar: és l'únic irreversible. El canvi 3 es desfà tornant al tier anterior; el canvi 1 es desfà recreant la regla; el canvi 4 es desfà esborrant el bucket. Un recurs destruït no torna, i amb ell s'endú per davant les adreces IP privades de tot el que contenia.

I la lliçó de procediment: aquest pla és exactament l'argument a favor de publicar el plan com a comentari del pull request. Llegint només l'HCL modificat, el canvi de /24 a /22 sembla una edició menor d'un caràcter. És el pla el que revela que aquest caràcter destrueix la subxarxa de la base de dades.

Solució 3

Estructura del repositori alpinashop-infra:

alpinashop-infra/
├── modulos/
│   ├── red-alpinashop/
│   ├── bucket-alpinashop/
│   └── servicio-web/
├── entornos/
│   ├── dev/
│   │   ├── backend.tf        # prefix = "dev/"
│   │   ├── main.tf
│   │   ├── red.tf
│   │   ├── datos.tf
│   │   └── dev.tfvars
│   └── prod/
│       ├── backend.tf        # prefix = "prod/"
│       └── ... (mateixa estructura)
├── paneles/                  # JSON de 06-04
├── alertas/                  # polítiques de 06-04
├── cloudbuild-plan.yaml
├── cloudbuild-apply.yaml
├── .pre-commit-config.yaml
├── CODEOWNERS
└── .gitignore

Backend i separació d'estats. Bucket alpinashop-terraform-estado a alpinashop-cicd, amb versionatge, accés uniforme i prevenció d'accés públic. I estats separats per entorn i per domini:

Prefix Conté Motiu de la separació
prod/red VPC, subxarxes, tallafoc, NAT, DNS Canvia poc, radi de dany alt
prod/datos Cloud SQL, buckets, BigQuery Conté dades: màxima protecció
prod/computo MIG, GKE, balancejador Canvia sovint
prod/observabilidad Taulers, alertes, sortidors Canvia molt, risc nul
dev/* El mateix per a desenvolupament Aïllament total

La separació d'estats és una decisió de seguretat, no d'organització. Amb un únic estat, qualsevol operació sobre qualsevol recurs bloqueja tot i un error té abast total. Amb estats separats, tocar un tauler de Cloud Monitoring no pot afectar la base de dades ni tan sols en el pitjor cas.

Pipelines:

Pipeline Disparador Què fa Aprovació
infra-plan-dev PR contra main fmt, validate, plan de dev, comentari al PR No
infra-plan-prod PR contra main Igual per a prod No
infra-apply-dev Push a main apply a dev amb el pla del PR No: automàtic
infra-apply-prod Manual després del merge apply a prod Sí, --require-approval

Que desenvolupament s'apliqui automàticament és deliberat: és la manera de garantir que alpinashop-dev reflecteix sempre main, que és just el que impedeix la deriva de 06-05. Producció exigeix aprovació per l'impacte.

Permisos IAM:

Compte Rols Àmbit Justificació
sa-tf-plan viewer + storage.objectViewer sobre l'estat prod i dev Només lectura: un plan no modifica res
sa-tf-apply-dev editor acotat Només alpinashop-dev Sense accés a producció
sa-tf-apply-prod Rols específics per servei Només alpinashop-prod Mai owner
Persones de gcp-infra@ viewer + roles/iam.serviceAccountTokenCreator prod Sense accés directe d'escriptura

La decisió de seguretat més important: sa-tf-plan és de només lectura. El plan s'executa a cada pull request, i un pull request el pot obrir qualsevol —inclòs algú amb males intencions que modifiqui el cloudbuild-plan.yaml a la seva branca—. Si aquest compte tingués permisos d'escriptura, obrir un PR seria suficient per modificar producció. És exactament l'antipatró que es va explicar a 06-01, i aquí és encara més greu perquè parlem d'infraestructura.

La segona: les persones no tenen escriptura directa en producció. S'impersona sa-tf-apply-prod amb serviceAccountTokenCreator, cosa que queda registrada als registres d'auditoria amb el nom de la persona. És l'aplicació literal de 03-04 i de «ningú no és owner de prod».

Protecció de branques i CODEOWNERS:

# CODEOWNERS d'alpinashop-infra
*                          @alpinashop/infraestructura
/entornos/prod/            @alpinashop/infraestructura @alpinashop/seguridad
/entornos/prod/red.tf      @alpinashop/seguridad
/entornos/*/iam.tf         @alpinashop/seguridad
/modulos/                  @alpinashop/infraestructura @alpinashop/seguridad

Sobre main: prohibit el push directe, una aprovació mínima (dues per a entornos/prod/), CI en verd obligatòria, aprovacions que caduquen en haver-hi canvis nous, historial lineal, sense excepcions per a administradors i escaneig de secrets.

El procediment d'emergència, que és la part de l'exercici que més gent resol malament.

La resposta ingènua és «que la Marta tingui permisos permanents d'emergència». És dolenta: un permís permanent s'acaba usant fora de les emergències i erosiona tota la resta. La resposta correcta té quatre elements:

1. Accés d'emergència amb impersonació registrada. La Marta pertany a un grup gcp-emergencia@ que pot impersonar sa-tf-apply-prod sense passar pel pipeline. No és un permís ocult: és un camí documentat, auditat i amb nom.

gcloud config set auth/impersonate_service_account \
  [email protected]
cd entornos/prod
terraform plan -var-file=prod.tfvars -out=emergencia.tfplan   # PLA SEMPRE
terraform apply emergencia.tfplan

2. El plan no se salta ni en una emergència. És el primer que la gent proposa eliminar per pressa, i és exactament al revés: a les 3 de la matinada, amb son i amb pressió, és quan més falta fa veure què es destruirà. Costa vint segons i evita convertir un incident en dos.

3. Alerta automàtica de l'ús de l'accés d'emergència. Amb el sortidor a Pub/Sub de 06-06, qualsevol impersonació de sa-tf-apply-prod fora del pipeline publica un missatge que arriba al canal de seguretat en el moment. No per impedir-ho, sinó perquè tothom sàpiga que va passar.

4. Regularització obligatòria en 24 hores. El canvi d'emergència es va fer amb el codi de main, així que l'estat i el codi coincideixen només si el canvi es va fer editant el codi. Si es va fer amb gcloud a mà, hi ha deriva. En tots dos casos, en les 24 hores següents cal obrir un pull request que documenti el canvi i demostri amb un plan buit que codi i realitat coincideixen de nou. Sense aquest pas, la primera emergència és el principi del retorn al caos, perquè a partir d'aquí ningú no confia que el codi sigui la veritat.

I la reflexió que tanca l'exercici. Un procediment d'emergència ben dissenyat no elimina les proteccions: les substitueix per traçabilitat. En condicions normals, la protecció és preventiva —revisió, aprovació, permisos acotats—. En una emergència, la protecció és detectiva —alerta immediata, registre d'auditoria, regularització obligatòria—. El que no ha d'existir mai és un camí sense cap de les dues, perquè aquest camí es converteix en l'habitual.

Conclusió

AlpinaShop té la seva infraestructura escrita, versionada, revisable i reproduïble. La pregunta que obria 06-05 —quant es triga a reconstruir l'entorn— ja té resposta: uns vint minuts i un terraform apply.

Saps per què Terraform va guanyar: multiproveïdor, ecosistema de mòduls, maduresa, comunitat, i el reconeixement definitiu que Infrastructure Manager és Terraform gestionat per Google. Amb la nota honesta sobre la llicència i OpenTofu.

Domines els conceptes: proveïdor amb la seva versió fixada perquè no fer-ho és deixar que una actualització decideixi per tu; recurs amb el seu nom local diferent del nom real; font de dades que només llegeix; variable amb validation; sortida amb sensitive, sabent que amaga però no xifra; i dependències implícites que neixen de les referències, amb depends_on com a últim recurs.

Entens l'estat de veritat: què conté —incloses contrasenyes en clar—, per què no va mai a Git, i les quatre regles que se'n deriven. Saps muntar el backend a Cloud Storage amb versionatge, bloqueig automàtic i prefixos que separen estats per limitar el radi de dany. I saps que .terraform.lock.hcl sí que va a Git.

Manejes el flux initvalidateplanapply, amb el -out=fitxer que garanteix que s'aplica exactament el revisat. I sobretot saps llegir un pla: +, ~, - i el -/+ que ha d'aturar-te sempre, amb la taula de en quins recursos un reemplaçament és acceptable i en quins significa perdre dades.

Tens el codi real d'AlpinaShop en HCL —la VPC, les subxarxes amb private_ip_google_access, les regles de tallafoc amb els rangs de les sondes de Google explicats en un comentari, el bucket amb el seu cicle de vida i les seves quatre etiquetes— i amb això el coneixement que vivia al cap de la Marta ha passat al repositori. Saps parametritzar amb variables i tfvars perquè alpinashop-dev i alpinashop-prod comparteixin codi, i per què AlpinaShop tria carpetes per entorn davant de workspaces: perquè l'única barrera d'un workspace és recordar-se de seleccionar-lo.

Saps escriure un mòdul amb les seves tres regles —una responsabilitat, parametritzar el que varia i fixar el que és política, exposar les sortides necessàries— i quan consumir mòduls del registre públic, amb la regla que com més complex és el recurs més compensa, i els dos advertiments: fixa la versió, i un mòdul de tercers és codi amb permisos sobre la teva infraestructura.

Saps importar el que ja existeix amb blocs import declaratius i -generate-config-out, de tres en tres, verificant el pla buit, començant pel que és inofensiu i deixant Cloud SQL per al final. Tens Terraform al pipeline: plan automàtic a cada pull request publicat com a comentari —el que canvia la cultura de l'equip, perquè el revisor veu l'efecte sense executar res—, apply només després d'aprovació i aplicant el pla ja revisat, i sense una sola clau JSON en tot el flux.

Saps què no ha de gestionar Terraform —valors de secrets, dades, objectes efímers— amb la regla que ho resumeix: gestiona la forma, no el contingut, i la pregunta que resol els dubtes: això canvia sol, o perquè algú ho decideix? I tens les cinc capes de protecció contra l'esborrat accidental, amb prevent_destroy i deletion_protection com les dues primeres i la revisió humana com la que de veritat importa.

Finalment, coneixes Infrastructure Manager com a forma gestionada d'executar-lo i el mapa d'alternatives —Pulumi, Config Connector, Crossplane, CDKTF— amb l'observació que la limitació d'HCL és en part una virtut, perquè obliga que la infraestructura sigui llegible.


I aquí acaba el mòdul 6. Mira què ha canviat.

En començar, AlpinaShop tenia una aplicació que es desplegava des d'un portàtil amb l'etiqueta latest, uns manifests a Drive, una funció pendent des del mòdul 5, cap manera de saber si la botiga funcionava tret d'esperar el correu d'un client, i una infraestructura que existia només a l'historial d'un terminal.

Ara hi ha lliurament automatitzat: el codi a GitHub amb revisió obligatòria, Cloud Build construint, provant i publicant imatges etiquetades amb el commit, promoció a producció amb aprovació, i el pipeline d'ML per fi en nivell 2 de maduresa. Hi ha peces per esdeveniments que reaccionen soles, amb idempotència, reintents i dead letter. Hi ha observabilitat completa: mètriques, taulers, alertes que avisen en cinc minuts, comprovacions des de quatre continents, registres estructurats, traces distribuïdes i un recorregut provat que va de l'alerta a la línia de codi en disset minuts. I hi ha infraestructura com a codi: versionada, revisada en pull requests i reproduïble.

El sistema ja no depèn que dues persones es recordin d'executar les coses.

El que queda són les decisions que es van anar ajornant, i ara hi ha base per prendre-les. El catàleg continua a GKE, tot i que DA-001 va decidir fa temps que el seu lloc és Cloud Run —i ara que hi ha pipeline, observabilitat i infraestructura com a codi, aquest moviment és per fi viable—. Hi ha un magatzem físic amb sistemes propis que algú haurà d'integrar. Les xarxes es van quedar en allò bàsic i hi ha converses pendents sobre VPC compartida i connectivitat híbrida. La seguretat es va construir peça a peça i mai no es va revisar en conjunt. La factura ha crescut amb cada mòdul i ningú no l'ha mirat amb deteniment. S'han muntat alertes, però no s'ha definit què significa «funcionar bé»: no hi ha SLO ni pressupostos d'error. I l'organització ja té tres projectes, quatre repositoris, desenes de comptes de servei i cap política que governi el conjunt.

Al mòdul 7, temes avançats, es resolen tots: Anthos per a l'híbrid i el multinúvol, Cloud Run per portar el catàleg on sempre hauria d'haver estat, xarxes avançades amb VPC compartida, aparellament i connectivitat híbrida, seguretat revisada de dalt a baix, gestió de costos perquè la factura deixi de ser una sorpresa mensual, fiabilitat amb SLO i pressupostos d'error que converteixin les alertes de 06-04 en objectius mesurables, i govern a escala amb polítiques d'organització i auditoria.

AlpinaShop ja construeix, desplega i s'observa sola. Ara toca fer-la robusta, econòmica i governable.

Curs de Google Cloud Platform (GCP)

Mòdul 1: Introducció a Google Cloud Platform

Mòdul 2: Serveis principals de GCP

Mòdul 3: Xarxes i seguretat

Mòdul 4: Dades i anàlisi

Mòdul 5: Aprenentatge automàtic i IA

Mòdul 6: DevOps i monitoratge

Mòdul 7: Temes avançats de GCP

Mòdul 8: Projecte final

© Copyright 2026. Tots els drets reservats