무색
기술블로그
에세이
연구
소개

무색

소프트웨어로 비즈니스의 가능성을 만듭니다. 웹·앱 개발, 음성 AI, 자동화 콘텐츠 제작까지 — 기술이 필요한 곳에 무색이 있습니다.

연락처

contact@museck.com

사업자 정보

상호: 무색

대표: 배성재

사업자등록번호: 577-58-00836

인천광역시 연수구 인천타워대로 323, 에이동 8층 801-802호 AB-132 (송도동, 송도 센트로드)

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관·연락처
INCHEON, KR
MetalLB L2 Failover — Cilium ARP 함정과 해결
홈랩 삽질기
2026. 1. 2.

MetalLB L2 Failover가 안 되던 날 - Cilium과 ARP의 함정

metallbciliumkuberneteshomelabl2-advertisement

worker2를 끄니 세상이 멈췄다

내 홈랩 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 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 speaker

speaker가 재시작되면서 살아있는 노드로 재할당한다. 하지만 이건 대증요법이고 근본 원인이 따로 있었다.

Cilium lxc* 인터페이스가 만든 함정

디버깅을 더 해보니 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에서 systemd로

같은 시기에 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 워크로드처럼 호스트 자원에 직접 접근해야 하는 경우에는 호스트에서 직접 돌리는 게 오버헤드가 적다.

setup-all.sh 리팩토링

홈랩 인프라 관리 스크립트도 손봤다. 기존에는 VM별로 시작/중지 메뉴가 있었는데 실제 운영할 때는 "PVE-1 호스트 전체 시작" 이런 식으로 쓰게 된다. 그래서 Node1(PVE-1: master + worker1)과 Node2(PVE-2: worker2) 단위로 묶어서 리팩토링했다. Windows 호스트의 CPU/메모리 잔여량을 확인하는 메뉴도 추가해서 VM 시작 전에 리소스 여유를 확인할 수 있게 했다.

배운 것

  • MetalLB L2 모드를 Cilium CNI와 쓸 때는 반드시 interfaces를 물리 NIC로 제한해야 한다. 가상 인터페이스의 ARP가 failover를 방해할 수 있다
  • ServiceL2Status의 status.node가 고착되면 리소스를 삭제하고 speaker를 재시작하면 된다
  • 네트워크 컴포넌트를 조합할 때는 ARP 레벨까지 이해해야 한다. CNI와 로드밸런서의 상호작용은 예상 밖의 장애를 만든다
  • GPU 워크로드는 컨테이너보다 호스트에서 직접 실행하는 게 나을 때가 많다

홈랩이라서 가능한 삽질이고 덕분에 L2 ARP 메커니즘을 제대로 이해하게 됐다. 프로덕션 환경이었으면 BGP 모드를 쓰겠지만 홈랩에서는 L2가 간편하고 이런 경험도 쌓을 수 있으니까.

자주 묻는 질문

MetalLB L2 failover가 작동하지 않을 때 해결 방법은?
ServiceL2Status 리소스를 전부 삭제하고 speaker DaemonSet을 재시작하면 살아있는 노드로 재할당됩니다. 근본 해결은 L2Advertisement에 interfaces: [eth0]을 추가해서 물리 NIC로 ARP를 제한하는 것입니다.
Cilium CNI와 MetalLB를 함께 쓸 때 주의할 점은?
Cilium이 생성하는 lxc*, cilium* 가상 인터페이스가 ARP 응답을 불안정하게 만들 수 있습니다. L2Advertisement의 interfaces 필드로 ARP를 보낼 인터페이스를 물리 NIC(eth0)로 제한해야 합니다.
MetalLB L2 모드와 BGP 모드 중 홈랩에 적합한 것은?
홈랩에서는 L2 모드가 설정이 간단하고 별도 라우터 설정 없이 동작합니다. BGP 모드는 다중 노드 분산이 가능하지만 라우터가 BGP를 지원해야 하므로 프로덕션 환경에 더 적합합니다.
홈랩 삽질기(6/19)
Prev

홈랩 모니터링, Prometheus 걷어내고 Loki로 갈아탄 이야기

Next

SSD 패스스루 + NFS 공유 스토리지 구축기