En 06-05 quedó todo dicho salvo lo esencial. Sabes cuál es el problema —la infraestructura de AlpinaShop existe solo porque Marta ejecutó los comandos correctos, y nadie sabría reproducirla—, sabes qué es la infraestructura como código, entiendes el estado y conoces el procedimiento de migración paso a paso. Lo que falta es la herramienta con la que hacerlo.
Esta lección la trae, y con ella se cierra el módulo. Al terminar, la VPC de AlpinaShop, sus subredes, sus reglas de firewall y sus buckets estarán escritos en ficheros que se revisan en un pull request, se aplican desde un pipeline y se pueden reproducir en otra región cambiando una variable. Y la respuesta a "¿cuánto se tarda en reconstruir el entorno?" pasará de "nadie lo sabe" a "unos veinte minutos".
Un aviso sobre el alcance: esta lección enseña Terraform lo suficiente para gestionar la infraestructura de AlpinaShop con criterio. Terraform da para un curso entero, y aquí se cubre lo que se usa el 95 % del tiempo, señalando lo que queda fuera.
Contenido
- Por qué Terraform ganó
- Los conceptos: proveedor, recurso, dato, variable, salida y dependencias
- El estado: qué contiene y por qué nunca va a Git
- El backend remoto en Cloud Storage
- El flujo de trabajo:
init,validate,plan,apply,destroy - Cómo se lee un plan
- Código real de AlpinaShop: la red y el bucket
- Variables,
tfvarsy entornos - Módulos: escribir el módulo
red-alpinashop - Módulos del registro público, con criterio
- Importar lo que ya existe
- Terraform en CI/CD:
planen cada pull request - Qué NO debe gestionar Terraform
prevent_destroyy el peligro deterraform destroy- Infrastructure Manager y alternativas
- El estado final de AlpinaShop
- Por qué Terraform ganó
En 06-05 se vio por qué Deployment Manager perdió. La otra cara es por qué ganó Terraform, y no es solo mérito técnico:
| Razón | Detalle |
|---|---|
| Multiproveedor | GCP, AWS, Azure, Kubernetes, GitHub, Cloudflare, Datadog, PostgreSQL… con un mismo lenguaje |
| Ecosistema de módulos | Miles de módulos públicos revisados y mantenidos, muchos por Google |
| Madurez | Desde 2014, con los casos raros ya resueltos y documentados |
| Comunidad | Ejemplos, libros, cursos y —muy importante— profesionales que ya lo conocen |
| Adoptado por el propio Google | Infrastructure Manager es Terraform gestionado |
| Lenguaje legible | HCL es más claro que YAML anidado o que JSON |
La primera fila importa más de lo que parece incluso para quien solo usa GCP. La infraestructura real de AlpinaShop no es solo GCP: hay un registro DNS en el registrador del dominio, repositorios y protecciones de rama en GitHub, quizá mañana un CDN externo. Con Terraform, todo eso se declara en el mismo sitio y con el mismo flujo, y las dependencias entre proveedores funcionan igual que dentro de uno.
Una nota sobre la licencia, porque hay que ser honesto: en 2023 HashiCorp cambió Terraform de una licencia de código abierto a la Business Source License, lo que provocó la aparición de OpenTofu, una bifurcación bajo licencia abierta gestionada por una fundación. Para el uso normal —una empresa gestionando su propia infraestructura— la BSL no impone ninguna restricción práctica, y ambas herramientas son compatibles a nivel de código y de estado. Es algo que conviene conocer; para AlpinaShop no cambia nada.
- Los conceptos: proveedor, recurso, dato, variable, salida y dependencias
Terraform se escribe en HCL (HashiCorp Configuration Language), y tiene pocos tipos de bloque. Con estos seis se hace casi todo.
Proveedor (provider): el complemento que sabe hablar con una API. Se declara con su versión fijada:
terraform {
required_version = ">= 1.9"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0" # permite 6.x, no salta a 7.x
}
}
}
provider "google" {
project = var.proyecto
region = var.region
}Fijar la versión del proveedor no es opcional. Sin version, Terraform descarga la última disponible, y una versión mayor puede cambiar el comportamiento de recursos existentes. La sintaxis ~> 6.0 permite actualizaciones menores y de parche pero no el salto a la 7, que es donde están los cambios incompatibles.
Recurso (resource): algo que Terraform crea y gestiona.
resource "google_compute_network" "principal" {
# └── tipo └── nombre LOCAL, solo dentro de Terraform
name = "alpinashop-vpc" # el nombre real en GCP
auto_create_subnetworks = false
}La distinción entre el nombre local (principal) y el atributo name (alpinashop-vpc) confunde al principio. El local es la etiqueta con la que otras partes del código referencian el recurso; el atributo name es lo que aparece en la consola de GCP.
Fuente de datos (data): consulta algo que existe pero que Terraform no gestiona.
data "google_project" "actual" {}
data "google_compute_image" "debian" {
family = "debian-12"
project = "debian-cloud" # imagen pública de Google
}La diferencia con un resource es total: un data solo lee, nunca crea ni modifica. Sirve para obtener el número de proyecto, la última imagen de un sistema operativo o un recurso creado por otro equipo.
Variable (variable): entrada parametrizable.
variable "entorno" {
description = "Entorno de despliegue"
type = string
validation {
condition = contains(["dev", "prod"], var.entorno)
error_message = "El entorno debe ser 'dev' o 'prod'."
}
}
variable "cidr_web" {
description = "Rango CIDR de la subred web"
type = string
default = "10.10.0.0/24"
}El bloque validation es el equivalente a los esquemas de Deployment Manager de 06-05, y produce un error claro antes de tocar nada.
Salida (output): valor que se expone al terminar.
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 se imprime en la consola
}sensitive = true evita que el valor aparezca en la salida. No lo cifra ni lo oculta del estado, que es una distinción importante y que retomamos en el apartado 3.
Dependencias. La mayoría son implícitas: nacen de referenciar un recurso desde otro.
resource "google_compute_subnetwork" "web" {
name = "sn-web-euw1"
ip_cidr_range = var.cidr_web
region = var.region
# Esta referencia DECLARA que la subred depende de la red
network = google_compute_network.principal.id
}Terraform construye un grafo, crea primero la red y después la subred, y paraleliza todo lo que no tiene dependencias entre sí. Las explícitas con depends_on son el último recurso, para dependencias que no se expresan mediante datos:
resource "google_compute_instance" "app" {
# ...
# La API debe estar habilitada antes, pero eso no se refleja en ningún atributo
depends_on = [google_project_service.compute]
}Consejo: usa depends_on lo mínimo posible. Un depends_on que se puede sustituir por una referencia es una oportunidad perdida de que el código exprese la relación real.
- El estado: qué contiene y por qué nunca va a Git
En 06-05 se explicó qué es el estado conceptualmente. Aquí toca la parte práctica, que es donde están los problemas reales.
El fichero terraform.tfstate es un JSON que contiene, para cada recurso gestionado: su nombre local, su tipo, su identificador real en GCP y todos sus atributos tal como estaban en la última operación.
{
"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 la última línea. La contraseña está en claro en el fichero de estado. No es un error de configuración: es el funcionamiento normal. Terraform guarda todos los atributos de cada recurso, y algunos atributos son secretos. Lo mismo ocurre con claves generadas, tokens y certificados.
De ahí las cuatro reglas del estado:
| Regla | Motivo |
|---|---|
| Nunca a Git | Contiene secretos en claro, y Git no olvida (06-02) |
| Backend remoto compartido | Si cada uno tiene su copia local, el equipo se pisa |
| Con bloqueo | Dos apply simultáneos corrompen el estado |
| Con versionado | Un estado corrupto o borrado se recupera de una versión anterior |
Y una consecuencia importante que se deriva de todo esto: el acceso al estado es tan sensible como el acceso a producción. Quien puede leer el bucket del estado puede leer las contraseñas que Terraform gestione. Por eso el apartado 13 insiste en que Terraform no debe gestionar valores de secretos.
El .gitignore no es negociable:
*.tfstate
*.tfstate.*
*.tfstate.backup
.terraform/
.terraform.lock.hcl.bak
crash.log
*.tfvars.secret
override.tfCon una excepción deliberada: .terraform.lock.hcl SÍ va a Git. Ese fichero fija las versiones exactas de los proveedores y sus sumas de verificación, y versionarlo garantiza que todo el equipo y el pipeline usan exactamente lo mismo. Es el equivalente a un package-lock.json.
- El backend remoto en Cloud Storage
El backend define dónde vive el estado. Para GCP, Cloud Storage.
Primero se crea el bucket, y esto es lo único que se hace a mano en todo el proceso —problema del huevo y la gallina—:
gcloud storage buckets create gs://alpinashop-terraform-estado \
--project=alpinashop-cicd \
--location=europe-west1 \
--uniform-bucket-level-access \
--public-access-prevention
# VERSIONADO: imprescindible, permite recuperar un estado corrupto
gcloud storage buckets update gs://alpinashop-terraform-estado --versioning
# Acceso restringido: quien lea esto lee secretos
gcloud storage buckets add-iam-policy-binding gs://alpinashop-terraform-estado \
--member='group:[email protected]' \
--role=roles/storage.objectAdmin \
--project=alpinashop-cicdY se declara en el código:
terraform {
backend "gcs" {
bucket = "alpinashop-terraform-estado"
prefix = "prod/red" # una ruta distinta por entorno y componente
}
}El prefix es más importante de lo que parece: separa estados. prod/red, prod/datos, dev/red son estados independientes, y esa separación limita el radio de daño: un error operando la red no puede afectar al estado de la base de datos.
El bloqueo funciona solo, sin configuración. Terraform crea un objeto .tflock mientras opera; si otra persona lanza apply a la vez, obtiene un error claro en lugar de corromper el estado:
Error: Error acquiring the state lock Lock Info: ID: b3d4e5f6 Who: [email protected] Created: 2026-08-05 18:42:11 UTC
Si un proceso muere dejando el bloqueo puesto —una compilación cancelada, por ejemplo—, se libera con terraform force-unlock ID. Es un comando que hay que usar con cuidado: solo cuando estés seguro de que no hay ninguna operación en curso.
- El flujo de trabajo:
init, validate, plan, apply, destroy
init, validate, plan, apply, destroyflowchart LR
A[init<br/>descargar proveedores<br/>configurar backend] --> B[validate<br/>sintaxis y tipos]
B --> C[plan<br/>QUÉ VA A PASAR]
C --> D{¿El plan<br/>es correcto?}
D -->|No| E[Corregir el código]
E --> C
D -->|Sí| F[apply<br/>ejecutar]
F --> G[Infraestructura<br/>actualizada]
# 1. init: descarga proveedores y configura el backend. Se ejecuta al empezar
# y cada vez que cambian proveedores o módulos.
terraform init
# 2. fmt y validate: formateo canónico y comprobación de sintaxis y tipos.
# Rápidos, sin tocar nada, ideales para un gancho de pre-commit (06-02).
terraform fmt -recursive
terraform validate
# 3. plan: LA OPERACIÓN MÁS IMPORTANTE. Compara código, estado y realidad.
terraform plan -out=plan.tfplan
# 4. apply: ejecuta el plan guardado. Con el fichero, aplica EXACTAMENTE
# lo que revisaste; sin él, recalcula y podría hacer algo distinto.
terraform apply plan.tfplan
# Utilidades
terraform show # ver el estado en formato legible
terraform state list # listar recursos gestionados
terraform output id_red # consultar una salidaEl detalle de -out=plan.tfplan merece énfasis porque es la diferencia entre revisar y confiar. Sin él, terraform apply recalcula el plan en ese momento; si algo cambió entre medias, aplica algo distinto de lo que se revisó. Con el fichero, aplica exactamente lo aprobado. En un pipeline es obligatorio.
Y terraform destroy destruye todo lo del estado. Su uso legítimo es un entorno efímero de pruebas. En producción es el comando más peligroso de la herramienta, y el apartado 14 explica cómo protegerse.
- Cómo se lee un plan
Saber leer un plan es la habilidad más importante de esta lección. Un apply mal revisado es un incidente.
Los cuatro símbolos:
| Símbolo | Significado | Nivel de atención |
|---|---|---|
+ |
Crear un recurso nuevo | Comprobar que es lo que esperas |
~ |
Modificar en el sitio | Leer qué campo cambia |
- |
Destruir | Alta: ¿por qué desaparece? |
-/+ |
Destruir y recrear | MÁXIMA: para y entiende por qué |
Y la línea resumen al final:
La regla de oro es la del -/+. Un reemplazo ocurre cuando cambia un atributo que la API de GCP no permite modificar en caliente. Terraform, sin más opciones, destruye y crea. Y en algunos recursos eso es catastrófico:
# google_sql_database_instance.pedidos must be replaced
-/+ resource "google_sql_database_instance" "pedidos" {
~ region = "europe-west1" -> "europe-west4" # forces replacement
}Ese plan destruiría la base de datos de pedidos de AlpinaShop con todos sus datos. Terraform lo dice con claridad —# forces replacement— pero un apply automático o distraído lo ejecuta sin dudar.
| Recurso | ¿Un -/+ es aceptable? |
Por qué |
|---|---|---|
| Regla de firewall | Sí | Se recrea en segundos |
| Plantilla de instancia | Sí | Son inmutables por diseño |
| Subred | No | Desconecta todo lo que hay dentro |
| Cloud SQL | NUNCA sin plan explícito | Pérdida de datos |
| Bucket con contenido | NUNCA | Pérdida de objetos |
| Clúster de GKE | No | Downtime completo |
Qué hacer ante un -/+ inesperado, en este orden: leer qué atributo lo fuerza —Terraform lo marca con # forces replacement—; decidir si ese cambio es realmente necesario; si lo es, buscar una vía sin destruir —crear el recurso nuevo, migrar y borrar el viejo—; y si no lo es, corregir el código. Nunca aplicar sin haber entendido la causa.
Otro caso que confunde: los planes que nunca salen vacíos aunque no cambies nada. Suelen deberse a un atributo que GCP normaliza —una lista que reordena, un valor por defecto que rellena— y se resuelven o bien escribiendo el valor real en el código, o bien, cuando el proveedor tiene un comportamiento incorrecto, con lifecycle { ignore_changes = [...] }. Esta segunda opción es un parche y conviene usarla con moderación: cada ignore_changes es una parte de la infraestructura que Terraform deja de vigilar.
- Código real de AlpinaShop: la red y el bucket
Ahora el código de verdad, explicado línea a línea.
# ============================================================
# red.tf — Red de AlpinaShop
# ============================================================
resource "google_compute_network" "principal" {
name = "alpinashop-vpc"
# false = subredes creadas por nosotros, con los rangos que decidamos.
# Con true, GCP crearía una subred automática en CADA región (03-01).
auto_create_subnetworks = false
# REGIONAL: las rutas solo se propagan dentro de la región. Suficiente
# para AlpinaShop y con menos superficie que GLOBAL.
routing_mode = "REGIONAL"
description = "VPC principal de AlpinaShop - gestionada con Terraform"
}
resource "google_compute_subnetwork" "web" {
name = "sn-web-euw1"
ip_cidr_range = var.cidr_web
region = var.region
# Referencia: crea la dependencia implícita con la red
network = google_compute_network.principal.id
# CRÍTICO: permite que las VM SIN IP pública alcancen las APIs de Google
# (Cloud Storage, Secret Manager, Logging). Sin esto, se rompe medio módulo 3.
private_ip_google_access = true
# Logs de flujo con muestreo: diagnóstico de red sin coste desbocado (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 — Reglas de firewall
# ============================================================
resource "google_compute_firewall" "permitir_salud" {
name = "fw-permitir-salud"
network = google_compute_network.principal.name
allow {
protocol = "tcp"
ports = ["8080"]
}
# Rangos FIJOS de las sondas de comprobación de estado de Google (03-02).
# No son direcciones arbitrarias: si se quitan, hc-catalogo marca todas
# las instancias como no sanas y el balanceador deja de enviar tráfico.
source_ranges = ["35.191.0.0/16", "130.211.0.0/22"]
target_tags = ["catalogo-web"]
description = "Permite las sondas de hc-catalogo hacia /salud en 8080"
}
resource "google_compute_firewall" "permitir_sql_interno" {
name = "fw-permitir-sql-interno"
network = google_compute_network.principal.name
allow {
protocol = "tcp"
ports = ["5432"]
}
# Solo desde la subred web: la base de datos no se expone a nada más
source_ranges = [var.cidr_web]
target_tags = ["base-datos"]
description = "PostgreSQL accesible únicamente desde la subred web"
}
# Denegar y REGISTRAR el resto del tráfico entrante.
# Prioridad 65000: se evalúa la última, después de todas las permisivas.
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"]
# Los intentos denegados se registran: base para investigar (06-06)
log_config { metadata = "INCLUDE_ALL_METADATA" }
}# ============================================================
# almacenamiento.tf — Bucket del catálogo
# ============================================================
resource "google_storage_bucket" "catalogo" {
name = "alpinashop-catalogo"
location = var.region
# Acceso uniforme: solo IAM, sin ACL por objeto. Mucho más simple de auditar.
uniform_bucket_level_access = true
public_access_prevention = "enforced"
versioning { enabled = true }
# Ciclo de vida: las versiones antiguas bajan de clase y se borran (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ón: impide que un 'terraform destroy' borre el bucket (apartado 14)
lifecycle {
prevent_destroy = true
}
}Fíjate en dos cosas de este código que son la razón de ser del ejercicio. Primero, los comentarios explican el porqué, no el qué: que 35.191.0.0/16 son las sondas de Google, que private_ip_google_access rompe medio módulo 3 si falta. Ese conocimiento vivía en la cabeza de Marta y ahora vive en el repositorio. Y segundo, las etiquetas entorno, equipo, centro-coste y aplicacion son las de todo el curso: al ser código, dejan de aplicarse "cuando alguien se acuerda" y se aplican siempre.
- Variables,
tfvars y entornos
tfvars y entornosEl objetivo es que alpinashop-dev y alpinashop-prod compartan el mismo código con distintos valores, resolviendo la deriva de configuración de 06-05.
# variables.tf — el mismo para los dos entornos
variable "proyecto" {
description = "ID del proyecto de GCP"
type = string
}
variable "entorno" {
description = "Entorno: dev o prod"
type = string
validation {
condition = contains(["dev", "prod"], var.entorno)
error_message = "El entorno debe 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 = "Tipo de máquina de Cloud SQL"
type = string
default = "db-g1-small" # el valor barato como defecto
}# entornos/dev.tfvars
proyecto = "alpinashop-dev"
entorno = "dev"
cidr_web = "10.110.0.0/24" # rangos distintos: evita solapes
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"Carpetas por entorno frente a workspaces, que es la decisión estructural que hay que tomar:
| Aspecto | Carpetas por entorno | Workspaces |
|---|---|---|
| Estructura | entornos/dev/, entornos/prod/ |
Un directorio, varios estados |
| Estado | Backend distinto por carpeta | Mismo backend, prefijos distintos |
| Divergencia entre entornos | Posible y visible | Difícil |
| Riesgo de equivocarse de entorno | Bajo: estás en otra carpeta | Alto: es un comando |
| Duplicación de código | Alguna, se mitiga con módulos | Ninguna |
| Recomendación | Para prod y dev | Entornos efímeros y muy iguales |
AlpinaShop elige carpetas por entorno, y la razón principal es de seguridad operativa: con workspaces, la única diferencia entre aplicar en desarrollo y aplicar en producción es haber ejecutado terraform workspace select prod antes, y es demasiado fácil olvidarlo. Con carpetas, cada una tiene su propio backend y su propio provider, y aplicar en producción exige estar físicamente en el directorio de producción. Es una barrera pequeña que evita el error más caro.
La estructura resultante del repositorio alpinashop-infra:
alpinashop-infra/ ├── modulos/ │ └── red-alpinashop/ │ ├── main.tf │ ├── variables.tf │ └── outputs.tf ├── entornos/ │ ├── dev/ │ │ ├── main.tf # invoca los módulos │ │ ├── backend.tf # prefix = "dev/" │ │ └── dev.tfvars │ └── prod/ │ ├── main.tf │ ├── backend.tf # prefix = "prod/" │ └── prod.tfvars └── paneles/ # los JSON de 06-04
- Módulos: escribir el módulo
red-alpinashop
red-alpinashopUn módulo es un directorio con código Terraform que se invoca con parámetros. Es el mecanismo de reutilización.
# 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 para salida sin 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 y el NAT solo se crean si se piden: count con una condición
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" # solo errores: el volumen completo es 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 }Y su uso desde cada entorno:
# 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
}
# Las salidas del módulo se consumen como cualquier otro 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"]
}
}Las tres reglas de un buen módulo:
- Una responsabilidad clara.
red-alpinashophace la red. Un módulo que crea la red, la base de datos y el balanceador es un monolito con otro nombre. - Parametrizar lo que varía, fijar lo que no.
private_ip_google_access = trueestá fijado a propósito: no es una decisión que cada entorno deba tomar, es una regla de AlpinaShop. - Exponer las salidas que otros necesitan. Un módulo cuyas salidas no bastan obliga a romper su encapsulación.
Y una advertencia sobre el exceso de abstracción, que es el error más común al descubrir los módulos: un módulo con veinte variables para cubrir todos los casos imaginables es más difícil de usar que escribir el recurso directamente. Empieza con código plano, extrae un módulo cuando lo repitas por segunda vez, y no antes.
- Módulos del registro público, con criterio
El Terraform Registry aloja miles de módulos públicos, y Google mantiene una colección de módulos para GCP de calidad notable.
module "red" {
source = "terraform-google-modules/network/google"
version = "~> 9.0" # SIEMPRE fijar la versión
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"
},
]
}Los criterios para decidir si usar un módulo público o escribir el tuyo:
| Criterio | A favor de usarlo | En contra |
|---|---|---|
| Quién lo mantiene | Google, HashiCorp, organización conocida | Una persona anónima |
| Actividad | Commits recientes, incidencias atendidas | Sin actualizar en dos años |
| Complejidad que ahorra | Recursos con muchas piezas interrelacionadas | Un recurso trivial |
| Ajuste a tu caso | Cubre lo que necesitas | Te obliga a contorsionarte |
| Legibilidad | Puedes leer su código | Cinco niveles de abstracción |
La regla que uso: cuanto más complejo el recurso, más compensa el módulo público. Un módulo para crear una VPC con dos subredes ahorra poco y añade una dependencia externa; un módulo para montar una VPC compartida con reglas jerárquicas, o un clúster de GKE endurecido con todas sus buenas prácticas, ahorra semanas de trabajo y de errores.
Y dos advertencias serias. Fija siempre la versión con version = "~> X.Y": sin ella, un terraform init en el pipeline puede traer una versión nueva del módulo que cambie recursos de producción sin que nadie lo haya decidido. Y un módulo público es código de terceros con permisos sobre tu infraestructura: para módulos de organizaciones no conocidas, léelo antes, o bifúrcalo a tu propio repositorio. Es exactamente el mismo razonamiento de la cadena de suministro de software de 06-01.
- Importar lo que ya existe
Este es el momento de aplicar el procedimiento de 06-05 con la herramienta real. Todo lo que Marta creó a mano en los módulos 2 y 3 tiene que pasar a estar gestionado sin destruir ni recrear nada.
La forma moderna, con bloques import declarativos:
# importaciones.tf — fichero temporal, se borra al terminar la migración
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"
}Y el truco que ahorra la mayor parte del trabajo manual:
# Genera el HCL correspondiente a los recursos importados
terraform plan -generate-config-out=generado.tfTerraform escribe en generado.tf la configuración de los recursos declarados en los bloques import, leída de la realidad. Después toca limpiarla —quitar campos calculados, sustituir literales por referencias, añadir comentarios— exactamente como se explicó en 06-05.
El procedimiento completo, con el ciclo que hay que respetar:
# 1. Importar y generar
terraform plan -generate-config-out=generado.tf
# 2. Revisar y limpiar generado.tf, integrarlo en los ficheros definitivos
# 3. Aplicar: registra los recursos en el estado SIN modificarlos
terraform apply
# 4. EL CRITERIO DE ÉXITO
terraform plan
# → "No changes. Your infrastructure matches the configuration."
# 5. Borrar importaciones.tf, que ya no hace faltaConsejos para que la migración no se atragante:
- De tres en tres, no de cincuenta en cincuenta. Un plan con cincuenta recursos importados tiene cientos de líneas y las diferencias importantes se pierden. Importa un grupo pequeño, verifica plan vacío, avanza.
- Empieza por lo inofensivo. Reglas de firewall y buckets vacíos primero; Cloud SQL, el último.
- Todo primero en
alpinashop-dev. Un error allí no cuesta nada. prevent_destroyantes del primerapplyen cualquier recurso con datos.- Un
-/+te para siempre. La regla del apartado 6, aplicada con más razón durante una importación.
El formato del identificador varía por recurso y está documentado al final de la página de cada uno en el proveedor google. Es el detalle más tedioso, y no hay atajo.
- Terraform en CI/CD:
plan en cada pull request
plan en cada pull requestAquí converge todo el módulo: el repositorio de 06-02, el pipeline de 06-01 y la infraestructura como código de 06-05.
El flujo que AlpinaShop implanta:
flowchart TD
A[Marta abre PR<br/>en alpinashop-infra] --> B[Cloud Build:<br/>fmt, validate, plan]
B --> C[El plan se publica<br/>como comentario del PR]
C --> D{Revisión:<br/>CODEOWNERS de seguridad}
D -->|Cambios pedidos| A
D -->|Aprobado| E[Merge a main]
E --> F[Disparador de apply<br/>con --require-approval]
F --> G{Marta aprueba}
G -->|Sí| H[terraform apply]
G -->|No| I[No se aplica]
El pipeline de plan, que corre en 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 código no está formateado. Ejecuta: 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 seguridad: avisar si el plan destruye algo
if grep -qE '^Plan:.*to destroy' /workspace/plan.txt; then
echo "=========================================="
echo "ATENCION: este plan DESTRUYE recursos."
echo "Revisión 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_ONLYEl paso que publica el plan como comentario del pull request es el que cambia la cultura del equipo. Sin él, revisar un cambio de infraestructura obliga a leer HCL e imaginar el efecto. Con él, el revisor ve exactamente qué recursos se crean, se modifican y se destruyen, sin ejecutar nada. Es la aplicación literal del principio de 06-05: revisar la infraestructura como se revisa el código.
El pipeline de apply, con aprobación 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'Fíjate en que aplica el fichero de plan generado en el PR, no uno recalculado. Es lo que garantiza que se ejecuta exactamente lo aprobado. Y el -auto-approve no es peligroso aquí precisamente por eso: la aprobación humana ya ocurrió, en el disparador --require-approval de 06-01.
Y sin ninguna clave. La cuenta de servicio del pipeline se autentica con la identidad de Cloud Build; si el apply corriera en GitHub Actions, sería con Workload Identity Federation de 06-02. En ningún punto de este flujo existe un fichero JSON de credenciales.
Los permisos de la cuenta de servicio de apply merecen una nota: es la cuenta con más poder de toda la infraestructura, porque puede modificar redes, IAM y bases de datos. Debe estar acotada a los recursos que gestiona, no ser Editor del proyecto, y su uso debe estar auditado. Es la misma advertencia de 06-01 elevada un nivel.
- Qué NO debe gestionar Terraform
Tan importante como saber qué gestionar es saber qué dejar fuera. La regla, ya enunciada en 06-05: Terraform gestiona la forma, no el contenido.
| No gestionar | Por qué | Quién lo gestiona |
|---|---|---|
| Valores de secretos | Acabarían en claro en el estado | Secret Manager, valor creado fuera (03-06) |
| Objetos de un bucket | Son datos, cambian constantemente | La aplicación |
| Filas y esquema de la BD | Migraciones versionadas | El pipeline de la app (06-01) |
| Instancias de un MIG | Las gestiona el propio MIG | El autoescalador |
| Pods y Deployments | Ciclo de vida distinto | Manifiestos y GKE (02-05) |
| Tablas creadas por pipelines | Nacen y mueren solas | Dataflow, BigQuery (04-02) |
| Modelos y artefactos de ML | Los produce el pipeline | Vertex AI (05-07) |
La primera fila es la más importante y la más incumplida. Mira la diferencia:
# MAL: la contraseña acaba en claro en el fichero de estado
resource "google_sql_user" "app" {
name = "catalogo"
instance = google_sql_database_instance.pedidos.name
password = "P4ssw0rd-real" # ← en el .tfstate, en claro
}
# BIEN: Terraform crea el CONTENEDOR del secreto; el valor se pone fuera
resource "google_secret_manager_secret" "db_password" {
secret_id = "db-password-catalogo"
replication {
user_managed {
replicas { location = "europe-west1" }
}
}
}
# Y el valor se establece con gcloud o desde la aplicación:
# gcloud secrets versions add db-password-catalogo --data-file=-Y un caso intermedio que conviene resolver bien: una contraseña generada aleatoriamente. random_password también queda en el estado, así que la solución correcta es que la genere el proveedor —Cloud SQL puede hacerlo— o un proceso externo, y que Terraform solo cree el contenedor.
La pregunta que resuelve las dudas: ¿esto cambia por sí solo, o cambia porque alguien lo decide? Lo que cambia solo —datos, instancias autoescaladas, tablas de pipelines— no es de Terraform. Lo que cambia porque alguien lo decide, sí.
prevent_destroy y el peligro de terraform destroy
prevent_destroy y el peligro de terraform destroyterraform destroy destruye todo lo que hay en el estado. Es una operación legítima para entornos efímeros y una catástrofe en producción, y hay tres formas de ejecutarla sin querer: escribirla en el directorio equivocado, un apply con un -/+ no revisado, y borrar recursos del código sin darse cuenta de que borrarlos significa destruirlos.
La protección de primer nivel, prevent_destroy, en todo lo que contenga datos:
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ón propia de la API de Cloud SQL, independiente de Terraform
deletion_protection = true
lifecycle {
# Cualquier plan que destruya este recurso FALLA con un error explícito
prevent_destroy = true
}
}Con prevent_destroy = true, un plan que intente destruir el recurso no se ejecuta:
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.
Es un error de la herramienta, no un aviso. Para destruirlo de verdad hay que editar el código, quitar la protección y volver a aplicar: dos pasos deliberados en lugar de uno accidental.
Las cinco capas de protección de AlpinaShop, defensa en profundidad:
| Capa | Qué protege | Contra qué |
|---|---|---|
prevent_destroy |
Recursos con datos | destroy accidental y -/+ |
deletion_protection |
Cloud SQL y GKE | Borrado por cualquier vía, incluida la consola |
| Revisión del plan en el PR | Todo | Errores humanos, con otro par de ojos |
Aprobación manual del apply |
Producción | Aplicar algo no revisado |
| IAM restrictivo | Producción | Que quien no debe pueda aplicar |
Y una precisión sobre el alcance de prevent_destroy: protege el recurso, no el estado. Si alguien borra el bloque del código, Terraform no lo ve y no protege nada. La protección real la dan las capas 3, 4 y 5 juntas.
- Infrastructure Manager y alternativas
Ejecutar Terraform tú mismo implica gestionar el bucket del estado, el bloqueo, las versiones y los permisos del pipeline. Infrastructure Manager es el servicio gestionado de Google que hace todo eso ejecutando Terraform por debajo:
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| Aspecto | Terraform propio | Infrastructure Manager |
|---|---|---|
| Estado | Tu bucket, tu responsabilidad | Gestionado |
| Bloqueo | Automático en GCS | Gestionado |
| Autenticación | Cuenta de servicio del pipeline | Nativa de GCP |
| Historial | En Cloud Build | En el propio servicio |
| Multiproveedor | Sí | Sí, pero pensado para GCP |
| Flexibilidad del pipeline | Total | Menor |
Para AlpinaShop, que ya tiene el pipeline de 06-01 funcionando y quiere control total sobre el flujo, Terraform propio es la elección. Infrastructure Manager es excelente para equipos que prefieren no gestionar el estado y viven exclusivamente en GCP.
Las alternativas al propio Terraform, para tener el mapa completo:
| Herramienta | Modelo | Fuerte en | Débil en |
|---|---|---|---|
| Terraform / OpenTofu | HCL declarativo | Estándar, ecosistema, multiproveedor | Estado propio, HCL limitado como lenguaje |
| Pulumi | Python, TypeScript, Go | Lenguajes reales, bucles y tipos | Ecosistema menor, riesgo de código demasiado listo |
| Config Connector | CRD de Kubernetes | Reconciliación continua, GitOps | Requiere clúster; solo GCP |
| Crossplane | CRD de Kubernetes | Multiproveedor, plataformas internas | Complejo, para organizaciones grandes |
| CDK for Terraform | Lenguajes sobre Terraform | Combina ambos mundos | Capa adicional de indirección |
Recomendación para AlpinaShop: Terraform, por la razón que abre la lección —el ecosistema y la gente que ya lo conoce—. Y una observación sobre Pulumi que merece decirse: escribir infraestructura en un lenguaje de propósito general es tentador y tiene un riesgo real, porque es fácil escribir infraestructura demasiado inteligente. La limitación de HCL es en parte una virtud: obliga a que la infraestructura sea legible por alguien que no escribió el código.
- El estado final de AlpinaShop
Merece la pena mirar el antes y el después, porque el recorrido ha sido largo:
| Aspecto | Antes del módulo 6 | Ahora |
|---|---|---|
| Código | Portátil de Dani y Drive | GitHub, revisado, con protección de ramas |
| Despliegue | docker build a mano, etiqueta latest |
Cloud Build, imagen etiquetada con el SHA |
| Pruebas | "Opcionales según las prisas" | Obligatorias, bloquean el merge |
| Producción | kubectl set image desde un portátil |
Promoción con aprobación manual |
| Eventos | Sin implementar | Cloud Functions, con idempotencia y DLQ |
| Saber si funciona | Correo de un cliente | Alertas en 5 minutos, panel, uptime checks |
| Diagnosticar | Buscar texto en logs | Logs estructurados, trazas, 17 minutos a la causa |
| Infraestructura | Historial del terminal de Marta | HCL versionado, revisado y reproducible |
| Reconstruir el entorno | Nadie sabe | terraform apply, unos veinte minutos |
| Deriva entre entornos | Real y desconocida | El mismo código, distintos tfvars |
| Quién puede desplegar | Solo Dani | Cualquiera abre un PR; aprueba quien debe |
Ese cambio de la última fila es el resumen cultural del módulo. El conocimiento ha salido de las cabezas y ha entrado en el repositorio.
Errores Comunes y Consejos
Subir el .tfstate a Git. Contiene secretos en claro y Git no olvida. Backend remoto en Cloud Storage con versionado, y .gitignore desde el primer commit.
No fijar la versión del proveedor ni de los módulos. Un init en el pipeline puede traer una versión nueva que cambie recursos de producción sin que nadie lo haya decidido. version = "~> 6.0" siempre.
Ejecutar apply sin fichero de plan. Recalcula el plan y puede aplicar algo distinto de lo revisado. plan -out=fichero y apply fichero.
Ignorar un -/+. Es el error más caro de la herramienta. Un reemplazo sobre Cloud SQL o sobre un bucket con contenido destruye datos. Para siempre y entiende qué atributo lo fuerza.
Gestionar secretos con Terraform. El valor acaba en claro en el estado. Terraform crea el contenedor en Secret Manager; el valor se establece fuera.
Borrar recursos del código pensando que "dejan de gestionarse". Borrarlos del código significa destruirlos. Para dejar de gestionar sin destruir: terraform state rm.
Usar workspaces para separar producción de desarrollo. La única barrera es acordarse de workspace select, y algún día alguien no se acordará. Carpetas por entorno.
Crear módulos demasiado pronto. Un módulo con veinte variables para cubrir todos los casos es peor que el código plano. Extrae un módulo la segunda vez que repitas algo.
Importar cincuenta recursos de golpe. El plan resultante es ilegible y las diferencias peligrosas se pierden. De tres en tres, verificando plan vacío.
Olvidar prevent_destroy en recursos con datos. Son dos líneas y pueden salvar la base de datos.
Seguir tocando cosas a mano. El pecado original de la infraestructura como código. En cuanto el código y la realidad divergen, la herramienta deja de ser fiable y vuelves al punto de partida.
Consejo final: empieza por lo nuevo. Si la migración de todo lo existente parece inabordable, adopta la regla de que a partir de hoy, todo lo nuevo se crea con Terraform, y ve importando lo viejo cuando lo toques. En unos meses, la mayor parte estará gestionada sin haber hecho nunca un proyecto de migración.
Ejercicios
Ejercicio 1: escribir un módulo reutilizable
Escribe un módulo bucket-alpinashop que cree un bucket de Cloud Storage con las convenciones de AlpinaShop: acceso uniforme, prevención de acceso público, versionado configurable, las cuatro etiquetas obligatorias (entorno, equipo, centro-coste, aplicacion), una regla de ciclo de vida parametrizable y prevent_destroy para producción. Escribe sus variables con validación, sus salidas, y el bloque que lo invoca para crear alpinashop-datalake en producción. Explica qué decidiste parametrizar y qué decidiste fijar, y por qué.
Ejercicio 2: analizar un plan peligroso
Un pull request de alpinashop-infra genera este plan:
# 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.Analiza cada cambio, clasifícalo por riesgo, di si aprobarías el PR y qué pedirías al autor. Indica cuál es el más peligroso y por qué.
Ejercicio 3: diseñar el flujo completo de infraestructura como código
Marta te pide diseñar de principio a fin el flujo de trabajo de infraestructura de AlpinaShop, dando por hecho que la migración de 06-05 ya está completa. Define: la estructura del repositorio, la configuración del backend y la separación de estados, los pipelines de Cloud Build necesarios, los permisos IAM de cada cuenta de servicio implicada, las protecciones de rama y CODEOWNERS, y el procedimiento de emergencia para cuando haya que cambiar algo en producción a las 3 de la madrugada con el pipeline caído. Justifica las decisiones de seguridad.
Soluciones
Solución 1
# modulos/bucket-alpinashop/variables.tf
variable "nombre" {
description = "Nombre del bucket, único globalmente"
type = string
validation {
condition = can(regex("^alpinashop-[a-z0-9-]+$", var.nombre))
error_message = "El nombre debe empezar por 'alpinashop-' y usar minúsculas."
}
}
variable "entorno" {
type = string
validation {
condition = contains(["dev", "prod"], var.entorno)
error_message = "El entorno debe 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 versionado de objetos"
type = bool
default = true
}
variable "dias_a_nearline" {
description = "Días tras los que pasar a NEARLINE; 0 desactiva la regla"
type = number
default = 90
validation {
condition = var.dias_a_nearline >= 0
error_message = "Debe ser 0 o un número positivo de días."
}
}
variable "dias_borrado" {
description = "Días tras los que borrar; 0 = no borrar nunca"
type = number
default = 0
}# modulos/bucket-alpinashop/main.tf
resource "google_storage_bucket" "esta" {
name = var.nombre
location = var.region
# FIJOS: son política de AlpinaShop, no decisiones por 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" }
}
}
# Limpieza de versiones antiguas si hay versionado
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 se borra solo
}Qué parametricé y por qué:
| Elemento | Decisión | Razón |
|---|---|---|
| Nombre, etiquetas | Parámetro | Distinto en cada bucket, obviamente |
| Versionado | Parámetro | Un bucket de logs efímeros no lo necesita |
| Días de ciclo de vida | Parámetro | El data lake y las imágenes tienen patrones distintos |
| Región | Parámetro con defecto | Casi siempre europe-west1, pero puede variar |
| Acceso uniforme | FIJO | Política de seguridad, no una opción |
| Prevención de acceso público | FIJO | Igual: nunca hay motivo para desactivarlo |
prevent_destroy |
FIJO | Ver la discusión de abajo |
El principio de diseño: se parametriza lo que varía legítimamente entre casos; se fija lo que es una decisión de la organización. Convertir public_access_prevention en variable sería abrir la puerta a que alguien, con prisa, cree un bucket público "solo para una prueba". Si en algún momento hiciera falta un bucket público de verdad —un sitio estático—, se declara con google_storage_bucket directamente y ese caso excepcional recibe una revisión específica. Un módulo también sirve para hacer difícil lo que no debería hacerse.
Sobre prevent_destroy, una decisión discutible que conviene razonar. Lo he fijado a true siempre, en lugar de parametrizarlo por entorno. El motivo es que prevent_destroy no admite expresiones: el bloque lifecycle no puede usar variables, así que prevent_destroy = var.entorno == "prod" da error de sintaxis. Es una limitación real de Terraform. Las opciones son fijarlo a true para todos —lo que obliga a un paso manual para borrar un bucket de pruebas, molesto pero seguro— o crear dos módulos. He elegido lo primero: la molestia de borrar a mano un bucket de desarrollo es mucho menor que el riesgo de destruir uno de producción.
Y una advertencia sobre dynamic: los bloques dinámicos son potentes y hacen el código más difícil de leer. Con tres reglas de ciclo de vida condicionales, el módulo empieza a acercarse al límite de lo razonable. Si crecieran a ocho, sería mejor exponer una variable de lista de reglas y dejar que quien lo usa las declare explícitamente.
Solución 2
Análisis cambio a cambio:
| # | Cambio | Riesgo | Diagnóstico |
|---|---|---|---|
| 1 | - destruir fw-permitir-ssh |
Medio | Puede ser intencionado y bueno, o un descuido |
| 2 | -/+ reemplazar sn-datos |
CRÍTICO | Destruye la subred de la base de datos |
| 3 | ~ reducir el tier de Cloud SQL |
Alto | No destruye datos, pero degrada producción |
| 4 | + crear el bucket datalake |
Bajo | Un recurso nuevo, sin efectos colaterales |
Cambio 1 — destruir la regla de SSH. Es el único cambio que podría ser una mejora: si esa regla permitía SSH desde rangos amplios, eliminarla es exactamente lo que recomienda 03-01. Pero un - destroy en un plan siempre exige preguntar por qué desaparece. Hay dos causas posibles con implicaciones opuestas: que el autor la haya borrado deliberadamente del código, o que la haya borrado por accidente al editar el fichero. Lo que hay que pedir: que la descripción del pull request lo explique, y confirmación de que nadie depende de ese acceso —un proveedor, un proceso de copias, un acceso de soporte—. Si nadie lo sabe, la alternativa segura es restringir el origen en lugar de eliminar la regla, y observar durante una semana con los logs de firewall de 06-06 antes de retirarla.
Cambio 2 — el reemplazo de la subred. Este es el que hace que el PR se rechace.
Ampliar 10.20.0.0/24 a 10.20.0.0/22 es una operación aparentemente razonable —más direcciones disponibles— y la API de GCP no permite cambiar el rango de una subred en caliente cuando hay recursos dentro. Terraform, sin más opciones, propone destruirla y crearla.
Lo que ocurriría al aplicar: la destrucción de sn-datos-euw1 afecta a todo lo que vive en ella, empezando por la instancia de Cloud SQL alpinashop-pedidos con su IP privada. En el mejor caso, la operación falla a mitad porque GCP se niega a borrar una subred con recursos, y el estado queda incoherente —Terraform cree haber empezado una operación que no puede terminar—. En el peor, si algo hubiera podido borrarse, las IP privadas cambiarían y todo lo que las referencia dejaría de funcionar. En ninguno de los dos casos el resultado es aceptable.
Y hay una alternativa que hace el reemplazo completamente innecesario, y es lo que hay que proponer al autor: GCP permite ampliar el rango de una subred existente sin recrearla, mediante gcloud compute networks subnets expand-ip-range. La operación es no destructiva y solo admite ampliar, nunca reducir:
gcloud compute networks subnets expand-ip-range sn-datos-euw1 \
--region=europe-west1 --prefix-length=22 --project=alpinashop-prodDespués se actualiza el ip_cidr_range en el código y terraform plan sale vacío, porque la realidad ya coincide. Es un caso donde la operación correcta se hace fuera de Terraform y el código se limita a reflejarla, y conviene reconocerlo: forzar a la herramienta a hacer algo que la API no soporta bien es peor que hacer una excepción documentada.
Cambio 3 — reducir el tier de Cloud SQL. No destruye datos y por eso mucha gente lo daría por bueno. Pero pasar de db-custom-4-15360 a db-custom-2-7680 reduce a la mitad la CPU y la memoria de la base de datos de producción, y tiene dos consecuencias inmediatas: un reinicio de la instancia, es decir, corte de servicio de unos minutos, y una capacidad sensiblemente menor para el mismo tráfico.
Las preguntas al autor: ¿es intencionado o es un tfvars equivocado? Y si es intencionado, ¿está justificado con datos? Aquí es donde 06-04 entra en juego: si la CPU de Cloud SQL lleva meses por debajo del 20 %, es un ahorro perfectamente razonable. Si está al 60 %, reducir a la mitad la capacidad es provocar el próximo incidente. Un cambio de dimensionamiento sin la métrica que lo respalda es una conjetura. Y en cualquier caso, debe aplicarse en una ventana de mantenimiento, no un martes a las once de la mañana.
Una sospecha razonable, dicho sea de paso: que este cambio provenga de haber aplicado dev.tfvars sobre producción por error, porque db-custom-2-7680 tiene toda la pinta de ser el valor de desarrollo. Merece la pena comprobarlo.
Cambio 4 — crear el bucket. El único inofensivo. Se revisa que tenga acceso uniforme, prevención de acceso público, las cuatro etiquetas y su ciclo de vida, y ya está.
¿Aprobaría el PR? No. Y lo que pediría, en este orden:
- Dividir el pull request en tres. Este PR mezcla una mejora de seguridad, un cambio de red, un cambio de dimensionamiento y un recurso nuevo. Son cuatro intenciones distintas, con riesgos y momentos de aplicación distintos. Un PR con una sola intención se revisa bien, se aprueba rápido y se revierte limpiamente si sale mal.
- Sacar la ampliación de la subred de Terraform, hacerla con
expand-ip-rangey actualizar el código después. - Justificar el cambio de
tiercon la métrica de CPU de los últimos dos meses, y programarlo en una ventana de mantenimiento. - Explicar en la descripción por qué desaparece la regla de SSH y confirmar que nadie depende de ella.
El más peligroso es el cambio 2, sin discusión, y por una razón que conviene explicitar: es el único irreversible. El cambio 3 se deshace volviendo al tier anterior; el cambio 1 se deshace recreando la regla; el cambio 4 se deshace borrando el bucket. Un recurso destruido no vuelve, y con él se lleva por delante las direcciones IP privadas de todo lo que contenía.
Y la lección de procedimiento: este plan es exactamente el argumento a favor de publicar el plan como comentario del pull request. Leyendo solo el HCL modificado, el cambio de /24 a /22 parece una edición menor de un carácter. Es el plan el que revela que ese carácter destruye la subred de la base de datos.
Solución 3
Estructura del repositorio 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/" │ └── ... (misma estructura) ├── paneles/ # JSON de 06-04 ├── alertas/ # políticas de 06-04 ├── cloudbuild-plan.yaml ├── cloudbuild-apply.yaml ├── .pre-commit-config.yaml ├── CODEOWNERS └── .gitignore
Backend y separación de estados. Bucket alpinashop-terraform-estado en alpinashop-cicd, con versionado, acceso uniforme y prevención de acceso público. Y estados separados por entorno y por dominio:
| Prefijo | Contiene | Motivo de la separación |
|---|---|---|
prod/red |
VPC, subredes, firewall, NAT, DNS | Cambia poco, radio de daño alto |
prod/datos |
Cloud SQL, buckets, BigQuery | Contiene datos: máxima protección |
prod/cómputo |
MIG, GKE, balanceador | Cambia a menudo |
prod/observabilidad |
Paneles, alertas, sumideros | Cambia mucho, riesgo nulo |
dev/* |
Lo mismo para desarrollo | Aislamiento total |
La separación de estados es una decisión de seguridad, no de organización. Con un único estado, cualquier operación sobre cualquier recurso bloquea todo y un error tiene alcance total. Con estados separados, tocar un panel de Cloud Monitoring no puede afectar a la base de datos ni siquiera en el peor caso.
Pipelines:
| Pipeline | Disparador | Qué hace | Aprobación |
|---|---|---|---|
infra-plan-dev |
PR contra main |
fmt, validate, plan de dev, comentario en el PR |
No |
infra-plan-prod |
PR contra main |
Igual para prod | No |
infra-apply-dev |
Push a main |
apply en dev con el plan del PR |
No: automático |
infra-apply-prod |
Manual tras el merge | apply en prod |
Sí, --require-approval |
Que desarrollo se aplique automáticamente es deliberado: es la forma de garantizar que alpinashop-dev refleja siempre main, que es justo lo que impide la deriva de 06-05. Producción exige aprobación por el impacto.
Permisos IAM:
| Cuenta | Roles | Ámbito | Justificación |
|---|---|---|---|
sa-tf-plan |
viewer + storage.objectViewer sobre el estado |
prod y dev | Solo lectura: un plan no modifica nada |
sa-tf-apply-dev |
editor acotado |
Solo alpinashop-dev |
Sin acceso a producción |
sa-tf-apply-prod |
Roles específicos por servicio | Solo alpinashop-prod |
Nunca owner |
Personas de gcp-infra@ |
viewer + roles/iam.serviceAccountTokenCreator |
prod | Sin acceso directo de escritura |
La decisión de seguridad más importante: sa-tf-plan es de solo lectura. El plan se ejecuta en cada pull request, y un pull request lo puede abrir cualquiera —incluido alguien con malas intenciones que modifique el cloudbuild-plan.yaml en su rama—. Si esa cuenta tuviera permisos de escritura, abrir un PR sería suficiente para modificar producción. Es exactamente el antipatrón que se explicó en 06-01, y aquí es aún más grave porque hablamos de infraestructura.
La segunda: las personas no tienen escritura directa en producción. Se impersona sa-tf-apply-prod con serviceAccountTokenCreator, lo que queda registrado en los logs de auditoría con el nombre de la persona. Es la aplicación literal de 03-04 y de "nadie es owner de prod".
Protección de ramas y CODEOWNERS:
# CODEOWNERS de 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: prohibido el push directo, una aprobación mínima (dos para entornos/prod/), CI en verde obligatoria, aprobaciones que caducan al haber cambios nuevos, historial lineal, sin excepciones para administradores y escaneo de secretos.
El procedimiento de emergencia, que es la parte del ejercicio que más gente resuelve mal.
La respuesta ingenua es "que Marta tenga permisos permanentes de emergencia". Es mala: un permiso permanente se acaba usando fuera de las emergencias y erosiona todo lo demás. La respuesta correcta tiene cuatro elementos:
1. Acceso de emergencia con impersonación registrada. Marta pertenece a un grupo gcp-emergencia@ que puede impersonar sa-tf-apply-prod sin pasar por el pipeline. No es un permiso oculto: es un camino documentado, auditado y con nombre.
gcloud config set auth/impersonate_service_account \
[email protected]
cd entornos/prod
terraform plan -var-file=prod.tfvars -out=emergencia.tfplan # PLAN SIEMPRE
terraform apply emergencia.tfplan2. El plan no se salta ni en una emergencia. Es lo primero que la gente propone eliminar por prisa, y es exactamente al revés: a las 3 de la madrugada, con sueño y con presión, es cuando más falta hace ver qué se va a destruir. Cuesta veinte segundos y evita convertir un incidente en dos.
3. Alerta automática del uso del acceso de emergencia. Con el sumidero a Pub/Sub de 06-06, cualquier impersonación de sa-tf-apply-prod fuera del pipeline publica un mensaje que llega al canal de seguridad en el momento. No para impedirlo, sino para que todo el mundo sepa que ocurrió.
4. Regularización obligatoria en 24 horas. El cambio de emergencia se hizo con el código de main, así que el estado y el código coinciden solo si el cambio se hizo editando el código. Si se hizo con gcloud a mano, hay deriva. En ambos casos, en las 24 horas siguientes hay que abrir un pull request que documente el cambio y demuestre con un plan vacío que código y realidad coinciden de nuevo. Sin ese paso, la primera emergencia es el principio del regreso al caos, porque a partir de ahí nadie confía en que el código sea la verdad.
Y la reflexión que cierra el ejercicio. Un procedimiento de emergencia bien diseñado no elimina las protecciones: las sustituye por trazabilidad. En condiciones normales, la protección es preventiva —revisión, aprobación, permisos acotados—. En una emergencia, la protección es detectiva —alerta inmediata, registro de auditoría, regularización obligatoria—. Lo que nunca debe existir es un camino sin ninguna de las dos, porque ese camino se convierte en el habitual.
Conclusión
AlpinaShop tiene su infraestructura escrita, versionada, revisable y reproducible. La pregunta que abría 06-05 —cuánto se tarda en reconstruir el entorno— ya tiene respuesta: unos veinte minutos y un terraform apply.
Sabes por qué Terraform ganó: multiproveedor, ecosistema de módulos, madurez, comunidad, y el reconocimiento definitivo de que Infrastructure Manager es Terraform gestionado por Google. Con la nota honesta sobre la licencia y OpenTofu.
Dominas los conceptos: proveedor con su versión fijada porque no hacerlo es dejar que una actualización decida por ti; recurso con su nombre local distinto del nombre real; fuente de datos que solo lee; variable con validation; salida con sensitive, sabiendo que oculta pero no cifra; y dependencias implícitas que nacen de las referencias, con depends_on como último recurso.
Entiendes el estado de verdad: qué contiene —incluidas contraseñas en claro—, por qué nunca va a Git, y las cuatro reglas que se derivan de ello. Sabes montar el backend en Cloud Storage con versionado, bloqueo automático y prefijos que separan estados para limitar el radio de daño. Y sabes que .terraform.lock.hcl sí va a Git.
Manejas el flujo init → validate → plan → apply, con el -out=fichero que garantiza que se aplica exactamente lo revisado. Y sobre todo sabes leer un plan: +, ~, - y el -/+ que debe pararte siempre, con la tabla de en qué recursos un reemplazo es aceptable y en cuáles significa perder datos.
Tienes el código real de AlpinaShop en HCL —la VPC, las subredes con private_ip_google_access, las reglas de firewall con los rangos de las sondas de Google explicados en un comentario, el bucket con su ciclo de vida y sus cuatro etiquetas— y con ello el conocimiento que vivía en la cabeza de Marta ha pasado al repositorio. Sabes parametrizar con variables y tfvars para que alpinashop-dev y alpinashop-prod compartan código, y por qué AlpinaShop elige carpetas por entorno frente a workspaces: porque la única barrera de un workspace es acordarse de seleccionarlo.
Sabes escribir un módulo con sus tres reglas —una responsabilidad, parametrizar lo que varía y fijar lo que es política, exponer las salidas necesarias— y cuándo consumir módulos del registro público, con la regla de que cuanto más complejo es el recurso más compensa, y las dos advertencias: fija la versión, y un módulo de terceros es código con permisos sobre tu infraestructura.
Sabes importar lo que ya existe con bloques import declarativos y -generate-config-out, de tres en tres, verificando el plan vacío, empezando por lo inofensivo y dejando Cloud SQL para el final. Tienes Terraform en el pipeline: plan automático en cada pull request publicado como comentario —lo que cambia la cultura del equipo, porque el revisor ve el efecto sin ejecutar nada—, apply solo tras aprobación y aplicando el plan ya revisado, y sin una sola clave JSON en todo el flujo.
Sabes qué no debe gestionar Terraform —valores de secretos, datos, objetos efímeros— con la regla que lo resume: gestiona la forma, no el contenido, y la pregunta que resuelve las dudas: ¿esto cambia solo, o porque alguien lo decide? Y tienes las cinco capas de protección contra el borrado accidental, con prevent_destroy y deletion_protection como las dos primeras y la revisión humana como la que de verdad importa.
Por último, conoces Infrastructure Manager como forma gestionada de ejecutarlo y el mapa de alternativas —Pulumi, Config Connector, Crossplane, CDKTF— con la observación de que la limitación de HCL es en parte una virtud, porque obliga a que la infraestructura sea legible.
Y aquí termina el módulo 6. Mira lo que ha cambiado.
Al empezar, AlpinaShop tenía una aplicación que se desplegaba desde un portátil con la etiqueta latest, unos manifiestos en Drive, una función pendiente desde el módulo 5, ninguna forma de saber si la tienda funcionaba salvo esperar el correo de un cliente, y una infraestructura que existía solo en el historial de un terminal.
Ahora hay entrega automatizada: el código en GitHub con revisión obligatoria, Cloud Build construyendo, probando y publicando imágenes etiquetadas con el commit, promoción a producción con aprobación, y el pipeline de ML por fin en nivel 2 de madurez. Hay piezas por eventos que reaccionan solas, con idempotencia, reintentos y dead letter. Hay observabilidad completa: métricas, paneles, alertas que avisan en cinco minutos, comprobaciones desde cuatro continentes, logs estructurados, trazas distribuidas y un recorrido probado que va de la alerta a la línea de código en diecisiete minutos. Y hay infraestructura como código: versionada, revisada en pull requests y reproducible.
El sistema ya no depende de que dos personas se acuerden de ejecutar las cosas.
Lo que queda son las decisiones que se fueron aplazando, y ahora hay base para tomarlas. El catálogo sigue en GKE, aunque DA-001 decidió hace tiempo que su sitio es Cloud Run —y ahora que hay pipeline, observabilidad e infraestructura como código, ese movimiento es por fin viable—. Hay un almacén físico con sistemas propios que alguien tendrá que integrar. Las redes se quedaron en lo básico y hay conversaciones pendientes sobre VPC compartida y conectividad híbrida. La seguridad se construyó pieza a pieza y nunca se revisó en conjunto. La factura ha crecido con cada módulo y nadie la ha mirado con detenimiento. Se han montado alertas, pero no se ha definido qué significa "funcionar bien": no hay SLO ni presupuestos de error. Y la organización tiene ya tres proyectos, cuatro repositorios, decenas de cuentas de servicio y ninguna política que gobierne el conjunto.
En el módulo 7, temas avanzados, se resuelven todos: Anthos para el híbrido y el multinube, Cloud Run para llevar el catálogo a donde siempre debió estar, redes avanzadas con VPC compartida, emparejamiento y conectividad híbrida, seguridad revisada de arriba abajo, gestión de costes para que la factura deje de ser una sorpresa mensual, fiabilidad con SLO y presupuestos de error que conviertan las alertas de 06-04 en objetivos medibles, y gobierno a escala con políticas de organización y auditoría.
AlpinaShop ya construye, despliega y se observa sola. Ahora toca hacerla robusta, económica y gobernable.
Curso de Google Cloud Platform (GCP)
Módulo 1: Introducción a Google Cloud Platform
- ¿Qué es Google Cloud Platform?
- Configuración de tu cuenta de GCP
- Descripción general de la consola de GCP
- Proyectos, jerarquía de recursos y facturación
- Regiones, zonas y modelo de responsabilidad compartida
- Cloud Shell y la CLI de gcloud
Módulo 2: Servicios principales de GCP
- Compute Engine: máquinas virtuales en Google Cloud
- Cloud Storage: almacenamiento de objetos
- Cloud SQL: bases de datos relacionales gestionadas
- App Engine: plataforma como servicio
- Google Kubernetes Engine (GKE)
- Bases de datos NoSQL: Firestore, Bigtable y Spanner
- Cómo elegir el servicio de cómputo adecuado
Módulo 3: Redes y seguridad
- Redes VPC
- Balanceo de carga en la nube
- Cloud CDN
- Gestión de identidad y acceso (IAM)
- Cloud Armor
- Secretos y cifrado: Secret Manager y Cloud KMS
- Cloud DNS, certificados TLS y publicación segura de servicios
Módulo 4: Datos y análisis
- BigQuery: el almacén de datos analítico
- Cloud Dataflow: procesamiento de datos por lotes y en streaming
- Cloud Dataproc: Spark y Hadoop gestionados
- Cloud Pub/Sub: mensajería asíncrona
- Cloud Data Fusion: integración de datos sin código
- Orquestación de pipelines con Cloud Composer y Workflows
- Gobierno del dato y cuadros de mando con Dataplex y Looker Studio
Módulo 5: Aprendizaje automático e IA
- Vertex AI: la plataforma de machine learning de GCP
- AutoML: modelos a medida sin escribir código
- TensorFlow en GCP: entrenamiento y servicio de modelos
- API de lenguaje natural
- API de visión
- IA generativa en Vertex AI: modelos Gemini y embeddings
- MLOps: del modelo al producto con Vertex AI Pipelines
Módulo 6: DevOps y monitoreo
- Cloud Build: integración continua en GCP
- Cloud Source Repositories y gestión del código fuente
- Cloud Functions: funciones sin servidor
- Cloud Monitoring (antes Stackdriver): métricas, paneles y alertas
- Cloud Deployment Manager e infraestructura como código nativa
- Cloud Logging y Cloud Trace: logs, trazas y diagnóstico
- Terraform en GCP: infraestructura como código en la práctica
Módulo 7: Temas avanzados de GCP
- Híbrido y multinube con Anthos
- Computación sin servidor con Cloud Run
- Redes avanzadas: VPC compartida, peering y conectividad híbrida
- Mejores prácticas de seguridad
- Gestión y optimización de costos
- Fiabilidad: SLO, alta disponibilidad y recuperación ante desastres
- Gobierno a escala: organización, políticas y auditoría
