본문으로 건너뛰기
6.1 볼륨 기초

6.1 볼륨 기초

컨테이너의 파일시스템은 이미지에서 출발하는 일회용 공간이다. 컨테이너가 죽어 kubelet이 다시 기동하면 새 컨테이너는 이미지 그대로의 깨끗한 상태로 시작하고, 이전 컨테이너가 기록한 파일은 전부 사라진다. 같은 Pod 안의 컨테이너 둘이 파일을 주고받을 공용 경로도 기본으로는 없다. 볼륨(volume)은 이 두 문제를 해결하는 장치다. Pod의 spec.volumes에 선언해 두면 각 컨테이너가 volumeMounts로 원하는 경로에 마운트해서 쓴다.

Pod 수명과 볼륨 수명

볼륨의 소속은 컨테이너가 아니라 Pod다. 이 차이가 수명을 가른다. 컨테이너가 crash로 재시작돼도 볼륨은 그대로 남아 있으므로, 새 컨테이너는 이전 컨테이너가 볼륨에 기록한 데이터를 이어서 본다. 반대로 Pod가 노드에서 삭제되면 emptyDir 같은 임시(ephemeral) 볼륨은 데이터와 함께 사라진다.

정리하면 수명의 층은 세 단계다. 컨테이너 쓰기 계층은 컨테이너 재시작에 소멸하고, 임시 볼륨은 Pod 삭제에 소멸하며, Pod보다 오래 살아야 하는 데이터는 PersistentVolume의 영역이다.

apiVersion: v1
kind: Pod
metadata:
  name: scratch-demo
spec:
  containers:
  - name: writer
    image: busybox:1.36
    command: ["sh", "-c", "while true; do date >> /work/log.txt; sleep 5; done"]
    volumeMounts:
    - name: work
      mountPath: /work
  - name: reader
    image: busybox:1.36
    command: ["sh", "-c", "tail -f /data/log.txt"]
    volumeMounts:
    - name: work
      mountPath: /data
  volumes:
  - name: work
    emptyDir: {}

같은 볼륨 work를 두 컨테이너가 서로 다른 경로에 마운트했다. writer가 /work에 쓴 파일을 reader가 /data에서 읽는다. 볼륨이 컨테이너 사이의 공유 지점이 되는 가장 단순한 형태다.

emptyDir와 hostPath

emptyDir: 스크래치 공간

emptyDir는 Pod가 노드에 배정될 때 빈 디렉터리로 만들어지는 임시 볼륨이다. 정렬 작업의 중간 파일, 다시 만들면 되는 캐시, 위 예시 같은 컨테이너 간 파일 전달에 쓴다. 데이터는 기본적으로 노드의 디스크에 저장된다.

medium: Memory를 지정하면 kubelet이 디스크 대신 tmpfs를 마운트한다. 디스크 I/O가 전혀 없는 메모리 파일시스템이라 빠르지만, 여기에 쓴 데이터는 전부 컨테이너의 메모리 사용량으로 계산되어 memory limit을 잠식하고, 노드가 재부팅되면 사라진다.

  volumes:
  - name: cache
    emptyDir:
      medium: Memory
      sizeLimit: 256Mi

sizeLimit을 지정하면 볼륨 사용량이 한도를 넘었을 때 kubelet이 Pod를 evict한다. 한도 없이 쓰면 폭주한 임시 파일이 노드의 디스크나 메모리를 채울 때까지 아무도 사용량을 제한하지 않으므로, 스크래치 용도라도 한도는 정해 두는 편이 안전하다.

hostPath: 노드 파일시스템 노출

hostPath는 노드 파일시스템의 특정 경로를 Pod 안으로 마운트한다. 노드 자체를 다루는 시스템 워크로드를 위한 볼륨이다. /var/log를 읽는 로그 수집 DaemonSet, 컨테이너 런타임 소켓에 접근해야 하는 모니터링 에이전트가 대표 사례다.

일반 워크로드의 데이터 저장에 쓰면 두 가지 문제가 생긴다. 첫째, 데이터가 특정 노드에 결합된다. Pod가 다른 노드로 다시 스케줄되면 이전 노드에 남은 데이터는 따라오지 않고, 같은 매니페스트의 Pod가 노드마다 다른 내용을 보게 된다. 둘째, 보안이다. 호스트 파일시스템에 대한 접근은 컨테이너 격리를 우회하는 통로가 되므로, 공식 문서도 꼭 필요한 파일이나 디렉터리로 범위를 좁히고 가능하면 읽기 전용으로 마운트하라고 권고한다.

type 필드는 마운트 전 검증을 담당한다. Directory는 해당 경로가 이미 디렉터리로 존재해야 통과하고, DirectoryOrCreate는 없으면 만들어 준다. 기본값(빈 문자열)은 아무 검증도 하지 않으므로 명시하는 편이 실수를 줄인다.

hostPath로 노드 경로에 쓰기 접근을 열면 그 Pod는 사실상 노드 관리 권한의 일부를 가진다. 시스템 워크로드가 아닌 Pod의 hostPath 사용은 admission 정책으로 차단하는 클러스터가 많고, 데이터 저장 용도라면 PersistentVolume으로 옮기는 것이 맞다.

설정을 파일로 전달하는 볼륨들

configMap 볼륨과 secret 볼륨은 API 오브젝트의 각 key를 파일로 노출한다. 환경 변수 주입과 달리 볼륨 방식은 원본 ConfigMap이 바뀌면 kubelet의 주기 동기화를 타고 파일 내용도 결국 갱신된다. 갱신이 즉시는 아니고, kubelet 동기화 주기와 캐시 전파 지연만큼 늦는다. 환경 변수로 주입한 값은 컨테이너를 재기동해야만 바뀐다는 점이 두 방식의 실질적인 차이다.

downwardAPI 볼륨은 Pod 자신의 메타데이터를 파일로 노출한다. Pod 이름, namespace, label, annotation, 리소스 limit 같은 값을 애플리케이션이 API 서버 호출 없이 파일로 읽는다.

projected 볼륨은 configMap, secret, downwardAPI, serviceAccountToken 네 종류의 소스를 하나의 디렉터리에 합성한다. 설정 파일과 인증서와 토큰을 각각 다른 볼륨으로 마운트하는 대신 한 경로 아래로 모을 때 쓴다. ServiceAccount 토큰이 컨테이너에 기본으로 마운트되는 것도 내부적으로는 projected 볼륨이다.

subPath와 갱신 미전파

subPath는 볼륨 전체가 아니라 그 안의 하위 경로 하나만 컨테이너 경로에 마운트한다. 볼륨 하나를 여러 용도로 나눠 쓰거나, 이미 파일이 있는 디렉터리에 설정 파일 하나만 추가할 때 유용하다. 디렉터리째 마운트하면 기존 파일이 전부 가려지는데, subPath는 그 파일 하나만 겹쳐 놓기 때문이다.

    volumeMounts:
    - name: config
      mountPath: /etc/nginx/nginx.conf
      subPath: nginx.conf

여기에 함정이 있다. configMap이나 secret 볼륨을 subPath로 마운트한 컨테이너는 원본이 바뀌어도 갱신을 받지 못한다. 공식 문서에 명시된 동작이다. 볼륨 전체 마운트는 kubelet이 심볼릭 링크를 새 버전 디렉터리로 교체하는 방식으로 갱신을 전파하는데, subPath 마운트는 특정 파일에 직접 bind mount를 걸어 두므로 링크 교체가 반영되지 않는다.

설정 갱신이 살아 있어야 하는 파일은 subPath 대신 디렉터리 단위 마운트로 받고, 경로 충돌은 애플리케이션 쪽 include 설정으로 푸는 방식을 먼저 검토한다. subPath를 유지해야 한다면 ConfigMap 변경 시 Pod 재기동까지가 배포 절차라고 간주해야 한다.

정리

볼륨은 Pod에 속하는 자원이라 컨테이너 재시작을 넘어 데이터를 보존하지만, emptyDir 같은 임시 볼륨의 수명은 Pod까지다. emptyDir는 스크래치, hostPath는 노드를 다루는 시스템 워크로드, configMap 계열은 설정 전달이라는 자리가 각각 정해져 있다. 어느 것도 Pod보다 오래 살아야 하는 데이터의 답은 아니며, 그 답은 PersistentVolume에서 시작한다.