
내 홈랩 K8s 클러스터는 Proxmox 호스트 2대 위에 노드 3개가 돌아간다. PVE-1에 master와 worker1, PVE-2에 worker2. PVE-2는 Windows에서 WSL이나 무거운 작업이 필요할 때 끌 수 있게 설계해뒀다. K8s 노드가 하나 빠져도 서비스는 계속 돌아야 하니까.
어느 날 PVE-2를 끄고 나서 museck.com에 접속해봤는데 응답이 없었다. kubectl get nodes를 쳐보니 worker2가 NotReady인 건 예상대로였는데 Traefik LoadBalancer의 External IP가 여전히 worker2에 묶여 있었다. MetalLB L2 모드의 failover가 작동하지 않은 거다.
MetalLB L2 모드는 특정 노드가 LoadBalancer IP에 대한 ARP 응답을 담당하는 구조다. 누군가 해당 IP로 패킷을 보내면 담당 노드가 "나야" 하고 ARP reply를 보내서 트래픽을 받아온다. 노드가 죽으면 다른 노드가 이걸 넘겨받아야 하는데 그게 안 된 거다.
원인을 파다 보니 ServiceL2Status라는 커스텀 리소스가 나왔다. MetalLB speaker가 각 서비스별로 이 리소스를 만들고 status.node 필드에 현재 담당 노드를 기록한다. 문제는 이 필드가 사실상 immutable이라는 점이었다. worker2가 NotReady가 되어도 status.node는 worker2를 가리킨 채 바뀌지 않았다.
수동으로 해결하려면 이렇게 하면 된다.
# ServiceL2Status 전부 삭제 후 speaker 재시작
kubectl delete servicel2status -A --all
kubectl rollout restart daemonset -n metallb-system speakerspeaker가 재시작되면서 살아있는 노드로 재할당한다. 하지만 이건 대증요법이고 근본 원인이 따로 있었다.
디버깅을 더 해보니 Cilium CNI가 만드는 가상 인터페이스가 문제를 악화시키고 있었다. Cilium은 Pod 네트워킹을 위해 lxc*나 cilium* 같은 가상 인터페이스를 잔뜩 생성한다. MetalLB speaker는 기본적으로 모든 네트워크 인터페이스에서 ARP 응답을 보내는데, 이 가상 인터페이스를 통한 ARP 응답은 불안정했다.
물리 인터페이스(eth0)를 통한 ARP는 안정적으로 도달하지만 lxc* 인터페이스를 거치는 ARP는 외부 네트워크까지 제대로 전달되지 않거나 타이밍 문제를 일으켰다. 스위치 입장에서는 같은 IP에 대해 여러 인터페이스에서 ARP가 날아오니 MAC 테이블이 불안정해지는 거다.
해결책은 단순했다. L2Advertisement에 interfaces 필드를 추가해서 ARP를 보낼 인터페이스를 물리 NIC로 제한하면 된다.
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
spec:
interfaces:
- eth0 # Cilium lxc* 인터페이스 제외, 물리 NIC만 사용이 한 줄 추가로 lxc* 인터페이스를 통한 불안정한 ARP 응답이 원천 차단됐다. 적용 후 worker2를 끄고 켜는 테스트를 반복해봤는데 failover가 깔끔하게 동작했다.
같은 시기에 JupyterLab도 정리했다. 처음에는 Docker 컨테이너로 띄웠는데 GPU를 컨테이너에 붙이려면 nvidia-container-toolkit 설정이 번거롭고 Python 패키지 관리도 컨테이너 안에서 해야 해서 불편했다.
그래서 WSL 호스트에서 uv tool install jupyterlab로 직접 설치하고 systemd 서비스로 등록했다. GPU에 바로 접근 가능하고 uv로 Python 환경도 깔끔하게 관리된다. K8s에서 접근할 수 있게 외부 서비스 프록시(Service + Endpoints + IngressRoute)를 구성했다.
# WSL JupyterLab을 K8s 서비스로 프록시
apiVersion: v1
kind: Endpoints
metadata:
name: jupyter
namespace: jupyter
subsets:
- addresses:
- ip: 192.168.31.2 # WSL IP
ports:
- port: 8888컨테이너가 항상 답은 아니다. GPU 워크로드처럼 호스트 자원에 직접 접근해야 하는 경우에는 호스트에서 직접 돌리는 게 오버헤드가 적다.
홈랩 인프라 관리 스크립트도 손봤다. 기존에는 VM별로 시작/중지 메뉴가 있었는데 실제 운영할 때는 "PVE-1 호스트 전체 시작" 이런 식으로 쓰게 된다. 그래서 Node1(PVE-1: master + worker1)과 Node2(PVE-2: worker2) 단위로 묶어서 리팩토링했다. Windows 호스트의 CPU/메모리 잔여량을 확인하는 메뉴도 추가해서 VM 시작 전에 리소스 여유를 확인할 수 있게 했다.
interfaces를 물리 NIC로 제한해야 한다. 가상 인터페이스의 ARP가 failover를 방해할 수 있다ServiceL2Status의 status.node가 고착되면 리소스를 삭제하고 speaker를 재시작하면 된다홈랩이라서 가능한 삽질이고 덕분에 L2 ARP 메커니즘을 제대로 이해하게 됐다. 프로덕션 환경이었으면 BGP 모드를 쓰겠지만 홈랩에서는 L2가 간편하고 이런 경험도 쌓을 수 있으니까.