Cloudflare가 soft-unicast를 활용해 리눅스 네트워킹 스택의 한계를 넘으려다 마주한 conntrack/NAT 상호작용, 라우팅, early demux 문제와 우회법, 그리고 최종 설계 선택을 공유합니다.
2025-10-29
읽는 데 11분

이런 가설이 있습니다. 누군가가 리눅스 네트워킹 스택이 정확히 무엇을 왜 하는지 완벽히 밝혀내는 순간, 그것은 즉시 사라지고 더 기괴하고 설명하기 어려운 무언가로 대체될 것이라는 가설이죠.
또 다른 가설은, 그 일이 지금까지 몇 번 일어났는지 추적하기 위해 Git이 만들어졌다는 것입니다.
Cloudflare의 많은 제품은 네트워크 하드웨어와 소프트웨어의 한계를 밀어붙이지 않고서는 불가능합니다. 성능을 개선하고 효율을 높이며, 데이터센터 간 IP 서브넷 공유 방식인 soft-unicast 같은 새로운 기능을 구현하기 위해서죠. 다행히도 대부분의 사람은 운영체제가 네트워크와 인터넷 접근을 어떻게 처리하는지 깊숙한 부분까지 알 필요가 없습니다. 네, Cloudflare 내부의 대부분의 사람도 마찬가지입니다.
하지만 때로는 리눅스 네트워킹 스택의 설계 의도를 훨씬 넘어서는 시도를 하게 됩니다. 이 글은 그중 하나에 대한 이야기입니다.
이전에 올린 리눅스 네트워킹 스택 관련 글에서, soft-unicast의 이상적인 모델을 IP 패킷 포워딩 규칙의 기본적인 현실과 어떻게 맞출지에 대한 문제를 예고한 바 있습니다. Soft-unicast는 머신 간에 IP 주소를 공유하는 당사의 방식입니다. 우리가 이를 통해 하는 멋진 일들은 여기에서 확인할 수 있습니다. 한 대의 머신 입장에서 보면, 수십에서 수백 개에 달하는 IP 주소와 소스 포트 범위의 조합 중 아무 것이나 아웃바운드 연결에 사용될 수 있습니다.
iptables의 SNAT 타깃은 NAT 시 선택되는 포트를 제한하는 소스 포트 범위 옵션을 지원합니다. 이론적으로는 이 목적에 iptables를 계속 사용할 수 있고, 여러 개의 IP/포트 조합을 지원하기 위해 별도의 패킷 마크를 쓰거나 여러 개의 TUN 디바이스를 사용할 수 있습니다. 하지만 실제 배포에서는 방대한 수의 iptables 규칙과 가능하다면 네트워크 디바이스를 관리해야 하는 부담, 다른 용도의 패킷 마크와의 간섭, 기존 IP 범위의 배포 및 재할당 문제 같은 도전에 부딪히게 됩니다.
방화벽의 부담을 늘리는 대신, 우리는 soft-unicast 주소 공간으로 IP 패킷을 이그레스하는 데 특화된 단일 목적의 서비스를 만들었습니다. 오랜 세월 속에 이유는 잊혀졌지만, 우리는 이를 SLATFATF라 명명했고 줄여서 “fish”라고 부릅니다. 이 서비스의 유일한 책임은 soft-unicast 주소 공간을 사용해 IP 패킷을 프록시하고, 그 주소의 임대를 관리하는 것입니다.
WARP만이 우리 네트워크에서 soft-unicast IP 공간을 사용하는 유일한 존재는 아닙니다. 많은 Cloudflare 제품과 서비스가 soft-unicast 기능을 활용하며, 이들 중 상당수는 HTTP 연결이나 기타 TCP 기반 프로토콜을 프록시하거나 운반하기 위해 TCP 소켓을 생성하는 시나리오에서 이를 사용합니다. 따라서 fish는 열린 소켓이 사용하지 않는 주소를 임대하고, fish가 임대한 주소로는 소켓을 열 수 없도록 보장해야 합니다.
첫 번째 시도는 fish에서 클라이언트별로 구분되는 주소를 사용하고 Netfilter/conntrack이 계속 SNAT 규칙을 적용하게 두는 것이었습니다. 하지만 우리는 패킷 리라이트를 사용할 때 적나라하게 드러나는, 리눅스의 소켓 서브시스템과 Netfilter conntrack 모듈 간의 유감스러운 상호작용을 발견했습니다.
soft-unicast 주소 조각(slice) 198.51.100.10:9000-9009가 있다고 가정해 봅시다. 그리고 198.51.100.10:9000에서 203.0.113.1:443으로 연결되는 TCP 소켓을 바인드하려는 서로 다른 두 개의 프로세스가 있다고 합시다. 첫 번째 프로세스는 성공적으로 수행할 수 있지만, 두 번째 프로세스가 연결을 시도하면 에러를 받게 됩니다. 이미 해당 5-튜플과 일치하는 소켓이 존재하기 때문입니다.
소켓을 생성하는 대신, 동일한 목적지 IP에 대해 서로 다른 소스 IP를 사용해 TUN 디바이스로 패킷을 내보내고, 소스 NAT로 그 패킷을 이 범위의 주소로 리라이트하면 어떻게 될까요?
소스 주소를 198.51.100.10:9000-9009로 리라이트하는 nftables의 “snat” 규칙을 추가하면, Netfilter는 fishtun에서 관찰되는 각 신규 연결마다 conntrack 테이블에 엔트리를 만들고, 새 소스 주소를 원래 주소에 매핑합니다. 같은 TUN 디바이스에서 동일한 목적지 IP로 더 많은 연결을 포워딩하려고 하면, 요청한 범위에서 새로운 소스 포트를 선택합니다. 사용할 수 있는 10개의 포트가 모두 할당될 때까지요. 그 다음에는 기존 연결이 만료되어 conntrack 테이블의 엔트리가 해제될 때까지 새로운 연결은 드롭됩니다.
소켓을 바인드할 때와 달리, Netfilter는 conntrack 테이블에서 첫 번째 빈 공간을 그냥 집어 듭니다. 하지만 가능한 엔트리를 모두 소진하면 IP 패킷을 쓸 때 EPERM 에러를 받게 됩니다. 어느 쪽이든, 커널 소켓을 바인드하든 conntrack으로 패킷을 리라이트하든, 요구 사항에 맞는 빈 엔트리가 없을 때는 에러로 알 수 있습니다.
이제 두 접근법을 결합한다고 해봅시다. 첫 번째 프로세스가 TUN 디바이스에서 soft-unicast 포트 범위로 리라이트되는 IP 패킷을 하나 내보냅니다. 이어서 두 번째 프로세스가 그 IP 패킷과 동일한 주소로 TCP 소켓을 바인드하고 connect를 호출합니다.
첫 번째 문제는, 두 번째 프로세스가 connect()를 호출하는 시점에 198.51.100.10:9000에서 203.0.113.1:443으로의 활성 연결이 존재한다는 사실을 알 방법이 없다는 점입니다. 두 번째 문제는, 그럼에도 불구하고 두 번째 프로세스 입장에서는 연결이 성공한다는 점입니다.
두 개의 연결이 같은 5-튜플을 공유하는 일은 있어서는 안 됩니다. 실제로도 그렇지 않습니다. 대신 TCP 소켓의 소스 주소가 조용히 다음 사용 가능한 포트로 리라이트됩니다.
이 동작은 SNAT이나 MASQUERADE 규칙 없이 conntrack만 사용하더라도 나타납니다. 보통은 conntrack 엔트리의 수명이 관련 소켓의 수명과 일치하지만, 이는 보장되지 않으며, 소켓의 소스 주소가 생성된 IP 패킷의 소스 주소와 일치할 것이라 기대할 수도 없습니다.
soft-unicast 관점에서 결정적으로, 이것은 conntrack이 우리 머신에 할당된 포트 슬라이스 밖의 소스 포트로 연결을 리라이트할 수 있음을 의미합니다. 이 경우 연결은 조용히 깨지고, 불필요한 지연과 잘못된 연결 타임아웃 보고가 발생합니다. 다른 해결책이 필요합니다.
WARP의 경우, 우리는 IP 패킷을 리라이트하고 포워딩하는 것을 멈추고, 대신 서버 내에서 모든 TCP 연결을 종료한 뒤 올바른 soft-unicast 주소를 가진 로컬 생성 TCP 소켓으로 프록시하기로 했습니다. 이는 CDN으로 향하는 연결이나 Zero Trust Secure Web Gateway의 일부로 가로채는 연결 등, 이미 일부 연결에서 사용하던 손쉬운 실용적 해법이었습니다. 다만 기존 방식에 비해 리소스 사용이 추가되고 잠재적으로 지연이 늘어날 수 있습니다. 우리는 다른 포워딩 방법을 찾고 싶었습니다.
패킷 리라이트와 바운드 소켓을 둘 다 쓰려면, 단일한 기준을 정해야 합니다. Netfilter는 소켓 서브시스템을 알지 못하지만, 소켓을 사용하면서 soft-unicast도 인지하는 코드는 대부분 Cloudflare가 만들고 제어하는 코드입니다. 그래서 조금 더 젊었던 저는 Netfilter의 설계에 맞춰 우리 코드를 바꾸는 것이 말이 된다고 생각했습니다.
첫 시도는 conntrack 모듈에 대한 Netlink 인터페이스를 사용해, 소켓을 만들기 전에 커넥션 트래킹 테이블을 검사하고 조작하는 것이었습니다. Netlink는 다양한 리눅스 서브시스템을 위한 확장 가능한 인터페이스로, ip 같은 커맨드라인 도구나 우리 사례의 conntrack-tools 등이 사용합니다. 바인드하려는 소켓에 해당하는 conntrack 엔트리를 우리가 미리 만들면, conntrack이 연결을 잘못된 포트 번호로 리라이트하지 않게 보장하고, 매번 성공을 확실히 할 수 있습니다. 마찬가지로, 엔트리 생성에 실패하면 다른 유효한 주소를 시도할 수 있습니다. 이 접근은 우리가 소켓을 바인드하든 IP 패킷을 포워딩하든 상관없이 동작합니다.
문제는 이게 그다지 효율적이지 않다는 점입니다. Netlink는 바인드/커넥트 소켓 콤비 플레이에 비해 느리고, conntrack 엔트리를 만들 때는 흐름에 대한 타임아웃을 지정해야 하며 연결 시도가 실패하면 엔트리를 삭제해서, 특정 5-튜플에 대해 커넥션 테이블이 너무 빨리 가득 차지 않게 해야 합니다. 다시 말해, 리소스가 제한된 인기 목적지에 대해 높은 트래픽을 지원하려면 tcp_tw_reuse 옵션을 손수 재구현해야 합니다. 게다가, 엉뚱하게 날아든 RST 패킷 하나로 커넥션 트래킹 엔트리가 지워질 수 있습니다. 우리의 규모에서는 일어날 수 있는 일은 실제로 일어납니다. 취약한 해법이 설 자리는 아닙니다.
conntrack 엔트리를 만드는 대신, 커널 기능을 약간 변칙적으로 우리에게 유리하게 활용할 수 있습니다. 한동안 리눅스에는 TCP_REPAIR 소켓 옵션이 추가되어 있었는데, 본래는 서버 간 연결 마이그레이션(예: VM 재배치)을 지원하기 위한 것이었습니다. 이 기능의 범위는 새 TCP 소켓을 만들고 그 연결 상태 전체를 수동으로 지정하는 데까지 허용합니다.
이것을 다른 용도로 쓰면, TCP의 3-way 핸드셰이크를 수행하지 않았음에도 “연결된” 소켓을 만들 수 있습니다. 적어도 커널은 그렇게 하지 않습니다 — 만약 여러분이 TCP SYN이 담긴 IP 패킷을 포워딩하고 있다면, 세상의 예상 상태에 대해 더 많은 확신을 갖고 있을 수 있습니다.
하지만 TCP Fast Open의 도입으로 더 간단한 방법이 생겼습니다. 초기 페이로드와 함께 전송되는 SYN 패킷이 즉시 연결을 성립시키는 유효한 쿠키를 포함한다는 가정하에, 전통적인 3-way 핸드셰이크를 수행하지 않는 “연결된” 소켓을 만들 수 있습니다. 그러나 아무 것도 소켓에 쓰기 전까지는 전송되지 않으므로, 이는 우리의 필요를 완벽하게 충족합니다.
직접 해보세요:
TCP_FASTOPEN_CONNECT = 30
TCP_FASTOPEN_NO_COOKIE = 34
s = socket(AF_INET, SOCK_STREAM)
s.setsockopt(SOL_TCP, TCP_FASTOPEN_CONNECT, 1)
s.setsockopt(SOL_TCP, TCP_FASTOPEN_NO_COOKIE, 1)
s.bind(('198.51.100.10', 9000))
s.connect(('1.1.1.1', 53))
실제로는 존재하지 않는 소켓에 해당하지만 “연결된” 소켓을 바인드하는 것에는 중요한 특징이 하나 있습니다. 다른 프로세스가 동일한 주소로 소켓을 바인드하려 하면 실패한다는 점입니다. 이는 패킷 포워딩과 소켓 사용을 공존시키려던 처음의 문제를 만족시킵니다.
이로써 한 가지 문제는 해결했지만, 다른 문제가 생깁니다. 기본적으로 동일한 IP 주소를 로컬에서 발생한 패킷과 포워딩되는 패킷 양쪽에 동시에 사용할 수는 없습니다.
예를 들어, IP 주소 198.51.100.10을 TUN 디바이스에 할당한다고 해봅시다. 그러면 어떤 프로그램이든 198.51.100.10:9000 주소로 TCP 소켓을 만들 수 있습니다. 또한 198.51.100.10:9001 주소를 가진 패킷을 그 TUN 디바이스에 써 넣을 수도 있고, 리눅스를 적절히 구성하면 그 패킷은 TCP 소켓과 동일한 경로를 따라 게이트웨이로 포워딩됩니다. 여기까지는 좋습니다.
인바운드 경로에서는 198.51.100.10:9000으로 향하는 TCP 패킷은 받아들여져 TCP 소켓으로 데이터가 전달됩니다. 하지만 198.51.100.10:9001로 향하는 TCP 패킷은 드롭됩니다. 이들은 TUN 디바이스로 포워딩되지도 않습니다.
왜 그럴까요? 로컬 라우팅은 특별하기 때문입니다. 로컬 주소로 수신된 패킷은 “input”으로 취급되어, 여러분이 생각하는 어떠한 라우팅에도 상관없이 포워딩되지 않습니다. 기본 라우팅 규칙을 보시죠.
cbranch@linux:~$ ip rule
cbranch@linux:~$ ip rule
0: from all lookup local
32766: from all lookup main
32767: from all lookup default
규칙의 우선순위는 음수가 아닌 정수이며, 값이 작은 규칙부터 평가됩니다. 따라서 “맨 앞”에 룰을 삽입해 마크된 패킷을 패킷 포워딩 서비스의 TUN 디바이스로 리다이렉트하려면 다소 어색한 규칙 조작이 필요합니다. 기존 규칙을 삭제한 뒤, 올바른 순서로 새 규칙을 만들어야 합니다. 하지만 이 규칙을 조작하는 동안 패킷을 잃을 경우를 대비해 “local” 테이블로의 경로가 전혀 없게 두고 싶지는 않을 겁니다. 결국 결과는 대략 다음과 같습니다.
ip rule add fwmark 42 table 100 priority 10
ip rule add lookup local priority 11
ip rule del priority 0
ip route add 0.0.0.0/0 proto static dev fishtun table 100
WARP에서와 마찬가지로, 우리는 “fishtun” 인터페이스에서 나오는 패킷에 마크를 할당해 이를 다시 그쪽으로 라우팅하는 방식으로 연결 관리를 단순화합니다. 로컬에서 생성된 TCP 소켓에도 동일한 마크가 적용되는 것을 막기 위해, IP를 fishtun 대신 루프백 인터페이스에 할당하고, fishtun에는 주소를 아예 할당하지 않습니다. 하지만 명시적 라우팅 규칙이 있으니 fishtun에는 주소가 필요 없습니다.
마지막 수정 사항을 테스트하는 동안 불운한 문제를 만났습니다. 운영 환경에서는 작동하지 않았던 것입니다.
리눅스 네트워킹 스택을 거치는 패킷의 경로를 디버깅하는 일은 간단하지 않습니다. 주어진 패킷에 어떤 규칙과 테이블이 적용되는지 이해하는 데 도움이 되는 도구로 nftables의 nftrace를 설정하거나 iptables의 LOG/TRACE 타깃을 적용하는 방법 등이 있긴 합니다.
리눅스 네트워킹 및 *tables를 거치는 패킷 플로우 경로 개략도 작성자: Jan Engelhardt
우리의 기대는 패킷이 prerouting 훅을 지나고, 라우팅 결정이 내려져 패킷이 우리의 TUN 디바이스로 보내진 다음, 패킷이 forward 테이블을 통과하는 것이었습니다. 테스트 호스트의 IP에서 기원한 패킷을 추적해 보니, prerouting 단계에 들어가는 것은 보이지만 ‘라우팅 결정’ 블록 이후에 사라졌습니다.
도표에는 “소켓 조회” 블록이 있지만, 이는 input 테이블 처리가 끝난 뒤에 발생합니다. 우리의 패킷은 input 테이블로 들어가기조차 하지 않습니다. 우리가 한 유일한 변화는 로컬 소켓을 생성한 것뿐입니다. 소켓 생성을 중단하면, 패킷은 예전처럼 forward 테이블로 넘어갑니다.
알고 보니 ‘라우팅 결정’의 일부에는 프로토콜 특화 처리도 포함되어 있었습니다. IP 패킷의 경우 라우팅 결정이 캐시될 수 있고, 기본적인 주소 유효성 검사가 수행됩니다. 2012년에는 추가 기능이 더해졌습니다. 바로 early demux입니다. 그 취지는, 이 시점에 어차피 무언가를 조회하고 있고, 수신되는 패킷의 대다수는 미지의 패킷이나 어딘가로 포워딩되어야 할 패킷이 아니라 로컬 소켓을 위한 것일 것으로 예상되므로, 그렇다면 여기서 소켓을 바로 찾아 추가 라우트 조회를 아끼자는 것이었습니다.
우리에게는 불행하게도, 우리는 방금 소켓을 만들었지만 그 소켓으로 패킷을 받고 싶지는 않았습니다. 라우팅 테이블에 대한 우리의 조정은 무시됩니다. 소켓이 발견되면 라우팅 조회 자체가 완전히 건너뛰어지기 때문입니다. Raw 소켓은 라우팅 결정과 무관하게 모든 패킷을 받기 때문에 이 문제를 피하지만, 패킷 속도가 너무 빨라 비효율적입니다. 이 문제를 피하는 유일한 방법은 early demux 기능을 비활성화하는 것입니다. 하지만 패치의 주장에 따르면 이 기능은 성능을 개선합니다. 그렇다면 이를 비활성화하면 기존 워크로드의 성능은 어느 정도 퇴보할까요?
간단한 실험으로 확인해 봤습니다. 특정 데이터센터의 일부 머신에서 net.ipv4.tcp_early_demux sysctl을 0으로 설정해 일정 시간 운영한 뒤, 기본 설정을 유지하는 동일 하드웨어 구성의 머신들과 CPU 사용량을 비교했습니다.


핵심 지표는 /proc/stat의 CPU 사용량입니다. 성능 저하가 있었다면, 리눅스 네트워크 처리가 이뤄지는 컨텍스트인 “softirq”에 할당된 CPU 사용량이 높아지고, 사용자 공간(상단)이나 커널 시간(하단)은 큰 변화가 없을 것으로 예상됩니다. 관찰된 차이는 미미했고, 대부분 비혼잡 시간대의 효율을 약간 떨어뜨리는 정도로 나타났습니다.
IP 패킷 포워딩을 위한 다양한 해법을 테스트하는 동안에도, 우리는 계속해서 네트워크에서 TCP 연결을 종료하고 있었습니다. 초기의 우려와 달리 성능 영향은 작았고, 오리진 도달 가능성에 대한 가시성 향상, 네트워크 내부의 빠른 라우팅, soft-unicast 주소 사용의 관측 단순화라는 이점 덕분에 입증 책임이 뒤바뀌었습니다. 과연 순수한 IP 포워딩을 구현하고 두 개의 다른 이그레스 계층을 유지할 가치가 있을까요?
현재까지의 답은 ‘아니오’입니다. Fish는 오늘날 우리 네트워크에서 동작하지만, 훨씬 더 작은 책임인 ICMP 패킷 처리만 맡고 있습니다. 그러나 우리가 모든 IP 패킷을 터널링하기로 결정한다면, 그 방법을 정확히 알고 있습니다.
Cloudflare의 일반적인 엔지니어링 역할은, 대규모 환경에서 낯설고 어려운 문제를 많이 해결하는 일을 포함합니다. 문서가 많지 않은 상황에서도 리눅스 커널의 역량을 탐구하고 기발한 접근을 시도하는 목표 지향형 엔지니어라면, 채용 공고를 확인해 주세요. 여러분의 이야기를 듣고 싶습니다!
Cloudflare의 연결 클라우드는 기업 전체 네트워크를 보호하고, 고객이 인터넷 규모의 애플리케이션을 효율적으로 구축하도록 돕고, 모든 웹사이트나 인터넷 애플리케이션을 가속화하며, DDoS 공격을 방어하고, 해커의 침입을 차단하며, 제로 트러스트 여정을 지원합니다.
어떤 기기에서든 1.1.1.1을 방문해 인터넷을 더 빠르고 안전하게 만들어 주는 무료 앱을 시작해 보세요.
더 나은 인터넷 구축을 돕기 위한 우리의 사명을 더 알고 싶다면 여기서 시작하세요. 새로운 커리어 방향을 찾고 계시다면 채용 공고를 확인하세요.