Serie: De Cero a Kubernetes — Parte 1 · Parte 2 · Parte 3 · Parte 4 · Parte 5
Esta es la primera parte de una guía de 5 entregas donde vamos a construir, desde cero absoluto, un cluster Kubernetes completo con Terraform, desplegar un backend con base de datos, dos frontends independientes, y gestionarlo todo con GitOps usando ArgoCD.
No es un tutorial de "haz clic aquí y cruza los dedos". Vamos a entender cada pieza. Al final de la serie tendrás un cluster real corriendo en la nube, con TLS automático, deployments reproducibles, y todo el código versionado en GitHub.
Lo que construiremos en esta Parte 1:
- 3 VMs (1 control-plane + 2 workers) en Hetzner Cloud con Terraform
- Red privada entre VMs, firewalls con puertos Kubernetes, SSH keys
- containerd como container runtime en cada nodo
-
kubeadm initen el control-plane,kubeadm joinen los workers - Calico como CNI (Container Network Interface)
- Todo automatizado vía cloud-init — en 5 minutos tienes cluster
¿Por qué kubeadm y no un servicio managed?
EKS, GKE y AKS son excelentes. Pero cuando usas un managed service, no tocas el control-plane, no ves etcd, no configuras el CNI, no entiendes qué pasa cuando un nodo no se une al cluster.
kubeadm es la herramienta oficial de Kubernetes para bootstrappear clusters. Es lo que usan los ingenieros que construyen plataformas. Al terminar esta serie, sabrás exactamente qué hace cada componente y podrás reproducir este cluster en cualquier proveedor: Hetzner, AWS EC2, GCP Compute Engine, o tus propias máquinas físicas.
Arquitectura de infraestructura
┌──────────────────────────────────────────────────────┐
│ Hetzner Cloud │
│ │
│ ┌─────────────────────┐ ┌──────────┐ ┌──────────┐ │
│ │ control-plane │ │ worker-0 │ │ worker-1 │ │
│ │ (CX21, 2vCPU/4GB) │ │(CX21) │ │(CX21) │ │
│ │ │ │ │ │ │ │
│ │ api-server │ │ kubelet │ │ kubelet │ │
│ │ etcd │ │ containerd│ │ containerd│ │
│ │ controller-manager │ │ Calico │ │ Calico │ │
│ │ scheduler │ │ │ │ │ │
│ │ kubelet │ │ │ │ │ │
│ │ containerd │ │ │ │ │ │
│ │ Calico │ │ │ │ │ │
│ └────────┬────────────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └────────────────────┼─────────────┘ │
│ │ │
│ ┌───────────▼───────────┐ │
│ │ Red privada │ │
│ │ (10.0.0.0/16) │ │
│ └───────────────────────┘ │
│ │
│ Firewall: SSH(22) + k8s API(6443) + NodePorts │
│ + Calico networking (179/tcp, 4789/udp) │
└──────────────────────────────────────────────────────┘
Estructura del proyecto Terraform
Creamos un directorio infra/ en nuestro monorepo:
infra/
├── main.tf # Providers, recursos principales
├── variables.tf # Variables de entrada
├── outputs.tf # Outputs: IPs, kubeconfig
├── terraform.tfvars # Valores (no commitear secretos)
├── versions.tf # Versiones de providers
├── cloud-init/
│ ├── control-plane.yaml
│ └── worker.yaml
└── .gitignore
Paso 1: Configurar providers y variables
versions.tf — Bloqueamos versiones para reproducibilidad:
terraform {
required_version = ">= 1.9.0"
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.47"
}
tls = {
source = "hashicorp/tls"
version = "~> 4.0"
}
}
}
main.tf — El esqueleto:
provider "hcloud" {
token = var.hcloud_token
}
# Red privada
resource "hcloud_network" "k8s" {
name = "k8s-network"
ip_range = "10.0.0.0/16"
}
resource "hcloud_network_subnet" "k8s" {
network_id = hcloud_network.k8s.id
type = "cloud"
network_zone = "eu-central"
ip_range = "10.0.1.0/24"
}
Paso 2: SSH keys
resource "tls_private_key" "k8s" {
algorithm = "ED25519"
}
resource "hcloud_ssh_key" "k8s" {
name = "k8s-cluster-key"
public_key = tls_private_key.k8s.public_key_openssh
}
Terraform guarda la clave privada en el state. Puedes extraerla con terraform output -raw ssh_private_key.
Paso 3: Firewall con los puertos exactos de Kubernetes
No abras todo. Kubernetes necesita puertos específicos:
resource "hcloud_firewall" "k8s" {
name = "k8s-firewall"
# SSH desde cualquier IP (o tu IP fija para producción)
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = ["0.0.0.0/0"]
}
# Kubernetes API (control-plane)
rule {
direction = "in"
protocol = "tcp"
port = "6443"
source_ips = ["0.0.0.0/0"]
}
# NodePort range (30000-32767 para ingress y servicios)
rule {
direction = "in"
protocol = "tcp"
port = "30000-32767"
source_ips = ["0.0.0.0/0"]
}
# Calico BGP (179/tcp) y VXLAN (4789/udp)
rule {
direction = "in"
protocol = "tcp"
port = "179"
source_ips = ["10.0.0.0/16"]
}
rule {
direction = "in"
protocol = "udp"
port = "4789"
source_ips = ["10.0.0.0/16"]
}
# Comunicación interna completa entre nodos del cluster
rule {
direction = "in"
protocol = "tcp"
port = "10250-10256"
source_ips = ["10.0.0.0/16"]
}
}
Explicación de cada puerto:
-
6443→ API server.kubectlykubeadmhablan con este endpoint -
30000-32767→ Rango NodePort. El ingress nginx se expone por aquí mientras no tengamos LoadBalancer -
179/tcp→ BGP de Calico para compartir rutas entre nodos -
4789/udp→ VXLAN de Calico para encapsular tráfico de pods entre nodos -
10250→ Kubelet API (logs, exec, métricas) -
10256→ kube-proxy health check -
10257-10259→ Controller manager y scheduler (solo en control-plane)
Paso 4: Las VMs — el corazón del cluster
# Control-plane
resource "hcloud_server" "control_plane" {
name = "k8s-control-plane"
server_type = "cx21"
image = "ubuntu-24.04"
location = "nbg1"
ssh_keys = [hcloud_ssh_key.k8s.id]
firewall_ids = [hcloud_firewall.k8s.id]
network {
network_id = hcloud_network.k8s.id
ip = "10.0.1.10"
}
# cloud-init ejecuta todo el bootstrap al primer boot
user_data = templatefile("${path.module}/cloud-init/control-plane.yaml", {
pod_network_cidr = "192.168.0.0/16"
})
depends_on = [hcloud_network_subnet.k8s]
}
# Workers
resource "hcloud_server" "worker" {
count = 2
name = "k8s-worker-${count.index}"
server_type = "cx21"
image = "ubuntu-24.04"
location = "nbg1"
ssh_keys = [hcloud_ssh_key.k8s.id]
firewall_ids = [hcloud_firewall.k8s.id]
network {
network_id = hcloud_network.k8s.id
ip = "10.0.1.${20 + count.index}"
}
user_data = templatefile("${path.module}/cloud-init/worker.yaml", {
control_plane_ip = "10.0.1.10"
# El token se genera en el cloud-init del control-plane
# y se comparte vía output de Terraform
})
depends_on = [hcloud_server.control_plane]
}
Paso 5: cloud-init — la magia que automatiza todo
Aquí es donde sucede la instalación real de Kubernetes. cloud-init ejecuta estos scripts en el primer boot de cada VM.
cloud-init/control-plane.yaml:
#cloud-config
hostname: control-plane
package_update: true
package_upgrade: true
packages:
- containerd
- apt-transport-https
- ca-certificates
- curl
- gnupg
write_files:
# Configuración de containerd con systemd como cgroup driver
- path: /etc/containerd/config.toml
permissions: '0644'
content: |
version = 2
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
SystemdCgroup = true
# Modprobe overlay y br_netfilter para networking de pods
- path: /etc/modules-load.d/k8s.conf
content: |
overlay
br_netfilter
# Parámetros de kernel para networking
- path: /etc/sysctl.d/k8s.conf
content: |
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
runcmd:
# 1. Cargar módulos de kernel y sysctl
- modprobe overlay
- modprobe br_netfilter
- sysctl --system
# 2. Desactivar swap (Kubernetes lo requiere)
- swapoff -a
- sed -i '/swap/d' /etc/fstab
# 3. Reiniciar containerd con la nueva config
- systemctl restart containerd
- systemctl enable containerd
# 4. Agregar repo de Kubernetes y instalar kubeadm, kubelet, kubectl
- |
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.31/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.31/deb/ /" > /etc/apt/sources.list.d/kubernetes.list
apt-get update
apt-get install -y kubelet kubeadm kubectl
apt-mark hold kubelet kubeadm kubectl
# 5. Inicializar el cluster
- |
kubeadm init \
--pod-network-cidr=${pod_network_cidr} \
--apiserver-advertise-address=10.0.1.10 \
--upload-certs \
| tee /root/kubeadm-init.log
# 6. Configurar kubeconfig para root
- mkdir -p /root/.kube
- cp /etc/kubernetes/admin.conf /root/.kube/config
# 7. Instalar Calico
- |
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.1/manifests/tigera-operator.yaml --kubeconfig=/etc/kubernetes/admin.conf
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.1/manifests/custom-resources.yaml --kubeconfig=/etc/kubernetes/admin.conf
# 8. Generar token de join para workers y guardarlo
- kubeadm token create --print-join-command > /root/join-command.sh
- chmod 644 /root/join-command.sh
final_message: "Control-plane bootstrap completado en $UPTIME segundos"
cloud-init/worker.yaml:
#cloud-config
hostname: worker
package_update: true
package_upgrade: true
packages:
- containerd
- apt-transport-https
- ca-certificates
- curl
- gnupg
write_files:
- path: /etc/containerd/config.toml
permissions: '0644'
content: |
version = 2
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
SystemdCgroup = true
- path: /etc/modules-load.d/k8s.conf
content: |
overlay
br_netfilter
- path: /etc/sysctl.d/k8s.conf
content: |
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
runcmd:
- modprobe overlay
- modprobe br_netfilter
- sysctl --system
- swapoff -a
- sed -i '/swap/d' /etc/fstab
- systemctl restart containerd
- systemctl enable containerd
# Instalar kubeadm, kubelet, kubectl
- |
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.31/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.31/deb/ /" > /etc/apt/sources.list.d/kubernetes.list
apt-get update
apt-get install -y kubelet kubeadm kubectl
apt-mark hold kubelet kubeadm kubectl
# Esperar que el join-command esté disponible
# (esto se resuelve con el script de abajo)
final_message: "Worker bootstrap completado en $UPTIME segundos. Ejecuta join manual luego del init del control-plane."
Paso 6: Unir los workers al cluster
Como el join-command se genera en el control-plane durante el bootstrap, necesitamos que los workers lo obtengan después. Usamos un recurso null_resource en Terraform o lo hacemos manual la primera vez:
# Desde tu máquina local, después del terraform apply:
scp root@<control-plane-ip>:/root/join-command.sh .
scp join-command.sh root@<worker-0-ip>:/root/
ssh root@<worker-0-ip> "bash /root/join-command.sh"
# Repetir para worker-1
En producción esto se automatiza con Vault o un bucket S3 compartido. Para el tutorial, este paso manual de 2 minutos es suficiente y te deja ver exactamente qué pasa.
Paso 7: Verificación
$ terraform apply
# ... (2-3 minutos para crear VMs + cloud-init)
$ ssh root@<control-plane-ip>
root@control-plane:~# kubectl get nodes
NAME STATUS ROLES AGE VERSION
k8s-control-plane Ready control-plane 5m v1.31.0
k8s-worker-0 Ready <none> 2m v1.31.0
k8s-worker-1 Ready <none> 2m v1.31.0
root@control-plane:~# kubectl get pods -A
NAMESPACE NAME READY STATUS RESTARTS AGE
calico-system calico-kube-controllers-xxxxx 1/1 Running 0 4m
calico-system calico-node-xxxxx 1/1 Running 0 4m
calico-system calico-node-xxxxx 1/1 Running 0 2m
calico-system calico-node-xxxxx 1/1 Running 0 2m
kube-system coredns-xxxxx 1/1 Running 0 5m
kube-system coredns-xxxxx 1/1 Running 0 5m
kube-system etcd-control-plane 1/1 Running 0 5m
kube-system kube-apiserver-control-plane 1/1 Running 0 5m
kube-system kube-controller-manager-control-plane 1/1 Running 0 5m
kube-system kube-proxy-xxxxx 1/1 Running 0 5m
kube-system kube-scheduler-control-plane 1/1 Running 0 5m
Tres nodos Ready, todos los pods del sistema corriendo. Cluster listo.
Outputs de Terraform
output "control_plane_ip" {
value = hcloud_server.control_plane.ipv4_address
}
output "worker_ips" {
value = [for w in hcloud_server.worker : w.ipv4_address]
}
output "ssh_private_key" {
value = tls_private_key.k8s.private_key_pem
sensitive = true
}
output "kubeconfig" {
value = "${path.module}/kubeconfig.yaml" # lo copiamos con un provisioner
sensitive = true
}
Costos reales
| Recurso | Precio mensual |
|---|---|
| 3× CX21 (2vCPU, 4GB RAM, 40GB SSD) | €11.97 |
| Red privada | €0.00 |
| Firewall | €0.00 |
| Tráfico (20TB incluido) | €0.00 |
| Total | ≈ €12/mes |
Menos de $13 USD al mes por un cluster Kubernetes real con control-plane dedicado. Compáralo con EKS que cobra $0.10/hora solo por el control-plane ($72/mes) más las VMs aparte.
Qué aprendiste en esta parte
- Estructura de un proyecto Terraform profesional para Kubernetes
- Puertos exactos que Kubernetes necesita (y por qué cada uno)
- cloud-init como herramienta de automatización de bootstrap
- containerd como container runtime (no Docker — Kubernetes deprecó Docker en v1.24)
- kubeadm init y los flags críticos (pod-network-cidr, advertise-address)
- Calico como CNI para networking de pods entre nodos
- El concepto de control-plane + workers y qué corre en cada uno
En la Parte 2, tomaremos este cluster vacío y lo convertiremos en una plataforma lista para producción: namespaces, ingress controller con TLS automático, storage persistente, y RBAC. Todo con Terraform, sin un solo kubectl apply manual.
En Guayoyo Tech diseñamos y desplegamos arquitecturas Kubernetes como esta para empresas reales. Terraform, kubeadm, GitOps, CI/CD — lo armamos contigo, sin humo y sin vendor lock-in. Si quieres que tu infraestructura se parezca a lo que acabas de leer, hablemos gratis 15 minutos. Tu cluster, tu nube, tus reglas — nosotros ponemos la ingeniería.

Top comments (0)