1. 개요

Netfilter 는 리눅스 커널의 IP 패킷 경로에 정해진 다섯 지점을 두고, 그 지점에 외부 모듈이 함수를 등록해 패킷을 검사·수정·폐기할 수 있게 하는 커널 프레임워크다.

Netfilter 자체는 아무 판단도 하지 않는다. 자리, 명부, 등록 API 세 부품으로 이루어지고, 판단은 명부에 걸린 함수가 한다.

① 자리 — 커널 IP 코드의 다섯 줄NF_HOOK(PRE_ROUTING)NF_HOOK(LOCAL_IN)NF_HOOK(FORWARD)NF_HOOK(LOCAL_OUT)NF_HOOK(POST_ROUTING)② 명부 — 자리 번호별 함수 포인터 배열, priority 순defragconntracknatfilternatconfirmfilter(빈 칸)conntracknatfilternatconfirm③ 등록 API외부 모듈이 명부에자기 함수 주소를 넣는다nf_register_net_hook()struct nf_hook_opsiptables · conntrack · eBPF(모듈 로드 시 한 번)패킷이 ① 을 밟으면 ② 를 순서대로 부른다. ② 를 채우는 건 ③ 이고, 판단은 그 함수들이 한다

“훅(hook)” 은 두 뜻으로 쓰인다. 자리를 가리킬 때(hook point)와, 거기 등록된 함수를 가리킬 때(hook function). 이 문서는 자리를 2장, 함수를 5장에서 다룬다.

넓은 뜻으로 netfilter.org 프로젝트는 iptables, nftables, conntrack 같은 도구까지 통틀어 Netfilter 라고 부른다. 이 문서는 좁은 뜻, 커널 훅 프레임워크만 다룬다.

커널 2.4(2001) 부터 메인라인에 있고 CONFIG_NETFILTER=y 로 컴파일된다. 모든 배포판 커널이 켜 둔다. 이 옵션을 끄면 NF_HOOK 매크로는 다음 함수 호출 한 줄로 치환되어 사라지고, 방화벽·NAT·conntrack 이 전부 불가능해진다.

grep CONFIG_NETFILTER= /boot/config-$(uname -r)
# CONFIG_NETFILTER=y

2. 훅 포인트

자리는 추상적인 개념이 아니다. 커널 소스 net/ipv4/ 의 특정 함수 안 특정 줄에 적힌 NF_HOOK(...) 호출이다. 다섯 줄이 있고, 각 줄에 자리 번호가 상수로 박혀 있다.

2.1. 다섯 지점

커널 상수iptables 체인NF_HOOK 줄이 있는 함수시점
NF_INET_PRE_ROUTINGPREROUTINGip_rcv()장치에서 올라온 직후, 라우팅 전
NF_INET_LOCAL_ININPUTip_local_deliver()라우팅 결과 “내 것”, 소켓 전
NF_INET_FORWARDFORWARDip_forward()라우팅 결과 “남 것”, 다른 장치로 넘기기 전
NF_INET_LOCAL_OUTOUTPUT__ip_local_out()소켓이 만든 패킷, 라우팅 전
NF_INET_POST_ROUTINGPOSTROUTINGip_output()장치로 내보내기 직전

iptables 체인 이름은 이 상수 이름에서 왔다. iptables -A INPUT ... 은 NF_INET_LOCAL_IN 자리의 명부에 걸린 filter 함수에 규칙 한 줄을 더하는 것이다.

/* net/ipv4/ip_input.c — 축약 */
int ip_local_deliver(struct sk_buff *skb)
{
    /* 자리 번호가 상수로 박혀 있다 — 이 함수는 언제나 LOCAL_IN */
    return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, net, NULL,
                   skb, skb->dev, NULL, ip_local_deliver_finish);
}

/* net/ipv4/ip_forward.c — 축약 */
int ip_forward(struct sk_buff *skb)
{
    return NF_HOOK(NFPROTO_IPV4, NF_INET_FORWARD, net, NULL,
                   skb, skb->dev, rt->dst.dev, ip_forward_finish);
}

패킷이 “지금 어느 자리에 있는지” 를 판단하는 코드는 없다. 패킷(skb)에 자리 정보가 실려 있지도 않다. 어느 함수로 흘러들어갔느냐가 곧 자리이고, 그 함수에 번호가 적혀 있다.

2.2. NF_HOOK 의 동작

NF_HOOK 한 줄은 “이 번호의 명부를 순서대로 부르고, 전부 통과면 다음 함수로 가라” 다.

ip_local_deliver() 안의 NF_HOOK 한 줄을 펼치면1for (ops in hooks[LOCAL_IN]) {2v = ops->hook(priv, skb, st);3if (v == NF_DROP) {kfree_skb(skb); return;}4/* NF_ACCEPT: 다음 칸 */}5return okfn(skb);1hooks[LOCAL_IN] — priority 순으로 한 칸씩filterhook(skb)2nathook(skb)2confirmhook(skb)244DROPDROPDROP3kfree_skb(skb) — 폐기. okfn 은 부르지 않는다전부 ACCEPT5okfn(skb) — 다음함수, 원래 가던 길코드 다섯 줄이 그림 다섯 칸이다 — 자리는 명부를 돌리고, 판정에 따라 폐기 아니면 다음함수

명부가 비어 있으면 okfn(skb) 호출 한 번과 같다. 부팅 직후의 커널이 이 상태다.

2.3. 경로의 조합

패킷의 입구는 둘이다. 장치에서 올라오면 ip_rcv(), 소켓에서 내려오면 __ip_local_out(). 입구가 다르니 PREROUTING 과 OUTPUT 은 섞이지 않는다. PREROUTING 뒤의 라우팅 조회가 결과를 skb 의 dst 칸에 적고, local 이면 ip_local_deliver() 를, 아니면 ip_forward() 를 부른다. FORWARD 와 OUTPUT 은 둘 다 ip_output() 으로 합류한다.

프로세스의 소켓 — recv() / send()장치에서 수신ip_rcv()PREROUTING라우팅 결정내 것인가 남 것인가FORWARDINPUTOUTPUT라우팅 결정어느 장치로 나갈까POSTROUTING장치로 송신dev_queue_xmit남 것내 것ip_output() 으로 합류lo: 송신을 끝낸 패킷을 수신 큐에 다시 넣어 ip_rcv() 부터 — 네 자리를 다 지나는 이유입구 둘 × 라우팅 분기 하나 = 경로 넷. 자리는 판단이 아니라 함수 흐름의 결과다
경로지나는 자리예
밖에서 와서 내가 받음PREROUTING → INPUTssh 수신
밖에서 와서 남에게 넘김PREROUTING → FORWARD → POSTROUTING노드가 Pod 패킷 전달
내가 만들어 내보냄OUTPUT → POSTROUTINGssh 응답
내가 만들어 나에게 보냄OUTPUT → POSTROUTING, 다시 PREROUTING → INPUTlo 로 curl

3. 패킷 표현

훅 함수가 받는 패킷은 int 배열이 아니라 바이트 배열이다. 헤더 필드 크기가 4비트부터 4바이트까지 제각각이라 최소 단위로 둔다. 커널은 이 배열을 들고 다니지 않고, 배열의 위치를 적은 구조체 struct sk_buff 의 포인터 하나를 함수에서 함수로 넘긴다.

(struct iphdr *)(struct tcphdr *)Ethernet 14BIP 헤더 20BTCP 헤더 20B데이터 (페이로드)0143454…struct sk_buff {*data배열 시작 주소network_header= data + 14transport_header= data + 34*dev, _skb_refdst, …들어온 장치, 라우팅 결과}패킷은 바이트 배열 하나, sk_buff 는 그 배열의 위치표 — 구조체는 복사가 아니라 안경처럼 씌우는 캐스팅

헤더를 읽을 때는 헤더 모양 그대로 정의된 구조체를 그 위치에 캐스팅한다. 메모리를 새로 만들지 않는다.

/* ip_hdr(skb)  = (struct iphdr *)(data + network_header)    */
/* tcp_hdr(skb) = (struct tcphdr *)(data + transport_header) */
struct iphdr  *iph = ip_hdr(skb);
struct tcphdr *th  = tcp_hdr(skb);

if (iph->protocol == IPPROTO_TCP && ntohs(th->dest) == 80) ...

2바이트 이상 필드는 네트워크 순서(big-endian) 로 놓여 있어서 ntohs(), ntohl() 로 뒤집어 읽는다. 포트 80 은 배열에 00 50 으로 놓여 있고, 그대로 읽으면 x86 에서 20480 이 나온다.

4. 호출 사슬

“패킷을 함수에서 함수로 넘긴다” 는 skb 포인터를 인자로 다음 함수를 부르는 것이다. 바이트 배열은 메모리에 한 번 놓인 채 움직이지 않고, 주소값 하나만 전달된다.

수신 한 번의 호출 구조는 이렇다. 한 칸 들어갈 때마다 호출이 한 겹 깊어진다.

ip_rcv(skb)장치에서 올라온 입구NF_HOOK(PRE_ROUTING, skb, ip_rcv_finish)자리 — 번호 0hooks[PRE_ROUTING]:defragconntracknat평평한 for — 이 함수들은 NF_HOOK 을 안 부른다ip_rcv_finish(skb)okfn — 전부 ACCEPT 였을 때만. 라우팅 조회ip_local_deliver(skb)라우팅 결과 "내 것"NF_HOOK(LOCAL_IN, skb, ip_local_deliver_finish)자리 — 번호 1. 같은 번호 두 번은 없다hooks[LOCAL_IN]:filternatconfirm평평한 forip_local_deliver_finish(skb)okfntcp_v4_rcv(skb)소켓 수신 큐에 넣고 끝 — 여기서부터 return 이 풀린다한 칸 들어갈 때마다 호출이 한 겹 — 재귀가 아니라 번호가 바뀌며 내려가는 파이프라인

재귀가 아니다. 명부에 걸린 함수(conntrack, filter 등) 는 skb 를 보고 판정만 돌려주지 자기 안에서 NF_HOOK 을 다시 부르지 않는다. 명부 순회는 평평한 for 문 한 번으로 끝난다. NF_HOOK 이 여러 번 나오는 것은 다음 함수(okfn) 쪽이 또 다른 번호의 NF_HOOK 줄을 품고 있기 때문이고, 한 패킷이 같은 번호를 두 번 밟는 일은 없다. PRE_ROUTING → LOCAL_IN 으로 번호가 바뀌며 한 방향으로 흐르는 파이프라인이다.

4.1. 동기 호출

한 패킷의 수신은 한 번의 긴 함수 호출이다. ip_rcv() 는 NF_HOOK 이 돌아올 때까지, NF_HOOK 은 ip_rcv_finish() 가 돌아올 때까지 기다린다. 제일 안쪽에서 패킷이 소켓 수신 큐에 들어가면 return 이 바깥으로 차례로 풀린다.

ip_rcvNF_HOOK(PRE_ROUTING)ip_rcv_finiship_local_deliverNF_HOOK(LOCAL_IN)ip_local_deliver_finishtcp_v4_rcv소켓 수신 큐에 넣기여기서 패킷의 일은 끝 — 프로세스를 깨운다호출 ↘↗ return시간 → (마이크로초 단위, 전부 한 CPU 의 softirq 에서)바깥 함수는 끝나지 않고 기다리는 게 아니라 안쪽을 실행 중이다 — sleep 도 I/O 대기도 없다

이 사슬은 softirq 문맥에서 돌고, 여기서는 잠들 수 없다. 명부에 걸린 함수가 느리면 그 CPU 에서 대기 중인 패킷 전부가 밀린다. iptables 규칙이 정해진 틀만 허용하는 것, eBPF 검증기가 무한 루프를 거부하는 것, NFQUEUE 가 느린 것이 전부 이 제약에서 나온다.

프로세스는 이 사슬 밖에 있다. 사슬이 다 풀린 뒤에야 “수신 큐에 뭐 들어왔다” 하고 깨워진다.

4.2. 스택 깊이

깊이는 정해져 있고 짧다. 수신 사슬이 20~30 프레임이고, 커널 스택은 x86-64 에서 16KB, softirq 는 별도 IRQ 스택을 쓴다. 패킷마다 사슬이 끝까지 풀린 뒤 다음 패킷이 시작하니 패킷 수만큼 누적되지 않는다.

송신 사슬 끝에서 수신 사슬이 그대로 이어지면 깊어질 수 있는데, lo 가 그 경우다. 커널은 여기를 큐로 끊는다.

/* drivers/net/loopback.c — 축약 */
static netdev_tx_t loopback_xmit(struct sk_buff *skb,
                                 struct net_device *dev)
{
    ...
    netif_rx(skb);        /* backlog 큐에 넣고 바로 return */
    return NETDEV_TX_OK;  /* 송신 스택은 여기서 전부 풀림 */
}
/* 잠시 뒤 softirq 가 큐를 꺼내 ip_rcv() 부터 새 스택 */

그래서 lo 로 보낸 패킷이 네 자리를 다 지나는 것은 한 스택이 아니라 두 번의 스택이다.

4.3. 재진입 상한

VXLAN, IPIP 같은 터널은 송신 중에 캡슐을 씌우고 다시 송신 경로로 들어간다. 터널 안에 터널이면 스택이 겹쳐 쌓이고, 커널은 dev_queue_xmit() 에서 재진입 횟수를 세다가 XMIT_RECURSION_LIMIT(8) 을 넘으면 패킷을 버리고 로그를 남긴다.

Dead loop on virtual device vxlan0, fix it urgently!

5. 훅 함수

훅의 둘째 뜻. 명부에 등록된 함수다. 다섯 자리 모두 같은 타입의 함수를 받고, 같은 판정값을 돌려준다.

typedef unsigned int nf_hookfn(void *priv, struct sk_buff *skb,
                               const struct nf_hook_state *state);
/* state 에 hook 번호, in/out 장치, net 이 들어 있다 */

5.1. 등록과 순서

등록은 struct nf_hook_ops 에 함수와 자리를 적어 nf_register_net_hook() 을 부르는 것이다. 구조체 안에 함수(.hook) 와 자리(.hooknum) 가 따로 있다.

static unsigned int my_hook(void *priv, struct sk_buff *skb,
                            const struct nf_hook_state *state)
{
    if (ip_hdr(skb)->protocol == IPPROTO_ICMP)
        return NF_DROP;
    return NF_ACCEPT;
}

static struct nf_hook_ops ops = {
    .hook     = my_hook,            /* 훅의 둘째 뜻: 함수 */
    .hooknum  = NF_INET_LOCAL_IN,   /* 훅의 첫째 뜻: 자리 */
    .pf       = NFPROTO_IPV4,
    .priority = NF_IP_PRI_FILTER,   /* 0 */
};

nf_register_net_hook(&init_net, &ops);    /* 명부에 넣는다 */
nf_unregister_net_hook(&init_net, &ops);   /* 뺀다 */

명부는 고정이 아니다. 부팅 직후에는 다섯 명부가 전부 비어 있고, 모듈이 로드되면서 채운다. nf_conntrack 은 네 자리에, iptable_filter 는 세 자리에 자기 함수를 넣는다. iptables 명령을 처음 치면 해당 모듈이 자동 로드된다.

같은 자리에 여럿이면 priority 오름차순으로 불린다. conntrack(-200) 이 nat(-100) 보다 먼저 와야 “기존 연결인지” 를 확인한 뒤 NAT 가 메모를 쓰거나 읽을 수 있다.

priority상수누가
-400NF_IP_PRI_CONNTRACK_DEFRAG조각 재조립
-300NF_IP_PRI_RAWraw 테이블
-200NF_IP_PRI_CONNTRACKconntrack 조회
-150NF_IP_PRI_MANGLEmangle 테이블
-100NF_IP_PRI_NAT_DSTDNAT
0NF_IP_PRI_FILTERfilter 테이블
100NF_IP_PRI_NAT_SRCSNAT / MASQUERADE
INT_MAXNF_IP_PRI_CONNTRACK_CONFIRMconntrack 메모 확정

지금 명부에 뭐가 걸려 있는지는 nftables 가 보여 준다. 리눅스 전용 명령이고, macOS 에는 Netfilter 가 없다(BSD 의 pf 를 쓴다).

sudo nft list hooks
family ip {
  hook prerouting {
    -0000000400 nf_defrag_ipv4
    -0000000200 nf_conntrack_in
    -0000000100 chain nat PREROUTING
  }
  hook input {
    +0000000000 chain filter INPUT
    +0000000100 chain nat INPUT
    +2147483647 nf_confirm
  }
  ...
}

5.2. 지점별 가용 정보

함수 모양은 같지만 자리마다 state 에 채워지는 값이 다르다. 라우팅 전에는 나갈 장치를 모르고, 소켓이 만든 패킷에는 들어온 장치가 없다.

자리들어온 장치 (-i)나갈 장치 (-o)라우팅 결과
PREROUTING있음없음없음
INPUT있음없음local
FORWARD있음있음있음
OUTPUT없음있음있음
POSTROUTING없음있음있음

목적지를 바꾸는 DNAT 는 라우팅 전(PREROUTING, OUTPUT) 에서만 말이 된다. 라우팅 뒤에 바꾸면 이미 내려진 결정과 어긋난다. 출발지를 바꾸는 SNAT 는 나갈 장치가 정해진 뒤(POSTROUTING) 에서만 말이 된다. 커널이 금지하는 게 아니라 논리상 그렇고, iptables 가 이 논리를 테이블·타깃 제한으로 코드화해 둔다(6장).

5.3. 판정값

값뜻
NF_ACCEPT통과. 명부의 다음 칸으로
NF_DROP폐기. kfree_skb(), 다음 함수 안 부름
NF_STOLEN함수가 패킷을 가져갔다. 커널은 더 관여하지 않음
NF_QUEUE유저 공간(NFQUEUE) 으로 넘김. 판정은 거기서
NF_REPEAT같은 함수를 한 번 더 부름. 거의 안 쓰임

수정은 판정값이 아니다. 함수가 skb 를 고친 뒤 NF_ACCEPT 를 돌려주면 수정된 패킷이 다음으로 간다. DNAT 가 iph->daddr 를 덮어쓰고 체크섬을 다시 계산한 뒤 ACCEPT 하는 식이다.

5.4. 권한과 한계

훅 함수는 skb 포인터를 통째로 받으니 헤더뿐 아니라 페이로드까지 전부 읽고 고칠 수 있다. iptables 의 -m string, DPI, conntrack 의 FTP 헬퍼(페이로드 안 IP 문자열까지 고침) 가 이 자리에서 한다.

한계는 셋이다.

  • 암호화된 페이로드는 바이트는 읽어도 뜻을 모른다. TLS 안의 HTTP 는 안 보인다
  • 조각난 패킷은 조각 하나만 보면 반쪽이다. 그래서 conntrack 이 PREROUTING 명부 맨 앞(-400) 에 재조립 함수를 걸어 두고, 뒤의 함수들은 합쳐진 패킷을 본다
  • skb 의 바이트가 한 덩어리로 연속이 아닐 수 있다. 큰 패킷은 페이로드가 페이지 조각에 흩어져 있어 skb_linearize() 로 합치거나 조각을 따라가야 한다

그리고 이 자리에서 다 읽어도 패킷은 아직 소켓에 안 닿았다. 127.0.0.1 바인딩이 패킷을 막는 게 아니라 마지막 소켓 찾기에서 실패하게 할 뿐인 것, 애플리케이션 모르게 커널 레벨에서 내용이 읽히고 바뀔 수 있는 것, 둘 다 여기서 나온다.

6. 위에 올라가는 것

도구거는 자리비고
iptables rawPREROUTING, OUTPUTconntrack 전에 NOTRACK
iptables mangle다섯 자리 전부헤더 필드 수정
iptables natPREROUTING, INPUT, OUTPUT, POSTROUTINGFORWARD 없음
iptables filterINPUT, FORWARD, OUTPUT통과/거부
nftables다섯 자리iptables 와 같은 자리에 거는 새 규칙 엔진. iptables (nf_tables) 는 이 위에서 도는 구세대 문법
conntrackPREROUTING, OUTPUT(조회), INPUT, POSTROUTING(확정)연결 메모장
ipvsINPUT 등커널 로드밸런서. kube-proxy ipvs 모드
eBPF BPF_PROG_TYPE_NETFILTER다섯 자리커널 6.4+. iptables 함수 대신 내 프로그램을 건다

iptables 는 자기 함수 하나를 여러 자리에 걸어 두고, 그 함수 안에서 사용자가 넣은 규칙 목록을 훑는다. 규칙 안의 사용자 정의 체인 점프는 이 함수 안에서 목록을 따라가는 것이지 NF_HOOK 을 다시 타는 게 아니다. 타깃별 자리 제한도 iptables 가 검사한다.

iptables -t nat -A POSTROUTING -j DNAT --to 10.0.0.1
# iptables: Invalid argument.
# DNAT 는 PREROUTING / OUTPUT 전용

7. 앞에 끼어드는 것

Netfilter 자리 자체를 바꿔 끼우는 것은 없다. 대신 그 앞에 새 자리를 뚫거나, 커널 스택을 아예 안 탄다.

NICXDPeBPFTCeBPFNetfilter 다섯 자리iptables · conntrack · NAT라우팅 · 소켓프로세스드라이버 직후스택 진입 전가장 싸게 버림 (DDoS)장치 큐, 스택 안Cilium: Service·Policy를 여기서 끝냄앞에서 끝내면명부가 비어 지나가기만유저 공간이 직접DPDK · AF_XDP, 커널 스택 우회NIC 를 유저 공간에 매핑, 커널은 안 봄앞에 자리를 새로 뚫는다 — Netfilter 자리 자체는 바꾸지 않고, 비워 둘 뿐이다
방식어디서Netfilter 와의 관계
eBPF on NetfilterNetfilter 자리대체 아님. 같은 자리에 내 프로그램
eBPF TCNetfilter 앞우회. 여기서 끝내면 Netfilter 명부에 할 일이 없다
eBPF XDP드라이버 직후우회. 스택 진입 전에 버리거나 돌려보냄
DPDK / AF_XDP커널 스택 밖완전 우회. 유저 공간이 NIC 를 직접 읽음

Cilium 이 kube-proxy 를 없애는 방식이 TC 자리다. Service 테이블을 eBPF 맵에 넣고 DNAT·NetworkPolicy·노드 간 라우팅을 거기서 끝낸다. iptables 규칙 목록을 위에서부터 훑는 대신 해시 맵 조회 한 번이고, 그 대가로 conntrack 도 자기 맵으로 따로 만든다. 그래도 kubelet 이 요구하는 br_netfilter 와 일부 MASQUERADE 는 iptables 로 남는 경우가 많다.

iptables 규칙을 eBPF 로 자동 번역해 Netfilter 를 없애려던 bpfilter 는 몇 년 끌다 2024년에 메인라인에서 제거됐다. 자리 자체는 바꾸기 어렵고, 그 앞에 새 자리를 뚫는 쪽이 리눅스의 현재 답이다.

8. 관련 문서

  • iptables (예정)
  • Conntrack — 자리에 걸린 함수 둘과 해시 테이블. 한 항목이 두 키로 걸리는 이유
  • eBPF (예정)
  • Service — Service IP 가 PREROUTING / OUTPUT 의 DNAT 로 Pod IP 가 된다
  • CNI — 노드가 FORWARD 자리를 지나는 라우터가 되게 설정한다
  • VXLAN — 4.3 의 재진입이 일어나는 터널

9. 출처