tech

컨테이너 환경에서의 eBPF/XDP 로드밸런서 Deep Dive

클라이언트의 요청을 여러 개의 멀티 인스턴스 형태 서버에 로드밸런싱하려면, 앞단에서 패킷을 받아 포워딩해주는 로드밸런서 서버가 필요하다. 하지만 일반적인 로드밸런서는 애플리케이션 단까지 패킷을 수신하고 다시 전송하기까지 비용이 크다. NIC에서 수신된 패킷을 기반으로 커널 내에서 SKB를 만들고, 만들어진 SKB는 리눅스 커널 네트워크 스택을 통과한다. 이후 유저 영역의 소켓 버퍼로 메모리를 복사해야 하는데, 이 과정에는 컨텍스트 전환이 포함돼 비용 소모가 크다.

이번 챕터에서는 NIC에서 수신된 패킷을 리눅스 커널 네트워크 스택을 태우지 않고, 유저 영역으로의 메모리 카피도 하지 않는 제로카피 방식으로 원하는 서버로 패킷을 포워딩하는 기법인 eBPF/XDP를 소개한다.

더불어, XDP 기반 로드밸런서가 일반적인 L4, L7 Nginx 로드밸런서 대비 레이턴시(Latency), RPS(초당 요청 처리 수), CPU 사용량 측면에서 어느 정도의 성능 향상을 가져오는지 측정하고 분석해 본다.

이미지 출처: https://en.wikipedia.org/wiki/Iptables

참고: 이 글은 리눅스 네트워크 스택의 상세 구조나 eBPF의 원론적 개념보다는 XDP, Veth(가상 이더넷) 환경에서의 동작 원리, 그리고 퍼포먼스 측면에 집중하여 서술한다.

eBPF란 무엇인가?

본격적으로 eBPF/XDP를 설명하기 앞서, eBPF의 기본 개념을 간략히 짚고 넘어간다.

이미지 출처: https://ebpf.io/what-is-ebpf

eBPF는 본래 BPF(Berkeley Packet Filter)에서 유래했으나, extended BPF로 발전하며 패킷 필터링을 넘어 관찰 가능성(Observability), 트레이싱(Tracing), 보안 등 다양한 기능을 수행하게 되었다. 이제는 약어로서의 의미보다 독립적인 기술 용어로 통용된다.

eBPF 프로그램은 이벤트 기반으로 동작하며, 커널이나 애플리케이션이 특정 훅(Hook) 지점을 통과할 때 실행된다. 주요 훅 지점은 다음과 같다.

  • System Calls
  • Kernel/User Tracing
  • Network Events
  • Function Entry/Exit

eBPF 프로그램은 커널 공간(Kernel Space)에서 동작하기 때문에 안정성이 매우 중요하다. 따라서 엄격한 Verifier(검증기)를 통해 코드에 위험 요소(예: 패킷 포인터 접근 시 경계 초과 등)가 없는지 확인한 후, JIT Compiler를 통해 실행된다. 검증기의 제약으로 인해 일반적인 함수 사용이 제한되므로, eBPF는 특수한 형태의 커널 헬퍼 함수(Helper Function)를 제공한다.

즉, eBPF는 크게 BPF MAP, Virtual Machine, Verifier 세 가지 핵심 요소로 구성된다.

XDP(eXpress Data Path)

XDP(eXpress Data Path)는 프로그래밍 가능한 패킷 처리 기술이다. 과거에 많이 언급되던 DPDK는 커널을 우회하기 때문에 보안과 안정성을 보장하기 어렵다는 한계가 있었다. 반면 XDP는 eBPF 프로그램이 검증기와 JIT을 통해 커널 공간에서 안전하게 실행된다. 또한 XDP는 리눅스 커널의 일부로 구현돼 있어 리눅스 네트워크 스택과 완전히 통합된다.

그렇기 때문에 프로그래머는 커널과 사용자 공간 사이의 컨텍스트 전환 없이(Native 기준), 패킷이 NIC에 도착해 CPU에서 처리되는 가장 빠른 시점에 XDP 프로그램을 실행해 패킷 처리 결정 또는 조작 등을 구현할 수 있다.

작성된 XDP 프로그램은 종료 시 Action이라는 정수형 반환값을 필요로 하며, 지원되는 값은 다음과 같다.

  • XDP_ABORTED: 패킷을 DROP 함과 동시에 예외(Exception)를 발생시킨다.
  • XDP_DROP: 패킷을 즉시 폐기한다.
  • XDP_PASS: 패킷을 리눅스 커널의 네트워크 스택으로 올려 보낸다.
  • XDP_TX: 수신했던 NIC로 패킷을 다시 내보낸다. (로드밸런서 구현의 핵심)
  • XDP_REDIRECT: 패킷을 다른 NIC, 다른 CPU, 또는 사용자 공간(AF_XDP)으로 전달한다.
    • Note: XDP_REDIRECT는 다른 Action과 달리 별도의 BPF Helper 함수를 필요로 한다.

위 그림에서 알 수 있듯이, XDP_REDIRECT는 커널 네트워크 스택을 우회해 다른 대상으로 전달하고, XDP_PASS는 기존처럼 커널 네트워크 스택을 타도록 한다. XDP_TX는 수신했던 NIC로 패킷을 재주입한다. XDP 로드밸런서가 XDP_TX 액션 값을 이용해 패킷을 Link Layer에서 처리한다는 점에서 매우 중요한 개념이다.

XDP 실행 흐름과 Veth (Virtual Ethernet)

XDP가 무엇인지는 대략 감이 와도 “어디서, 어떻게 동작하는지”는 바로 와닿지 않을 수 있다. 실제로 XDP Program이 실행되는 과정을 짧게 살펴본다. 사용되는 시스템은 실제 호스트가 아닌 가상머신(virtual machine)가상이더넷(veth) 기반으로 진행할 예정이므로, 리눅스 커널의 veth를 중심으로 살펴본다.

또한 실제 XDP 프로그램을 veth에 attach할 예정인데, veth에 XDP 프로그램이 붙었는지 여부에 따라 동작이 달라진다. 이 부분도 집중적으로 살펴본다. XDP에는 NativeGeneric 모드가 존재하며, Generic 모드는 간단히만 보고 실제 구현과 동작은 Native 모드를 기준으로 진행한다.

XDP INSTALL & ATTACH

XDP 프로그램을 네트워크 인터페이스에 설치(Install)하고 연결(Attach)하는 커널 내부 로직을 살펴보자.

(Linux Kernel v6.11 ~ v6.17 기준)

​​/* drivers/net/veth.c */
static const struct net_device_ops veth_netdev_ops = {
    .ndo_init           = veth_dev_init,
    .ndo_start_xmit     = veth_xmit,
    .ndo_bpf		    = veth_xdp,
    .ndo_xdp_xmit		= veth_ndo_xdp_xmit,
    .ndo_get_peer_dev	= veth_peer_dev,
};

/* drivers/net/virtio_net.c */
static const struct net_device_ops virtnet_netdev = {
    .ndo_open            = virtnet_open,
    .ndo_stop   	     = virtnet_close,
    .ndo_start_xmit      = start_xmit,
    .ndo_bpf		     = virtnet_xdp,
};

/* net/core/dev.c */
static int dev_xdp_attach(struct net_device *dev, struct netlink_ext_ack *extack,
              struct bpf_xdp_link *link, struct bpf_prog *new_prog,
              struct bpf_prog *old_prog, u32 flags)
{
    enum bpf_xdp_mode mode;
    [...]
    /* Generic or Native */
    mode = dev_xdp_mode(dev, flags);
    [...]
    /* get xdp installation function */
    bpf_op = dev_xdp_bpf_op(dev, mode);
    [...]
    /* install xdp hook */
    err = dev_xdp_install(dev, mode, bpf_op, extack, flags, new_prog);
}

/* net/core/dev.c */
static bpf_op_t dev_xdp_bpf_op(struct net_device *dev, enum bpf_xdp_mode mode)
{
    switch (mode) {
    case XDP_MODE_SKB:
        return generic_xdp_install;
    case XDP_MODE_DRV:
    case XDP_MODE_HW:
        return dev->netdev_ops->ndo_bpf;
    default:
        return NULL;
    }
}

dev_XDP 프로그램을 설치하면 dev_xdp_attach를 호출해 XDP를 붙이는데, 이때 dev_xdp_mode를 통해 XDP 동작 모드를 Generic 또는 Native 중 하나로 선택한다. dev_xdp_install 내부에서 Generic의 경우 generic_xdp_install을 호출해 generic_xdp_needed_key 값을 세팅한다.

Generic 모드에서 해당 값이 세팅되면 __netif_receive_skb_coredo_xdp_generic을 호출해 XDP 프로그램을 실행한다. 함수 구조상 sk_buff 할당 이후에 실행되기 때문에, 일반적으로 알려진 “sk_buff 할당 이전에 실행되는 Native 모드”와 차이가 있고 성능도 떨어진다. 다만 모든 드라이버에서 동작 가능하다는 장점이 있어 테스트 환경 구성에는 유용하다.

그 외의 경우에는 ndo_bpf에 연결된 함수 포인터를 호출한다. virtio_netvirtnet_xdp, vethveth_xdp가 된다. 실제 로드밸런서 구현에서 XDP 프로그램이 실행되는 곳은 veth이므로 이 구간을 살펴본다. NAPI는 아래 링크를 참고하면 좋다.

추가로 Offloaded 모드도 존재하는데, 이는 CPU가 아닌 NIC에서 XDP 프로그램이 처리됨을 의미한다.

/* drivers/net/veth.c */
static int veth_enable_xdp_range(struct net_device *dev, int start, int end, bool napi_already_on)
{
    /* ... */
    if (!napi_already_on)
        netif_napi_add(dev, &rq->xdp_napi, veth_poll); // NAPI Polling 함수로 veth_poll 등록
    /* ... */
    return err;
}

veth_xdp에서 타고 들어가면 veth_enable_xdp_range를 확인할 수 있다. 이 구간을 통해 NAPI poll 함수 포인터에 veth_poll이 등록된다. 즉, NAPI가 활성화되고 polling 함수로 veth_poll을 사용한다는 의미다. 지금은 XDP 설치 및 실행 흐름이 핵심이므로, NAPI 자체에 대한 깊은 설명은 생략한다.

bpftrace를 사용하면 여기까지 실행되는지 확인할 수 있다.

$ sudo bpftrace -e 'kprobe:veth_enable_xdp_range { print(kstack); }'
Attaching 1 probe...

veth_enable_xdp_range+0
veth_xdp_set+312
veth_xdp+40
dev_xdp_install+116
dev_xdp_attach+512
bpf_xdp_link_attach+512
link_create+548
__sys_bpf+788

kprobe를 사용해 veth_enable_xdp_range가 호출되는 시점에 커널 스택을 출력했다. 이 역시 eBPF 기반 커널 트레이싱 도구이며, 여기서도 eBPF의 편리함과 강력함을 확인할 수 있다.

여기까지가 XDP 프로그램을 attach했을 때의 로직이다. NAPI 관련은 보여야 할 내용이 많아, 기회가 된다면 다른 챕터에서 다루고 싶다.

번외) veth가 Native XDP를 지원하나요? 2018년 커널 패치를 통해 veth 드라이버에도 Generic이 아닌 Native XDP 지원이 추가되었다. 상세 내용은 [Merge branch ‘bpf-veth-xdp-support’] 커밋을 참고하기 바란다.

XDP EXECUTION Flow

로드밸런서를 구현하려면 XDP_PASS가 아니라 XDP_TX를 Action으로 반환하고, 패킷을 실제 서버로 전송해야 한다. 따라서 설치된 XDP 프로그램이 실행되는 구간과 XDP_TX를 반환했을 때의 동작 구조를 살펴볼 필요가 있다.

패킷 처리 과정에서 XDP Program을 실행시키는 공통 흐름은 bpf_prog_run_xdp 호출로 이어진다. veth 기준으로 해당 함수가 실행되기까지를 설명하자면 다음과 같다. 아래와 같이 was라는 network namespace를 만들고 veth를 배정한다.

# was라는 network namespace를 만든다.
$ sudo ip netns add was

# veth0, veth1쌍을 생성한다.
$ sudo ip link add veth0 type veth peer name veth1

# veth1를 was network namespace에 배정한다.
$ sudo ip link set veth1 netns was
# 배정 확인
$ sudo ip netns exec was ip link

# veth0에 10.10.0.2 IP 주소를 부여한다. veth1에도 10.10.0.3을 부여한다.
$ sudo ip a add 10.10.0.2/24 dev veth0
$ sudo ip netns exec was ip a add 10.10.0.3/24 dev veth1

# veth0, veth1 up으로 변경한다.
$ sudo ip link set dev veth0 up
$ sudo ip netns exec was ip link set dev veth1 up

# 삭제
$ ip netns del was


### 1
$ sudo ip link
13: veth0@if12       UP             0a:31:b3:03:2a:a9 <BROADCAST,MULTICAST,UP,LOWER_UP>

### 2
$ sudo ip netns exec was ip link
lo               UNKNOWN        00:00:00:00:00:00 <LOOPBACK,UP,LOWER_UP>
12: veth1@if13       UP             fe:2d:16:44:5c:c4 <BROADCAST,MULTICAST,UP,LOWER_UP>

ifindex가 각각 13, 12로 출력된다. 이 상태에서 was 네임스페이스에서 웹서버를 실행한다.

$ sudo ip netns exec was python3 -m http.server

Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

그리고 다른 한쪽은 bpftrace로 트레이싱한다.

sudo bpftrace -e 'kfunc:dev_hard_start_xmit
{
  if(args->dev->ifindex==2 || args->dev->ifindex==1) {
    return;
  }
  printf("name=%s, ifindex=%d\n", argos->dev->name, args->dev->ifindex);
  print(kstack);
}'

Attaching 1 probe...

name=veth0, ifindex=13
dev_hard_start_xmit+8
[...]
__tcp_transmit_skb+1156
tcp_connect+1168
tcp_v4_connect+964

패킷 전송 시 콜스택을 보면 기본적으로 상위 레이어부터
dev_hard_start_xmitxmit_onenetdev_start_xmit__netdev_start_xmitndo_start_xmit 흐름을 수행한다. ndo_start_xmit 함수 포인터는 앞에서 설명했듯 veth_xmit과 연결된다.

/* drivers/net/veth.c */
static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
{
    struct veth_priv *rcv_priv, *priv = netdev_priv(dev);
    rcv = rcv_dereference(priv->peer); // peer = veth1
    [...]

    // if xdp attached, use_napi=true
    ret = veth_forward_skb(rcv, skb, rq, use_napi);
    [...]
}

static int veth_forward_skb(struct net_device *dev, struct sk_buff *skb,
                struct veth_rq *rq, bool xdp)
{
    return __dev_forward_skb(dev, skb) ?: xdp ?
        veth_xdp_rx(rq, skb) :
        __netif_rx(skb);
}

veth_xmit의 두 번째 인자인 struct net_device *dev는 전송자인 veth0이다. 내부에서 peer(veth1)를 꺼내 rcv에 저장한다.

  • XDP가 꺼져 있는 일반 경우: __netif_rx가 호출되고 내부적으로 enqueue_to_backlog()가 실행돼, 패킷을 처리 중인 CPU의 백로그에 패킷을 넣는다. 그 CPU에 SoftIRQ를 트리거해 peer(veth1)의 net_rx_action이 트리거되고 process_backlog()가 호출돼 패킷을 처리한다. 물론 RSS가 켜져 있다면 다른 CPU로 이동 처리도 가능하다.
  • XDP가 설치된 경우: veth_xdp_rx를 호출하고 ptr_ring 버퍼에 패킷을 주입한다.
/* drivers/net/veth.c */
static int veth_xdp_rx(struct veth_rq *rq, struct sk_buff *skb)
{
    if (unlikely(ptr_ring_produce(&rq->xdp_ring, skb)))
        return NETDEV_TX_BUSY; /* signal qdisc layer */

    return NET_RX_SUCCESS; /* same as NETDEV_TX_OK */
}

어느 경우든 패킷을 주입한 뒤 __napi_schedule()을 호출해 SoftIRQ가 raise된다. XDP가 붙어 있다면 veth_poll, 아니라면 process_backlog가 호출돼 수신된 패킷을 처리한다.

여기서 중요한 포인트는 다음이다.

  • __napi_schedule() 호출은 veth0(전송자) 측에서 발생한다.
  • veth_poll 또는 process_backlog 호출은 veth1(수신자) 측에서 발생한다.

수신 처리 전에 지금까지 내용을 bpftrace로 확인해본다.

sudo bpftrace -e 'kfunc:__netif_receive_skb {
    if(args->skb->dev->ifindex == 1 || args->skb->dev->ifindex == 2){
        return;
    }
    printf("name: %s\n", args->skb->dev->name);
    print(kstack);
}'

name: veth1

__netif_receive_skb+8
process_backlog+148
__napi_poll+72
net_rx_action+488
handle_softirqs+312
__do_softirq+32
____do_softirq+28

XDP가 붙지 않으면 수신 측 콜스택은 위처럼 형성된다. XDP가 붙어 있다면 아래처럼 형성된다.

sudo bpftrace -e 'kfunc:napi_gro_receive {
    if(args->skb->dev->ifindex == 1 || args->skb->dev->ifindex == 2){
        return;
    }
    printf("name: %s\n", args->skb->dev->name);
    print(kstack);
}'

name: veth1

napi_gro_receive+8
veth_xdp_rcv.constprop.0+732
veth_poll+152
__napi_poll+72
net_rx_action+488
handle_softirqs+312
__do_softirq+32
____do_softirq+28
/* drivers/net/veth.c */
static int veth_poll(struct napi_struct *napi, int budget)
{
[...]
    done = veth_xdp_rcv(rq, budget, &bq, &stats);
[...]
}

static int veth_xdp_rcv(struct veth_rq *rq, int budget,
            struct veth_xdp_tx_bq *bq,
            struct veth_stats *stats)
{
    for (i = 0; i < budget; i++) {
        void *ptr = __ptr_ring_consume(&rq->xdp_ring);

        if (veth_is_xdp_frame(ptr)) {
            /* ndo_xdp_xmit */
[...]
            frame = veth_xdp_rcv_one(rq, frame, bq, stats);
[...]
        } else {
            /* ndo_start_xmit */
            struct sk_buff *skb = ptr;
[...]
            skb = veth_xdp_rcv_skb(rq, skb, bq, stats);
            if (skb) {
                netif_receive_skb(skb);
            }
[...]
        }
        done++;
    }
[...]
}

veth_xdp_rcv__ptr_ring_consumexdp_ring에서 데이터를 소비한다. 소비된 데이터는 veth_xdp_rcv_one 또는 veth_xdp_rcv_skb를 호출하며, 두 함수 모두 내부적으로 bpf_prog_run_xdp를 호출해 실제 XDP 프로그램을 실행한다.

  • XDP_PASSnetif_receive_skb 등으로 커널 네트워크 스택 처리를 진행한다.
  • XDP_TX면 아래 로직을 수행한다.
/* drivers/net/veth.c */
static struct sk_buff *veth_xdp_rcv_skb(struct veth_rq *rq,
                    struct sk_buff *skb,
                    struct veth_xdp_tx_bq *bq,
                    struct veth_stats *stats)
{
[...]
    act = bpf_prog_run_xdp(xdp_prog, xdp);
[...]
    switch (act) {
[...]
    case XDP_TX:
        veth_xdp_tx(rq, xdp, bq);
    }
}

static int veth_poll(struct napi_struct *napi, int budget)
{
[...]
    done = veth_xdp_rcv(rq, budget, &bq, &stats);
[...]
    if (stats.xdp_tx > 0)
        veth_xdp_flush(rq, &bq);
[...]
}

XDP_TX를 반환하면 veth_xdp_tx가 호출돼 처리한 패킷을 frame으로 변환해 큐에 넣는다. 이후 veth_poll로 돌아오면 stats.xdp_tx가 0 이상이므로 veth_xdp_flush가 호출된다.

/* drivers/net/veth.c */
static int veth_xdp_xmit(struct net_device *dev, int n,
             struct xdp_frame **frames,
             u32 flags, bool ndo_xmit)
{
[...]
/* The napi pointer is set if NAPI is enabled, which ensures that
    * xdp_ring is initialized on receive side and the peer device is up.
    */
if (!rcu_access_pointer(rq->napi))
    goto out;

[...]
    for (i = 0; i < n; i++) {
        struct xdp_frame *frame = frames[i];
        void *ptr = veth_xdp_to_ptr(frame);

        __ptr_ring_produce(&rq->xdp_ring, ptr);
    }
[...]
}

static void veth_xdp_flush_bq(struct veth_rq *rq, struct veth_xdp_tx_bq *bq)
{
[...]
    sent = veth_xdp_xmit(rq->dev, bq->count, bq->q, 0, false);
[...]
}

static void veth_xdp_flush(struct veth_rq *rq, struct veth_xdp_tx_bq *bq)
{
[...]
    veth_xdp_flush_bq(rq, bq);
    rcv = rcv_dereference(priv->peer); // veth1이 수신이라면 상대는 veth0
    rcv_rq = &rcv_priv->rq[veth_select_rxq(rcv)];
[...]
    __veth_xdp_flush(rcv_rq);
}

veth_xdp_xmitveth_xdp_tx가 bulk queue에 쌓아둔 frame을 __ptr_ring_producexdp_ring에 주입한다.

if (!rcu_access_pointer(rq->napi))
    goto out;

여기서 혼동하기 쉬운 지점이 있다. 수신 측 가상 이더넷이 veth1이고 XDP_TX 상황이라면, veth1의 peer인 veth0을 꺼내 rcv에 저장한다. 그리고 전송할 데이터를 veth0에 주입해 veth0에서 veth_poll이 트리거되도록 만든다. 즉, veth1에만 XDP가 설치되면 안 된다. XDP_TX를 쓰려면 veth0, veth1 페어 모두 XDP가 설치돼야 하는 이유다.

이 함수 구간을 통해, 처음 말한 XDP_TX가 어떻게 수신했던 NIC로 패킷을 되돌리는지 확인할 수 있다.

veth는 터널링 개념이 있다는 걸 알면서도, 대부분 “veth1로 전송하면 바로 veth1로 가겠지”라고 생각하기 쉽다. ‘수신했던 네트워크 인터페이스로 돌려보낸다’고 하면 veth1을 떠올리는 것도 자연스럽다. 하지만 코드와 결과는 그렇게 단순하지 않다.

veth826ee2b와 pair인 eth0를 만들고, eth0는 네트워크 네임스페이스로 격리한 뒤 XDP_TX를 반환하는 XDP 프로그램을 붙인다. 또한 veth826ee2b에도 XDP_PASS를 반환하는 XDP 프로그램을 똑같이 붙인 뒤 아래 명령을 실행한다. 그리고 nc로 3-way handshake만 진행하는 패킷을 전달한다.

sudo bpftrace -e 'kfunc:veth_xdp_xmit{
    printf("name: %s, ifindex: %d\n", args->dev->name, args->dev->ifindex);
}'

# 결과 
name: eth0, ifindex: 2
name: eth0, ifindex: 2
name: eth0, ifindex: 2

sudo bpftrace -e 'kfunc:veth_poll{
    printf("name: %s, ifindex: %d\n", args->napi->dev->name, args->napi->dev->ifindex);
}'

# 결과 
name: eth0, ifindex: 2
name: veth826ee2b, ifindex: 183
name: eth0, ifindex: 2
name: veth826ee2b, ifindex: 183
name: eth0, ifindex: 2
name: veth826ee2b, ifindex: 183

veth_xdp_xmit는 앞서 말했듯 XDP_TX를 리턴하면 호출되는 함수다. 3-way handshake는 클라이언트와 서버 간 3개의 패킷을 주고받으므로, 중간 로드밸런서에서 bpftrace를 걸었을 때 veth_xdp_xmit가 3번 모두 eth0로 찍히는 건 자연스럽다.

veth_poll 트레이싱 결과를 보면, 클라이언트가 로드밸런서로 패킷을 줬을 때 veth_poll이 트리거된다. 그리고 앞서 말했듯 XDP_TX를 반환하면(가상 이더넷 기준) 자신이 수신했던 인터페이스의 peer에게 패킷을 주입하고 __napi_schedule()을 호출해 peer의 veth_poll 호출을 유도한다. veth826ee2b 인터페이스에서는 변경된 MAC 주소 기반으로 브릿지를 통해 원하는 가상 인터페이스로 프레임이 전달된다. 이 부분은 XDP와 veth를 넘어서는 내용이므로 여기서는 다루지 않는다.

참고로 다른 조작된 패킷(MAC 주소)을 전송하는 함수는 br_handle_frame이다. 분석은 따로 하지 않고 넘어간다.

여기까지가 XDP 프로그램을 설치하고 XDP_PASS, XDP_TX일 때의 간략한 동작 과정이다. 앞서 언급했듯 XDP는 커널 내부 소스와 통합돼 있어 커널 안정성 기반으로 동작할 수 있다. 다만 드라이버마다 Native 모드 지원 여부는 다르며, 지원되지 않는 드라이버는 Generic 모드를 사용해야 한다.

NAPI, 네트워크 스택 등 실제 네트워크 구조에 대한 설명은 여러 부분 생략했다. 그 과정에서 예상치 못한 잘못된 내용이 있을 수 있으나, 큰 흐름을 이해하기 위한 구조로 봐주면 좋겠다.

위 그림은 지금까지 설명한 내용을 한 장으로 정리한 것이다. 제한된 공간에 담다 보니 네트워크 스택은 대체로 생략했다.

sudo bpftrace -e 'kprobe:br_handle_frame{ printf("handle\n"); }'

로드밸런서

지금까지 XDP를 간단히 살펴봤다. 이제 이 글의 목적인 XDP 로드밸런서를 구현하고 테스트한다. XDP 로드밸런서는 2가지 방식과 DSR 방식이 존재하는데, DSR은 다음 기회에 살펴본다.

이번에 진행하는 방식은 구현 난도가 가장 낮은 방식이지만, 성능 측면에서 다음에 다룰 DSR(Direct Server Return) 방식보다는 좋지 않다.

위 모델은 XDP 로드밸런서를 구현할 때 베이스가 되는 구조라고 생각한다. XDP는 NIC에서 패킷 수신 후 리눅스 네트워크 스택에 진입하기 전에 실행되므로, ethernet frame, ip, tcp headeroption 처리까지 필요하다.

XDP 프로그램이 트리거된 패킷은 목적지가 로드밸런서 쪽으로 돼 있기 때문에, 이를 원하는 서버 목적지 정보로 바꿔줘야 한다. 또한 IP Header, TCP Header가 수정되므로 각 checksum도 재계산해야 한다. 그리고 로드밸런서가 클라이언트와 서버 간 커넥션을 어떻게 유지할지도 중요하다. 아래 챕터에서 조금 더 자세히 살펴본다.

위 구조를 기반으로 Cilium eBPF를 사용해 코드 레벨 설명을 한다. 다만 설명을 위해 전체 코드가 아니라 부분적으로만 다룬다.

실제 내용을 진행하기 전에, eBPF에는 Verifier라는 검증기가 있다는 점을 다시 강조한다. XDP 프로그램 개발 시 패킷 데이터를 직접 접근·수정해야 하는데, 반드시 “접근 가능한 범위인지”, “경계를 넘지 않는지” 조건 검사를 기반으로 프로그래밍해야 한다.

데이터 자료구조 (BPF MAP)

로드밸런서는 클라이언트와 직접 커넥션을 맺지 않는다. 그런데 서버로 포워딩할 때 서로 다른 클라이언트가 동일한 소스 포트를 들고 오면, 포트 충돌로 TCP Sequence가 꼬일 수 있다. 그래서 로드밸런서 측은 클라이언트 소스 포트가 아니라 로드밸런서가 사용할 고유 포트를 하나 확보해 포워딩해야 한다.

아래 같은 상황이다.

# 1 클라이언트
10.201.0.3:3282 -> 로드밸런서:3282 -> 서버1

# 2 클라이언트
10.201.0.4:3282 -> 로드밸런서:3282 -> 서버1

따라서 로드밸런서 측은 비어 있는 포트를 사용해야 한다.

struct session{
    __u32 client_ip;
    __u16 client_port;
    __u32 server_ip; 
    __u16 server_port;
    __u8 reserve;
    __u8 used; 
    __u16 lb_port;

    __u8 client_mac[ETH_ALEN];
    __u8 server_mac[ETH_ALEN];

    __u8 client_state;
    __u8 server_state;
};

struct {
    __uint(type, BPF_MAP_TYPE_HASH); 
    __uint(max_entries, MAX_SESSION);
    __type(key, __u32);
    __type(value, struct session);
} session_map SEC(".maps");

위 구조체를 value로 두고, key는 클라이언트 출발지 주소와 소스 포트 값으로 해시를 만든 뒤 최대 세션 수로 모듈러 연산을 한다. 결과값을 로드밸런서 포트 범위에 더해 나온 값을 key로 쓰고, 동시에 실제 서버로 전달되는 로드밸런서의 소스 포트로 활용한다.

포워딩할 서버 목록은 아래 자료구조로 두며, 유저 스페이스에서 등록한다.

struct server_config {
    __u32 ip;
    __u16 port;
    __u8 mac[ETH_ALEN];
};

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, SERVER_NUM);
    __type(key, __u32);
    __type(value, struct server_config);
} servers SEC(".maps");

클라이언트 → 로드밸런서 → 서버

sudo ip netns exe client1 curl 10.201.0.4:8000

클라이언트가 서버에 접속하려면 먼저 로드밸런서 주소에 접근한다. TCP라면 TCP 3 Way Handshake를 진행한다. 로드밸런서의 목적은 클라이언트와 3WH로 커넥션을 맺는 게 아니라, 패킷을 Forward하는 것이다. 그래서 3WH를 대상 서버와 진행할 수 있게 도와줘야 한다.

클라이언트가 첫 TCP 커넥션 요청을 하면 SYN 패킷을 보낸다. 로드밸런서 측에서 TCP header를 파싱했을 때 SYN 플래그가 켜져 있다면 첫 커넥션으로 보고 해시 테이블에 등록한다.

__u32 hash = get_two_hash(iph->saddr, tcph->source);
/* port_num == 로드밸런서 포트 */
__u32 port_num = (hash % MAX_SESSION) + 20000;
/* server_key는 실제 서버 갯수를 모듈러연산해서 결정하도록 한다. */
__u32 server_key = hash % SERVER_NUM;  

if(tcph->syn) {
    [...]
    struct session ss = {
            .client_ip = iph->saddr,
            .client_port = tcph->source,
            .server_ip = server->ip, 
            .server_port = server->port,
            .lb_port = port_num,
            .used = 1,
            .client_state = ESTABLISHED,
            .server_state = ESTABLISHED,
    };

    bpf_map_update_elem(&session_map, &port_num, &ss, BPF_NOEXIST);
}

session 구조체를 만들었으니, 이제 실제 클라이언트의 목적지(MAC, IP, Port) 정보를 서버로 바꾼다. 그리고 출발지 정보(MAC, IP, Port)도 로드밸런서에서 온 것처럼 바꾼다.

__builtin_memcpy(eth->h_dest, ss->server_mac, ETH_ALEN);
__builtin_memcpy(eth->h_source, load_balancer_mac, ETH_ALEN);

tcph->source = ss->lb_port;
iph->saddr = load_balancer_ip;
iph->daddr = ss->server_ip;

iph->check = iph_csum(iph); 
tcph->check = tcph_csum(tcph, iph, data_end); 

return XDP_TX;

IP Header, TCP Header가 변경됐기 때문에 checksum도 다시 계산해야 한다. 체크섬 계산은 BPF helper를 쓰거나 직접 word 단위로 반복문을 돌려 계산해도 된다. 다만 TCP 패킷 길이가 홀수인 경우 남는 1바이트도 계산에 포함해야 한다.

마지막으로 XDP_TX를 반환해 도착했던 NIC로 프레임을 재주입한다. 재주입된 프레임은 설정된 MAC/IP/Port를 기반으로 서버 측으로 전달된다.

서버 → 로드밸런서 → 클라이언트

서버로 전달된 패킷은 서버의 네트워크 스택에서 처리되고, 응답은 출발지(로드밸런서)로 전송된다. 로드밸런서가 패킷을 수신하면 TCP Header의 목적지 포트를 key로 session_map에서 세션을 찾아 클라이언트 정보를 복원한다.

__u32 port_num = tcph->dest;
struct session *ss = bpf_map_lookup_elem(&session_map, &port_num);

session에는 클라이언트 MAC, IP, 실제 소스 포트가 저장돼 있다. 이를 기반으로 아래처럼 바꾸고 checksum을 재계산한다.

__builtin_memcpy(eth->h_dest, ss->client_mac, ETH_ALEN);
__builtin_memcpy(eth->h_source, load_balancer_mac, ETH_ALEN);

iph->saddr = load_balancer_ip;
iph->daddr = ss->client_ip;
tcph->dest = ss->client_port;

// 체크섬 계산
iph->check = iph_csum(iph);
tcph->check = tcph_csum(tcph, iph, data_end);

조작된 패킷은 XDP_TX를 반환해 다시 도착했던 NIC로 재주입한다. 재주입된 패킷은 클라이언트에게 전달된다.

서버 측에서 RST 또는 FIN 플래그를 보내면 커넥션 종료로 보고 session_map에서 삭제한다.

if(tcph->rst) goto delete;

switch(ss->server_state) {
    case ESTABLISHED:
        if(tcph->fin) {
            ss->server_state = FIN;
            goto update;
        }
        break;
    case FIN:
        if(tcph->ack){
            if(is_closed(ss)) goto delete;
        }
        break;
}

bpf_map_delete_elem(&session_map, &port_num);

RST는 맵에서 바로 삭제한다. FIN은 TCP 종료 절차를 고려해 클라이언트/서버 모두 FIN을 전송하고 ACK를 수신한 뒤 삭제한다.

위 그림은 로드밸런서 동작을 한 장으로 표현했다. 클라이언트(Users)가 로드밸런서 컨테이너로 패킷을 전송하면, 로드밸런서의 가상 네트워크 인터페이스(NIC-veth)에 패킷이 도착한다. 도착한 패킷은 XDP hook에 설치된 XDP 프로그램이 실행되며 MAC/IP/Port를 수정하고 XDP_TX를 반환한다. 그러면 도착했던 NIC로 재주입되고, 수정된 헤더 정보 기반으로 목표 서버로 포워딩된다. 반대로 로드밸런서가 XDP_PASS를 반환하면 로드밸런서의 커널 네트워크 스택을 타게 되고 iptables나 애플리케이션에서 처리된다.

퍼포먼스

레이턴시와 리소스 사용률을 확인하기 위해 다음 세팅으로 부하 테스트를 진행했다.

ContainerCPUMemory포워딩 웹서버
L7-NGINX1000m128Mi10.201.0.20, 10.201.0.21
L4-NGINX1000m128Mi10.201.0.30, 10.201.0.31
L2-XDP1000m128Mi10.201.0.5, 10.201.0.6

레이턴시

VUs 100, Reqs 1,000,000 / VUs 500, Reqs 1,000,000

ContainerDurationP50RPSDurationP50RPS
L7-NGINX1m7.6s5.66ms147851m15.4s30.72ms13251
L4-NGINX27.3s2.16ms3657027.9s11.23ms35758
L2-XDP19.3s1.26ms5165218.9s5.72ms52700

VUs 1000, Reqs 3,000,000 / VUs 3000, Reqs 6,000,000

ContainerDurationP50RPSDurationP50RPS
L7-NGINX3m54.4s32.2ms127934m26s31.87ms11273
L4-NGINX1m30.3s12.61ms331881m9s6.79ms43502
L2-XDP53.1s8.74ms5647846s18ms65206

가상 유저 1000명, 전체 요청 3,000,000 기준으로 XDP 로드밸런서는 L7-NGINX, L4-NGINX 대비 각각 P50 기준으로 72.8% 감소, 30.7% 감소를 확인했다. 초당 요청 수는 각각 343.6%, 71% 증가율을 보였다.

리소스 사용량

VUs 100, Duration 10m / VUs 1000, Duration 10m

ContainerCPUMemoryCPUMemory
L7-NGINX0.5746.96M0.58816.5M
L4-NGINX0.4405.32M0.5311.3M
L2-XDP0.00326.1M0.004523.4M

VUs 3000, Duration 10m / VUs 5000, Duration 10m

ContainerCPUMemoryCPUMemory
L4-NGINX0.62116.8M0.67215.6M
L2-XDP0.006523.5M0.024723.1M

리소스 사용량은 XDP가 앞선 이론에서 설명했듯(Native XDP 기준) 패킷이 NIC에 도착하고 CPU 처리의 가장 빠른 시점에 처리되기 때문에 CPU 사용량이 현저히 낮다는 점을 확인했다.

로드밸런서별 CPU 사용량
로드밸런서의 CPU 사용량

한계

제시한 로드밸런서 디자인은 Direct Server Return 방식이 아닌 NAT 방식으로 구성돼 있어, 성능 향상을 추가로 도모할 여지가 있다. 또한 단일 노드에서만 동작 가능하며 쿠버네티스 배포 시 다른 노드로 포워딩하는 디자인 모델을 제시하지 않았기 때문에 한계가 있다.

완전히 L2 레이어에서만 동작 중인 XDP 로드밸런서는 ingress에서만 XDP 프로그램 실행이 가능하고 egress에서는 불가능하다. 이는 TC 레벨에서 추가 작업이 필요함을 의미한다.

결론

NIC에 도착한 패킷을 처리하려면 커널 네트워크 스택을 거쳐 유저 스페이스 영역까지 패킷 데이터를 복사해야 한다. 전송 시에는 역과정으로 또 한 번 진행된다. 이 오버헤드는 불필요하다. 패킷을 수신 즉시 로드밸런싱 처리해 커널 네트워크 스택을 태우지 않고 전송한다면, 오버헤드가 줄어 레이턴시와 리소스 사용량도 자연스럽게 줄어든다.

이 개념은 쿠버네티스에서 kube-proxy 구현에도 적용될 수 있음을 시사한다.

요약

일반적인 로드밸런서나 쿠버네티스의 iptables, ipvs는 공통적으로 네트워크 스택을 타며, 패킷 메모리 카피가 발생하는 순간이 있다. XDP 프로그램은 NIC에서 오프로딩돼 실행될 수 있고, 네트워크 스택을 거치지 않기 때문에 일반적으로 빠르다고 볼 수 있다.

다만 단일 시스템에서 network namespace만 나눈 상태에서 진행한 테스트라 CPU 사용량까지 더 정교한 의미로 해석하기 어려웠던 점은 아쉽다. eBPF/XDP는 지금도 발전 중이며 커널 코드에 많은 기여가 발생하고 있다는 점은 분명하다. 추후 활용 사례가 더 늘어날 것이라고 생각한다.

참고문헌

김수창 기자

eBPF/XDP는 매우 강력한 기술이지만 제대로 쓰이기 위해서는 리눅스에서 NIC에서 패킷을 수신하고 처리하는 과정에 대해서 대략적으로 알아야 할 필요가 있다고 생각했기에 관련 설명을 추가했다. 이 글이 컨테이너화 시대에 XDP라는 기술을 사용하고 이해하는 데 도움이 되었으면 한다. 리눅스 커널 코드의 이야기는 매우 방대해 보는 관점에 따라 다르게 해석될 여지가 있기에 완전 신뢰가 아닌 참고용으로 봐주었으면 한다.


TOP