Skip to Content

9장. 포트는 열리지 않는다 — veth·bridge·NAT

-p 18090:80 한 줄이 만든 건 규칙 한 줄이었습니다. “18090 포트로 온 것은 172.17.0.3의 80번으로 보내라.” 포트가 “열리는” 게 아니라 주소가 바꿔치기되는 거예요.

{/* 삽화 자리: 선 안의 건물과 선 밖의 큰 길 사이에 짧은 다리 하나가 놓여 있고, 그 다리는 광장(브리지)으로 이어진다. 광장 입구에 안내원이 서서, 바깥에서 온 편지의 수신인 주소를 다른 주소로 고쳐 적어 안쪽으로 넘긴다. 벽은 없고 다리와 안내원만 있다. 프롬프트: docs/book/illustration-prompts-container.md */}


배경

6장에서 네트워크 namespace를 만들었더니 eth0이 사라졌습니다. 남은 건 lo와 꺼져 있는 터널 스텁뿐이었죠. 끊기기만 하고 이어지지는 않았습니다.

그런데 진짜 컨테이너에는 eth0이 있습니다. 3장 실측에서 172.17.0.2라는 주소를 갖고 있었고요.

누가 그 다리를 놓아준 걸까요. 그리고 -p 18090:80이라고 쓰면 정확히 무슨 일이 일어날까요.

답은 전부 명령 몇 줄로 확인됩니다.

스토리

nginx 컨테이너를 포트를 열어 띄우고, 안에서 자기 네트워크를 물어봤습니다.

docker run -d --name ch09 -p 18090:80 nginx:alpine docker exec ch09 ip -o -4 addr show eth0 docker exec ch09 ip route docker exec ch09 ip -o link show eth0 # eth0@ifNN 이 여기서 나옵니다
=== 1. 컨테이너 안에서 본 자기 네트워크 === 주소 : 172.17.0.3/16 기본길 : 172.17.0.1 eth0 의 짝 번호: 196

✅ 실측 (Docker Engine 29.7.2 / Docker Desktop 4.87.0 / nginx:alpine / Apple M1 / macOS 26.6, 2026-09. 번호와 주소는 따라 하면 다르게 나옵니다. 전체 소스: docs/book/code/container/ch09/bridge.sh)

세 줄인데 마지막 줄이 낯설 겁니다.

6장에서 eth0@if139라는 표기를 봤죠. @ 뒤의 숫자가 짝의 번호입니다. 장치마다 붙는 이 번호를 ifindex라고 해요. 여기서는 196번입니다.

정확히는 “짝의 번호”까지만 맞습니다. 그 짝이 호스트에 있다는 건 Docker 기본 bridge라서 그런 거고요. 번호는 namespace마다 따로 매겨지니, 다른 namespace의 번호를 이쪽에서 그냥 찾으면 엉뚱한 장치가 잡힙니다. 실제로 호스트 쪽에서 같은 쌍을 보면 veth…@ip6_vti0처럼 찍히는데, 호스트의 그 번호가 마침 6장에서 본 터널 fallback 장치라서 그래요.

eth0은 혼자 있는 장치가 아닙니다. 항상 둘이 한 쌍이고, 한쪽에 들어간 것이 다른 쪽으로 나옵니다. 랜선 한 가닥을 생각하시면 됩니다. 이 쌍의 이름이 veth(virtual ethernet)고요.

그럼 196번은 누구일까요. 호스트에서 찾아봤습니다.

# 리눅스 서버 기준 sudo ip -o -4 addr show docker0 sudo ip -o link | grep '^196:'
=== 2. 호스트에서 본 반대쪽 === 기본길의 정체: docker0 172.17.0.1/16 196번 장치 : vethb22eb65 그 장치가 붙은 곳: master docker0

✅ 실측 (같은 환경, 같은 소스)

그림이 맞춰집니다.

컨테이너의 eth0 반대쪽은 호스트의 vethb22eb65이고, 그게 docker0이라는 브리지에 붙어 있습니다. 그리고 컨테이너가 기본 길로 삼는 172.17.0.1이 바로 그 docker0의 주소고요.

3장에서 --network host로 봤을 때 docker0=172.17.0.1/16이 보였던 게 이겁니다. 그때는 이름만 보고 넘어갔는데, 이제 정체가 드러났어요.

이 장의 호스트 관찰은 전부 리눅스 기준입니다. 리눅스 서버라면 위 명령을 sudo로 그냥 치세요. 맥에서는 docker0도 veth…도 iptables 규칙도 Docker Desktop의 리눅스 VM 안에 있어서 터미널에 ip link를 쳐도 안 나옵니다. VM 안에서 보려면 docker run --rm --privileged --network=host alpine ip -o link처럼 호스트 네트워크를 쓰는 컨테이너를 하나 띄우면 돼요.

핵심

다리와 광장

컨테이너마다 veth 쌍이 하나씩 만들어집니다. 한쪽 끝은 컨테이너의 네트워크 namespace 안에 들어가 eth0이라는 이름을 받고, 다른 쪽 끝은 호스트에 남아 veth…라는 이름으로 docker0에 꽂힙니다.

docker0은 브리지입니다. 소프트웨어로 만든 스위치라고 보시면 돼요. 여기 꽂힌 것들끼리는 서로 통합니다. 그래서 같은 네트워크의 컨테이너끼리 IP로 직접 통신이 되는 거고요.

6장에서 만든 그 빈 namespace에 빠져 있던 게 이 veth 쌍입니다. 커널에게 “새 네트워크 namespace를 만들어줘”라고 하면 만들어만 주지, 거기에 선을 꽂아주지는 않아요. 꽂는 건 컨테이너 런타임의 일입니다.

하는 일누가 배선하나
net namespace인터페이스·주소·라우팅을 나눈다커널이 기본 제공 — 만들어만 주고 비워둔다
veth 쌍두 namespace 사이를 잇는 선런타임
docker0 브리지선들이 모이는 광장런타임
NAT 규칙바깥 주소와 안쪽 주소를 번역런타임

아래 셋이 없으면 컨테이너는 6장에서 본 그 상태입니다. lo만 있는 섬이요.

-p가 하는 일

이제 두 번째 질문입니다. -p 18090:80은 무엇을 만들까요.

흔한 오해가 “컨테이너의 포트를 연다”입니다. 아니에요. 규칙을 하나 넣는 겁니다.

# 리눅스 서버 기준 sudo iptables -t nat -S DOCKER | grep 18090 sudo iptables -t nat -S POSTROUTING | grep 172.17
=== 3. -p 18090:80 이 만든 규칙 === -A DOCKER -p tcp -m tcp --dport 18090 -j DNAT --to-destination 172.17.0.3:80

✅ 실측 (같은 환경, 같은 소스)

조각별로 읽으면 이렇습니다.

조각뜻
-A DOCKERDOCKER 체인에 규칙을 덧붙인다
-p tcp --dport 18090TCP이고 목적지 포트가 18090이면 (여기 -p는 프로토콜이지 docker run -p가 아닙니다)
-j DNAT --to-destination 172.17.0.3:80목적지 주소를 이걸로 바꿔라

그 DOCKER 체인은 어디서 불릴까요. 밖에서 들어온 패킷도, 호스트가 자기 자신에게 보낸 패킷도 이 체인을 지나도록 걸려 있습니다. 그래서 호스트에서 친 curl localhost:18090도 이 규칙에 걸려요.

DNAT가 destination NAT, 목적지 주소 변환입니다. 패킷이 호스트에 도착한 뒤 목적지 주소가 바꿔치기돼서 브리지를 타고 컨테이너로 들어가요.

그래서 “포트를 연다”보다 “주소를 바꿔 적는다”가 정확합니다. 컨테이너의 80번 포트는 처음부터 열려 있었어요. 다만 172.17.0.3이라는 주소가 바깥에서는 닿지 않는 주소였을 뿐입니다.

나가는 쪽도 규칙이 있습니다.

--- 나가는 쪽 (컨테이너 → 바깥) --- -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE … (헤어핀용 규칙이 한 줄 더 나옵니다. 컨테이너가 자기 게시 포트로 자기를 부를 때 쓰는 거라 지금 이야기와는 별개예요.)

✅ 실측 (같은 환경, 같은 소스)

172.17.0.0/16에서 출발했고 docker0으로 나가는 게 아니라면 출발지 주소를 바꿔라. MASQUERADE는 출발지를 나가는 인터페이스의 주소로 바꾸는 겁니다.

컨테이너가 바깥 인터넷에 접속할 때 상대가 보는 주소는 172.17.0.3이 아니라 호스트 주소인 이유가 이거예요. 애초에 172.17.x.x는 사설 주소라 인터넷에서 돌아올 길이 없습니다.

그래서 통합니다

두 규칙이 붙은 결과를 확인해봤습니다.

=== 4. 진짜로 통하나 === 호스트에서 localhost:18090 : 200

✅ 실측 (같은 환경, 같은 소스)

curl http://localhost:18090이 200을 받습니다.

그 200이 오기까지의 경로를 되짚으면 이렇습니다. 요청이 호스트의 18090에 도착하고, DNAT 규칙이 목적지를 172.17.0.3:80으로 고치고, 패킷이 docker0을 거쳐 vethb22eb65로 들어가고, 반대쪽 끝인 컨테이너의 eth0으로 나오고, nginx가 받습니다.

벽을 뚫은 게 아닙니다. 다리를 건너고 주소를 고쳐 적은 거예요.

언제 이게 안 통하나

두 가지를 짚어두겠습니다.

첫째, 기본 bridge에서는 IP로만 통하고 이름으로는 안 통합니다. docker0에 꽂힌 컨테이너끼리 172.17.0.3으로는 부를 수 있지만 ping web은 안 돼요. 내장 DNS는 사용자 정의 네트워크에만 붙거든요. 그래서 실무에서는 기본 bridge를 거의 안 씁니다. compose가 자동으로 만들어주는 네트워크를 쓰죠. 그 이야기가 16장입니다.

둘째, MASQUERADE는 나가는 쪽 주소를 바꿉니다. 그래서 컨테이너가 다른 컨테이너의 게시 포트로 접속하면, 받는 쪽 로그에 찍히는 클라이언트 주소가 원래 주소가 아니라 게이트웨이 주소일 수 있어요. “접속자 IP가 전부 172.17.0.1로 찍힌다”는 그 사고입니다.

아예 안 긋는 선택

네트워크 namespace는 인터페이스 목록을 나눕니다. 6장에서 확인했죠. 그런데 그 격리는 veth 쌍 하나로 이어집니다. 런타임이 그 선을 잇는 순간 패킷이 오갈 길이 생기고요.

3장에서 --network host를 주면 이 모든 게 없어집니다. namespace를 안 나누니 veth도 브리지도 NAT도 필요 없어요. 컨테이너가 호스트 인터페이스를 그대로 씁니다. 대신 포트도 그대로 공유하니 같은 포트를 두 컨테이너가 못 씁니다.

-p를 같이 주면 Docker가 경고까지 합니다. 게시 포트는 호스트 네트워크 모드에서 무시된다고요. 포트를 여는 게 아니라는 걸 Docker 자신이 말해주는 셈입니다.

선을 긋고 다리를 놓을지, 아예 안 그을지의 선택입니다. 벽이면 뚫거나 말거나뿐인데, 선은 어디에 그을지를 고를 수 있어요.

정리

  • 컨테이너의 eth0은 veth 쌍의 한쪽 끝입니다. 반대쪽 끝은 호스트에 veth…라는 이름으로 남아 docker0 브리지에 꽂힙니다.
  • 실측에서 컨테이너의 eth0@if196을 따라가니 호스트의 vethb22eb65가 나왔고, 그게 master docker0이었습니다.
  • 컨테이너의 기본 게이트웨이 172.17.0.1이 곧 docker0의 주소입니다.
  • 기본 bridge는 IP로만 통하고 이름으로는 안 통합니다. 내장 DNS는 사용자 정의 네트워크에만 붙어요(16장).
  • 6장에서 만든 빈 네트워크 namespace에 빠져 있던 게 이 veth 쌍입니다. 커널은 namespace만 만들고, 선을 꽂는 건 런타임의 일입니다.
  • -p 18090:80은 포트를 여는 게 아니라 DNAT 규칙을 넣는 것입니다. “18090으로 온 것은 172.17.0.3:80으로 보내라.”
  • 나가는 쪽에는 MASQUERADE 규칙이 붙어 출발지 주소가 호스트 주소로 바뀝니다. 172.17.x.x는 사설 주소라 그대로는 인터넷에서 돌아올 길이 없어요.
  • --network host를 주면 namespace도 veth도 브리지도 NAT도 없습니다. 대신 포트를 호스트와 공유합니다.

생각해볼 질문

2부가 거의 끝났습니다. 컨테이너가 그냥 프로세스라는 걸 보고(5장), 선을 긋고(6장), 제한을 걸고(7장), 파일시스템을 넣고(8장), 선을 이었습니다(9장).

그런데 8장에서 알아낸 것 하나가 마음에 걸립니다. 컨테이너를 지우면 쓰기 층이 통째로 사라진다는 것이요.

로그는 그렇다 쳐도, 데이터베이스가 컨테이너 안에서 돈다면 어떨까요. 컨테이너를 새 이미지로 교체하는 순간 데이터가 없어집니다. 배포할 때마다 DB가 초기화되는 셈이에요.

그럼 사라지면 안 되는 것은 어디에 둬야 할까요. 그리고 그 “어디”에 둔 파일은 누구 소유가 될까요. 5장에서 컨테이너의 root와 호스트의 root가 같은 사용자라고 했던 게 여기서 걸립니다.

▶ 10장. 사라지는 쓰기, 남는 쓰기 — 컨테이너 레이어·볼륨·바인드 마운트

Last updated on