Docker 컨테이너 DNS 설정 트러블슈팅 — 서비스 이름 해석 실패와 TTL 조정

목차

sqlalchemy.exc.OperationalError: (psycopg2.OperationalError)
could not translate host name "db" to address:
Temporary failure in name resolution

그러나, Docker 컨테이너 DNS 설정에서 가장 흔하게 만나는 에러 중 하나다. FastAPI 컨테이너에서 PostgreSQL 컨테이너를 db라는 서비스 이름으로 호출했는데 이름 해석 자체가 안 됐다. compose 파일에 depends_on: [db]를 적어뒀으니 당연히 연결될 줄 알았다. 두 시간을 날렸다.

이처럼, 프론트엔드에서 백엔드로 전환한 지 2년이 됐지만, 네트워크 레이어 쪽은 여전히 가끔 발목을 잡는다. 브라우저 DNS는 OS와 ISP가 알아서 해주는 영역이라 신경 쓸 일이 거의 없었다. Docker 안의 DNS는 사정이 좀 다르더라.

첫 시도 — depends_on만 믿었던 게 잘못

처음 작성한 compose 파일은 이렇게 생겼다.

services:
  api:
    build: ./api
    depends_on:
      - db
    ports:
      - "8000:8000"
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret

depends_on이 시작 순서뿐 아니라 네트워크 연결까지 책임지는 줄 알았다. 실제로 docker compose up으로 띄웠다면 둘 다 자동 생성된 default network에 묶여서 동작했을 거다. 문제는 내가 했던 작업이었다. 디버깅 중에 db 컨테이너만 한 번 docker run으로 따로 띄워서 데이터를 확인하고, 거기에 api 컨테이너만 다시 붙이려 한 것이었다.

또한, 이 경우 두 컨테이너가 default bridge에 들어간다. default bridge는 컨테이너 이름 기반 DNS 해석을 지원하지 않는다.

$ docker exec api cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0

$ docker exec api nslookup db
Server:    127.0.0.11
Address:   127.0.0.11:53

** server can't find db: NXDOMAIN

127.0.0.11은 Docker의 임베디드 DNS 서버다. user-defined network 안에서만 컨테이너 이름과 서비스 이름을 풀어준다. default bridge에서는 외부 DNS 포워딩 역할만 한다.

원인 정리 — Docker DNS의 두 모드

Docker daemon의 DNS는 네트워크 종류에 따라 동작이 갈린다.

항목 default bridge user-defined bridge
컨테이너 이름 해석 불가 가능
서비스 이름 해석 (compose) 불가 가능
임베디드 DNS (127.0.0.11) 외부 포워딩만 내부 + 외부
자동 격리 모든 컨테이너 공유 네트워크별 격리

(출처: Docker Engine 25.0 공식 문서, Networking overview)

특히, 핵심은 user-defined network를 만들고 그 안에 컨테이너를 넣어야 임베디드 DNS가 이름을 풀어준다는 점이다. compose가 알아서 만들어주긴 하지만, 명시적으로 적어두는 편이 디버깅하기 편하다.

임베디드 DNS의 동작 방식

예를 들어, 127.0.0.11은 컨테이너 안에서만 보이는 가상 DNS 서버다. iptables NAT 룰로 컨테이너 내부 트래픽이 daemon의 DNS 리졸버로 전달된다. host의 /etc/resolv.conf와는 별개로 동작한다는 뜻이다.

요청이 들어오면 다음 순서로 처리한다.

  1. 같은 네트워크에 속한 컨테이너 이름·서비스 이름·alias 매칭
  2. 매칭 실패 시 daemon에 설정된 upstream DNS로 포워딩 (기본은 host의 nameserver)

upstream을 명시하지 않으면 host의 /etc/resolv.conf를 그대로 가져온다. WSL2나 일부 VPN 환경에서 이 부분이 꼬이면 외부 DNS 자체가 안 된다. 별도 이슈라 뒤에서 다시 다룬다.

해결 — user-defined network 명시

compose 파일을 다시 썼다. networks 블록을 명시하고, 서비스마다 어느 네트워크에 들어가는지 적었다.

services:
  api:
    build: ./api
    networks:
      - backend
    depends_on:
      - db
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://postgres:secret@db:5432/app

  db:
    image: postgres:16-alpine
    networks:
      - backend
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app

networks:
  backend:
    driver: bridge
    name: myapp_backend

결국, 이렇게 띄우면 두 컨테이너가 myapp_backend라는 user-defined bridge에 함께 묶인다. api 컨테이너에서 db를 호출하면 임베디드 DNS가 db 컨테이너의 IP를 반환한다.

$ docker exec myapp-api-1 nslookup db
Server:    127.0.0.11
Address:   127.0.0.11:53

Non-authoritative answer:
Name:   db
Address: 172.20.0.2

성공했을 때 그 허탈함이란. 프론트에서 fetch가 안 되는 이유가 CORS인지 cookie인지 헷갈리던 시절과 비슷한 종류의 부끄러움이었다. (개인적으로 인프라 쪽은 한 번 막히면 출구가 안 보이는 느낌이 든다)

docker run으로 띄울 때

compose 없이 컨테이너를 직접 띄울 때도 같은 원리가 적용된다.

docker network create backend
docker run -d --name db --network backend postgres:16-alpine
docker run -d --name api --network backend my-api:latest

--link 옵션은 deprecated이다. alias가 필요하면 --network-alias 플래그를 쓴다.

docker run -d --name db \
  --network backend \
  --network-alias database \
  --network-alias primary-db \
  postgres:16-alpine

그러나, 이러면 같은 network 안의 다른 컨테이너에서 db, database, primary-db 모두로 접근할 수 있다.

DNS TTL과 캐시 — alpine 이미지의 함정

서비스 이름 해석 문제를 해결한 뒤, 새로운 증상이 생겼다. db 컨테이너를 재시작하면 IP가 바뀌는데, api 컨테이너가 한참 동안 옛날 IP로 붙으려 했다. 한 번에 30~60초씩 끊기는 일이 생겼다.

또한, 원인은 application 레벨 DNS 캐시였다. Python 표준 라이브러리는 자체 캐시가 거의 없지만, JVM이나 Node.js HTTP 클라이언트는 자체 캐시 또는 keep-alive 커넥션 풀을 가진다. 그리고 alpine 기반 이미지에서는 또 다른 문제가 있다.

musl libc의 DNS 구현 차이

즉, alpine은 glibc 대신 musl을 쓴다. musl의 DNS 리졸버는 glibc와 두 가지 점에서 다르다.

  1. /etc/resolv.conf의 모든 nameserver에 동시에 쿼리를 날린다 (glibc는 순차 시도)
  2. search 도메인 처리 방식이 다르다

그런데, 만났던 가장 실전 이슈는 두 번째였다. compose 환경에서 db 같은 짧은 이름을 호출하면 임베디드 DNS는 정상 응답한다. 그런데 일부 라이브러리가 FQDN 형태로 다시 쿼리를 날리면서 search 도메인이 붙은 db.local, db.example.com 같은 이름을 만들어 보내는 경우가 있다. 이게 timeout으로 이어지면서 첫 응답이 5초씩 느려진다.

# Dockerfile
FROM python:3.12-slim  # alpine 대신 slim 사용
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

production용은 slim 또는 distroless로 가는 편이 안전하다. alpine이 이미지가 작긴 한데, 디버깅 비용이 더 들 때가 있다. 이건 팀 컨벤션마다 다르다.

TTL과 daemon 옵션

Docker 임베디드 DNS는 응답에 TTL 600초를 기본으로 붙인다. 컨테이너 IP가 자주 바뀌는 환경(rolling deploy, swarm mode 등)에서는 이 값이 너무 길다. daemon 옵션으로 조정할 수 있다.

// /etc/docker/daemon.json
{
  "dns": ["8.8.8.8", "1.1.1.1"],
  "dns-opts": ["ndots:0", "timeout:2", "attempts:2"],
  "dns-search": []
}

ndots:0은 점이 없는 짧은 이름을 search 도메인 붙이지 말고 그대로 쿼리하라는 뜻이다. compose 환경에서 db, redis 같은 이름을 자주 쓴다면 이 설정이 응답 속도를 줄여준다.

물론, application 레이어 캐시는 별도다. Java라면 networkaddress.cache.ttl, Node.js라면 dns 모듈의 setDefaultResultOrder 또는 lookup 옵션을 손봐야 한다. Python requests는 connection pool 단위로 IP를 잡고 있어서, pool 자체를 새로 만들거나 keep-alive를 끊어야 새 IP로 바뀐다.

컨테이너 단위 DNS 설정 — compose에서 직접 지정

특정 컨테이너만 다른 DNS를 쓰게 하려면 compose 또는 run 단위로 옵션을 넘긴다.

services:
  api:
    image: my-api:latest
    networks:
      - backend
    dns:
      - 1.1.1.1
      - 8.8.8.8
    dns_search:
      - example.local
    dns_opt:
      - ndots:1
      - timeout:3

결국, 이걸 쓰면 컨테이너의 /etc/resolv.conf가 위 값으로 덮인다. 단, 임베디드 DNS(127.0.0.11)는 여전히 우선순위가 높다. user-defined network 안에서는 컨테이너 이름 해석이 그대로 동작하고, 외부 도메인 쿼리만 명시한 DNS로 포워딩된다.

그러나, 회사 사내망에서 사내 DNS를 강제해야 하는 경우, 또는 외부 DNS 차단 환경에서 특정 resolver만 허용해야 하는 경우에 자주 쓴다.

디버깅 체크리스트

비슷한 증상으로 막혔을 때 순서대로 확인하면 빠르다.

# 1. 컨테이너가 어느 네트워크에 속해 있는지
docker inspect <container> --format '{{json .NetworkSettings.Networks}}' | jq

# 2. 같은 네트워크의 다른 컨테이너 목록
docker network inspect <network> \
  --format '{{range .Containers}}{{.Name}} {{end}}'

# 3. 컨테이너 안에서 DNS 설정 확인
docker exec <container> cat /etc/resolv.conf

# 4. 임베디드 DNS로 직접 쿼리
docker exec <container> nslookup <target>

# 5. 외부 DNS 동작 확인 (임베디드 우회)
docker exec <container> nslookup google.com 8.8.8.8

1번과 2번이 가장 자주 답을 준다. 두 컨테이너가 다른 네트워크에 있으면 임베디드 DNS가 서로의 이름을 모른다. compose 프로젝트가 두 개로 분리된 경우, 또는 한쪽은 docker run으로 띄운 경우에 이 함정에 빠진다.

게다가, 여러 compose 프로젝트를 묶어야 한다면 external network를 공유한다.

# project-a/compose.yaml
networks:
  shared:
    external: true
    name: shared_backend

services:
  api:
    networks:
      - shared

미리 docker network create shared_backend로 만들어두고, 두 프로젝트 모두 external로 참조한다. 이 패턴은 모놀리식 레포를 여러 compose로 나눠 운영할 때 유용하다.

운영 중에 한 번 더 만난 케이스

배포 직후, k8s가 아닌 docker swarm으로 돌아가는 작은 서비스에서 비슷한 증상이 다시 나왔다. swarm mode에는 service VIP라는 또 다른 레이어가 있다. DNS 쿼리가 개별 task IP가 아닌 가상 IP로 응답한다. 이게 ingress network와 overlay network 사이에서 가끔 sync가 늦다.

반면, swarm 환경 디버깅은 single host와 다르게 가야 한다. docker service inspect, docker network inspect <overlay>, 그리고 노드별 임베디드 DNS 상태를 따로 봐야 한다. 글이 길어져서 다 다루지는 못하겠다. 한 가지만 적자면, swarm DNS는 round-robin으로 응답하니 sticky session이 필요하면 application 레이어에서 별도 처리해야 한다.

(GitHub moby/moby #34064에 이 동작에 대한 토론이 남아 있다. 2026년 5월 기준 일부 엣지 케이스는 여전히 진행 중인 것으로 보인다)

다음에 시도할 것

특히, 위 가이드를 따라가면 대부분의 Docker 컨테이너 DNS 설정 이슈는 풀린다. 당장 적용할 수 있는 액션 세 가지를 추리면 이렇다.

  1. compose 파일에 networks 블록을 명시하고, 서비스마다 어느 네트워크에 들어가는지 적어둔다
  2. docker exec <container> nslookup <target> 한 줄로 임베디드 DNS 동작을 가장 먼저 확인한다
  3. alpine 이미지에서 DNS 지연이 생기면 slim/distroless로 갈아타거나 dns_opt: [ndots:0]을 추가해본다

특히, 다만 swarm mode와 사내 사설 DNS가 섞인 환경의 동작은 더 지켜봐야 한다.

관련 글