0. Contents
1. Overview
02-1 에서 본 것: 정적인 구조
- 02-1 은 커널이 가진 것들의 이야기였다
- 프로그램이 부탁하는 손잡이(소켓)
- 패킷이 드나드는 문(인터페이스)
- 문과 주소가 통째로 격리된 방(Network Namespace)
- 손으로 만들어 보니 전부 정적인 구조였다
- 방을 만들고 파이프를 꽂고 스위치에 꽂으면 거기 그대로 있다
02-2: 패킷이 지나갈 때 커널이 하는 네 가지 일
- 02-2 는 그 사이를 패킷이 지나갈 때의 이야기다
- 문으로 들어온 패킷이 소켓에 닿기까지 커널은 네 가지 일을 한다
- 통과시킬지 버릴지 정한다(Netfilter)
- 이게 어느 연결의 패킷인지 기억한다(Conntrack)
- 필요하면 주소를 바꾼다(NAT)
- 내 것인지 남 것인지, 어느 문으로 보낼지 정한다(Routing)
- 그리고 마지막에 — 이 전부가 커널 안에서 일어나고, 프로세스는 한 번도 깨어나지 않는다
Service 와 이어지는 지점
- 쿠버네티스의 Service 가 이 넷의 조합이다 → 그래서 중요하다
- 어디에도 없는 주소로 보낸 패킷이 진짜 Pod 에 닿는다
- 답장은 없는 주소에서 온 것처럼 돌아온다
- 책은 iptables 규칙으로 설명한다
- 이 글에서는 그 규칙을 VM 에서 손으로 넣고 메모장을 열어 본다
실습 환경과 출발점
- 실습은 02-1 과 같은 오라클 VM 이다
- 02-1 의 4.5 에서 브리지에 꽂은 방끼리 ping 이 안 됐던 일이 있었다
- 그 이유를 찾는 데서 시작한다
2. Netfilter — 패킷 길의 검문소
2.1 훅 — 자리와 규칙은 다르다
Netfilter: 검문소를 세울 수 있는 자리
- Netfilter 는 검문소가 아니라 검문소를 세울 수 있는 자리다
- 커널의 패킷 길에는 정해진 다섯 지점이 있다
- 거기에 “패킷이 여기 오면 이 함수를 불러라” 하고 훅(hook)을 걸 수 있다
- 훅에 걸린 함수가 패킷을 보고 통과(ACCEPT), 폐기(DROP), 수정 중 하나를 돌려준다
다섯 자리의 순서
- 패킷 입장에서 기억하는 게 낫다
- 문으로 들어오자마자
PREROUTING - 그다음 라우팅 결정 — 내 것인가 남 것인가
- 내 것이면 소켓으로 가기 직전에
INPUT - 남 것이면 다른 문으로 나가기 전에
FORWARD
- 내 것이면 소켓으로 가기 직전에
- 내 프로세스가 만든 패킷은
OUTPUT에서 시작한다 - 어느 쪽이든 문으로 나가기 직전이
POSTROUTING
자리와 그 위에 거는 것들
- Netfilter 자체는 규칙이 없고 자리만 제공한다
- 그 자리에 거는 것
- 규칙: iptables 와 nftables
- 연결 장부: Conntrack
- 6장에서 보듯 eBPF 프로그램도 걸 수 있다
- 02-1 의 strace/ss 와 같은 구도다
- 부서는 커널에 있다
- 우리가 치는 명령은 그 부서에 규칙을 넣거나 상태를 읽는 도구다
iptables명령은 치고 나면 끝나고, 패킷마다 실행되는 게 아니다
iptables 와 nftables: 문법은 달라도 자리는 같다
- 이 우분투의
iptables --version은(nf_tables)를 돌려준다- 02-1 1.2 의 ifconfig/ip 처럼 구세대 명령이 신세대 엔진 위에서 도는 것이다
nft list ruleset을 치면 같은 규칙이 어느 훅에 걸렸는지가 그대로 보인다- 규칙 문법은 둘이 다르지만 자리는 같다
- 이 글에서는 책과 같은
iptables문법으로 쓴다
2.2 카운터로 세어 보기 — lo 는 네 자리를 다 지난다 [실습]
타깃 없는 규칙으로 세기
- 패킷이 어느 자리를 지나는지 직접 셀 수 있다
- iptables 규칙에 타깃(
-j)을 안 붙이면 → 아무것도 안 하고 지나간 패킷 수만 센다 - 그걸 다섯 자리에 하나씩 꽂았다
sudo iptables -t raw -I PREROUTING -p tcp --dport 8080
sudo iptables -t raw -I OUTPUT -p tcp --dport 8080
sudo iptables -t mangle -I POSTROUTING -p tcp --dport 8080
sudo iptables -t filter -I INPUT -p tcp --dport 8080
sudo iptables -t filter -I FORWARD -p tcp --dport 8080
테이블 이름과 실행
-t raw,-t mangle은 테이블 이름이다- 같은 훅 자리에 테이블이 여러 겹 걸려 있다
- 자리마다 아무 테이블이나 하나 골라 꽂은 것이다 — 몰라도 된다
- 02-1 의 Python 서버를 띄우고 lo 로 curl 한 번
curl -s http://127.0.0.1:8080/ -o /dev/null
결과: lo 패킷은 네 자리를 전부 지난다
- 내가 만들어 나한테 보낸 패킷은 네 자리를 전부 지난다
- 나가는 쪽 둘(OUTPUT → POSTROUTING)을 지나 lo 로 내보내진다
- lo 가 되돌린 패킷이 들어오는 쪽 둘(PREROUTING → INPUT)을 지난다
- 02-1 3.2 의 “lo 로 내보낸 패킷은 바로 수신 경로로 돌아온다” 가 검문소 입장에서는 이렇게 보인다
- 6 은 8080 으로 가는 방향의 패킷 수 — SYN, ACK, GET, FIN 과 그 ACK 들이다
- FORWARD 는 0, 남의 패킷이 아니니까
2.3 방향이 다르면 길이 다르다 — SSH 는 둘만 지난다 [실습]
SSH 카운터: 방향마다 둘씩
- 같은 카운터를 22번 포트에 꽂고 노트북에서 VM 으로 ssh 를 몇 번 때렸다
- 들어오는 패킷: PREROUTING 과 INPUT 에서 110씩 세였다
- 답장: OUTPUT 과 POSTROUTING 에서 81씩 세였다
- 밖에서 들어온 패킷은 앞의 둘만, 그 답장은 뒤의 둘만 지난다
- 같은 세션인데 방향에 따라 다른 자리를 지난다
- FORWARD 는 역시 0 이다 → 이 VM 은 남의 패킷을 넘긴 적이 없다
다섯 자리가 만드는 네 길
- 다섯 자리를 조합하면 길은 넷이다
- 밖에서 와서 내가 받는 것(PRE → INPUT)
- 밖에서 와서 남에게 넘기는 것(PRE → FORWARD → POST)
- 내가 만들어 내보내는 것(OUTPUT → POST)
- 내가 만들어 나한테 보내는 것(네 자리 전부)
- 쿠버네티스에 대입하면
- 노드가 Pod 의 패킷을 다른 노드로 넘기는 건 FORWARD 길이다
- Service IP 를 Pod IP 로 바꾸는 건 PREROUTING(밖에서 온 것)이나 OUTPUT(노드 자신이 만든 것)에서 일어난다
- 4장에서 한다
2.4 브리지도 검문을 받는다 — 02-1 의 ping 이 막힌 이유 [실습]
증상: ARP 는 되고 ICMP 만 막힘
- 02-1 4.5 에서 방 셋을 브리지 br0 에 꽂았는데 ping 이 100% 실패했다
- ARP 는 됐다
ip neigh에 REACHABLE 이 떴다- 브리지의 MAC 표도 채워졌다
- ICMP 만 안 갔다
- 브리지는 제대로 동작한 것이다 → 원인은 이 VM 의 FORWARD 체인이었다
sysctl net.bridge.bridge-nf-call-iptables
sudo iptables -S FORWARD
원인: 브리지 트래픽도 FORWARD 를 지난다
- 브리지는 2계층 장치라 IP 방화벽과 무관할 것 같다
- 하지만
bridge-nf-call-iptables = 1이면 브리지 안에서 포트 사이를 오가는 프레임도 IP 패킷이면 FORWARD 훅을 지난다- 브리지 입장에서는 한 포트로 들어와 다른 포트로 나가는 것 → “남의 패킷” 이다
- 이 VM 의 FORWARD 는 끝이 REJECT 다
- ARP 는 IP 가 아니라 이 훅을 안 지난다 → MAC 은 배웠다
- ICMP 는 IP 라 거절됐다
sudo iptables -I FORWARD -i br0 -o br0 -j ACCEPT
해결: br0 안쪽만 통과시키는 한 줄
- br0 에서 들어와 br0 로 나가는 것만 통과시키는 규칙 한 줄을 맨 앞에 넣자 ping 이 갔다
- 쿠버네티스 노드의 cni0 브리지에서 Pod 끼리 통신이 되는 것도 CNI 가 이런 규칙을 넣어 두었기 때문이다
- 이 sysctl 은 kubelet 이 켜라고 요구하는 값이다
- Pod 사이의 트래픽에 NetworkPolicy 를 걸 수 있는 이유가 바로 “브리지 트래픽도 FORWARD 를 지나게 해 두었기” 때문이다
전체 명령과 출력 — 05-netfilter-routing · 03-veth-netns
3. Conntrack — 검문소의 메모장
3.1 왜 필요한가 — 답장만 받아라
훅의 한계: 기억이 없다
- 훅은 패킷을 한 장씩 보고, 앞에 뭐가 지나갔는지 모른다
- 그런데 제일 흔한 방화벽 규칙은 이것이다
- “밖에서 먼저 들어오는 건 막되, 내가 시작한 연결의 답장은 받아라”
- 패킷 한 장만 보면 답장인지 새 연결인지 구분이 안 된다
- 누군가 “내가 보낸 요청이 있었다” 를 기억하고 있어야 한다 → 그 메모장이 Conntrack 이다
INPUT 체인에 이미 있는 규칙
- 2.1 에서 본 이 VM 의 INPUT 체인에 그 규칙이 이미 있다
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
...
-A INPUT -j REJECT --reject-with icmp-host-prohibited
ESTABLISHED 한 줄의 역할
- 메모장에 있는 연결이면(ESTABLISHED) 통과, 없으면 맨 아래 REJECT
- 02-1 의 Python 서버에 lo 로 curl 이 됐던 것도, 노트북에서 ssh 가 되는 것도 이 한 줄 덕분이다
- 메모장이 없으면 “내가 시작한 연결” 이라는 개념 자체가 성립하지 않는다
NAT 에도 필요한 장부
- NAT 도 마찬가지다
- 첫 패킷에서 주소를 바꿨으면 같은 연결의 모든 패킷을 똑같이 바꿔야 한다
- 답장은 반대로 되돌려야 한다
- 장부 없이는 불가능하다 → 4장이 그 이야기다
3.2 한 줄의 모양 — 두 방향 튜플
메모장의 키: 5-tuple
- 연결을 구분하는 다섯 값이다
- 출발 IP, 출발 포트, 목적지 IP, 목적지 포트, 프로토콜
- 5-tuple 이라고 부른다
- 02-1 2.3 에서 “FD 4 가 네 번 나와도 다른 연결, 구분은 포트” 라고 한 근거가 이것이다
열린 연결 하나 읽기
- 02-1 의 서버에 연결 하나를 열어 둔 채로 메모장을 읽었다
sudo conntrack -L -p tcp --dport 8080 --state ESTABLISHED
ss -ntp | grep 8080
ss 와 conntrack 이 보여 주는 모양
- 같은 연결 하나를 두 도구가 다르게 보여 준다
ss: 양쪽 끝의 소켓 두 개(bash 의 3번, python3 의 4번)conntrack: 한 줄에 두 방향
- 첫째 튜플: 원래 방향(손님 53424 → 서버 8080)
- 둘째 튜플: 응답 방향(서버 8080 → 손님 53424)
- 86399 는 이 줄이 지워지기까지 남은 초다
- 패킷이 오갈 때마다 다시 채워진다
- IP 헤더의 TTL 과는 무관하다
NAT 유무와 둘째 줄
- NAT 가 없으면 둘째 줄은 첫째 줄을 뒤집은 것뿐이다
- NAT 가 있으면 둘째 줄에 바뀐 주소가 적힌다
- 4.2 에서 그 모양을 본다
3.3 이벤트 — NEW 에서 TIME_WAIT 까지 [실습]
메모 변화 찍기
- 메모가 바뀌는 순간을 찍을 수 있다
conntrack -E를 켜 두고 curl 한 번
sudo conntrack -E -p tcp --dport 8080 -o timestamp &
curl -s http://127.0.0.1:8080/ -o /dev/null
handshake 를 따라 바뀌는 상태
- SYN 을 보자
[NEW]로 줄이 생기고[UNREPLIED]— 답은 아직 - SYN-ACK 를 보자 SYN_RECV
- 그다음 ACK 로 ESTABLISHED 가 된다
[ASSURED]가 붙는다- 타임아웃이 120초에서 86400초(하루)로 늘어난다
- 1장 4.3 의 3-way handshake 를 메모장이 따라 적은 것이다
- 전부 1밀리초 안에 지나갔다
연결이 끝난 뒤: TIME_WAIT 120초
- 끝난 뒤에도 바로 지워지지 않는다 → TIME_WAIT 로 120초 남는다
- 그래서 curl 을 두 번 하면 메모장에 출발 포트만 다른 두 줄이 보인다
- 02-1 의 “FD 4 가 두 번” 을 연결 쪽에서 본 것이다
주의: 두 ESTABLISHED 의 기준 차이
- Conntrack 의 ESTABLISHED 와 TCP 의 ESTABLISHED 는 기준이 다르다
- TCP: handshake 가 끝나야 ESTABLISHED
- Conntrack: 양방향으로 패킷이 한 번씩만 오가면 그렇게 적는다
- 그래서 연결이 없는 UDP 도 메모장에 적힌다
- DNS 질의 한 장이 나가면
[NEW] [UNREPLIED] - 응답 한 장이 오면 UNREPLIED 가 빠진다
- 그걸로 방화벽의 ESTABLISHED 규칙이 성립한다
- 타임아웃은 30초로 짧다
- DNS 질의 한 장이 나가면
3.4 메모장이 차면 — count, max, conntrack -S
메모장의 크기
- 메모장은 크기가 있다
cat /proc/sys/net/netfilter/nf_conntrack_count /proc/sys/net/netfilter/nf_conntrack_max
53
131072
꽉 찼을 때: table full
- 지금 53줄, 최대 131072줄
- 꽉 차면 새 연결을 적을 자리가 없다 → 패킷을 버린다
- 로그에
nf_conntrack: table full, dropping packet이 뜨는 장애가 이것이다 conntrack -S의insert_failed와drop이 그 흔적이다- 이 VM 에도 이미 수백 건 있다
- 22번 포트로 들어오는 스캐너들이 SYN 만 던지고 가는 것들이다
실무에서 겪는 곳과 교훈
- 연결을 많이 만드는 서비스(DNS, 프록시)가 도는 쿠버네티스 노드에서 실제로 겪는다
- SYN 만 잔뜩 보내 메모장을 채우는 공격도 같은 원리다
- 실무 교훈: max 를 무작정 키우기 전에 먼저 볼 것
- count/max 비율
- 어떤 상태의 줄이 쌓이는지(
conntrack -L | awk '{print $4}' | sort | uniq -c)
전체 명령과 출력 — 06-conntrack
4. NAT — 규칙은 첫 패킷에만
4.1 DNAT — 없는 주소로 보내면
NAT 의 두 방향: SNAT 와 DNAT
- 1장 2.3 의 공유기는 나가는 패킷의 출발지를 바꿨다(SNAT)
- 반대로 들어오는 패킷의 목적지를 바꾸는 게 DNAT 이다
- 쿠버네티스 Service 가 이것이다
- 클라이언트가 Service IP 로 보낸 패킷의 목적지를 Pod IP 로 바꾼다
실습 구성: 쿠버네티스 없이 만든 Pod 와 Service IP
- VM 에서 그대로 만들어 봤다 — 쿠버네티스는 없다
- 02-1 4장의 방식으로 방 둘을 만들어 br0 에 꽂고 각각 서버를 띄웠다
- pod-a(10.200.0.2), pod-b(10.200.0.3) 라고 불렀다
- 커널 입장에서 Pod 는 정확히 이것 — 방 하나, veth 하나, 그 안의 서버
- CNI 가 하는 일을 손으로 한 셈이다
- 그리고
10.96.0.5— 어느 문에도 없는 주소를 Service IP 로 쓴다
ip route get 10.96.0.5
curl -s -m 2 -o /dev/null -w 'http %{http_code}\n' http://10.96.0.5/
10.96.0.5 via 10.0.0.1 dev ens3 src 10.0.0.198
http 000
규칙 전: 아무도 받지 않는 주소
- 라우팅 표에 없는 주소라 default 로 ens3 를 통해 나간다
- 아무도 안 받아서 타임아웃
- 1장 01-2 7.2 의 “Service IP 는 어떤 기기에도 없는 주소” 가 딱 이 상태다
- 규칙 한 줄을 넣는다
sudo iptables -t nat -A OUTPUT -d 10.96.0.5 -p tcp --dport 80 -j DNAT --to 10.200.0.2:8080
규칙 후: curl 의 믿음과 선 위의 주소
- 같은 curl 이 이번엔
pod-a를 받았다 -v로 보면Connected to 10.96.0.5 port 80이라고 믿고 있다- 그 사이에 br0 를 tcpdump 로 찍으면 전혀 다른 주소가 오간다
주소가 바뀐 자리: OUTPUT
- 선 위에는 10.96.0.5 가 한 번도 안 나온다
- curl 이 쓴 주소는 커널의 OUTPUT 자리에서 바뀌었다
- 서버는 10.0.0.198 이 자기한테 직접 보낸 줄로 안다
- 내가 만든 패킷이라 PREROUTING 이 아니라 OUTPUT 이다
- 밖에서 들어온 패킷이었다면 PREROUTING 에서 같은 일이 일어난다
- 둘 다 라우팅 결정 앞이다
- 목적지를 바꿔야 “내 것인지, 어느 문으로 갈지” 를 바뀐 주소로 판단할 수 있으니까
4.2 답장은 어떻게 돌아오나 — 메모장의 둘째 줄
남은 의문: 10.96.0.5 에서 온 답장
- 서버는 10.0.0.198 에게 답장을 보낸다 — 10.96.0.5 는 모른다
- 그런데 curl 은 10.96.0.5 에서 온 답장을 받았다
- 누가 되돌렸는가
sudo conntrack -L -d 10.96.0.5
tcp 6 86399 ESTABLISHED src=10.0.0.198 dst=10.96.0.5 sport=45026 dport=80 src=10.200.0.2 dst=10.0.0.198 sport=8080 dport=45026 [ASSURED]
DNAT 뒤의 두 방향 튜플
- 3.2 의 두 방향 튜플이 여기서 달라진다
- 첫째 줄: curl 이 보낸 그대로(
dst=10.96.0.5:80) - 둘째 줄: 바뀐 주소(
src=10.200.0.2:8080)
- 첫째 줄: curl 이 보낸 그대로(
- DNAT 규칙이 첫 패킷의 목적지를 바꾸면서 “답장은 이 모양으로 올 것” 을 둘째 줄에 적어 둔 것이다
답장 되돌리기: 규칙이 아닌 메모의 역적용
- 답장이 PREROUTING 에 들어오면 Conntrack 이 먼저 메모장과 대조한다
- 둘째 줄과 맞으니 “아, 첫째 줄 연결의 답장” 하고, 출발지를 10.96.0.5:80 으로 거꾸로 되돌린다
- 이때 DNAT 규칙을 다시 보지 않는다
- 모양은 반대 방향 NAT 지만 규칙이 아니라 메모의 역적용이다
NAT 규칙은 첫 패킷에만
- 그래서 NAT 규칙은 연결의 첫 패킷에만 돈다
- 나머지 패킷은 전부 메모장이 한다
- 가는 건 똑같이 바꾼다
- 오는 건 거꾸로 되돌린다
- DNAT 규칙 하나만 걸어도 답장이 알아서 돌아온다 → 반대 방향 규칙을 따로 쓰지 않는 이유다
- 1장의 공유기가 SNAT 규칙 하나로 답장을 노트북에 돌려주는 것도 같은 원리다
4.3 로드밸런싱 — kube-proxy 의 iptables 모드 [실습]
Pod 둘로 나눠 보내기
- Service 뒤에 Pod 가 둘이면 나눠 보내야 한다
- kube-proxy 의 iptables 모드가 쓰는 규칙을 그대로 넣었다
sudo iptables -t nat -A OUTPUT -d 10.96.0.5 -p tcp --dport 80 \
-m statistic --mode random --probability 0.5 -j DNAT --to 10.200.0.2:8080
sudo iptables -t nat -A OUTPUT -d 10.96.0.5 -p tcp --dport 80 \
-j DNAT --to 10.200.0.3:8080
for i in $(seq 1 10); do curl -s http://10.96.0.5/; done
pod-b pod-a pod-a pod-a pod-a pod-b pod-a pod-b pod-b pod-b
확률 규칙의 분배
- 첫 규칙이 50% 확률로 pod-a 를 고른다
- 안 걸린 나머지는 둘째 규칙으로 pod-b
- 열 번 중 다섯 번씩 갔다
- Pod 가 셋이면 첫 규칙 1/3, 둘째 규칙 1/2, 셋째 규칙 나머지로 늘어난다
연결 단위로 고정해야 하는 이유
- 여기서 패킷마다 다른 Pod 로 보내면 안 된다
- SYN 은 pod-a 로, 다음 데이터는 pod-b 로 가면 → pod-b 는 “이게 무슨 연결이야” 하고 RST 를 날린다
- 4.2 의 메모장이 그걸 막는다 — 증거가 둘 있다
증거: 규칙 카운터와 메모장의 응답 방향
iptables -t nat -L OUTPUT -v로 두 DNAT 규칙의 카운터를 보면 5 + 5 = 10- 연결 수와 같다
- 같은 패킷을 2.2 의 방식으로 raw 자리에서 세면 70 — 열 연결이 보낸 패킷 전부다
- 규칙은 첫 패킷 10개만 봤고 나머지 60개는 메모장이 처리했다
- 메모장의 응답 방향을 세면
src=10.200.0.25줄,src=10.200.0.35줄- 연결마다 백엔드가 고정되어 있다
정리: Service 의 정체와 다른 구현들
- 이게 Service 의 정체다
- 없는 주소로 보낸 첫 패킷에서 DNAT 가 Pod 를 고른다
- 메모장이 그 선택을 연결 끝까지 유지한다
- 책은 iptables 로 설명하지만 지금은 선택지가 더 있다
- ipvs 모드: 커널의 로드밸런서 모듈이 해시 테이블로 같은 일을 한다
- nftables 모드: 새 기본값으로 가는 중이다
- Cilium 같은 CNI: kube-proxy 를 빼고 6장의 eBPF 로 한다
- 원리는 같고 규칙을 넣는 도구만 다르다
4.4 MASQUERADE — 방 안에서 인터넷으로 [실습]
반대 방향: 방 안에서 인터넷으로
- 방 안의 pod-a(10.200.0.2)가 인터넷으로 나간다
- 먼저 이 VM 의 FORWARD 가 REJECT 라 br0 → ens3 를 열어 주었다
- 그리고 NAT 없이 보내 봤다
NAT 없이: 나갔지만 답장이 없다
- 나가긴 했다
- 메모장에
SYN_SENT … [UNREPLIED]줄이 생겼다 - FORWARD 를 지났다
- 메모장에
- 그런데 답장이 한 번도 안 왔다
- 출발지가 10.200.0.2 인 채로 ens3 를 떠났다
- 1.1.1.1 입장에서 10.200.0.2 는 돌아갈 길이 없는 사설 주소다
- 1장의 “공유기가 출발지를 바꿔 쓰는 이유” 를 실패로 먼저 봤다
sudo iptables -t nat -A POSTROUTING -s 10.200.0.0/24 -o ens3 -j MASQUERADE
sudo ip netns exec net1 curl -s -m 5 -o /dev/null -w 'http %{http_code}\n' http://1.1.1.1/
sudo conntrack -L -s 10.200.0.2 -d 1.1.1.1
http 301
tcp 6 119 TIME_WAIT src=10.200.0.2 dst=1.1.1.1 sport=55510 dport=80 src=1.1.1.1 dst=10.0.0.198 sport=80 dport=55510 [ASSURED]
MASQUERADE 후: 답장은 ens3 의 주소로
- 한 줄 뒤 답이 왔다
- 301 은 HTTPS 로 가라는 응답이고, 왔다는 게 중요하다
- 메모장의 둘째 줄을 보면
dst=10.0.0.198— 답장은 ens3 의 주소로 온다 - 1.1.1.1 은 10.200.0.2 를 본 적이 없다
- 되돌리는 건 4.2 와 같다
- 둘째 줄과 맞춰 목적지를 10.200.0.2 로 되돌려 br0 로 넘긴다
자리의 논리: DNAT 는 라우팅 앞, SNAT 는 뒤
- MASQUERADE 가 POSTROUTING 에 있는 이유도 논리로 나온다
- 출발지는 어느 문으로 나갈지 정해진 뒤에 그 문의 주소로 바꾸면 된다 → 마지막 자리다
- DNAT 가 라우팅 앞(PREROUTING/OUTPUT)이고 SNAT 가 라우팅 뒤(POSTROUTING)인 것
- 외울 게 아니라 순서상 그럴 수밖에 없다
sudo iptables -t nat -S POSTROUTING
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A POSTROUTING -s 10.200.0.0/24 -o ens3 -j MASQUERADE
도커가 먼저 건 같은 줄
- 도커가 이미 같은 줄을 걸어 두고 있었다
- 02-1 5장의 컨테이너가 인터넷에 나갈 수 있는 이유다
- 도커도 공유기다
- 쿠버네티스 노드도 Pod 가 클러스터 밖으로 나갈 때 같은 일을 한다
전체 명령과 출력 — 07-nat
5. Routing — 내 것인가 남 것인가
5.1 ip route get — 표를 읽지 말고 커널에게 묻는다
1장에 더하는 두 가지
- 1장 01-2 3.1 에서 라우팅 표를 “가장 길게 맞는 줄” 로 읽는 법을 봤다
- 2장이 더하는 것
- 그 결정이 검문소 사이에 끼어 있다는 위치
- 표를 머릿속으로 맞춰 볼 필요 없이 커널에게 직접 묻는 명령
ip route get 1.1.1.1
ip route get 10.0.0.198
ip route get 127.0.0.1
ip route get 172.17.0.9
1.1.1.1 via 10.0.0.1 dev ens3 src 10.0.0.198
local 10.0.0.198 dev lo src 10.0.0.198
local 127.0.0.1 dev lo src 127.0.0.1
172.17.0.9 dev docker0 src 172.17.0.1
ip route get 결과 읽기
- “이 주소면 어느 문으로, 누구에게, 어느 출발 주소로” 가 한 줄로 나온다
- 1.1.1.1 은 default 로 ens3 를 통해 10.0.0.1 에게
- 172.17.0.9 는 도커 브리지로 직접
- 그리고 둘째 줄 — 내 ens3 주소 10.0.0.198 로 보내는 건
local … dev lo- 내 주소로 보내면 선을 안 타고 lo 로 돈다는 뜻이다
- 02-1 3.2 의 “127.x 만 lo 가 아니다” 의 근거다
- 이
local줄들은ip route show table local에 따로 있다
5.2 ip_forward — 노드는 라우터다
FORWARD 길의 전제
- 2.3 의 FORWARD 길은 “남의 패킷을 넘길 때” 였다
- 그런데 일반 컴퓨터는 남의 패킷을 넘기지 않는다
sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 1
ip_forward 값: 노트북 0, 이 VM 1
- 노트북은 이 값이 0 이다
- 목적지가 내가 아닌 패킷이 들어오면 FORWARD 까지 가지도 않고 버린다
- 종점이지 경유지가 아니니까
- 이 VM 이 1 인 건 도커가 켰기 때문이다
- 컨테이너의 패킷을 ens3 로 넘겨 주려면 라우터 노릇을 해야 한다
- 4.4 에서 방 안의 curl 이 “나가긴 했다” 가 된 것도 이 값 덕분이다
쿠버네티스 노드의 값
- 쿠버네티스 노드도 1 이다
- kubelet 이 요구하고, 안 켜져 있으면 설치가 실패한다
- 노드가 Pod 들의 라우터이기 때문이다
다른 노드의 Pod 로 가는 길
- Pod A(10.244.1.3)가 다른 노드의 Pod B(10.244.2.7)에게 보내면, 노드 1 입장에서 목적지는 내가 아니다 → 남의 패킷
- 라우팅 표의
10.244.2.0/24 via 10.0.0.5줄이 “저 대역은 노드 2 에게” 라고 말한다 - FORWARD 를 지나 ens3 로 나간다
- 노드 2 가 받아서 또 FORWARD, veth 로 Pod B 에게
- 1장 01-2 5.3 의 “라우터 한 칸 — 벗기고, 보고, 새로 붙인다” 를 노드가 매 패킷마다 한다
- 그
via 10.0.0.5줄을 누가 넣느냐가 CNI 장의 이야기다
5.3 밖에서 들어온 패킷 — 0.0.0.0 과 127.0.0.1 [실습]
실험 구성: 노트북에서 공인 IP 로
- 지금까지는 lo 와 방 안의 패킷이었다
- 진짜 문으로 들어오는 패킷을 보려고 노트북에서 VM 의 공인 IP 로 curl 을 쐈다
- VM 에서는 ens3 를 tcpdump 하고 PREROUTING 과 INPUT 에 카운터를 꽂았다
- 서버는 두 번 띄웠다
- 처음엔
0.0.0.0:8080 - 다음엔
127.0.0.1:8080
- 처음엔
서버 0.0.0.0:8080 — 노트북: 200 OK VM 카운터: PRE 13 / INPUT 13 서버 로그: GET / 두 줄
서버 127.0.0.1:8080 — 노트북: Connection refused VM 카운터: PRE 15 / INPUT 15 서버 로그: 없음
둘째 경우의 tcpdump
- ens3 의 tcpdump 로 둘째 경우를 보면 이렇다
IP <노트북>.51657 > 10.0.0.198.8080: Flags [S], seq 3073052855, ...
IP 10.0.0.198.8080 > <노트북>.51657: Flags [R.], seq 0, ack 3073052856, win 0, length 0
막힌 곳: 소켓 찾기
- SYN 이 들어왔고, 커널이 바로 RST 를 돌려보냈다
- 카운터가 늘었다 → 패킷은 PREROUTING 과 INPUT 을 다 지났다
- 막힌 건 그다음, 소켓 찾기다
- 커널이 “10.0.0.198 의 8080 을 듣는 소켓” 을 찾는다
- 127.0.0.1 에만 바인딩된 소켓은 그 조건에 안 맞는다
- 없으니 RST
- 서버는 아무것도 몰랐다
lo 바인딩: 설정이지 인증이 아니다
- 02-1 3.2 에서 미뤄 둔 “lo 바인딩은 설정이지 인증이 아니다” 가 이 뜻이다
- 127.0.0.1 바인딩은 패킷을 막는 게 아니다
- 패킷은 커널까지 들어오고 검문소도 다 지난다
- 마지막 소켓 찾기에서 실패하게 만드는 것뿐이다
- 그 찾기를 속일 수 있으면 뚫린다
5.4 Martian 과 route_localnet — CVE-2020-8558
Martian 패킷
- 있을 수 없는 주소를 달고 온 패킷이다
- 대표: 출발지나 목적지가 127.x 인데 ens3 로 들어온 것
- 127.x 는 lo 에서만 나와야 한다 → 밖에서 그런 게 오면 조작이다
- 리눅스는 기본으로 버린다
sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf.ens3.route_localnet
net.ipv4.conf.all.rp_filter = 2
net.ipv4.conf.ens3.route_localnet = 0
두 sysctl 의 뜻
rp_filter: 출발지 검증- “이 출발지로 답장을 보낸다면 이 문으로 나가는 게 맞나” 를 라우팅 표로 확인한다
- 아니면 버린다
route_localnet = 0: 127.x 를 lo 밖에서 라우팅하지 않는다는 뜻
CVE-2020-8558: 원인과 결론
- 2020년의 CVE-2020-8558 은 kube-proxy 가 이
route_localnet을 1 로 바꿔서 생겼다- NodePort 를 localhost 로도 접근 가능하게 하려는 편의였다
- 그 결과 ens3 로 들어온 목적지 127.0.0.1 패킷도 lo 로 라우팅됐다
- 5.3 의 소켓 찾기에서 “127.0.0.1 의 8080 을 듣는 소켓” 이 이번엔 조건에 맞는다
- 노드의 localhost 전용 서비스에 같은 네트워크의 다른 호스트가 닿을 수 있었다
- 5.3 과 이어서 보면 결론이 하나다 → 127.0.0.1 에 바인딩한 서비스에도 인증을 걸어야 한다
전체 명령과 출력 — 05-netfilter-routing · 08-external
6. eBPF — 검문소에 내 코드를
6.1 올릴 때 한 번, 돌 때는 커널
기존 개입 방법과 한계
- 지금까지 패킷 처리에 끼어드는 방법은 둘이었다
- iptables 규칙: “조건이면 행동” 의 정해진 틀만 된다 → 복잡한 판단은 못 한다
- NFQUEUE: 패킷을 유저 공간 프로그램에 넘길 수도 있다
- 패킷마다 커널 밖으로 나갔다 들어오니 느리다
- 커널 모듈: 직접 짜면 빠르다
- 버그 하나로 서버가 통째로 죽는다
eBPF: 검증을 거쳐 커널 안에서 도는 프로그램
- eBPF 는 그 중간이다
- 작은 프로그램을 짜서 커널에 올린다
- 커널의 검증기가 “무한 루프 없나, 엉뚱한 메모리 안 건드리나” 를 먼저 확인한다
- 통과한 것만 커널 안에서 돌린다
- 커널 모듈만큼 빠르면서 커널을 죽이지 않는다
흐름 두 단계
- 올릴 때
- 유저 공간의 로더(Go, Rust, 또는 bpftrace)가
bpf()시스템 호출로 프로그램을 커널에 넘긴다 - 검증기를 통과하면 원하는 자리에 붙인다
- 02-1 2.1 의 “커널에 부탁” 이 여기서는 한 번뿐이다
- 유저 공간의 로더(Go, Rust, 또는 bpftrace)가
- 돌 때
- 그 자리에 패킷이 올 때마다 커널이 프로그램을 실행한다
- 로더는 더 이상 관여하지 않고, 꺼져 있어도 된다
NFQUEUE 와 헷갈리지 않기
- “유저 공간 프로그램이 패킷을 처리한다” 고 생각하면 NFQUEUE 와 헷갈린다
- 그게 아니라 유저 공간에서 짜서 커널에 넘겨준 코드가 커널 안에서 돈다
- 선을 넘는 건 올릴 때 한 번이다
6.2 붙이는 자리 — XDP, TC, Netfilter, tracepoint
붙이는 자리에 따라 갈리는 일
- 같은 eBPF 프로그램이라도 어디에 붙이느냐로 하는 일이 갈린다
- 패킷이 들어오는 길에 자리가 셋 있다
패킷 길의 세 자리: XDP, TC, Netfilter 훅
- XDP: 랜카드 드라이버 바로 위
- 패킷이 커널 스택에 들어오기도 전이다 → 여기서 버리면 제일 싸게 버린다
- DDoS 방어에 쓴다
- TC: 인터페이스의 큐
- 스택에는 들어왔지만 Netfilter 전
- Netfilter 훅: 2장의 다섯 자리
- Linux 6.4 부터
BPF_PROG_TYPE_NETFILTER로 여기에도 붙일 수 있다 - 이 VM 의 커널 6.8 이 그 범위다
- iptables 규칙 대신 프로그램이 ACCEPT/DROP 을 돌려준다
- Linux 6.4 부터
Netfilter 와 eBPF 의 관계
- 그래서 Netfilter 와 eBPF 는 경쟁 관계가 아니다
- Netfilter: 자리
- iptables 규칙: 그 자리에 거는 정해진 틀의 판단
- eBPF 프로그램: 그 자리에 거는 내가 짠 판단
- 셋 다 커널 안이다
패킷 길 밖의 자리: tracepoint 와 kprobe
- “이 커널 함수가 불렸다” 를 알려 주는 자리다 → 여기 붙이면 관찰 도구가 된다
bpftrace -e 'tracepoint:syscalls:sys_enter_execve { ... }'가 이것이고 패킷과 무관하다- 같은 eBPF 인데 붙이는 자리가 관찰이냐 결정이냐를 가른다
Cilium 이 kube-proxy 를 없앨 수 있는 이유
- 4.3 의 iptables 규칙은 목록이다
- Service 가 1,000개면 패킷마다 수천 줄을 위에서부터 훑는다
- Pod 하나가 바뀌어도 전체를 다시 쓴다
- eBPF 프로그램 하나를 TC 자리에 걸고 Service 정보는 커널 안의 해시 맵에 넣으면
- 패킷마다 조회 한 번
- 갱신은 한 칸
- 3.5 의 Conntrack 이 5-tuple 을 해시로 찾는 것과 같은 발상이다
- CNI 장에서 다시 본다
7. Discussion
7.1 하나의 요청으로 — curl 한 번의 열다섯 단계
curl 한 번의 전체 경로
- 02-1 과 02-2 를 합치면
curl http://127.0.0.1:8080/한 번이 이렇게 된다 - 왼쪽 번호 뒤가 어느 절인지다
프로세스의 몫과 커널의 몫
- 프로세스가 한 일은 1, 2, 12~15 뿐이다
- 나머지 아홉은 전부 커널이고, 그중 5, 6, 8, 9 가 이 편이다
- 쿠버네티스 Pod 로 옮기면 이 열다섯에 몇 단계가 끼어든다
- 앞뒤로 방(02-1 4장)과 veth(02-1 4.3)
- 목적지가 Service 면 OUTPUT 이나 PREROUTING 의 DNAT(4.1)
- 다른 노드면 FORWARD(5.2)
- CNI 가 eBPF 기반이면 그 자리의 프로그램(6.2)
- 소켓은 새로 만들지 않는다
- 같은
socket()과accept4()이고, 커널 부서들 사이에 몇 단계가 끼는 것뿐이다
- 같은
핵심 메시지
문과 방을 다 제대로 이어도 그 사이를 지나는 패킷은 커널의 검문을 받고, 검문소는 기억이 없어서 메모장이 있고, 주소를 바꾸는 건 메모장 위에서만 성립한다. 02-1 의 ping 을 막았던 REJECT 한 줄에서 시작해 Service 의 DNAT 까지 손으로 넣어 보면, kube-proxy 가 하는 일이 규칙 몇 줄과 메모장 하나라는 게 보인다. 다음은 이 부품들을 CNI 가 어떻게 끼워 맞추는지 — Pod 에 방을 만들고 파이프를 꽂고,
via 10.0.0.5줄을 누가 넣는지다.
8. References
"bridge-nf-call-iptables — pass bridged IPv4 traffic to iptables' chains."
"The connection tracking system stores information about the state of a connection in a memory structure that contains the source and destination IP addresses, port number pairs, protocol types, state, and timeout."
"In this mode, kube-proxy configures packet forwarding rules using the iptables API of the kernel netfilter subsystem."
"BPF_PROG_TYPE_NETFILTER — programs of this type are attached to netfilter hooks and can accept or drop packets."
