GKE de bajo costo con OpenTofu

Cómo levantar un cluster de Kubernetes en GCP gastando lo mínimo posible, con OpenTofu, paso a paso.
Kubernetes
GCP
OpenTofu
IaC
Published

March 7, 2026

Kubernetes en la nube puede ser caro. Pero con las opciones correctas, se puede tener un cluster de GKE funcional gastando casi nada — ideal para labs, proyectos personales o ambientes de desarrollo.

En este post vamos a levantar un cluster paso a paso con OpenTofu (el fork open source de Terraform), explicando qué significa cada config y por qué la elegimos. Está basado en el repo Neutrollized/free-tier-gke, con modificaciones para simplificarlo. Todo el código está en emimartin26/gke-lab.

Prerequisitos

gcloud auth application-default login

Estructura del proyecto

iac/
├── provider.tf      # versiones y configuración de providers
├── variables.tf     # inputs del módulo
├── terraform.tfvars # valores concretos
├── data.tf          # data sources (rangos de IP de health checks)
├── network.tf       # VPC y subred
├── iam.tf           # service accounts y permisos
├── main.tf          # cluster y node pool
└── outputs.tf       # valores útiles post-deploy

Provider

terraform {
  required_version = ">= 1.6"
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 7.11"
    }
    google-beta = {
      source  = "hashicorp/google-beta"
      version = "~> 7.11"
    }
  }
}

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

provider "google-beta" {
  project = var.project_id
  region  = var.region
  zone    = var.zone
}

Usamos dos providers: google para los recursos estables y google-beta para features que todavía no llegaron a GA, como el endpoint DNS del control plane.

Variables

variable "project_id" {
  type = string
}

variable "cluster_name" {
  type    = string
  default = "gke-lab"
}

variable "region" {
  type    = string
  default = "us-central1"
}

variable "zone" {
  type    = string
  default = "us-central1-c"
}

Y el terraform.tfvars con los valores reales:

project_id   = "mi-proyecto-gcp"
cluster_name = "gke-lab"
region       = "us-central1"
zone         = "us-central1-c"

La elección de zona es clave para el costo: GKE otorga $74.40/mes en créditos por cuenta de billing, lo que cubre la tarifa de management (~$72/mes) de un cluster zonal. Es decir, tu primer cluster zonal sale prácticamente gratis. Los clusters regionales cuestan lo mismo pero los créditos no llegan a cubrirlos.

Red

resource "google_compute_network" "k8s" {
  name                    = "${var.cluster_name}-vpc"
  auto_create_subnetworks = false  # creamos la subred manualmente
}

resource "google_compute_subnetwork" "k8s" {
  name                     = "${var.cluster_name}-subnet"
  ip_cidr_range            = "192.168.0.0/24"  # hasta 254 nodos
  network                  = google_compute_network.k8s.id
  private_ip_google_access = true  # acceso a APIs de GCP sin IP pública
  region                   = var.region
}

# Data source: rangos de IP oficiales de los health checkers de GCP
data "google_netblock_ip_ranges" "health_checkers" {
  range_type = "health-checkers"
}

resource "google_compute_firewall" "lb_health_check" {
  name          = "${var.cluster_name}-allow-health-check"
  network       = google_compute_network.k8s.name
  direction     = "INGRESS"
  source_ranges = data.google_netblock_ip_ranges.health_checkers.cidr_blocks_ipv4

  allow {
    protocol = "tcp"
  }
}

Preferimos una VPC dedicada en lugar de usar la default. El data source google_netblock_ip_ranges trae los rangos de IP oficiales de GCP, y el firewall los usa para que los load balancers puedan verificar que los pods están vivos.

IAM

GKE necesita una service account para los nodos. En lugar de usar la SA por defecto (que tiene permisos de más), creamos una con el mínimo necesario:

resource "google_service_account" "gke_nodes" {
  account_id   = "${var.cluster_name}-nodes-sa"
  display_name = "GKE node pool service account"
}

resource "google_project_iam_member" "gke_nodes" {
  for_each = toset([
    "roles/container.defaultNodeServiceAccount",  # base para nodos GKE
    "roles/artifactregistry.reader",               # pull de imágenes
    "roles/stackdriver.resourceMetadata.writer",   # métricas y logs
  ])

  project = var.project_id
  role    = each.value
  member  = "serviceAccount:${google_service_account.gke_nodes.email}"
}

También creamos una SA para Workload Identity — la forma correcta de darle acceso a APIs de GCP a los pods, sin hardcodear credentials:

resource "google_service_account" "wi_gsa" {
  account_id   = "${var.cluster_name}-wi-gsa"
  display_name = "Workload Identity GSA"
}

resource "google_project_iam_member" "wi_gsa" {
  for_each = toset([
    "roles/cloudtrace.agent",
    "roles/monitoring.metricWriter",
    "roles/cloudprofiler.agent",
  ])

  project = var.project_id
  role    = each.value
  member  = "serviceAccount:${google_service_account.wi_gsa.email}"
}

# Le permite al ServiceAccount de K8s "default/default" usar esta GSA
resource "google_service_account_iam_member" "wi_binding" {
  service_account_id = google_service_account.wi_gsa.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "serviceAccount:${var.project_id}.svc.id.goog[default/default]"
}

Una vez que el cluster esté levantado, hay que anotar el KSA (Kubernetes Service Account) para que use la GSA:

kubectl annotate serviceaccount default \
  iam.gke.io/gcp-service-account=$(tofu output -raw wi_gsa_email)

Con esto, cualquier pod que corra con el service account default del namespace default se autentica con GCP usando la GSA, sin necesidad de montar secrets ni credenciales.

El cluster

Esta es la parte más importante. Cada opción tiene un impacto en costo o seguridad:

resource "google_container_cluster" "primary" {
  provider = google-beta

  name     = var.cluster_name
  location = var.zone  # zonal = cubierto por créditos free tier

  # GKE crea un node pool por defecto que no podemos configurar bien.
  # La solución: crearlo y borrarlo inmediatamente, y usar el nuestro.
  remove_default_node_pool = true
  initial_node_count       = 1

  network    = google_compute_network.k8s.id
  subnetwork = google_compute_subnetwork.k8s.id

  # VPC-native: los pods obtienen IPs reales de la VPC.
  # Requerido para la mayoría de las features modernas de GKE.
  networking_mode = "VPC_NATIVE"
  ip_allocation_policy {
    cluster_ipv4_cidr_block  = "10.0.0.0/16"  # IPs para pods (~65k)
    services_ipv4_cidr_block = "10.1.0.0/20"  # IPs para services (~4k)
  }

  # El control plane no tiene IP pública, pero es accesible vía DNS.
  # Esto evita exponer la API de K8s a internet sin necesitar VPN.
  private_cluster_config {
    enable_private_endpoint = true
    enable_private_nodes    = false  # nodos con IP pública, sin NAT
  }

  control_plane_endpoints_config {
    dns_endpoint_config {
      allow_external_traffic = true  # acceso vía gcloud + IAM, sin VPN
    }
  }

  master_authorized_networks_config {}

  # Cilium/eBPF en lugar de iptables: mejor performance y network policies integradas
  datapath_provider = "ADVANCED_DATAPATH"

  # Workload Identity: los pods se autentican con GCP sin credenciales hardcodeadas
  workload_identity_config {
    workload_pool = "${var.project_id}.svc.id.goog"
  }

  enable_shielded_nodes = true  # verificación criptográfica de identidad del nodo

  release_channel {
    channel = "REGULAR"  # upgrades automáticos a versiones estables de K8s
  }

  maintenance_policy {
    daily_maintenance_window {
      start_time = "03:00"  # UTC — cuando hay menos tráfico
    }
  }

  addons_config {
    horizontal_pod_autoscaling {
      disabled = false  # escala réplicas según CPU/memoria
    }
    http_load_balancing {
      disabled = false  # crea load balancers de GCP desde recursos Ingress
    }
  }

  deletion_protection = false  # útil en labs; en prod debería ser true
}

Node pool

resource "google_container_node_pool" "primary" {
  provider = google-beta

  name     = "main"
  location = var.zone
  cluster  = google_container_cluster.primary.id

  initial_node_count = 1
  autoscaling {
    min_node_count = 1
    max_node_count = 3
  }

  management {
    auto_repair  = true  # GKE reemplaza nodos unhealthy automáticamente
    auto_upgrade = true  # mantiene la versión del nodo en sync con el control plane
  }

  node_config {
    spot         = true         # ~60% más barato que on-demand, pueden ser preemptados
    machine_type = "e2-medium"  # 2 vCPU, 4 GB RAM — suficiente para un lab
    disk_size_gb = 50
    image_type   = "COS_CONTAINERD"

    shielded_instance_config {
      enable_secure_boot          = true
      enable_integrity_monitoring = true
    }

    service_account = google_service_account.gke_nodes.email
    oauth_scopes = [
      "https://www.googleapis.com/auth/devstorage.read_only",
      "https://www.googleapis.com/auth/logging.write",
      "https://www.googleapis.com/auth/monitoring",
      "https://www.googleapis.com/auth/servicecontrol",
      "https://www.googleapis.com/auth/service.management.readonly",
      "https://www.googleapis.com/auth/trace.append",
    ]

    metadata = {
      disable-legacy-endpoints = "true"  # bloquea SSRF vía la vieja metadata API
    }

    kubelet_config {
      insecure_kubelet_readonly_port_enabled = "FALSE"  # cierra el puerto 10255 sin auth
    }

    workload_metadata_config {
      mode = "GKE_METADATA"  # los pods ven el metadata server de GKE, no las credenciales de la VM
    }
  }
}

El truco más importante para el costo: spot = true. Los nodos Spot son instancias que GCP puede recuperar con 30 segundos de aviso, pero cuestan hasta un 60% menos. Para un lab o ambiente de desarrollo, es perfectamente aceptable.

Outputs

output "connect" {
  description = "Comando para configurar kubectl"
  value       = "gcloud container clusters get-credentials ${var.cluster_name} --location ${var.zone} --project ${var.project_id} --dns-endpoint"
}

output "wi_gsa_email" {
  description = "Email del GSA para Workload Identity"
  value       = google_service_account.wi_gsa.email
}

Levantar el cluster

cd iac/

# Inicializar providers
tofu init

# Ver qué va a crear
tofu plan

# Aplicar
tofu apply

Una vez deployado, conectarse al cluster:

# El comando exacto lo da el output
tofu output -raw connect | bash

Verificar que funciona:

kubectl get nodes

Resumen de decisiones de costo

Decisión Ahorro
Cluster zonal Management cubierto por créditos ($74.40/mes)
Nodos Spot ~60% vs on-demand
e2-medium Mínimo para GKE funcional
Sin NAT Gateway Sin costo de Cloud NAT
Sin cluster regional Sin redundancia multi-zona (aceptable en labs)

Para destruir el cluster cuando no lo usás:

tofu destroy

Con esta configuración, un nodo e2-medium spot en us-central1 cuesta alrededor de ~$18/mes. Si destruís el cluster entre sesiones con tofu destroy, el costo baja proporcionalmente.