BlueNyang
[Cloud Infra] 여러 컨테이너를 한 장으로, 그리고 남는 데이터
Docker
· IaaS 배포 실전 (3편)

[Cloud Infra] 여러 컨테이너를 한 장으로, 그리고 남는 데이터

BlueNyangBlueNyang
·
·
약 9분
# docker# docker-compose# volume# named volume# bind volume# cloud infra
시리즈·IaaS 배포 실전(3개의 글)
  • ···
  • 2.[Cloud Infra] 도커의 시작
  • 3.[Cloud Infra] 여러 컨테이너를 한 장으로, 그리고 남는 데이터현재

지난 글에서는 서버에 도커 엔진을 올리고, Dockerfile로 프론트엔드와 백엔드 이미지를 직접 만들었습니다. docker buildfrontend:1.0, backend:1.0을 굽고 docker run -d -p로 각각 띄워 봤으니, 이제 "내 코드가 담긴 컨테이너"까지는 서버 위에 올라간 상태입니다.

다만 실제 서비스는 컨테이너 하나로 끝나지 않습니다. 프론트엔드, 백엔드, 데이터베이스가 최소 세 개고, 이들은 서로를 찾아야 하며, 그중 데이터베이스는 컨테이너를 지워도 데이터가 남아야 합니다. 2편 마지막에 미뤄 둔 쓰기 레이어 문제가 여기서 실제 문제가 됩니다. docker rm을 한 번 하면 그 안에서 만들어진 모든 데이터가 함께 사라집니다.

이번 글에서는 이 세 컨테이너를 docker-compose.yaml 한 장에 묶고, 볼륨으로 데이터를 컨테이너 바깥에 빼두고, 도커 내부 네트워크에서 IP 대신 서비스명으로 통신하는 구조까지 만들어 보겠습니다.

Note
명령어는 이전 글들과 같이 Rocky Linux(RHEL) 기준이며, 2편에서 설치한 docker-compose-plugin이 있다고 가정합니다. 플러그인 방식이므로 명령어는 하이픈이 없는 docker compose입니다.

1. docker run이 쌓이면 생기는 일

1.1. 명령어 히스토리에 남은 인프라

2편의 방식대로 세 개를 올린다고 해 보겠습니다. 데이터베이스까지 붙이면 명령은 대략 이렇게 길어집니다.

bash
docker network create study-net

docker run -d --name study-postgres \
  --network study-net \
  -e POSTGRES_DB=studydb \
  -e POSTGRES_USER=studyuser \
  -e POSTGRES_PASSWORD='...' \
  -v pg-data:/var/lib/postgresql/data \
  postgres:15-alpine

docker run -d --name study-backend \
  --network study-net \
  -p 3000:3000 \
  -e DATABASE_HOST=study-postgres \
  backend:1.0

docker run -d --name study-frontend \
  --network study-net \
  -p 8080:80 \
  frontend:1.0

동작은 합니다. 문제는 이 설정이 어디에도 기록되어 있지 않다는 점입니다. 서버를 한 번 갈아엎거나, 팀원이 같은 환경을 만들어야 하거나, 두 달 뒤에 포트를 하나 바꾸려는 순간, 근거로 남는 것이 셸 히스토리뿐입니다.

"그때 무슨 옵션을 줬더라"를 복원하느라 시간을 쓰는 일은 코드가 아니라 절차가 문서화되지 않아 생기는 비용입니다. 개발자의 반복 동작은 자동화하거나 최소한 파일로 남겨야 한다고 생각하는 이유이기도 합니다.

백엔드의 DATABASE_HOST에 IP가 아니라 study-postgres를 적은 부분을 기억해 두시면 좋습니다. 사용자 정의 네트워크에 붙은 컨테이너는 도커 내장 DNS에 컨테이너 이름으로 등록되므로, --name에 준 값이 그대로 호스트명이 됩니다. 뒤에서 볼 Compose는 여기서 한 걸음 더 나아가 서비스명을 쓰는데, 그 차이는 4장에서 정리하겠습니다.

1.2. Compose가 대신하는 것

Docker Compose는 위의 실행 옵션들을 YAML(YML) 파일 하나에 명세하고, 그 명세대로 컨테이너·네트워크·볼륨을 한 번에 만드는 도구입니다. 새로운 런타임이 아니라, docker run에 주던 옵션을 파일로 옮긴 것에 가깝습니다.

이렇게 되면 인프라 구성이 코드와 같은 취급을 받습니다. 저장소에 docker-compose.yaml을 넣어두면, 저장소를 내려받은 누구나 docker compose up -d 한 줄로 같은 구성을 재현할 수 있습니다. 이 시리즈의 뒷부분에서 Traefik과 GHCR을 붙일 때도 이 파일을 계속 고쳐 쓰게 됩니다.

1.3. YAML(YML)을 쓸 때 걸리는 지점

Compose 파일은 YAML(YML)입니다. 문법 자체는 단순하지만, 처음 쓸 때 자주 막히는 규칙이 몇 가지 있습니다.

  • 들여쓰기에 탭 문자(\t)를 쓸 수 없습니다. 공백 2칸을 기준으로 계층을 만듭니다.
  • 키와 값을 잇는 콜론 뒤에는 공백이 필요합니다. (image: postgres로 씁니다. — 공백이 없는 image:postgres는 키와 값으로 갈라지지 않아 파싱이 깨집니다.)
  • 리스트를 나타내는 대시 뒤에도 공백이 필요합니다. (- study-net)

에디터에서 탭이 자동으로 들어가는 설정이라면, 이 파일 하나 때문에 원인 모를 파싱 에러를 보게 됩니다. YAML 전용 포매터나 에디터의 "탭을 공백으로" 설정을 먼저 확인해 두는 편이 낫습니다.

2. docker-compose.yaml 한 장 읽기

2.1. 파일의 뼈대

Compose 파일의 최상위 키는 대체로 세 개입니다.

최상위 키역할
services띄울 컨테이너 각각의 명세. 키 이름이 곧 서비스명
volumes컨테이너 바깥에 데이터를 남기기 위한 Named Volume 선언
networks컨테이너들이 묶일 가상 네트워크 선언

예전 자료에는 파일 맨 위에 version: "3.8" 같은 줄이 있는 경우가 많습니다. 이 키는 Compose V1 시절의 파일 포맷 버전이고, 현재의 docker compose(V2)에서는 무시되며 경고만 출력합니다. 새로 쓰는 파일이라면 넣지 않아도 됩니다.

2.2. 통합 예시

프론트엔드, 백엔드, PostgreSQL을 한 파일에 묶으면 다음과 같습니다.

yaml
# docker-compose.yaml
services:
  # 1. 데이터베이스
  db:
    image: postgres:15-alpine
    container_name: study-postgres
    restart: always
    environment:
      POSTGRES_DB: studydb
      POSTGRES_USER: studyuser
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pg-data:/var/lib/postgresql/data
    networks:
      - study-net

  # 2. 백엔드 API (NestJS)
  backend:
    image: backend:1.0
    container_name: study-backend
    restart: always
    ports:
      - "3000:3000"
    environment:
      DATABASE_HOST: db # IP가 아니라 서비스명
      DATABASE_PORT: 5432
      DATABASE_DB: studydb
      DATABASE_USER: studyuser
      DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
    depends_on:
      - db # 기동 순서만 보장합니다 (4.3에서 보완)
    networks:
      - study-net

  # 3. 프론트엔드 (Vite 빌드 + Nginx)
  frontend:
    image: frontend:1.0
    container_name: study-frontend
    restart: always
    ports:
      - "8080:80"
    networks:
      - study-net

volumes:
  pg-data:

networks:
  study-net:
    driver: bridge

image에 적은 backend:1.0, frontend:1.0은 2편에서 docker build -t로 만든 로컬 이미지입니다. 아직 레지스트리에 올리지 않았으므로 이 서버에서 빌드한 이미지가 그대로 쓰입니다. 이 자리를 GHCR 경로로 바꾸는 것이 5편의 내용입니다.

container_name은 2편에서 --name에 주던 값입니다. 2편에서는 app-backend처럼 붙였지만, 여기서는 Compose 프로젝트 기준으로 study- 접두사에 맞췄습니다. 이름을 고정하면 docker ps 출력은 읽기 쉬워지지만 같은 서비스를 두 개 이상 띄울 수 없게 되므로, 컨테이너를 새로 띄우고 교체하는 배포가 필요해지는 5편에서 이 줄을 다시 보게 됩니다.

restart: always는 데몬이 재시작되면 사람이 직접 내려 둔 컨테이너까지 다시 올립니다. 그게 부담스러우면 unless-stopped가 무난한 선택입니다.

${POSTGRES_PASSWORD}는 같은 디렉터리의 .env 파일에서 값을 읽습니다. 비밀번호를 YAML 본문에 직접 적으면 그 파일은 그대로 저장소에 올라갑니다.

Warning
.env는 반드시 .gitignore에 넣고, .env.example만 올립니다. 한 번 푸시된 값은 커밋을 되돌려도 히스토리에 남습니다.

A11yMARKET에서는 환경 변수 파일이 그대로 PR에 포함된 적이 있었습니다. 그때는 값을 바꾸고 넘어갔지만, 저장소 히스토리는 그대로 남는다는 걸 확인한 뒤로 커밋 훅과 시크릿 스캐너를 기본 구성으로 두게 됐습니다.

2.3. 자주 쓰는 명령

bash
# 백그라운드로 전체 기동
docker compose up -d

# 상태 확인
docker compose ps

# 실시간 로그 (서비스명 생략 시 전체)
docker compose logs -f backend

# 컨테이너와 네트워크 정리 (볼륨은 남음)
docker compose down

# 볼륨까지 함께 삭제
docker compose down -v

up -d를 처음 실행하면 네트워크가 만들어지고, 볼륨이 만들어지고, 그다음 서비스가 순서대로 올라가는 출력이 보입니다. 앞서 docker run으로 손수 하던 순서를 그대로 대신하고 있는 셈입니다.

docker compose up -d 실행 시 network/volume/service가 순서대로 생성되는 터미널 출력
docker compose up -d 실행 시 network/volume/service가 순서대로 생성되는 터미널 출력

3. 데이터 영속성과 볼륨

3.1. 쓰기 레이어는 컨테이너와 함께 사라진다

2편에서 컨테이너는 이미지 레이어 위에 쓰기 레이어를 하나 얹고 시작한다고 정리했습니다. 컨테이너 안에서 만들어진 파일은 모두 이 쓰기 레이어에 쌓이고, 컨테이너를 삭제하면 같이 없어집니다. 데이터베이스 파일도 예외가 아닙니다.

이 부분은 설명보다 직접 확인하는 쪽이 빠릅니다. 위 구성을 띄운 상태에서 DB에 들어가 행을 하나 넣어 둡니다.

bash
docker compose exec db psql -U studyuser -d studydb
sql
CREATE TABLE ping (id serial PRIMARY KEY, note text);
INSERT INTO ping (note) VALUES ('before down');
SELECT * FROM ping;

그다음 두 가지를 비교해 봅니다. 각각을 실행한 뒤 위의 psql로 다시 들어가 SELECT * FROM ping;을 해 보면 차이가 그대로 보입니다.

bash
# 1) 컨테이너만 지우고 다시 올리기 — 데이터가 남아 있다
docker compose down
docker compose up -d

# 2) 볼륨까지 지우고 다시 올리기 — DB가 초기 상태로 돌아간다
docker compose down -v
docker compose up -d

차이를 만드는 것은 volumes: - pg-data:/var/lib/postgresql/data 한 줄입니다. PostgreSQL이 데이터를 쓰는 경로를 컨테이너 바깥의 볼륨으로 연결해 두었기 때문에, 컨테이너가 사라져도 그 경로의 내용은 호스트에 남습니다.

Caution
down -v는 선언된 Named Volume을 지웁니다. 운영 중인 서버에서 "컨테이너가 이상하니 내렸다 올리자"는 생각으로 습관처럼 -v를 붙이면 DB가 통째로 초기화됩니다. 정리 용도라면 down만 쓰고, -v는 의도적으로 초기화할 때만 붙이는 편이 안전합니다.

3.2. Named Volume과 Bind Mount

볼륨을 연결하는 방법은 크게 두 가지입니다.

구분Named VolumeBind Mount
표기- pg-data:/var/lib/postgresql/data- ./data:/var/lib/postgresql/data
저장 위치도커가 관리하는 영역(/var/lib/docker/volumes/...)호스트의 지정한 경로
관리 주체도커 엔진이 생성·권한을 처리호스트 OS의 디렉터리 구조와 권한에 종속
주 용도DB 데이터 등 컨테이너가 소유하는 상태설정 파일 주입, 개발 중 소스 동기화
컨테이너 내부 경로와 Named Volume·Bind Mount가 연결되는 구조
컨테이너 내부 경로와 Named Volume·Bind Mount가 연결되는 구조

3.3. 어느 쪽을 쓸 것인가

기준은 "그 데이터를 누가 소유하는가"로 잡으면 대체로 갈립니다.

데이터베이스 파일처럼 컨테이너 안의 프로세스가 소유하고 특정 UID로 써야 하는 데이터는 Named Volume이 편합니다. 도커가 권한을 맞춰 주기 때문에, 호스트의 사용자 권한과 어긋나 컨테이너가 부팅 중 죽는 문제를 덜 만납니다. Bind Mount로 DB 데이터를 붙였다가 권한 문제로 막히는 경우는 흔합니다.

반대로 Traefik 설정 파일이나 Nginx 설정처럼 사람이 편집하고 컨테이너는 읽기만 하는 파일은 Bind Mount가 낫습니다. 호스트에서 바로 수정하고 컨테이너만 다시 올리면 되기 때문입니다. 4편에서 Traefik의 동적 설정 파일과 acme.json을 붙일 때 이 방식을 쓰게 됩니다.

4. 내부 네트워크와 서비스 디스커버리

4.1. 서비스명이 곧 호스트명

같은 브리지 네트워크에 묶인 컨테이너들은 도커 내장 DNS를 통해 서로를 서비스명으로 찾습니다. 위 파일에서 백엔드의 DATABASE_HOST에 IP가 아니라 db를 적은 이유가 이것입니다.

컨테이너 IP는 재기동할 때마다 바뀔 수 있는 값이므로, 애플리케이션 설정에 IP를 적는 순간 그 설정은 다음 배포에서 깨집니다. 서비스명은 파일에 적힌 이름 그대로이므로 바뀌지 않습니다.

1.1.의 docker run에서 study-postgres라고 적어야 했던 자리가 여기서는 db로 짧아진 것도 같은 이유입니다. docker run은 컨테이너 이름을 DNS에 등록하지만, Compose는 그와 별개로 서비스명을 네트워크 별칭으로 함께 등록합니다. 그래서 container_namestudy-postgres로 고정해 두어도 db로 찾을 수 있습니다.

study-net 안에서 backend가 서비스명 db로 PostgreSQL을 찾고, db에는 외부 포트가 열려 있지 않은 구조
study-net 안에서 backend가 서비스명 db로 PostgreSQL을 찾고, db에는 외부 포트가 열려 있지 않은 구조

한 가지 덧붙이면, networks: 선언을 생략해도 Compose는 프로젝트마다 기본 네트워크를 자동으로 만들고 서비스명 DNS도 그대로 동작합니다. 위 파일에서 study-net을 굳이 명시한 것은, 4편에서 Traefik이 이 네트워크에 함께 붙어야 하기 때문입니다.

실제로 이름이 풀리는지는 백엔드 컨테이너 안에서 확인할 수 있습니다.

bash
# 서비스명이 컨테이너 IP로 풀리는가
docker compose exec backend getent hosts db

# DB가 접속을 받을 준비가 됐는가
docker compose exec db pg_isready -U studyuser -d studydb

첫 명령에서 IP와 db가 같이 찍히면 이름 해석은 정상입니다. 애플리케이션 로그만 보고 원인을 추측하기 전에, 이 두 줄로 네트워크 문제인지 애플리케이션 설정 문제인지를 먼저 가를 수 있습니다.

4.2. DB에 ports를 열지 않는 이유

위 예시에서 db 서비스에는 ports가 없습니다. 의도한 것입니다.

ports는 호스트의 포트를 컨테이너로 연결하는 설정이고, 이는 곧 서버 바깥에서 그 포트로 접근할 수 있게 된다는 뜻입니다. 백엔드는 브라우저가 직접 호출해야 하므로 열어야 하지만, 데이터베이스를 호출하는 것은 백엔드 컨테이너뿐입니다. 같은 네트워크 안에 있으면 ports 없이도 통신이 되므로, 굳이 5432를 바깥에 노출할 이유가 없습니다.

5432:5432를 열어 둔 서버가 인터넷에 그대로 붙어 있는 구성은 스캐너에 바로 잡히는 종류의 노출입니다. 로컬 개발 도구로 DB를 직접 보고 싶다면, 포트를 여는 대신 SSH 터널을 쓰는 편이 낫습니다. 1편에서 만들어 둔 SSH 접속을 그대로 활용할 수 있습니다.

4.3. depends_on이 보장하지 않는 것

depends_on: - db는 기동 순서만 정합니다. DB 컨테이너가 시작된 다음에 백엔드를 시작할 뿐, PostgreSQL이 접속을 받을 준비를 마쳤는지는 보지 않습니다. 그래서 첫 up -d에서 백엔드가 연결 실패로 죽는 장면을 흔하게 만납니다.

방법은 두 가지입니다. 하나는 헬스체크가 통과할 때까지 기다리게 하는 것입니다.

yaml
# 전체가 아니라 달라지는 부분만 발췌한 것입니다
services:
  db:
    image: postgres:15-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U studyuser -d studydb"]
      interval: 5s
      timeout: 3s
      retries: 5

  backend:
    image: backend:1.0
    depends_on:
      db:
        condition: service_healthy

다른 하나는 애플리케이션 쪽에 재시도를 두는 것입니다. 저는 두 번째를 기본으로 두는 쪽을 선호합니다. 운영 중에 DB가 잠시 끊기는 상황은 기동 시점 말고도 생기고, 그때 복구를 컴포즈 설정이 대신해 주지는 않기 때문입니다. restart: always와 함께 두면 일시적인 장애에서 스스로 복구할 여지가 생깁니다.

5. 정리

이번 글에서는 2편에서 만든 이미지들을 docker-compose.yaml 한 장으로 묶었습니다. 흩어져 있던 docker run 옵션이 파일로 남으면서, 서버를 다시 세우거나 다른 사람이 같은 구성을 재현하는 일이 명령 한 줄로 줄었습니다.

데이터 쪽에서는 컨테이너의 쓰기 레이어가 삭제와 함께 사라진다는 2편의 이야기를 볼륨으로 마무리했습니다. DB처럼 컨테이너가 소유하는 상태는 Named Volume으로, 사람이 편집하는 설정 파일은 Bind Mount로 나누는 기준까지가 이번 범위입니다. 네트워크에서는 IP 대신 서비스명으로 통신하고, 바깥에서 부를 필요가 없는 컨테이너는 ports를 열지 않는 구성을 잡았습니다.

다만 지금 상태는 프론트엔드가 8080, 백엔드가 3000처럼 포트 번호를 달고 서 있습니다. 브라우저 주소창에 포트를 붙여야 하고, HTTPS도 아직 없습니다. 다음 글에서는 Traefik을 최전방에 세워 80/443 하나로 요청을 받고, 도메인에 따라 알맞은 컨테이너로 나눠 보내며, Let's Encrypt로 인증서를 자동 발급받는 구조를 만들어 보겠습니다.

BlueNyang
작성자BlueNyang
BlueNyang

BlueNyang

BlueNyang의 개발 log

카테고리

  • Development
  • Framework
  • Language
  • Dev Tools
  • DevOps & Infra
  • Studies

페이지

© 2026 BlueNyang. All rights reserved.

Made with Nuxt.js and Directus