
이 글에서 얻을 것
kube-proxy 제거 시 명시 선언이 필요한 것들
타임아웃 3계층
NodePort+VIP 함정
요약
프로덕션 Kubernetes 클러스터에서 CNI를 flannel에서 Cilium으로 바꿨습니다. Ingress-NGINX Controller의 지원 종료가 직접적인 계기였지만, 실제로는 CNI·kube-proxy·인그레스 계층이 한꺼번에 바뀌는 작업이었습니다. 주말 이틀 동안 클러스터를 재설치하고, 사용자·Project·ImageHub·Workload 전체를 백업/복구했습니다.
전환 자체는 계획대로 끝났습니다. 흥미로운 건 그 다음이었습니다. 전환 당일에는 아무 문제 없이 동작하던 서비스들이, 이후 열흘에 걸쳐 하나씩 문제를 드러냈습니다. 도메인·타임아웃·외부 VIP·모니터링 — 전부 “예전에는 되던 것”이었고, 전부 원인은 같은 자리에 있었습니다.
이 글은 왜 Cilium으로 갔는지, 무엇이 실제로 바뀌었는지, 그리고 전환 이후 드러난 다섯 가지 이슈를 정리한 기록입니다.
왜 Cilium이었나
시작은 선택이 아니라 통보였습니다. Kubernetes 공식 커뮤니티에서 관리하던 Ingress-NGINX Controller의 지원이 종료됐습니다. 보안 패치와 버그픽스가 더 이상 제공되지 않는다는 뜻이고, 인그레스는 클러스터의 모든 외부 트래픽이 통과하는 지점이라 “일단 두고 보자”가 성립하지 않는 컴포넌트입니다.
대안을 검토하면서 정리한 근거는 이랬습니다.
Kubernetes가 공식적으로 Ingress의 후속으로 Gateway API를 권장하고 있습니다. 어차피 갈아탈 거면 인그레스 리소스의 다음 세대로 가는 편이 낫습니다.
Gateway API 구현체 중에서 성숙도가 높은 것은 Cilium과 Istio 정도입니다.
Cilium을 택하면 인그레스 컨트롤러만 바꾸는 게 아니라 eBPF 기반 CNI로서의 성능 이득도 함께 가져올 수 있습니다.
Cilium은 사이드카를 붙이지 않아 자원 효율이 좋습니다. GPU 노드가 대부분인 환경에서 노드당 사이드카 비용은 무시할 수 없는 항목입니다.
즉 “인그레스 컨트롤러 교체”로 시작한 검토가 “네트워킹 스택 전체 교체”로 결론이 났습니다. 이 확대가 이 작업의 난이도를 만든 지점이기도 합니다.
무엇이 실제로 바뀌는가
고객사에 사전 안내를 준비하면서, 바뀌는 것을 정리해보니 생각보다 범위가 넓었습니다.
계층 | 이전 | 이후 |
|---|---|---|
CNI | flannel ( | Cilium ( |
서비스 로드밸런싱 |
| Cilium eBPF (kube-proxy 제거) |
외부 노출 | Ingress (NGINX) 단일 | Ingress + Gateway API 이원화 |
L7 프록시 | NGINX | Envoy |
설정 표현 | Ingress 어노테이션 | Helm values · ConfigMap · HTTPRoute |
여기서 두 가지가 특히 중요했습니다.
하나, kube-proxy가 사라집니다. Cilium이 eBPF로 서비스 로드밸런싱을 직접 처리하므로 kube-proxy는 불필요한 시스템 컴포넌트가 되어 제거했습니다. 이 결정 하나가 나중에 두 개의 이슈를 만듭니다.
둘, 외부 노출 경로가 둘로 갈라집니다. 기존에는 인그레스 하나를 플랫폼과 사용자 워크로드가 전부 공유하는 구조였습니다. 전환 후에는 플랫폼 메인 서비스용 Ingress와 사용자 워크로드용 Gateway를 분리했습니다. 덕분에 워크로드 트래픽이 플랫폼 접속에 영향을 주지 않게 됐지만, 대신 VIP가 3개 필요해졌습니다. 그리고 이 도메인 재배치가 SSO 이슈의 씨앗이 됩니다.
부수적으로 클러스터 재설치가 전제 조건이었습니다. CNI 교체는 기동 중인 클러스터에서 무중단으로 갈아끼울 수 있는 성격의 작업이 아니기 때문입니다.
전환 작업 — 주말 이틀
작업 한 달 전부터 고객사·SI 측과 백업 방식, 업그레이드 순서, 시간 단위 작업 계획을 조율했습니다. 실제 작업 전에는 별도 클러스터에서 다음을 먼저 검증했습니다.
스토리지 백업/복구 테스트 (실제 볼륨 규모 기준)
DB, Project, ImageHub, Workload 각각에 대한 백업 & 복구 스크립트 작성 및 실행 검증
신규 버전 마이그레이션 작업과 작동 검증
작업은 토요일 오전 파일 반입으로 시작해, 클러스터 재설치 → 데이터 복구 → 가용성 테스트 순으로 이틀에 걸쳐 진행됐습니다. 가용성 테스트는 네트워크 인터페이스를 제거하고 OS를 리부팅하는 시나리오로 진행했고, 실패한 항목은 없었습니다.
백업/복구는 시간이 예상보다 걸린 것을 빼면 문제가 없었습니다. 사전 검증에 들인 시간이 실제 작업일의 리스크를 줄여준 가장 확실한 사례였습니다. 복구가 되는지를 걱정할 필요가 없었기 때문에, 그날 실제로 터진 다른 문제에 집중할 수 있었습니다.
문제는 그 “다른 문제”들이 작업 종료 후에도 계속 나왔다는 것입니다.
전환 이후 드러난 다섯 가지
1) TLS handshake 실패 — 도메인을 옮기면 SNI도 옮겨야 한다
전환 작업 중 가장 많은 시간과 에너지를 쓴 이슈입니다.
이 고객사는 사용자가 a.example.com으로 접속하고, 상용 SSO 게이트웨이(리버스 프록시형)가 그 앞단에서 TLS 종단과 인증을 담당하는 구조였습니다. 그리고 a.example.com과 b.example.com이 같은 IP를 바라보도록 DNS가 설정되어 있었습니다.
이번 전환에서 b.example.com을 Workspace 전용 도메인으로 쓰기로 하면서 이 도메인의 IP를 분리했습니다. 설치 직후, SSO를 통한 접속이 되지 않았습니다.
원인을 찾는 데 하루가 걸렸습니다. SSO 호스트에서 curl과 openssl로 직접 접속하면 정상이었습니다. 그런데 SSO 애플리케이션을 통하면 TLS 오류가 났습니다. 네트워크는 멀쩡한데 인증만 실패하는 상황이라, 한때는 TLS 버전 문제로 의심하기도 했습니다.
실제 원인은 단순했습니다. SSO 게이트웨이는 백엔드로 접속할 때 SNI 필드에 b.example.com을 실어 보내고 있었습니다. 전환 전에는 두 도메인이 같은 IP였기 때문에 아무 문제가 없었고, 그래서 아무도 그 설정을 의식하지 않았습니다. 도메인이 분리되자 SNI와 실제 인그레스 호스트가 어긋났고, TLS handshake가 거기서 끊겼습니다.
SNI를 a.example.com으로 맞추자 곧바로 정상 동작했습니다. 이 수정과 적용을 위해 일요일 새벽 시간대에 긴급 회의를 열어야 했고, 더 어려웠던 건 고객사에서도 이 설정의 이력을 정확히 아는 담당자가 거의 없었다는 점입니다.
교훈: 인그레스 전환을 “네트워킹 변경”으로 분류하면 이런 이슈를 놓칩니다. 도메인·인증서·SNI에 영향을 주는 변경은 아이덴티티/인증 변경으로 취급해서 별도 체크리스트와 롤백 시나리오를 준비해야 합니다. 그리고 이런 설정의 변경 이력은 특정 개인의 기억이 아니라 문서로 남아 있어야, 담당자가 바뀌어도 대응할 수 있습니다.
2) 정확히 5분에 끊기는 504 — 타임아웃이 3개다
전환 일주일 뒤, “기존에는 정상이던 서비스가 정확히 5분 시점에 504 Gateway Timeout으로 끊긴다”는 문의가 들어왔습니다. 대상은 LLM 서빙 워크로드였고, 사용자 측 애플리케이션의 타임아웃은 모두 20분 이상으로 설정되어 있었습니다.
NGINX 시절에는 인그레스 어노테이션 두 줄이면 끝나는 설정이었습니다.
proxy-read-timeout: 3600
proxy-send-timeout: 3600Cilium에서는 같은 결과를 얻으려면 세 군데를 전부 올려야 합니다.
envoy:
idleTimeoutDurationSeconds: 3600 # ① 백엔드 L7 프록시 route idle (기본 60)
streamIdleTimeoutDurationSeconds: 3600 # ② 백엔드 L7 프록시 stream idle (기본 300)
extraConfig:
proxy-stream-idle-timeout-seconds: "3600" # ③ 게이트웨이 stream idle (기본 300)왜 3개냐면, 워크로드 HTTP 트래픽이 게이트웨이 Envoy를 지난 뒤 백엔드 노드의 L7 가시성 프록시 hop을 하나 더 거치기 때문입니다. 게이트웨이(③)만 올리면 백엔드 프록시의 기본값에서 그대로 끊깁니다. 게다가 ③은 envoy.streamIdleTimeoutDurationSeconds로는 반영되지 않고 반드시 extraConfig에 들어가야 합니다(Cilium 쪽 알려진 버그).
실제로 설치 당시 적용된 값은 이랬습니다.
# 잘못된 설정
envoy:
idleTimeoutDurationSeconds: 3600
streamIdleTimeoutDurationSeconds: 3600
extraConfig: # ← envoy 하위로 들어감
proxy-stream-idle-timeout-seconds: "3600"extraConfig가 한 단계 안쪽으로 들어가 있었습니다. 들여쓰기 두 칸 때문에 세 값 중 하나가 적용되지 않았고, 그래서 정확히 300초(5분)에 끊겼습니다. 전달된 설정도 정확했고 이해한 내용도 정확했지만, 반영 결과를 최종 확인하는 단계가 비어 있었습니다.
조치는 cilium-config ConfigMap 수정 후 cilium operator 재시작이었습니다. 워크로드는 재시작되지 않고 기존 통신도 끊기지 않습니다. 클러스터 단위 설정이라 워크로드별로 다시 설정할 필요도 없었습니다.
부수적으로, 고객이 함께 물어본 질문이 오히려 더 중요했습니다. “기존 NGINX 환경에서 안내해주신 방법으로는 현재 설정값을 확인할 수가 없습니다.” 설정을 바꾸는 방법뿐 아니라 설정을 조회하는 방법까지 전부 달라졌는데, 그건 인수인계 문서에 없었습니다.
교훈: 프록시를 교체할 때는 설정값의 마이그레이션뿐 아니라 설정을 확인·변경하는 운영 절차까지 같이 이관해야 합니다. 그리고 “설정을 전달했다”와 “설정이 반영됐다”는 다른 사건입니다. 적용 후 실측(실제로 몇 초에 끊기는지)을 확인 단계로 넣어야 합니다.
3) NodePort가 외부에서만 안 통한다 — Cilium에선 선언이 필요하다
이 글에서 가장 중요한 이슈입니다. 원인이 명확하고, Cilium 전환의 본질을 그대로 보여주기 때문입니다.
이 클러스터에는 사내 연계 시스템이 NodePort를 통해 플랫폼 내부 서비스와 직접 통신하는 구성이 있었습니다. 전환 며칠 뒤, 그 연계가 되지 않는다는 문의가 들어왔습니다. 고객사 측 추정은 “대상 서비스가 내려갔을 것”이었습니다.
확인해보니 증상이 묘했습니다.
접속 위치 | 대상 | 결과 |
|---|---|---|
클러스터 내부 |
| 정상 |
외부 |
| 즉시 거부 |
tcpdump를 걸어보니 이렇게 나왔습니다.
<외부 시스템> > 10.x.x.10.31441: Flags [S] ← SYN이 노드에 도달 (경로는 정상)
10.x.x.10.31441 > <외부 시스템>: Flags [R.] ← 20µs 만에 RST20µs 만의 RST는 커널이 직접 보낸 거부입니다. “그 포트에 리스너가 없다”는 응답이죠. 그런데 Service도, Endpoint도, Pod도 전부 정상이었습니다. 서비스는 멀쩡한데 커널은 포트가 없다고 답하는 모순입니다.
Cilium의 로드밸런싱 테이블을 열어보니 답이 나왔습니다.
# cilium bpf lb list | grep 31441
10.x.x.11:31441/TCP → <파드 IP>:<포트> ← 노드 IP만 등록
→ 10.x.x.10:31441 항목 없음 ← 결정적 증거10.x.x.10은 keepalived가 별도로 올린 HA VIP였습니다. 노드의 primary IP는 10.x.x.11이고요. 즉 Cilium의 장부에 이 VIP가 NodePort 프론트엔드로 등록되어 있지 않았습니다. 실제로 노드 IP(10.x.x.11)로 접속하면 정상 동작했습니다.
여기서 두 가지 질문이 남습니다.
왜 내부에서는 됐나. Cilium은 내부 트래픽을 connect() 시스템콜 단계에서 파드 IP로 변환합니다(socket-LB). VIP가 적힌 패킷 자체가 만들어지지 않으니 등록 여부와 무관하게 동작합니다. 외부 트래픽만 NIC에서 IP:PORT 정확 매칭을 거치고, VIP가 미등록이라 매칭에 실패해 커널로 통과 → RST.
왜 kube-proxy에서는 됐나. 이게 핵심입니다. kube-proxy가 만드는 iptables 규칙은 NodePort를 --dst-type LOCAL로 매칭합니다. 노드의 모든 로컬 IP를 암묵적으로 포함한다는 뜻이고, keepalived가 인터페이스에 올린 VIP도 자동으로 여기 들어갑니다. 반면 Cilium은 선언된 IP만 프론트엔드로 받습니다.
정리하면 이렇습니다.
kube-proxy (iptables) | Cilium (eBPF) | |
|---|---|---|
NodePort 수신 대상 | 노드의 모든 로컬 IP (암묵) | 등록된 프론트엔드 IP만 (명시) |
클러스터 밖에서 붙인 VIP | 자동으로 포함됨 | 별도 선언 필요 |
keepalived VIP는 Kubernetes 바깥에서 만들어진 IP였고, Cilium에 선언된 적이 없었습니다. 장애가 아니라 구성 누락이었습니다. 그리고 이 누락은 kube-proxy 환경에서는 존재할 수조차 없었습니다. 암묵적으로 커버되고 있었으니까요.
조치는 Service에 해당 IP를 명시적으로 선언하는 것이었습니다.
kubectl -n <namespace> patch svc <service> --type merge -p '{
"spec": {
"externalIPs": ["10.x.x.10"],
"ports": [
{"name": "svc", "port": <서비스 포트>, "targetPort": <타깃 포트>, "nodePort": 31441, "protocol": "TCP"},
{"name": "svc-ext", "port": 31441, "targetPort": <타깃 포트>, "protocol": "TCP"}
]
}
}'externalIPs가 핵심입니다. 이 선언이 있어야 Cilium이 해당 VIP를 NodePort 프론트엔드로 등록합니다. 31441 포트를 별도로 열어둔 것은 연계 시스템 쪽 설정을 바꾸지 않고 기존 주소·포트를 그대로 유지하기 위해서고, --type merge는 ports 배열을 통째로 교체하므로 기존 포트 정의도 함께 넣어야 합니다.
교훈: Cilium 환경에서 NodePort를 외부로 열려면, 노드 IP가 아닌 주소로 들어오는 트래픽은 별도 선언이 필요합니다. kube-proxy를 제거한다는 것은, kube-proxy가 암묵적으로 처리해주던 것을 전부 명시적으로 선언해야 한다는 뜻이기도 합니다. 클러스터 바깥에서 만들어진 IP(keepalived VIP, 본딩 인터페이스의 보조 IP 등)로 들어오는 NodePort 트래픽이 있다면 전부 전환 전 조사 대상입니다. “지금 이 클러스터에 Kubernetes가 모르는 IP가 있는가”를 묻는 항목을 체크리스트에 넣었습니다.
4) 사라진 프로세스 — 모니터링은 CNI를 모른다
고객사 클러스터의 모든 서버에는 호스트 기반 보안 모니터링 도구가 설치되어 있었습니다. 그리고 그 도구에는 Kubernetes 네트워크 관련 감시 프로세스로 flanneld와 kube-proxy가 등록되어 있었습니다.
전환 이후, 모니터링 시스템이 이렇게 알리기 시작했습니다.
감시 프로세스가 DOWN되었습니다(/usr/local/bin/kube-proxy --config=...)
감시 프로세스가 DOWN되었습니다(/opt/bin/flanneld --ip-masq --kube-subnet-mgr)전 노드에서, 계속.
당연한 결과입니다. flannel은 더 이상 쓰지 않고, kube-proxy는 불필요한 컴포넌트로 제거했으니까요. 하지만 고객사 입장에서는 “보안 모니터링이 전 노드에서 장애를 보고하는 상황”이었습니다. Cilium으로 바뀌면서 감시 대상 프로세스도 함께 바뀌었는데, 그 갱신이 전환 작업 항목에 들어 있지 않았습니다.
대응은 감시 프로세스 목록을 갱신하는 것이었습니다. flanneld와 kube-proxy를 제거하고 cilium-agent를 추가했습니다. 한 가지 짚어둘 점은 Cilium은 flannel처럼 호스트의 시스템 데몬으로 돌지 않는다는 것입니다. DaemonSet 파드로 뜨기 때문에, 호스트 프로세스 감시 목록에 넣는 것 자체가 적절한지도 함께 검토해야 했습니다.
교훈: 인프라 컴포넌트 교체의 영향 범위에는 그 컴포넌트를 바라보고 있던 바깥 시스템이 포함됩니다. 모니터링 대상 목록, 방화벽 정책, 자산 관리 대장 — 우리가 만들지 않았지만 우리 때문에 틀려지는 것들입니다. 컴포넌트를 교체하면 그 컴포넌트를 참조하는 목록도 같은 작업 안에서 갱신되어야 합니다.
5) 뒤늦게 정리된 것들 — L2 광고와 방화벽
위 네 가지만큼 극적이지는 않지만, 전환 이후 몇 주에 걸쳐 정리된 항목들입니다.
L2 announcement의 lease holder. Cilium은 LoadBalancer IP를 L2로 광고할 때 lease를 통해 광고 주체 노드를 선출합니다. 내부 클러스터에서 UI 접근이 안 되는 일이 있었는데, 확인해보니 인그레스 IP 대역을 갖고 있지 않은 노드가 lease holder로 선출되어 있었습니다. 트래픽이 그 노드까지는 갔지만 거기서 더 가지 못한 것입니다.
kubectl get lease cilium-l2announce-kube-system-cilium-ingress -n kube-system \
-o jsonpath='{.spec.holderIdentity}'이 명령 하나로 “지금 어느 노드를 통해 노출되고 있는가”를 확인할 수 있습니다. 해당 노드를 LB 노드 목록에서 제외해 해결했습니다. NGINX 인그레스 시절에는 없던 종류의 확인 항목입니다.
마스터↔워커 방화벽 포트. Cilium이 요구하는 포트를 뒤늦게 정리했습니다. 폐쇄망 고객사에서는 이게 설치 전 필수 협의 항목인데, 초기에는 문서가 없었습니다.
8472/UDP : VXLAN
4240/TCP : cilium-health
4244/TCP : Hubble
9962/TCP : cilium-agent metric
9964/TCP : cilium-envoy
9965/TCP : Hubble metric다섯 개를 관통하는 한 가지
정리하고 보니 이슈들이 같은 모양을 하고 있었습니다.
다섯 건 중 네 건이 “설정이 옮겨지지 않은 문제”가 아니라 “설정이 존재하는지조차 몰랐던 문제”였습니다. SNI는 두 도메인이 같은 IP였기 때문에 아무도 볼 필요가 없었습니다. VIP는 kube-proxy가 알아서 커버했기 때문에 선언할 필요가 없었습니다. 감시 프로세스 목록은 CNI가 바뀌면 같이 바뀌어야 한다는 걸 아무도 작업 항목으로 적어두지 않았습니다. 타임아웃은 어노테이션 두 줄이 세 군데로 흩어질 줄 몰랐습니다.
플랫폼을 교체한다는 것은 암묵적으로 성립하던 전제들이 한꺼번에 명시적 선언을 요구하기 시작한다는 뜻입니다. 그리고 그 전제들은 정의상 문서에 없습니다. 문서에 있었다면 암묵적이지 않았을 테니까요.
그래서 이런 전환에서 마이그레이션 체크리스트를 “기존 설정을 빠짐없이 옮겼는가”로 작성하면 절반만 맞는 체크리스트가 됩니다. 나머지 절반은 “이전 구현체가 우리 대신 해주던 일이 무엇이었는가”입니다. 후자는 옮길 설정이 아예 존재하지 않기 때문에, 설정 diff를 아무리 정교하게 떠도 잡히지 않습니다.
체크리스트에 고정한 것
이번 회고를 계기로 설치 프로세스에 반영한 항목들입니다.
Kubernetes가 모르는 IP 조사 — keepalived VIP, 보조 IP 등 클러스터 바깥에서 만들어져 서비스 트래픽이 들어오는 IP 전수 확인. kube-proxy 제거 시 전부 명시적 선언 대상
도메인·인증서·SNI 영향도를 별도 체크리스트로 분리 — 인그레스 변경이 도메인 배치에 영향을 준다면 SSO/프록시 계층의 SNI 설정을 사전 점검 항목으로 고정
타임아웃 3개 값 세트를 설치 전 고객사 prerequisite에 포함 — 기본값을 300초로 박아두고, 더 긴 값이 필요하면 설치 시점에 협의
적용 후 실측 확인 — 설정 전달과 반영은 별개. 타임아웃은 실제로 몇 초에 끊기는지 측정해서 확인
운영 절차 이관 — 설정값뿐 아니라 설정을 조회·변경하는 방법까지 문서화해서 전달
컴포넌트를 참조하는 목록 동시 갱신 — 감시 프로세스 목록, 방화벽 포트 목록 등 교체 대상 컴포넌트를 참조하는 산출물을 전환 작업 항목에 포함
왜 이런 일은 계속 반복될까 — 개인적인 생각
체크리스트를 아무리 촘촘하게 만들어도, 결국 비슷한 일은 또 일어날 거라고 생각합니다. 이유를 생각해보면 어쩌면 당연한 일입니다.
이번에 체크리스트에 추가한 항목들은 전부 한 번 당해본 뒤에야 쓸 수 있는 문장이었습니다. “kube-proxy가 암묵적으로 처리하던 것을 조사하라”는 항목은, kube-proxy가 무엇을 암묵적으로 처리하고 있었는지 알아야 쓸 수 있습니다. 그리고 그건 대개 그것이 사라진 뒤에 알게 됩니다. 다음 전환에서는 또 다른 암묵적 전제가 있을 것이고, 그건 지금의 체크리스트에 없을 것입니다.
그리고 또 하나 느끼는 건, 설치 중에 발생한 사소한 일들 — 기록으로 남기지 않은 것들은 시간이 지나면 결국 모두 사라진다는 것입니다. 이번 전환에서 이슈 하나하나를 트러블슈팅 문서로 남긴 이유도 그래서입니다. 남기지 않으면 다음에 똑같은 시행착오를 반복하면서도, 그게 반복이라는 사실조차 모르게 됩니다.
그래서 저는 “이런 일이 다시는 없을 것”이라고는 기대하지 않습니다. 대신 이번에 원인 파악에 하루가 걸린 이슈가 다음엔 반나절 걸린다면, 그것으로 충분히 좋은 발전이라고 생각합니다.
마치며
온프레미스 Kubernetes 기반 AI 인프라를 다수 고객사에 설치·운영하다 보면, 계획대로 되는 부분보다 계획 밖에서 터지는 부분이 실제 러닝 커브를 만든다는 걸 매번 느낍니다. Cilium 전환은 기술적으로는 성공한 작업이었고, 실제로 성능과 구조 양쪽에서 얻은 것이 있습니다. 다만 그 청구서가 전환 당일이 아니라 그 다음 열흘에 걸쳐 도착했을 뿐입니다.
앞으로도 AIPub 엔지니어링 블로그에서는 이런 “실제로 겪은 것”을 중심으로 이야기를 이어가려고 합니다.
이 글은 실제 고객사 전환 작업 회고를 바탕으로, 고객 식별 정보(회사명, 도메인, IP, 내부 설정값 등)를 제외하거나 예시 값으로 치환하여 기술적 교훈 중심으로 재구성했습니다.