Skip to Content

16장. 서비스 이름으로 부른다 — compose 네트워크·내장 DNS·depends_on

컨테이너 두 개를 그냥 띄우면 서로 이름을 못 찾습니다. 네트워크를 하나 만들어 붙이면 그때부터 찾아요. resolv.conf의 nameserver가 127.0.0.11로 바뀌거든요.

{/* 삽화 자리: 같은 방에 두 사람이 서 있는데 서로를 부르는 소리가 중간에서 흩어져 닿지 않는다. 옆 칸에서는 바닥에 선이 하나 그어져 있고 그 안에 선 두 사람 사이에 교환원이 서서, 한쪽이 내민 이름표를 받아 반대쪽을 손가락으로 정확히 가리킨다. 프롬프트: docs/book/illustration-prompts-container.md */}


배경

15장 끝에서 주소 이야기를 했습니다. 컨테이너를 지우고 다시 띄우면 지워진 컨테이너의 주소를 다음 컨테이너가 물려받았죠. 주소는 컨테이너 것이 아니라 빈자리 것이었어요.

그러면 앱은 DB를 어떻게 찾을까요.

답은 이름입니다. jdbc:postgresql://db:5432/demo처럼 쓰는 거죠. 주소가 바뀌어도 이름은 그대로니까요.

그런데 그 이름을 주소로 바꿔주는 건 누구일까요. 그리고 이름이 풀린다고 해서 연결이 되는 걸까요.

스토리

먼저 아무 옵션 없이 컨테이너 둘을 띄우고 서로 불러봤습니다. getent hosts는 시스템이 이름을 어떻게 푸는지 그대로 물어보는 명령입니다. /etc/hosts를 보고, 그다음 DNS에 묻죠. ping은 이름 해석 말고 다른 것도 같이 실패하니 원인이 섞입니다.

docker run -d --name n1 alpine:3.20 sleep 120 docker run -d --name n2 alpine:3.20 sleep 120 docker exec n2 getent hosts n1
=== 1. 기본 bridge: 이름으로 부를 수 있나 === n2 에서 n1 을 찾으면: (못 찾음) n2 의 resolv.conf : nameserver 192.168.65.7

✅ 실측 (Docker Engine 29.8.0 / Docker Desktop 4.91.0 / Apple M1 / macOS 26.6, 2026-09. 전체 소스: docs/book/code/container/ch16/dns.sh)

못 찾습니다.

9장에서 두 컨테이너가 같은 bridge에 붙어 IP로는 서로 통한다는 걸 봤죠. 통하기는 하는데 이름은 모르는 겁니다.

resolv.conf가 이유를 알려줍니다. nameserver가 192.168.65.7이에요. 호스트가 쓰는 바깥 DNS 서버를 그대로 물려받은 겁니다. 그 서버가 n1이라는 이름을 알 리가 없죠.

이름을 주소로 바꾸는 것을 이름 해석이라고 합니다. 앞으로는 “이름이 풀린다”고 쓸게요.

이번엔 네트워크를 하나 만들어 붙여봤습니다.

docker network create ch16demo docker run -d --name n1 --network ch16demo alpine:3.20 sleep 120 docker run -d --name n2 --network ch16demo alpine:3.20 sleep 120 docker exec n2 getent hosts n1
=== 2. 사용자 정의 네트워크에서는 === n2 에서 n1 을 찾으면: 172.21.0.2 n1 n1 n2 의 resolv.conf : nameserver 127.0.0.11

✅ 실측 (같은 환경, 같은 소스)

찾습니다. 그리고 nameserver가 바뀌었어요.

핵심

127.0.0.11은 누구인가

루프백 주소입니다. 그런데 컨테이너 안에서 127.0.0.11로 가는 DNS 질의는 Docker가 가로채요.

Docker는 내장 DNS 리졸버를 컨테이너마다 하나씩 붙입니다. 네트워크마다가 아니라 컨테이너마다예요. 컨테이너 안에서 53번으로 나가는 질의는 그 컨테이너의 네트워크 네임스페이스 안 NAT 규칙이 실제 포트로 돌려줍니다.

=== 9. 내장 DNS 는 네트워크마다 하나인가, 컨테이너마다 하나인가 === b1 안에서 53번이 실제로 향하는 곳: 127.0.0.11:60626 b2 안에서 53번이 실제로 향하는 곳: 127.0.0.11:45295 b2 -> b1 (ch16b) : 172.20.0.2 b1 b1 b2 -> c1 (ch16c) : 172.21.0.2 c1 c1 b1 -> c1 (다른 망) : 못찾음

✅ 실측 (같은 환경, 같은 소스. b1은 ch16b에만, b2는 두 네트워크에 붙였습니다)

같은 127.0.0.11인데 포트가 다릅니다. 컨테이너마다 리졸버가 따로라는 뜻이에요. 9장에서 읽어본 그 DNAT 규칙이 여기서 다시 나옵니다.

그리고 그 리졸버가 아는 이름은 그 컨테이너가 붙은 네트워크들의 이름입니다. 두 네트워크에 붙은 b2는 양쪽을 다 풀지만, 한쪽에만 붙은 b1은 건너편 c1을 못 찾아요.

모르는 이름이 오면 바깥 DNS로 넘깁니다. 그래서 getent hosts google.com도 여전히 됩니다.

기본 bridge에는 이 리졸버가 안 붙습니다. 그래서 DNS로 이름을 푸는 건 사용자 정의 네트워크에서만 돼요. Docker 공식 문서도 기본 bridge를 레거시로 표시하고 사용자 정의 네트워크를 권합니다.

이게 “컨테이너 여러 개를 쓸 거면 네트워크를 따로 만들라”는 조언의 정체입니다. 격리가 아니라 이름 때문이에요.

예외가 하나 있긴 합니다. 레거시 --link를 쓰면 기본 bridge에서도 이름이 풀려요. 다만 DNS가 아니라 /etc/hosts에 줄을 박아 넣는 방식이고, 지금 쓰면 곧 없어진다는 경고가 뜹니다. 옛날 글이나 레거시 compose의 links:에서 이걸 보면 그 사정이에요.

compose가 대신 해주는 것

네트워크를 매번 손으로 만들고 --network를 붙이는 건 번거롭습니다. compose는 그걸 대신해 줍니다.

services: db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: demo POSTGRES_DB: demo app: image: container-demo:exec ports: - "18160:8080" depends_on: - db

depends_on은 뒤에서 따로 뜯어봅니다. 지금은 “db 먼저”라고 적어둔 거라고만 보세요. (논점만 남기고 잘랐습니다. 전체는 docs/book/code/container/ch16/compose.yaml에 있고, 거기 db에는 뒤에서 쓸 healthcheck도 붙어 있습니다.)

docker compose up -d를 하면 이렇게 됩니다.

=== 5. compose 가 만드는 것 === 네트워크: ch16_default driver=bridge 컨테이너명=ch16-app-1 서비스명=app 컨테이너명=ch16-db-1 서비스명=db 앱 안에서 db 를 찾으면: 172.20.0.2 db 앱의 resolv.conf : nameserver 127.0.0.11

✅ 실측 (같은 환경, 같은 소스)

네트워크를 하나 만들고({프로젝트}_default), 모든 서비스를 거기 붙이고, resolv.conf를 127.0.0.11로 세팅합니다. 방금 손으로 한 걸 그대로 해주는 거예요.

주목할 건 가운데 두 줄입니다. 컨테이너 이름은 ch16-db-1인데 앱이 찾은 이름은 db예요.

compose는 서비스 이름을 별칭으로 등록합니다. 둘 다 풀리긴 해요 — ch16-db-1로 찾아도 나옵니다. 그런데 컨테이너 이름에는 프로젝트 이름과 번호가 붙어서, 프로젝트 이름만 바꿔도 설정이 깨집니다. 그래서 db를 씁니다.

참고로 이 주소값들은 따라 하면 다르게 나옵니다. 서브넷은 호스트에 이미 있는 네트워크 수에 따라 정해지거든요.

주소가 바뀌어도 이름은 그대로

15장에서 남긴 질문의 답이 여기 있습니다. db를 지우고 다시 띄워봤어요. 그 사이에 다른 컨테이너를 하나 끼워 빈자리를 먼저 차지하게 했고요.

=== 6. db 를 지우고 다시 띄우면 === before: 172.20.0.2 db after : 172.20.0.4 db

✅ 실측 (같은 환경, 같은 소스)

주소는 .2에서 .4로 바뀌었습니다. 그런데 앱이 찾는 이름은 그대로 db예요.

내장 DNS가 컨테이너가 뜨고 질 때마다 자기 기록을 갱신하기 때문입니다. 앱은 아무것도 몰라도 됩니다.

depends_on은 무엇을 기다리나

여기까지면 다 된 것 같습니다. 이름도 풀리고, 순서도 depends_on으로 정했으니까요.

그런데 DB가 뜨자마자 붙어보게 해봤습니다.

client: image: postgres:16-alpine depends_on: - db command: psql -h db -U postgres -d demo -c "select 'connected' as result"
=== 3. depends_on 만 쓰면 === client-1 | psql: error: connection to server at "db" (172.20.0.2), port 5432 failed: FATAL: the database system is starting up client-1 exited with code 2

✅ 실측 (같은 환경, 같은 소스)

죽었습니다.

돌릴 때마다 메시지가 갈립니다. the database system is starting up일 때도 있고 Connection refused일 때도 있어요. Postgres가 아직 포트를 안 열었으면 앞엣것, 열었지만 준비가 덜 됐으면 뒤엣것입니다. 둘 다 “아직 못 받는다”는 뜻이고요.

에러 메시지를 잘 보세요. "db" (172.20.0.2)입니다. 이름은 완벽하게 풀렸어요. 주소까지 찾아갔고요.

거절당한 건 그다음입니다. Postgres 프로세스는 떴는데 아직 접속을 못 받는 상태였어요.

depends_on이 기다리는 건 “컨테이너가 시작됐는가”까지입니다. 그 안의 프로세스가 일할 준비가 됐는지는 안 봐요. 15장에서 본 그 구분입니다. 컨테이너가 도는 것과 앱이 일할 수 있는 것은 다릅니다.

15장의 그 검사를 여기서 씁니다

고치는 방법은 15장에 이미 있었습니다. 준비됐는지 판정하는 검사를 붙이고, depends_on이 그 판정을 기다리게 하면 돼요.

db: image: postgres:16-alpine healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d demo"] interval: 2s timeout: 2s retries: 10 client: depends_on: db: condition: service_healthy
=== 4. condition: service_healthy 를 붙이면 === Container ch16h-db-1 Healthy client-1 | result client-1 | ----------- client-1 | connected client-1 | (1 row) client-1 exited with code 0

✅ 실측 (같은 환경, 같은 소스)

Healthy가 찍힌 다음에 클라이언트가 붙었고, 이번엔 성공했습니다.

15장에서 HEALTHCHECK가 “알려주는 기능이지 고쳐주는 기능이 아니다”라고 했죠. 여기가 그 “알림”을 처음으로 누가 받아 쓰는 자리입니다. compose가 그 판정을 보고 시작 순서를 정해요.

그러니까 depends_on은 두 가지가 있는 셈입니다.

조건기다리는 것
service_started컨테이너가 시작되기까지. 짧은 문법 depends_on: [db]가 이것이다
service_healthy그 서비스의 검사가 통과하기까지
service_completed_successfully그 컨테이너가 exit 0으로 끝나기까지

세 번째가 DB 마이그레이션에 쓰입니다. 스키마를 올리는 컨테이너를 한 번 돌리고, 그게 성공으로 끝난 뒤에 앱을 띄우는 거죠.

service_healthy를 쓰려면 그 서비스에 healthcheck가 반드시 있어야 합니다. 없으면 이렇게 됩니다.

=== 11. healthcheck 없는 서비스에 service_healthy 를 걸면 === compose config → 통과 Container ch16chk-db-1 Waiting dependency failed to start: container ch16chk-db-1 has no healthcheck configured

✅ 실측 (같은 환경, 같은 소스)

설정 검사(compose config)는 통과합니다. 막히는 건 up 할 때예요. 그것도 의존 컨테이너를 이미 띄워놓고 나서 멈춥니다.

그리고 이건 시작할 때 한 번입니다. 뜨고 나서 DB가 죽으면 compose는 아무것도 안 해요. 앱이 재연결을 스스로 처리해야 합니다.

한 이름, 여러 주소

서비스를 여러 개 띄우면 어떻게 될까요. 먼저 그대로 늘려봤습니다.

=== 7. 고정 포트를 둔 채 늘리면 === Bind for 0.0.0.0:18160 failed: port is already allocated

✅ 실측 (같은 환경, 같은 소스)

안 됩니다. 9장에서 본 그 포트 매핑이 걸립니다. 호스트의 18160번은 하나뿐인데 셋이 가져갈 수는 없죠.

포트를 떼고 다시 늘렸습니다.

=== 8. 포트를 떼고 늘리면: 한 이름, 여러 주소 === app 이 풀리는 주소들: 172.20.0.3 172.20.0.4 172.20.0.5 같은 이름을 여섯 번 물어보면: 1회: 172.20.0.3 2회: 172.20.0.3 3회: 172.20.0.3 4회: 172.20.0.5 5회: 172.20.0.4 6회: 172.20.0.5

✅ 실측 (같은 환경, 같은 소스)

한 이름이 세 주소로 풀립니다. 그리고 물어볼 때마다 순서가 바뀌어요.

“돈다”기보다 섞여 나온다가 정확합니다. 위 여섯 번을 보면 같은 주소가 연달아 나오기도 하죠. 차례대로 배분하는 게 아니라 응답 순서를 매번 섞는 거라, 짧은 구간에서는 한쪽으로 쏠립니다.

이걸 라운드로빈 DNS라고 합니다. 부하 분산처럼 보이지만 아니에요. 어디서 갈리는지 재봤습니다.

=== 10. DNS 는 죽은 것은 빼주지만, 멈춘 것은 못 알아본다 === 초기 3개 : 172.20.0.2 172.20.0.3 172.20.0.5 ch16s-app-2 을 kill (172.20.0.2) 3초 후 : 172.20.0.3 172.20.0.5 (빠졌다) ch16s-app-3 을 pause (172.20.0.3) 3초 후 : 172.20.0.3 172.20.0.5 (못 받는데 그대로 있다)

✅ 실측 (같은 환경, 같은 소스)

두 줄이 정반대입니다.

컨테이너를 죽이면 3초 안에 DNS에서 빠집니다. 앞에서 본 그 갱신이에요 — 내장 DNS는 컨테이너가 네트워크에 붙었는지 떨어졌는지를 압니다.

그런데 컨테이너를 pause로 멈추면 그대로 남아 있습니다. 네트워크에는 여전히 붙어 있으니까요. 못 받는 상태인데 DNS는 계속 그 주소를 줍니다.

구분라운드로빈 DNS로드밸런서
무엇을 보고 판정하나네트워크에 붙어 있는가검사를 돌려 받을 수 있는가
컨테이너가 죽으면뺀다 (실측 3초 안)뺀다
컨테이너는 살아 있는데 앱이 멈추면계속 그 주소를 준다검사 실패로 뺀다
분배 기준순서를 섞어서 줄 뿐연결 수·응답 시간 등
캐시클라이언트가 붙잡아 두면 그마저 안 섞인다해당 없음

세 번째 줄이 15장의 그 문제입니다. Health=unhealthy인데 앱은 200을 돌려주던 장면의 반대편이에요. 여기서는 앱이 못 받는데 DNS가 계속 보냅니다. 5부의 프로브가 이 자리를 메웁니다(20장).

마지막 줄도 실무에서 아픕니다. JVM은 이름 해석 결과를 붙잡아 둬요.

=== 12. JVM 이 DNS 결과를 얼마나 붙잡고 있나 === # is to cache for 30 seconds. #networkaddress.cache.ttl=-1 networkaddress.cache.negative.ttl=10

✅ 실측 (Temurin 21.0.12 이미지 안의 conf/security/java.security)

성공한 조회는 30초, 실패한 조회는 10초입니다. 그동안은 몇 번을 불러도 같은 주소로만 가요. 섞여 나오든 말든 클라이언트가 안 물어보니까요.

그래서 compose로 여러 개를 띄우는 건 부하 분산이 아니라 그냥 “여러 개가 떠 있는 상태”입니다. 앞에 뭔가를 세워야 분산이 돼요. 5부의 Service가 정확히 그 자리에 들어옵니다(19장).

그래서 어떻게 잡나

상황하면 좋은 것
컨테이너끼리 이름으로 부르기네트워크를 만들어 붙이기. 기본 bridge에서는 안 된다
설정에 적을 호스트 이름IP 말고 서비스 이름. 컨테이너 이름도 말고
DB보다 앱이 먼저 뜨는 문제healthcheck + condition: service_healthy
뜨고 나서 DB가 죽는 경우compose는 안 도와준다. 앱이 재연결을 처리해야 한다
같은 서비스를 여러 개고정 포트를 떼기. 다만 분산은 안 된다

정리

  • 기본 bridge에는 이름 해석이 없습니다. resolv.conf의 nameserver가 호스트의 바깥 DNS라서, 컨테이너 이름을 물어도 못 찾아요.
  • 사용자 정의 네트워크에 붙이면 nameserver가 127.0.0.11로 바뀝니다. Docker가 컨테이너마다 붙이는 내장 DNS 리졸버예요. 그 컨테이너가 붙은 네트워크들의 이름만 알고, 모르는 이름은 바깥으로 넘깁니다.
  • compose는 {프로젝트}_default 네트워크를 만들고 모든 서비스를 붙입니다. 이름은 서비스 이름을 쓰세요 — 컨테이너 이름도 풀리지만 프로젝트명과 번호가 붙어 잘 깨집니다.
  • 주소가 바뀌어도 이름은 그대로입니다. 실측에서 db가 .2에서 .4로 옮겨갔는데 앱은 계속 db로 찾았어요.
  • depends_on의 짧은 문법은 “컨테이너가 시작됐는가”까지만 기다립니다. 실측에서 이름은 172.20.0.2로 잘 풀렸는데 Postgres가 아직 접속을 안 받아 클라이언트가 exit 2로 죽었습니다.
  • healthcheck + condition: service_healthy를 붙이면 판정을 기다립니다. 15장의 그 검사를 처음으로 누가 받아 쓰는 자리예요.
  • 다만 시작할 때 한 번입니다. 뜨고 나서 DB가 죽으면 앱이 알아서 재연결해야 합니다.
  • 서비스를 늘리면 한 이름이 여러 주소로 풀립니다. 순서는 도는 게 아니라 섞여 나와요.
  • 라운드로빈 DNS는 부하 분산이 아닙니다. 죽은 컨테이너는 3초 안에 빼주지만, pause로 멈춘 컨테이너는 못 받는데도 계속 줍니다. 네트워크에 붙었는지만 보거든요.
  • 게다가 JVM은 이름 해석 결과를 30초 캐시합니다(실패는 10초). 그동안은 섞임조차 안 봅니다.

생각해볼 질문

이제 앱과 DB가 한 덩어리로 뜹니다. 이름으로 서로를 부르고, 준비될 때까지 기다리고요.

그런데 방금 한 줄을 그냥 넘겼습니다. “뜨고 나서 DB가 죽으면 compose는 아무것도 안 한다”는 줄이요.

죽으면 누가 다시 띄울까요.

15장에서 이미 한 번 걸렸던 자리이기도 합니다. --restart=always를 걸어뒀는데 unhealthy인 컨테이너를 재시작하지 않았죠. 그 정책은 종료 코드를 본다고 했고요.

그럼 어떤 종료 코드에 반응할까요. 그리고 정상 종료와 사고를 어떻게 구분할까요.

▶ 17장. 죽고 다시 살아나기 — exit code·restart 정책·latest 태그

Last updated on