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
- Cuenta de GCP con billing habilitado
- OpenTofu instalado
- gcloud CLI instalado y autenticado
- kubectl instalado
gcloud auth application-default loginEstructura 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 applyUna vez deployado, conectarse al cluster:
# El comando exacto lo da el output
tofu output -raw connect | bashVerificar que funciona:
kubectl get nodesResumen 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 destroyCon 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.
Links útiles
- Google Kubernetes Engine — página oficial de GKE
- GKE Pricing — detalle de costos, free tier y créditos
- GKE Dataplane V2 (Cilium/eBPF) — cómo funciona
ADVANCED_DATAPATH - Spot VMs en GCP — pricing y limitaciones de instancias Spot
- Workload Identity — autenticación de pods con APIs de GCP
- OpenTofu — documentación del fork open source de Terraform
- Google Provider (Terraform Registry) — referencia de los recursos usados en este post