쿠버네티스 스토리지는 왜 이렇게 복잡한가
emptyDir 수명부터 CSI 볼륨 마운트 3단계, StatefulSet의 안정적 ID 보장, 그리고 스토리지 선택이 DB 성능을 결정하는 이유까지 추적한다.
- 01 쿠버네티스는 왜 모든 것을 API Server를 통해서만 통신하는가
- 02 쿠버네티스 파드는 어떻게 태어나고 사라지는가
- 03 쿠버네티스 네트워킹은 어떻게 동작하는가
- 04 쿠버네티스 스토리지는 왜 이렇게 복잡한가
- 05 Kubernetes는 자원을 어떻게 나누고 지키는가
- 06 Kubernetes 배포는 왜 이렇게 설계됐는가
- 07 쿠버네티스는 어떻게 스스로를 운영하는가
“파드를 재시작했더니 데이터가 사라졌다.” 쿠버네티스 운영 초기에 누구나 한 번쯤 겪는 장애다. 반대로 “PVC를 삭제했더니 DB 데이터가 날아갔다”도 흔한 사고다. 두 사고의 뿌리는 같다 — 쿠버네티스 스토리지가 볼륨 종류마다 수명이 다르고, 계층마다 담당이 나뉜다는 사실을 모르는 것이다. 이 복잡함은 설계 실수가 아니라, 컨테이너·노드·클러스터·클라우드 디스크라는 서로 다른 수명 주기를 하나의 API로 추상화한 결과다. 이 추상화 뒤에 무엇이 있는가?
볼륨 수명은 누가 결정하는가
쿠버네티스 볼륨을 구분하는 가장 중요한 기준은 수명이다.
emptyDir는 파드와 함께 태어나고 파드와 함께 사라진다. 컨테이너가 OOM으로 재시작되어도 emptyDir 데이터는 남는다 — 파드 uid 디렉토리가 살아있기 때문이다. 파드가 다른 노드로 재스케줄링될 때 비로소 삭제된다. 컨테이너 재시작과 파드 재시작이 다르다는 것이 핵심이다.
hostPath는 노드에 영속한다. 파드가 삭제되어도 노드의 /var/log는 그대로다. 그래서 DaemonSet 기반 로그 수집 에이전트가 hostPath를 쓴다 — Fluentd가 /var/log/pods를 마운트해 모든 파드의 로그를 읽는 패턴이 대표적이다.
configMap과 secret은 쿠버네티스 오브젝트 수명을 따른다. 그리고 여기에 중요한 차이가 숨어 있다.
configMap을 볼륨으로 마운트하면 kubelet이 symlink 원자적 교체를 통해 약 1~2분 내 자동 갱신한다. envFrom/env로 주입하면 환경변수는 프로세스 시작 시 복사되므로 파드 재시작 없이는 절대 반영되지 않는다. secret 볼륨은 tmpfs(메모리 기반)로 마운트되어 노드 디스크에 평문으로 기록되지 않는다.
kubelet이 볼륨을 마운트하는 순서
파드가 노드에 배정되면 kubelet은 컨테이너를 실행하기 전에 볼륨을 준비한다. 순서는 다음과 같다.
1. Volume Manager 준비
ConfigMap/Secret → API Server에서 데이터 조회
emptyDir → 로컬 디렉토리 생성
PVC → CSI 드라이버 호출 (Attach/Mount)
2. /var/lib/kubelet/pods/<pod-uid>/volumes/ 에 배치
3. containerd에 컨테이너 생성 요청 시
config.json의 mounts[] 에 볼륨 경로 포함
→ 컨테이너 시작 시 bind mount로 연결
모든 Init Container와 메인 컨테이너는 같은 볼륨 경로를 바라본다. Init Container가 emptyDir에 파일을 기록하고 종료해도 데이터는 유지되고, 메인 컨테이너가 그 파일을 읽을 수 있다. 이 패턴으로 초기화 데이터를 메인 컨테이너에 전달한다.
CSI — 스토리지 드라이버를 쿠버네티스 밖으로
PVC가 실제 클라우드 디스크에 연결되는 과정은 세 단계로 나뉜다: Provision → Attach → Mount.
CSI 이전에는 AWS EBS 드라이버가 kubelet 바이너리 안에 포함(in-tree)되어 있었다. EBS 드라이버 버그가 kubelet 버그가 됐고, 드라이버 업데이트를 위해 쿠버네티스 전체 릴리즈가 필요했다. CSI는 드라이버를 외부 프로세스로 분리해 이 의존성을 끊었다.
CSI 드라이버는 두 컴포넌트로 구성된다.
Controller Plugin (Deployment, 클러스터당 수 개)
CreateVolume() → 클라우드 디스크 생성
ControllerPublishVolume() → 디스크를 노드에 Attach
external-provisioner/attacher Sidecar가 k8s API를 Watch해 호출
Node Plugin (DaemonSet, 모든 노드에 실행)
NodeStageVolume() → 파일시스템 포맷/마운트
NodePublishVolume() → 파드 경로에 bind mount
kubelet이 gRPC로 직접 호출
파드가 ContainerCreating에서 멈출 때 진단 경로도 이 구조에서 나온다. kubectl get volumeattachment로 Attach 단계 확인, kubelet 로그와 Node Plugin 로그로 Mount 단계를 추적하면 어느 계층에서 막혔는지 특정할 수 있다.
StatefulSet이 보장하는 세 가지
Deployment의 파드는 재시작되면 이름이 바뀐다. PostgreSQL Replica가 Primary를 postgres-primary.default.svc...로 참조하고 있었는데 Primary 파드가 postgres-abc123으로 재생성되면 복제가 끊긴다.
StatefulSet은 세 가지를 고정한다.
안정적 이름: postgres-0, postgres-1은 재시작 후에도 동일하다. 생성은 0→1→2 오름차순, 삭제는 2→1→0 역순으로 진행되며 각 파드가 Ready/종료 상태가 되어야 다음으로 넘어간다. 이 순서가 Primary 먼저 시작, Replica 나중 시작을 보장한다.
안정적 DNS: Headless Service(clusterIP: None)와 결합해 postgres-0.postgres-headless.default.svc.cluster.local 형태의 파드별 DNS가 생성된다. 파드가 재시작되어 IP가 바뀌어도 DNS 이름은 유지되고 새 IP를 가리킨다.
안정적 스토리지: volumeClaimTemplates가 data-postgres-0, data-postgres-1처럼 파드별 PVC를 자동 생성한다. postgres-0이 삭제되고 재생성될 때 동일한 data-postgres-0에 재연결된다. StatefulSet을 삭제해도 PVC는 자동 삭제되지 않는다 — 데이터 보호를 위한 의도적 설계다.
스토리지 선택이 성능을 결정한다
“쿠버네티스에 PostgreSQL을 올렸더니 느리다”는 문제의 원인은 거의 항상 스토리지 선택이다. 컨테이너 오버헤드는 무시할 수준이다.
fsync p99 지연 비교:
로컬 NVMe SSD: 0.1~0.5ms → 수만 TPS 가능
AWS EBS gp3: 1~5ms → 수천 TPS
AWS EBS io2: 0.5~2ms → 고성능 OLTP
NFS: 10~100ms → DB에 치명적
AWS EFS: 10ms+ → 쓰기 집약 워크로드 부적합
PostgreSQL WAL은 매 트랜잭션 커밋마다 fsync를 요구한다. NFS의 fsync 지연이 100ms라면 초당 트랜잭션은 10개가 한계다.
다중 AZ 클러스터에서는 volumeBindingMode: WaitForFirstConsumer가 필수다. 기본 Immediate 모드에서는 PVC 생성 즉시 특정 AZ의 디스크가 바인딩되는데, 파드가 다른 AZ 노드에 스케줄링되면 디스크를 마운트할 수 없다. WaitForFirstConsumer는 파드가 노드를 선택한 후 그 AZ에 맞는 디스크를 생성해 파드와 디스크가 항상 같은 AZ에 놓이게 한다.
로컬 NVMe PV는 최고 성능이지만 노드 장애 시 접근 불가 — 애플리케이션 레벨 HA(Patroni 등)가 필수다. EBS는 AZ 내 이동이 가능하지만 로컬 대비 10배 느리다. 관리형 RDS는 자동 HA·백업을 제공하지만 비용이 2~3배이고 벤더에 종속된다. 팀의 DB 운영 역량을 현실적으로 평가해야 한다 — DBA 경험 없이 k8s에서 PostgreSQL Primary/Replica를 운영하면 장애 복구에 수 시간이 걸릴 수 있다.
정리
- 볼륨 수명:
emptyDir= 파드,hostPath= 노드,configMap/secret= 쿠버네티스 오브젝트, PVC = 볼륨 정책. configMap볼륨은 symlink 교체로 자동 갱신되지만 환경변수 주입은 파드 재시작 없이 절대 반영되지 않는다.- CSI는 볼륨 연산을 Provision/Attach(Controller Plugin) — Mount(Node Plugin) 두 계층으로 분리한다. 장애 진단도 이 계층을 따라간다.
- StatefulSet은 이름·DNS·스토리지를 고정해 분산 DB가 파드 재시작 후에도 클러스터 구성을 유지하게 한다.
- 스토리지 선택이 성능을 결정한다. NFS fsync 지연은 DB에 치명적이고, 다중 AZ에서는
WaitForFirstConsumer가 필수다.
다음 글에서는 쿠버네티스가 파드의 CPU와 메모리를 어떻게 제한하는지 — requests/limits가 cgroups로 어떻게 구현되는지 추적한다.