내부망에서 한 PC에서만 서버 접속이 안 되는 문제: tcpdump로 찾은 Docker 네트워크 대역 충돌
배경
내부망 운영 환경에서 Docker Compose로 배포한 서버에 특정 PC 한 대만 접속하지 못하는 문제가 발생했습니다. 다른 PC에서는 잘 접속되는데, 이 PC에서 접속하면 타임아웃 오류가 났습니다.
편의상 이 글에서는 서버를 목적지, 접속이 안 되는 PC를 발신지라고 부르겠습니다.
원인 탐색
오류의 원인을 찾기 위해 확인한 사실은 다음과 같습니다.
- 다른 PC에서는 목적지에 잘 접속됩니다.
- 발신지에서 목적지가 아닌 다른 서버에는 잘 접속됩니다.
- 발신지에서 요청을 보내도 목적지의 애플리케이션 로그에는 요청 기록이 남지 않습니다.
목적지나 발신지 어느 한 쪽이 장애가 난 것이 아니라, 발신지에서 목적지로 접속할 때만 문제가 발생합니다.
그리고 목적지의 애플리케이션 로그에서도 단서를 찾을 수 없었습니다. 그래서 네트워크 단에서 패킷의 이동을 살펴보기 위해 tcpdump 명령을 사용했습니다.
tcpdump란
tcpdump는 네트워크 인터페이스를 통과하는 패킷을 캡처해 보여주는 도구입니다. 어떤 패킷이 서버에 들어왔는지, 서버가 패킷을 어느 인터페이스로 내보냈는지처럼 애플리케이션 로그로는 알 수 없는 네트워크 단의 내용을 볼 수 있습니다.
# 모든 인터페이스에서 특정 IP와 주고받는 패킷을 캡처
sudo tcpdump -i any -nn host <IP>
옵션 설명
| -i <인터페이스> | 캡처할 인터페이스 지정. any를 주면 모든 인터페이스를 캡처합니다. |
| -nn | IP와 포트를 숫자 그대로 출력합니다. |
| host <IP> | 해당 IP가 출발지이거나 목적지인 패킷만 캡처합니다. 해당 IP를 찾는 ARP도 포함됩니다. |
패킷이 이동하는 과정
tcpdump 출력을 해석하려면 서버가 패킷을 어느 인터페이스로 내보낼지 정하는 과정을 알아야 합니다. 서버는 패킷을 내보내기 전에 목적지 IP로 라우팅 테이블을 조회하고, 그 결과에 따라 다음과 같이 처리합니다.
- 목적지 IP와 같은 대역의 인터페이스가 하나 있는 경우: 그 인터페이스로 "이 IP를 쓰는 장비의 MAC 주소를 알려달라" 는 ARP 요청을 보내고, 응답받은 MAC 주소로 패킷을 보냅니다.
- 목적지 IP와 같은 대역의 인터페이스가 여러개인 경우: 대역이 가장 좁은, 즉 프리픽스가 가장 긴 쪽의 인터페이스를 선택합니다. 예를 들어 10.0.0.0/8과 10.1.0.0/16이 모두 해당하면 /16 쪽을 선택합니다. 이후 과정은 위와 같습니다.
- 목적지 IP와 같은 대역의 인터페이스가 없는 경우: 기본 게이트웨이로 패킷을 보내고, 이후의 전달은 게이트웨이에 맡깁니다.
tcpdump 캡처 내용
캡처에 등장하는 장비의 IP는 다음과 같습니다.
| 목적지 서버 | 10.10.1.20 (게이트웨이 10.10.1.1) |
| 정상 접속되는 PC | 10.10.2.31 |
| 발신지 PC | 172.18.5.10 |
먼저 접상 접속되는 PC의 요청을 살펴보니, 아래와 같이 3-way handshake가 바로 완료되었습니다. 서버의 응답인 SYN-ACK([S.])는 물리 인터페이스 eth0으로 나갔습니다. PC와 서버가 다른 대역에 있으므로 응답은 게이트웨이를 거쳐 전달되며, PC의 ACK([.])가 돌아온 것으로 보아 잘 도착했습니다.
$ sudo tcpdump -i eth0 -nn host 10.10.2.31 and port 8080
IP 10.10.2.31.50122 > 10.10.1.20.8080: Flags [S], seq 2850113604, win 64240, length 0
IP 10.10.1.20.8080 > 10.10.2.31.50122: Flags [S.], seq 1193046071, ack 2850113605, win 64240, length 0
IP 10.10.2.31.50122 > 10.10.1.20.8080: Flags [.], ack 1, win 1026, length 0
그러나 문제가 된 발신지의 요청은 달랐습니다.
$ sudo tcpdump -i any -nn host 172.18.5.10
eth0 In IP 172.18.5.10.52814 > 10.10.1.20.8080: Flags [S], seq 3021884411, win 64240, length 0
br-3f9c1a2b4d5e Out IP 172.18.5.10.52814 > 172.18.0.2.8080: Flags [S], seq 3021884411, win 64240, length 0
br-3f9c1a2b4d5e B ARP, Request who-has 172.18.5.10 tell 172.18.0.2, length 28
br-3f9c1a2b4d5e B ARP, Request who-has 172.18.5.10 tell 172.18.0.2, length 28
br-3f9c1a2b4d5e B ARP, Request who-has 172.18.5.10 tell 172.18.0.2, length 28
- 첫째 줄: 발신지 PC의 SYN 패킷이 eth0으로 들어왔습니다(In), 요청은 서버까지는 문제없이 도착했습니다.
- 둘째 줄: 목적지 IP가 172.18.0.2로 바뀐 패킷이 br-3f9c1a2b4d5e 인터페이스로 나갑니다(Out). Docker가 8080포트로 들어온 요청을 그 포트에 매핑된 컨테이너로 전달한 것입니다 (Compose 파일의 ports: "8080:8080" 설정).
- 나머지 줄: SYN-ACK는 보이지 않습니다. 대신 컨테이너(172.18.0.2)가 같은 브리지 인터페이스에서 172.18.5.10의 MAC 주소를 알려달라"는 ARP 요청을 브로드캐스트(B)로 보내고 있고, 응답이 없어 같은 요청을 반복합니다.
앞서 설명한 규칙에 따르면, ARP 요청을 보낸다는 것은 컨테이너가 발신지를 자신과 같은 대역의 이웃으로 판단했다는 뜻입니다.
그래서 SYN-ACK를 게이트웨이로 보내지 않고 발신지의 MAC 주소부터 찾았지만, 브리지 네트워크 안에는 그 IP를 쓰는 장비가 없어 응답을 받지 못했습니다.
이것으로 처음의 두 증상이 모두 설명됩니다. SYN-ACK가 발신지에 전달되지 않으니 TCP 연결이 맺어지지 않았고, 발신지는 응답을 기다리다 타임아웃이 났습니다. 연결이 맺어지지 않았으니 애플리케이션에서는 요청 자체가 들어오지 않아 로그도 남지 않습니다.
원인: Docker 브리지 네트워크와 겹친 대역
이름이 "br-"로 시작하는 인터페이스는 Docker가 컨테이너 네트워크용으로 만든 브리지 인터페이스입니다. "br-" 뒤의 문자열은 Docker 네트워크 ID의 앞 12자리이므로, 이 ID로 어떤 네트워크인지 구분할 수 있습니다.
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
3f9c1a2b4d5e myapp_default bridge local
$ docker network inspect myapp_default --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
172.18.0.0/16
Docker Compose 를 실행했을때 자동으로 만들어진 인터페이스였고, 대역은 172.18.0.0/16 이었습니다. 발신지의 IP인 172.18.5.10가 바로 이 대벽에 포함됩니다. 그래서 발신지로 가야 할 응답이 서버 밖으로 나가지 못하고 Docker 네트워크 안에서 발신지를 찾다가 전달되지 못했던 것입니다.
서버 라우팅 테이블에서도 이를 확인할 수 있습니다. ip route get은 해당 IP로 가는 패킷이 실제로 어느 경로로 나가는지 보여줍니다.
$ ip route
default via 10.10.1.1 dev eth0
10.10.1.0/24 dev eth0 proto kernel scope link src 10.10.1.20
172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1 linkdown
172.18.0.0/16 dev br-3f9c1a2b4d5e proto kernel scope link src 172.18.0.1
$ ip route get 10.10.2.31
10.10.2.31 via 10.10.1.1 dev eth0 src 10.10.1.20
$ ip route get 172.18.5.10
172.18.5.10 dev br-3f9c1a2b4d5e src 172.18.0.1
정상 접속되는 PC로 보내는 패킷은 게이트웨이(via 10.10.1.1)를 거쳐 eth0으로 나가지만, 발신지로 가는 패킷은 게이트웨이가 아니라 Docker 브리지(br-3f9c1a2b4d5e)로 나갑니다.
실제로 응답을 보내는 컨테이너도 마찬가지입니다. 컨테잉너 라우팅 테이블에도 172.18.0.0/16이 있어서, 컨테이너는 172.18.5.10을 같은 네트워크의 이웃으로 보고 응답을 게이트웨이로 보내지 않고 ARP로 찾습니다. 그러나 이 네트워크에는 그 IP를 쓰는 장비가 없어 ARP 응답이 오지 않았고, 결국 SYN-ACK를 발신지로 보내지 못했습니다.
Docker는 네트워크를 만들 때 호스트가 이미 쓰고 있는 대역은 피하려고 하지만, 게이트웨이 너머에서 쓰이는 대역까지는 알지 못합니다. 게다가 Docker가 기본으로 할당하는 대역이 172.17.0.0/16부터 시작하는 사설 IP 대역이어서, 내부망에서 쓰는 사설 IP와 대역이 겹치는 일이 생깁니다. (Docker 문서)
Networking overview
Learn how networking works from the container's point of view
docs.docker.com
해결
Docker Compose 파일에서 네트워크 대역을 내부망과 겹치지 않는 대역으로 지정해 문제를 해결했습니다. 그리고 나중에 내부망에서 이 대역을 쓰게 되면 같은 문제가 다시 생기므로, Docker 네트워크용으로 정한 대역은 내부망의 PC와 서버에 할당하지 않도록 정책으로 정했습니다.
Compose 파일에서 네트워크 대역 지정
services:
app:
image: myapp:latest
ports:
- "8080:8080"
networks:
default:
ipam:
config:
- subnet: 10.250.1.0/24
default는 Compose가 프로젝트마다 자동으로 만드는 기본 네트워크입니다. 여기에 ipam.config.subnet을 지정하면 Docker가 대역을 자동으로 고르지 않고 지정한 대역으로 네트워크를 만듭니다.
다만, 이미 만들어진 네트워크의 대역은 바뀌지 않으므로, 네트워크를 지우고 다시 만들어야 설정이 적용됩니다. 아래와 같이 Compose를 내렸다가 다시 올린 뒤 확인해보니, 네트워크가 새 대역으로 만들어졌고 발신지로 응답하는 패킷도 게이트웨이를 거쳐 eth0으로 나가게 되었습니다.
$ docker compose down
$ docker compose up -d
$ docker network inspect myapp_default --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
10.250.1.0/24
$ ip route get 172.18.5.10
172.18.5.10 via 10.10.1.1 dev eth0 src 10.10.1.20
발신지 PC에서 목적지로 접속하는 것도 정상적으로 되었습니다.
서버 전체에 적용하기
Compose 프로젝트가 여러 개라면 Compose 파일마다 대역을 적는 대신, Docker 데몬이 자동 할당하는 대역의 범위를 지정할 수도 있습니다. /etc/docker/daemon.json 에 default-address-pools를 설정하고 Docker 데몬을 재시작하면, 이후 새로 만들어지는 네트워크는 이 범위 안에서 대역을 할당받습니다.
{
"default-address-pools": [
{ "base": "10.250.0.0/16", "size": 24 }
]
}
위 설정은 10.250.0.0/16를 /24 단위로 나눠 네트워크마다 하나씩 할당합니다. 이미 만들어진 대역은 바뀌지 않으므로, 앞에서와 같이 지우고 다시 만들어야 합니다.
기본 브리지인 docker0의 대역은 아래처럼 bip 옵션으로 따로 지정할 수 있습니다.
{
"bip": "10.250.255.1/24",
"default-address-pools": [
{ "base": "10.250.0.0/16", "size": 24 }
]
}
마치며
내부망의 특정 PC 한 대만 서버에 접속하지 못하던 문제는, 그 PC의 IP가 서버의 Docker 네트워크 대역에 포함되어 응답이 서버 밖으로 나가지 못한 것이 원인이었습니다. Docker 네트워크 대역을 내부망과 겹치지 않게 지정해 해결했습니다.
이번 일에서 두 가지를 배웠습니다.
- 내부망 서버에서 Docker를 사용할때는 Docker 네트워크의 대역과 내부망 장비에 할당하는 대역이 겹치지 않도록 미리 정해 두어야 이번과 같은 문제를 예방할 수 있습니다.
- 애플리케이션 로그에 단서가 없을때는, tcpdump로 네트워크단의 패킷이 어떻게 이동하는지 확인하는 것이 원인을 찾는 데 도움이 될 수 있습니다.