Kubernetes Production-Grade en un Sol Servidor: La Guia Completa
Per què fer això
Un sol servidor. Tot l’stack de producció. Sense excuses.
La idea és simple: muntar un cluster Kubernetes que porti el mateix stack que faries servir a AKS o EKS — service mesh, TLS automàtic, observabilitat, seguretat en capes — però en el teu propi maquinari. No és un minikube. No és un laboratori de proves. És un entorn que serveix workloads reals a usuaris reals.
Per què un sol node? Perquè per a projectes personals, side projects, i experimentació seriosa, un servidor de 15-30€/mes et dona més que suficient. I l’experiència d’operar aquest stack és transferible directament a clusters multi-node al cloud.
El resultat final: un cluster on desplegar una app nova és crear un namespace, etiquetar-lo per al service mesh, crear un HTTPRoute, i fer kubectl apply. TLS automàtic, DNS automàtic, mètriques, logs, i seguretat — tot ja hi és.
El servidor i requisits previs
El que necessites:
- Servidor amb Ubuntu 22.04+ — mínim 4 vCPU i 8GB de RAM. Amb tot l’stack corrent, el consum base ronda els 4-5GB de RAM. Si has de córrer workloads a sobre, 16GB és més còmode.
- cgroup v2 — Ubuntu 22.04+ el porta habilitat per defecte. Verifica amb
stat -fc %T /sys/fs/cgroup(ha de retornarcgroup2fs). - Dominis a Cloudflare — necessaris per al DNS-01 challenge (wildcard certs) i External DNS (creació automàtica de registres A).
- API Token de Cloudflare amb permisos
Zone:DNS:Edit+Zone:Zone:Readper a totes les zones que faràs servir. - Tailscale instal·lat — per a accés segur a l’API Server.
L’API Server de Kubernetes (port 6443) mai ha d’estar exposat a internet. Fem servir Tailscale com a VPN per accedir des de fora, i nftables per filtrar per interfície de xarxa. Si algú arriba al teu 6443, té les claus del regne.
Base: kubeadm + containerd
Preparar el sistema
Primer, els mòduls del kernel i paràmetres de xarxa que Kubernetes necessita:
# Módulos del kernel
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter
# Parámetros de red
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sudo sysctl --system
Instal·lar containerd
sudo apt-get update
sudo apt-get install -y containerd
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml
# Habilitar SystemdCgroup — crítico para cgroup v2
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl restart containerd
sudo systemctl enable containerd
Si no habilites SystemdCgroup = true, kubelet i containerd faran servir drivers de cgroup diferents. El resultat: pods que moren aleatòriament, kubelet que es reinicia, i hores de debugging. És l’error més comú en instal·lacions amb kubeadm.
Instal·lar kubeadm, kubelet, kubectl
sudo apt-get install -y apt-transport-https ca-certificates curl gpg
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.32/deb/Release.key | \
sudo 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.32/deb/ /' | \
sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl
Inicialitzar el cluster
sudo kubeadm init \
--pod-network-cidr=10.244.0.0/16 \
--service-cidr=10.96.0.0/16 \
--skip-phases=addon/kube-proxy
Saltem kube-proxy amb --skip-phases=addon/kube-proxy perquè Calico en mode eBPF el reemplaça completament. eBPF gestiona el balanceig de càrrega de serveis directament al kernel, sense les cadenes d’iptables que kube-proxy genera. Millor rendiment, menys complexitat.
Després de l’init, configura l’accés i elimina el taint del node master:
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
# Permitir que el nodo master ejecute workloads
kubectl taint nodes --all node-role.kubernetes.io/control-plane-
En un cluster d’un sol node, aquest últim pas és obligatori. Sense ell, res es programa al teu únic node.
CNI: Calico amb eBPF
Per què Calico eBPF
Calico amb eBPF et dona tres coses que el mode iptables no:
- Reemplaça kube-proxy — balanceig de càrrega de serveis en eBPF, sense iptables
- Millor rendiment — especialment amb molts serveis, on les cadenes d’iptables es tornen lineals
- Visibilitat de xarxa nativa — Calico pot fer servir les dades d’eBPF per a polítiques i mètriques
Instal·lar el Tigera Operator
helm repo add projectcalico https://docs.tigera.io/calico/charts
helm repo update
kubectl create namespace tigera-operator
helm install calico projectcalico/tigera-operator \
--version v3.29.2 \
--namespace tigera-operator
Ara ve un gotcha real: necessites esperar que l’operator registri els CRDs abans d’aplicar el recurs d’Installation. Si apliques el CR immediatament, falla silenciosament o el recurs es queda en un estat inconsistent.
# Esperar a que los CRDs estén registrados
kubectl wait --for=condition=Established \
crd/installations.operator.tigera.io \
--timeout=120s
Aplicar l’Installation CR amb eBPF
# calico-installation.yaml
apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
name: default
spec:
calicoNetwork:
ipPools:
- name: default-ipv4-ippool
blockSize: 26
cidr: 10.244.0.0/16
encapsulation: VXLANCrossSubnet
natOutgoing: Enabled
nodeSelector: all()
linuxDataplane: BPF
hostPorts: Enabled
cni:
type: Calico
controlPlaneReplicas: 1
---
apiVersion: operator.tigera.io/v1
kind: APIServer
metadata:
name: default
spec: {}
kubectl apply -f calico-installation.yaml
Amb linuxDataplane: BPF, Calico opera enterament en eBPF. Si tenies kube-proxy instal·lat (que no és el cas si has seguit els passos anteriors), hauries de parxejar el seu DaemonSet perquè no es programi:
# Solo si kube-proxy está instalado
kubectl patch ds -n kube-system kube-proxy \
-p '{"spec":{"template":{"spec":{"nodeSelector":{"non-calico": "true"}}}}}'
Service Mesh: Istio Ambient Mode
Per què ambient i no sidecar
El mode sidecar injecta un proxy Envoy a cada pod. Funciona, però té overhead: cada pod consumeix més memòria, l’startup és més lent, i hi ha tota una categoria de bugs relacionats amb l’ordre d’inici del sidecar vs. l’aplicació.
El mode ambient canvia el model: un daemon ztunnel per node gestiona mTLS i L4 automàticament per a tots els pods del namespace. Si necessites funcionalitats L7 (retries, timeouts, circuit breaking, observabilitat HTTP), despliegues un waypoint proxy compartit per namespace. Només pagues el cost de L7 on el necessites.
Instal·lar Istio
istioctl install --set profile=ambient -y
Per habilitar ambient en un namespace d’aplicació:
kubectl label namespace la-meva-app istio.io/dataplane-mode=ambient
Per afegir un waypoint proxy compartit (L7):
istioctl waypoint apply --namespace la-meva-app --enroll-namespace
No apliquis ambient a namespaces d’infraestructura com kube-system, calico-system, tigera-operator, istio-system, o monitoring. El service mesh és per als teus workloads d’aplicació. Els components d’infraestructura tenen els seus propis mecanismes de seguretat i ficar-los al mesh només introdueix problemes.
Ingress: Gateway API + cert-manager + MetalLB
Aquesta és la capa que fa que els teus serveis siguin accessibles des d’internet amb TLS automàtic. Quatre components treballant junts.
MetalLB
Al cloud, un Service de tipus LoadBalancer obté una IP externa del proveïdor. En bare metal, necessites MetalLB per a això.
helm repo add metallb https://metallb.github.io/metallb
helm repo update
helm install metallb metallb/metallb \
--namespace metallb-system \
--create-namespace
Espera que els pods estiguin ready i després configura el pool d’IPs. En un sol servidor, el pool és la IP pública del servidor:
# metallb-config.yaml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default-pool
namespace: metallb-system
spec:
addresses:
- 203.0.113.10/32 # La teva IP pública
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
spec:
ipAddressPools:
- default-pool
cert-manager + ClusterIssuer
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true
El ClusterIssuer fa servir DNS-01 amb Cloudflare per poder emetre certificats wildcard:
# cluster-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: el-teu-email@example.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
Crea el Secret amb el token de Cloudflare:
kubectl create secret generic cloudflare-api-token \
--namespace cert-manager \
--from-literal=api-token=EL_TEU_TOKEN_DE_CLOUDFLARE
I el Certificate wildcard:
# wildcard-cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-darkden-net
namespace: istio-system
spec:
secretName: wildcard-darkden-net-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- "darkden.net"
- "*.darkden.net"
Istio Gateway
# gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: main-gateway
namespace: istio-system
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
gatewayClassName: istio
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- name: wildcard-darkden-net-tls
allowedRoutes:
namespaces:
from: All
Exemple d’HTTPRoute
# httproute-example.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: la-meva-app
namespace: la-meva-app
spec:
parentRefs:
- name: main-gateway
namespace: istio-system
sectionName: https
hostnames:
- "app.darkden.net"
rules:
- backendRefs:
- name: la-meva-app-svc
port: 8080
External DNS
Perquè els registres DNS es creïn automàticament quan crees un HTTPRoute:
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns
helm repo update
helm install external-dns external-dns/external-dns \
--namespace external-dns \
--create-namespace \
--set provider.name=cloudflare \
--set env[0].name=CF_API_TOKEN \
--set env[0].valueFrom.secretKeyRef.name=cloudflare-api-token \
--set env[0].valueFrom.secretKeyRef.key=api-token \
--set "sources={gateway-httproute}" \
--set policy=sync \
--set registry=txt \
--set txtOwnerId=k8s-cluster
Amb això, cada cop que crees un HTTPRoute amb un hostname, External DNS crea el registre A a Cloudflare automàticament. Quan l’esborres, el neteja.
Dades: Operators de bases de dades
En un sol servidor pots tenir múltiples bases de dades gestionades per operators de Kubernetes. No entraré en la configuració de cadascun — cada operator mereix el seu propi post — però el punt és que el patró funciona:
- CloudNativePG — PostgreSQL natiu de Kubernetes. Backups automatitzats, failover (en multi-node), WAL archiving. Per a aplicacions noves, és l’elecció per defecte.
- MariaDB Operator — per a aplicacions que necessiten MySQL/MariaDB.
- Redis Operator (Spotahome) — per a caching, sessions, i cues.
- Strimzi — Kafka per a arquitectures event-driven. Pesat en recursos, només si realment el necessites.
$ kubectl get pods -A | grep -E ‘cnpg|mariadb|redis’
cnpg-system cnpg-controller-manager-5d7f9b4c8-x2k9l 1/1 Running databases la-meva-app-pg-1 1/1 Running databases la-meva-app-mariadb-0 1/1 Running databases redis-node-0 1/1 Running
Observabilitat: Grafana + Loki + Prometheus
kube-prometheus-stack
Un sol Helm chart que et dona Prometheus, Grafana, Alertmanager, node-exporter, i un munt de dashboards preconfigurats:
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm install kube-prometheus prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--create-namespace \
--set grafana.adminPassword=LA_TEVA_PASSWORD_SEGURA \
--set prometheus.prometheusSpec.retention=30d \
--set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.resources.requests.storage=50Gi
Grafana s’exposa via HTTPRoute amb autenticació. Configura Google SSO (o el proveïdor que prefereixis) als values de Grafana per no dependre d’usuari/password.
Loki + Promtail
Per a logs centralitzats:
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update
helm install loki grafana/loki-stack \
--namespace monitoring \
--set promtail.enabled=true \
--set loki.persistence.enabled=true \
--set loki.persistence.size=20Gi
Dashboards recomanats
- Node Exporter Full (ID 1860) — mètriques del servidor: CPU, RAM, disc, xarxa
- Kubernetes / Pod Resources — consum per pod i namespace
- Trivy Operator Vulnerabilities (ID 17813) — vulnerabilitats detectades en imatges
- Istio Mesh Dashboard — tràfic del service mesh, latències, error rates
Seguretat: Defensa en profunditat
La seguretat no és un component — és un stack de capes. Cadascuna cobreix un angle diferent.
Trivy Operator
Escaneig automàtic de vulnerabilitats en totes les imatges del cluster:
helm repo add aqua https://aquasecurity.github.io/helm-charts
helm repo update
helm install trivy-operator aqua/trivy-operator \
--namespace trivy-system \
--create-namespace \
--set trivy.ignoreUnfixed=true
Trivy crea VulnerabilityReport CRDs per cada workload. Pots consultar-los amb kubectl i visualitzar-los al dashboard de Grafana.
OPA Gatekeeper
Policy enforcement per prevenir configuracions perilloses:
helm repo add gatekeeper https://open-policy-agent.github.io/gatekeeper/charts
helm repo update
helm install gatekeeper gatekeeper/gatekeeper \
--namespace gatekeeper-system \
--create-namespace
Constraint templates que hauries de tenir des del primer dia:
- Require resource limits — cap pod sense requests/limits de CPU i memòria
- Block
latesttag — forçar tags immutables en imatges - Require labels —
app.kubernetes.io/nameiapp.kubernetes.io/versionobligatoris - Block privileged containers — ningú corre com a root a menys que sigui explícitament necessari
Falco
Detecció d’amenaces en runtime. Falco monitoritza syscalls i genera alertes quan detecta comportament sospitós (shell en contenidor, lectura de secrets, escriptura en directoris sensibles):
helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update
helm install falco falcosecurity/falco \
--namespace falco \
--create-namespace \
--set falcosidekick.enabled=true \
--set falcosidekick.webui.enabled=true
La UI de Falco Sidekick mai s’exposa a internet. Accedeix-hi només a través de Tailscale o port-forward. Conté informació sensible sobre el comportament intern del teu cluster.
Network Policies
Default-deny a cada namespace d’aplicació, amb allows explícits:
# default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: la-meva-app
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
# allow-dns.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: la-meva-app
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to: []
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# allow-istio.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-istio
namespace: la-meva-app
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: istio-system
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: istio-system
nftables per protegir l’API Server
No facis servir iptables amb filtrat per IP d’origen si tens Calico eBPF. El processament eBPF modifica les IPs d’origen mitjançant DNAT abans que arribin a les regles d’iptables, així que les teves regles de filtrat per source IP mai matchejaran correctament. Filtra per interfície de xarxa amb nftables.
# /etc/nftables.conf
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
# API Server: solo accesible desde localhost y Tailscale
tcp dport 6443 iifname "lo" accept
tcp dport 6443 iifname "tailscale0" accept
tcp dport 6443 drop
}
}
sudo systemctl enable nftables
sudo systemctl start nftables
Amb això, l’API Server només accepta connexions des del propi servidor (localhost) i des de la VPN de Tailscale. Tot la resta es descarta.
Gestió: KEDA, Reloader, ESO
Tres eines que simplifiquen l’operació diària:
KEDA (Kubernetes Event-Driven Autoscaling) — escala pods basant-se en mètriques custom, longitud de cues, rate de peticions, o qualsevol font de dades. Més flexible que l’HPA natiu.
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda \
--namespace keda \
--create-namespace
Reloader — monitoritza ConfigMaps i Secrets. Quan canvien, fa rolling restart automàtic dels Deployments que els referencien. Sense Reloader, canviar un ConfigMap requereix reiniciar pods manualment.
helm repo add stakater https://stakater.github.io/stakater-charts
helm repo update
helm install reloader stakater/reloader \
--namespace reloader \
--create-namespace
External Secrets Operator — sincronitza secrets des de backends externs (Vault, Infisical, AWS Secrets Manager) cap a Kubernetes Secrets. Preparat per quan vulguis centralitzar la gestió de secrets fora del cluster.
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets external-secrets/external-secrets \
--namespace external-secrets \
--create-namespace
El resultat final
Amb tot instal·lat, això és el que tens corrent:
| Capa | Components |
|---|---|
| Runtime | containerd, kubelet, kubeadm |
| Networking | Calico eBPF (CNI + reemplaçament de kube-proxy) |
| Service Mesh | Istio ambient (ztunnel + waypoints) |
| Ingress | Gateway API + MetalLB + External DNS |
| TLS | cert-manager + Let’s Encrypt + Cloudflare DNS-01 |
| Dades | CloudNativePG, MariaDB Operator, Redis Operator |
| Observabilitat | Prometheus, Grafana, Alertmanager, Loki, Promtail |
| Seguretat | Trivy, Gatekeeper, Falco, Network Policies, nftables |
| Gestió | KEDA, Reloader, External Secrets Operator |
$ kubectl get pods -A —field-selector status.phase=Running -o json | jq ‘.items | length’ 47
47 pods corrent en un sol servidor. El consum base de l’stack d’infraestructura ronda els 4-5GB de RAM i 1.5-2 vCPUs. La resta està disponible per als teus workloads.
Desplegar una app nova és:
# Crear namespace i habilitar-lo per al service mesh
kubectl create namespace la-meva-nova-app
kubectl label namespace la-meva-nova-app istio.io/dataplane-mode=ambient
# Desplegar el waypoint si necessites L7
istioctl waypoint apply --namespace la-meva-nova-app --enroll-namespace
# Aplicar manifests (Deployment, Service, HTTPRoute)
kubectl apply -f la-meva-nova-app/
DNS, TLS, mètriques, logs, network policies, escaneig de vulnerabilitats — tot ja hi és esperant.
Conclusió
Aquest setup no és un homelab de joguina. És el mateix patró arquitectònic que faries servir en producció cloud, adaptat a un sol node. La diferència de cost és brutal: 15-30€/mes per un servidor dedicat vs. centenars d’euros en un cluster gestionat amb el mateix stack.
Els gotchas estan en els detalls que no apareixen a la documentació oficial:
- CRDs que no es registren a temps i causen fallades silencioses als operators
- eBPF que trenca el filtrat per source IP a iptables
- Istio init containers que interfereixen amb operators de bases de dades que necessiten inicialitzar el sistema de fitxers abans que la xarxa estigui disponible
- containerd sense SystemdCgroup que causa reinicis aleatoris de pods
Cadascun d’aquests et pot costar hores de debugging si no els coneixes d’entrada. Ara els coneixes.