Lab práctico · Semana 10: Entrega e implementación — CI/CD, infraestructura como código, APIs y herramientas

Infraestructura como código con Terraform y estado remoto

⏱ 90-120 minDificultad: mediaApartados: 5.24.16.3

Qué vas a construir

Desde Cloud Shell, con Terraform, desplegarás una VPC con una subred, una regla de firewall para SSH vía IAP, una VM e2-micro sin IP pública y un bucket de Cloud Storage. El estado de Terraform vivirá en un bucket remoto con versionado, como en un equipo real. Practicarás el ciclo init → plan → apply, un cambio incremental, la detección de desviaciones (drift) y destroy.

flowchart LR
  CS["Cloud Shell: ficheros .tf"] -->|"terraform plan / apply"| API["APIs de Google Cloud"]
  CS -->|"lee y escribe el estado (con bloqueo)"| ST["gs://PROYECTO-tfstate (versionado)"]
  API --> VPC["vpc-lab20 + subred"]
  API --> FW["firewall: SSH desde IAP"]
  API --> VM["vm-lab20 (e2-micro, sin IP pública)"]
  API --> B["bucket de datos"]

Antes de empezar

  • Proyecto dedicado (por ejemplo pca-lab-20) con facturación.
  • Terraform viene instalado en Cloud Shell. Comprueba la versión:
terraform -version
  • Variables y APIs:
export PROJECT_ID=$(gcloud config get-value project)
export REGION=europe-southwest1
export BUCKET_ESTADO=${PROJECT_ID}-tfstate
gcloud services enable compute.googleapis.com storage.googleapis.com

Terraform en Cloud Shell usa tus credenciales de usuario a través de Application Default Credentials, así que no necesitas claves.

Paso 1: bucket para el estado remoto

El fichero de estado es el «inventario» de Terraform. En local se perdería al cerrar Cloud Shell o no lo podrían usar tus compañeros. Lo guardamos en Cloud Storage siguiendo las recomendaciones de Google: versionado (para recuperar estados anteriores), uniform bucket-level access y public access prevention.

gcloud storage buckets create gs://$BUCKET_ESTADO \
  --location=$REGION \
  --uniform-bucket-level-access \
  --public-access-prevention

gcloud storage buckets update gs://$BUCKET_ESTADO --versioning

Este bucket se crea fuera de Terraform a propósito: es el «huevo» del que nace todo lo demás.

Paso 2: escribe la configuración

mkdir -p ~/lab20 && cd ~/lab20

versions.tf: proveedor y backend. El bloque backend no admite variables, así que dejamos el nombre del bucket fuera y lo pasamos en init (partial configuration).

cat > versions.tf <<'EOF'
terraform {
  required_version = ">= 1.5"
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 8.0"
    }
  }
  backend "gcs" {
    prefix = "lab20"
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}
EOF

variables.tf:

cat > variables.tf <<'EOF'
variable "project_id" {
  type        = string
  description = "ID del proyecto"
}

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

variable "zone" {
  type    = string
  default = "europe-southwest1-a"
}
EOF

main.tf: los recursos.

cat > main.tf <<'EOF'
resource "google_compute_network" "vpc" {
  name                    = "vpc-lab20"
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "subred" {
  name                     = "subred-lab20"
  ip_cidr_range            = "10.20.0.0/24"
  region                   = var.region
  network                  = google_compute_network.vpc.id
  private_ip_google_access = true
}

# SSH solo desde el rango de IAP TCP forwarding
resource "google_compute_firewall" "ssh_iap" {
  name          = "permitir-ssh-iap"
  network       = google_compute_network.vpc.id
  source_ranges = ["35.235.240.0/20"]
  target_tags   = ["ssh"]

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

resource "google_compute_instance" "vm" {
  name         = "vm-lab20"
  machine_type = "e2-micro"
  zone         = var.zone
  tags         = ["ssh"]

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-12"
    }
  }

  # Sin bloque access_config: la VM no tiene IP pública
  network_interface {
    subnetwork = google_compute_subnetwork.subred.id
  }

  labels = {
    entorno = "lab"
  }
}

resource "google_storage_bucket" "datos" {
  name                        = "${var.project_id}-datos-lab20"
  location                    = var.region
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"
  force_destroy               = true # solo para el lab: permite borrar con objetos dentro
}
EOF

outputs.tf y terraform.tfvars:

cat > outputs.tf <<'EOF'
output "vm_ip_interna" {
  value = google_compute_instance.vm.network_interface[0].network_ip
}

output "bucket_datos" {
  value = google_storage_bucket.datos.url
}
EOF

echo "project_id = \"$PROJECT_ID\"" > terraform.tfvars

Fíjate en las referencias entre recursos (google_compute_network.vpc.id): Terraform deduce de ellas el orden de creación (primero la VPC, después la subred y la VM).

Paso 3: init, fmt y validate

terraform init -backend-config="bucket=$BUCKET_ESTADO"
terraform fmt
terraform validate

init descarga el proveedor google y configura el backend gcs. fmt normaliza el formato y validate comprueba la sintaxis; en un pipeline real ambos se ejecutan en cada pull request.

Paso 4: plan y apply

terraform plan -out=tfplan

Lee la salida: debe decir Plan: 5 to add, 0 to change, 0 to destroy. Los + son recursos nuevos y (known after apply) son valores que solo se sabrán al crearlos.

Guardar el plan con -out garantiza que apply hace exactamente lo que revisaste:

terraform apply tfplan

Mira el estado remoto y la lista de recursos gestionados:

gcloud storage ls gs://$BUCKET_ESTADO/lab20/
terraform state list
terraform output

Verás default.tfstate en el bucket. Mientras un apply está en curso aparece también un fichero .tflock: es el bloqueo que impide que dos personas modifiquen el estado a la vez.

Paso 5: cambio incremental

Añade una etiqueta a la VM. Edita main.tf (con nano main.tf o con el Cloud Shell Editor) y cambia el bloque labels:

  labels = {
    entorno = "lab"
    equipo  = "plataforma"
  }
terraform plan

Ahora el plan muestra ~ (modificación en el sitio, 1 to change). Hay cambios que Terraform no puede aplicar en el sitio y obligan a recrear el recurso (-/+, por ejemplo cambiar la imagen de arranque): léelo siempre antes de aplicar, porque recrear una VM significa perder su disco. Aplica:

terraform apply

(Sin plan guardado, apply calcula el plan y te pide confirmación: escribe yes.)

Paso 6: detecta una desviación (drift)

Alguien cambia la VM «a mano», fuera de Terraform:

gcloud compute instances update vm-lab20 --zone=europe-southwest1-a \
  --update-labels=equipo=manual
terraform plan

El plan detecta la diferencia y propone devolver la etiqueta al valor del código (plataforma). Es la razón por la que, con IaC, todos los cambios pasan por el código: lo manual se sobrescribe en el siguiente apply.

Comprueba que funciona

  • terraform output muestra la IP interna de la VM y la URL del bucket.
  • La VM no tiene IP externa: gcloud compute instances describe vm-lab20 --zone=europe-southwest1-a --format="value(networkInterfaces[0].accessConfigs)" sale vacío.
  • Puedes entrar por IAP: gcloud compute ssh vm-lab20 --zone=europe-southwest1-a --tunnel-through-iap (si te lo pide, habilita la API de IAP con gcloud services enable iap.googleapis.com).
  • En el bucket de estado, gcloud storage ls -a gs://$BUCKET_ESTADO/lab20/ muestra varias generaciones de default.tfstate gracias al versionado.

Limpieza

Primero destruye lo gestionado por Terraform y después el bucket del estado:

cd ~/lab20
terraform destroy
gcloud storage rm -r gs://$BUCKET_ESTADO

destroy muestra un plan con 5 to destroy y pide confirmación. El segundo comando borra el bucket del estado con todas sus versiones. Comprueba que no queda nada con gcloud compute instances list y gcloud storage ls.

Preguntas para pensar como arquitecto

¿Por qué no se debe guardar el fichero de estado en Git?

Porque puede contener secretos en claro (contraseñas generadas, claves), porque Git no ofrece bloqueo (dos apply simultáneos lo corromperían) y porque es fácil trabajar con una copia desactualizada. El backend gcs resuelve las tres cosas: control de acceso con IAM (y cifrado con CMEK si hace falta), bloqueo y una única fuente de verdad, con versionado para recuperarse de errores.

Tu organización tiene 40 equipos que necesitan redes con la misma configuración de seguridad. ¿Cómo lo organizas?

Con un módulo de Terraform versionado (por ejemplo, basado en los módulos del Cloud Foundation Toolkit) que encapsula las buenas prácticas, publicado en un repositorio interno o en un catálogo (Application Design Center). Cada equipo lo usa con sus variables, en estados separados por equipo y entorno. Las políticas de organización y la validación con gcloud beta terraform vet en el pipeline impiden desviarse de lo permitido.

¿Cuándo recomendarías Infrastructure Manager en lugar de ejecutar Terraform en Cloud Shell o en un pipeline propio?

Cuando quieres Terraform sin gestionar los ejecutores ni el estado: Infrastructure Manager ejecuta Terraform en Cloud Build, guarda el estado y las revisiones en Cloud Storage, ofrece previews y deja registro de cada despliegue con IAM de Google Cloud. Un pipeline propio (Cloud Build o GitHub Actions con Workload Identity Federation) tiene sentido si ya tienes ese flujo y necesitas más control.

En el paso 6, ¿qué habría pasado si el cambio manual hubiera sido crítico (un parche urgente durante un incidente)?

El siguiente apply lo habría revertido sin avisar a nadie. La buena práctica es llevar cualquier cambio de emergencia al código lo antes posible (o hacerlo directamente vía pipeline) y revisar el plan en busca de desviaciones antes de aplicar. Algunas organizaciones ejecutan terraform plan periódicamente solo para detectar drift y alertar.


Volver al módulo