포스트

Docker Compose — 여러 컨테이너를 한 파일로 — 시리즈 3편

Docker Compose — 여러 컨테이너를 한 파일로 — 시리즈 3편

docker run 을 다섯 번 쳤다면, 그건 이미 Compose 의 신호입니다.

웹 한 개, DB 한 개, 캐시 한 개, 그리고 둘을 잇는 custom network 까지 — 손으로 띄우다 보면 어느 순간 README 에 “이 명령들을 순서대로 치세요” 가 5줄이 됩니다. 한 줄만 빠뜨려도 컨테이너 이름이 충돌하거나, 같은 네트워크에 안 붙어서 2편에서 봤던 “왜 통신이 안 되지” 가 재현됩니다.

Docker Compose 는 이걸 YAML 파일 하나로 묶는 도구입니다. 이번 편은 Compose 가 왜 필요한지, compose.yml 의 최소 골격, 서비스끼리 이름으로 부르는 메커니즘 (2편 네트워크의 연장), 그리고 운영에서 자주 밟는 함정 — depends_on 이 “준비 완료” 를 보장하지 않는다는 점 — 까지 정리합니다.

docker run 다섯 줄 vs compose.yml 한 파일

먼저 2편에서 손으로 띄웠던 구성을 그대로 옮겨 봅니다.

1
2
3
4
5
6
7
8
9
10
11
docker network create my-app
docker volume create pgdata

docker run -d --name db --network my-app `
  -e POSTGRES_PASSWORD=secret `
  -v pgdata:/var/lib/postgresql/data `
  postgres:16

docker run -d --name web --network my-app -p 8080:80 `
  -e DB_HOST=db `
  my-web-image

같은 걸 Compose 로 옮기면:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data

  web:
    image: my-web-image
    ports:
      - "8080:80"
    environment:
      DB_HOST: db
    depends_on:
      - db

volumes:
  pgdata:

docker compose up -d 한 줄이면 끝입니다. 네트워크는 명시하지 않아도 Compose 가 프로젝트 단위로 custom network 를 자동으로 만들어 줍니다 — 2편에서 “DNS 가 동작하려면 custom network” 라고 했던 그 조건이 기본으로 들어옵니다. 그래서 web 안에서 db 라는 이름이 그대로 호스트네임이 됩니다.

파일 이름은 docker-compose.yml 도 되고 compose.yml 도 됩니다. 신식은 compose.yml.

최소 골격 — services, networks, volumes

Compose 파일의 톱레벨은 사실상 세 개입니다.

1
2
3
4
5
6
7
8
9
10
11
services:   # 띄울 컨테이너들. 각 키가 서비스 이름이자 DNS 이름이 된다
  web:
    ...
  db:
    ...

networks:   # 명시하지 않으면 default 하나가 자동 생성된다
  ...

volumes:    # named volume 선언
  pgdata:

version: 필드를 본 적 있다면 잊어도 됩니다. v2 spec 부터는 권장되지 않는 필드입니다. 그대로 둬도 동작은 합니다.

가장 자주 쓰는 서비스 키만 추리면 다음과 같습니다.

역할
image사용할 이미지 (없으면 build: 으로 직접 빌드)
buildDockerfile 경로 또는 컨텍스트. image 와 같이 쓰면 빌드 결과에 그 이름이 붙는다
ports호스트:컨테이너 매핑. "8080:80" 처럼 문자열로 적는 게 안전
environment / env_file환경변수. 비밀 값은 .env 또는 env_file 로 분리
volumesbind mount (./app:/app) 또는 named volume (pgdata:/var/lib/...)
depends_on시작 순서 의존성
restartno / on-failure / always / unless-stopped

서비스 이름이 DNS 가 된다

2편의 핵심 결론이 “같은 custom network 에 있으면 컨테이너 이름이 호스트네임” 이었습니다. Compose 는 이걸 기본 동작으로 만듭니다.

flowchart LR
    subgraph proj[Compose 프로젝트 default network]
        web[web<br/>:80]
        db[db<br/>:5432]
        cache[cache<br/>:6379]
    end
    web -- "http://db:5432" --> db
    web -- "redis://cache:6379" --> cache

web 의 환경변수 DB_HOST=db, REDIS_HOST=cache 면 충분합니다. 호스트의 IP 도, 컨테이너 IP 도 신경 쓸 일이 없습니다. 같은 compose.yml 안에 있다는 것 자체가 “같은 그물 안” 을 보장합니다.

다른 Compose 프로젝트와 통신해야 할 때만 networks: 를 명시적으로 선언해서 external: true 로 공유합니다. 같은 프로젝트 안에서는 거의 손댈 일이 없습니다.

depends_on 의 함정 — “시작” 과 “준비 완료” 는 다르다

처음 Compose 를 쓰는 사람이 거의 다 밟는 함정이 이겁니다.

1
2
3
web:
  depends_on:
    - db

이렇게 적으면 “db 가 켜진 뒤에 web 이 시작” 됩니다. 하지만 켜진 것과 받을 준비가 된 건 다릅니다. postgres:16 컨테이너는 시작 직후 몇 초 동안 초기화 중이라 5432 포트가 아직 닫혀 있을 수 있습니다. 그 사이 web 이 부팅되어 DB 에 붙으려다 connection refused 로 죽습니다.

해결책은 healthcheck + condition: service_healthy 입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10

  web:
    image: my-web-image
    depends_on:
      db:
        condition: service_healthy

이제 webpg_isready 가 OK 를 돌려준 뒤에야 시작합니다. healthcheck 가 없는 이미지면 직접 만들어 줘야 하고, 만약 healthcheck 로직을 짜기 애매하면 애플리케이션 쪽에 재시도 로직 을 두는 게 더 깔끔합니다. 어차피 운영에서 DB 가 재시작될 수도 있으니, 클라이언트의 retry 는 어떻게든 필요합니다.

환경변수와 .env

비밀 값은 Compose 파일에 박지 않고 같은 디렉토리의 .env 파일로 뺍니다.

1
2
3
# .env
POSTGRES_PASSWORD=secret
APP_SECRET_KEY=sk-xxxx
1
2
3
4
5
6
# compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

⚠️ .env반드시 .gitignore 에 추가합니다. 예시용으로 .env.example 만 키 이름과 더미 값으로 커밋해 두는 패턴이 흔합니다.

더 많은 변수를 서비스에 한꺼번에 주입하고 싶다면 env_file: .env.web 처럼 파일 단위로 묶는 것도 됩니다.

자주 쓰는 명령 한 줌

1
2
3
4
5
6
7
8
docker compose up -d           # 백그라운드로 전체 기동
docker compose down            # 전체 정리. -v 를 붙이면 named volume 도 삭제
docker compose ps              # 이 프로젝트의 컨테이너 상태
docker compose logs -f web     # web 서비스 로그 follow
docker compose exec web sh     # 실행 중인 web 컨테이너에 셸 진입
docker compose restart web     # 특정 서비스만 재시작
docker compose pull            # image 만 최신화
docker compose build           # build: 가 있는 서비스만 다시 빌드

docker compose down -v 는 named volume 까지 지웁니다. DB 데이터가 사라지므로 운영에서는 절대 금물.

개발 / 운영 — override 와 profile

같은 서비스를 개발에서는 소스 코드 마운트 + 핫리로드로, 운영에서는 빌드된 이미지로 띄우고 싶을 때가 많습니다. Compose 는 두 가지 장치를 줍니다.

1) compose.override.yml 자동 병합

compose.yml 옆에 compose.override.yml 이 있으면 docker compose up 이 자동으로 둘을 병합합니다. 개발용 오버라이드를 여기에 둡니다.

1
2
3
4
5
6
7
8
# compose.override.yml — 로컬 전용
services:
  web:
    build: .
    volumes:
      - ./src:/app/src
    environment:
      DEBUG: "true"

운영 배포 시에는 override 가 없는 환경에서 compose.yml 만 적용되도록 두거나, docker compose -f compose.yml -f compose.prod.yml up -d 처럼 명시합니다.

2) profiles

특정 서비스를 평소엔 안 띄우고 필요할 때만 띄우고 싶을 때 (예: 디버그용 mailhog, e2e 테스트 러너).

1
2
3
4
services:
  mailhog:
    image: mailhog/mailhog
    profiles: ["dev"]

docker compose --profile dev up -d 일 때만 같이 뜹니다.

남는 것

Compose 의 본질은 “여러 docker run + network create + volume create 를 한 파일에 적은 것” 그 이상도 이하도 아닙니다.

그래서 1·2편의 모델 — 이미지/컨테이너, 네트워크, DNS — 이 머리에 있으면 Compose 는 거의 새로 배울 게 없습니다. 새로 신경 쓸 점은 두 가지뿐:

  1. depends_on 은 “시작 순서” 이지 “준비 완료” 가 아니다. healthcheck 또는 클라이언트 재시도.
  2. 비밀 값은 .env 로 빼고 .gitignore 에 올린다.

다음 편은 이 구성에서 거의 손대지 않고 지나간 한 가지 — 볼륨과 데이터 영속화 — 를 다룰 예정입니다. 컨테이너를 지워도 DB 데이터는 살아 있어야 하는 이유, bind mount 와 named volume 의 차이, 그리고 운영에서 볼륨을 백업하는 패턴까지.


참고

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.