docker run -p 했을 때 무슨 일이 벌어지는가?
해당 글은 리눅스 네트워크 기본기와 도커 사용 경험이 있는 초중급자를 위해 작성된 글 입니다
'docker run -p 8080:80 nginx 했더니 그냥 됐는데?' 에서 멈춰있는 사람을 위한 글
'그래서 그 -p 가 도대체 무슨 일을 하는것인가?' 를 따라가는 글이다
이미지 같은건 추후 추가할테니, 도식 텍스트로 우선 양해 바랍니다
'docker run -p 8080:80 nginx' 한 번이라도 쳐본적이 있다면, 의문이 든 적이 있을것이다
- 도커 컨테이너는 컨테이너만의 네트워크를 가진다는데,
어떻게 호스트의 8080 포트로 들어오면 컨테이너의 80 포트로 들어가는것인가?
- 호스트의 방화벽은 그대로인데, 도커가 알아서 포워딩을 해주는것인가?
- iptables -L 했더니 처음 보는 DOCKER 같은 체인이 잔뜩 생겨있는데,
이건 누가 만든것인가?
이 글은 위 의문들을 [iptables의 NAT 테이블을 직접 까보는 방식]으로 답하는 글이다
보너스로, iptables의 후속인 nftables도 같이 본다
iptables는 [리눅스 커널의 패킷 필터링 프레임워크인 netfilter]를 사용자가 제어하기 위한 CLI 도구이다
- 즉 [iptables ≠ 방화벽 그 자체]가 아닌, [iptables = 방화벽을 설정하는 도구]가 정확한 표현이다
- 실제로 패킷을 까보고 결정을 내리는것은 커널 안에서 동작하는 netfilter다
지금까지의 리눅스 네트워크 관리의 사실상 표준이었고, 1998년부터 사용되어왔다
iptables를 까려면, 먼저 [테이블(table)]과 [체인(chain)]이라는 두 개념을 알아야 한다
- filter : 패킷을 허용할것인가 막을것인가 (방화벽의 본체)
- nat : 패킷의 출발지/목적지 주소를 바꿀것인가 (도커가 주로 쓰는 그것)
- mangle : 패킷을 세부적으로 조작 (TTL, TOS 등)
- raw : 연결 추적(connection tracking)을 거치지 않는 특수 처리
- PREROUTING : 패킷이 들어와서 [라우팅 결정되기 전]
- INPUT : 라우팅 결과, 이 호스트가 목적지인 패킷
- FORWARD : 라우팅 결과, 이 호스트를 지나가는 패킷
- OUTPUT : 이 호스트에서 새로 생성된 패킷
- POSTROUTING : 패킷이 나가기 직전, [라우팅 결정 이후]
테이블과 체인의 관계를 표로 정리하면
| PREROUTING | INPUT | FORWARD | OUTPUT | POSTROUTING | |
|---|---|---|---|---|---|
| filter | O | O | O | ||
| nat | O | O | O | ||
| mangle | O | O | O | O | O |
| raw | O | O |
'그래서 패킷이 들어왔을 때, 도대체 어떤 순서로 체인을 거치는것인가?'
들어와서 [이 호스트에 머무는 패킷]의 흐름 :
PREROUTING -> (라우팅 결정) -> INPUT -> 로컬 프로세스 -> OUTPUT -> POSTROUTING
들어와서 [이 호스트를 지나가는 패킷(=포워딩)]의 흐름 :
PREROUTING -> (라우팅 결정) -> FORWARD -> POSTROUTING
위 표에서 [nat가 PREROUTING과 POSTROUTING을 가진 이유]가 여기서 나온다
패킷이 들어오자마자(=라우팅 결정 전) 목적지를 바꿔야 [원하는 곳으로 라우팅]이 가능하고,
패킷이 나가기 직전(=라우팅 결정 후) 출발지를 바꿔야 [응답이 돌아올 길]이 막히지 않는다
도커가 -p 옵션으로 만드는것은 PREROUTING 쪽 (외부->컨테이너) 룰이고,
컨테이너가 외부로 나갈 때 적용되는것은 POSTROUTING 쪽 (컨테이너->외부) 룰이다
nftables는 [iptables의 공식 후속작]이다. 2014년 리눅스 커널 3.13에 정식 도입되었다
- iptables 시대에 누적되어왔던 문제 (각 프로토콜별 별도 도구 - iptables/ip6tables/arptables/ebtables -, 룰 갱신 비효율, 표현력 부족 등)를 한 번에 해결하기 위해 만들어졌다
- 명령어는
nft이고, iptables보다 문법이 깔끔하며 성능도 좋다
| iptables | nftables | |
|---|---|---|
| 등장 | 1998년 | 2014년 |
| 명령어 | iptables / ip6tables / arptables / ebtables 따로 | nft 하나로 통합 |
| 룰 추가 | 룰 하나 추가할 때마다 전체 룰셋 재로딩 | 원자적(atomic) 부분 갱신 가능 |
| 문법 | 옵션 폭격 (-A, -p, -j ...) | 더 사람이 읽기 쉬운 표현식 기반 |
| 성능 | 룰이 많아질수록 선형적으로 느려짐 | 룰이 많아도 성능 유지 (set / map 자료구조 활용) |
'iptables 명령어 쳤더니 잘만 동작하던데, 우리 서버는 그럼 iptables 쓰는것인가?'
답: 모를수도 있다.
최근 배포판(Ubuntu 22.04+, RHEL 8+, Debian 11+ 등)에서는
`iptables` 명령어가 사실 [iptables-nft] 라는 호환 레이어로 동작하는 경우가 많다
즉 명령어는 iptables를 치지만, 내부적으로는 nftables 백엔드가 룰을 처리하고 있다는것
확인 방법 :
$ iptables --version
iptables v1.8.x (nf_tables) <- nftables 백엔드로 동작 중
iptables v1.8.x (legacy) <- 진짜 legacy iptables 백엔드
[ (nf_tables) ]가 붙어있다면, 호환 레이어로 동작하는것이다
이 경우, 'iptables -L'로 본 룰이 'nft list ruleset'에는 다른 모양으로 보일수 있다
(서로 다른 family로 들어가있을 수 있기 때문)
이래서 디버깅할 때 사람들이 미친다
NAT(Network Address Translation)는 단어 그대로 [네트워크 주소를 번역(=변환)하는 기술]이다
NAT는 크게 두 종류로 나뉜다 :
외부에서 들어오는 패킷의 [목적지 주소]를 내부 주소로 바꾸는것
- "내 호스트 IP의 8080 포트로 온 패킷은, 내부 컨테이너 IP의 80 포트로 보낸다" 가 정확히 이것
docker run -p 8080:80이 만드는 룰의 정체이다
내부에서 외부로 나가는 패킷의 [출발지 주소]를 외부 IP로 바꾸는것
- "컨테이너의 IP(172.17.0.x)는 외부에서 라우팅이 안 되니까, 호스트의 IP로 바꿔서 내보낸다"가 정확히 이것
- 컨테이너가 인터넷에 접속할 수 있는 이유이다
- SNAT는 변환할 IP를 명시적으로 지정하고, MASQUERADE는 [인터페이스의 현재 IP를 자동으로 사용]한다. 호스트 IP가 동적으로 바뀔수 있는 환경(DHCP 등)에서 MASQUERADE가 유리하다
요약 :
본격적으로 docker run -p 추적에 들어가기 전에, 도커가 호스트 네트워크에 무엇을 만들어놓는지 한번 정리하자
도커 데몬이 시작되면, 호스트에 [docker0] 라는 리눅스 브릿지가 자동 생성된다
- 기본 IP 대역은 보통 172.17.0.0/16
ip a명령으로 확인 가능하다- 이 브릿지는 [컨테이너들이 서로 통신하고, 외부로 나가는 통로] 역할을 한다
컨테이너가 만들어지면, 도커는 [veth pair (가상 이더넷 쌍)]이라는 가상 케이블 한 쌍을 만든다
- 한 쪽 끝은 컨테이너 내부의 eth0
- 다른 한 쪽 끝은 호스트의 docker0 브릿지에 꽂힘
- 이 한 쌍이 [컨테이너와 호스트를 잇는 가상 LAN 케이블]인것
[ Container ] [ Host ]
eth0 ---------- (veth pair) ---------- docker0 ---- (외부)
(172.17.0.2/16) (172.17.0.1/16)
그리고 도커는 자동으로 호스트의 iptables에 자기 전용 체인을 만든다 :
- DOCKER : 도커가 자동 관리하는 메인 룰 영역
- DOCKER-USER : 사용자가 추가하는 룰 (도커보다 먼저 처리됨, 도커가 안 건드림)
- DOCKER-ISOLATION-STAGE-1, STAGE-2 : 컨테이너 간 격리 전용
이 셋([브릿지] + [veth] + [iptables 룰])이 [도커 네트워크의 본체]이다
자, 이제 위 개념 다 가지고 실제로 명령어를 친다고 가정하자
$ docker run -d -p 8080:80 --name web nginx
이 한 줄을 치면, 도커가 백그라운드에서 다음 작업을 수행한다 :
4, 5, 6번이 우리가 까볼 룰들이다
호스트에서 iptables NAT 테이블을 까보자
$ sudo iptables -t nat -L -n -v
대략 이런 모양이 나온다 (필요한 부분만 발췌) :
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
DOCKER all -- 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL
Chain POSTROUTING (policy ACCEPT)
target prot opt source destination
MASQUERADE all -- 172.17.0.0/16 0.0.0.0/0
MASQUERADE tcp -- 172.17.0.2 172.17.0.2 tcp dpt:80
Chain DOCKER (2 references)
target prot opt source destination
RETURN all -- 0.0.0.0/0 0.0.0.0/0
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80
읽어보자.
PREROUTING 체인 :
- "들어온 패킷 중 목적지가 LOCAL(=이 호스트의 IP)이면, DOCKER 체인으로 점프해라"
- 즉 외부에서 호스트의 어떤 포트로든 패킷이 들어오면, DOCKER 체인을 거치게 된다
DOCKER 체인 :
- "TCP 8080 포트로 온 패킷은, 목적지를 172.17.0.2:80으로 바꿔라(DNAT)"
- 이게 바로 [-p 8080:80]이 만드는 룰의 정체이다
POSTROUTING 체인 :
- "172.17.0.0/16 (=컨테이너 네트워크)에서 나가는 패킷은 호스트 IP로 출발지 변환(MASQUERADE)해라"
- 이게 [컨테이너가 인터넷에 접속할 수 있는 이유]이다
위 룰들을 알고 나서, 외부에서 curl http://호스트IP:8080을 쳤을 때의 패킷 흐름을 따라가보자
[ 외부 클라이언트 ] -- (호스트IP:8080) --> [ 호스트의 eth0 ]
|
v
[ PREROUTING (nat) ]
|
DOCKER 체인으로 점프
|
v
DNAT : 목적지를 172.17.0.2:80 으로 변경
|
v
[ 라우팅 결정 ]
(목적지 172.17.0.2 는 docker0 쪽 -> FORWARD)
|
v
[ FORWARD (filter) ]
|
DOCKER 체인의 ACCEPT 룰에 의해 통과
|
v
[ docker0 ] -> [ veth ] -> [ 컨테이너의 eth0:80 ]
|
nginx 가 응답
핵심은 :
'어, 근데 호스트의 eth0가 받은 패킷의 목적지는 [호스트 IP]였는데,
어떻게 INPUT으로 안 가고 FORWARD로 갔지?'
이게 NAT의 미친(?) 부분이다.
PREROUTING의 DNAT가 [목적지 IP를 172.17.0.2로 갈아끼운 직후],
커널은 '이 패킷의 목적지가 어디지?' 를 다시 판단한다 (= 라우팅 결정)
이때 172.17.0.2는 호스트 IP가 아니라 docker0 너머의 IP니까,
[INPUT으로 가는게 아닌, FORWARD로 가야 한다] 라는 결론이 나오는것이다
즉 라우팅 결정의 기준이 되는 IP는 [DNAT 적용 이후의 IP]이다
이 순서가 헷갈리면 도커 네트워크 디버깅이 끔찍해진다
도커는 컨테이너를 띄울 때마다 DOCKER 체인에 룰을 자동으로 박는다
근데 사용자가 "특정 IP에서 오는 트래픽은 무조건 막고 싶다" 같은 커스텀 룰을 추가하고 싶을 때, 어디에 넣어야 도커가 안 건드리는가?
DOCKER-USER 체인은 :
- FORWARD 체인에서 [도커 룰보다 먼저] 처리된다
- 도커 데몬이 재시작되어도 룰이 살아남는다
- 사용자가 자유롭게 룰을 추가/삭제할 수 있다
# 예: 192.168.1.100 에서 오는 패킷을 컨테이너로 가지 못하게 막기
$ sudo iptables -I DOCKER-USER -s 192.168.1.100 -j DROP
본문 내내 iptables를 봤는데, [그래서 nftables는 도대체 언제?] 라는 의문이 생길 수 있다
현재 시점에서 :
- 도커는 [기본적으로 iptables 백엔드]를 사용한다 (최신 버전에서 nftables 백엔드 옵션이 추가되긴 했지만, 아직 기본값은 iptables)
- 새로운 시스템을 처음부터 세팅한다면 nftables가 더 깔끔하지만,
- [도커가 자동으로 만드는 룰]이 iptables 기반이라서, 도커 호스트 자체는 iptables 룰을 계속 보게 된다
즉 현실은 :
결론은 [iptables는 아직 안 죽었고, 한동안 안 죽을것]이다
docker run -p 8080:80 한 줄이 만드는 일을 정리하면 :
이 4개가 합쳐져서, 우리가 curl localhost:8080 을 쳤을 때 컨테이너의 nginx가 응답하는것이다
마법처럼 동작하는것 같던 [-p 옵션]의 정체는, 결국 [iptables 룰 몇 개]였다는것
도커 네트워크 디버깅을 하다가 막히면, 가장 먼저 까봐야 할 곳은
iptables -t nat -L -n -viptables -t filter -L -n -v이 두 명령어이다. 컨테이너가 외부로 못나가거나, 외부에서 컨테이너로 못들어올 때, 위 두 명령어를 외운채로 까보면 90%는 답이 나온다
이렇게 [docker run -p가 도대체 무슨일을 하는것인가]에 대해 까보았습니다
앞으로 도커 네트워크 디버깅에서 막막함을 조금이나마 덜수 있길 바랍니다
사실 요즘은 그냥 docker-compose 쓰면 이런거 신경 안써도 된다.. 까지 신경써야 한다면 당신은 SE다