En el Episode 3 dejé al cluster con storage replicado: PVCs de Longhorn que sobreviven a que se caiga un worker entero.

Pero un cluster que sólo se consulta con kubectl port-forward es una fortaleza sin puertas. Llega el momento de exponer servicios al LAN — y acá el homelab se choca con una de esas diferencias silenciosas entre la nube y casa.


El problema: type: LoadBalancer “simplemente funciona”… en la nube

En cualquier cloud, creás un Service con type: LoadBalancer y a los segundos aparece una IP. Eso es porque hay un cloud controller del proveedor escuchando: reserva una IP en su infraestructura, la conecta al cluster, y listo.

En un lab casero ese controller no existe. El Service queda así para siempre:

NAME     TYPE           EXTERNAL-IP   PORT(S)
mi-app   LoadBalancer   <pending>     80:31500/TCP

NodePort es la salida rápida, pero es fea: un puerto alto por app, y la IP del nodo hardcodeada donde quieras consumirla. Lo que quiero es lo que tiene la nube: IPs de mi LAN, limpias, una por servicio.

MetalLB en modo L2 lo arregla con un truco viejo y querido: ARP. Un worker del cluster “responde” por la IP cuando alguien en la LAN pregunta quién la tiene — y el router ni se entera. Sin tocar el router, sin DNS, sin nada del lado de la red.


1. Namespace privilegiado

El componente que hace la magia L2 —el speaker— necesita hostNetwork y NET_RAW: habla ARP directamente en la red del nodo. Con el enforce: baseline global del cluster, en Talos eso requiere pod security privileged (archivo metalLb/namespace.yaml):

kubectl apply -f metalLb/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: metallb-system
  labels:
    pod-security.kubernetes.io/enforce: privileged
    pod-security.kubernetes.io/audit: privileged
    pod-security.kubernetes.io/warn: privileged

Mismo patrón que Longhorn en el episodio anterior: el label va antes de instalar, no después.


2. Instalar (chart 0.16.1)

helm repo add metallb https://metallb.github.io/metallb && helm repo update
helm install metallb metallb/metallb -n metallb-system --version 0.16.1 \
  -f metalLb/values.yaml

metalLb/values.yaml tiene una sola línea — desactivar FRR-K8s:

frrk8s:
  enabled: false

FRR es para anunciar las IPs por BGP, que es lo que usaría si tuviera un router que hable BGP. Para L2/ARP no hace falta, y no tiene sentido cargar el pod extra.


3. El pool de IPs

Dos recursos CRD (metalLb/ip-pool.yaml): el rango de IPs que MetalLB puede repartir, y el “anuncio” que dice que se reparten por L2:

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: default-pool
  namespace: metallb-system
spec:
  addresses:
    - 192.168.68.200-192.168.68.220
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: default-l2
  namespace: metallb-system
spec:
  ipAddressPools:
    - default-pool
kubectl apply -f metalLb/ip-pool.yaml

Tres decisiones acá, ninguna obvious:

  • El rango está fuera del DHCP del router. Si el router puede asignar 192.168.68.205 a un teléfono y MetalLB también se la da a un Service, tenés un conflicto de IP que aparece y desaparece. Un rango que nadie más puede tocar es requisito, no detalle.
  • Con kube-proxy en nftables no hace falta strictARP. Ese flag es sólo para IPVS, y la mitad de los tutoriales que vas a encontrar te lo hacen configurar igual. Talos usa nftables por defecto: no tocar.
  • Los speakers corren sólo en los workers. Talos etiqueta el control plane con node.kubernetes.io/exclude-from-external-load-balancers, así que MetalLB automáticamente no lo considera para anunciar IPs. Un nodo menos expuesto a la LAN, gratis.

4. Probar

Un Service de prueba (metalLb/test-lb.yaml) — un deployment mínimo con type: LoadBalancer:

kubectl apply -f metalLb/test-lb.yaml
kubectl get svc lb-test
NAME     TYPE           EXTERNAL-IP      PORT(S)
lb-test  LoadBalancer   192.168.68.200   80:31234/TCP

<pending> duró lo que duró: MetalLB le asignó una IP del pool al vuelo. Y la prueba real, desde otra máquina de la LAN, no desde el cluster:

curl http://192.168.68.200

Si responde, el ARP hizo su magia: la LAN entera ya ve una IP que no existe en ningún router ni en ninguna VM — la “responde” un pod.

kubectl delete -f metalLb/test-lb.yaml

Lo que me llevo de este episodio

  • <pending> eterno no es un bug: es que falta el controller. MetalLB es ese controller, en versión casera.
  • El pool va fuera del DHCP. El conflicto de IPs silencioso es el bug más difícil de cazar de esta configuración.
  • strictARP es para IPVS. Con nftables, no tocar — por más que los tutoriales lo digan.
  • El control plane queda afuera solo. El label que Talos ya pone hace el trabajo; nada que configurar.
  • FRR-K8s desactivado. BGP es para cuando el router hable BGP; ARP es para hoy.

Próximo episodio

Los Services ya consiguen IPs de la LAN. Pero una IP por servicio escala mal: lo que falta es un único punto de entrada que reparta por nombre de host y con candado — el ingress. En el próximo episodio: Traefik + cert-manager, con CA propia y cero DNS.

Mi router ahora le entrega tráfico a IPs que nunca asignó.
No le avisé, y no parece importarle. 😎