굴비와 예제로 공부해 보는 SOCKMAP과 스플라이싱

커널 수준 네트워크 최적화 기법

Category: 네트워크 기술 → 커널 네트워킹
Difficulty: 중급
Date: 2025-12-16
Read Time: 11 mins read
Views: 조회

TCP 스플라이싱: 궁극의 리버스 프록시 뽑기

학부 실습부터 현업까지 흔히 사용을 하는 NGINX, HAProxy와 같은 리버스 프록시들은 태생적으로 L7 프록시이고 커널<->유저랜드 간의 데이터 전달이 잦은 데서부터 일단 성능을 극한까지 끌어올릴 수는 없다. 다르게 말하면, 복사 비용을 줄이고자 하는 온갖 기법들 중 아주 강력한 것은, 애초에 커널 영역에서 가능한 많은 것을 처리하면 그만인 것인데, 그런 의미에서 SOCKMAP 인프라스트럭쳐는 클라우드플레어 블로그에서는 성배에 비유될 만큼 좋은 방법이다. 이 API는 아주 신뢰할 수 있고, 무거운 애플리케이션에 대한 프록시에서 엄청난 성능 변화를(위에서 언급한 블로그에선 지각 변동이라고까지 언급한) 가져올 수 있다.

전통적 최적화는 L7 프록시보다 나은 점이 뭐지?

여기서 난 좀 맛있는 비유를 들고자 한다. 여러분은 어부이고 필요한 만큼 조기를 옮기고자 한다. 거래처에선 엄청나게 많은 조기를 옮겨 주기를 원한다.

두 가지 방법이 있다고 해 보자:

  1. 조기를 하나하나 바구니에 옮겨 담아서 포장업체에 주고, 업체는 그걸 따로 트럭에 쌓아서 보낸다
  2. 미리 조기끼리 묶어서 굴비로 만들어 놨다가 바로 적당히 남는 크기의 박스에 넣고 우리 수협의 트럭을 쓴다.

조기가 120마리일 때, 1번 방법에서 총 120 박스를 하나하나 포장해서 포장업체에 넘기고, 트럭에서 수송하게 된다. 2번 방법에서는, 조기 6두릅(1두릅=20마리)를 제일 큰 박스에 포장해서 우리 트럭으로 보낸다.

포장업체로의 화물 운송 시간이 빠지는 걸 제외해도, 굴비 1마리만 들어가는 박스라고 쳐도 1두릅이 1마리처럼 취급되니 20배 빠르다. 만약 물량 발주가 적어서 6두릅이 들어가는 박스를 찾았으면 소요 시간은 1, 즉 1번보다 120배가 빠르다.

확실히 이거까지만 봐도 고부하 환경에선 지각 변동이라고 할 만하다. 만약 최악(모든 박스가 굴비 1개만함)과 최선(한 박스에 굴비가 다 들어감)의 중간값을 생각을 한다고 해도, 수하물 위탁을 빼도 (6+1)/2 == 3.5; 120/3.5 == 약 34.28x로 충분히 좋아진 속도가 나온다.

그럼 이제 보통 이럴때 쓰는 함수들끼리 비교해 보자.

이런데는 어떤 함수들이 있는 걸까?

이게 굉장히 재밌는 부분이다. sendfile vs splice vs vmsplice를 볼 수 있는 대목이다. sendfile은 디스크파일로부터 소켓으로 읽어오는 형태이고, 제로카피는 아니지만 디스크->소켓 복사가 아닌 유저 스페이스 메모리 복사는 피한다.

또 비유하자면, 굴비 냉동창고에서 굴비를 꺼낸 후 박스에 담아 수협 트럭에 싣는 모습이다.

splice는 파이프로부터 소켓으로 읽어오고 네트워크 소켓으로의 복사, 그리고 그 역방향도 하는 제로카피인데, 이건 냉동창고에서 굴비를 꺼내 주는 역할을 하는 직원에게서 받아서 수협 운송 시간이 거의 없는 고속 컨베이어 벨트로 트럭에 쑤셔넣고, 돌아오면 거래처에서 온 해산물을 꺼내고 다시 굴비를 싣는 것이다. 왜 리누스가 sendfile을 선호하지 않았는지 알 만하다. (단, SPLICE_F_MOVE가 아니면 가끔 copy가 섞이기도 함)

vmsplice는 메모리 영역으로부터(특히 가상의 연속된 메모리 공간으로부터) 파이프로 실어 나르는 형태인데, 유저 스페이스 메모리를 피할 수 없지만 제로 카피이다. 이건 위탁 업체의 배송 서비스를 사용하지만, 위탁 업체의 트럭이 바로 옆 회사라 고속 벨트로 꽂아넣는다고 보면 된다.

가상의 연속 메모리 영역을 쓸 일이 있으면 vmsplice, 아니면 splice를 쓰면 될 것 같다. 메모리가 부족한 상황이라면 vmsplice를 활용하는 것도 좋으나 페이지 정렬 이슈 등 성능에서의 단점(trade-off)은 감안해야 한다.

그럼 NGINX, HAProxy는 이것도 안 하고 뭘 한건데?

사실 이 둘은 서로 건드리는 부분도 다르고, 왜 그렇게 다루는지도 달라서 뭐가 낫다고 잘라 말할 수는 없다. 그러나 짤막하게 소개해보면 이들이 무슨 생각을 한 건지 이해될 것이다.

NGINX는 한 마디로 말하면 이거다. 어떤 곳이 굴비를 자주 받아가는 경우 뭘 받는지 정보를 기억해 놨다가 바로 영광 x업체 굴비를 주자. 이 방식은 그때그때 정보를 새로 불러올 필요가 없으니 성능은 빠르나, 예기치 못하게 y업체 굴비를 받아야 할 경우에 대응하지 못하고 x업체 굴비를 줄 위험이 있다. 캐싱 문제를 한 번쯤 경험해 봤다면 이것과 유사한 상황임을 체감하기 더 좋을 것이다.

HAProxy는 다양한 경로를 사용한다고 보면 된다. 대규모 운송을 해야 할때, 영광<->서울로 가는 경로는 다양할 것이다. 좁은 도로를 달려야 하는 경우도 있기 때문에 HAProxy는 다양한 경로로 트럭을 운행해서 도착해야 하는 시간에 늦지 않게 굴비가 배달되는 것을 목표로 한다.

직접 커널 API를 호출하지 않는다는 점에서는 아주 훌륭한 접근이지만, 애초에 중간 경로를 줄여 버리면 운송 속도가 빨라지는 것은 당연하다.

파이썬 예제로 보는 Naive와 Splice의 차이

Naive(Splice 안씀)

while data:
    data = read(sd, 4096)
    writeall(sd, data)

Splice

pipe_rd, pipe_wr = pipe()
fcntl(pipe_rd, F_SET_PIPE_SZ, 4096) #굴비로 비유하면 4096개 굴비가 1묶음이다
while n
    n = splice(sd, pipe_wr, 4096)
    splice(pipe_rd, sd, n)

이 경우 만약 몇 묶음을 한 번에 보낼 수 있어지는 상황에선 splice 함수가 묶어서 보내게 된다. 이미 API가 잘 되어 있기 때문에 생각보다 파괴적인 수정 없이도 쉽게 만들 수가 있다.

SOCKMAP: eBPF를 여기 쓴다고? 무시무시한 성능이다!

이제 드디어 본론이다. 이제부턴 유저랜드에서의 접근이랑 확 달라진다. 우선, SOCKMAP에 대해서 비유를 해 보자. 이건 정말로 어마어마한 방식이다. (그럴 일은 없지만) 이건 영광군 모 공장에서 서울 모 거래처 사이를 지나는 고속열차를 만들어서, 트럭이라는 느린 매개체를 거치지 않고 열차에 실어 보내는 방법이라고 생각해도 좋다.

커널에서는 기본 동작이 파이프를 타고 버퍼링되어 나가는 것이기 때문에 속도가 늘어질 수밖에 없는데 이것을 eBPF 프로그램이 우회해서 처리하는 것이다.

일단 SOCKMAP API에서 우리가 사용해 볼 것은 bpf_sk_redirect_map 부분일 텐데, 그 전에 eBPF가 무엇인지 알아보자.

eBPF가 뭐죠?

eBPF(extended Berkeley Packet Filter)는 커널 소스 코드나 추가 모듈을 추가하지 않고 바로 패킷 필터를 시스템 콜과 소켓 사이에, 콜을 가로채서 끼워넣어 주는 기술이며, 패킷 필터가 아니어도 삽입할 수 있다. 이것은 기본적으로 커널 영역에서 돌아가고 CPU나 메모리 소비가 낮다. 넷플릭스 블로그를 참고해 보면 여기선 웬만한 인스턴스에서 1% 내의 CPU와 메모리 사용량을 보였다고 언급된다. 추가적인 자원 소모를 최소화하면서, 유저 공간과 커널 사이에서 정보를 과하게 주고받을 필요 없이 내가 쓰고 싶은 프로그램을 간단하게 커널에 주입하는 것이다. 그렇다면 앞서 말한 splice 등의 기법보다 가벼운 eBPF 모듈이 커널 내에서 소켓과 파이프 등이 복잡하게 오가는 과정을 통째로 건너뛰게하면 된다. 직통 핫라인같은 역할이 되는 것이다.

SOCKMAP이란?

이건 커널에서 소켓 간에, 즉 커널 영역 사이에서 소켓끼리 이어지는 통신을 위해서 짜여진 현대적인 API이다. 각 소켓을 이것으로 매핑을 하는 방식 역시 기존의 커널 개발에 대한 상식과 달리 간단하고, 특정 목적을 위한 통신으로 좁히면 코드도 짤막하게 끝내 버릴 수가 있다. 함수는 여기서 찾을 것이다. 차례대로 잘 따라와 주길 바란다.

eBPF 문서 우린 Network helpers에서 Redirect helpers로 따라 들어가서 문서를 참고한 후 127.0.0.1:8080에 하나의 네트워크 소켓을 여는 코드를 짤 것이다. 우리가 원하는 것은 127.0.0.1:8080으로부터 들어온 메시지를 소켓에 리디렉션하는 것이다. 그렇다면 우리가 봐야 하는 후보는 좁혀진다.

우리 목적에서 쓰려는 기본형으로 보이는 것들과, sk_ 태그가 붙은 것들, 그 중에서도 일반적인 동작을 오버라이드한다고 적힌 등의 예외사항이 없는 것들을 주목하자. 그렇게 추려 보면 아래 목록과 같이 추려진다.

bpf_clone_redirect
bpf_sk_redirect_map
bpf_redirect
bpf_redirect_map
bpf_sk_redirect_hash
bpf_msg_redirect_map
...

전반적으로 특정 소켓으로 들어온 메시지를 곧바로 꽂아 주는 작업, 예를 들면 외부로부터 특정 제어 패킷을 받는 서버 등에서 유용하다. 다른 용도로 eBPF를 쓸 수 있을까? 하면 그대로 docs에서 찾아보면 된다.

이 중 몇 가지를 추려서 docs 읽는 법도 알아보자.

bpf_redirect_map

Definition 쪽에 설명이 있다. 이 함수의 설명에는 파킷을 특정 맵의 인덱스 키에서 참조하는 엔드포인트로 리디렉션한다는 정보가 담겨 있고, 이 맵은 네트워크 디바이스나 CPU를 포함한다고 용도를 특정한다. BPF_F_BROADCAST처럼 특정 동작을 위한 플래그 역시 던져주고, 맵의 인덱스가 아닌 ifindex만 있어도 되는 보다 적은 조건으로 동작하는 함수 역시 bpf_redirect라고 알려 준다. 이 Definition 하나에 간략하게 Overview~Further Links까지 추려둔 셈이다.

Returns 부분은 초심자에게도 명확하게 설명되어 있다.

// XDP_REDIRECT on success, or the value of the two lower bits of the flags argument on error.

static long (* const bpf_redirect_map)(void *map, __u64 key, __u64 flags) = (void *) 51;

오류가 없을 때: XDP_REDIRECT, 오류 시 아래 2비트만큼 복사해서 준다는 점을 알 수 있다. 그렇기 때문에 코드 패턴은 아래와 같아진다.

// bpf_printk is for debugging; replace with ring buffer for production.
long ret = bpf_redirect_map(&tx_port, key, 0);
if (ret != XDP_REDIRECT) {
    bpf_printk("bpf_redirect_map failed: ret=%ld", ret);
    return XDP_ABORTED;
}

이와 같이 API만 잘 읽어도 코드 패턴을 어떻게 쓸지, 무슨 역할을 하는지도 다 파악할 수 있다.

줄임말 보기

sk는 socket의 줄임말로, msg는 message의 줄임말로 쓰이는 등 줄임말이 흔하다.

bpf_sk_redirect_map

이걸 그럼 자연어로(영어로) 풀어보자.

  • This BPF function does something.
    • This is doing something for a socket.
      • This is redirecting ??? by a map

???는 관련 직종이라면 자연히 the packet referenced으로 추론된다. 다시 조합해보면, This BPF function redirects the socket referenced by the map. 한국어로 이 BPF 함수는 맵을 참조해서 소켓을 리디렉션한다.

여기도 마찬가지로 특정 인그레스 경로를 선택하기 위한 플래그인 BPF_F_INGRESS 등을 안내하니 고급 언어들에서 하듯이 마찬가지로 잘 써주면 된다. 이쪽은 Program types와 Map types 등으로 어떤 플래그까지 허용되는지도 목록에 준다. 플래그를 받는 게 중요한 함수들이라도 이걸 보면 헷갈리지 않을 수 있다.

벤치마크 결과

yjlee@yjlee-linuxonmac:~/cloudflare-study/ebpf-sockmap$ sudo ./echo-sockmap 127.0.0.1:8080
[+] Accepting on 127.0.0.1:8080 busy_poll=0
[+] rx=102400001 tx=0
^C
yjlee@yjlee-linuxonmac:~/cloudflare-study/ebpf-sockmap$ sudo ./echo-naive 127.0.0.1:8080
[+] Accepting on 127.0.0.1:8080 busy_poll=0
[-] edge side EOF
[+] Read 97.7MiB in 1075.9ms
^C
yjlee@yjlee-linuxonmac:~/cloudflare-study/ebpf-sockmap$ sudo ./echo-splice 127.0.0.1:8080
[+] Accepting on 127.0.0.1:8080 busy_poll=0
[-] edge side EOF
[+] Read 97.7MiB in 1064.3ms
^C

Sockmap은 너무 빨라서 tx/rx로 표기해야 볼 수 있는 경지에 이르렀는데, splice는 잘 쳐줘 봐야 11ms 줄였다. 이건 마치 아무리 뛰어난 트럭 운전사를 고용해도 KTX나 SRT 고속 열차처럼 시속 300km로 운행할 수 없는 것과 마찬가지이다. 이런 압도적인 성능은 고성능 프록시에서 매력적이고, 당연히 ISP 등지에서 눈독들일 수밖에 없는 기술이다.

직접 실행해보려면, ARM64 머신에서도 어셈블리 최적화가 쓰이고, 커널에 부착 시 attach type을 명시하게 개선한 공부용 예제를 사용해 보자.

벤치마크 소스

대기업의 코드 컨벤션이나 품질을 엿볼 수 있는 귀중한 자료다. 꼼꼼하게 읽어 보고 개선점을 더 찾아보면 공부가 된다.

마치며

적절하다고 생각한 비유에 생선을 사용해 보니 차이를 이해하기가 쉬웠고, 어떻게 보면 맛있는 공부가 되었다. 네트워크에 대해 잘 모르는 나와 같은 초심자도 이러한 공부를 해보면 재미있으니, 꾸준히 네트워크 게시판에 포스팅하도록 하겠다.

Document Classification

Primary Category
네트워크 기술
Keywords
SOCKMAP 스플라이싱 TCP 커널 네트워킹 제로카피
Difficulty
중급
Permalink
https://gg582.github.io/network/2025-12-26-%EA%B5%B4%EB%B9%84%EC%99%80-%EC%98%88%EC%A0%9C%EB%A1%9C-%EA%B3%B5%EB%B6%80%ED%95%B4%EB%B3%B4%EB%8A%94-SOCKMAP%EA%B3%BC-%EC%8A%A4%ED%94%8C%EB%9D%BC%EC%9D%B4%EC%8B%B1

Citation

이윤진(Lee Yunjin) (2025). 굴비와 예제로 공부해 보는 SOCKMAP과 스플라이싱. 윤진의 IT 블로그. Retrieved from https://gg582.github.io/network/2025-12-26-%EA%B5%B4%EB%B9%84%EC%99%80-%EC%98%88%EC%A0%9C%EB%A1%9C-%EA%B3%B5%EB%B6%80%ED%95%B4%EB%B3%B4%EB%8A%94-SOCKMAP%EA%B3%BC-%EC%8A%A4%ED%94%8C%EB%9D%BC%EC%9D%B4%EC%8B%B1
── 하략 ──