
홈랩을 운영하다 보면 서비스끼리 파일을 공유해야 하는 순간이 꼭 찾아온다. 내 경우에는 Syncthing으로 동기화한 Obsidian vault를 n8n 워크플로우에서 읽어야 했고, 동시에 WSL 환경에서도 같은 파일을 편집하고 싶었다. 이런 요구사항이 쌓이면서 결국 "공유 스토리지"라는 근본적인 문제를 풀어야 했다.
최종 구조는 Samsung 990 PRO 2TB SSD를 Hyper-V에서 Proxmox로, 다시 worker VM으로 3단계 패스스루한 뒤 K8s NFS Server Pod으로 네트워크 공유하는 방식이다. 오래된 기술인 NFS가 이런 상황에서 가장 실용적인 해법이 된다는 게 좀 아이러니하기도 하다.
홈랩에서 돌리는 서비스가 늘어나면서 데이터 사일로 문제가 생겼다. Syncthing이 동기화한 파일을 n8n에서 처리하려면 같은 파일 시스템에 접근해야 한다. Postiz 같은 서비스도 마찬가지고. 그런데 K8s 환경에서는 각 Pod이 자기만의 볼륨을 갖고 있어서 서비스 간 파일 공유가 쉽지 않다.
게다가 WSL에서도 동일한 파일에 접근하고 싶었다. 개발 작업은 WSL에서 하는데, K8s 안에서만 파일이 존재하면 디버깅이나 수동 편집이 번거롭다. NFS로 공유하면 K8s Pod과 WSL이 같은 파일 시스템을 바라보게 되니 이 문제가 깔끔하게 풀린다.
내 홈랩은 Windows 호스트 위에 Hyper-V로 Proxmox를 돌리는 중첩 가상화 구조다. 여기에 물리 SSD를 K8s worker VM까지 전달하려면 세 단계를 거쳐야 한다.
중첩 가상화라서 성능이 걱정됐는데, 패스스루 방식이라 실제로 써보면 성능 손실을 체감하기 어렵다. SSD를 VM 디스크 이미지(qcow2) 안에 넣는 게 아니라 물리 디스크를 직접 전달하는 거라 I/O 오버헤드가 최소화된다.
worker1에 마운트된 SSD를 K8s 안팎에서 공유하기 위해 NFS Server를 Pod으로 배포했다. 별도의 NFS 서버 VM을 띄우는 대신 K8s 안에서 관리하는 게 운영 부담이 적다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: nfs-server
namespace: shared-storage
spec:
template:
spec:
nodeSelector:
node-role: infra
containers:
- name: nfs-server
image: itsthenetwork/nfs-server-alpine:latest
env:
- name: SHARED_DIRECTORY
value: /nfsshare
securityContext:
privileged: true
volumes:
- name: ssd-storage
hostPath:
path: /mnt/ssditsthenetwork/nfs-server-alpine 이미지를 쓰면 NFS 서버 설정이 간단해진다. SHARED_DIRECTORY 환경변수 하나로 공유할 디렉토리를 지정하면 된다. privileged: true가 필요한 건 NFS 커널 모듈 때문이다. hostPath로 /mnt/ssd를 마운트해서 SSD의 파일을 NFS로 내보내는 구조다.
NFS Service는 MetalLB LoadBalancer로 고정 IP(192.168.31.35:2049)를 할당했다. 왜 ClusterIP가 아니라 LoadBalancer를 써야 하냐면, NFS 마운트는 kubelet이 호스트 레벨에서 수행하기 때문이다. Pod 안에서 K8s DNS 이름(nfs-server.shared-storage.svc)으로 접근하는 게 아니라 노드의 kubelet이 직접 마운트하므로 K8s 네트워크 바깥에서 접근 가능한 IP가 필요하다.
K8s에서 NFS 볼륨을 여러 네임스페이스의 서비스가 공유하려면 약간의 패턴이 필요하다. PVC는 네임스페이스에 종속되니까 각 소비자 네임스페이스마다 별도의 PV + PVC 쌍을 만들어야 한다.
핵심은 모든 PV가 같은 NFS 루트(/)를 가리키게 하는 것이다. 이렇게 하면 Syncthing이 /shared/obsidian에 파일을 동기화하면 n8n에서도 같은 경로로 접근할 수 있다.
apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs-syncthing
spec:
capacity:
storage: 500Gi
nfs:
server: 192.168.31.35 # MetalLB IP
path: /
mountOptions:
- nfsvers=4
claimRef:
namespace: syncthing
name: shared-nfsclaimRef를 지정해서 PV와 PVC의 바인딩을 고정하는 게 포인트다. 이걸 빼면 K8s가 임의로 PV를 배정할 수 있어서 의도하지 않은 볼륨이 연결될 위험이 있다. Syncthing용, n8n용, 다른 서비스용으로 각각 PV-PVC 쌍을 만들되 전부 같은 NFS path: /를 가리키면 자연스럽게 파일 공유가 된다.
처음에는 소비자별로 NFS 경로를 다르게 줬다. Syncthing은 /syncthing, n8n은 /n8n 식으로. 깔끔해 보였는데 정작 서비스 간 파일 공유라는 원래 목적을 달성할 수가 없었다. Syncthing이 동기화한 파일을 n8n에서 읽으려면 두 서비스가 같은 경로를 봐야 하니까. 결국 모든 PV의 path를 /로 통일하는 리팩토링을 했다.
nfs-server-alpine은 NFSv4 전용 서버다. 그런데 PV에 mountOptions: [nfsvers=4]를 안 넣으면 클라이언트가 NFSv3로 먼저 시도하다가 실패한다. 에러 메시지가 직관적이지 않아서 원인을 찾는 데 시간이 좀 걸렸다. NFS 버전을 명시하는 습관을 들이는 게 좋다.
PV의 nfs.server에 K8s 서비스 DNS 이름을 넣으면 안 된다. NFS 마운트를 수행하는 주체가 Pod이 아니라 kubelet(호스트 프로세스)이기 때문이다. kubelet은 K8s CoreDNS를 사용하지 않으므로 nfs-server.shared-storage.svc 같은 이름을 해석할 수 없다. MetalLB가 할당한 외부 IP를 써야 한다. 이 부분은 한 번 삽질하면 잊어버리기 어려운데, 처음 접하면 놓치기 쉬운 포인트다.
분산 시스템에서 스토리지 공유는 가장 기본적이면서도 귀찮은 문제다. 이번에 구축한 구조를 요약하면 이렇다.
NFS는 1984년에 나온 프로토콜이다. 40년이 넘은 기술인데도 이런 상황에서는 CIFS나 S3 같은 대안보다 훨씬 쓰기 편하다. 설정이 단순하고 POSIX 호환이 완벽하며 거의 모든 OS에서 지원한다. 새로운 기술이 항상 답은 아니라는 걸 다시 한번 느꼈다.