Clúster Kubernetes Bare-Metal: AlmaLinux 10, k0s y Cilium (Single-Node)
Esta guía documenta el despliegue directo de un clúster Kubernetes de un solo nodo para producción en un servidor bare-metal. El stack técnico utiliza AlmaLinux 10, k0s, Cilium eBPF y Traefik, priorizando la eficiencia de recursos y el aislamiento de seguridad.
¿Por qué tiene sentido este setup en Bare-Metal?
A diferencia de los entornos cloud administrados (como EKS, GKE o AKS), desplegar en un servidor dedicado (Hetzner, OVH, etc.) elimina el sobrecoste de facturación por recursos virtualizados, balanceadores de carga físicos y cargos por transferencia de datos. Por una fracción del coste, tienes acceso al 100% del rendimiento del hardware real (CPU, RAM de baja latencia y discos NVMe locales).
Sin embargo, operar sin un proveedor cloud significa que no contamos con automatizaciones a nivel de hipervisor: nosotros somos responsables de la seguridad perimetral, la persistencia, el enrutamiento de red y la negociación de certificados.
Para resolver esto de forma eficiente y segura, implementamos este diseño tecnológico integrado:
- k0s: Un motor de Kubernetes empaquetado en un único binario sin dependencias externas, reduciendo el consumo de memoria del plano de control en reposo.
- Cilium CNI (eBPF sin Kube-Proxy): Sustituimos las ineficientes y masivas tablas de traducción de red de Linux (iptables) de kube-proxy por programas eBPF ejecutados directamente en el kernel de Linux. Esto optimiza el procesamiento de paquetes, reduce el uso de CPU a cero y permite una observabilidad extrema del tráfico.
- Traefik en hostNetwork (Hardened): En bare-metal no hay un "LoadBalancer virtual". Enlazar el Ingress Controller directamente a la red física (
hostNetwork: true) ofrece el máximo rendimiento de red libre de traducciones complejas de eBPF. Para asegurar este punto de entrada expuesto, lo forzamos a ejecutarse sin privilegios de root (non-root) en puertos no privilegiados, y delegamos la escucha estándar del puerto 80/443 a reglas a nivel del firewall del host. - Hardening perimetral con Cloudflare IPs: Blindamos el puerto web físico del servidor dedicándole una zona de cortafuegos que bloquea cualquier petición HTTP/HTTPS que no provenga de los servidores oficiales de Cloudflare. Cualquier atacante externo que escanee o intente atacar nuestra IP física encontrará los puertos cerrados, forzando a que todo el tráfico legítimo sea filtrado, inspeccionado por el WAF y mitigado contra ataques DDoS por el borde perimetral de Cloudflare de forma gratuita.
⚠️ Consideraciones multi-nodo
La arquitectura detallada a continuación está estrictamente diseñada para un único servidor físico. Si requieres escalar a múltiples nodos, la topología cambia:
- Roles: El manifiesto de infraestructura (k0sctl.yaml) debe separar las máquinas físicas asignando roles de controller (plano de control ciego) y worker (nodos de ejecución).
- Red de Pods: Cilium necesitará establecer túneles (Geneve/VXLAN) o habilitar el enrutamiento nativo con BGP para que los pods de diferentes nodos físicos puedan verse entre sí.
- Entrada de Tráfico: No puedes usar hostPort ni hostNetwork en Traefik sin consideraciones complejas si tienes varios nodos. Deberás configurar un anuncio L2 o BGP (nativamente con Cilium o añadiendo MetalLB) para aprovisionar IPs externas a servicios de tipo LoadBalancer.
1. Seguridad perimetral, Sysctl y CNI (hardening)
En un clúster de un solo nodo sin un firewall físico dedicado, la seguridad recae en el kernel de Linux. AlmaLinux 10 utiliza nftables. Implementaremos firewalld para definir las reglas de acceso iniciales y habilitaremos la configuración Sysctl necesaria para el correcto enrutamiento eBPF de Cilium.
Paso A: Configuración de Parámetros de Kernel (Sysctl)
Para que Cilium gestione el tráfico sin kube-proxy, el kernel debe permitir el reenvío de paquetes IPv4 y configurar el filtro de ruta inversa en modo laxo (rp_filter = 2) para permitir que eBPF maneje paquetes de forma asimétrica sin que el kernel los descarte. Crea el archivo /etc/sysctl.d/99-kubernetes.conf:
# Habilitar reenvío de paquetes IPv4 (Requisito para CNI/Pods)
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
# Configuración de rp_filter (Cilium requiere modo Loose para enrutamiento eBPF)
net.ipv4.conf.all.rp_filter = 2
net.ipv4.conf.default.rp_filter = 2
Aplica la nueva configuración de inmediato:
sudo sysctl --system
Paso B: Resolver el conflicto "no route to host" entre CNI y Firewalld
Por defecto, Cilium gestiona su propio rango CIDR interno para Pods y Servicios. Si firewalld está activo con su configuración por defecto, bloqueará el tráfico interno del clúster que viaja al API Server (habitualmente puerto 6443 o IP virtual 10.96.0.1), lanzando el clásico error de los pods de sistema: dial tcp 10.96.0.1:443: connect: no route to host.
Para solucionarlo de forma segura sin deshabilitar el firewall del host ni habilitar rangos gigantescos (/8) que expongan tu servidor a otros clientes del centro de datos, confiamos estrictamente en las interfaces de Cilium y en la subred de pods real:
# Instalar y activar firewalld
sudo dnf install firewalld
sudo systemctl enable --now firewalld
# Exponer estrictamente SSH
sudo firewall-cmd --zone=public --add-service=ssh --permanent
# Bloquear el acceso público al API de Kubernetes (6443)
sudo firewall-cmd --zone=public --remove-port=6443/tcp --permanent
# Opción A (Recomendado): Confiar en las interfaces de red de Cilium
sudo firewall-cmd --zone=trusted --add-interface=cilium_host --permanent
sudo firewall-cmd --zone=trusted --add-interface=cilium_net --permanent
sudo firewall-cmd --zone=trusted --add-interface=lxc+ --permanent
# Opción B (Alternativa): Confiar en los CIDRs reales de Pods (10.244.0.0/16) y Services (10.96.0.0/12)
sudo firewall-cmd --zone=trusted --add-source=10.244.0.0/16 --permanent
sudo firewall-cmd --zone=trusted --add-source=10.96.0.0/12 --permanent
# Permitir a la subred de Pods el acceso al puerto del API Server en la máquina física
sudo firewall-cmd --zone=public --add-rich-rule='rule family="ipv4" source address="10.244.0.0/16" port port="6443" protocol="tcp" accept' --permanent
sudo firewall-cmd --reload
Protege el puerto 22 contra ataques de fuerza bruta usando Fail2ban:
sudo dnf install epel-release -y
sudo dnf install fail2ban -y
Nota sobre SELinux
Si utilizas volúmenes locales dinámicos, el aislamiento estricto de SELinux en AlmaLinux puede bloquear la escritura de los contenedores. Pásalo temporalmente a modo permisivo (sudo setenforce 0) o etiqueta correctamente los directorios físicos de persistencia con container_file_t.
2. Despliegue declarativo con k0sctl
Define la infraestructura como código (IaC) para evitar la configuración manual por SSH. Usaremos k0sctl para inyectar k0s.
Para que Cilium funcione de manera óptima sin interferencias de iptables clásicas, deshabilitaremos por completo kube-proxy y el proveedor de red por defecto de k0s.
Crea el archivo k0sctl.yaml en tu máquina local:
apiVersion: k0sctl.k0sproject.io/v1beta1
kind: Cluster
metadata:
name: k0s-cluster
spec:
hosts:
- ssh:
address: <IP_DEL_SERVIDOR>
user: almalinux
port: 22
keyPath: ~/.ssh/tu_llave_privada
role: single
k0s:
config:
spec:
network:
provider: custom
podCIDR: 10.244.0.0/16
serviceCIDR: 10.96.0.0/12
kubeProxy:
disabled: true
Ejecuta el despliegue desde tu terminal:
k0sctl apply --config k0sctl.yaml
3. Red eBPF: inyección de Cilium
El clúster está inicializado pero sin red (estado NotReady). Inyecta Cilium desde tu máquina local usando su CLI oficial.
Paso A: instalar la CLI de Cilium (local)
CILIUM_CLI_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/cilium-cli/main/stable.txt)
curl -L --fail -o cilium-linux-amd64.tar.gz "https://github.com/cilium/cilium-cli/releases/download/${CILIUM_CLI_VERSION}/cilium-linux-amd64.tar.gz"
sudo tar xzvfC cilium-linux-amd64.tar.gz /usr/local/bin
Paso B: desplegar el CNI en el clúster
Al no tener kube-proxy, debes indicarle a Cilium la IP exacta del API Server para que pueda enrutar el tráfico de sistema:
cilium install \
--set kubeProxyReplacement=true \
--set k8sServiceHost=<IP_DEL_SERVIDOR> \
--set k8sServicePort=6443
Paso C: verificación
Comprueba que los programas eBPF se han compilado y los agentes están corriendo correctamente en el nodo:
cilium status --wait
4. Almacenamiento persistente dinámico
Kubernetes bare-metal carece de provisionadores de discos en la nube (como los EBS de AWS). Instala Local Path Provisioner para que las aplicaciones (ej. bases de datos SQLite/PostgreSQL) puedan reclamar carpetas del disco duro físico de forma automática mediante PVCs.
# Desplegar el controlador
kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.35/deploy/local-path-storage.yaml
# Forzarlo como la clase de almacenamiento predeterminada
kubectl patch storageclass local-path -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
5. Ingress controller: Traefik (hostNetwork y Hardening)
Para gestionar la entrada de tráfico HTTP/HTTPS y los certificados SSL automáticos, utilizaremos Traefik. En bare-metal, la solución más robusta y de mayor rendimiento es enlazar el Ingress Controller directamente a la red física usando `hostNetwork: true`, evitando el overhead y la inestabilidad de la traducción de puertos de eBPF (`hostPort` o `NodePort`).
Sin embargo, implementar `hostNetwork: true` requiere solucionar dos consideraciones de seguridad y arquitectura críticas:
- Resolución DNS Interna: Un pod en `hostNetwork` utiliza por defecto el `/etc/resolv.conf` del sistema host, perdiendo la capacidad de resolver nombres internos de Kubernetes (`.svc.cluster.local`). Para evitar que Traefik pierda la conectividad con tus microservicios, configuramos explícitamente la política DNS en `ClusterFirstWithHostNet`.
- Aislamiento No Privilegiado (Non-Root): Levantar Traefik como root (`runAsUser: 0`) para poder escuchar en los puertos privilegiados 80/443 de la red del host es un riesgo grave. En su lugar, obligamos al contenedor a ejecutarse de forma segura sin privilegios de root, escuchando en puertos no privilegiados (8000 y 8443), y aplicamos una redirección interna en el firewall del host (`80 -> 8000` y `443 -> 8443`).
Aplica la redirección en el firewall del host:
sudo firewall-cmd --zone=public --add-forward-port=port=80:proto=tcp:toport=8000 --permanent
sudo firewall-cmd --zone=public --add-forward-port=port=443:proto=tcp:toport=8443 --permanent
sudo firewall-cmd --reload
Por otra parte, si el dominio del servidor está detrás del proxy de **Cloudflare** (nube naranja activa), el reto SSL por defecto de Let's Encrypt (`tlschallenge` / TLS-ALPN-01) fallará debido a la interceptación SSL del borde. Debemos configurar el resolvedor para utilizar el reto por HTTP en el puerto 80 (puerto 8000 en el contenedor) configurando `httpchallenge`.
Crea el archivo traefik-values.yaml con este diseño robusto y seguro:
deployment:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
service:
type: ClusterIP
ports:
web:
port: 8000
expose:
default: true
exposedPort: 80
websecure:
port: 8443
expose:
default: true
exposedPort: 443
persistence:
enabled: true
storageClass: "local-path"
path: "/data"
size: 128Mi
additionalArguments:
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=admin@tudominio.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/data/acme.json"
Aplica la configuración vía Helm:
helm repo add traefik https://helm.traefik.io/traefik
helm repo update
helm upgrade --install traefik traefik/traefik -n kube-system -f traefik-values.yaml
6. Integración perimetral con Cloudflare
Una vez expuestos los puertos web, el siguiente paso crítico en producción es evitar que un escáner de red directo localice nuestra IP pública y ejecute ataques directamente sobre el clúster. Para ello, restringimos el acceso en los puertos 80/443 de forma que solo acepten tráfico proveniente de las IPs oficiales de Cloudflare.
Paso A: Configurar el filtro de IPs oficiales de Cloudflare en Firewalld
Crearemos una zona de firewalld dedicada exclusivamente a Cloudflare, agregaremos sus rangos CIDR públicos dinámicamente y eliminaremos el acceso libre general a HTTP/HTTPS de la zona pública.
# 1. Crear una zona en firewalld exclusiva para Cloudflare
sudo firewall-cmd --new-zone=cloudflare --permanent
sudo firewall-cmd --reload
# 2. Agregar los rangos IPv4 oficiales de Cloudflare a la zona
for ip in $(curl -s https://www.cloudflare.com/ips-v4); do
sudo firewall-cmd --zone=cloudflare --add-source="$ip" --permanent
done
# 3. Habilitar la redirección interna de puertos (80->8000, 443->8443) en la nueva zona
sudo firewall-cmd --zone=cloudflare --add-forward-port=port=80:proto=tcp:toport=8000 --permanent
sudo firewall-cmd --zone=cloudflare --add-forward-port=port=443:proto=tcp:toport=8443 --permanent
# 4. Remover HTTP y HTTPS generales de la zona pública general
sudo firewall-cmd --zone=public --remove-service=http --permanent
sudo firewall-cmd --zone=public --remove-service=https --permanent
sudo firewall-cmd --zone=public --remove-port=80/tcp --permanent
sudo firewall-cmd --zone=public --remove-port=443/tcp --permanent
sudo firewall-cmd --reload
Paso B: Evitar errores de TLS en subdominios anidados (segundo nivel)
Si tu servicio utiliza subdominios anidados como preprod.app.tudominio.com, te encontrarás con el error ERR_SSL_VERSION_OR_CIPHER_MISMATCH.
Esto ocurre porque el SSL Universal gratuito de Cloudflare solo cubre el dominio raíz (tudominio.com) y un único nivel comodín (*.tudominio.com). Para solucionarlo de manera gratuita, debemos aplanar los subdominios a un solo nivel utilizando guiones de separación en lugar de puntos adicionales:
7. Despliegue de Ingress parametrizado
Finalmente, al definir el recurso Ingress en Kubernetes, hay un comportamiento de Traefik crucial a tener en cuenta: si declaras el campo secretName en la especificación TLS del Ingress, Traefik asumirá que gestionas tú mismo los certificados de forma manual y desactivará por completo su resolvedor Let's Encrypt automático, arrojando errores del tipo secret does not exist.
A continuación, se muestra una plantilla de Ingress Helm correcta que automatiza el aprovisionamiento omitiendo condicionalmente secretName cuando el resolvedor automático está configurado:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app-ingress
annotations:
# 1. Habilitar enrutamiento TLS en Traefik
traefik.ingress.kubernetes.io/router.tls: "true"
{{- if .Values.ingress.tlsResolver }}
# 2. Especificar el resolvedor Let's Encrypt configurado
traefik.ingress.kubernetes.io/router.tls.certresolver: {{ .Values.ingress.tlsResolver | quote }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className | quote }}
{{- if or .Values.ingress.tlsSecret .Values.ingress.tlsResolver }}
tls:
- hosts:
- {{ .Values.ingress.host | quote }}
# 3. Omitir por completo secretName si usamos resolvedor automático
{{- if and .Values.ingress.tlsSecret (not .Values.ingress.tlsResolver) }}
secretName: {{ .Values.ingress.tlsSecret | quote }}
{{- end }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: app-gateway
port:
number: 8080
Con este diseño, el clúster bare-metal no es solo una instalación de laboratorio, sino una arquitectura de producción de alto rendimiento con eBPF puro, seguridad non-root blindada, y enrutamiento certificado y protegido contra escaneos directos gracias al perímetro de Cloudflare.