본문 바로가기
빌드노트 검색

빌드노트

옆 컨테이너가 떠 있는데 localhost 연결이 거절되는 이유

앱 컨테이너의 localhost는 옆 서버를 가리키지 않는다. 전용 bridge에서 DNS 해석과 HTTP 연결, 127.0.0.1·0.0.0.0 bind를 실제 비교하고, 상대 이름·컨테이너 포트·수신 주소를 확인하는 순서를 설명한다.

앱 컨테이너에서 http://localhost:8000으로 옆 컨테이너의 API를 호출하면, API가 실행 중이어도 연결이 거절될 수 있다. 각자 네트워크 공간을 가진 컨테이너에서 localhost는 요청을 보내는 컨테이너 자신을 가리킨다. 같은 PC에서 실행된다는 이유로 옆 컨테이너까지 찾아가지는 않는다.

두 컨테이너를 같은 사용자 정의 bridge에 연결하고, 앱의 접속 주소를 상대 컨테이너 이름 또는 네트워크 별칭과 컨테이너 내부 포트로 맞춘다. 다만 이름을 고쳐도 상대 서버가 127.0.0.1에서만 듣고 있으면 여전히 연결되지 않는다. 요청의 목적지와 서버의 수신 주소를 함께 봐야 한다. Docker bridge의 이름 해석과 통신 범위 (새 탭에서 열림)

이름은 풀리는데 연결은 거절됐다

이 차이를 확인하려고 기존 서비스와 분리한 내부 bridge에 클라이언트 하나와 HTTP 서버 둘을 만들었다. Windows의 Docker Desktop 4.92.0, Linux 컨테이너용 Docker Engine 29.8.0, Python 3.12.15를 사용했다. 호스트 포트는 공개하지 않았다. 두 서버에는 각각 server-loop, server-all이라는 네트워크 별칭을 붙였다.

두 서버는 같은 이미지와 8000 포트를 사용했다. 시작 명령에서 bind 주소만 달랐다. 아래 명령은 각각 해당 서버 컨테이너 안에서 실행된 프로세스의 인자다.

python -u -m http.server 8000 --bind 127.0.0.1 --directory /tmp
python -u -m http.server 8000 --bind 0.0.0.0 --directory /tmp

--bind는 서버가 요청을 받을 주소를 정한다. 첫 서버는 자기 loopback에서만 듣고, 두 번째 서버는 컨테이너의 모든 IPv4 인터페이스에서 듣도록 했다. Python http.server의 bind 옵션 (새 탭에서 열림)

HTTP 요청과 DNS 해석을 실제로 비교한 결과는 다음과 같았다.

  • 클라이언트의 localhost:8000 요청은 ConnectionRefusedError: [Errno 111] Connection refused로 끝났다. 이 클라이언트에는 8000 포트의 서버가 없었다. 127.0.0.1:8000을 직접 지정해도 같은 오류였다.
  • 클라이언트에서 server-loop는 172.27.0.2로 해석됐다. 그래도 server-loop:8000 요청은 같은 연결 거절이었다. 반면 이 서버 안에서 127.0.0.1:8000으로 요청하면 HTTP 200을 받았다.
  • 클라이언트에서 server-all은 172.27.0.3으로 해석됐고, server-all:8000 요청은 HTTP 200을 받았다. 이 서버 안에서 127.0.0.1:8000으로 요청해도 200이었다.

IP는 이번 실행에서 배정된 값이다. 설정에 이 IP를 복사할 필요는 없다. 사용자 정의 bridge에서는 컨테이너 이름과 별칭을 Docker가 해석해 준다. Docker가 자동으로 만드는 기본 bridge의 이름 해석 조건과는 다르다. 사용자 정의 bridge와 기본 bridge의 차이 (새 탭에서 열림)

첫 실패는 클라이언트 자신을 목적지로 삼았기 때문이다. 두 번째 실패는 이름 해석 뒤 도착하는 서버의 bridge 주소에서 프로세스가 듣지 않았기 때문이다. 서버의 TCP 리스닝 기록도 각각 127.0.0.1:8000, 0.0.0.0:8000이었다. 따라서 DNS가 풀리는지와 그 주소의 포트에서 연결을 받는지는 별개다.

확인 명령은 요청을 보내는 컨테이너에서 실행한다

호스트 터미널에서 성공한 요청만으로 앱 컨테이너의 경로를 확인할 수는 없다. 먼저 실패한 URL을 사용하는 프로세스가 어느 컨테이너에 있는지 찾는다. 아래는 PowerShell 7에서 그 앱과 상대 서버의 네트워크를 읽는 명령이다. 두 변수는 자신의 실제 컨테이너 이름으로 바꾼다.

$app = '내-앱-컨테이너'
$api = '상대-서버-컨테이너'
docker inspect --format '{{json .NetworkSettings.Networks}}' $app $api

컨테이너마다 JSON 한 개가 나온다. 두 출력에 공통으로 있는 네트워크의 NetworkID를 비교하고, 상대 서버의 Aliases도 확인한다. 실험에서는 두 컨테이너의 NetworkID가 같았고 서버 쪽 Aliases에 server-all이 있었다. 공통 사용자 정의 bridge가 없다면 배포 설정의 네트워크 연결부터 맞춘다. 이름만 바꾸는 것으로 해결할 조건이 아니다. docker inspect의 출력 형식 (새 탭에서 열림)

다음 두 명령은 앱 컨테이너에 Python 3가 설치된 경우 사용할 수 있다. server-all은 실제 상대 컨테이너 이름이나 같은 네트워크의 별칭으로, 8000은 상대 프로세스가 컨테이너 안에서 듣는 포트로 바꾼다. 첫 줄은 이름 해석, 두 번째 줄은 HTTP 연결을 확인한다.

docker exec $app python -c "import socket; print(socket.gethostbyname('server-all'))"
docker exec $app python -c "import http.client; c=http.client.HTTPConnection('server-all',8000,timeout=2); c.request('GET','/'); print(c.getresponse().status)"

실험에서 두 명령의 출력은 차례로 172.27.0.3, 200이었다. 두 번째 명령의 호스트만 localhost로 바꾸자 위의 ConnectionRefusedError가 나왔다. server-loop로 바꾸면 DNS 출력은 172.27.0.2였지만 HTTP 요청은 같은 오류였다. 이 명령은 HTTP 프록시를 거치지 않고 지정한 호스트로 직접 연결한다.

이름 해석 단계에서 socket.gaierror가 나오면 네트워크 연결과 별칭을 먼저 확인한다. 이는 예상되는 오류의 해석이며, 이번 실험에서 일부러 DNS 실패를 만들지는 않았다. python을 찾지 못한다면 진단 도구가 없는 것이므로 통신 실패로 세지 않는다. 이름 해석 뒤 연결이 거절되면 서버의 실제 수신 포트, bind 주소와 준비 상태를 확인한다. 시간 초과도 같은 원인이라고 단정하지 않는다.

서버 안에서만 성공하면 수신 주소를 확인한다

상대 서버 안에서의 확인은 출발점이 다르다. 같은 Python 조건에서 $api를 문제 서버의 실제 이름으로 정하고 실행한다.

docker exec $api python -c "import http.client; c=http.client.HTTPConnection('127.0.0.1',8000,timeout=2); c.request('GET','/'); print(c.getresponse().status)"

실험의 server-loop는 이 명령에서 200을 반환했다. 그러나 옆 컨테이너가 연결하는 172.27.0.2:8000에는 응답할 리스너가 없었다. 서버 시작 옵션과 로그의 리스닝 주소가 127.0.0.1:8000이라면, 같은 bridge의 앱이 접근할 수 있는 인터페이스에서도 듣도록 바꾼다. Python 예제에서는 --bind 0.0.0.0이 그 설정이었다. 실제 API에서는 해당 서버의 listen 또는 bind 설정을 찾아 적용한다.

이때 0.0.0.0은 서버의 수신 설정이다. 앱의 접속 URL에는 http://server-all:8000처럼 상대 이름을 사용한다. 컨테이너 안의 수신 범위를 바꾸는 것과 Docker 호스트에 포트를 공개하는 것은 별도 설정이다. 같은 사용자 정의 bridge의 통신에는 호스트 포트 공개가 필요하지 않으며, 호스트에 공개한 포트가 있더라도 이 경로에서는 컨테이너 내부의 수신 포트를 사용한다. Docker의 published ports 설명 (새 탭에서 열림)

이번 확인은 각자 네트워크 공간을 가진 Linux 컨테이너의 IPv4 bridge 통신까지다. localhost 해석에는 ::1도 나타났지만 IPv6 서버 통신은 검증하지 않았다. Docker의 container: 모드처럼 네트워크 공간을 공유하는 구성은 loopback의 의미가 달라지므로 같은 결론을 그대로 적용하지 않는다. Docker의 네트워크 공간 공유 설명 (새 탭에서 열림) host 모드, Compose 설정, 여러 호스트 간 통신과 실제 앱의 인증·업무 응답도 이번 실험 범위에 포함하지 않았다.

← 전체 글 보기