개요
지난번 AL 2023업그레이드할때 Node.js에서 겪을 수 있는 이슈를 설명하며 Cgroup이라는걸 알아보았다.
Linux 커널에서 동작하는 재밌는 친구였다.
그럼 이번엔 Network 쪽을 알아보자
sk_buff란?
그게 머에요..?
https://elixir.bootlin.com/linux/v7.1-rc6/source/include/linux/skbuff.h
skbuff.h - include/linux/skbuff.h - Linux source code v7.1-rc6 - Bootlin Elixir Cross Referencer
elixir.bootlin.com
Linux 커널에서 네트워크 쪽을 컨트롤링할때 일반적으로 우리는 '패킷'이라는 단위를 사용한다.
여기서 이 패킷이라는 것을 표현할때 가장 중요하게 사용되는 요소중 하나인 구조체가 존재하는데 이 친구가 바로 sk_buff다.
이 sk_buff라는 친구가 어디서, 왜 사용될까?
→ 데이터(네트워크 포함)의 수신을 하게 되었을때부터 이를 어플리케이션 레벨까지 전달할때 모두 사용되는 구조체이다.
우리가 알고 있는 OSI 7Layer로 보면 Layer 1 ~ Layer 7까지 모두 이 구조체를 통해서 전달을 한다는 의미다.

상식적으로 L1부터 L7까지 모두 연결되는건 모두가 알겠지만, 이 sk_buff라는 친구가 유독 특별한데는 이유가 있다.
바로 네트워크 스택들과 강결합이 되어있고, 단일 구조체에서 포인터만 딸깍 거리며 움직인다는 점 이다.
DevOps Engineering 적인 측면에서 접근해보면 eBPF와 같은 고-급 기술이 이러한 sk_buff와 연관이 깊다는 점이다.
eBPF라고 적어두면 긴가민가 할 수 있다. 하지만 이 친구를 예시로 들면 바로 아~ 싶을 것이다.
바로 eBPF 기반으로 동작하는 친구중 가장 유명한 친구인 Hot한 CNI Cilium이다.

왜 이 Cilium이 Hot하고 OverHead를 줄여주는지는 이번 블로그를 모두 보고나면 알 수 있을 것이다.
구조 뜯어보기

struct sk_buff
여기에는 메타데이터가 적재되어있다.
여기에 적혀있는 포인터 주소가 Head Romm, Data, Tail Room등을 가르킨다.
이건 sk_buff와는 다른 존재로, 메모리에서도 별도로 관리가 되고 있다.
하지만 중요한 내용이므로 여기에 함께 적어두었다.
Head Room
수신할때는 쓰지 않고 송신할때 사용되는 빈 공간이다.
패킷는 송신(TX)시 커널이 skb_push를 호출해 포인터를 뒤로(왼쪽으로) 후진시키며 TCP, IP, MAC 헤더를 차곡차곡 조립시킨다.
이때 해당 데이터들의 내용들이 들어갈 자리가 바로 Head Room이다.
애플리케이션(L7)이 "이 데이터 좀 보내줘"라고 커널에 던질 때, 커널은 밑단에서 헤더가 얼마나 붙을지 미리 계산해서 앞쪽에 빈 헤드룸을 넉넉하게 할당해둔다.
아래가 바로 skb_push 함수다.
아래 skb_push함수를 보면 현재 기준으로 뒤에 Head Room 방향으로 칸을 만들어서 Head Room의 일부 공간을 확보한다.
그러면 헤더가 들어갈 자리가 만들어지게 되고 그때 헤더값을 넣는다.
void *skb_push(struct sk_buff *skb, unsigned int len)
{
skb->data -= len;
skb->len += len;
if (unlikely(skb->data < skb->head))
skb_under_panic(skb, len, __builtin_return_address(0));
return skb->data;
}
일반적인 통신에서는 커널이 기본적으로 넉넉하게 잡아두는 표준 사이즈의 Head Room만으로도 포장(TCP/IP/MAC)을 치는 데 아무런 문제가 없지만, K8s CNI나 IPsec VPN처럼 패킷을 겹겹이 이중 삼중으로 포장(Encapsulation)해야 하는 환경에서는 기본 Head Room 사이즈를 초과해버릴 수 있으므로 추가적인 아키텍처 고려가 필수적이다.
Data
네트워크 인터페이스 Ring Buffer에 수신된 패킷은 DMA를 통해 커널의 sk_buff 구조체가 가리키는 '선형 Data 영역'에 적재된다.
이때 적재되는 것은 MAC, IP, TCP 헤더와 실제 데이터(Payload)가 모두 합쳐진 Raw 데이터(Raw Packet) 전체다.
- 커널이 이 거대한 통뼈 데이터에서 헤더를 구분해 내는 원리의 경우 각 프로토콜 헤더의 크기와 특정 필드(N번째 Byte)에 어떤 값이 들어있는지 이미 국제 표준(RFC)으로 엄격하게 협약되어 있어 그리 어려운일은 아니다.
커널은 데이터를 일일이 복사하거나 파싱하는 대신, 이 약속된 규격에 따라 skb_pull 같은 함수로 포인터만 뒤로 이동시키며 포장지(MAC, IP, TCP 헤더)를 차례대로 벗겨낸다.
이 모든 릴레이 과정이 끝나고 나면, 최종적으로 순수한 Payload만이 남아 어플리케이션으로 전달되게 된다.
- 단, 거대한 페이로드의 경우 메모리 최적화를 위해 Data 영역에 모두 담기지 않고 skb_shared_info를 통해 비선형(Non-linear) 메모리 페이지로 흩어져 관리된다.
Ring Buffer와 NIC, 그리고 AWS ENA나 K8s CNI 환경에서의 네트워크 인터페이스 동작 과정은 기회가 된다면 다른 포스팅에서 더 깊게 다루어볼 예정이다.
Tail Room
사내망 방화벽과 AWS 클라우드를 VPN(IPsec)으로 연결할 때, 데이터 유출과 변조를 막기 위해 패킷을 암호화하는것은 누구나 아는 사실이다. 하지만 이때 커널은 앞쪽(Head Room)에 ESP 헤더를 붙이는 것뿐만 아니라, 패킷의 맨 뒤(Tail Room)에도 'ESP 트레일러'와 '인증 데이터(ICV)'라는 꼬리표를 반드시 붙여야 한다.
이때 커널은 Tail Room 공간을 사용할 때 skb_put 함수를 호출하여 포인터를 뒤(오른쪽)로 밀어 공간을 확보한다. 이때 미리 넉넉하게 비워두었던 Tail Room 공간을 파먹으며(사용하며) 꼬리표가 들어갈 자리를 확보하는 것이다.
void *skb_put(struct sk_buff *skb, unsigned int len)
{
void *tmp = skb_tail_pointer(skb);
SKB_LINEAR_ASSERT(skb);
skb->tail += len;
skb->len += len;
if (unlikely(skb->tail > skb->end))
skb_over_panic(skb, len, __builtin_return_address(0));
return tmp;
}
또한 패킷이 목적지에 도착하기 전, 선을 타고 오는 동안 노이즈 때문에 데이터가 깨지지 않았는지 확인하기 위해 패킷의 맨 끝에Checksum 데이터를 덧붙이는데, 최근에는 네트워크 카드(NIC) 하드웨어가 이 작업을 대신해 주는 경우(Hardware Offloading)가 많지만, 커널 소프트웨어에서 이를 직접 계산해서 붙여야 할 때는 바로 이 테일룸 공간이 사용된다.
여기에 더해 한가지 중요한 기능이 존재한다.
이더넷 통신에는 "아무리 작은 패킷이라도 전체 크기가 최소 64바이트는 되어야 전송할 수 있다"는 규칙(CSMA/CD 제약)이 있다. 만약 애플리케이션이 보낸 데이터가 너무 작아서 전체 패킷 크기가 64바이트에 미달한다면? 패킷이 Drop되지는 않는다.
그랬다면 우리는 '.'같은 단일 문자열을 비인증 프로토콜 위에서 보내지도 못했을테니 말이다.
이런 경우를 대비해 커널은 Tail Room 공간을 파먹으며 남는 공간을 의미 없는 '0'으로 마구 채워 넣는다. 택배 상자가 너무 헐렁할 때 빈 공간에 뽁뽁이를 채워 넣는 것과 같은 원리라고 보면 된다.
skb_shared_info
만약 페이로드 자체가 수십 KB 단위로 매우 크다면 어떨까?
커널은 이 거대한 데이터를 제한된 크기의 메인 선형 버퍼(Data 영역)에 꾸역꾸역 밀어 넣는 바보 같은 짓을 하지 않는다.
덩치가 큰 데이터를 담기 위해 커널 메모리에서 '거대하고 연속된 빈 공간'을 찾는 것은 메모리 단편화를 유발하고 심각한 성능 저하를 가져오기 때문이다.
대신 커널은 'Scatter-Gather'라는 고도의 전략을 사용한다. 거대한 페이로드는 커널의 남는 메모리 페이지(Page) 공간에 조각조각 흩어두고(Scatter), 메인 버퍼 꼬리에 위치한 skb_shared_info라는 구조체 명부에 "이 데이터 조각들은 저기 메모리 주소들에 흩어져 있어"라고 포인터만 기록해 두는 것이다.
이후 패킷이 네트워크 카드(NIC)로 전송될 때, 랜카드가 이 명부만 보고 흩어진 조각들을 직접 긁어모아(Gather) 선으로 쏴버린다. 데이터 복사를 시도조차 하지 않고 처리를 할 수 있는 효율적인 방법이라 생각되는 포인트이다.
L1 ~ L2
https://elixir.bootlin.com/linux/v7.1-rc6/source/net/core/dev.c#L6454
dev.c - net/core/dev.c - Linux source code v7.1-rc6 - Bootlin Elixir Cross Referencer
elixir.bootlin.com
--작성중--