Skip to content
ioob.dev
Go back

Docker 입문 5편 — 볼륨과 데이터 영속성

· 8분 읽기
Docker 시리즈 (5/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

컨테이너는 죽는다, 데이터는 살아야 한다

4편에서 배운 컨테이너 생명주기를 떠올려보자. 컨테이너는 언제든 재생성된다. 배포할 때도, 서버가 재부팅될 때도, 심지어 자동 스케일 아웃될 때도. 이 과정에서 컨테이너 안 파일시스템은 전부 사라진다.

그럼 DB 데이터는? 업로드된 파일은? 캐시는? 전부 날아간다. 그래서 Docker는 아예 데이터를 “컨테이너 밖”에 두는 걸 기본 패턴으로 제시한다. 이걸 볼륨(Volume)이라 부른다.

flowchart LR
    subgraph C1["컨테이너 A (v1)"]
        APP1["앱 프로세스"]
        FS1["컨테이너 파일시스템<br/>(휘발)"]
    end

    subgraph C2["컨테이너 B (v2, 교체됨)"]
        APP2["앱 프로세스"]
        FS2["컨테이너 파일시스템<br/>(휘발)"]
    end

    VOL[("볼륨<br/>(영속)")]

    APP1 -.마운트.-> VOL
    APP2 -.마운트.-> VOL

컨테이너가 v1에서 v2로 교체돼도, 볼륨은 같은 자리에 그대로 있다. 새 컨테이너는 같은 볼륨을 다시 마운트해서 이전 데이터를 이어간다. 볼륨은 컨테이너보다 오래 산다.

세 가지 마운트 방식

Docker는 데이터를 컨테이너에 끼워주는 방식을 세 가지 제공한다.

방식설명주 용도
Named volumeDocker가 관리하는 저장 공간DB, 앱 데이터 등 운영 데이터
Bind mount호스트의 임의 디렉토리를 직접 마운트개발 중 코드 공유, 로그 수집
tmpfs메모리에만 존재하는 임시 저장 공간민감한 임시 데이터

이 셋이 쓰이는 자리가 분명히 다르다. 하나씩 파보자.

Named volume — Docker가 알아서 관리한다

가장 권장되는 방식이다. Docker가 자기만의 디렉토리 아래(/var/lib/docker/volumes/...)에 데이터를 저장하고, 이름으로 그걸 참조한다.

# 볼륨 생성
docker volume create pgdata

# 볼륨을 마운트해서 컨테이너 실행
docker run -d \
  --name postgres \
  -e POSTGRES_PASSWORD=secret \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16

# 볼륨 확인
docker volume ls
# DRIVER    VOLUME NAME
# local     pgdata

docker volume inspect pgdata
# [{ "Mountpoint": "/var/lib/docker/volumes/pgdata/_data", ... }]

-v pgdata:/var/lib/postgresql/data는 “pgdata라는 볼륨을 컨테이너 안 /var/lib/postgresql/data에 연결해라”다. 앞부분이 볼륨 이름, 뒷부분이 컨테이너 안 경로다.

컨테이너를 지우고 새로 띄워도 같은 볼륨을 마운트하면 데이터가 그대로다.

docker rm -f postgres
# 새 컨테이너로 재연결
docker run -d --name postgres -e POSTGRES_PASSWORD=secret \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16
# 이전 DB 내용이 그대로 살아 있다

Named volume을 권장하는 이유

  1. 호스트 경로에 묶이지 않는다. 호스트 디렉토리 경로가 바뀌어도 영향 없음
  2. 권한 관리가 단순하다. 이미지의 UID/GID와 맞춰 자동 초기화됨 (처음 마운트 시)
  3. Docker 수준에서 백업/이관이 쉽다. docker volume 명령으로 일관되게 다룸
  4. 드라이버 교체 가능. 로컬 볼륨뿐 아니라 NFS, AWS EBS 등 플러그인을 꽂을 수 있음
  5. OS 파일시스템 의존성이 적다. macOS/Windows의 Docker Desktop에서 bind mount보다 훨씬 빠름

DB, 메시지큐, 캐시처럼 ‘이 컨테이너가 소유하는 데이터’는 Named volume. 이게 기본 원칙이다.

Bind mount — 호스트 디렉토리를 직접 꽂는다

호스트 파일시스템의 특정 경로를 컨테이너 안으로 마운트한다. 컨테이너와 호스트가 같은 파일을 본다.

# 호스트의 현재 디렉토리를 컨테이너 /app에 마운트
docker run -d --name dev \
  -v $(pwd):/app \
  -p 3000:3000 \
  node:22-slim node /app/server.js

$(pwd)를 마운트한 덕분에 호스트에서 코드를 수정하면 컨테이너에서도 즉시 반영된다. 개발 환경에서 엄청 편하다. 정확히 말하면 이게 bind mount의 대표 용도다.

운영에선 bind mount를 조심히 쓴다. 이유가 있다.

운영에서 bind mount가 정당화되는 건 보통 이런 경우다.

# 읽기 전용 bind mount 예시
docker run -d --name nginx \
  -v $(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro \
  -p 80:80 \
  nginx:1.27

:ro는 read-only다. 컨테이너가 실수로 설정 파일을 덮어쓰는 걸 막는다.

—mount — -v의 더 명시적인 동생

같은 일을 하는 문법이 하나 더 있다. --mount-v보다 장황하지만 덜 모호하다.

# -v 스타일
docker run -v pgdata:/var/lib/postgresql/data postgres:16

# --mount 스타일
docker run --mount type=volume,source=pgdata,target=/var/lib/postgresql/data postgres:16

# bind mount도 마찬가지
docker run --mount type=bind,source=$(pwd),target=/app,readonly node:22-slim

-v는 문법이 간결한 대신 type 구분이 직관적이지 않다(경로 형태로 volume인지 bind인지 유추). --mounttype=...을 명시하므로 오타나 실수가 줄어든다. 스크립트/CI에선 --mount를 권장하는 흐름이다.

tmpfs — 디스크를 안 탄다

메모리에만 존재하는 임시 저장 공간이다. 컨테이너가 종료되면 사라진다.

docker run -d --name app \
  --tmpfs /tmp:size=64m \
  myapp

이 옵션이 쓰이는 자리는 두 가지다.

  1. 성능: 빠른 임시 저장. /tmp 같은 캐시에 유리
  2. 보안: 디스크에 안 남아야 할 민감한 데이터(세션, 토큰 등)

애플리케이션 대부분은 tmpfs를 신경 쓰지 않아도 된다. 필요할 때만 꺼내 쓰는 옵션이다.

UID/GID 권한 문제 — 초보자가 가장 많이 당한다

볼륨을 쓰다 보면 정체불명의 Permission denied를 만난다. 보통은 UID/GID 불일치 때문이다.

상황을 재현해보자. 호스트에서 현재 유저(UID 1000)의 디렉토리를 만들고, 컨테이너 안에서는 다른 UID로 그 디렉토리에 쓰기를 시도한다.

mkdir -p ./data
sudo chown 1000:1000 ./data

# PostgreSQL은 UID 70 (또는 999)로 실행됨
docker run --rm \
  -v $(pwd)/data:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret \
  postgres:16
# initdb: error: could not change permissions of directory ...

컨테이너 안 프로세스는 postgres 유저(예: UID 999)인데, 호스트 디렉토리는 UID 1000 소유다. 쓰기 권한이 없다.

해결 방법은 여러 갈래다.

1. Named volume 쓰기 (가장 단순)

Named volume은 처음 마운트될 때 Docker가 이미지의 기본 유저에 맞춰 권한을 설정해준다. 이게 “Named volume을 권장”하는 중요한 이유 중 하나다.

docker run -d --name db \
  -v pgdata:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret \
  postgres:16
# 문제 없이 뜬다

2. bind mount 쓸 때는 호스트 권한을 이미지의 UID/GID에 맞춘다

# postgres 이미지의 UID 확인
docker run --rm postgres:16 id postgres
# uid=999(postgres) gid=999(postgres)

sudo chown -R 999:999 ./data

3. 컨테이너 실행 시 UID를 호스트와 맞추기

docker run --rm \
  --user $(id -u):$(id -g) \
  -v $(pwd)/data:/app/data \
  myapp

--user는 Dockerfile의 USER 지시어를 덮어쓴다. 단, 이미지 내부에 해당 UID의 유저가 등록돼 있지 않아도 무방하지만, 일부 프로그램은 “등록된 유저”를 요구해서 에러를 낼 수 있다.

4. init 단계에서 권한 맞추기 (이미지 제작자의 선택)

데이터 볼륨을 처음 마운트할 때만 한 번 chown을 걸어 주는 entrypoint 스크립트를 두는 패턴도 흔하다.

#!/bin/sh
# entrypoint.sh
chown -R app:app /data
exec gosu app "$@"

gosusu보다 시그널을 더 잘 전달하는 대안이다. 공식 이미지들도 이 방식을 많이 쓴다.

핵심 원칙: 운영용 데이터는 Named volume을 쓴다. 꼭 bind mount를 써야 한다면 호스트 디렉토리의 UID/GID를 이미지에 맞춘다.

볼륨 백업과 복원

Named volume은 Docker가 관리하지만, 결국 호스트 파일시스템 어딘가에 있다. 백업도 어렵지 않다.

볼륨을 tar로 떠내기

docker run --rm \
  -v pgdata:/source:ro \
  -v $(pwd):/backup \
  alpine \
  tar czf /backup/pgdata-$(date +%Y%m%d).tar.gz -C /source .

무슨 일이 일어나는지 뜯어보면.

  1. 임시 alpine 컨테이너를 띄우고
  2. 백업할 볼륨(pgdata)을 /source에 read-only로 마운트
  3. 호스트의 현재 디렉토리를 /backup에 마운트
  4. /source 내용을 tar로 묶어 /backup에 저장

컨테이너는 실행이 끝나면 --rm으로 자동 삭제된다. 호스트에는 pgdata-YYYYMMDD.tar.gz가 남는다.

복원

# 새 볼륨 준비
docker volume create pgdata-restore

docker run --rm \
  -v pgdata-restore:/target \
  -v $(pwd):/backup \
  alpine \
  sh -c "cd /target && tar xzf /backup/pgdata-20260420.tar.gz"

# 새 볼륨으로 DB 실행
docker run -d --name pg-restored \
  -v pgdata-restore:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret \
  postgres:16

DB 같은 상태 서비스는 tar로 파일 덤프하는 방식보다 각 DB 고유의 백업 도구(pg_dump, mongodump 등)를 쓰는 게 안전하다. 실행 중인 파일을 복사하면 트랜잭션 중간 상태가 남을 수 있기 때문이다. tar 덤프는 “일관된 상태”를 보장하려면 컨테이너를 멈춘 뒤에 뜨는 게 정석이다.

볼륨 정리

쓰지 않는 볼륨은 조용히 디스크를 먹는다. 주기적으로 확인하자.

# 현재 어떤 컨테이너에도 연결 안 된 볼륨만 찾기
docker volume ls --filter dangling=true

# 한 번에 정리
docker volume prune

# 특정 볼륨만 삭제
docker volume rm old-cache

docker volume prune은 안전장치가 없다. 실수로 중요한 볼륨을 날리지 않도록, 정리 전에 docker volume ls --filter dangling=true로 한 번 확인하는 습관을 들이자.

Compose에서의 볼륨

단일 docker run 명령으로 볼륨을 관리하다 보면 점점 손이 많이 간다. docker compose는 이걸 선언형으로 정리한다.

# docker-compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro

volumes:
  pgdata:

volumes 섹션에서 named volume을 정의하고, 서비스에서 참조한다. ./init.sql은 bind mount다. Compose가 내부적으로 myproject_pgdata 같은 접두어를 붙인 볼륨을 만든다.

docker compose down은 컨테이너만 내린다. 볼륨은 남는다. 볼륨까지 지우려면 docker compose down -v를 명시해야 한다. 이 동작이 데이터 보호의 안전망 역할을 한다.

정리: 볼륨 선택의 기준

마지막으로 “어떤 마운트를 언제 쓰느냐”를 한 장으로 정리한다.

flowchart TD
    START["데이터를 컨테이너에 넣어야 한다"] --> Q1{"운영 데이터?<br/>(DB, 파일 업로드 등)"}
    Q1 -->|Yes| NV["Named volume"]
    Q1 -->|No| Q2{"호스트 파일을<br/>직접 공유?"}
    Q2 -->|Yes| BM["Bind mount<br/>(개발용 코드, 설정, 로그)"]
    Q2 -->|No| Q3{"디스크에<br/>남으면 안 됨?"}
    Q3 -->|Yes| TM["tmpfs"]
    Q3 -->|No| NV2["Named volume<br/>(기본값)"]

의심스러우면 Named volume이다. Bind mount는 의도가 분명할 때만 쓴다. tmpfs는 성능/보안 특수 목적에 한정.


다음 편에서는 컨테이너끼리, 또 컨테이너와 외부가 어떻게 통신하는가로 넘어간다. bridge와 host, overlay, none 드라이버의 차이, DNS로 컨테이너 이름을 찾는 구조, 포트 포워딩의 내부 동작, user-defined network의 장점까지 마지막으로 꿰맞춰본다.

6편: 네트워크


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Docker 입문 4편 — 컨테이너 생명주기
Next Post
Docker 입문 6편 — 네트워크