← Tornar al blog

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 retornar cgroup2fs).
  • 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:Read per a totes les zones que faràs servir.
  • Tailscale instal·lat — per a accés segur a l’API Server.
API Server i seguretat

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
SystemdCgroup

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
Sense 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:

  1. Reemplaça kube-proxy — balanceig de càrrega de serveis en eBPF, sense iptables
  2. Millor rendiment — especialment amb molts serveis, on les cadenes d’iptables es tornen lineals
  3. 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
Namespaces d'infraestructura

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.
bash

$ 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 latest tag — forçar tags immutables en imatges
  • Require labelsapp.kubernetes.io/name i app.kubernetes.io/version obligatoris
  • 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
Accés a Falco

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

Calico eBPF i filtrat d'IPs

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:

CapaComponents
Runtimecontainerd, kubelet, kubeadm
NetworkingCalico eBPF (CNI + reemplaçament de kube-proxy)
Service MeshIstio ambient (ztunnel + waypoints)
IngressGateway API + MetalLB + External DNS
TLScert-manager + Let’s Encrypt + Cloudflare DNS-01
DadesCloudNativePG, MariaDB Operator, Redis Operator
ObservabilitatPrometheus, Grafana, Alertmanager, Loki, Promtail
SeguretatTrivy, Gatekeeper, Falco, Network Policies, nftables
GestióKEDA, Reloader, External Secrets Operator
bash

$ 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.