컨테이너 내부에만 파일을 기록하면 파드가 사라질 때 데이터도 잃을 수 있습니다. Deployment가 새 파드를 만들거나 스케줄러가 파드를 다른 노드로 옮겨도 학습 결과·업로드 파일·체크포인트를 계속 사용하려면, 파드와 별도로 유지되는 저장 공간이 필요합니다.
애플리케이션 YAML마다 NFS 서버 주소와 경로를 직접 넣는 방법도 있지만, 이 경우 스토리지를 교체할 때 모든 워크로드를 수정해야 합니다. 누가 어떤 디렉터리를 쓰는지 중앙에서 관리하기도 어려워지므로 플랫폼이 제공하는 스토리지와 애플리케이션의 요청을 나눠 관리할 필요가 있습니다.
Kubernetes는 이 문제를 StorageClass → PersistentVolumeClaim(PVC) → PersistentVolume(PV) → CSI Driver → 실제 스토리지라는 계층으로 나눕니다. 이 글에서는 각 객체가 무엇을 추상화하는지 이해한 뒤, 기존 NFS 서버 위에 NFS CSI Driver를 설치하고 RWX 볼륨을 동적으로 만듭니다.
이 글에서 다루는 것
- 파드 볼륨과 영구 볼륨이 다른 이유
- PV, PVC, StorageClass, CSI Driver의 책임
- RWO, ROX, RWX, RWOP 접근 모드의 정확한 의미
- NFS가 MLOps 공유 데이터에 잘 맞는 경우와 맞지 않는 경우
- 최소 권한에 가까운 NFS export 구성
- NFS CSI Driver와 StorageClass 설치
- 여러 파드가 같은 PVC를 읽고 쓰는 전체 실습
- PVC Pending, mount 실패, permission denied를 진단하는 순서
- NFS 서버 HA, 성능, 보안, 백업과 reclaim policy 설계
실습 환경과 치환값
아래 IP는 문서용 예시입니다. 실제 환경에서는 NFS 서버와 모든 Kubernetes 노드가 통신할 수 있는 사설 주소로 바꿉니다.
| 의미 | 문서 예시 | 치환 기준 |
|---|---|---|
| NFS 서버 | 192.0.2.50 |
NFS_SERVER_IP |
| Kubernetes 노드 대역 | 192.0.2.0/24 |
export와 방화벽에서 허용할 실제 노드 대역 |
| NFS export | /srv/nfs/k8s |
NFS_EXPORT |
| StorageClass | nfs-csi |
플랫폼의 명명 규칙에 맞춤 |
| CSI Driver Chart | 4.13.2 |
NFS_CSI_VERSION |
| 실습 namespace | storage-lab |
삭제 가능한 격리 공간 |
이 글은 학습을 위해 Linux 한 대를 NFS 서버로 사용합니다. 이 서버가 멈추면 모든 마운트가 영향을 받으므로 운영 환경에서는 HA NAS, 분산 파일시스템, 관리형 파일 스토리지 등 장애 요구사항을 충족하는 구현을 선택해야 합니다.
Kubernetes 스토리지 객체를 한 번에 이해하기
Pod는 /shared에 연결할 PVC를 참조합니다. PVC에는 RWX와 1Gi 요구를 적습니다.
Pod → PVC볼륨 요구
단계를 선택하면 자동 재생이 멈춥니다. 선의 번호와 아래 설명을 함께 읽어 주세요.
전체 단계 한눈에 읽기
- 앱이 필요한 볼륨 요청
Pod는 /shared에 연결할 PVC를 참조합니다. PVC에는 RWX와 1Gi 요구를 적습니다.
- Pod → PVC: 볼륨 요구
- 제공 정책과 Driver 선택
nfs-csi StorageClass는 nfs.csi.k8s.io Driver와 NFS 설정을 지정합니다.
- PVC → StorageClass: StorageClass 선택
- StorageClass → NFS CSI Driver: provisioner 지정
- 기존 NFS에 공간 마련
Driver는 준비된 NFS export 아래에 PVC별 하위 디렉터리를 만듭니다. NFS 서버 자체를 새로 설치하는 단계는 아닙니다.
- NFS CSI Driver → 기존 NFS export: 하위 디렉터리 생성
- PV와 PVC 연결
생성된 볼륨은 PV 객체로 등록되고 PVC에 바인딩됩니다.
- NFS CSI Driver → PV 객체: PV 생성
- PV 객체 → PVC: 바인딩
- 노드에서 마운트
각 노드의 kubelet이 NFS 볼륨을 마운트하면 Pod가 /shared로 접근할 수 있습니다.
- PV 객체 → 노드에서 NFS mount: 마운트할 볼륨
- 노드에서 NFS mount → Pod: 볼륨 마운트
도식 원문
flowchart LR
APP["Pod: /shared가 필요"] --> PVC["PVC: RWX 1Gi 요청"]
PVC --> SC["StorageClass: nfs-csi"]
SC --> DRIVER["CSI Driver: nfs.csi.k8s.io"]
DRIVER --> NFS["NFS export 아래 subdirectory 생성"]
DRIVER --> PV["PV 객체 생성"]
PV --> PVC
PV --> MOUNT["각 노드 kubelet이 NFS mount"]
MOUNT --> APPPersistentVolumeClaim: 애플리케이션의 요청서
PVC는 “어떤 제품의 어느 장비를 써 달라”가 아니라 다음과 같은 요구를 표현합니다.
- 필요한 용량
- 접근 모드
- 사용할 StorageClass
- 필요하다면 volume mode와 selector
애플리케이션은 PVC 이름만 파드의 volumes에서 참조합니다. NFS 서버 주소를 알 필요가 없습니다.
StorageClass: 플랫폼의 제공 정책
StorageClass는 어떤 provisioner가 어떤 파라미터로 볼륨을 만들지 정합니다. NFS CSI에서는 server, share, subDir, 삭제 동작, mount option 등이 핵심입니다.
StorageClass를 선택할 때는 “스토리지 종류 이름”뿐 아니라 제공되는 정책도 확인해야 합니다. 같은 NFS라도 nfs-rwx-retain, nfs-rwx-ephemeral처럼 보존·성능·백업 정책에 따라 class를 나눌 수 있기 때문입니다.
PersistentVolume: 클러스터가 알고 있는 실제 볼륨
PV는 PVC와 실제 storage volume 사이의 바인딩을 나타내는 클러스터 범위 객체입니다. 동적 프로비저닝에서는 사용자가 PV를 직접 만들지 않습니다. external provisioner가 PVC를 감지해 CSI Driver에 볼륨 생성을 요청하고, 결과를 PV로 등록합니다.
CSI Driver: Kubernetes와 스토리지 사이의 표준 어댑터
CSI(Container Storage Interface)는 Kubernetes 핵심 코드에 각 벤더의 스토리지 로직을 넣지 않고 외부 driver가 볼륨 생명주기와 mount를 구현하게 합니다.
NFS CSI Driver의 플러그인 이름은 nfs.csi.k8s.io입니다. 이 driver가 NFS 서버를 만들어 주는 것은 아닙니다. 이미 준비된 NFSv3 또는 NFSv4 서버가 필요합니다. 동적 프로비저닝 시에는 export 아래에 PVC별 subdirectory를 만들고 그것을 하나의 PV처럼 다룹니다.
접근 모드: RWX를 제대로 읽는 법
| 약어 | Kubernetes 이름 | 의미 |
|---|---|---|
| RWO | ReadWriteOnce |
한 노드에서 읽기/쓰기 mount 가능 |
| ROX | ReadOnlyMany |
여러 노드에서 읽기 전용 mount 가능 |
| RWX | ReadWriteMany |
여러 노드에서 읽기/쓰기 mount 가능 |
| RWOP | ReadWriteOncePod |
클러스터 전체에서 단일 파드만 읽기/쓰기 가능 |
RWO는 “파드 하나만 사용”이라는 뜻이 아닙니다. 같은 노드의 여러 파드가 사용할 수 있는 구현도 있습니다. 정말 단일 파드만 허용하려면 CSI 볼륨에서 RWOP를 검토합니다.
RWX는 여러 노드가 볼륨을 mount할 수 있다는 의미입니다. 접근 모드만으로 파일 잠금이나 애플리케이션 트랜잭션까지 처리되지는 않습니다. 두 프로세스가 같은 파일을 동시에 수정할 때 데이터를 안전하게 유지하려면 애플리케이션의 쓰기 동작과 파일 잠금 방식을 함께 살펴봐야 합니다.
왜 NFS를 사용하는가
NFS의 가장 큰 장점은 표준 IP 네트워크 위에서 여러 노드가 같은 디렉터리를 공유할 수 있다는 점입니다.
MLOps에서는 다음 패턴에 잘 맞습니다.
- 여러 학습 파드가 읽는 공용 데이터셋
- 노트북 사용자의 공유 작업 디렉터리
- 모델 체크포인트와 중간 산출물
- 다수 파드가 읽는 정적 리소스
- 공유가 필요한 CI cache와 테스트 데이터
다만 “공유할 수 있다”는 특성만으로 “모든 데이터에 적합하다”고 판단하기는 어렵습니다. 다음과 같이 워크로드가 요구하는 성능과 운영 조건을 함께 비교해야 합니다.
| 요구 | NFS의 특성 | 판단 |
|---|---|---|
| 다중 노드 파일 공유 | RWX를 자연스럽게 제공 | 적합 |
| 대용량 순차 읽기 | 네트워크·서버 대역폭이 충분하면 효율적 | 측정 필요 |
| 작은 파일을 매우 자주 생성 | metadata latency와 서버 병목 가능 | 부하 시험 필요 |
| 낮은 지연과 높은 IOPS의 DB | 네트워크 파일시스템 특성과 충돌 가능 | 블록 스토리지 우선 검토 |
| 수십 TB 데이터셋 배포·버전 관리 | 파일 공유만으로 계보와 버전을 해결하지 못함 | object storage·catalog 병행 검토 |
| 노드 장애와 무관한 서비스 연속성 | 단일 NFS 서버는 SPOF | HA 구현 필요 |
NFS와 SAN 블록 스토리지도 경쟁 관계로만 보면 안 됩니다. 공유 파일에는 NFS, 단일 writer의 지연 민감 데이터에는 블록 볼륨을 사용하는 식으로 워크로드 특성에 따라 함께 쓸 수 있습니다.
실습 1: NFS 서버 준비
1. 패키지와 디렉터리
NFS 서버에서 실행합니다.
export NFS_SERVER_IP='192.0.2.50'
export NFS_EXPORT='/srv/nfs/k8s'
export K8S_NODE_CIDR='192.0.2.0/24'
sudo apt-get update
sudo apt-get install -y nfs-kernel-server nfs-common
sudo install -d -o nobody -g nogroup -m 0770 "${NFS_EXPORT}"실습 예제에서 볼 수 있는 chmod 0777과 no_root_squash 조합은 모든 클라이언트 root에 서버 root에 가까운 쓰기 권한을 줄 수 있습니다. 이 글에서는 이를 기본값으로 사용하지 않고, 모든 접근을 익명 UID/GID로 매핑해 하나의 격리된 export를 사용하도록 구성합니다.
2. export 정책
sudo tee /etc/exports.d/kubernetes.exports >/dev/null <<EOF
${NFS_EXPORT} ${K8S_NODE_CIDR}(rw,sync,no_subtree_check,root_squash,all_squash,anonuid=65534,anongid=65534)
EOF
sudo exportfs -rav
sudo systemctl enable --now nfs-kernel-server
sudo exportfs -v각 옵션의 의미는 다음과 같습니다.
rw: 읽기와 쓰기 허용sync: 서버가 안정적인 저장을 확인한 뒤 응답no_subtree_check: export 하위 경로 검사로 인한 문제를 줄임root_squash: client root를 서버 root로 인정하지 않음all_squash: 실습 export의 모든 요청을 익명 계정으로 통일anonuid,anongid: 익명 요청을nobody:nogroup에 해당하는 숫자로 매핑
all_squash를 사용하면 학습용 공유 공간의 권한을 단순하게 구성할 수 있지만, 사용자별 소유권은 구분할 수 없습니다. 여러 테넌트가 함께 사용하는 운영 환경에서는 전용 export, NFSv4 identity mapping, Kerberos, NAS ACL, namespace별 스토리지 계정 등을 검토해 필요한 격리를 설계합니다.
3. 방화벽 범위
NFSv4.1을 기준으로 Kubernetes 노드 대역에서 2049/TCP만 허용합니다. 아래 UFW 명령은 실제 방화벽 정책과 충돌하지 않는지 검토한 뒤 적용합니다.
sudo ufw allow from "${K8S_NODE_CIDR}" to any port 2049 proto tcp
sudo ufw statusNFSv3를 사용하면 rpcbind와 추가 동적 포트가 필요할 수 있습니다. 이 글은 방화벽과 운영을 단순화하기 위해 NFSv4.1을 사용합니다.
실습 2: 모든 Kubernetes 노드에서 NFS mount 확인
CSI node plugin이 mount를 수행하려면 각 Linux 노드에 mount.nfs가 있어야 합니다. control-plane과 agent를 포함해 볼륨을 사용할 수 있는 모든 노드에 설치합니다.
sudo apt-get update
sudo apt-get install -y nfs-common
command -v mount.nfsCSI를 설치하기 전에 host에서 직접 mount를 시험하면 네트워크와 Kubernetes 문제를 분리할 수 있습니다.
export NFS_SERVER_IP='192.0.2.50'
export NFS_EXPORT='/srv/nfs/k8s'
sudo install -d -m 0755 /mnt/nfs-check
sudo mount -t nfs4 \
-o nfsvers=4.1,hard,timeo=600,retrans=2 \
"${NFS_SERVER_IP}:${NFS_EXPORT}" /mnt/nfs-check
echo "nfs check from $(hostname)" | \
sudo tee "/mnt/nfs-check/$(hostname).txt"
sudo ls -la /mnt/nfs-check
sudo umount /mnt/nfs-check각 노드에서 파일 생성과 unmount가 성공하는지 확인한 뒤 다음 단계로 넘어갑니다. host의 직접 mount가 실패하는 상태에서는 CSI를 설치해도 볼륨을 사용할 수 없으므로, NFS 서버의 export, 방화벽, route, DNS, 권한부터 점검하고 수정해야 합니다.
NFS 서버에서도 파일이 보이는지 확인합니다.
sudo find "${NFS_EXPORT}" -maxdepth 1 -type f -ls실습 3: NFS CSI Driver 설치
1. Helm Chart 설치
2026-07-14 기준 이 글은 NFS CSI Driver 4.13.2를 고정합니다. 실제 설치 전에는 공식 repository의 지원 Kubernetes 버전과 changelog를 확인합니다.
export NFS_CSI_VERSION='4.13.2'
helm repo add csi-driver-nfs \
https://raw.githubusercontent.com/kubernetes-csi/csi-driver-nfs/master/charts
helm repo update csi-driver-nfs
helm upgrade --install csi-driver-nfs \
csi-driver-nfs/csi-driver-nfs \
--namespace kube-system \
--version "${NFS_CSI_VERSION}" \
--set kubeletDir=/var/lib/kubelet \
--wait --timeout 10m위 명령은 RKE2가 kubelet 데이터를 /var/lib/kubelet에 제공하는 기본 구성을 기준으로 합니다. data-dir나 kubelet root를 바꾼 환경에서는 chart의 kubeletDir과 실제 경로를 일치시켜야 합니다.
2. controller와 node plugin 확인
kubectl -n kube-system get deployment,daemonset,pod \
-l app.kubernetes.io/name=csi-driver-nfs
kubectl get csidriver nfs.csi.k8s.ioChart label이 릴리스에 따라 다르면 이름으로 확인합니다.
kubectl -n kube-system get pods | grep csi-nfs
kubectl -n kube-system get daemonset csi-nfs-node
kubectl -n kube-system get deployment csi-nfs-controllercontroller는 PVC를 보고 subdirectory와 PV를 만들며, node DaemonSet은 파드가 배치된 노드에서 NFS를 mount합니다. DaemonSet의 DESIRED와 READY가 일치해야 모든 노드에서 볼륨을 사용할 수 있습니다.
실습 4: StorageClass 생성
NFS 서버 값만 바꾸면 적용할 수 있도록 shell 환경 변수를 사용합니다. heredoc 안의 \${pvc...}는 shell이 아니라 CSI Driver가 나중에 치환해야 하므로 $를 이스케이프합니다.
export NFS_SERVER_IP='192.0.2.50'
export NFS_EXPORT='/srv/nfs/k8s'
cat > nfs-storageclass.yaml <<EOF
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs-csi
provisioner: nfs.csi.k8s.io
parameters:
server: ${NFS_SERVER_IP}
share: ${NFS_EXPORT}
subDir: \${pvc.metadata.namespace}/\${pvc.metadata.name}
onDelete: retain
mountPermissions: "0770"
reclaimPolicy: Retain
volumeBindingMode: Immediate
allowVolumeExpansion: true
mountOptions:
- nfsvers=4.1
- hard
- timeo=600
- retrans=2
EOF
kubectl apply -f nfs-storageclass.yaml
kubectl get storageclass nfs-csi -o yaml이 실습에서는 nfs-csi를 default StorageClass로 지정하지 않습니다. default class를 바꾸면 storageClassName을 생략한 모든 PVC의 동작에 영향을 주기 때문입니다. 플랫폼 전체에 적용되는 설정이므로 충분한 검증과 마이그레이션 계획을 갖춘 뒤 변경해야 합니다.
보존 정책을 이중으로 확인하는 이유
reclaimPolicy: Retain: PVC가 삭제되어도 PV를 자동 삭제하지 않습니다.onDelete: retain: 관리자가 PV를 삭제하더라도 NFS subdirectory를 유지하도록 driver에 지시합니다.
이 실습에서는 실수로 데이터를 삭제할 가능성을 줄이기 위해 보존을 기본값으로 사용합니다. 이 정책을 사용하면 Released PV와 남은 디렉터리를 별도로 정리하는 운영 절차가 필요합니다. 임시 cache처럼 재생성할 수 있는 데이터에는 별도 StorageClass를 만들고 Delete 정책을 적용합니다.
또한 PVC의 1Gi 요청이 NFS 서버의 실제 디렉터리 quota를 자동으로 강제한다고 가정하면 안 됩니다. 범용 NFS CSI의 subdirectory 방식에서는 요청 용량이 Kubernetes 스케줄링과 객체 정보로 쓰이더라도 서버 측 quota는 별도 구현이 필요합니다.
실습 5: RWX PVC를 여러 파드에서 사용
1. PVC와 writer Deployment
cat > nfs-rwx-test.yaml <<'EOF'
apiVersion: v1
kind: Namespace
metadata:
name: storage-lab
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: shared-data
namespace: storage-lab
spec:
accessModes:
- ReadWriteMany
storageClassName: nfs-csi
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nfs-writer
namespace: storage-lab
spec:
replicas: 2
selector:
matchLabels:
app: nfs-writer
template:
metadata:
labels:
app: nfs-writer
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app: nfs-writer
containers:
- name: writer
image: busybox:1.37.0
command:
- sh
- -c
- |
while true; do
date -Iseconds >> "/shared/${HOSTNAME}.log"
sleep 10
done
volumeMounts:
- name: shared
mountPath: /shared
volumes:
- name: shared
persistentVolumeClaim:
claimName: shared-data
EOF
kubectl apply -f nfs-rwx-test.yaml
kubectl -n storage-lab get pvc,pod -w정상이라면 PVC가 Bound되고 writer 두 개가 Running이 됩니다. anti-affinity는 서로 다른 노드를 선호하지만 강제하지 않습니다. 노드가 두 대 이상인 환경에서는 실제로 분산되었는지 반드시 확인합니다.
kubectl -n storage-lab get pod -l app=nfs-writer \
-o custom-columns=NAME:.metadata.name,NODE:.spec.nodeName,PHASE:.status.phase두 파드가 서로 다른 노드에 배치되었는지를 확인해야 다중 노드 RWX 검증으로 이어갈 수 있습니다. 두 파드가 한 노드에만 있다면 이후 읽기·쓰기 확인으로 검증할 수 있는 범위는 다중 파드 공유까지이며, 다중 노드 검증으로 표현해서는 안 됩니다.
2. 공유 파일 확인
각 파드에서 같은 디렉터리 목록이 보이는지 확인합니다.
for pod in $(kubectl -n storage-lab get pod -l app=nfs-writer \
-o jsonpath='{.items[*].metadata.name}'); do
echo "=== ${pod} ==="
kubectl -n storage-lab exec "${pod}" -- sh -c \
'ls -l /shared && tail -n 2 /shared/*.log'
done두 hostname의 로그 파일이 어느 파드에서나 보이면 동일한 NFS subdirectory가 mount된 것입니다.
NFS 서버에서도 namespace/PVC 패턴의 디렉터리를 확인합니다.
sudo find /srv/nfs/k8s -maxdepth 3 -type f -name '*.log' -ls예상 구조는 다음과 같습니다.
/srv/nfs/k8s/
└── storage-lab/
└── shared-data/
├── nfs-writer-...log
└── nfs-writer-...log3. reader 파드로 재마운트 확인
writer에서 파일을 읽는 것만으로는 새 파드가 같은 볼륨을 mount할 수 있는지 확인하기 어렵습니다. 별도 reader를 만들어 기존 파일을 읽도록 하고, 새 파드의 mount 경로까지 확인합니다.
cat > nfs-reader.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: nfs-reader
namespace: storage-lab
spec:
restartPolicy: Never
containers:
- name: reader
image: busybox:1.37.0
command: ["sh", "-c", "ls -l /shared; tail -n 2 /shared/*.log"]
volumeMounts:
- name: shared
mountPath: /shared
readOnly: true
volumes:
- name: shared
persistentVolumeClaim:
claimName: shared-data
readOnly: true
EOF
kubectl apply -f nfs-reader.yaml
kubectl -n storage-lab wait pod/nfs-reader \
--for=jsonpath='{.status.phase}'=Succeeded --timeout=3m
kubectl -n storage-lab logs nfs-readerreader 로그에서 두 writer 파일이 보이면 새 파드의 read-only mount까지 성공한 것입니다.
검증 체크리스트와 예상 결과
kubectl get csidriver nfs.csi.k8s.io
kubectl -n kube-system get daemonset csi-nfs-node
kubectl get storageclass nfs-csi
kubectl -n storage-lab get pvc shared-data
kubectl -n storage-lab get pod -o wide
kubectl get pv완료 기준은 다음과 같습니다.
CSIDriver/nfs.csi.k8s.io가 등록되어 있습니다.csi-nfs-node가 스케줄 가능한 모든 Linux 노드에서 Ready 상태입니다.- PVC
shared-data가Bound이고 접근 모드에RWX가 보입니다. - writer 두 개가 같은 PVC에 서로 다른 파일을 지속적으로 기록합니다.
- 다중 노드 클러스터에서는 writer가 서로 다른 노드에 배치되어도 같은 파일 목록을 봅니다.
- NFS 서버 export 아래에
storage-lab/shared-data디렉터리가 생성됩니다.
자주 만나는 문제
PVC가 Pending에 머문다
kubectl -n storage-lab describe pvc shared-data
kubectl -n storage-lab get events --sort-by=.lastTimestamp
kubectl -n kube-system logs deployment/csi-nfs-controller \
-c nfs --tail=200Chart의 컨테이너 이름이 다르면 kubectl describe pod로 실제 이름을 먼저 확인합니다. 주요 원인은 다음과 같습니다.
- StorageClass 이름 또는 provisioner 오타
- NFS CSI controller가 준비되지 않음
server나share값 오류- NFS export root에 subdirectory를 만들 권한이 없음
- NFS 서버 방화벽 또는 route 문제
이벤트의 ProvisioningFailed 메시지를 먼저 읽습니다. PVC를 반복 삭제·생성하면 최초 오류 증거만 잃을 수 있습니다.
파드가 ContainerCreating에서 멈춘다
kubectl -n storage-lab describe pod \
-l app=nfs-writer
kubectl -n storage-lab get events --sort-by=.lastTimestamp
kubectl -n kube-system get pod -o wide | grep csi-nfs-nodeMountVolume.SetUp failed와 함께 mount.nfs: command not found가 보이면 해당 노드에 nfs-common이 없습니다. access denied by server는 export 대역·경로를, No route to host는 방화벽과 routing을, Protocol not supported는 NFS 버전과 서버 설정을 확인합니다.
permission denied가 난다
NFS 서버에서 실제 export와 디렉터리 숫자 UID/GID를 봅니다.
sudo exportfs -v
sudo stat -c '%a %u:%g %n' /srv/nfs/k8s
sudo find /srv/nfs/k8s -maxdepth 3 -printf '%m %u:%g %p\n'root_squash 환경에서 컨테이너 root는 서버 root가 아닙니다. 이를 이유로 즉시 no_root_squash와 0777을 적용하지 않습니다. StorageClass의 mountPermissions, export의 익명 UID/GID, 파드 securityContext의 runAsUser, runAsGroup, fsGroup을 하나의 권한 모델로 맞춥니다.
한 파드의 수정이 다른 파드에서 늦게 보인다
애플리케이션 버퍼링과 NFS client attribute cache를 구분합니다. 먼저 sync 또는 파일 close 후 읽기로 애플리케이션 버퍼를 배제합니다. noac 같은 강한 mount option은 성능을 크게 떨어뜨릴 수 있으므로 증거 없이 추가하지 않습니다.
PVC를 지웠는데 NFS 디렉터리가 남는다
이 실습에서는 Retain과 onDelete: retain을 사용해 PVC 삭제 후에도 데이터를 보존하도록 구성합니다. 따라서 NFS 디렉터리가 남는 것은 의도한 동작입니다. PV와 디렉터리의 owner, 보존 기간, 백업 완료 여부를 확인한 다음 승인된 정리 절차에 따라 삭제합니다.
PVC의 요청 용량보다 더 많이 쓸 수 있다
범용 NFS CSI의 subdirectory는 자동 quota 경계가 아닙니다. NAS quota, 별도 export, filesystem project quota 등 서버 측 기능을 함께 구성하거나, quota를 지원하는 벤더 CSI를 선택합니다. Kubernetes의 PVC 숫자만 보고 실제 용량 격리가 되었다고 판단하지 않습니다.
정리 절차: Retain 정책을 이해하고 삭제하기
실습 자원을 지우기 전에 PV 이름과 서버 subdirectory를 기록합니다.
export TEST_PV="$(kubectl -n storage-lab get pvc shared-data \
-o jsonpath='{.spec.volumeName}')"
echo "${TEST_PV}"
kubectl delete namespace storage-lab
kubectl get pv "${TEST_PV}"정상이라면 PV가 Released로 남습니다. 데이터가 필요 없는 실습임을 확인하고 백업도 필요 없을 때만 PV를 삭제합니다.
kubectl delete pv "${TEST_PV}"onDelete: retain 때문에 NFS 디렉터리는 남을 수 있습니다. 서버 파일 삭제는 Kubernetes 복구가 어려운 파괴적 작업입니다. 정확한 경로와 보존 정책을 검토한 뒤 스토리지 관리자 권한으로 별도 수행합니다.
운영과 보안 고려사항
단일 NFS 서버를 운영 HA로 오해하지 않는다
Kubernetes가 파드를 다른 노드로 재스케줄하더라도 NFS 서버가 한 대라면 해당 서버의 장애에 계속 영향을 받습니다. 목표 RPO/RTO를 충족하려면 NAS controller 이중화, 복제, failover IP, filesystem 일관성, 백업 복구 시간을 함께 검증해야 합니다.
sec=sys는 네트워크와 숫자 UID를 신뢰한다
일반 NFS sec=sys는 client가 전달한 UID/GID를 기반으로 권한을 판단하고 전송 자체를 암호화하지 않습니다. 스토리지 전용 VLAN, 방화벽 allowlist, NFSv4 Kerberos, 암호화된 네트워크 경로, NAS ACL 중 환경에 맞는 통제를 적용합니다. Kubernetes Secret을 NFS 공유에 평문 파일로 모아 두지 않습니다.
공유 쓰기는 애플리케이션 동시성 문제를 만든다
두 학습 작업이 같은 checkpoint 파일명을 쓰면 RWX가 정상이어도 결과가 손상될 수 있습니다. run ID나 모델 버전별 디렉터리를 사용하고, atomic rename·file lock·단일 writer 같은 규칙을 애플리케이션에 둡니다.
성능을 평균값 하나로 판단하지 않는다
NFS 성능은 서버 디스크, NIC, mount option, 파일 크기, 동시 client 수에 따라 크게 달라집니다. MLOps에서는 대용량 순차 읽기와 수많은 작은 파일 metadata 작업을 따로 측정합니다. 학습 GPU가 I/O를 기다리는 시간과 NFS 서버의 CPU·network·disk latency를 함께 관찰합니다.
스냅샷 지원을 당연하게 가정하지 않는다
CSI API에 VolumeSnapshot이 있다고 모든 NFS backend가 효율적인 snapshot을 제공하는 것은 아닙니다. 범용 NFS CSI의 snapshot 구현과 서버 기능, 데이터 크기, 정합성을 검증합니다. 대규모 디렉터리를 단순 복사·압축하는 방식은 snapshot이라는 이름과 달리 긴 시간과 추가 용량을 소비할 수 있습니다.
백업은 PV 객체가 아니라 데이터와 복구 절차를 포함한다
PV/PVC YAML에는 NFS 파일 내용이 들어 있지 않으므로 이 객체만 백업해서는 데이터를 복구할 수 없습니다. export 데이터와 함께 StorageClass 파라미터, server identity, 권한·ACL을 보관하고, 복구 후 애플리케이션 검증까지 하나의 runbook으로 관리합니다.
요약
- PVC는 애플리케이션의 요구, StorageClass는 플랫폼 정책, PV는 실제 바인딩, CSI Driver는 스토리지 동작을 구현합니다.
- NFS CSI Driver는 NFS 서버를 만들지 않고 기존 export 아래에 PVC별 subdirectory를 동적으로 만듭니다.
- NFS의 RWX는 공유 데이터와 MLOps 산출물에 유용하지만 DB의 낮은 지연, 서버 HA, quota, 데이터 계보를 자동으로 해결하지 않습니다.
0777과no_root_squash를 기본 예제로 사용하지 않고 export 범위, squash, UID/GID, 파드 securityContext를 함께 설계합니다.- 검증은 PVC
Bound에서 끝나지 않습니다. 서로 다른 파드와 가능하면 서로 다른 노드에서 같은 데이터를 읽고 쓰는지 확인해야 합니다. Retain정책은 삭제 사고를 줄이는 대신 Released PV와 잔여 디렉터리의 운영 절차를 요구합니다.
이전 글: Kubernetes 트래픽은 어디로 들어오는가 — kube-vip, MetalLB, Gateway API
다음 글: SSO와 Keycloak 이해하기 — Realm, Client, OIDC와 SAML