Cilium 이야기: 작은 코드 변경이 만들어낸 놀라운 네트워크 안정성 향상
서론
얼마 전에 이전 직장 동료의 Cilium 프로젝트에 대한 PR을 구경했습니다.
bpf:nat: Restore ORG NAT entry if it's not found
(테스트코드를 제외하고) 수정량 자체는 if
문 블록 하나 추가하는 정도로 적습니다. 하지만 이 수정이 가져온 영향력은 엄청나고 단순한 아이디어 하나가 시스템 안정성에 엄청나게 큰 기여를 할 수 있다는 사실이 개인적으로 재미있게 느껴져서 네트워크 분야에 전문지식이 없는 사람들도 쉽게 이 사례를 이해할 수 있도록 한번 이야기를 풀어보려고 합니다.
배경지식
스마트폰 만큼이나 중요한 현대인들의 필수품이 있다면 아마 와이파이 공유기가 아닐까 합니다. 와이파이 공유기는 와이파이라는 통신 규격을 통해 기기와 통신을 수행하며 자신이 가지고 있는 공인 IP 주소를 여러기기에서 사용할 수 있도록 공유해주는 역할을 수행합니다. 여기서 생기는 기술적 특이점은 어떻게 "공유"해 주는가? 입니다.
여기서 사용되는 기술이 바로 Network Address Translation (NAT) 입니다. NAT 은 TCP 혹은 UDP 통신이 IP 주소와 port 정보의 조합으로 이뤄진다는 점에서 사설 IP:Port
로 이뤄지는 내부통신을 현재 사용하고 있지 않는 공인 IP:Port
에 맵핑하여 외부와 통신을 수행할 수 있게 해주는 기술입니다.
NAT
NAT 장비는 내부 기기가 외부 인터넷에 접속하려고 할 때, 해당 기기의 사설 IP 주소와 포트 번호 조합을 자신의 공인 IP 주소와 사용하지 않는 임의의 포트 번호로 변환합니다. 이 변환 정보는 NAT 장비 내부의 NAT 테이블이라는 곳에 기록됩니다.
예를 들어, 집 안의 스마트폰(사설 IP: a.a.a.a
, 포트: 50000)이 웹 서버(공인 IP: c.c.c.c
, 포트: 80)에 접속하려고 한다고 가정해 봅시다.
1스마트폰 (a.a.a.a:50000) ==> 공유기 (b.b.b.b) ==> 웹 서버 (c.c.c.c:80)
공유기는 스마트폰의 요청을 받으면 다음과 같은 TCP Packet 을 보게될것입니다.
1# 공유기가 받은 TCP 패킷, 스마트폰 => 공유기
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| a.a.a.a | 50000 | c.c.c.c | 80 |
이 패킷을 그대로 웹 서버(c.c.c.c
)로 보냈다가는 사설 IP 주소를 가지는 스마트폰 (a.a.a.a
) 에게 응답이 돌아오지 않을것이므로, 공유기는 먼저 현재 통신에 관여하지 않는 임의의 port (예: 60000) 를 찾아내고 내부 NAT 테이블에 기록해둡니다.
1# 공유기 내부 NAT 테이블
2| local ip | local port | global ip | global port |
3-----------------------------------------------------
4| a.a.a.a | 50000 | b.b.b.b | 60000 |
공유기는 NAT 테이블에 새 항목을 기록한 후, 스마트폰에서 받은 TCP 패킷의 출발지 IP 주소와 포트 번호를 자신의 공인 IP 주소(b.b.b.b
)와 새로 할당한 포트 번호(60000)로 변경하여 웹 서버로 전송합니다.
1# 공유기가 보낸 TCP 패킷, 공유기 => 웹 서버
2# SNAT 수행
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| b.b.b.b | 60000 | c.c.c.c | 80 |
이제 웹 서버(c.c.c.c
)는 공유기(b.b.b.b
)의 60000번 포트에서 온 요청으로 인식하고, 응답 패킷을 다음과 같이 공유기로 보냅니다.
1# 공유기가 받은 TCP 패킷, 웹 서버 => 공유기
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| c.c.c.c | 80 | b.b.b.b | 60000 |
공유기는 이 응답 패킷을 받으면 NAT 테이블에서 목적지 IP 주소(b.b.b.b
)와 포트 번호(60000)에 해당하는 원래의 사설 IP 주소(a.a.a.a
)와 포트 번호(50000)를 찾아내어 패킷의 목적지를 스마트폰으로 변경합니다.
1# 공유기가 보낸 TCP 패킷, 공유기 => 스마트폰
2# DNAT 수행
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| c.c.c.c | 80 | a.a.a.a | 50000 |
이 과정을 통해 스마트폰은 마치 자신이 직접 공인 IP 주소를 가지고 웹 서버와 통신하는 것처럼 느껴지게 됩니다. NAT 덕분에 하나의 공인 IP 주소로 여러 개의 내부 기기가 동시에 인터넷을 사용할 수 있게 되는 것이죠.
Kubernetes
쿠버네티스는 요 근래 나온 기술들 중에 최고로 정교하면서도 복잡한 네트워크 구조를 가지고 있습니다. 그리고 물론 앞서 말한 NAT 도 다양한 곳에서 활용되고 있습니다. 대표적인 사례가 다음 두가지 입니다.
Pod 내부에서 클러스터 외부와 통신을 수행할 때
쿠버네티스 클러스터 내부의 파드들은 일반적으로 클러스터 네트워크 내에서만 통신할 수 있는 사설 IP 주소를 할당받습니다. 따라서 파드가 외부 인터넷과 통신하려면 클러스터 외부로 나가는 트래픽에 대해 NAT가 필요합니다. 이때 NAT는 주로 해당 파드가 실행되고 있는 쿠버네티스 노드(클러스터의 각 서버)에서 수행됩니다. 파드가 외부로 향하는 패킷을 보내면, 해당 패킷은 먼저 파드가 속한 노드로 전달됩니다. 노드는 이 패킷의 출발지 IP 주소(파드의 사설 IP)를 자신의 공인 IP 주소로 바꾸고, 출발지 포트도 적절히 변경하여 외부로 전달합니다. 이 과정은 앞서 와이파이 공유기에서 설명한 NAT 과정과 유사합니다.
예를 들어, 쿠버네티스 클러스터 안의 파드 (10.0.1.10
, 포트: 40000)가 외부의 API 서버 (203.0.113.45
, 포트: 443)에 접속한다고 가정하면 쿠버네티스 노드는 다음과 같은 패킷을 파드로부터 받게 되고
1# 노드가 받은 TCP 패킷, 파드 => 노드
2| src ip | src port | dst ip | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000 | 203.0.113.45 | 443 |
노드는 다음과 같은 내용을 기록한 뒤
1# 노드 내부 NAT 테이블 (예시)
2| local ip | local port | global ip | global port |
3---------------------------------------------------------
4| 10.0.1.10 | 40000 | 192.168.1.5 | 50000 |
다음과 같이 SNAT을 수행한 후 외부로 패킷을 보내게 됩니다.
1# 노드가 보낸 TCP 패킷, 노드 => API 서버
2# SNAT 수행
3| src ip | src port | dst ip | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000 | 203.0.113.45 | 443 |
그 후 과정은 앞서 스마트폰 공유기 사례와 같은 과정을 거치게 됩니다.
클러스터 외부에서 NodePort 를 통해서 Pod 와 통신할 때
쿠버네티스에서 서비스를 외부로 노출하는 방법 중 하나는 NodePort 서비스를 사용하는 것입니다. NodePort 서비스는 클러스터 내의 모든 노드의 특정 포트(NodePort)를 열어두고, 이 포트로 들어오는 트래픽을 서비스에 속한 파드들로 전달하는 방식입니다. 외부 사용자는 클러스터의 노드의 IP 주소와 NodePort를 통해 서비스에 접근할 수 있습니다.
이때 NAT는 중요한 역할을 수행하며, 특히 DNAT (Destination NAT) 와 SNAT (Source NAT) 가 동시에 발생합니다. 외부에서 특정 노드의 NodePort로 트래픽이 들어오면, 쿠버네티스 네트워크는 이 트래픽을 최종적으로 해당 서비스를 제공하는 파드로 전달해야 합니다. 이 과정에서 먼저 DNAT가 발생하여 패킷의 목적지 IP 주소와 포트 번호가 파드의 IP 주소와 포트 번호로 변경됩니다.
예를 들어, 클러스터 외부의 사용자 (203.0.113.10
, 포트: 30000)가 쿠버네티스 클러스터의 한 노드 (192.168.1.5
)의 NodePort (30001
)를 통해 서비스에 접근한다고 가정해 봅시다. 이 서비스는 내부적으로 IP 주소가 10.0.2.15
이고 포트가 8080
인 파드를 가리키고 있다고 가정합니다.
1외부 사용자 (203.0.113.10:30000) ==> 쿠버네티스 노드 (외부:192.168.1.5:30001 / 내부: 10.0.1.1:42132) ==> 쿠버네티스 파드 (10.0.2.15:8080)
여기서 쿠버네티스 노드의 경우 외부에서 접근 가능한 노드의 IP 주소 192.168.1.5 와 내부 쿠버네티스 네트워크에서 유효한 IP 주소 10.0.1.1 모두를 가지고 있습니다. (사용하는 CNI 종류에 따라서 이와 관련된 정책은 다양해지지만 이 글에서는 Cilium 을 기준으로 설명을 진행합니다.)
외부 사용자의 요청이 노드에 도착하면, 노드는 이 요청을 처리할 파드로 전달해야 합니다. 이때 노드는 다음과 같은 DNAT 규칙을 적용하여 패킷의 목적지 IP 주소와 포트 번호를 변경합니다.
1# 노드가 파드로 보낼려고 준비중인 TCP 패킷
2# DNAT 적용 후
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000 | 10.0.2.15 | 8080 |
여기서 중요한 점은 파드가 이 요청에 대한 응답을 보낼 때, 출발지 IP 주소는 자신의 IP 주소(10.0.2.15
)이고 목적지 IP 주소는 요청을 보낸 외부 사용자의 IP 주소(203.0.113.10
)가 됩니다. 이럴경우 외부 사용자는 자신이 요청한적도 없는 존재하지 않는 IP 주소로부터 응답을 받고 해당 패킷을 그냥 DROP 해버립니다. 그래서 쿠버네티스 노드는 파드가 외부로 응답 패킷을 보낼 때 SNAT를 추가적으로 수행하여 패킷의 출발지 IP 주소를 노드의 IP 주소 (192.168.1.5 또는 내부 네트워크 IP인 10.0.1.1, 이 경우 10.0.1.1 로 진행) 로 변경합니다.
1# 노드가 파드로 보낼려고 준비중인 TCP 패킷
2# DNAT, SNAT 적용 후
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 10.0.1.1 | 40021 | 10.0.2.15 | 8080 |
이제 해당 패킷을 받은 파드는 처음에 NodePort 로 요청을 받은 노드로 응답을 하게되고, 노드는 똑같은 DNAT, SNAT 과정을 역적용해 외부사용자에게 정보를 되돌려주게 됩니다. 이 과정에서 각 노드는 다음과 같은 정보를 저장하게 될 것입니다.
1# 노드 내부 DNAT 테이블
2| original ip | original port | destination ip | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5 | 30001 | 10.0.2.15 | 8080 |
5
6# 노드 내부 SNAT 테이블
7| original ip | original port | destination ip | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10 | 30000 | 10.0.1.1 | 42132 |
본론
일반적으로 리눅스에서 이러한 NAT 과정들은 iptables 를 통해 conntrack 이라는 서브시스템에 의해서 관리되고 동작합니다. 실제로 flannel 이나 calico 등의 다른 CNI 프로젝트에서는 이를 활용해 위와같은 문제를 처리하고 있습니다. 다만 문제는 Cilium 은 eBPF 라는 기술을 사용하면서 이러한 전통적인 리눅스 네트워크 스택을 싹다 무시해버린다는 점 입니다. 🤣
그 결과 Cilium 은 위 그림에서 기존 리눅스 네트워크스택이 해주던 업무들 중 Kubernetes 상황에서 필요한 기능들만 직접 구현하는 길을 채택했습니다. 그래서 앞서 말한 SNAT 과정에 대해서 Cilium 은 SNAT 테이블을 LRU Hash Map (BPF_MAP_TYPE_LRU_HASH) 형태로 직접 관리하고 있습니다.
1# Cilium SNAT 테이블
2# !쉬운 설명을 위한 예시. 실제 정의는: https://github.com/cilium/cilium/blob/v1.18.0-pre.1/bpf/lib/nat.h#L149-L166
3| src ip | src port | dst ip | dst port | protocol, conntrack 등 기타등등 메타데이터
4----------------------------------------------
5| | | | |
그리고 Hash Table 인 만큼 빠른 조회를 위해서 키값이 존재하는데 src ip
, src port
, dst ip
, dst port
조합을 키값으로 사용하고 있습니다.
문제 인식
현상 - 1: 조회
그러다보니 발생하는 문제점이 한가지, eBPF 를 거치는 패킷이 NAT 과정에 대해서 SNAT 혹은 DNAT 과정을 수행할 필요가 있는 패킷인지 검증하기 위해 위 Hash Table 을 조회를 수행해야 하는데 앞서 봐왔던 것 처럼 SNAT 과정에는 두가지 종류의 패킷이 존재합니다. 1. 내부에서 외부로 나가는 패킷, 그리고 2. 그 응답으로 외부에서 내부로 들어오는 패킷. 이 두 패킷은 NAT 과정에 대한 변환이 필요하면서도 src ip, port 와 dst ip, port 값이 뒤바뀐다는 특징이 있습니다.
그래서 빠른 조회를 위해서 src 와 dst 가 바뀐 값을 key 로 Hash Table 에 값을 하나 더 추가하던가, SNAT과 상관 없는 패킷일지라도 모든 패킷에 대해서 같은 Hash Table 을 두번씩 조회를 할 필요가 있습니다. 당연히 Cilium 은 더 좋은 성능을 위해 RevSNAT 이라는 이름으로 같은 데이터를 두번 집어넣는 방식을 채택하였습니다.
현상 - 2: LRU
그리고 위 문제와 별개로 모든 하드웨어에 무한한 리소스는 존재할 수 없고, 특히나 빠른 성능이 요구되는 하드웨어단 로직인 만큼 동적인 자료구조도 사용할 수 없는 상황에서 자원이 부족할때 기존 데이터를 evict 할 필요가 있습니다. Cilium 에서는 이를 리눅스에서 기본으로 제공하는 기본 자료구조인 LRU Hash Map 을 사용함으로서 해결하였습니다.
현상 1 + 현상 2 = 커넥션 유실
https://github.com/cilium/cilium/issues/31643
즉 하나의 SNAT 된 TCP (혹은 UDP) 커넥션에 대해
- 하나의 Hash Table 에 나가는 패킷, 들어오는 패킷에 대하여 같은 데이터가 두번 기록되어 있고
- LRU 로직에 따라서 언제든 둘 중 하나의 데이터가 유실될 수 있는 상황에서
외부로 나가는 혹은 외부에서 들어오는 패킷에 대한 NAT 정보 (이하 entry) 중 하나라도 LRU 에 의해서 사라지게 되면 정상적으로 NAT을 수행하지 못해 전체 커넥션 유실로 이어질 수 있다는 것 입니다.
해결
여기서 앞서 언급했었던 다음 PR 들이 등장합니다.
bpf:nat: restore a NAT entry if its REV NAT is not found
bpf:nat: Restore ORG NAT entry if it's not found
기존에는 패킷이 eBPF 를 거칠 때 SNAT 테이블에서 src ip, src port, dst ip, dst port 조합으로 키를 만들어 조회를 시도합니다. 만약 키가 존재하지 않으면 SNAT 규칙에 따라 새로운 NAT 정보를 생성하고 테이블에 기록합니다. 새로운 커넥션일 경우 이는 정상적인 통신으로 이어질 것 이고, 만약 LRU 에 의해서 의도치않게 키가 제거된 경우라면 기존 통신에 사용하던 포트와 다른 포트로 새롭게 NAT을 수행하게 되면서 패킷을 받는쪽에서 수신을 거부할 것이고 RST 패킷과 함께 커넥션이 종료될 것 입니다.
여기서 위 PR 이 접근한 방식은 단순합니다.
어느 방향이던 패킷이 한번 관측되면 그 역방향에 대한 entry 도 새롭게 갱신해주자.
어느 방향이던 통신이 관측되면 양쪽 entry 모두 새롭게 갱신되면서 LRU 로직의 Eviction 우선순위 대상에서 멀어지게 되고 이를 통해서 어느 한쪽 entry 만 삭제되어 전체 통신이 붕괴되는 시나리오가 될 가능성을 낮출 수 있게 됩니다.
이는 매우 단순한 접근 방식이고 간단한 아이디어처럼 보일 수 있지만 이러한 접근방식을 통해 응답 패킷에 대한 NAT 정보가 먼저 만료되어 연결이 끊어지는 문제를 효과적으로 해결하고 시스템의 안정성을 크게 향상시킬 수 있게 되었습니다. 또 네트워크 안정성 측면에서 다음과 같은 성과를 이루어낸 중요한 개선이라고 할 수 있습니다.
결론
저는 이 PR 이 NAT 란 어떻게 동작하는지에 대한 기초적인 CS 지식부터 시작해 복잡한 시스템 내부에서도 간단한 아이디어 하나가 얼마나 큰 변화를 가져올 수 있는지를 보여주는 아주 좋은 사례라고 생각합니다.
아, 물론 복잡한 시스템의 사례를 이번 글에서 직접적으로 보여드리진 않았습니다. 하지만 이 PR 을 제대로 이해하기위해 저는 DeepSeek V3 0324
에게 Please
라는 단어까지 붙이면서 3시간 가까이 구걸을 했고, 그 결과 Cilium 에 대한 지식 +1 과 아래와 같은 그림을 하나 얻을 수 있게 되었습니다. 😇
그리고 이슈들과 PR 을 읽어보면서 제가 예전에 만들어둔 무언가 때문에 이슈가 발생했을 것 같은 불길한 예감들에 대한 보상심리로 이러한 글을 작성해 봅니다.
후기 - 1
참고로 이 이슈는 아주 효과적인 이슈 회피법이 존재합니다. 이슈가 발생하는 근본 원인이 NAT 테이블 공간 부족이므로 NAT 테이블 크기를 늘리면 됩니다. :-D
누군가는 같은 이슈를 만났을때 이슈도 남기지 않고 NAT 테이블 크기 늘려놓고 도망갔을 상황에, 본인과 직접적으로 연관된 이슈가 아님에도 불구하고 철저하게 분석하고 이해하고 객관적인 근거데이터와 함께 Cilium 생태계에 기여까지 해주신 gyutaeb 님의 열정에 감탄하였고 존경합니다.
이 글을 작성하고자 결심하게된 계기였습니다.
후기 - 2
이 이야기는 Go 언어를 전문적으로 다루는 Gosuda 와는 사실 직접적으로 어울리는 주제는 아니긴 합니다. 하지만 Go 언어와 클라우드 생태계는 밀접한 관련이 있고 Cilium 의 기여자들은 Go 언어에 어느정도 소양이 있는 만큼 개인 블로그에 올릴 수 있는 내용을 Gosuda 로 한번 가져와 봤습니다.
관리자 중 한명 (저 자신) 의 허락이 있었기 때문에 아마 괜찮을 것이라고 생각합니다.
안괜찮다고 생각하신다면 언제 삭제될지 모르니 얼른 PDF 로 저장해두시길 바랍니다. ;)
후기 - 3
이번 글 작성에는 Cline 과 Llama 4 Maveric 의 도움을 크게 받았습니다. 비록 Gemini 로 분석을 시작했고, DeepSeek 에게 구걸했지만 정작 도움은 Llama 4 에게 받았습니다. Llama 4 좋습니다. 꼭 써보세요.