NAT (Network Address Translation)
Linux 커널의 NAT는 별도 마법이 아니라 nf_nat와 nf_conntrack가 결합된 상태 기반 주소 변환(Address Translation) 경로입니다. 이 문서는 SNAT·DNAT·MASQUERADE·REDIRECT, hairpin NAT, helper/ALG, CGNAT, NAT64와 NPTv6의 차이, nftables flowtable 가속, 튜닝 포인트와 실제 장애 분석 절차를 공식 문서와 커널 소스 기준으로 다시 정리합니다.
핵심 요약
- conntrack — 같은 흐름인지 판별하고 원본/응답 튜플을 기억하는 상태 엔진입니다.
- NAT 바인딩 — 첫 패킷에서 선택된 주소·포트 변환 결과입니다.
- SNAT — 출발지 주소와 포트를 바꿔 외부로 나갈 수 있게 합니다.
- DNAT — 목적지 주소와 포트를 바꿔 내부 서비스로 포워딩합니다.
- Hairpin NAT — 내부 클라이언트가 외부 공개 주소로 같은 내부 서버에 접근할 때 필요한 역경로 유지 기법입니다.
단계별 이해
- 첫 패킷을 찾기
룰셋이 아니라 “어떤 훅에서 최초 바인딩이 만들어졌는가”를 먼저 봅니다. - 라우팅 전후를 나누기
PREROUTING의 DNAT는 라우팅 결정에 영향을 주고, POSTROUTING의 SNAT는 출력 경로 직전에 적용됩니다. - conntrack 상태를 확인하기
후속 패킷은 규칙을 다시 찾지 않으므로, 실패 원인은 룰보다 상태 테이블과 타이머(Timer)에 있는 경우가 많습니다. - 대칭 경로를 검증하기
hairpin, 멀티홈, 컨테이너(Container) 환경에서는 응답이 반드시 같은 번역기를 거쳐 돌아오도록 설계해야 합니다.
nf_nat, nf_conntrack, nftables/iptables NAT 경로를 중심으로 설명합니다.
RFC 4787·5382·5508·6888·6146·6296은 상호 운용성 요구사항과 번역 모델을 설명하는 기준선으로 사용합니다.
중요한 점은 NAT는 방화벽(Firewall) 그 자체가 아니라 주소 변환이라는 점입니다. 외부 노출 정도와 허용 여부는 별도 필터 규칙과 라우팅 정책이 결정합니다.
NAT 개요
NAT는 IPv4 주소 부족 때문에 널리 쓰였지만, 커널 관점에서 더 본질적인 정의는 패킷 헤더의 주소·포트 필드를 한 지점에서 다른 값으로 안정적으로 재기록하고, 응답 경로에서 그 역변환을 보장하는 상태 기계입니다. Linux에서는 이 상태 기계를 nf_conntrack가 유지하고, 실제 필드 수정을 nf_nat가 수행합니다.
중요한 오해 하나를 먼저 지우면 좋습니다. NAT는 흔히 “외부에서 못 들어오게 하니까 보안”으로 설명되지만, 그 효과는 대개 기본 드롭 정책과 상태 기반 필터가 함께 있을 때 생깁니다. NAT만 있다고 해서 보안 정책이 자동으로 완성되지는 않습니다. 반대로 포트 포워딩과 hairpin NAT처럼, NAT는 오히려 외부 노출 경로를 만드는 도구이기도 합니다.
| 형태 | 무엇을 바꾸는가 | 주로 쓰는 훅 | 대표 용도 | 실무 메모 |
|---|---|---|---|---|
| SNAT | 출발지 IP와 포트 | POSTROUTING | 사설망의 인터넷 접속 | 고정 공인 IP 또는 공인 IP 풀을 명시적으로 운용할 때 적합합니다. |
| MASQUERADE | 출발지 IP와 포트 | POSTROUTING | 동적 WAN 주소 환경 | 출구 인터페이스의 현재 주소를 자동 사용합니다. PPPoE·DHCP uplink에 많이 씁니다. |
| DNAT | 목적지 IP와 포트 | PREROUTING, OUTPUT | 포트 포워딩, 서비스 공개 | PREROUTING DNAT는 라우팅 결정 전에 일어나므로 목적지 변화가 경로 선택에 직접 반영됩니다. |
| REDIRECT | 목적지 IP를 로컬 호스트로 | PREROUTING, OUTPUT | 투명 프록시, 로컬 프록시 강제 | DNAT의 특수형입니다. 목적지를 “현재 장비 자신”으로 바꿉니다. |
| NETMAP / prefix | 대역 또는 프리픽스 | PREROUTING, POSTROUTING | 1:1 대역 매핑(Mapping), 프리픽스 교체 | 결정적 매핑을 원할 때 유용합니다. IPv6에서는 NPTv6 논의와 이어집니다. |
NAT 아키텍처와 커널 경로
NAT의 핵심은 “룰 평가”와 “패킷 재기록”을 분리하는 데 있습니다. 첫 패킷에서 nf_nat_setup_info()가 새 튜플을 고르고 reply 방향 튜플을 바꿉니다. 그 다음 패킷부터는 nf_nat_packet()이 이미 저장된 상태만 보고 헤더를 수정합니다. NAT는 매 패킷마다 규칙 전체를 재평가하는 시스템이 아닙니다.
/* net/netfilter/nf_nat_core.c, 개념 파악용 축약 */
if (nf_ct_is_confirmed(ct))
return NF_ACCEPT;
nf_ct_invert_tuple(&curr_tuple,
&ct->tuplehash[IP_CT_DIR_REPLY].tuple);
get_unique_tuple(&new_tuple, &curr_tuple, range, ct, maniptype);
if (!nf_ct_tuple_equal(&new_tuple, &curr_tuple)) {
nf_ct_invert_tuple(&reply, &new_tuple);
nf_conntrack_alter_reply(ct, &reply);
ct->status |= (maniptype == NF_NAT_MANIP_SRC) ?
IPS_SRC_NAT : IPS_DST_NAT;
}
if (ct->status & statusbit)
verdict = nf_nat_manip_pkt(skb, ct, mtype, dir);
nf_nat_setup_info()는 아직 confirm되지 않은 연결에서만 NAT 정보를 세팅하고,
reply tuple을 바꿔 응답 역변환을 준비합니다. 이후 nf_nat_packet()은 이미 설정된 상태 비트를 보고 실제 헤더를 수정합니다.
SNAT와 MASQUERADE
SNAT는 출발지 주소와 포트를 바꿔서 내부 호스트 여러 대가 외부와 통신하도록 만드는 가장 흔한 NAT 형태입니다. 리눅스에서 보통 POSTROUTING 훅에 두며, nftables manpage 기준으로 snat는 postrouting과 input nat chain에서 유효합니다. 실무에서는 거의 항상 POSTROUTING을 봅니다.
| 항목 | SNAT | MASQUERADE |
|---|---|---|
| 외부 주소 지정 | 규칙에 공인 IP 또는 IP 풀을 직접 적음 | 출구 인터페이스의 현재 주소를 자동 사용 |
| 적합한 환경 | 고정 공인 IP, CGNAT, 로드 분산형 주소 풀 | DHCP, PPPoE, 빈번한 WAN 주소 변경 |
| 추가 특징 | persistent, 포트 범위, 풀 운용이 용이 |
인터페이스 다운/주소 제거 이벤트와 연동해 stale mapping 정리에 유리 |
| 주의할 점 | 공인 IP 변경 시 규칙을 직접 바꿔야 함 | 대규모 고정 주소 풀 운용에는 덜 명시적 |
커널 소스 nf_nat_masquerade.c를 보면 MASQUERADE는 NETDEV_DOWN와 주소 제거 이벤트를 감시해 해당 인터페이스에 묶인 conntrack 엔트리를 정리합니다. 그래서 주소가 바뀌는 uplink에서는 단순 SNAT보다 운용상 안전합니다.
table inet nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
# 고정 공인 IP 풀 사용
oifname "wan0" ip saddr 10.10.0.0/16 \
snat to 203.0.113.10-203.0.113.13 persistent,fully-random
# 동적 주소 uplink
oifname "ppp0" ip saddr 192.168.0.0/24 masquerade
}
}
persistent는 가능한 한 같은 내부 클라이언트에 같은 외부 매핑을 유지하려는 플래그이고, fully-random은 포트 선택을 완전히 무작위화합니다. nftables manpage에 따르면 커널 5.0 이상에서는 random과 fully-random이 같은 의미입니다.
포트 할당 내부 동작: get_unique_tuple()
NAT 바인딩이 만들어질 때 커널이 실제로 하는 일은 충돌하지 않는 유일한 튜플을 찾는 것입니다. 이 과정의 핵심 함수가 get_unique_tuple()이며, nf_nat_core.c에 위치합니다. 이 함수는 사용자가 지정한 범위(주소 풀, 포트 범위) 안에서 기존 conntrack 엔트리와 충돌하지 않는 새 튜플을 선택합니다.
/* net/netfilter/nf_nat_core.c — get_unique_tuple() 개념 축약 */
static void
get_unique_tuple(struct nf_conntrack_tuple *tuple,
const struct nf_conntrack_tuple *orig_tuple,
const struct nf_nat_range2 *range,
struct nf_conn *ct,
enum nf_nat_manip_type maniptype)
{
/* 1단계: 원래 tuple이 범위에 속하면 그대로 사용 시도 */
if (in_range(orig_tuple, range)) {
if (!nf_nat_used_tuple(orig_tuple, ct)) {
*tuple = *orig_tuple;
return;
}
}
/* 2단계: 범위 내에서 사용되지 않은 조합 탐색 */
find_best_ips_proto(zone, tuple, range, ct, maniptype);
/* 3단계: 프로토콜별 포트/ID 선택 */
if (!(range->flags & NF_NAT_RANGE_PROTO_RANDOM_ALL))
if (!nf_nat_used_tuple(tuple, ct))
return;
/* 4단계: 포트 범위를 순회하며 비충돌 포트 확보 */
find_proto_unique(tuple, range, ct, maniptype);
}
이 알고리즘의 핵심 포인트는 다음과 같습니다.
| 단계 | 동작 | 실패 시 |
|---|---|---|
| 원본 튜플 유지 | 변환 없이 원래 주소/포트가 범위 안이고 미사용이면 그대로 사용 | 다음 단계로 진행 |
| IP 선택 | find_best_ips_proto()가 범위 내에서 IP 선택. NF_NAT_RANGE_PERSISTENT이면 내부 주소의 해시(Hash)로 결정적 선택 |
범위 내 IP 순환 |
| 포트 선택 | 프로토콜별 unique_tuple() 콜백(Callback)으로 포트/ID를 선택. fully-random이면 prandom_u32() 기반 |
범위 소진 시 실패 |
| 충돌 검사 | nf_nat_used_tuple()이 기존 conntrack과 비교. 5-tuple 전체가 겹치지 않으면 통과 |
다른 포트로 재시도 |
persistent 플래그를 사용하면 같은 내부 클라이언트가 같은 외부 IP에 매핑되므로 웹 서비스의 IP 기반 세션 유지에 유리합니다.
반면 fully-random은 보안 측면에서 포트 예측을 어렵게 하므로, 두 요구사항이 충돌하면 주소는 persistent, 포트는 random 조합이 합리적입니다.
nf_nat_used_tuple()은 conntrack 해시 테이블(Hash Table)에서 reply 방향 충돌을 검사합니다. 이 검사가 중요한 이유는 두 개의 서로 다른 내부 연결이 같은 외부 5-tuple을 공유하면 응답 역변환이 모호해지기 때문입니다. 포트 고갈이 발생하는 근본 원인도 여기에 있습니다. 범위 안의 모든 포트가 이미 다른 연결에 사용 중이면 get_unique_tuple()이 실패하고, 해당 패킷은 NAT를 받지 못합니다.
/* net/netfilter/nf_nat_core.c — nf_nat_used_tuple() 개념 */
static bool
nf_nat_used_tuple(const struct nf_conntrack_tuple *tuple,
const struct nf_conn *ignored_conntrack)
{
/* reply 방향의 역튜플을 만들어서 해시 테이블에서 검색 */
struct nf_conntrack_tuple_hash *found;
struct nf_conntrack_tuple reply;
nf_ct_invert_tuple(&reply, tuple);
found = nf_conntrack_find_get(net, zone, &reply);
if (found) {
/* 이미 다른 연결이 이 튜플을 쓰고 있음 → 충돌 */
if (nf_ct_tuplehash_to_ctrack(found) != ignored_conntrack)
return true;
}
return false;
}
DNAT, REDIRECT, 그리고 hairpin NAT
DNAT는 목적지 주소와 포트를 바꿔 패킷을 내부 서버나 다른 경로로 보냅니다. 가장 전형적인 예는 포트 포워딩입니다. nftables 기준으로 dnat와 redirect는 prerouting과 output nat chain에서 유효합니다. 로컬 프로세스(Process)가 생성한 패킷을 다른 로컬 소켓(Socket)으로 돌리는 경우에는 OUTPUT REDIRECT가 자연스럽습니다.
table inet nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "wan0" tcp dport 443 dnat to 192.168.10.30:8443
iifname "wan0" tcp dport 22 redirect to :2222
}
chain output {
type nat hook output priority -100; policy accept;
tcp dport 853 redirect to :10053
}
}
REDIRECT는 DNAT의 특수형이며 목적지 주소를 현재 로컬 장비로 바꿉니다. 커널 소스 nf_nat_redirect.c는 이 경로가 NF_INET_PRE_ROUTING과 NF_INET_LOCAL_OUT에서만 사용되어야 함을 분명히 드러냅니다.
실무에서 자주 놓치는 부분은 hairpin NAT입니다. 내부 클라이언트가 외부 공개 주소(203.0.113.10:443)로 같은 내부 서버(192.168.10.30:8443)에 접근하면 DNAT만으로는 끝나지 않는 경우가 많습니다. 서버가 클라이언트에게 직접 응답해 버리면 경로가 비대칭이 되고, 클라이언트는 “내가 접속한 외부 주소”가 아니라 “내부 서버 주소”에서 답이 돌아와 세션이 깨질 수 있습니다. 그래서 hairpin에서는 반환 경로를 번역기 자신으로 묶기 위한 추가 SNAT/masquerade가 흔히 필요합니다.
conntrack과 NAT 상태
NAT를 이해할 때 가장 중요한 자료구조는 struct nf_conn입니다. 여기에 원본 방향 tuple, reply 방향 tuple, 상태 비트, 필요시 NAT 확장과 helper 확장이 연결됩니다. NAT가 “연결 기반”처럼 보이는 이유는 실제로 이 상태가 모든 후속 패킷의 판단 기준이 되기 때문입니다.
| 커널/사용자 지표 | 의미 | 현재 문서 기본값/성격 | 왜 중요한가 |
|---|---|---|---|
nf_conntrack_buckets |
conntrack 해시 버킷 수 | 메모리 기준으로 계산, 초기 netns에서만 변경 가능 | 충돌과 탐색 비용에 직접 영향이 있습니다. |
nf_conntrack_count |
현재 할당된 흐름 수 | 읽기 전용(Read-Only) | 테이블 포화 임박 여부를 보는 1차 지표입니다. |
nf_conntrack_max |
허용 최대 흐름 수 | 기본값은 nf_conntrack_buckets |
초과 시 새 연결이 실패합니다. |
nf_conntrack_tcp_timeout_established |
ESTABLISHED TCP 타임아웃 | 432000초 (5일) | 긴 유휴 TCP 세션 유지 비용을 좌우합니다. |
nf_conntrack_udp_timeout |
일반 UDP 타임아웃 | 30초 | 짧은 UDP 흐름은 빨리 비우지만, NAT keepalive가 없는 애플리케이션에는 짧을 수 있습니다. |
nf_conntrack_udp_timeout_stream |
양방향 UDP 타임아웃 | 120초 | RTP·QUIC 같은 장시간 UDP에서 체감에 직접 연결됩니다. |
nf_flowtable_tcp_timeout |
flowtable의 TCP fastpath 타임아웃 | 30초 | 오프로드 경로에서 얼마 동안 캐시(Cache)를 유지할지 결정합니다. |
nf_flowtable_udp_timeout |
flowtable의 UDP fastpath 타임아웃 | 30초 | 짧은 burst 트래픽에서 오프로드 hit 비율에 영향이 큽니다. |
여기서 흥미로운 점은 RFC 4787이 일반 UDP NAT 매핑 타이머에 대해 “2분 미만으로 끝나면 안 된다”고 요구한다는 점입니다. 반면 현재 Linux nf_conntrack_udp_timeout 기본값은 30초입니다. 이는 리눅스 기본값이 곧바로 CPE/CGN 상호 운용성 요구사항을 만족한다는 뜻이 아님을 보여줍니다. 인터넷 경계 NAT 장비로 쓸 때는 역할에 맞게 타임아웃을 직접 조정해야 합니다. 이 문장은 RFC와 커널 문서를 함께 읽은 해석입니다.
# 현재 사용량과 한계
sysctl net.netfilter.nf_conntrack_count
sysctl net.netfilter.nf_conntrack_max
# 상태 보기
conntrack -L -o extended
conntrack -E
# 특정 흐름만 보기
conntrack -L -p tcp --orig-src 192.168.10.20 --dport 443
NAT helper와 ALG
FTP, SIP, 일부 레거시 제어 프로토콜처럼 “제어 채널 안에 다음 데이터 채널 주소와 포트가 실려 오는” 프로토콜은 NAT에 불리합니다. NAT는 L3/L4 헤더만 바꿔서는 충분하지 않고, 애플리케이션 페이로드(Payload)를 읽어 RELATED 흐름을 예상해야 합니다. 이 역할이 conntrack helper입니다.
현재 nftables 모델은 iptables 시절의 “자동으로 붙어주는 helper” 관성보다 훨씬 명시적입니다. nftables manpage도 helper는 ct helper set으로 연결에 명시적으로 붙여야 하며, conntrack lookup 뒤에서만 유효하다고 설명합니다. 즉, helper는 가능한 한 좁은 범위에 붙여야 합니다.
table inet helpers {
ct helper ftp-standard {
type "ftp" protocol tcp;
}
chain prerouting {
type filter hook prerouting priority filter; policy accept;
ip daddr 192.168.10.30 tcp dport 21 ct helper set "ftp-standard"
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ip daddr 192.168.10.30 ct helper "ftp" tcp dport 1024-65535 accept
}
}
daddr와 서비스에 엄격히 묶지 않으면 원치 않는 RELATED 포트 개방 효과가 생길 수 있다고 경고합니다.
TLS로 보호되는 제어 채널에서는 helper가 페이로드를 읽을 수 없으므로, “helper가 안 먹는다”보다 “암호화(Encryption) 때문에 원래 불가능하다”가 정확한 설명일 때가 많습니다.
더 긴 설명은 conntrack 헬퍼 & ALG 문서를 참고하세요. NAT 문서에서는 helper를 “예외적 필요”로 보는 편이 정확합니다. 현대 프로토콜은 가능하면 애플리케이션 계층 NAT 의존을 줄이는 방향으로 설계됩니다.
RFC 동작 모델: mapping, filtering, hairpin
“full cone”, “restricted cone”, “symmetric NAT” 같은 표현은 여전히 현장에서 자주 쓰이지만, RFC 4787 이후 문서는 이를 mapping behavior, filtering behavior, hairpin behavior, timer behavior로 더 정밀하게 분해합니다. Linux NAT를 이 별명 하나로 규정하려고 하면 거의 항상 과도한 단순화가 됩니다.
| RFC 요구사항 | 핵심 내용 | Linux NAT를 읽을 때의 의미 |
|---|---|---|
| RFC 4787 REQ-1 / RFC 5382 REQ-1 | UDP와 TCP에서 Endpoint-Independent Mapping 요구 | 외부 목적지가 달라도 같은 내부 endpoint에 같은 외부 매핑을 유지하는 방향이 상호 운용성에 유리합니다. |
| RFC 4787 REQ-8 / RFC 5382 REQ-3 | 투명성이 중요하면 Endpoint-Independent Filtering 권고 | 필터링은 NAT 그 자체보다 방화벽 정책과 더 강하게 연결됩니다. NAT 규칙만 보고 외부 허용 범위를 단정하면 안 됩니다. |
| RFC 4787 REQ-9 / RFC 5382 REQ-8 / RFC 5508 REQ-7 | UDP·TCP·ICMP hairpin 지원 | 포트 포워딩을 내부에서도 같은 공개 주소로 쓰려면 hairpin 경로를 설계해야 합니다. |
| RFC 4787 REQ-5 | UDP 매핑 타이머는 일반적으로 2분 미만이면 안 됨 | Linux 기본 UDP timeout은 이보다 짧을 수 있으므로, CPE/CGN 역할이면 운영자가 조정해야 합니다. |
| RFC 5508 | ICMP Query/Error도 NAT traversal과 hairpin 고려 필요 | PMTUD와 에러 전달 문제를 “그냥 ICMP 막힘”으로 뭉개면 원인 분석이 막힙니다. |
정리하면, Linux NAT를 “기본적으로 port-restricted cone NAT다” 같은 한 줄로 정의하는 것은 정확하지 않습니다. 외부에서 관찰되는 동작은 NAT 바인딩 정책, 필터 규칙, helper 사용 여부, hairpin 설계, 타이머 설정이 합쳐져 결정됩니다.
CGNAT (Carrier-Grade NAT)
CGNAT는 가정용 공유기의 NAT를 그대로 크게 키운 것이 아니라, 수많은 가입자가 한정된 공인 IPv4 주소 공간(Address Space)을 공유하도록 만드는 통신사업자급 상태 시스템입니다. 그래서 단순한 address rewrite보다 공정성(Fairness), 로그량, 포트 할당, 메모리 예산, 장애 반경이 핵심 문제가 됩니다.
RFC 6598은 CGN과 CPE 사이에서 사용할 공유 주소 공간으로 100.64.0.0/10을 예약했습니다. 이 공간은 RFC 1918 사설 주소와 비슷하게 외부 라우팅 대상이 아니지만, 목적이 다릅니다. 통신사업자 내부에서 CPE와 CGN 사이 구간을 번호 매기기 위한 별도 공간으로 이해하는 편이 정확합니다.
| CGN 운영 포인트 | RFC 6888 관점 | 실무 의미 |
|---|---|---|
| 주소 풀링 | 기본은 paired pooling 권고 | 가능하면 가입자당 같은 외부 IP를 유지해 애플리케이션 혼란과 로그 복잡도를 줄입니다. |
| 포트 제한 | 가입자별 외부 포트 수 제한 필요 | 한 가입자가 포트를 독식해 전체 CGN을 고갈시키지 않게 합니다. |
| 상태 메모리 | 메모리 사용량 제한과 속도 제한 필요 | 대규모 봇 감염이나 비정상 burst가 전체 장비를 무너뜨리지 않게 합니다. |
| 로그 | 목적지 주소/포트 로깅은 가능한 한 피해야 함 | 개인정보와 저장 비용 모두 커지므로, 필요한 최소 정보만 남기는 것이 원칙입니다. |
| 포트 할당 알고리즘 | 포트 효율, 로그 최소화, 예측 난이도 사이 균형 | 결정적 포트 블록은 로그를 줄이지만 포트 활용률과 분산성에 trade-off가 있습니다. |
| 명시적 제어 | 가입자가 매핑을 제어할 프로토콜 필요 | PCP 같은 외부 매핑 제어 체계가 없으면 일부 응용 프로그램이 급격히 불리해집니다. |
NAT64와 NPTv6
IPv6 전환 문맥에서 “NAT”라는 단어는 NAT44 하나만 가리키지 않습니다. RFC 6146의 stateful NAT64는 IPv6 클라이언트와 IPv4 서버를 이어 주는 주소·프로토콜 번역기이고, RFC 6296의 NPTv6는 IPv6-to-IPv6 프리픽스 번역기입니다. 둘은 목표도, 상태 모델도, 운용상의 함정도 다릅니다.
| 항목 | NAT44 | Stateful NAT64 | NPTv6 |
|---|---|---|---|
| 주소 패밀리 | IPv4 ↔ IPv4 | IPv6 ↔ IPv4 | IPv6 ↔ IPv6 |
| 상태 | 보통 stateful | stateful | stateless |
| 핵심 테이블 | conntrack + NAT binding | BIB + session table | 프리픽스 매핑 규칙 |
| 포트 변환 | 필요 시 수행 | 주로 수행 | 하지 않음 |
| RFC 포인트 | 4787/5382/5508/6888 | 6146 | 6296 |
| 운영 핵심 | 주소 절약, 포워딩, hairpin | IPv6 전용 클라이언트의 IPv4 접근 | 멀티홈, 프리픽스 교체, 상태 없는 주소 독립성 |
RFC 6146는 stateful NAT64가 Endpoint-Independent Mapping을 사용하고 TCP, UDP, ICMP Query마다 별도 BIB와 session table을 가진다고 설명합니다. 또 hairpin을 고려하며, end-to-end IPsec과는 기본적으로 양립하지 않는다고 적시합니다. 즉 NAT64는 단순한 dnat/snat 규칙의 변형이 아니라, 별도의 주소 패밀리 변환 모델입니다.
반면 RFC 6296의 NPTv6는 상태가 없고 checksum-neutral하며, 포트를 건드리지 않습니다. 그래서 NAT44보다 훨씬 단순하지만, 보안 기능을 제공하는 것은 아닙니다. RFC 6296도 NPTv6만으로 방화벽이 되는 것은 아니며, 필요한 경우 별도 필터링이 병행되어야 한다고 읽는 편이 맞습니다.
Stateless NAT와 prefix rewrite
stateful NAT가 conntrack을 기반으로 흐름 상태를 유지하는 반면, stateless NAT는 패킷마다 규칙대로 헤더를 바꾸고 별도 상태를 유지하지 않습니다. nftables NAT 위키가 강조하듯, 이런 방식은 1:1 변환이나 매우 통제된 환경에서는 빠르고 단순할 수 있지만, 일반적인 인터넷 경계 NAT로는 함정이 많습니다.
핵심 제약은 conntrack을 끄고 사용해야 한다는 점입니다. 공식 문서는 notrack를 raw 우선순위(Priority) 또는 그보다 이른 훅에 배치하지 않으면 conntrack lookup이 먼저 일어나므로 stateless 설계가 성립하지 않는다고 설명합니다.
table inet rawnat {
chain prerouting {
type filter hook prerouting priority raw; policy accept;
tcp dport 443 ip daddr set 192.0.2.10 tcp dport set 8443 notrack
}
}
nf_flowtable과 NAT fastpath
nf_flowtable은 “conntrack을 버리는 기능”이 아니라, 한 번 conntrack/NAT로 검증된 흐름을 더 짧은 경로로 전달하는 fastpath입니다. 커널 문서가 분명히 적듯 첫 패킷은 기존 IP forwarding path를 통과해야 하고, flowtable 엔트리는 보통 첫 응답 패킷을 본 뒤 만들어집니다. 이후 패킷은 ingress 훅에서 flowtable lookup을 하고, hit이면 neigh_xmit()로 바로 전송할 수 있습니다.
여기서 중요한 사실은 flowtable 엔트리도 NAT 구성을 저장한다는 점입니다. 따라서 flowtable hit 경로에서도 SNAT/DNAT는 계속 적용됩니다. 이것이 “NAT가 있으면 fastpath를 못 탄다”는 단순화가 틀린 이유입니다. 다만 fragment, helper, 복잡한 예외 경로, 미지원 드라이버는 여전히 classic path에 남을 수 있습니다.
table inet fastpath {
flowtable f {
hook ingress priority 0;
devices = { lan0, wan0 };
flags offload;
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related flow offload @f accept
tcp dport { 80, 443 } accept
udp dport 443 accept
}
}
[OFFLOAD], 하드웨어 오프로드는 [HW_OFFLOAD] 태그로 conntrack 출력에서 확인할 수 있습니다.
더 깊은 내용은 Netfilter Flowtable 문서를 참고하세요.
성능과 용량 설계
NAT 성능 문제는 대개 “룰이 많아서 느리다”보다 다음 네 축에서 터집니다. 상태 수, 포트 수, 경로 대칭성, 예외 프로토콜입니다. 근거 없는 Mpps 숫자를 외우기보다, 어떤 자원이 먼저 고갈되는지 모델링하는 편이 훨씬 실전적입니다.
| 병목(Bottleneck) 후보 | 전형적 증상 | 첫 확인 항목 | 대응 방향 |
|---|---|---|---|
| conntrack 테이블 포화 | 새 연결만 실패, 기존 연결은 유지 | nf_conntrack_count, nf_conntrack_max |
버킷/최대치 조정, timeout 재설계, 비정상 흐름 차단 |
| 외부 포트 고갈 | 특정 공인 IP에서만 신규 세션 실패 | SNAT 주소 풀, 가입자당 포트 사용량 | 공인 IP 풀 확장, deterministic port block 설계, per-user quota |
| 비대칭 라우팅 | 요청은 나가는데 응답이 INVALID 또는 미도달 | ip route get, ECMP/정책 라우팅, firewall node 수 |
왕복이 같은 번역기를 지나게 설계 |
| helper/ALG | 일부 레거시 프로토콜만 비정상 | helper attach 여부, 제어 채널 암호화 여부 | helper 범위 축소, 애플리케이션 설계 변경 |
| PMTU/MSS 문제 | 큰 패킷만 멈춤, HTTPS 일부 응답만 끊김 | ICMP frag-needed, TCP MSS, 터널(Tunnel) 중첩 여부 | MSS clamp, ICMP 경로 보존, MTU 재설계 |
| 오프로드 미적중 | 평소보다 CPU 사용 급증, 특정 플로우만 느림 | [OFFLOAD]/[HW_OFFLOAD], fragment 여부 |
flowtable 대상 흐름 선별, 미지원 프로토콜 분리 |
운영 튜닝의 기본 원칙은 단순합니다. 세션 수를 예측하고, 세션당 메모리와 타이머를 계산하고, 주소/포트 풀을 그보다 넉넉하게 설계하면 됩니다. 반대로 “일단 nf_conntrack_max만 크게 올리자” 접근은 메모리 압박과 GC 지연(Latency)을 다른 형태로 되돌려 받을 가능성이 큽니다.
디버깅(Debugging) 절차
NAT 문제를 디버깅할 때는 항상 질문 순서를 고정하는 편이 좋습니다. 1) 룰이 맞는가, 2) 첫 패킷이 어떤 체인에서 어떤 verdict를 받는가, 3) conntrack 엔트리가 어떤 tuple로 확정됐는가, 4) 응답이 같은 번역기를 지나오는가. 이 순서를 지키면 문제 공간이 빠르게 줄어듭니다.
# 1. 현재 룰셋과 체인 우선순위 확인
nft list ruleset
# 2. 실제 첫 패킷 추적
nft monitor trace
# 3. conntrack 상태/이벤트 확인
conntrack -L -o extended
conntrack -E
# 4. 경로 확인
ip route get 198.51.100.10 from 192.168.10.20 iif lan0
# 5. 양쪽 인터페이스에서 동시에 관찰
tcpdump -ni lan0 host 192.168.10.30
tcpdump -ni wan0 host 203.0.113.10
| 증상 | 유력 원인 | 가장 먼저 볼 것 |
|---|---|---|
| 외부로는 나가지만 응답이 안 돌아옴 | SNAT 누락, 역방향 경로 비대칭, upstream reverse path 문제 | POSTROUTING hit 여부, reply tuple, 상류 라우팅 |
| 포트 포워딩은 외부에서 되는데 내부에서만 안 됨 | hairpin용 SNAT/masquerade 누락 | LAN→공개주소 트래픽의 DNAT 이후 반환 경로 |
| 유휴 후 UDP 세션이 끊김 | timeout이 애플리케이션 keepalive보다 짧음 | nf_conntrack_udp_timeout, keepalive 주기 |
| FTP/SIP 같은 일부 프로토콜만 실패 | helper 미부착 또는 암호화로 인한 ALG 불가 | ct helper, RELATED 허용 규칙 |
| 규칙을 고쳐도 문제 재현이 그대로 | 기존 conntrack 엔트리가 계속 살아 있음 | 관련 엔트리 삭제 후 재시험 |
| flow offload가 기대만큼 안 걸림 | 첫 응답 패킷 미관측, fragment, helper, 미지원 경로 | conntrack 태그, nft flowtable 대상 조건 |
컨테이너와 네임스페이스(Namespace)에서의 NAT
컨테이너 환경의 NAT는 대개 “컨테이너 안”이 아니라 호스트 네임스페이스의 브리지(Bridge) 출구에서 일어납니다. Docker 기본 브리지 모델을 떠올리면 이해가 쉽습니다. 컨테이너는 veth로 브리지에 붙고, 호스트의 POSTROUTING MASQUERADE가 외부 통신을 담당합니다.
table ip nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 172.17.0.0/16 oifname "eth0" masquerade
}
}
네트워크 네임스페이스를 쓰면 NAT 규칙과 conntrack 상태도 네임스페이스 경계와 함께 관리됩니다. 다만 실제로 어느 네임스페이스의 NAT가 적용되는지는 “패킷이 어느 네임스페이스의 훅을 지나는가”로 결정되므로, 브리지·veth·hostNetwork·CNI 정책을 같이 봐야 합니다. Kubernetes도 CNI에 따라 iptables/nftables NAT, eBPF 기반 SNAT, 또는 NAT 최소화 모델까지 다양합니다.
NAT와 터널의 상호작용
NAT가 터널(GRE, VXLAN, IPsec, WireGuard 등)과 만나면 어떤 헤더를 기준으로 NAT가 동작하는가가 핵심 문제가 됩니다. 터널 캡슐화(Encapsulation) 전에 NAT가 적용되는지, 캡슐화 후 외부 헤더에 NAT가 적용되는지에 따라 완전히 다른 경로가 만들어집니다.
| 터널 유형 | NAT 통과 가능 여부 | conntrack 처리 | 주요 주의사항 |
|---|---|---|---|
| GRE | 제한적 (L4 포트 없음) | nf_conntrack_proto_gre가 key/call-id로 추적 |
동일 NAT 뒤에 여러 GRE 터널이 있으면 call-id 충돌 위험. PPTP helper 필요 시 보안 함의 고려 |
| VXLAN / GENEVE | 양호 (외부 UDP 기반) | 외부 UDP 헤더로 일반 conntrack | 내부 패킷은 별도 bridge/namespace에서 처리. 외부 NAT가 source port를 바꾸면 ECMP 분산에 영향 |
| IPsec ESP | 직접 불가 → NAT-T 필요 | ESP는 L4 포트 없음, NAT-T는 UDP 4500으로 캡슐화 | IKE 협상에서 NAT 감지 시 자동으로 NAT-T 전환. IPsec & xfrm 문서 참고 |
| WireGuard | 양호 (UDP 기반) | 외부 UDP 헤더로 일반 conntrack | PersistentKeepalive로 NAT 매핑 유지. WireGuard 문서 참고 |
| L2TP | 양호 (UDP 1701 또는 IP 프로토콜 115) | UDP 모드는 일반 conntrack, IP 프로토콜 모드는 제한 | IPsec과 함께 쓸 때는 IPsec의 NAT-T 제약이 우선. L2TP 문서 참고 |
특히 GRE는 L4 포트가 없기 때문에 NAT에 본질적으로 불리합니다. 커널의 nf_conntrack_proto_gre는 GRE key와 call-id를 사용해 흐름을 구분하지만, 하나의 NAT 뒤에 여러 GRE 터널이 존재하면 충돌이 발생할 수 있습니다.
/* net/netfilter/nf_conntrack_proto_gre.c — GRE conntrack 튜플 추출 */
static bool gre_pkt_to_tuple(
const struct sk_buff *skb,
unsigned int dataoff,
struct net *net,
struct nf_conntrack_tuple *tuple)
{
const struct pptp_gre_header *grehdr;
/* GRE key 또는 call ID를 L4 포트 대신 사용 */
tuple->src.u.gre.key = grehdr->call_id;
tuple->dst.u.gre.key = grehdr->call_id;
return true;
}
tcp-mss-clamp를 FORWARD 체인에 설정하지 않으면
"소량 데이터는 되는데 대량 전송만 멈추는" 전형적인 PMTUD 블랙홀 증상이 나타날 수 있습니다.
# 터널 환경에서 MSS 클램핑 예시
table inet mangle {
chain forward {
type filter hook forward priority mangle; policy accept;
oifname "gre1" tcp flags syn tcp option maxseg size set rt mtu
oifname "wg0" tcp flags syn tcp option maxseg size set rt mtu
}
}
eBPF 기반 NAT
전통적인 Netfilter NAT는 conntrack 상태와 nf_nat 경로에 의존하지만, 최근에는 eBPF를 사용하여 NAT를 구현하는 접근법이 등장했습니다. 대표적으로 Cilium은 conntrack과 NAT 테이블을 BPF 맵으로 구현하고, TC 또는 XDP 훅에서 패킷 헤더를 직접 수정합니다.
| 측면 | Netfilter NAT | eBPF 기반 NAT |
|---|---|---|
| 상태 저장 | nf_conntrack 해시 테이블 |
BPF 해시 맵 (BPF_MAP_TYPE_HASH, BPF_MAP_TYPE_LRU_HASH) |
| 훅 위치 | Netfilter 훅 (PREROUTING, POSTROUTING 등) | TC ingress/egress, XDP, cgroup socket |
| 헤더 수정 | nf_nat_manip_pkt() |
bpf_skb_store_bytes(), bpf_l3_csum_replace(), bpf_l4_csum_replace() |
| Helper/ALG | conntrack helper 프레임워크 | 일반적으로 미지원 (애플리케이션 계층에서 해결) |
| 성능 | flowtable fastpath 가능 | XDP에서는 드라이버 수준 처리로 높은 pps |
| 디버깅 | conntrack -L, nft monitor trace |
bpftool map dump, bpf_trace_printk() |
| 생태계 | 범용, 모든 배포판 기본 지원 | Cilium, Katran 등 특화 프로젝트 |
eBPF NAT의 핵심 아이디어는 BPF 맵을 conntrack 테이블 대용으로 사용하고, TC/XDP 프로그램에서 직접 L3/L4 헤더를 재기록하는 것입니다. 기본적인 SNAT를 BPF로 구현하면 다음과 같은 형태가 됩니다.
/* eBPF TC 프로그램에서의 SNAT 개념 예시 */
struct nat_entry {
__be32 orig_src;
__be16 orig_sport;
__be32 nat_src;
__be16 nat_sport;
};
struct {
__uint(type, BPF_MAP_TYPE_LRU_HASH);
__uint(max_entries, 65536);
__type(key, struct flow_key);
__type(value, struct nat_entry);
} nat_table SEC(".maps");
SEC("tc")
int snat_egress(struct __sk_buff *skb)
{
struct nat_entry *entry;
struct flow_key key = {};
/* 5-tuple 추출하여 key 구성 */
extract_flow_key(skb, &key);
entry = bpf_map_lookup_elem(&nat_table, &key);
if (!entry) {
/* 새 흐름: NAT 매핑 생성 */
struct nat_entry new_entry = {};
allocate_nat_binding(&new_entry, &key);
bpf_map_update_elem(&nat_table, &key, &new_entry, BPF_NOEXIST);
entry = &new_entry;
}
/* IP 헤더의 source address 변경 + 체크섬 보정 */
bpf_l3_csum_replace(skb, IP_CSUM_OFF, entry->orig_src, entry->nat_src, 4);
bpf_skb_store_bytes(skb, IP_SRC_OFF, &entry->nat_src, 4, 0);
/* L4 source port 변경 + 체크섬 보정 */
bpf_l4_csum_replace(skb, L4_CSUM_OFF, entry->orig_sport, entry->nat_sport, 2);
bpf_skb_store_bytes(skb, L4_SPORT_OFF, &entry->nat_sport, 2, 0);
return TC_ACT_OK;
}
nft monitor trace 수준의 내장 디버깅, 범용 배포판 호환성을 포기하는 대가가 있습니다.
범용 게이트웨이에서는 Netfilter NAT가 여전히 합리적이고, 대규모 컨테이너 환경에서 Service 라우팅 성능이 핵심이면 eBPF가 강점을 발휘합니다.
NAT 바인딩 생명 주기
NAT 바인딩은 생성부터 소멸까지 명확한 생명 주기를 가집니다. 이 주기를 이해하면 "규칙을 고쳤는데 왜 안 바뀌지", "타임아웃 전에 왜 끊기지", "왜 새 연결이 실패하지" 같은 실무 질문에 체계적으로 답할 수 있습니다.
특히 confirm 전에 드롭되는 패킷은 conntrack 엔트리와 NAT 바인딩이 모두 사라집니다. 이것은 실제로 방화벽 정책이 FORWARD에서 패킷을 거부했을 때 의도적으로 발생하는 것이며, 바인딩이 누적되는 것을 막아 줍니다.
/* net/netfilter/nf_conntrack_core.c — confirm 전후의 차이 */
/* confirm 전: unconfirmed list에만 존재, 해시 미등록 */
static int __nf_conntrack_confirm(struct sk_buff *skb)
{
struct nf_conn *ct = nf_ct_get(skb, &ctinfo);
/* reply 방향 충돌이 있으면 confirm 실패 */
if (nf_ct_is_dying(ct) || __nf_conntrack_confirm_conflict(ct))
return NF_DROP;
/* 해시 테이블에 양 방향 삽입 */
__nf_conntrack_hash_insert(ct, hash, reply_hash);
ct->status |= IPS_CONFIRMED;
return NF_ACCEPT;
}
nf_conntrack_max에 도달하면 커널은 가장 덜 활발한 UNREPLIED 연결부터 제거하여 공간을 확보합니다.
이 early drop 메커니즘 때문에 max 값을 무작정 올리기보다 불필요한 연결의 타임아웃을 줄이는 것이 더 효과적인 경우가 많습니다.
NAT 로깅과 감사
법적 요구사항이 있는 환경(통신사업자, 공공기관)에서는 NAT 바인딩을 기록해야 할 수 있습니다. "누가 언제 어떤 외부 주소/포트를 사용했는가"를 사후에 추적하려면 최소한 타임스탬프, 내부 주소/포트, 외부 주소/포트, 프로토콜을 기록해야 합니다.
# nftables 로그 기반 NAT 감사
table inet nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "wan0" ip saddr 10.0.0.0/8 log prefix "NAT-SNAT " group 1 \
snat to 203.0.113.10-203.0.113.13
}
}
# conntrack 이벤트 기반 로깅 (더 정확함)
conntrack -E -e NEW -o timestamp,id
# nfnetlink_log 그룹 수신
ulogd -c /etc/ulogd.conf
| 로깅 방식 | 장점 | 단점 | 적합한 환경 |
|---|---|---|---|
nftables log |
규칙 위치에서 즉시 기록, 유연한 prefix | 매 패킷 로그 시 성능 영향, NAT 후 주소는 별도 캡처 필요 | 소규모, 특정 흐름만 감사 |
| conntrack 이벤트 | NEW/DESTROY 이벤트만 기록, 바인딩 생성/소멸 시점 정확 | ulogd/별도 데몬 필요, 이벤트 유실 가능 | 중규모, CGN 감사 |
| 결정적 포트 블록 | 로그 없이도 역추적(Backtrace) 가능 (알고리즘으로 계산) | 포트 효율 저하, 설계 복잡 | 대규모 CGN, 로그 저장 비용 절감 |
IPv6 환경에서의 NAT 필요성 재평가
IPv6의 설계 철학은 "NAT 없이 end-to-end 연결성을 회복"하는 것입니다. 하지만 실무에서는 IPv6에서도 NAT가 사용되는 경우가 있으며, 각각의 동기와 trade-off를 정확히 이해하는 것이 중요합니다.
| IPv6 NAT 유형 | 동기 | 대안 | 권고 |
|---|---|---|---|
| NPTv6 (RFC 6296) | 멀티홈 시 프리픽스 독립성, ISP 변경 시 내부 주소 유지 | PI (Provider Independent) 주소, ULA + PA 듀얼 | 멀티홈이 꼭 필요하고 PI 주소를 받기 어려운 경우에 한정 |
| Stateful NAT66 | "IPv4처럼 숨기고 싶다" | stateful 방화벽 (ip6tables/nftables filter) | NAT가 보안을 제공한다는 것은 오해. 방화벽으로 충분 |
| NAT64 (RFC 6146) | IPv6-only 네트워크에서 IPv4 서비스 접근 | 듀얼스택, 464XLAT, MAP-E/MAP-T | IPv6 전환기에 실용적. DNS64와 함께 사용 |
| SIIT/MAP-E/MAP-T | IPv4-as-a-Service over IPv6 인프라 | 듀얼스택 | 통신사업자의 IPv4 서비스 유지에 사용 |
CGNAT: 포트 블록 할당과 RFC 6888
앞서 CGNAT의 운영 포인트를 개괄했지만, 실제 대규모 CGN을 설계할 때는 포트 블록 할당(Port Block Allocation, PBA) 전략이 성능, 로그 비용, 공정성 세 축을 동시에 결정합니다. 단순히 "포트를 랜덤으로 뿌리자"가 아니라, 가입자별로 미리 할당된 포트 범위 안에서만 NAT 바인딩을 만드는 방식입니다.
RFC 6888은 CGN에 대해 다음과 같은 핵심 요구사항을 명시합니다. 이들은 단순한 권고가 아니라, 상호 운용성과 법적 추적 가능성을 위한 실질적 기준선입니다.
| RFC 6888 요구사항 | 내용 | Linux CGN 설계 시 의미 |
|---|---|---|
| REQ-1 | Endpoint-Independent Mapping (EIM) 지원 필수 | persistent 플래그로 같은 내부 endpoint에 같은 외부 매핑 유지. nftables snat persistent 사용 |
| REQ-2 | 가입자별 외부 포트 수 제한 필수 | PBA로 블록 단위 제한 또는 connlimit으로 연결 수 상한. 하나의 봇넷 가입자가 전체 풀을 고갈시키는 것 방지 |
| REQ-3 | NAT 상태 테이블 메모리 보호 필수 | nf_conntrack_max + 가입자별 conntrack limit. early drop 메커니즘이 최후 방어선 |
| REQ-5 | EIM 권장, Endpoint-Independent Filtering (EIF) 지원 시 기본은 끄기 | EIF는 방화벽 정책으로 분리. NAT 규칙에서 EIF를 기본 활성화하면 보안 약화 위험 |
| REQ-7 | Hairpin 지원 필수 | CGN 뒤의 가입자 간 통신에서도 hairpin 경로가 동작해야 함 |
| REQ-9 | 로깅: 목적지까지 기록하는 것은 피해야 함 | Deterministic PBA로 per-session 로그 제거. 블록 할당/해제만 기록하면 역추적 가능 |
| REQ-10 | 가입자가 매핑을 제어할 프로토콜(PCP) 지원 권장 | Port Control Protocol (RFC 6887)로 가입자가 명시적 포트 포워딩 요청 가능 |
Deterministic PBA의 핵심은 로깅 없이도 역추적이 가능하다는 점입니다. 외부 IP와 포트만 알면 어느 가입자에게 할당된 블록인지 산술적으로 계산할 수 있습니다. 이는 대규모 환경에서 per-session 로그의 저장 비용과 개인정보 문제를 동시에 해결합니다.
# Deterministic PBA 역추적 알고리즘 예시
def reverse_lookup(ext_ip, ext_port, block_size=256, port_start=1024):
"""외부 IP:포트 → 가입자 ID 역산"""
block_index = (ext_port - port_start) // block_size
subscriber_id = ip_to_pool_index(ext_ip) * blocks_per_ip + block_index
return subscriber_id
# 예: 203.0.113.1:1300 → 블록 1 → 가입자 B
# (1300 - 1024) // 256 = 1 → 블록 #1 → 가입자 B
# Linux에서 CGNAT용 nftables 설정 예시
table ip cgnat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
# 가입자별 포트 범위를 nftables map으로 관리
ip saddr 100.64.0.0/10 oifname "wan0" \
snat to 203.0.113.1-203.0.113.4 persistent,fully-random
# 가입자별 동시 연결 제한 (REQ-2)
ip saddr 100.64.0.0/10 ct count over 1024 drop
}
}
# conntrack 테이블 CGNAT 규모 조정
sysctl -w net.netfilter.nf_conntrack_max=2097152
sysctl -w net.netfilter.nf_conntrack_buckets=524288
# UDP 타이머: RFC 4787 요구 최소 2분
sysctl -w net.netfilter.nf_conntrack_udp_timeout=120
sysctl -w net.netfilter.nf_conntrack_udp_timeout_stream=180
# TCP ESTABLISHED 타이머 축소 (5일은 CGN에 과도)
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=7200
persistent 플래그는 EIM을 근사적으로 구현하지만, 완전한 EIM은 추가적인 매핑 정책 설계가 필요합니다.
NAT64/NPTv6 구현 상세: TAYGA, Jool, 464XLAT
앞서 NAT64와 NPTv6의 개념적 차이를 설명했지만, 실제 Linux에서 이를 구현하는 도구는 크게 세 가지입니다. TAYGA(사용자 공간(User Space) stateless NAT64), Jool(커널 모듈(Kernel Module) stateful NAT64), 그리고 464XLAT(CLAT + PLAT 조합)입니다. 각각의 아키텍처와 성능 특성이 다르므로 환경에 맞게 선택해야 합니다.
| 구현 | 유형 | 실행 공간 | 상태 | 특징 | 주요 제한 |
|---|---|---|---|---|---|
| TAYGA | Stateless NAT64/SIIT | 사용자 공간 (TUN 디바이스) | Stateless | 설정이 단순, 1:1 매핑에 적합 | 포트 공유 불가, TUN 경유로 성능 제한 |
| Jool | Stateful NAT64 / SIIT | 커널 모듈 | Stateful (BIB + session) | RFC 6146 준수, 높은 성능, EAMT 지원 | 외부 커널 모듈, 커널 버전 호환성 관리 필요 |
| 464XLAT CLAT | Stateless NAT46 | 클라이언트 디바이스 | Stateless | IPv4-only 앱의 IPv6 네트워크 사용 가능 | CLAT 구현 필요 (clatd, Android native) |
| nftables prefix NAT | NPTv6 | 커널 (Netfilter) | Stateless | 메인라인 지원, checksum-neutral | IPv6→IPv6만, 포트 변환 없음 |
# Jool 커널 모듈 기반 Stateful NAT64 설정 예시
modprobe jool
jool instance add "nat64inst" --iptables --pool6 64:ff9b::/96
# IPv4 풀 설정
jool pool4 add --tcp 203.0.113.10 1024-65535
jool pool4 add --udp 203.0.113.10 1024-65535
jool pool4 add --icmp 203.0.113.10 0-65535
# BIB/Session 테이블 확인
jool bib display --tcp
jool session display --tcp
# DNS64: BIND 9 설정 예시
# named.conf에 추가:
# dns64 64:ff9b::/96 {
# clients { any; };
# mapped { !rfc1918; any; };
# suffix ::;
# };
# TAYGA stateless NAT64 설정
# /etc/tayga.conf
# tun-device nat64
# ipv4-addr 192.168.255.1
# ipv6-addr 2001:db8:1::1
# prefix 64:ff9b::/96
# dynamic-pool 192.168.255.0/24
tayga --mktun
ip link set nat64 up
ip route add 192.168.255.0/24 dev nat64
ip route add 64:ff9b::/96 dev nat64
# NPTv6: nftables로 프리픽스 변환
table ip6 nptv6 {
chain prerouting {
type filter hook prerouting priority raw; policy accept;
ip6 daddr 2001:db8:200::/48 \
ip6 daddr set fd00:10::/48 notrack
}
chain postrouting {
type filter hook postrouting priority srcnat; policy accept;
ip6 saddr fd00:10::/48 \
ip6 saddr set 2001:db8:200::/48 notrack
}
}
64:ff9b::198.51.100.40)로 변환해야 클라이언트가 NAT64 프리픽스를 사용해 접속을 시작할 수 있습니다.
DNS64 없는 NAT64는 수동으로 합성 주소를 사용하는 매우 제한된 경우에만 동작합니다.
conntrack과의 깊은 연동: nf_nat_hook과 튜플 변환
NAT와 conntrack의 관계는 "conntrack이 상태를 저장하고 NAT가 헤더를 고친다" 수준보다 훨씬 깊습니다. 커널 내부에서 NAT는 conntrack의 튜플 변환 메커니즘을 직접 조작하며, 이 과정을 이해해야 zone 기반 NAT, 다중 NAT 체인, 복잡한 DNAT+SNAT 조합의 동작을 정확하게 예측할 수 있습니다.
/* net/netfilter/nf_nat_core.c — nf_nat_manip_pkt() 개념 축약 */
static unsigned int
nf_nat_manip_pkt(struct sk_buff *skb,
struct nf_conn *ct,
enum nf_nat_manip_type mtype,
enum ip_conntrack_dir dir)
{
struct nf_conntrack_tuple target;
/* ORIGINAL 방향이면 NAT된 값 적용, REPLY면 역변환 */
if (dir == IP_CT_DIR_ORIGINAL)
target = ct->tuplehash[IP_CT_DIR_REPLY].tuple;
else
target = ct->tuplehash[IP_CT_DIR_ORIGINAL].tuple;
/* L3 헤더 수정: IP 주소 변경 + IP 체크섬 */
l3proto->manip_pkt(skb, &target, mtype);
/* L4 헤더 수정: 포트 변경 + TCP/UDP 체크섬 */
l4proto->manip_pkt(skb, ct, mtype, &target);
return NF_ACCEPT;
}
# conntrack zone 설정: 멀티테넌트 NAT 환경
table inet zone_nat {
chain prerouting {
type filter hook prerouting priority raw; policy accept;
iifname "tenant-a" ct zone set 1
iifname "tenant-b" ct zone set 2
}
chain nat_prerouting {
type nat hook prerouting priority dstnat; policy accept;
# 같은 외부 포트를 서로 다른 내부 서버로 DNAT
ct zone 1 tcp dport 80 dnat to 10.1.0.10:80
ct zone 2 tcp dport 80 dnat to 10.2.0.10:80
}
}
# zone별 conntrack 확인
conntrack -L -z 1
conntrack -L -z 2
ct zone set으로 raw 우선순위에서 zone을 지정할 수 있습니다.
Masquerade 내부 구현: 인터페이스 변경 감지
MASQUERADE는 단순히 "SNAT인데 IP를 자동으로 찾아주는 것"이 아닙니다. 커널의 nf_nat_masquerade.c는 인터페이스 다운과 주소 제거 이벤트를 감시하는 notifier를 등록하고, 해당 인터페이스에 묶인 conntrack 엔트리를 자동 정리합니다. 이 메커니즘이 MASQUERADE를 DHCP/PPPoE 환경에서 안전하게 만드는 핵심입니다.
/* net/netfilter/nf_nat_masquerade.c — 핵심 구조 축약 */
/* 1. 인터페이스 다운 이벤트 감시 */
static int masq_device_event(
struct notifier_block *this,
unsigned long event,
void *ptr)
{
struct net_device *dev = netdev_notifier_info_to_dev(ptr);
if (event == NETDEV_DOWN) {
/* 이 인터페이스와 연결된 NAT 매핑 정리 시작 */
nf_ct_iterate_cleanup_net(net, device_cmp,
(void *)(long)dev->ifindex, 0, 0);
}
return NOTIFY_DONE;
}
/* 2. 주소 변경 이벤트 감시 */
static int masq_inet_event(
struct notifier_block *this,
unsigned long event,
void *ptr)
{
struct in_ifaddr *ifa = ptr;
struct net_device *dev = ifa->ifa_dev->dev;
/* 주소가 제거되면 해당 주소로 NAT된 연결 정리 */
nf_ct_iterate_cleanup_net(net, masq_device_cmp,
(void *)(long)dev->ifindex, 0, 0);
return NOTIFY_DONE;
}
/* 3. conntrack 엔트리 비교: 해당 인터페이스의 NAT인지 확인 */
static int device_cmp(
struct nf_conn *ct,
void *ifindex)
{
const struct nf_conn_nat *nat = nfct_nat(ct);
if (!nat)
return 0;
/* masquerade가 이 인터페이스를 통해 적용됐는지 확인 */
return nat->masq_index == (int)(long)ifindex;
}
| 이벤트 | MASQUERADE 동작 | SNAT 동작 | 실무 영향 |
|---|---|---|---|
| 인터페이스 다운 (NETDEV_DOWN) | 해당 인터페이스의 모든 NAT conntrack 정리 | 변화 없음 (conntrack 유지) | PPPoE 재연결 시 MASQUERADE는 자동으로 stale 매핑 제거 |
| 주소 제거 (NETDEV_CHANGEADDR) | 해당 주소에 묶인 NAT conntrack 정리 | 변화 없음 (규칙의 고정 IP가 바뀌지 않으면) | DHCP 갱신 시 MASQUERADE가 주소 변경을 자동 반영 |
| 새 패킷 도착 | 출구 인터페이스의 현재 주소를 실시간(Real-time)으로 조회 | 규칙에 명시된 고정 IP를 사용 | MASQUERADE는 매 새 연결마다 현재 주소를 반영 |
NAT 성능 최적화: conntrack 튜닝과 GRO/GSO
앞서 성능 설계의 개괄을 다뤘지만, 여기서는 구체적인 튜닝 파라미터와 GRO/GSO가 NAT 경로에 미치는 영향을 상세히 살펴봅니다. NAT 성능은 결국 conntrack 해시 효율, 메모리 예산, NIC 오프로드와의 상호작용 세 가지로 분해됩니다.
| 파라미터 | 기본값 | 권장 튜닝 방향 | 계산 근거 |
|---|---|---|---|
nf_conntrack_buckets |
메모리에 비례 (보통 65536) | 예상 최대 연결 수의 1/4 ~ 1/2 | 평균 체인 길이 2~4가 목표. 체인이 길면 lookup O(n) 증가 |
nf_conntrack_max |
= nf_conntrack_buckets (커널 버전에 따라 다름) | 예상 동시 세션 수의 1.5~2배 | 엔트리당 ~300바이트. 1M 엔트리 ≈ 300MB 메모리 |
nf_conntrack_tcp_timeout_established |
432000초 (5일) | CGN: 7200초 (2시간), 서버: 3600초 | 유휴 TCP가 테이블을 장기 점유하는 것을 방지 |
nf_conntrack_tcp_timeout_time_wait |
120초 | 60초로 축소 가능 | TIME_WAIT는 이미 종료된 연결이므로 빠른 회수가 유리 |
hashsize (모듈 파라미터) |
시스템 메모리에 비례 | 부팅 시 options nf_conntrack hashsize=262144 |
런타임에 변경 불가(일부 커널), 부팅 시 설정 권장 |
# conntrack 해시 테이블 상태 확인
sysctl net.netfilter.nf_conntrack_count
sysctl net.netfilter.nf_conntrack_max
sysctl net.netfilter.nf_conntrack_buckets
# 해시 체인 분포 확인 (커널 디버그 정보)
cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/net/nf_conntrack | wc -l
# 메모리 사용량 추정
# entries * ~300 bytes = 메모리
# 1,000,000 entries ≈ 300MB
# 2,000,000 entries ≈ 600MB
# 권장 CGN 튜닝 셋
sysctl -w net.netfilter.nf_conntrack_max=2097152
sysctl -w net.netfilter.nf_conntrack_buckets=524288
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=7200
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=60
sysctl -w net.netfilter.nf_conntrack_udp_timeout=120
sysctl -w net.netfilter.nf_conntrack_udp_timeout_stream=180
# GC 관련 (커널 5.x+에서는 자동 관리가 대부분)
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_close_wait=60
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_fin_wait=60
GRO/GSO와 NAT의 상호작용은 자주 간과되지만 성능에 큰 영향을 미칩니다. GRO(Generic Receive Offload)는 여러 패킷을 하나의 큰 skb로 합쳐서 상위 스택에 올리고, GSO(Generic Segmentation Offload)는 반대로 전송 시 큰 skb를 분할합니다. 문제는 NAT가 이 합쳐진/분할된 패킷의 어느 시점에서 체크섬(Checksum)을 보정하는가입니다.
| 오프로드 | NAT 경로에서의 동작 | 성능 영향 | 주의사항 |
|---|---|---|---|
| GRO (수신) | 합쳐진 skb에 대해 conntrack lookup 1회 | lookup 횟수 감소로 성능 향상 | 서로 다른 conntrack 엔트리의 패킷이 합쳐지면 문제 발생 가능 |
| GSO (송신) | NAT 헤더 수정 후 GSO로 분할 | 큰 패킷 단위로 NAT 처리하므로 per-packet 비용 감소 | flowtable 경로에서도 GSO 유지 가능 |
| TSO (HW 송신) | NIC가 세그먼트 분할 | CPU 부하 최소화 | HW offload NAT와 결합 시 최대 효율 |
| LRO (HW 수신) | NIC가 합침, 포워딩 환경에서는 GRO 권장 | 포워딩 시 LRO는 수신측 IP가 바뀌므로 비활성화 필요 | ethtool -K eth0 lro off |
Hairpin NAT: 내부 클라이언트에서 공인 IP로의 접근
앞서 hairpin NAT의 개념을 설명했지만, 실제 패킷 경로와 필요한 규칙을 구체적으로 추적하면 이해가 훨씬 깊어집니다. Hairpin NAT의 핵심 문제는 응답의 비대칭 경로입니다. DNAT만 적용하면 서버는 클라이언트에게 직접 응답하고, 클라이언트는 "내가 접속한 공인 IP가 아닌 내부 IP에서 답이 왔다"고 인식해 세션을 거부합니다.
# Hairpin NAT 완전한 nftables 설정
table inet nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
# 외부 + 내부 모두에서 공인 IP로 접근 시 DNAT
tcp dport 443 ip daddr 203.0.113.10 dnat to 192.168.10.30:8443
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
# 외부 나가는 트래픽: 일반 masquerade
oifname "wan0" masquerade
# Hairpin: 내부→내부 트래픽에서 소스를 게이트웨이로 변경
# (서버가 게이트웨이로 응답을 보내도록 강제)
ip saddr 192.168.10.0/24 ip daddr 192.168.10.30 tcp dport 8443 \
snat to 192.168.10.1
}
}
nftables NAT 구문: map, vmap, 체이닝
nftables의 NAT는 단순한 snat to/dnat to를 넘어 map과 vmap을 활용해 동적이고 효율적인 NAT 규칙을 구성할 수 있습니다. 이는 수십~수백 개의 포트 포워딩 규칙이 필요한 환경에서 규칙 수를 극적으로 줄이고 lookup 성능을 개선합니다.
# map 기반 다중 포트 포워딩 (DNAT)
table inet nat {
map portfwd {
type inet_service : ipv4_addr . inet_service
elements = {
8080 : 10.0.1.10 . 80,
8443 : 10.0.2.20 . 443,
2222 : 10.0.3.30 . 22,
3306 : 10.0.4.40 . 3306,
5432 : 10.0.5.50 . 5432
}
}
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "wan0" dnat to tcp dport map @portfwd
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "wan0" masquerade
}
}
# 런타임에 포트 포워딩 추가 (재로드 불필요)
nft add element inet nat portfwd { 9090 : 10.0.6.60 . 80 }
nft delete element inet nat portfwd { 3306 : 10.0.4.40 . 3306 }
# vmap 기반 테넌트별 NAT 체이닝
table inet multitenant {
chain tenant_a_nat {
snat to 203.0.113.10
}
chain tenant_b_nat {
snat to 203.0.113.20
}
map tenant_dispatch {
type ipv4_addr : verdict
flags interval
elements = {
10.0.1.0/24 : jump tenant_a_nat,
10.0.2.0/24 : jump tenant_b_nat
}
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "wan0" ip saddr vmap @tenant_dispatch
}
}
# concatenated map: 소스 주소+포트로 복합 매핑
table inet complex_nat {
map svc_map {
type ipv4_addr . inet_service : ipv4_addr . inet_service
elements = {
192.168.1.0/24 . 80 : 10.0.1.10 . 8080,
192.168.2.0/24 . 80 : 10.0.2.20 . 8080
}
}
}
dnat 규칙 100개를 나열하는 것과 map 1개를 사용하는 것은 성능이 크게 다릅니다.
map은 내부적으로 해시 테이블을 사용하므로 O(1) lookup이 가능하지만, 규칙 나열은 O(n) 순차 검색입니다.
또한 map의 원소는 룰셋 전체를 교체하지 않고도 동적으로 추가/삭제할 수 있어 운영상 유리합니다.
디버깅 & 트레이싱: ftrace와 nf_nat 추적
앞서 기본 디버깅 절차를 다뤘지만, 복잡한 NAT 문제에서는 nft monitor trace와 conntrack -E 이상의 도구가 필요합니다. 커널의 ftrace 인프라를 활용하면 nf_nat_manip_pkt(), nf_nat_setup_info() 같은 내부 함수의 호출을 직접 추적할 수 있습니다.
# === 1. nft monitor trace: 패킷별 체인 통과 추적 ===
# 추적 대상 패킷 마킹
table inet trace_debug {
chain prerouting {
type filter hook prerouting priority raw; policy accept;
ip saddr 192.168.10.20 tcp dport 443 meta nftrace set 1
}
}
# 실시간 모니터링
nft monitor trace
# 출력 예시:
# trace id abc123 inet trace_debug prerouting packet:
# iif "lan0" ether saddr ... ip saddr 192.168.10.20
# ip daddr 203.0.113.10 tcp dport 443
# trace id abc123 inet nat prerouting rule
# dnat to 192.168.10.30:8443 (verdict accept)
# === 2. conntrack 이벤트 모니터링 ===
# 새 연결과 소멸 이벤트만 추적
conntrack -E -e NEW,DESTROY -p tcp --orig-dst 203.0.113.10
# 타임스탬프와 ID 포함
conntrack -E -o timestamp,id
# 특정 NAT 바인딩 확인
conntrack -L -p tcp --reply-src 203.0.113.10 -o extended
# === 3. ftrace를 이용한 커널 함수 추적 ===
# nf_nat 관련 함수 추적 설정
cd /sys/kernel/debug/tracing
echo 0 > tracing_on
echo function > current_tracer
echo 'nf_nat_setup_info nf_nat_packet nf_nat_manip_pkt' > set_ftrace_filter
echo 1 > tracing_on
# 추적 결과 확인
cat trace_pipe | head -100
# 특정 프로세스/CPU만 추적
echo 0 > tracing_on
echo function_graph > current_tracer
echo 'nf_nat_*' > set_ftrace_filter
echo 1 > tracing_on
# === 4. perf를 이용한 NAT 경로 프로파일링 ===
# NAT 관련 함수의 CPU 소비 비율
perf top -g --filter 'nf_nat'
perf record -g -p $(pgrep ksoftirqd) -- sleep 10
perf report --sort comm,dso,symbol
# === 5. dropwatch로 패킷 드롭 위치 추적 ===
dropwatch -l kas
# 또는 perf로 kfree_skb 트레이스포인트
perf record -e skb:kfree_skb -a -- sleep 5
perf script
| 도구 | 용도 | 장점 | 오버헤드 |
|---|---|---|---|
nft monitor trace |
패킷별 체인/규칙 통과 추적 | nftables 네이티브, 쉬운 사용 | 마킹된 패킷에 대해 중간, 프로덕션 주의 |
conntrack -E |
conntrack 이벤트 (NEW/UPDATE/DESTROY) | 바인딩 생성/소멸 시점 정확 | 낮음 (이벤트 기반) |
ftrace function |
커널 함수 호출 추적(Call Trace) | nf_nat 내부 동작 가시화 | 높음, 프로덕션 비권장 |
ftrace function_graph |
함수 호출 그래프와 소요 시간 | 성능 병목 함수 식별 | 매우 높음, 디버그 환경 전용 |
perf |
CPU 프로파일링(Profiling), 드롭 포인트 | 시스템 전체 성능 분석 | 샘플링 기반으로 상대적 낮음 |
dropwatch |
패킷 드롭 위치 추적 | "어디서 버려지는가" 직접 확인 | 낮음 |
tcpdump (양쪽) |
NAT 전후 패킷 비교 | 가장 기본적이고 확실한 검증 | 캡처 양에 비례 |
nft monitor trace는 마킹된 패킷에 대해서만 동작하므로 범위를 좁히면 프로덕션에서도 사용 가능합니다.
반면 ftrace function_graph는 모든 NAT 패킷에 대해 오버헤드가 발생하므로 트래픽이 있는 환경에서는 성능 저하가 심합니다.
실무에서는 "tcpdump 양쪽 + conntrack -E + nft trace"의 3단 조합이 대부분의 문제를 해결합니다.
컨테이너/쿠버네티스 NAT: kube-proxy와 CNI
앞서 컨테이너 NAT의 기본을 다뤘지만, Kubernetes 환경에서의 NAT는 훨씬 복잡한 다층 구조를 가집니다. kube-proxy의 iptables 모드, IPVS 모드, 그리고 eBPF 기반 CNI(Cilium)가 각각 다른 방식으로 Service의 ClusterIP를 backend Pod IP로 변환합니다.
# kube-proxy iptables 모드: 실제 생성되는 규칙 확인
iptables -t nat -L KUBE-SERVICES -n --line-numbers
iptables -t nat -L KUBE-SVC-XXXXX -n # Service별 체인
iptables -t nat -L KUBE-SEP-YYYYY -n # Endpoint별 체인
# 확률 기반 DNAT 규칙 (3개 backend 예시)
# -A KUBE-SVC-XXXXX -m statistic --mode random --probability 0.33333
# -j KUBE-SEP-AAA
# -A KUBE-SVC-XXXXX -m statistic --mode random --probability 0.50000
# -j KUBE-SEP-BBB
# -A KUBE-SVC-XXXXX -j KUBE-SEP-CCC
# KUBE-SEP-AAA 내부:
# -A KUBE-SEP-AAA -p tcp -j DNAT --to-destination 10.244.2.10:8080
# IPVS 모드 확인
ipvsadm -Ln
# TCP 10.96.0.100:80 rr
# -> 10.244.2.10:8080 Masq 1 0 0
# -> 10.244.3.11:8080 Masq 1 0 0
# Cilium eBPF 모드: Service 매핑 확인
cilium service list
cilium bpf lb list
# conntrack과 kube-proxy의 상호작용 디버깅
conntrack -L -d 10.96.0.100
# 참고: IPVS 모드에서도 conntrack은 사용됨 (MASQUERADE 등)
| Kubernetes NAT 시나리오 | NAT 유형 | 어디서 발생 | 주의사항 |
|---|---|---|---|
| Pod → ClusterIP Service | DNAT (ClusterIP → Pod IP) | PREROUTING 또는 OUTPUT (로컬 Pod) | conntrack 기반 세션 어피니티가 필요하면 sessionAffinity: ClientIP 설정 |
| Pod → 외부 인터넷 | SNAT/MASQUERADE | 노드의 POSTROUTING | CNI에 따라 Pod IP가 직접 라우팅되면 SNAT 불필요 |
| 외부 → NodePort | DNAT (NodeIP:NodePort → Pod IP) | PREROUTING | externalTrafficPolicy: Cluster이면 추가 SNAT으로 클라이언트 IP 손실 |
| 외부 → LoadBalancer | LB → DNAT → Pod | 외부 LB + 노드 PREROUTING | 클라우드 LB는 보통 DSR 미지원, 이중 NAT 발생 가능 |
| Pod → 같은 Service (hairpin) | DNAT + 조건부 SNAT | source Pod = destination Pod일 때 | 자기 자신으로 DNAT되면 hairpin 문제 재현 |
NAT와 보안: 오해와 실제
NAT가 "보안을 제공한다"는 관념은 현장에서 가장 흔하고 위험한 오해 중 하나입니다. NAT는 주소 변환 도구이지 접근 제어(Access Control) 도구가 아닙니다. 이 섹션에서는 NAT의 보안 효과와 한계를 정확히 구분합니다.
| NAT가 실제로 하는 것 | NAT가 하지 않는 것 | 올바른 대안 |
|---|---|---|
| 내부 주소를 외부에서 직접 보지 못하게 함 | 포트 포워딩/DNAT을 설정하면 노출됨 | 방화벽 규칙으로 명시적 허용/거부 |
| 외부에서 먼저 연결을 시작하기 어렵게 함 (conntrack 의존) | 상태 테이블이 있으면 응답 패킷은 통과함 | ct state new 기반 필터로 방향 제어 |
| 내부 토폴로지(Topology)를 부분적으로 숨김 | 애플리케이션 레이어에서 내부 IP 노출 가능 (SIP, FTP 등) | ALG/helper 제한, 프록시 사용 |
| 포트 스캔 대상을 줄임 (외부 포트만 노출) | UPnP/PCP로 자동 포트 매핑이 활성화되면 무력화 | UPnP 비활성화, PCP 정책 적용 |
# NAT가 아닌 방화벽으로 보안을 확보하는 올바른 패턴
table inet firewall {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iifname "lo" accept
# 명시적으로 허용하는 서비스만
tcp dport { 22, 80, 443 } accept
icmpv6 type { echo-request, nd-neighbor-solicit, nd-router-advert } accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
# 내부→외부는 허용
iifname "lan0" oifname "wan0" accept
# 포트 포워딩 대상만 허용
iifname "wan0" oifname "lan0" ip daddr 192.168.10.30 tcp dport 8443 accept
}
}
# NAT는 주소 변환 역할만
table inet nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "wan0" masquerade
}
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "wan0" tcp dport 443 dnat to 192.168.10.30:8443
}
}
stateful firewall만으로 동일한 보안 수준을 달성할 수 있다는 사실이
"NAT가 보안을 제공한다"는 오해의 본질을 잘 드러냅니다.
보안은 접근 정책(누가 무엇에 접근할 수 있는가)이지, 주소 변환(어떤 주소를 어떤 주소로 바꾸는가)이 아닙니다.
NAT 장애 패턴과 해결 체크리스트
실무에서 반복되는 NAT 장애 패턴을 체계화하면 디버깅 시간을 크게 단축할 수 있습니다. 아래는 가장 흔한 10가지 패턴과 각각의 진단 순서입니다.
| # | 패턴 | 근본 원인 | 진단 명령 | 해결 |
|---|---|---|---|---|
| 1 | 새 연결만 실패, 기존은 정상 | conntrack 테이블 포화 | sysctl net.netfilter.nf_conntrack_count |
max 증가, timeout 축소, 불필요 트래픽 notrack |
| 2 | 특정 외부 IP로만 SNAT 실패 | 해당 IP의 포트 고갈 | conntrack -L --reply-src IP | wc -l |
IP 풀 확장, 가입자별 제한 |
| 3 | 규칙 변경 후 효과 없음 | 기존 conntrack 엔트리 유지 | conntrack -D -p tcp --dport 443 |
관련 conntrack 삭제 후 재시험 |
| 4 | 내부에서 공인 IP 접근 불가 | hairpin SNAT 누락 | tcpdump -ni lan0 양방향 |
내부→내부 SNAT 추가 또는 split-horizon DNS |
| 5 | UDP 세션이 유휴 후 끊김 | NAT 타이머 < 앱 keepalive | sysctl net.netfilter.nf_conntrack_udp_timeout |
타이머 증가 또는 앱 keepalive 단축 |
| 6 | FTP active 모드 실패 | helper 미부착 | conntrack -L | grep helper |
ct helper set "ftp" 규칙 추가 |
| 7 | 큰 파일만 전송 멈춤 | PMTUD 블랙홀 | tcpdump -ni wan0 icmp |
TCP MSS clamp, ICMP 경로 보존 |
| 8 | PPPoE 재연결 후 NAT 깨짐 | SNAT의 고정 IP가 더 이상 유효하지 않음 | ip addr show ppp0 |
MASQUERADE 사용으로 전환 |
| 9 | Kubernetes Service 접속 간헐 실패 | conntrack race, 동시 SYN 충돌 | conntrack -S (drop/insert_failed 카운터) |
IPVS 모드 전환 또는 nf_conntrack_tcp_be_liberal=1 |
| 10 | flowtable offload 적용 안 됨 | 첫 응답 미관측, fragment, helper | conntrack -L | grep OFFLOAD |
offload 대상 조건 확인, helper 분리 |
# 종합 진단 스크립트
#!/bin/bash
echo "=== conntrack 상태 ==="
sysctl net.netfilter.nf_conntrack_count
sysctl net.netfilter.nf_conntrack_max
echo "사용률: $(( $(sysctl -n net.netfilter.nf_conntrack_count) * 100 / \
$(sysctl -n net.netfilter.nf_conntrack_max) ))%"
echo ""
echo "=== conntrack 통계 (에러 카운터) ==="
conntrack -S
echo ""
echo "=== NAT 관련 conntrack 분포 ==="
echo "SRC_NAT: $(conntrack -L 2>/dev/null | grep -c SRC_NAT)"
echo "DST_NAT: $(conntrack -L 2>/dev/null | grep -c DST_NAT)"
echo "OFFLOAD: $(conntrack -L 2>/dev/null | grep -c OFFLOAD)"
echo ""
echo "=== 외부 IP별 포트 사용량 (상위 5개) ==="
conntrack -L 2>/dev/null | \
grep -oP 'src=\K[0-9.]+' | sort | uniq -c | sort -rn | head -5
echo ""
echo "=== 최근 드롭된 conntrack 이벤트 ==="
dmesg | grep -i 'nf_conntrack: table full' | tail -5
conntrack -S의 drop과 insert_failed 카운터가 0이 아니면 테이블 관련 문제를 강하게 의심해야 합니다.
참고자료
- Linux Kernel Documentation: Netfilter Conntrack Sysfs variables — conntrack/flowtable sysctl 기본값과 의미
- Linux Kernel Documentation: Netfilter's flowtable infrastructure — flowtable 소프트웨어/하드웨어 fastpath
- nftables manpage —
snat,dnat,masquerade,redirect, helper, flow offload 문법 - nftables wiki: Performing NAT — 첫 패킷 바인딩, stateful/stateless NAT 설명
- nftables wiki: Conntrack helpers — helper 할당과 보안상 주의점
- net/netfilter/nf_nat_core.c —
nf_nat_setup_info(),nf_nat_packet() - net/netfilter/nf_nat_masquerade.c — MASQUERADE와 netdevice/address notifier 처리
- net/netfilter/nf_nat_redirect.c — REDIRECT 훅 제약
- net/netfilter/nf_flow_table_core.c — flowtable NAT 플래그와 fastpath 코어
- RFC 3022 — Traditional NAT
- RFC 4787 — UDP NAT 동작 요구사항
- RFC 5382 — TCP NAT 동작 요구사항
- RFC 5508 — ICMP NAT 동작 요구사항
- RFC 6888 — CGN 요구사항
- RFC 6598 — CGN Shared Address Space 100.64.0.0/10
- RFC 6146 — Stateful NAT64
- RFC 6296 — IPv6-to-IPv6 Network Prefix Translation (NPTv6)
- man7: nft(8) — nftables 명령어 매뉴얼로 NAT 규칙 문법을 포함합니다
- man7: iptables-extensions(8) — SNAT, DNAT, MASQUERADE, REDIRECT 타겟 상세 설명입니다
- man7: conntrack(8) — conntrack 테이블 조회 및 관리 도구 매뉴얼입니다
- Linux Kernel Documentation: ip-sysctl — ip_forward, ip_local_port_range 등 NAT 관련 sysctl 설정을 설명합니다
- RFC 2663 — IP Network Address Translator Terminology and Considerations
- RFC 7857 — Updates to Network Address Translation (NAT) Behavioral Requirements
- RFC 7915 — IP/ICMP Translation Algorithm (SIIT, NAT64 변환 알고리즘)