Kubernetes에서 통신사의 IP주소를 LoadBalancer에 할당한 깔끔한 방법
통신사 DHCP로 받은 IPv4를 Kubernetes LoadBalancer의 External IP로 쓰기까지 - 조용한 DHCP 클라이언트, eBPF ARP 응답, 그리고 비대칭 라우팅을 DSCP 태깅으로 푼 과정.
IP 주소의 필요성
Kubernetes에서 여러 서비스를 구동하려면 IP 주소가 여러 개 필요합니다. 서비스별로 할당하고, 관리용으로도 할당하죠. 큰 기업들은 주로 IP를 구매합니다. 그리고 통신사와 트랜짓 계약을 맺어서 IP를 광고하죠. 그러나 아이피 구매와 트랜짓 계약은 연에 수 천만원대의, 작은 서비스 입장에선 꽤나 부담스러운 큰 소비를 요구합니다. AS402790을 운영하고 있지만, 지금 당장 IPv4를 대여/소유하는 것은 현실적으로 불가능합니다.
통신사는 합리적인 가격에 IPv4 주소를 대여해 줍니다. 통신사 모뎀은 DHCP를 통해 최대 8개가량의 유동적인 IPv4를 할당해 줍니다. 이 IP주소는 서버가 켜져 있는 동안에는 드물게 바뀌고, 소규모 서비스를 하기에 개수도 적당합니다. 이제 우리의 목표는 이 IP가 Kubernetes LoadBalancer의 External IP로 인식되게 하는 것입니다.
기존 LoadBalancer와 이들의 문제점
Attacca 클러스터는 Cilium을 LoadBalancer로 사용합니다. 여기에 DHCP를 통해 IP를 받는 레이어를 추가해야 합니다.
MetalLB는 DHCP를 지원하지 않고, dhcp-cni-plugin은 비-kubernetes 노드에서 IP를 여러 개 받는 데에는 적합하지 않습니다. 라즈베리파이급 사양의 엣지 라우터에서 native하게 돌아가는 Kubernetes LoadBalancer가 필요합니다.
간단한 문제들
트래픽은 Client -> 엣지 라우터 -> 클러스터 라우터 -> Kubernetes 클러스터 로 흐릅니다. 단순한 문제들은 다음과 같이 해결합니다:
- 엣지 라우터에서 IP주소 여러 개 받기 - OpenVSwitch로 가상 스위치 구축
- 엣지 라우터와 클러스터 라우터의 리전간 통신 - WireGuard 터널 사용 이제 엣지 라우터는 8개의 가상 네트워크 인터페이스를 가지고 있고, 모두 개별적인 MAC주소로 통신사와 연결됩니다.
Forward 트래픽 처리
얻어낼 IP 주소가 1.2.3.4라고 가정하겠습니다. 이 구성의 중요한 점은, 통신사는 엣지 라우터가 1.2.3.4라고 인식해야 하고, 엣지 라우터는 1.2.3.4가 WireGuard 터널 너머 클러스터 라우터에 있다고 인식해야 합니다.
우선 후자부터, 구성 요소들의 Linux 커널에 수동으로 경로를 할당하지 않고, 엣지 라우터에 도착한 1.2.3.4행 패킷을 자연스럽게 WireGuard -> 클러스터 라우터 -> K8s 노드 로 향하게 하기 위해, BGP를 사용했습니다. 두 라우터 노드에게 개별 Private ASN을 할당해주고, Cilium이 Kubernetes에 있는 CiliumLoadBalancerIPPool을 BGP로 광고하게 CiliumBGPPeerConfig를 추가했습니다. 이제 Cilium에 1.2.3.4/32 IP 풀을 등록하면, BGP가 빠르게 엣지 라우터까지 1.2.3.4에 대한 정보를 전파시키고, 엣지 라우터에서 1.2.3.4에 접속이 가능합니다.
여기서 dumbarp라는 데몬을 만들었습니다. dumbarp-k8s 컨테이너가 1분마다 엣지 라우터에게 IP주소를 요청하고, CiliumLoadBalancerIPPool를 그에 맞게 업데이트합니다.
이제 전자 문제입니다. 통신사는 엣지 라우터가 1.2.3.4이게 인식해야 하는데, 엣지 라우터의 인터페이스에는 1.2.3.4 IP가 등록되어 있으면 안됩니다. 엣지 라우터의 인터페이스에 IP가 등록되어 있으면 엣지 라우터가 1.2.3.4가 엣지 라우터에서 구동중인 서비스를 향한 패킷(local-bound)인 줄 알고 Connection Refused를 띄워버리기 때문이죠. 이를 위해 두 가지 구현이 필요합니다.
조용한 DHCP 클라이언트
조용한 DHCP 클라이언트가 필요합니다. DHCP서버에서 IP를 요청하고 갱신하되, Linux 인터페이스의 IP 주소로 추가하지는 말아야 합니다. 또한, dumbarp-k8s가 요청할 수 있게 임대한 IP주소를 API로 노출시킬 수 있어야 합니다.
dhcpcd의 post-lease hook같은 여러 방법이 있었지만, 간단하고 Monolithic한 설계를 위해, Rust의 mozim crate를 활용해 위 조건을 모두 충족하는 DHCP 클라이언트를 dumbarpd에 내장시켰습니다.
조용한 ARP 응답
통신사 라우터는 당연히 1.2.3.4를 찾기 위해 ARP를 사용합니다. 1.2.3.4를 향하는 패킷이 있을 때, 통신사 말단 라우터에서 어떤 포트가 1.2.3.4인지 찾아야 하기 때문이죠.
Linux는 기본적으로 인터페이스에 IP가 등록되어야 ARP 요청에 응답을 보냅니다. 그러나 아까 말했듯이, 인터페이스에 IP가 등록되어 있으면 Linux 패킷을 다른 곳으로 라우팅하지 않고 Connection Refused를 띄워버리죠.
다행이 ARP패킷은 단순했습니다. 요청이 들어왔을 때 응답만 보내주면 되죠. 여러 방법 중에 가장 빠르고 단순한 eBPF를 선택했습니다. Rust의 aya crate를 사용해 만든 간단한 eBPF 프로그램이 XDP(네트워크 인터페이스 근처)에 후킹해 리눅스 네트워킹 시스템에 닿기도 전에 ARP에 응답합니다. 이 eBPF 프로그램도 dumbarpd에 내장시켰습니다.
이렇게 조용한 두 프로그램을 통해 통신사에서 IP를 받고 그것을 Kubernetes로 라우팅했습니다.
응답 트래픽 처리
1.2.3.4에 HTTP 서버를 하나 켜 두고, 엣지 라우터에서 curl 1.2.3.4를 하면 응답이 매우 잘 돌아옵니다. 앞서 한 세팅이 잘 작동하죠. 서버실 1층 카페에서 1.2.3.4에 접속해도 정말 잘 작동합니다.
그러나 문제가 있습니다. 다른 건물, 혹은 심지어 해외에서 접속하려고 시도하면, 접속이 되지 않고 Connection Timed Out가 뜹니다. 흥미로운 문제죠. 엣지 라우터 바로 옆 건물에선 되는데 집에선 접속이 안 되는 현상이니까요. 일단 tcpdump로 모든 패킷의 경로를 분석 해 봤습니다.
원인은 비대칭 라우팅이었습니다. 제 집의 IP가 4.3.2.1이라고 해보죠. 4.3.2.1에서 1.2.3.4로 요청을 보내면, 모든 파이프라인을 거쳐서 Kubernetes 클러스터에 도달합니다. 그리고 열어둔 HTTP서버는 이에 대한 응답 패킷을 보냅니다. 이는 1.2.3.4->4.3.2.1의 IP패킷이죠. 그러나 여기서 라우팅 문제가 터집니다. Kubernetes 클러스터 입장에서 4.3.2.1은 인터넷에 있는 모르는 default route입니다. 그래서 클러스터는 이 응답 패킷을 엣지 라우터로 되돌려보내는 대신, 바로 인터넷으로 보내버립니다.
여기서 통신사의 방화벽이 같은 건물에서 온 요청과 집에서 온 요청의 행동 차이를 만들어냅니다. 같은 건물에서 요청을 보내면 같은 통신사 스위치를 통하기에, 1.2.3.4에서 오는 반환 패킷이 어디서 오든 상관이 없죠. L2니까요. 그러나 제 집에서 요청을 보내면, 4.3.2.1로 돌아가는 패킷이 엣지 라우터가 아닌 이상한 곳에서 나온다면, 통신사 방화벽은 이 패킷을 drop시켜버립니다. 그래서 이런 문제가 터졌던 거죠.
첫 번째 시도: Source IP 기반 라우팅
문제는 클러스터 라우터였습니다. 클러스터 라우터가 4.3.2.1행 패킷을 엣지 라우터를 통하지 않고 보내버린거죠. 이 문제를 직면한 입장에서, 가장 단순한 해결안은 클러스터 라우터가 1.2.3.4에서 오는 모든 패킷을 엣지 라우터로 보내게 하는 것이었습니다. 그래서 dumbarp-gateway를 도입합니다.
dumbarp-gateway는 클러스터 라우터에서 돌아가는 데몬입니다. 이는 dumbarpd가 얻은 모든 IP(예: 1.2.3.4)에 대해, Source IP가 그 IP(1.2.3.4)인 패킷을 해당 노드(dumbarpd가 구동중인 엣지 라우터)로 라우팅하는 Policy Routing 테이블을 생성합니다. 결과는 성공적이었습니다. 집에서 1.2.3.4에 접속이 잘 됐죠.
비대칭에서 비대칭으로
그러나 여기서 또 문제가 터집니다. 서버실에서 1.2.3.4에 접속이 되지 않았죠. 서버실 내의 핵심 네트워크는 1.2.3.4를 광고하는 Kubernetes 클러스터와 피어링되어서, 엣지 라우터를 통하지 않고 다이렉트로 Kubernetes 클러스터에 도달합니다. 그러나 dumbarp-gateway 는 1.2.3.4에서 오는 모든 패킷을 엣지 라우터로 보냅니다. 이번엔 서버실 노트북으로 가야 하는 반환 패킷이 엣지 라우터로 나가버렸죠.
여기서 해결해야하는 핵심 문제는, 엣지 라우터에서 온 1.2.3.4행 패킷은 엣지 라우터로 되돌려보내고, 나머지는 자유롭게 라우팅되게 하는 것입니다.
dumbarp의 꽃
여기서 dumbarp의 꽃이 등장합니다. 바로 DSCP 태깅이죠. 우선 우리는 서버실의 모든 라우터들이 어떤 IP패킷을 보고 “이 패킷은 엣지 라우터 1에서 온 것이다” 를 알아야 합니다.
IPv4패킷에는 기본적으로 커스텀 헤더가 없습니다. 그러나 운 좋게도, 비슷한 용도로 쓰일만한 DSCP 필드가 있습니다. 원래는 QoS 식별용 6비트 정수이지만, dumbarp는 이를 엣지 라우터 식별용으로 사용합니다.
dumbarpd에 패킷이 들어왔을 때, eBPF는 이 패킷의 DSCP필드를 등록된 엣지 라우터의 ID로 변경합니다.
모든 라우터에는 dumbarp-routerd 데몬이 돌아갑니다. dumbarp-routerd는 라우터에서 eBPF를 통해 커넥션을 트래킹하고, DSCP필드에 엣지 라우터 ID가 등록되어 있다면 해당 패킷에 connmark를 추가합니다. 그리고 dumbarp-routerd는 connmark를 가진 패킷이 해당하는 엣지 라우터로 라우팅되는 Policy Routing 규약을 Linux에 등록합니다.
추가적으로, 내부적으로 쓰이는 DSCP필드가 통신사로 나가는 것을 막기 위해, dumbarpd는 패킷의 기존 DSCP필드를 기억해두고 필요 시 나가는 패킷의 DSCP필드를 원상복구합니다.
완성
이런 여정으로 dumbarp 시리즈가 개발되었습니다. 그리고 서버실, 집, 옆 건물 모두에서 Attacca에 원활한 접근이 가능해졌습니다.