Ubuntu에서 방화벽을 만질 일이 생기면 대개 ufw allow 22 한 줄로 끝난다. 그런데 포트 하나를 조건부로 열거나, ICMP 유입량을 제한하거나, 허용 포트 목록을 서비스 재시작 없이 갱신하려고 하면 ufw의 추상화로는 표현이 안 된다. 결국 그 아래에서 실제로 도는 nftables 문법을 알아야 하는데, nft list ruleset을 처음 열어보면 ufw와 도커가 만들어둔 규칙이 섞여 있어 어디부터 손대야 할지 감을 잡기 어렵다. 이 글에서는 Ubuntu 24.04(nftables 1.0.9)에서 테이블·체인·훅 구조를 파악하고, 원격 접속이 끊길 걱정 없이 격리된 환경에서 규칙셋을 처음부터 작성해 실제 트래픽으로 검증하는 과정을 정리한다.
지금 커널에 올라가 있는 규칙 확인
nft list tables로 현재 등록된 테이블부터 본다. 도커가 설치된 호스트라면 iptables-nft가 만든 테이블이 함께 보인다.
$ sudo nft list tables
table ip nat
table ip filter
table ip6 nat
table ip6 filter
$ sudo nft list ruleset | head -3
# Warning: table ip nat is managed by iptables-nft, do not touch!
# Warning: table ip filter is managed by iptables-nft, do not touch!
# Warning: table ip6 nat is managed by iptables-nft, do not touch!
경고 문구 그대로, ip filter/ip nat은 도커가 iptables 명령으로 관리하는 테이블이라 nft로 직접 편집하면 안 된다. 내가 쓸 규칙은 별도 테이블에 새로 만든다.
테이블·체인·훅 구조
nftables는 테이블(주소 패밀리별 네임스페이스) 안에 체인을 두고, 체인이 훅에 붙어야 패킷을 본다. 훅에 붙지 않은 체인은 jump 대상으로만 쓰이는 일반 체인이다.
| 요소 | 값 | 설명 |
|---|---|---|
family | ip / ip6 / inet / arp / bridge / netdev | inet은 IPv4·IPv6를 한 테이블에서 처리 |
hook | prerouting / input / forward / output / postrouting | 패킷 경로상 어디서 볼지 |
type | filter / nat / route | 체인 용도 |
priority | raw(-300) → mangle(-150) → dstnat(-100) → filter(0) → srcnat(100) | 같은 훅에 체인이 여러 개면 낮은 값이 먼저 |
policy | accept / drop | 어떤 규칙에도 안 걸렸을 때의 기본 판정 |
같은 훅에 여러 테이블의 체인이 붙어 있으면 모두 평가된다. 한 체인에서 accept해도 다른 테이블의 체인이 drop하면 패킷은 버려진다 — 이 글 뒤쪽에서 실제로 걸린다.
잠기지 않고 실험할 환경 만들기
policy drop인 체인을 원격 서버에 바로 적용하면 SSH가 그 자리에서 끊긴다. 네트워크 네임스페이스와 veth 쌍으로 격리된 랩을 만들면 호스트 접속과 무관하게 규칙을 실험할 수 있다.
#!/bin/bash
# 10.99.0.1(호스트) <-> 10.99.0.2(랩) 로 연결된 격리 환경
ip netns add fwlab
ip link add veth-host type veth peer name veth-lab
ip link set veth-lab netns fwlab
ip addr add 10.99.0.1/24 dev veth-host
ip link set veth-host up
ip netns exec fwlab ip addr add 10.99.0.2/24 dev veth-lab
ip netns exec fwlab ip link set veth-lab up
ip netns exec fwlab ip link set lo up$ sudo ip netns exec fwlab ip -br a
lo UNKNOWN 127.0.0.1/8 ::1/128
veth-lab@if5 UP 10.99.0.2/24 fe80::a815:dff:fefc:72c6/64
이제 ip netns exec fwlab nft ...로 적용한 규칙은 랩 안에서만 유효하고, 호스트 쪽에서 curl·ping으로 외부 트래픽 역할을 할 수 있다.
규칙셋 작성과 문법 검사
규칙은 명령 한 줄씩 쌓지 말고 파일로 관리한다. 맨 위의 flush ruleset 덕분에 파일 하나가 곧 전체 상태가 되고, nft -f는 파일 전체를 원자적으로 적용한다.
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
set allowed_tcp {
type inet_service
elements = { 22, 8080 }
}
chain input {
type filter hook input priority filter; policy drop;
iif "lo" accept
ct state established,related accept
ct state invalid counter drop
ip protocol icmp icmp type echo-request limit rate 5/second accept
tcp dport @allowed_tcp ct state new counter accept
counter log prefix "nft-drop: " level info drop
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}적용 전에 -c(check)로 문법만 검사한다. 오타 하나로 규칙셋이 반쯤 적용되는 사고를 막아준다.
$ sudo ip netns exec fwlab nft -c -f lab.nft && echo "문법 오류 없음"
문법 오류 없음
$ cat bad.nft
table inet filter {
chain input {
tcp dport 22 acept
}
}
$ sudo ip netns exec fwlab nft -c -f bad.nft; echo "exit=$?"
bad.nft:3:23-23: Error: syntax error, unexpected newline
tcp dport 22 acept
^
exit=1
실제 트래픽으로 확인하기
랩 안에 8080·9999 두 포트로 HTTP 서버를 띄우고, 호스트에서 접속해 본다. 셋에 등록된 8080만 열려 있어야 한다.
ip netns exec fwlab bash -c 'python3 -m http.server 8080 >/dev/null 2>&1 &'
ip netns exec fwlab bash -c 'python3 -m http.server 9999 >/dev/null 2>&1 &'
curl -s -o /dev/null -w 'HTTP %{http_code}\n' --max-time 3 http://10.99.0.2:8080/
curl -s -o /dev/null -w 'HTTP %{http_code}\n' --max-time 3 http://10.99.0.2:9999/; echo "curl exit=$?"HTTP 200
HTTP 000
curl exit=28
열린 포트는 즉시 200, 막힌 포트는 응답 없이 타임아웃(exit=28)이다. reject가 아니라 drop이라 RST 대신 무응답이 돌아온다.
어느 규칙이 몇 번 걸렸는지는 카운터로 확인한다.
$ sudo ip netns exec fwlab nft list chain inet filter input
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
iif "lo" accept
ct state established,related accept
ct state invalid counter packets 0 bytes 0 drop
ip protocol icmp icmp type echo-request limit rate 5/second burst 5 packets accept
tcp dport @allowed_tcp ct state new counter packets 1 bytes 60 accept
counter packets 4 bytes 240 log prefix "nft-drop: " level info drop
}
}
셋으로 허용 포트 관리하기
포트 목록을 셋으로 빼두면 규칙셋을 다시 적용하지 않고 원소만 추가·삭제해도 즉시 반영된다.
$ sudo ip netns exec fwlab nft add element inet filter allowed_tcp '{ 9999 }'
$ sudo ip netns exec fwlab nft list set inet filter allowed_tcp
table inet filter {
set allowed_tcp {
type inet_service
elements = { 22, 8080, 9999 }
}
}
$ curl -s -o /dev/null -w '9999 -> HTTP %{http_code}\n' --max-time 3 http://10.99.0.2:9999/
9999 -> HTTP 200
규칙 자체를 지울 때는 핸들이 필요하다. nft -a로 핸들 번호를 확인하고 nft delete rule inet filter input handle 8 형태로 지운다.
$ sudo ip netns exec fwlab nft -a list chain inet filter input
chain input { # handle 1
iif "lo" accept # handle 5
ct state established,related accept # handle 6
ct state invalid counter packets 0 bytes 0 drop # handle 7
ip protocol icmp icmp type echo-request limit rate 5/second burst 5 packets accept # handle 8
tcp dport @allowed_tcp ct state new counter packets 2 bytes 120 accept # handle 9
counter packets 4 bytes 240 log prefix "nft-drop: " level info drop # handle 10
rate limit이 조용히 무력화되는 경우
위 규칙셋에는 ICMP echo를 초당 5개로 제한하는 줄이 있다. 초당 20개를 보내면 절반 이상 막혀야 하는데, 실제로는 전부 통과한다.
$ ping -c 20 -i 0.05 -W 1 10.99.0.2 | tail -2
20 packets transmitted, 20 received, 0% packet loss, time 1048ms
rtt min/avg/max/mdev = 0.035/0.039/0.052/0.004 ms
규칙마다 counter를 붙여 다시 보내고 어디서 통과했는지 세어보면 원인이 드러난다.
$ sudo ip netns exec fwlab nft list chain inet filter input | grep counter
ct state established,related counter packets 19 bytes 1596 accept
ct state invalid counter packets 0 bytes 0 drop
ip protocol icmp icmp type echo-request limit rate 5/second burst 5 packets counter packets 1 bytes 84 accept
tcp dport @allowed_tcp ct state new counter packets 0 bytes 0 accept
counter packets 0 bytes 0 log prefix "nft-drop: " level info drop
20개 중 19개가 rate limit 규칙까지 가지도 못하고 위쪽 ct state established,related accept에서 끝났다. conntrack이 ICMP 세션도 추적하기 때문에 같은 id의 echo-request는 두 번째부터 established로 분류된다.
게다가 limit rate 5/second accept는 초과분을 버리지 않는다. 한도를 넘은 패킷은 이 규칙에 매칭되지 않을 뿐이라 그대로 아래 규칙으로 흘러간다. 제한을 실제로 걸려면 limit rate over ... drop으로 초과분을 명시적으로 버리고, 그 아래에 통과용 accept를 따로 둔다.
chain input {
type filter hook input priority filter; policy drop;
iif "lo" accept
# 초과분을 먼저 버리고
icmp type echo-request limit rate over 5/second counter log prefix "icmp-flood: " drop
# 한도 안쪽만 통과시킨다
icmp type echo-request counter accept
ct state established,related counter accept
ct state invalid counter drop
tcp dport @allowed_tcp ct state new counter accept
counter log prefix "nft-drop: " level info drop
}$ ping -c 20 -i 0.05 -W 1 10.99.0.2 | tail -2
20 packets transmitted, 9 received, 55% packet loss, time 983ms
rtt min/avg/max/mdev = 0.036/0.039/0.045/0.002 ms
$ sudo ip netns exec fwlab nft list chain inet filter input | grep icmp
icmp type echo-request limit rate over 5/second burst 5 packets counter packets 11 bytes 924 log prefix "icmp-flood: " drop
icmp type echo-request counter packets 9 bytes 756 accept
9개 통과 + 11개 드롭으로 합이 맞는다. 카운터를 규칙마다 붙여두면 이런 순서 문제를 추측이 아니라 숫자로 확인할 수 있다.
드롭 로그 확인
log prefix가 붙은 규칙은 커널 로그로 나간다. 다만 네임스페이스 안에서 발생한 로그는 기본적으로 호스트 dmesg에 올라오지 않는다.
$ sudo sysctl net.netfilter.nf_log_all_netns
net.netfilter.nf_log_all_netns = 0
$ sudo dmesg | grep -c nft-drop
0
$ sudo sysctl -w net.netfilter.nf_log_all_netns=1
$ sudo dmesg | grep nft-drop | tail -1
[ 313.091695] nft-drop: IN=veth-lab OUT= MAC=aa:15:0d:fc:72:c6:... SRC=10.99.0.1 DST=10.99.0.2 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=315 DF PROTO=TCP SPT=57926 DPT=9999 WINDOW=64240 RES=0x00 SYN URGP=0
init 네임스페이스가 아닌 곳의 netfilter 로그는 nf_log_all_netns가 0이면 버려진다. 컨테이너 안에서 규칙을 디버깅하는데 로그가 하나도 안 보인다면 이 값을 먼저 확인한다.
영구 적용
Ubuntu의 nftables.service는 부팅 시 /etc/nftables.conf를 그대로 읽는다. 따로 저장 명령이 필요 없고, 검증한 파일을 그 경로에 두고 유닛만 켜면 된다.
$ systemctl cat nftables.service | grep Exec
ExecStart=/usr/sbin/nft -f /etc/nftables.conf
ExecReload=/usr/sbin/nft -f /etc/nftables.conf
ExecStop=/usr/sbin/nft flush ruleset
sudo cp lab.nft /etc/nftables.conf
sudo nft -c -f /etc/nftables.conf # 반드시 먼저 문법 검사
sudo systemctl enable --now nftablesExecStop이 flush ruleset이므로, 이 유닛을 멈추면 다른 도구가 만든 규칙까지 전부 날아간다. 도커나 ufw와 같이 쓰는 호스트에서는 유닛 중지·재시작 시점을 특히 주의한다.
원격 서버라면 적용 전에 되돌림 타이머를 먼저 걸어둔다. 접속이 끊겨도 2분 뒤 규칙이 사라져 다시 들어갈 수 있다.
# 2분 뒤 규칙셋을 비우는 일회성 타이머 예약
sudo systemd-run --on-active=120 --unit=fw-rollback /usr/sbin/nft flush ruleset
sudo nft -f /etc/nftables.conf # 여기서 접속이 끊기면 2분 뒤 자동 복구
# 접속이 멀쩡하면 예약 취소
sudo systemctl stop fw-rollback.timer$ sudo systemd-run --on-active=120 --unit=fw-rollback /usr/sbin/nft flush ruleset
Running timer as unit: fw-rollback.timer
Will run service as unit: fw-rollback.service
$ systemctl list-timers fw-rollback.timer --no-pager
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sun 2026-08-09 23:49:56 KST 1min 59s - - fw-rollback.timer fw-rollback.service
주의사항
- 원격 잠김 방지: 운영 서버에
policy drop체인을 적용할 때는 위의 되돌림 타이머를 반드시 먼저 건다. SSH를 허용하는 줄을 넣었더라도 오타 하나면 그대로 잠긴다. - ufw·도커 테이블은 건드리지 않는다:
table ip filter,table ip nat은 iptables-nft가 소유한다. 직접 편집하면 해당 도구가 규칙을 재생성할 때 충돌한다. 사용자 규칙은 별도 테이블이나 도커가 제공하는DOCKER-USER체인에 넣는다. flush ruleset의 범위: 파일 맨 위의flush ruleset은 내 테이블만이 아니라 모든 테이블을 지운다. 도커가 도는 호스트라면flush table inet filter처럼 대상을 좁힌다.- 규칙 순서가 곧 의미: 위에서 본 것처럼
ct state established를 위에 두면 그 아래의 rate limit·로깅 규칙은 첫 패킷만 보게 된다. 카운터를 붙여 실제 매칭 횟수를 확인하는 습관이 안전하다. - 카운터 초기화: 측정 전에는
nft reset counters inet filter로 0에서 시작한다. 이전 실험의 재전송 패킷이 섞여 숫자를 오독하기 쉽다. dropvsreject: drop은 무응답이라 클라이언트가 타임아웃까지 기다린다. 내부망처럼 빠른 실패가 나은 곳에서는reject with tcp reset을 고려한다.
마무리
nftables는 테이블·체인·훅·우선순위라는 네 축만 잡으면 나머지는 문법 문제다. 규칙셋을 파일 하나로 관리하고, 규칙마다 카운터를 붙이고, 네임스페이스 랩에서 실제 트래픽으로 검증하는 순서를 지키면 운영 서버에 적용할 때의 위험이 크게 줄어든다. 특히 conntrack 상태 규칙의 위치 하나로 rate limit이 통째로 무력화될 수 있다는 점은 카운터 없이는 알아채기 어렵다.