본문으로 건너뛰기
개발 머꼬
개발 노트Docker
hohyeon.dev3

DB는 떠 있는데 컨테이너 안에서만 ECONNREFUSED 127.0.0.1:5432가 난 이유

  • #Common Pitfall
  • #Docker
  • #Engineering Note

문제 발생

로컬에서 잘 돌던 API 서버를 Docker Compose로 옮기는 날이었습니다. api와 db 두 서비스를 적고 docker compose up을 쳤는데, api 컨테이너가 시작하자마자 죽었습니다.

Error: connect ECONNREFUSED 127.0.0.1:5432

docker ps에는 db 컨테이너가 멀쩡히 떠 있었고, 호스트에서 psql -h 127.0.0.1 -p 5432로 붙으면 잘 들어가졌습니다. DB는 살아 있는데 api만 못 찾는 겁니다. 접속 주소를 고쳐서 넘어갔더니, 이번엔 배포 서버에서 간헐적으로 같은 에러가 났습니다. 재시작하면 멀쩡했습니다. 그걸 또 고치고 났더니 마지막으로, 호스트에서 curl localhost:3000을 쳤는데 이번엔 api가 응답 없이 연결을 끊어 버렸습니다. 포트는 분명 3000:3000으로 열어 뒀는데요.

ECONNREFUSED 하나를 쫓아가다가 서로 다른 원인 세 개를 차례로 만난 셈이었습니다.

원인 분석

컨테이너 안의 127.0.0.1은 그 컨테이너 자신입니다. 컨테이너는 저마다 자기 네트워크 공간을 갖습니다. Docker 문서의 브리지 네트워크 예제에서 컨테이너 안에서 ip addr show를 치면 컨테이너 자체의 lo(127.0.0.1)와 eth0(172.17.0.x)이 따로 나옵니다. 그러니 api 컨테이너에서 127.0.0.1:5432로 붙는 건 api 컨테이너 자기 자신의 5432 포트에 붙는 거고, 거기엔 아무것도 없습니다. 호스트에서 psql이 됐던 건 호스트의 127.0.0.1에 db의 포트가 발행돼 있었기 때문입니다. 주소는 같은데 가리키는 곳이 다릅니다.

Docker 없이도 재현됩니다. 호스트에서 127.0.0.1:5432를 열어 둔 채로, 새 네트워크 네임스페이스(컨테이너가 쓰는 격리 방식)에서 같은 주소로 붙어 보면 바로 거절당합니다.

host: listening 127.0.0.1:5432
[same namespace]     connected ('127.0.0.1', 5432)
[new network namespace]  ERR 111 Connection refused

컨테이너끼리는 서비스 이름으로 찾습니다. Compose 네트워킹 문서에 따르면 각 컨테이너는 서비스 이름으로 다른 컨테이너를 조회해 IP를 얻을 수 있고, 그래서 web 서비스는 postgres://db:5432로 DB에 붙습니다. 같은 DB가 호스트에서는 postgres://localhost:8001처럼 발행된 포트로 보입니다. 문서는 HOST_PORT와 CONTAINER_PORT가 다른 용도라고도 못 박습니다. 서비스 간 통신은 컨테이너 포트를 쓰고, 호스트 포트는 네트워크 바깥에서 들어올 때만 쓴다고요. api의 DB_HOST를 127.0.0.1에서 db로 바꾸자 첫 번째 에러가 사라졌습니다.

두 번째 에러는 순서 문제였습니다. 간헐적으로 나던 ECONNREFUSED db:5432는 db 컨테이너는 떴지만 PostgreSQL이 아직 접속을 받을 준비가 안 됐을 때 api가 먼저 붙으려다 난 겁니다. Compose의 시작 순서 문서가 정확히 이 상황을 설명합니다. 시작할 때 Compose는 컨테이너가 "준비될" 때까지 기다리지 않고 "실행 중"이 될 때까지만 기다리며, 관계형 DB처럼 접속을 받기 전에 자기 서비스를 먼저 띄워야 하는 경우 문제가 될 수 있다고요. depends_on: [db]만 적어 둔 상태였는데, 이건 컨테이너를 먼저 시작하라는 뜻이지 DB가 응답할 때까지 기다리라는 뜻이 아니었습니다.

세 번째는 api가 자기 안에서만 듣고 있었습니다. 포트를 3000:3000으로 발행해도 컨테이너 안의 프로세스가 127.0.0.1에 바인딩돼 있으면 밖에서 들어온 연결은 닿지 않습니다. 발행된 포트로 들어온 트래픽은 컨테이너의 eth0 쪽으로 오는데, 프로세스는 lo에서만 기다리고 있으니까요. 개발 서버들이 기본값으로 이렇게 동작하는 경우가 많습니다. Vite의 server.host 기본값은 localhost이고, Flask 문서도 개발 서버가 기본으로 자기 컴퓨터에서만 접근되며 --host 0.0.0.0을 줘야 바깥에서 보인다고 설명합니다.

해결 방안

  1. 컨테이너에서 다른 컨테이너로 갈 때는 서비스 이름과 컨테이너 포트를 씁니다. 접속 주소를 환경변수로 빼 두고, 로컬 실행과 Compose 실행에 다른 값을 넣습니다. 호스트의 발행 포트 번호는 컨테이너 안에서 아무 의미가 없다는 걸 기억해 둡니다.
services:
  api:
    environment:
      DB_HOST: db        # 127.0.0.1 아님
      DB_PORT: 5432      # 컨테이너 포트, 발행 포트가 아님
  db:
    image: postgres:18
    ports:
      - "8001:5432"      # 호스트에서 붙을 때만 8001
  1. 준비될 때까지 기다리려면 healthcheck와 condition: service_healthy를 씁니다. 시작 순서 문서가 제시하는 해법이 이겁니다. depends_on을 긴 문법으로 쓰고, 의존 대상이 healthcheck로 정의한 건강 상태가 될 때까지 기다리게 합니다. PostgreSQL은 이미지에 pg_isready가 들어 있어서 그대로 쓰면 됩니다.
services:
  api:
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:18
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      retries: 5
      start_period: 30s

다만 이건 "처음 뜰 때"만 해결합니다. 운영 중에 DB가 재시작되면 api는 다시 ECONNREFUSED를 만나니, 애플리케이션 쪽 DB 클라이언트에도 재연결과 재시도를 넣어 둡니다. 시작할 때 한 번 기다리는 것과 끊겼을 때 다시 붙는 것은 다른 문제입니다.

  1. 컨테이너 안의 서버는 0.0.0.0에 바인딩합니다. 발행 포트가 있는데 호스트에서 안 붙는다면 제일 먼저 의심할 곳입니다. Node라면 app.listen(3000, '0.0.0.0'), Vite는 --host 0.0.0.0, Flask는 flask run --host 0.0.0.0입니다. 반대로 호스트 쪽 발행은 꼭 필요한 범위로 좁힙니다. Docker 문서대로 포트를 발행하면 기본적으로 호스트뿐 아니라 바깥에서도 접근 가능해지니, 로컬에서만 쓸 DB라면 127.0.0.1:8001:5432처럼 호스트 IP를 앞에 붙여 호스트에서만 접근되게 합니다.

  2. 컨테이너에서 호스트로 가야 하면 host.docker.internal을 씁니다. 호스트에서 직접 돌리는 DB나 로컬 서비스에 컨테이너가 붙어야 하는 경우입니다. Docker Desktop은 이 이름을 호스트의 내부 IP로 자동으로 풀어 줍니다. Linux의 Docker Engine에서는 자동이 아니라서, docker run 문서가 설명하는 --add-host의 특수값 host-gateway를 써서 같은 이름을 직접 매핑합니다. Compose에서는 extra_hosts입니다.

services:
  api:
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      DB_HOST: host.docker.internal
  1. Docker 밖에서도 localhost는 두 주소일 수 있습니다. 컨테이너와 상관없는 로컬 개발 중에 ECONNREFUSED ::1:5432를 본 적이 있다면 이쪽입니다. localhost가 IPv6 ::1과 IPv4 127.0.0.1 둘로 풀리는데, 서버는 127.0.0.1에서만 듣고 있는 경우입니다. Node 17부터 dns.lookup()이 주소를 OS가 돌려준 순서대로(verbatim) 쓰도록 바뀌어서 ::1이 먼저 올 수 있게 됐고, Node 20부터는 autoSelectFamily가 기본으로 켜져 둘 다 시도하면서 대체로 가려졌습니다. 지금 Node 문서에도 이 옵션의 초기 기본값은 true라고 적혀 있습니다. 그래도 오래된 Node를 쓰거나 다른 언어의 클라이언트라면 이 함정이 남아 있으니, localhost 대신 127.0.0.1을 적거나 서버가 ::1에서도 듣게 하는 쪽이 확실합니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.