1. 개요

Conntrack(connection tracking) 은 Netfilter 자리에 걸린 함수 둘과 커널 메모리의 해시 테이블 하나로, 지나가는 패킷이 어느 연결에 속하는지 기억하는 커널 모듈이다.

훅 함수는 패킷을 한 장씩 보고 앞에 뭐가 지나갔는지 모른다. “내가 시작한 연결의 답장만 받아라” 나 “첫 패킷에서 바꾼 주소를 같은 연결의 나머지 패킷에도 똑같이 적용하라” 는 누군가 연결을 기억해야 성립하고, 그 기억이 이 테이블이다.

NETFILTER HOOKS · 명부에 걸린 conntrack 함수PREROUTINGINPUTFORWARDOUTPUTPOSTROUTINGnf_conntrack_in-200nf_conntrack_in-200nf_confirmlastnf_confirmlast—nf_conntrack_hash해시 테이블 · 항목(struct nf_conn) 하나가 연결 하나lookup · createlookup · createinsert (confirm)입구 둘(PRE·OUT)에서 찾고, 출구 둘(IN·POST)의 맨 끝에서 넣는다 — 중간에 버려진 패킷은 테이블에 남지 않는다

nf_conntrack 모듈이 로드되면 네 자리의 명부에 함수를 등록한다. iptables -m state 나 NAT 규칙을 처음 넣을 때 자동으로 로드된다.

lsmod | grep nf_conntrack
sudo nft list hooks | grep -E 'conntrack|confirm'

2. 구성

2.1. 훅 함수 둘

함수자리priority하는 일
nf_conntrack_inPREROUTING, OUTPUT-200패킷에서 키를 뽑아 테이블 조회. 없으면 새 항목을 만든다. 결과를 skb 에 붙인다
nf_confirmINPUT, POSTROUTINGINT_MAX새 항목을 테이블에 정식으로 넣는다

들어오는 패킷은 PREROUTING 에서 조회되고 INPUT(내 것) 이나 POSTROUTING(남 것) 에서 확정된다. 나가는 패킷은 OUTPUT 에서 조회, POSTROUTING 에서 확정. 입구와 출구에 맞춰 네 자리다. FORWARD 에는 없다. 조회는 이미 PREROUTING 에서 끝났고 확정은 POSTROUTING 에서 하면 되기 때문이다.

같은 자리의 다른 함수들은 테이블을 직접 뒤지지 않는다. nf_conntrack_in 이 skb->_nfct 에 붙여 둔 포인터를 읽는다.

읽는 쪽priority무엇을
nat (DNAT -100 / SNAT 100)conntrack 뒤첫 패킷이면 규칙을 돌려 결과를 항목에 적고, 아니면 항목에 적힌 대로 주소를 바꾼다
filter 의 -m state / -m conntrack0항목의 상태(NEW, ESTABLISHED …) 와 방향

2.2. 테이블

nf_conntrack_hash 는 버킷 배열이고, 각 버킷이 항목들의 연결 리스트다.

cat /proc/sys/net/netfilter/nf_conntrack_count     # 지금 항목 수
cat /proc/sys/net/netfilter/nf_conntrack_max       # 항목 수 상한
cat /proc/sys/net/netfilter/nf_conntrack_buckets   # 버킷 수

평균 체인 길이는 count / buckets 이고, 조회 비용이 그만큼이다. max 는 항목 수 상한이지 버킷 수가 아니다.

3. 항목

항목 하나(struct nf_conn)가 연결 하나다. conntrack -L 의 한 줄이 그것이다.

tcp 6 86399 ESTABLISHED src=127.0.0.1 dst=127.0.0.1 sport=53424 dport=8080
                        src=127.0.0.1 dst=127.0.0.1 sport=8080 dport=53424 [ASSURED]
 │  │   │       │       └ 첫째 줄: 키 1 (ORIGINAL, 요청 방향)
 │  │   │       │         둘째 줄: 키 2 (REPLY, 응답 방향)
 │  │   │       상태
 │  │   남은 초
 │  프로토콜 번호 (6 = TCP)
 프로토콜

3.1. 키

테이블의 키는 struct nf_conntrack_tuple 한 덩어리다. 5-tuple 에 주소 체계와 zone 이 더해진다.

필드뜻위 줄의 키 1
src.ip출발 IP127.0.0.1
src.port출발 포트53424
dst.ip목적지 IP127.0.0.1
dst.port목적지 포트8080
dst.protonum프로토콜6 (TCP)
src.l3num주소 체계2 (IPv4)
zone테이블을 논리적으로 나누는 번호0
hash = jhash2(&tuple, sizeof(tuple) / 4, zone ^ hash_rnd) % buckets;

hash_rnd 는 부팅 때 뽑는 난수다. 공격자가 같은 버킷으로 몰리는 키를 계산해 체인을 길게 만드는 것을 막는다.

UDP 는 포트가 그대로 들어가고, ICMP 는 포트 자리에 type/code 와 id 가 들어가 echo 요청과 응답이 한 항목으로 묶인다.

3.2. 한 항목, 두 키

패킷 한 장은 자기 헤더에서 키 하나를 뽑아 한 번 조회한다. 그런데 한 연결의 패킷은 두 방향으로 오고, 요청과 답장의 키는 서로 뒤집힌 모양이라 다른 버킷으로 간다. 그래서 항목 하나를 두 키로 테이블에 건다.

NAT 없음 — 키 2 는 키 1 을 뒤집은 것키 1 · 요청 패킷이 뽑는 키127.0.0.1:53424 → 127.0.0.1:8080키 2 · 답장 패킷이 뽑는 키127.0.0.1:8080 → 127.0.0.1:53424항목 X · struct nf_connESTABLISHED · 86399 s · ASSURED메모리에 하나. 어느 키로 와도 여기DNAT 있음 — 키 2 에 바뀐 주소가 적힌다키 1 · curl 이 보낸 모양 그대로10.0.0.198:45026 → 10.96.0.5:80키 2 · 실제로 돌아올 답장의 모양10.200.0.2:8080 → 10.0.0.198:45026항목 Y원래 목적지 10.96.0.5:80 을 기억답장은 키 2 로 Y 를 찾고, 이 메모로 되돌린다패킷은 키 하나로 한 번 찾는다 — 항목이 두 키에 걸려 있어 두 방향이 같은 항목에 모인다

일반 해시맵으로 쓰면 이것과 같다.

map["127.0.0.1:53424 → 127.0.0.1:8080"] = 항목 X
map["127.0.0.1:8080 → 127.0.0.1:53424"] = 항목 X      # 같은 X

항목 안에는 키별로 테이블에 걸리는 고리(tuplehash[ORIGINAL], tuplehash[REPLY]) 가 있고, 조회는 버킷 체인을 따라가며 고리의 키를 비교하다 맞는 고리에서 항목으로 올라간다. 어느 고리에서 맞았느냐가 “이 패킷은 요청 방향인가 응답 방향인가” 가 된다(IP_CT_IS_REPLY). 방화벽의 ESTABLISHED 판정과 NAT 의 역적용이 이 방향 정보로 갈린다.

3.3. 항목의 나머지 칸

칸뜻
statusNEW / ESTABLISHED 같은 상태 비트, ASSURED, 확정 여부
timeout지워지기까지 남은 초. 패킷이 오갈 때마다 다시 채워진다
NAT 정보어느 방향에 어떤 주소 변환을 적용했는지
프로토콜별 상태TCP 는 handshake 단계, 시퀀스 창
카운터방향별 패킷·바이트 수 (nf_conntrack_acct=1 일 때)

4. 패킷 한 장의 처리

들어온 패킷 한 장이 conntrack 과 그 위의 함수들을 어떻게 지나는지다.

PREROUTINGnf_conntrack_in헤더에서 키를 뽑아 조회없으면 새 항목 (NEW)skb->_nfct = 항목 포인터PREROUTINGnat · DNAT_nfct 를 읽는다첫 패킷: 규칙 → 결과를 항목에아니면: 항목대로 주소만 바꿈INPUTfilter-m state 가 _nfct 의상태를 본다ACCEPT / DROPINPUT · lastnf_confirm새 항목이면 삽입timeout 재설정skb->_nfct — 같은 포인터를 들고 끝까지 간다. 테이블을 다시 뒤지는 함수는 없다DROP — confirm 에 닿지 못한다새 항목은 테이블에 들어가지 못하고 버려진다조회는 맨 앞, 삽입은 맨 끝 — 사이의 함수들은 skb 에 붙은 포인터만 읽는다

4.1. 조회와 생성

/* nf_conntrack_in — 축약 */
tuple = extract_tuple(skb);           /* 헤더에서 키 */
h = hash_lookup(tuple);               /* ORIGINAL 이든 REPLY 든 */
if (!h) {
    ct = new_conntrack(tuple);        /* 키 2 는 키 1 을 뒤집어 미리 채움 */
    ctinfo = IP_CT_NEW;               /* 아직 테이블 밖 */
} else {
    ct = container_of(h);
    ctinfo = is_reply(h) ? IP_CT_ESTABLISHED_REPLY   /* REPLY 고리에서 맞았나 */
                         : IP_CT_ESTABLISHED;
}
skb->_nfct = ct | ctinfo;             /* 패킷에 붙인다 */
return NF_ACCEPT;

새 항목의 키 2 는 이 시점엔 키 1 을 뒤집은 것이다. 뒤의 DNAT 가 목적지를 바꾸면 그때 키 2 의 출발지가 바뀐 주소로 고쳐진다. 3.2 의 두 번째 그림이 그 결과다.

4.2. 확정이 맨 끝인 이유

새 항목을 조회 시점에 바로 테이블에 넣으면, 그 패킷이 뒤의 filter 에서 DROP 됐을 때 존재하지 않는 연결이 테이블에 남는다. 그래서 삽입은 priority INT_MAX 로 INPUT · POSTROUTING 명부의 마지막 칸에 두고, 패킷이 모든 검문을 통과해 거기까지 살아 도달했을 때만 넣는다.

conntrack -S 의 insert_failed 가 이 삽입이 실패한 횟수다. 테이블이 꽉 찼거나, 같은 키의 항목이 그 사이 다른 CPU 에서 먼저 들어갔을 때다.

5. 상태

5.1. TCP

Conntrack 의 상태는 TCP 의 상태와 이름이 같지만 기준이 다르다. TCP 는 handshake 가 끝나야 ESTABLISHED 이고, conntrack 은 양방향으로 패킷을 한 번씩 봤으면 ESTABLISHED 다.

PACKETSSYNSYN-ACKACK · 데이터FIN · ACK 양쪽SYN_SENTNEW · UNREPLIED120 sSYN_RECV답이 한 번 왔다60 sESTABLISHEDASSURED · 양방향 확인432000 sTIME_WAIT끝났지만 바로 안 지움120 s-m state 의 NEW 는 첫 칸, ESTABLISHED 는 둘째 칸부터 — 답이 한 번만 와도 성립한다conntrack -E 로 보면 세 칸이 1 ms 안에 지나가고, TIME_WAIT 로 120 초 남는다TCP 의 상태 기계를 메모장이 따라 적은 것 — 기준은 handshake 완료가 아니라 양방향 패킷
상태들어가는 조건기본 timeout (초)sysctl
SYN_SENT첫 SYN120nf_conntrack_tcp_timeout_syn_sent
SYN_RECVSYN-ACK60…_syn_recv
ESTABLISHEDACK432000 (5 일)…_established
FIN_WAIT한쪽 FIN120…_fin_wait
CLOSE_WAIT반대쪽 FIN60…_close_wait
LAST_ACK마지막 ACK 대기30…_last_ack
TIME_WAIT양쪽 FIN 완료120…_time_wait
CLOSERST10…_close

값은 /proc/sys/net/netfilter/nf_conntrack_tcp_timeout_* 에 있고, 배포판이나 클라우드 이미지가 바꿔 두기도 한다. ESTABLISHED 가 86400 으로 보이면 그 환경의 설정이다.

5.2. UDP 와 ICMP

연결이 없는 프로토콜도 항목이 생긴다. 조건이 “양방향으로 한 번씩” 이기 때문이다.

프로토콜NEWESTABLISHED (UNREPLIED 해제)timeout
UDP첫 데이터그램반대 방향 한 장30 초, 양방향 반복이면 120 초 (udp_timeout_stream)
ICMPecho 요청echo 응답 (같은 id)30 초

DNS 질의 한 장이 나가면 [NEW] [UNREPLIED], 응답 한 장이 오면 UNREPLIED 가 빠지고 방화벽의 ESTABLISHED 규칙이 성립한다.

5.3. ASSURED

양방향으로 충분히 오간 항목에 붙는 표시다. TCP 는 handshake 완료, UDP 는 양방향 반복. 테이블이 가득 찼을 때 커널은 ASSURED 가 아닌 항목부터 지워 자리를 만든다. SYN 만 던지고 가는 스캐너의 항목이 먼저 지워지는 이유다.

6. 테이블 포화

cat /proc/sys/net/netfilter/nf_conntrack_count /proc/sys/net/netfilter/nf_conntrack_max
sudo conntrack -S            # cpu 별 insert_failed, drop, early_drop
sudo conntrack -L | awk '{print $4}' | sort | uniq -c    # 상태별 항목 수

count 가 max 에 닿으면 새 항목을 만들 수 없어 패킷을 버린다. 커널 로그에 이렇게 남는다.

nf_conntrack: table full, dropping packet
지표뜻
insert_failed확정 단계에서 삽입 실패
drop항목을 만들 수 없어 패킷 폐기
early_drop자리를 만들려고 ASSURED 아닌 항목을 강제로 지움

연결을 많이 만드는 서비스(DNS, 프록시, 쿠버네티스 노드의 kube-proxy) 에서 겪는다. max 를 키우기 전에 어떤 상태의 항목이 쌓이는지 먼저 본다. TIME_WAIT 가 대부분이면 timeout 을 줄이는 쪽이 맞고, SYN_SENT 가 쌓이면 밖에서 오는 스캔이다.

7. 확인 명령

명령보는 것
conntrack -L [-p tcp] [--dport N] [-s IP] [-d IP]테이블의 항목. netlink 로 읽는다
conntrack -E [-o timestamp]항목이 생기고 바뀌고 지워지는 이벤트
conntrack -SCPU 별 통계
conntrack -D ...항목 삭제
/proc/sys/net/netfilter/nf_conntrack_*count, max, buckets, 프로토콜별 timeout
nft list hooks어느 자리에 conntrack 함수가 걸려 있는지

conntrack 도구는 테이블을 읽는 유저 공간 프로그램이고, 패킷을 보는 것은 커널의 두 함수다.

8. 관련 문서

  • Netfilter — conntrack 함수가 걸리는 자리와 명부, priority 순서
  • NAT (예정) — 첫 패킷에만 규칙, 나머지는 이 테이블로
  • iptables (예정) — -m state, -m conntrack 매치
  • Service — Service IP 로 가는 연결마다 백엔드 Pod 가 고정되는 이유가 이 테이블이다

9. 출처