Skip to content
ioob.dev
Go back

Docker 7편 — Docker Compose로 다중 컨테이너 오케스트레이션

· 7분 읽기
Docker 시리즈 (7/13)
  1. Docker 입문 1편 — Docker란
  2. Docker 입문 2편 — 이미지와 레이어
  3. Docker 입문 3편 — Dockerfile 작성법
  4. Docker 입문 4편 — 컨테이너 생명주기
  5. Docker 입문 5편 — 볼륨과 데이터 영속성
  6. Docker 입문 6편 — 네트워크
  7. Docker 7편 — Docker Compose로 다중 컨테이너 오케스트레이션
  8. Docker 8편 — 멀티스테이지 빌드로 이미지 다이어트
  9. Docker 9편 — 레지스트리, 이미지는 어디에 두는가
  10. Docker 10편 — 컨테이너 보안, 터지기 전에 막는 것들
  11. Docker 11편 — BuildKit과 고급 빌드
  12. Docker 12편 — 프로덕션 모범 사례
  13. Docker 13편 — 트러블슈팅과 대안
Table of contents

Table of contents

Compose가 하는 일

먼저 Compose가 docker compose up 한 번에 무슨 일을 벌이는지 전체 흐름부터 보자.

flowchart LR
    CMD["docker compose up"] --> PARSER["compose.yaml 파싱"]
    PARSER --> NET["default 네트워크 생성"]
    PARSER --> VOL["볼륨 생성"]
    PARSER --> BUILD["이미지 빌드 / pull"]
    BUILD --> RUN1["web 컨테이너"]
    BUILD --> RUN2["db 컨테이너"]
    BUILD --> RUN3["cache 컨테이너"]
    NET --> RUN1
    NET --> RUN2
    NET --> RUN3
    VOL --> RUN2
    RUN2 -.->|"healthcheck OK"| RUN1

핵심은 두 가지다. 첫째, 서비스들은 자동으로 같은 네트워크에 들어가서 서비스 이름으로 서로를 부를 수 있다. 둘째, 의존성과 헬스체크를 선언해두면 시작 순서를 Compose가 대신 맞춰준다.

설치 확인

최신 Docker Desktop이나 Docker Engine에는 Compose v2가 플러그인으로 내장되어 있다. 예전 docker-compose(하이픈)가 아니라 docker compose(공백)를 쓴다.

docker compose version

버전이 찍히면 준비 끝이다. docker-compose 명령을 여전히 쓰는 팀이 있다면 v1이라 EOL된 버전이니 v2로 올리는 걸 권한다.

최소한의 compose.yaml

파일 이름은 compose.yaml이 공식 권장이다. 예전에는 docker-compose.yml이었는데 지금은 둘 다 지원한다. 가장 작은 구성을 보자.

# compose.yaml
services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"

services 아래에 web이라는 서비스 이름을 하나 정의했다. 이미지는 nginx, 호스트 8080을 컨테이너 80으로 연결했다. 이 파일이 있는 디렉토리에서 기동한다.

docker compose up -d

-d는 detached, 즉 백그라운드 실행이다. 브라우저로 http://localhost:8080을 열면 Nginx 기본 페이지가 뜬다. 내릴 때는 docker compose down이다.

세 가지 축 — services, networks, volumes

Compose 파일은 크게 세 가지 톱 레벨 키로 구성된다. 이 셋만 이해하면 나머지는 디테일이다.

services:   # 컨테이너 하나하나의 정의
  ...
networks:   # 서비스끼리 붙일 네트워크
  ...
volumes:    # 데이터 영속화에 쓸 볼륨
  ...

간단한 웹+DB+캐시 구성을 예로 본다.

services:
  web:
    image: myapp:latest
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    networks:
      - backend

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

  cache:
    image: redis:7-alpine
    networks:
      - backend

networks:
  backend:

volumes:
  db-data:

가볍게 읽어보면 이야기가 보인다. web은 3000번 포트를 열고, DB와 캐시에 의존한다. db는 postgres 이미지에 볼륨을 붙여서 데이터가 재시작해도 살아남게 했다. 셋 다 backend라는 같은 네트워크에 들어간다.

여기서 주목할 건 environment에 있는 db:5432cache:6379다. 호스트 이름 자리에 서비스 이름을 그대로 썼다. Compose가 만들어주는 네트워크 안에서는 서비스 이름이 DNS 이름처럼 동작한다. IP를 알 필요가 없다.

depends_on과 healthcheck — 시작 순서의 진실

처음 Compose를 쓰면 depends_on이 시작 순서를 보장해준다고 착각하기 쉽다. 정확히는 컨테이너가 시작되는 순서만 보장한다. “DB 프로세스가 쿼리를 받을 준비가 됐는지”는 보장하지 않는다.

그래서 condition을 붙인다.

sequenceDiagram
    participant Compose
    participant DB
    participant Web
    Compose->>DB: 컨테이너 시작
    DB-->>Compose: started
    loop 5초 간격 healthcheck
        Compose->>DB: pg_isready
        DB-->>Compose: ok?
    end
    DB-->>Compose: healthy
    Compose->>Web: 컨테이너 시작 (의존 충족)

DB 같이 준비 시간이 필요한 서비스는 반드시 healthcheck를 정의하고 service_healthy로 묶는다. 안 그러면 웹이 먼저 뜨고 DB 연결 실패로 재시작 루프에 빠진다.

네트워크 — 분리와 노출

한 네트워크에 다 넣어도 되지만, 규모가 커지면 분리하는 게 안전하다. 예를 들어 DB는 외부에서 접근 불가능해야 하고, 웹만 리버스 프록시 네트워크에 노출돼야 한다면 이렇게 쓴다.

services:
  proxy:
    image: traefik:v3
    networks:
      - edge

  web:
    image: myapp:latest
    networks:
      - edge      # proxy와 통신
      - backend   # db와 통신

  db:
    image: postgres:16-alpine
    networks:
      - backend   # 오직 backend에만

networks:
  edge:
  backend:
    internal: true   # 외부 인터넷 접근 차단

internal: true를 쓰면 해당 네트워크는 외부 라우팅이 끊긴다. DB가 인터넷으로 뭔가 당겨올 일은 없으니 기본값으로 걸어두는 게 안전하다.

볼륨 — named vs bind

볼륨은 두 가지 방식이 있다. 쓰임새가 다르다.

services:
  db:
    image: postgres:16-alpine
    volumes:
      - db-data:/var/lib/postgresql/data   # named volume
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro   # bind mount

volumes:
  db-data:

개발 환경에서는 소스 코드를 bind mount로 넣어서 파일 수정이 즉시 반영되게 하고, 데이터는 named volume으로 관리한다.

.env와 변수 치환

비밀번호나 환경별 값을 파일에 박아두면 안 된다. .env 파일을 같은 디렉토리에 놓으면 Compose가 자동으로 읽는다.

# .env
POSTGRES_PASSWORD=secret
APP_PORT=3000
services:
  web:
    image: myapp:latest
    ports:
      - "${APP_PORT}:3000"
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

.env.gitignore에 반드시 넣는다. 커밋 실수 한 번이면 비밀번호가 원격 리포로 가버린다.

profiles — 상황별로만 켜기

개발자는 앱+DB만 띄우면 되지만, QA 환경에서는 여기에 Kafka, Mailhog 같은 게 더 필요할 수 있다. 서비스마다 profiles를 붙이면 원하는 상황에서만 뜨게 할 수 있다.

services:
  web:
    image: myapp:latest
  db:
    image: postgres:16-alpine

  mailhog:
    image: mailhog/mailhog
    profiles: ["dev"]

  kafka:
    image: confluentinc/cp-kafka:7.6.0
    profiles: ["qa"]

기본 기동은 web과 db만 뜬다.

docker compose up -d                        # web, db만
docker compose --profile dev up -d          # + mailhog
docker compose --profile qa up -d           # + kafka

옵션 없이 up을 하면 프로파일이 지정된 서비스는 건드리지 않는다. 필요할 때만 불러서 쓰는 구조다.

환경별 override

개발/스테이징/프로덕션에서 같은 기본 설정을 쓰되 일부만 덮어쓰고 싶을 때가 많다. Compose는 compose.yaml + compose.override.yaml 병합을 기본값으로 한다. 여러 파일을 명시적으로 합칠 수도 있다.

# compose.yaml — 공통
services:
  web:
    image: myapp:latest
    environment:
      LOG_LEVEL: info
# compose.dev.yaml — 개발용 덮어쓰기
services:
  web:
    build: .
    volumes:
      - ./src:/app/src
    environment:
      LOG_LEVEL: debug
    ports:
      - "3000:3000"
# compose.prod.yaml — 프로덕션용 덮어쓰기
services:
  web:
    restart: always
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M

실행할 때 -f로 여러 파일을 나열하면 순서대로 덮어쓴다.

docker compose -f compose.yaml -f compose.dev.yaml up -d
docker compose -f compose.yaml -f compose.prod.yaml up -d

같은 서비스 키의 중복된 설정은 뒤 파일이 이긴다. 배열은 기본적으로 덮어쓰기인데, 환경 변수 같은 맵은 병합된다는 점만 기억해두면 충돌이 날 일은 거의 없다.

자주 쓰는 명령

Compose를 쓰다 보면 다음 명령을 수백 번 친다.

# 전체 기동 / 중지
docker compose up -d
docker compose down

# 볼륨까지 같이 삭제 (데이터 날아감 주의)
docker compose down -v

# 특정 서비스만
docker compose up -d web
docker compose restart web

# 로그 보기
docker compose logs -f web

# 컨테이너 안으로
docker compose exec web sh

# 이미지 재빌드하며 기동
docker compose up -d --build

# 현재 정의된 최종 구성 확인 (override 병합 결과)
docker compose config

마지막 config는 자주 까먹는데 디버깅에 매우 유용하다. 여러 파일을 합쳤을 때 최종적으로 뭐가 될지 미리 볼 수 있다.

여기까지의 상태

Compose 파일 하나로 웹 + DB + 캐시를 한 번에 띄우고, 네트워크를 분리하고, 환경별 override까지 구성하는 방법을 훑었다. 실무에서는 여기에 모니터링(Prometheus, Grafana)이나 메시지 브로커(Kafka, RabbitMQ)가 더 붙을 뿐, 뼈대는 똑같다.


다음 편에서는 이미지 크기를 줄이고 빌드 속도를 끌어올리는 멀티스테이지 빌드를 다룬다. 빌드 산출물과 런타임을 분리해서 최종 이미지가 얼마나 작아질 수 있는지 직접 보여준다.

8편: 멀티스테이지 빌드


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Docker 입문 6편 — 네트워크
Next Post
Docker 8편 — 멀티스테이지 빌드로 이미지 다이어트