IT/Kubernets

Cilium kube-proxy replacement 갈아탔다가 LoadBalancer 클라이언트 IP 다 날린 이야기

gfrog 2026. 6. 30. 06:30
SMALL

이번 분기 OKR 중에 "데이터플레인 통합" 항목이 하나 있었다. 우리는 그동안 kube-proxy(iptables 모드) + Calico CNI 조합을 써왔는데, 네트워크폴리시 관측이 약하고 iptables 룰이 노드당 8천 줄을 넘기 시작하면서 syncProxyRules 지연이 P95 기준 1.2초까지 튀는 날이 가끔 있었다. 다른 팀이 먼저 Cilium 으로 갈아탔길래, 우리도 다음 주에 도입하기로 했다.

결론부터 말하면 도입은 했다. 그런데 도입 첫날 새벽 2시에 보안팀한테 "audit log 의 source IP 가 전부 노드 IP 로 찍히는데 뭔가 잘못된 거 아니냐" 라는 메시지를 받았고, 거기서부터 24시간이 좀 정신없었다.

1차 시도 — kubeProxyReplacement=true 만 켜고 끝낼 줄 알았다

Helm values 에 kubeProxyReplacement: true 만 넣고 kube-proxy DaemonSet 을 지웠다. 공식 문서대로다. 노드 12대 클러스터에서 한 노드씩 cordon → drain → 재기동 하는 식으로 무중단 마이그레이션 했고, 처음 30분간은 진짜로 아무 문제 없어 보였다. Hubble 켜자마자 트래픽 그래프 예쁘게 나오고, 응답 시간 P99 도 외려 30ms 정도 내려갔다. 솔직히 이때까지는 "왜 진작 안 갈아탔지" 싶었다.

문제는 다음날 아침이었다. 보안팀에서 "L7 WAF 룰 중에 IP 기반 차단이 동작을 안 한다" 는 리포트가 올라왔다. 확인해보니, 외부에서 들어오는 모든 요청의 source IP 가 클러스터 노드 중 하나의 IP 로 SNAT 된 채 백엔드 파드까지 도달하고 있었다. 그게 그대로 액세스 로그에 남으니, IP 차단 룰이 무의미해진 거다. 더 무서운 건 그게 외부 보안 솔루션의 anomaly detection 에도 영향을 줘서, "동일 IP 에서 비정상 트래픽" 알람이 폭주하기 시작했다는 점이다.

왜 그런 일이 생겼나

원인은 externalTrafficPolicy 였다. 우리는 Service 의 기본값인 Cluster 를 쓰고 있었다. iptables kube-proxy 시절에도 사실 이 설정에서는 SNAT 이 일어났다. 다만 우리 LoadBalancer(MetalLB BGP 모드) 가 ingress controller 의 NodePort 로 들어오는 트래픽을 BGP ECMP 로 분산했고, ingress-nginx 가 use-proxy-protocol: "true" + externalTrafficPolicy: Local 조합으로 운영되고 있었기 때문에 거기서 실제 클라이언트 IP 가 복원됐다.

문제는 ingress 가 아니라 ingress 를 거치지 않고 직접 LoadBalancer 로 노출된 몇 개의 서비스였다. gRPC 백엔드, 사내 admin 콘솔, 그리고 audit log 수집 endpoint. 이것들은 externalTrafficPolicy: Cluster 였고, Cilium KPR 도 동일한 시맨틱을 따르기 때문에 source IP 가 노드 IP 로 SNAT 됐다. 사실 이건 iptables 시절에도 동일하게 일어나야 정상인데, 이상하게 그쪽은 client IP 가 살아 있었다.

여기서 한참을 파봤다. 결국 알아낸 건, 기존 환경에는 누군가가 한참 전에 --proxy-mode=ipvs 로 우회 설정을 해뒀고, IPVS 의 DR(Direct Routing) 모드 비스무리한 동작에 의해 일부 경로에서 source IP 가 보존되고 있었다는 것이다. 그게 사실은 정상 시맨틱이 아닌데, 우리 팀은 그걸 표준 동작으로 알고 있었다.

이게 좀 무서운 게 뭐냐면, "지금 잘 동작한다" 가 "정상적으로 동작한다" 와 같지 않다는 거다. 그동안 어딘가 비표준 설정에 기대 있던 셈이고, 그걸 모르고 갈아탔으니 깨질 만 했다.

두 번째 시도 — externalTrafficPolicy 만 Local 로 바꾸면 되는 거 아냐?

문제가 명확해 보였으니 해결도 간단해 보였다. 문제의 LoadBalancer 서비스 3개를 externalTrafficPolicy: Local 로 바꿨다. 바꾸자마자 source IP 는 원복됐고 보안팀 알람도 멈췄다. 새벽 4시쯤 슬랙에 "해결 완료" 까지 쳤다.

그런데 30분 뒤에 다시 alert 가 떴다. 이번엔 gRPC 백엔드의 P99 latency 가 평소의 5배 가까이 튀고 있었다. kubectl get endpointslices 로 보니, gRPC 백엔드 파드는 12개 노드 중 4개에만 떠 있었다. externalTrafficPolicy: Local 은 외부 트래픽이 들어온 노드에 로컬 백엔드가 없으면 그 트래픽을 그냥 drop 한다. MetalLB BGP 가 ECMP 로 12개 노드에 트래픽을 흩뿌리는데, 그중 8개 노드는 로컬 백엔드가 없는 상태였으니 결과적으로 트래픽의 2/3 이 의미 없는 노드로 가서 죽거나 timeout 났던 거다.

여기서 멘탈이 좀 나갔다. 그러고 30분쯤 다른 옵션을 찾아봤다.

세 번째 시도 — DSR(Direct Server Return) + MetalLB BGP

찾아보니 Cilium 의 KPR 모드에서 loadBalancer.mode: dsr 옵션이 있었다. DSR 은 백엔드 파드가 응답을 클라이언트에게 직접 보내는 방식이라, 중간 노드에서 SNAT 이 일어나지 않고 source IP 가 보존된다. 그리고 핵심은, externalTrafficPolicy: Cluster 인 상태에서도 source IP 가 살아 있다는 점이다.

# helm values (요지만)
kubeProxyReplacement: true
loadBalancer:
  mode: dsr
  algorithm: maglev
  dsrDispatch: opt   # IPv4 의 경우 IP option 으로 client IP 인코딩
bpf:
  masquerade: true

다만 DSR 은 제약이 좀 있다. 우리 환경 기준으로 정리하면:

  • L7 LB 가 아닌 L4 LB 환경이어야 한다 (우리는 MetalLB BGP 라 OK)
  • 백엔드 파드가 응답에 LB 의 VIP 를 source IP 로 써야 하는데, Cilium 이 eBPF 로 이 부분을 처리한다 (커널 4.19.57 이상 필요. 우리는 6.x 라 OK)
  • dsrDispatch: opt 는 IPv4 한정. IPv6 는 Geneve 디스패치가 필요한데 이건 또 다른 이슈가 있다 (다행히 우리는 IPv4 only)
  • 모든 노드에 동일한 BPF 설정이 들어가야 한다. 마이그레이션 중간 상태에선 일부 트래픽이 깨질 수 있다

마지막 항목이 좀 까다로워서, 결국 점검 시간을 30분 잡고 한 번에 전 노드 재기동했다. 트래픽 끊김 없는 무중단 전환을 포기한 셈이다. 우리 같은 사이즈에서는 이게 더 안전했다.

그리고 알게 된 것

전환 끝나고 며칠 모니터링했는데 잘 돌아간다. 부가 효과로 노드 간 east-west 트래픽이 좀 줄었다 (응답 경로가 짧아졌으니). 다만 디버깅이 좀 골치 아파졌다. tcpdump 를 LB 노드에서 떠봤을 때 응답 패킷이 안 잡힌다. 직접 백엔드 노드에서 떠야 보인다. 처음엔 이거 모르고 "패킷이 왜 안 나가지" 하면서 한참을 헤맸다.

또 하나, Cilium 1.16 부터 DSR 의 Geneve 디스패치 모드가 안정화됐는데, IPv6 듀얼스택을 검토 중인 팀이라면 그쪽이 더 일관된 선택일 수도 있다. 우리는 아직 IPv4 only 라 opt 로 충분했다.

마지막으로, 이번 일로 한 가지 교훈을 다시 새겼다. CNI 같은 인프라 컴포넌트를 갈아탈 때, "지금 어떻게 동작하고 있나" 를 정상 시맨틱 기준으로 다시 검증해야 한다. "그동안 잘 돌아갔다" 는 건, 사실은 어딘가 비표준 설정에 기댄 우연일 수도 있다. 우리는 그걸 모르고 갈아탔다가 새벽에 4시간을 날렸다.

BIG