Skip to content

필사 모드: 도커 네트워크 모드와 연결 문제 — bridge, host, overlay, 그리고 127.0.0.1의 함정

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — 컨테이너는 떴는데 접속이 안 된다

docker ps는 Up이라고 말합니다. 로그에도 Server listening on port 3000이 찍혀 있습니다. 그런데 브라우저에서 열면 연결이 거부됩니다. 옆 컨테이너에서 curl http://api:3000을 하면 이름을 못 찾습니다. 컨테이너 안에서 curl http://127.0.0.1:5432로 호스트의 데이터베이스에 붙으려 했더니 아무것도 없다고 나옵니다.

세 증상 모두 애플리케이션 버그가 아닙니다. 컨테이너가 자기만의 네트워크 네임스페이스를 갖는다는 사실 하나에서 전부 따라 나오는 결과입니다. 네트워크 네임스페이스는 인터페이스와 라우팅 테이블뿐 아니라 루프백 주소와 포트 번호 공간까지 통째로 복제합니다. 컨테이너의 127.0.0.1은 호스트의 127.0.0.1과 완전히 다른 주소입니다.

이 글은 모드별 동작을 먼저 정리하고, 그 위에서 위 세 증상을 각각 해부합니다.

다섯 가지 모드가 실제로 하는 일

docker run --network가 받는 값은 다섯 종류입니다. 이름만 외우면 헷갈리므로 각각이 네트워크 네임스페이스를 어떻게 다루는지로 이해하는 편이 낫습니다.

bridge는 기본값입니다. 컨테이너마다 새 네임스페이스를 만들고, veth 쌍의 한쪽을 컨테이너에 넣고 다른 쪽을 호스트의 브리지에 붙입니다.

docker run -d --name web nginx:1.27
ip -br link show type bridge
ip -br link | grep veth
docker0          UP             02:42:8f:1c:03:aa <BROADCAST,MULTICAST,UP,LOWER_UP>
veth3a91c7e@if12 UP             ba:19:7c:2e:44:81 <BROADCAST,MULTICAST,UP,LOWER_UP>
docker exec web ip -br addr
lo               UNKNOWN        127.0.0.1/8
eth0@if13        UP             172.17.0.2/16

host는 네임스페이스를 아예 만들지 않고 호스트의 스택을 그대로 씁니다. NAT가 없으니 오버헤드가 사라지지만 포트 충돌이 그대로 생기고, -p 옵션은 무시됩니다. 리눅스에서만 의미가 있고 Docker Desktop에서는 동작이 다릅니다.

docker run --rm --network host alpine:3.20 ip -br addr | head -3
lo               UNKNOWN        127.0.0.1/8
enp5s0           UP             10.0.4.17/24
docker0          UP             172.17.0.1/16

none은 네임스페이스를 만들되 루프백만 둡니다. 네트워크가 필요 없는 배치 작업이나 격리된 실행 환경에 씁니다.

container:NAME은 다른 컨테이너의 네임스페이스에 합류합니다. 두 컨테이너가 같은 인터페이스와 같은 루프백을 공유하므로 서로를 127.0.0.1로 부를 수 있습니다.

docker run -d --name app myapp:v1
docker run -d --name sidecar --network container:app envoyproxy/envoy:v1.31-latest
docker exec sidecar ip -br addr
lo               UNKNOWN        127.0.0.1/8
eth0@if17        UP             172.17.0.4/16

쿠버네티스 파드가 바로 이 구조입니다. pause 컨테이너가 네임스페이스를 소유하고 나머지가 합류하기 때문에 같은 파드의 컨테이너끼리 루프백으로 통신할 수 있습니다.

overlay는 여러 호스트에 걸친 가상 L2 네트워크입니다. VXLAN으로 패킷을 캡슐화해 노드 사이를 넘나들고, Swarm이나 외부 키밸류 저장소가 필요합니다.

모드네임스페이스컨테이너 IP이름 해석포트 퍼블리싱주 용도
기본 bridge새로 생성172.17.0.0/16안 됨필요단독 컨테이너
사용자 정의 bridge새로 생성네트워크별 서브넷내장 DNS필요단일 호스트 다중 서비스
host호스트 공유호스트 IP호스트 설정무시됨고성능, 포트 스캐너
none새로 생성루프백만없음불가완전 격리
container:NAME대상과 공유대상과 동일대상과 동일대상이 소유사이드카
overlay새로 생성오버레이 서브넷내장 DNS + VIP라우팅 메시멀티 호스트

기본 bridge에서 컨테이너가 서로를 이름으로 못 찾는 이유

가장 흔한 첫 좌절입니다.

docker run -d --name db postgres:16
docker run --rm alpine:3.20 ping -c1 db
ping: bad address 'db'

두 컨테이너는 같은 docker0 브리지에 있고 IP로는 통신됩니다. 안 되는 것은 이름 해석뿐입니다. 기본 bridge 네트워크에는 도커의 내장 DNS가 붙지 않기 때문입니다. 예전에는 --link/etc/hosts에 항목을 넣어 해결했지만, 이 기능은 오래전에 레거시로 지정되었습니다.

사용자 정의 네트워크를 만들면 상황이 달라집니다.

docker network create app-net
docker run -d --name db --network app-net postgres:16
docker run --rm --network app-net alpine:3.20 ping -c1 db
PING db (172.19.0.2): 56 data bytes
64 bytes from 172.19.0.2: seq=0 ttl=64 time=0.089 ms

차이는 컨테이너 안의 리졸버 설정에 그대로 드러납니다.

docker run --rm --network app-net alpine:3.20 cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0

127.0.0.11은 컨테이너 네임스페이스 안에만 존재하는 주소이고, 그 네임스페이스의 NAT 규칙이 이 요청을 도커 데몬의 리졸버로 넘깁니다. 데몬은 같은 네트워크에 속한 컨테이너 이름과 별칭을 알고 있으므로 답을 돌려주고, 모르는 이름은 호스트의 업스트림 DNS로 넘깁니다.

별칭을 여러 개 붙일 수도 있습니다. 서비스 이름을 바꾸는 동안 구 이름을 유지할 때 유용합니다.

docker run -d --name api-v2 \
  --network app-net \
  --network-alias api \
  --network-alias backend \
  api:v312

Compose를 쓰면 이 모든 것이 자동입니다. Compose는 프로젝트마다 사용자 정의 네트워크를 만들고 서비스 이름을 별칭으로 등록합니다. 그래서 Compose에서는 잘 되던 것이 docker run으로 옮기면 안 되는 일이 생깁니다.

services:
  api:
    image: api:v312
    environment:
      DATABASE_URL: postgres://app@db:5432/app   # 'db'가 서비스 이름
  db:
    image: postgres:16

결론은 단순합니다. 기본 bridge는 하위 호환을 위해 남아 있는 것이고, 실무에서는 항상 사용자 정의 네트워크를 만들어 씁니다.

포트 퍼블리싱의 실체 — iptables DNAT와 바인딩 주소

-p 8080:80이 하는 일은 마법이 아니라 NAT 규칙 한 줄입니다.

docker run -d --name web -p 8080:80 nginx:1.27
sudo iptables -t nat -S DOCKER
-N DOCKER
-A DOCKER -i docker0 -j RETURN
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

호스트의 8080으로 들어온 TCP 패킷의 목적지가 172.17.0.2:80으로 바뀝니다. 여기에 더해 도커는 docker-proxy라는 사용자 공간 프로세스를 함께 띄웁니다.

ps -eo pid,args | grep docker-proxy | head -1
  52104 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80

이 프로세스는 호스트 자신에서 오는 루프백 트래픽처럼 NAT 규칙이 적용되지 않는 경로를 처리합니다. 대부분의 트래픽은 iptables가 처리하고 docker-proxy는 보조 역할입니다.

-host-ip 0.0.0.0이 눈에 띄어야 합니다. 기본 퍼블리싱은 호스트의 모든 인터페이스에 바인딩합니다. 사내망이든 퍼블릭 IP든 전부 열립니다. 로컬 개발용 데이터베이스를 이렇게 열어 두고 인터넷에 노출된 사례는 지금도 계속 나옵니다.

# 위험: 모든 인터페이스에 노출
docker run -d -p 5432:5432 postgres:16

# 안전: 호스트 루프백에서만 접근 가능
docker run -d -p 127.0.0.1:5432:5432 postgres:16
ss -tlnp | grep -E '5432'
LISTEN 0  4096  127.0.0.1:5432  0.0.0.0:*  users:(("docker-proxy",pid=52310,fd=4))

원격 개발 서버라면 여기에 SSH 포워딩을 얹는 것이 정석입니다.

ssh -N -L 5432:127.0.0.1:5432 dev@build-01.internal

컨테이너끼리만 통신하면 되는 서비스는 아예 퍼블리싱하지 않는 것이 맞습니다. 같은 사용자 정의 네트워크에 있으면 이름으로 붙을 수 있으므로 -p가 필요 없습니다. Compose의 expose는 문서화 용도일 뿐 호스트에 포트를 열지 않습니다.

컨테이너 안의 127.0.0.1은 호스트가 아니다

초보자가 가장 많이 밟는 지점입니다. 로컬에서 개발할 때 데이터베이스는 호스트에서 돌리고 애플리케이션만 컨테이너로 띄우면, 익숙한 연결 문자열이 그대로 실패합니다.

docker run --rm --network app-net api:v312 \
  sh -c 'nc -zv 127.0.0.1 5432'
nc: 127.0.0.1 (127.0.0.1:5432): Connection refused

컨테이너의 루프백은 컨테이너 자신입니다. 호스트에 붙으려면 호스트를 가리키는 다른 주소가 필요합니다.

Docker Desktop이라면 특수 이름이 이미 준비되어 있습니다.

docker run --rm alpine:3.20 \
  sh -c 'apk add -q bind-tools && dig +short host.docker.internal'
192.168.65.254

리눅스에서는 자동으로 제공되지 않으므로 명시적으로 추가합니다.

docker run --rm --add-host=host.docker.internal:host-gateway \
  alpine:3.20 getent hosts host.docker.internal
172.17.0.1        host.docker.internal

host-gateway는 도커가 브리지의 게이트웨이 주소로 치환해 주는 예약어입니다. Compose에서도 같은 방식으로 씁니다.

services:
  api:
    image: api:v312
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      DATABASE_URL: postgres://app@host.docker.internal:5432/app

한 가지 더 확인해야 합니다. 호스트의 서비스가 127.0.0.1에만 바인딩되어 있으면 브리지 게이트웨이 주소로는 접근할 수 없습니다. PostgreSQL이라면 listen_addresses를 조정하거나, 컨테이너의 서브넷만 허용하도록 pg_hba.conf를 손봐야 합니다. 호스트 서비스를 0.0.0.0으로 여는 순간 방화벽 확인이 함께 필요해집니다.

애플리케이션이 루프백에만 바인딩할 때

두 번째로 흔한 증상입니다. 컨테이너는 떴고 포트도 퍼블리싱했는데 접속이 거부됩니다.

docker run -d --name api -p 3000:3000 api:v312
curl -sS -m 3 http://localhost:3000/healthz
curl: (52) Empty reply from server

컨테이너 내부에서 실제 리스닝 주소를 확인합니다.

docker exec api sh -c 'ss -tlnp'
State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port  Process
LISTEN  0       511          127.0.0.1:3000        0.0.0.0:*      users:(("node",pid=1,fd=20))

애플리케이션이 컨테이너의 루프백에만 바인딩했습니다. 호스트에서 들어온 패킷은 eth0, 즉 172.17.0.2로 도착하므로 이 소켓에 닿지 못합니다. 올바른 값은 0.0.0.0입니다.

LISTEN  0  511  0.0.0.0:3000  0.0.0.0:*  users:(("node",pid=1,fd=20))

프레임워크마다 기본값이 다릅니다. 개발 편의를 위해 루프백을 기본으로 두는 도구가 많아서 컨테이너로 옮길 때 반드시 걸립니다.

# Node / Express
node -e "require('express')().listen(3000, '0.0.0.0')"

# Flask
flask run --host=0.0.0.0 --port=5000

# Django
python manage.py runserver 0.0.0.0:8000

# Rails
bin/rails server -b 0.0.0.0 -p 3000

# Vite
vite --host 0.0.0.0

# Next.js
next start -H 0.0.0.0 -p 3000

# Go: net/http
# http.ListenAndServe(":8080", nil)  <- 이미 모든 인터페이스

여기서 자주 나오는 걱정은 "0.0.0.0으로 열면 위험한 것 아닌가"입니다. 컨테이너 안에서의 0.0.0.0은 그 컨테이너의 네트워크 네임스페이스 전체를 뜻할 뿐이고, 외부 노출 여부는 앞 절의 퍼블리싱 바인딩 주소가 결정합니다. 애플리케이션은 컨테이너 안에서 0.0.0.0에 바인딩하고, 노출 범위는 호스트 쪽 바인딩 주소로 통제합니다. 두 계층을 섞으면 둘 다 틀립니다.

방화벽, MTU, 오버레이 — 운영에서 만나는 것들

로컬에서는 안 나오고 서버에서만 나오는 문제가 세 종류 있습니다.

첫째, 호스트 방화벽이 도커 포트를 막지 못합니다. UFW로 8080을 막아 두었는데 외부에서 붙는 상황입니다.

sudo ufw status | head -4
Status: active
To                         Action      From
--                         ------      ----
8080/tcp                   DENY        Anywhere
curl -sS -o /dev/null -w '%{http_code}\n' http://203.0.113.42:8080/
200

이유는 체인의 위치입니다. UFW의 규칙은 대부분 INPUT 체인에 들어갑니다. 그런데 컨테이너로 향하는 패킷은 호스트가 최종 목적지가 아니므로 INPUT이 아니라 FORWARD 체인을 지나고, 그보다 앞서 nat 테이블의 PREROUTING에서 이미 DNAT로 목적지가 바뀝니다. UFW가 검사할 기회가 없습니다.

해결책은 셋입니다. 가장 안전하고 단순한 것은 앞서 본 바인딩 주소 제한입니다. 외부 노출이 필요한 경우에는 도커가 사용자 규칙을 위해 비워 둔 DOCKER-USER 체인에 직접 넣습니다.

sudo iptables -I DOCKER-USER -i enp5s0 ! -s 10.0.0.0/8 -p tcp --dport 8080 -j DROP
sudo iptables -t filter -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -i enp5s0 ! -s 10.0.0.0/8 -p tcp -m tcp --dport 8080 -j DROP
-A DOCKER-USER -j RETURN

DOCKER-USER는 도커가 재시작해도 보존되며 도커의 자동 생성 규칙보다 먼저 평가됩니다. 세 번째 선택지인 iptables: false는 도커가 NAT 규칙을 아예 만들지 않게 하지만 컨테이너 아웃바운드까지 직접 설정해야 하므로 권장하지 않습니다. 최근 도커 버전은 기본 필터링을 강화하고 있으므로, 실제 동작은 반드시 사용 중인 버전에서 직접 확인해야 합니다.

둘째, MTU 문제입니다. 증상이 아주 특징적입니다. TCP 연결은 맺어지고 작은 요청은 성공하는데 큰 응답이나 TLS 핸드셰이크에서 멈춥니다.

docker exec api curl -sS -o /dev/null -w '%{http_code}\n' https://api.partner.example.com/v1/ping
200
docker exec api curl -sS -o /dev/null -w '%{http_code}\n' https://api.partner.example.com/v1/bulk
curl: (28) Operation timed out after 30001 milliseconds with 0 bytes received

원인은 캡슐화입니다. 오버레이 네트워크는 VXLAN 헤더로 50바이트를 더 쓰므로 실제 가용 MTU가 1450으로 줄어듭니다. 여기에 IPsec이나 WireGuard가 겹치면 더 줄어듭니다. 그런데 컨테이너 인터페이스가 여전히 1500이면 큰 패킷이 조각화되어야 하고, 경로상에서 ICMP가 차단되어 있으면 송신 측이 이를 알 수 없어 그대로 멈춥니다. 이것이 블랙홀 방식의 MTU 문제입니다.

진단은 조각화 금지 플래그를 붙인 핑으로 합니다.

docker exec api ping -M do -s 1472 -c 2 api.partner.example.com
PING api.partner.example.com (203.0.113.90) 1472(1500) bytes of data.
ping: local error: message too long, mtu=1450
docker exec api ping -M do -s 1422 -c 2 api.partner.example.com
1430 bytes from 203.0.113.90: icmp_seq=1 ttl=52 time=11.8 ms

1422바이트는 통과하고 1472는 통과하지 않으므로 실제 MTU는 1450입니다. 네트워크 생성 시 MTU를 명시하면 해결됩니다.

docker network create \
  --driver bridge \
  --opt com.docker.network.driver.mtu=1450 \
  app-net
// /etc/docker/daemon.json — 기본 브리지에 적용
{
  "mtu": 1450
}

셋째, 오버레이 네트워크 자체의 요구사항입니다. Swarm 오버레이는 노드 사이에 세 종류의 통신이 열려 있어야 합니다. 클러스터 관리에 2377/tcp, 노드 간 가십 통신에 7946/tcp와 7946/udp, VXLAN 데이터 평면에 4789/udp입니다. 보안 그룹에서 4789/udp 하나가 막혀 있으면 서비스 디스커버리는 되는데 실제 트래픽만 안 흐르는 혼란스러운 상태가 됩니다.

docker network create --driver overlay --attachable --opt encrypted app-mesh
docker service create --name api --network app-mesh --replicas 3 api:v312
docker network inspect app-mesh --format '{{json .Containers}}' | jq 'keys | length'
3

--opt encrypted를 켜면 IPsec으로 노드 간 트래픽이 암호화되지만, 헤더가 더해져 유효 MTU가 다시 줄어듭니다. 앞의 MTU 계산을 다시 해야 합니다.

문제 위치를 좁히는 순서는 항상 같습니다. 컨테이너 안에서 리스닝 주소를 보고, 같은 네트워크의 다른 컨테이너에서 이름과 IP로 각각 붙어 보고, 호스트에서 퍼블리싱된 포트로 붙어 보고, 마지막으로 외부에서 시도합니다. 어느 단계에서 끊기는지가 곧 원인입니다.

docker exec api ss -tlnp                                  # 1. 리스닝 주소
docker run --rm --network app-net alpine:3.20 \
  sh -c 'nc -zv api 3000; nc -zv 172.19.0.3 3000'         # 2. 이름/IP 해석
curl -sS -m 3 http://127.0.0.1:3000/healthz               # 3. 호스트에서
curl -sS -m 3 http://203.0.113.42:3000/healthz            # 4. 외부에서

마치며 — 연결 문제는 네 단계 중 어디서 끊겼는지의 문제다

도커 네트워킹에서 기억할 문장은 하나입니다. 컨테이너는 자기만의 네트워크 스택을 가지므로, 주소는 항상 "누구의 관점에서" 인지를 붙여서 읽어야 합니다. 컨테이너의 127.0.0.1, 호스트의 127.0.0.1, 브리지 게이트웨이, 그리고 외부에서 본 호스트 IP는 전부 다른 것입니다.

실무 기본값도 짧게 정리됩니다. 사용자 정의 네트워크를 만들어 이름 해석을 얻고, 애플리케이션은 컨테이너 안에서 0.0.0.0에 바인딩하고, 외부 노출은 호스트 쪽 바인딩 주소로만 통제하고, 컨테이너끼리만 쓰는 포트는 퍼블리싱하지 않습니다. 그리고 호스트 방화벽이 도커 포트를 막아 줄 것이라고는 절대 가정하지 않습니다.

현재 단락 (1/176)

`docker ps`는 Up이라고 말합니다. 로그에도 `Server listening on port 3000`이 찍혀 있습니다. 그런데 브라우저에서 열면 연결이 거부됩니다. 옆 컨...

작성 글자: 0원문 글자: 9,217작성 단락: 0/176