웹·DB·캐시를 각각 docker run으로 띄우면 컨테이너마다 긴 명령어를 외워야 하고, 네트워크를 손으로 만들어 연결해야 한다. 더 흔한 문제는 DB가 아직 안 떴는데 앱이 먼저 떠서 접속 에러로 죽는 것이다. Docker Compose는 스택 전체를 YAML 한 장에 선언하고 한 명령으로 띄운다.

멀티 컨테이너에서 실제로 발목을 잡는 건 서비스 시작 순서다. 헬스체크로 DB가 준비된 다음에 앱을 띄우는 스택을 만들고 docker compose up을 돌려 그 순서가 지켜지는지 로그로 확인한다. 출력은 Docker Compose v5.1.1에서 돌린 결과다.

하나의 스택, 하나의 파일

다음은 nginx(앱), PostgreSQL(DB), Redis(캐시)로 이루어진 스택이다. db 서비스의 healthcheck와, api가 그 헬스 상태를 기다리는 depends_on ... condition: service_healthy가 핵심이다.

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
      interval: 3s
      timeout: 3s
      retries: 10

  cache:
    image: redis:7-alpine
    command: redis-server --save "" --appendonly no

  api:
    image: nginx:1.27-alpine
    depends_on:
      db:
        condition: service_healthy    # db가 "건강"해질 때까지 대기
      cache:
        condition: service_started    # cache는 시작만 하면 됨
    ports:
      - "8088:80"

volumes:
  db-data:

서비스 정의에 들어간 옵션의 역할은 다음과 같다.

  • depends_on + condition: 시작 순서를 보장한다. service_started는 컨테이너가 뜨기만 하면 되고, service_healthy는 헬스체크를 통과해야 다음 단계로 넘어간다.
  • healthcheck: 컨테이너가 떴다고 해서 그 안의 프로세스가 요청을 받을 준비가 된 건 아니다. pg_isready로 Postgres가 연결을 받는지 주기적으로 확인한다.
  • volumes: db-data에 DB 파일을 저장해 컨테이너를 지워도 데이터가 남는다.
  • ports: 호스트의 8088을 컨테이너의 80에 연결한다.

dbcache에는 포트를 호스트로 노출하지 않았다. Compose가 만든 기본 네트워크 안에서 api가 서비스 이름으로 직접 접근하므로 외부로 열 필요가 없다.

시작 순서 확인

docker compose up -d

이미지를 받은 뒤 컨테이너를 만드는 마지막 로그가 시작 순서를 보여준다.

 Container composedemo-cache-1  Started
 Container composedemo-db-1     Started
 Container composedemo-db-1     Waiting
 Container composedemo-db-1     Healthy
 Container composedemo-api-1    Starting
 Container composedemo-api-1    Started

dbStarted 된 직후 Waiting으로 들어가고, 헬스체크를 통과해 Healthy가 된 뒤에야 apiStarting한다. 선언한 대로 DB가 준비된 다음에 앱이 뜬 것이다. depends_on만 쓰고 condition을 빼면 Compose는 컨테이너가 뜨는 것까지만 기다리고 헬스는 기다리지 않으므로 Waiting → Healthy 단계가 사라진다.

docker compose ps로 상태를 보면 db(healthy)로 표시된다.

NAME                  IMAGE                SERVICE   STATUS
composedemo-api-1     nginx:1.27-alpine    api       Up 11 seconds
composedemo-cache-1   redis:7-alpine       cache     Up 22 seconds
composedemo-db-1      postgres:16-alpine   db        Up 22 seconds (healthy)

서비스 이름이 곧 호스트 이름이다

같은 Compose 파일의 서비스들은 자동으로 만들어진 네트워크에 묶이고, 서로를 서비스 이름으로 찾는다. api 컨테이너 안에서 이름을 조회하면 Compose의 내장 DNS가 각 서비스를 컨테이너 IP로 풀어 준다.

docker compose exec api sh -c "getent hosts db; getent hosts cache"
172.18.0.2        db  db
172.18.0.3        cache  cache

그래서 애플리케이션 설정에는 IP가 아니라 db:5432, cache:6379처럼 서비스 이름을 쓴다. 컨테이너 IP는 재시작마다 바뀌지만 서비스 이름은 변하지 않는다.

호스트로 노출한 포트도 동작한다. api의 80을 8088로 매핑했으므로 호스트에서 바로 접근된다.

curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8088/
# HTTP 200

다 쓴 스택은 한 명령으로 정리한다. -v는 볼륨까지 같이 지운다.

docker compose down -v
 Container composedemo-api-1    Removed
 Container composedemo-db-1     Removed
 Volume composedemo_db-data     Removed
 Network composedemo_default    Removed

환경별로 설정 나누기

같은 스택이라도 개발과 운영에서 달라지는 부분이 있다. Compose는 여러 파일을 겹쳐 쓸 수 있어, 공통 설정 위에 환경별 오버라이드를 올린다. 뒤에 지정한 파일이 앞 파일을 덮어쓴다.

# docker-compose.override.yml — 개발용 (소스 마운트, 디버그 포트)
services:
  api:
    volumes:
      - ./src:/app
    ports:
      - "9229:9229"
    environment:
      - NODE_ENV=development
# 개발: base + 개발 오버라이드
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# 운영: base + 운영 오버라이드, 백그라운드
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

파일 이름이 docker-compose.override.yml이면 docker compose up만 해도 base에 자동으로 겹쳐진다.

자주 쓰는 명령어

명령어설명
docker compose up -d백그라운드로 스택 시작
docker compose up --build이미지를 다시 빌드한 뒤 시작
docker compose ps서비스 상태 확인 (헬스 포함)
docker compose logs -f api특정 서비스 로그 실시간 추적
docker compose exec db psql -U postgres -d app컨테이너 안에서 명령 실행
docker compose config최종 병합된 설정 검증·출력
docker compose down -v스택 중지 후 볼륨까지 삭제

설정이 의도대로 병합됐는지 의심스러우면 docker compose config로 최종 결과를 확인한다. 시작 실패는 대부분 의존 서비스가 준비되기 전에 떠서 생기므로, healthcheck + condition: service_healthy 조합으로 해결한다.