NFS에서 살아남는 법
K8s PVC로 NFS를 쓰다 맞닥뜨린 fsync 지옥 생존기
홈랩 K8s 클러스터를 운영하면서 PVC 스토리지로 NFS를 메인으로 쓰고 있다.
이유는 단순하다. K8s에서 여러 노드에 걸쳐 Pod가 스케줄링될 때, 로컬 디스크는 특정 노드에 고정되어 버린다. 노드를 drain해야 할 일이 생기면 그 노드에 PVC가 묶인 Pod는 옮길 수가 없다. NFS를 쓰면 어느 노드에서든 마운트할 수 있으니, 스케줄링 자유도가 높아진다.
그런데 쓰다 보면 NFS가 생각보다 까다롭다는 걸 알게 된다.
오늘은 NFS 위에서 직접 겪었던 장애들과, 그때마다 어떻게 살아남았는지 기록해 본다.
NFS의 fsync 문제
NFS에서 발생하는 대부분의 문제는 fsync 레이턴시에서 온다.
로컬 SSD에서 fsync는 보통 1ms 이하다. 하지만 NFS에서의 fsync는 네트워크 RTT + NAS 디스크 flush 시간이 더해진다. 홈랩 환경에서는 이게 10~100ms 수준이 될 수 있다.
문제는 일부 소프트웨어가 fsync를 동기적으로 호출한다는 점이다. "나 디스크에 다 썼어?" 라고 확인이 올 때까지 다음 작업을 멈추고 기다린다. 로컬 디스크라면 금방 돌아오지만, NFS에서는 그 기다림이 길어진다. 길어지면 timeout이 나고, timeout이 나면 liveness probe가 실패하고, liveness probe가 실패하면 kubelet이 컨테이너를 죽인다.
이게 NFS 위에서 특정 소프트웨어가 자꾸 죽는 근본 원인이다.
사건 1: Sentry taskbroker SQLite가 자꾸 죽었다
Sentry의 taskbroker 컴포넌트는 내부적으로 SQLite를 사용한다. SQLite는 WAL(Write-Ahead Logging) 모드에서 fsync를 자주 호출한다. 트랜잭션 커밋마다 WAL 파일에 fsync를 날려 데이터 안전성을 보장하는 방식이다.
로컬 디스크에서는 전혀 문제가 없다. 하지만 PVC가 NFS에 마운트되어 있으면, 매 트랜잭션마다 NFS 서버까지 왕복하며 flush 확인을 기다린다. taskbroker가 처리할 작업이 많을수록 fsync 호출이 잦아지고, 결국 전체적으로 느려지다가 응답 불가 상태가 된다.
해결책은 단순했다. taskbroker의 SQLite 데이터는 사실 영속성이 필요 없다. 작업 큐를 임시로 담아두는 용도라 재시작되면 다시 채워진다. 그래서 PVC 대신 emptyDir로 전환했다.
sentry:
taskBroker:
persistence:
enabled: false # NFS 대신 emptyDir 사용Pod가 죽어도 작업 큐가 날아가는 건 괜찮다. 어차피 재시작하면 다시 채워지니까. 굳이 NFS에 붙들어 맬 이유가 없었다.
교훈: SQLite on NFS는 위험하다. 영속성이 필요 없다면 emptyDir이 낫다.
사건 2: Redis AOF fsync가 Sentry를 통째로 눕혔다
오늘 아침에 Sentry가 갑자기 흔들렸다. 이벤트를 보니 이랬다.
Warning Unhealthy pod/sentry-sentry-redis-master-0
Liveness probe failed: Timed out
Normal Killing pod/sentry-sentry-redis-master-0
Container redis failed liveness probe, will be restarted
Redis master가 liveness probe에 5번 연속 실패해서 kubelet에게 SIGKILL을 받았다. Exit Code는 137. 그러자 replica도 master 연결이 끊겨 18번을 재시작했고, Redis가 불안정한 동안 Sentry web과 taskworker들도 probe 실패로 같이 흔들렸다.
이전 컨테이너 로그를 보니 원인이 있었다.
* Asynchronous AOF fsync is taking too long (disk is busy?)
* Starting automatic rewriting of AOF on 17368% growth
AOF 파일이 17368% 성장해 자동 rewrite가 발생했고, 이후 NFS 디스크 I/O 부하가 높아지면서 AOF fsync가 계속 지연됐다. Redis는 기본적으로 AOF fsync를 1초 주기로 호출하는데(appendfsync everysec), NFS에서 이게 블로킹되면 Redis main thread가 멈춰버린다. main thread가 멈추면 ping에도 응답 못 하고, liveness probe가 timeout 나고, 죽는다.
디스크 용량 문제가 아니었다. NFS 볼륨에 17TB 중 3.2GB만 쓰고 있었다. 순수하게 fsync 레이턴시 문제였다.
해결책은 appendfsync no로 변경하는 것이었다.
redis:
commonConfiguration: |-
appendonly yes
appendfsync no # OS에 fsync 위임, Redis 블로킹 없음
save ""appendfsync no는 Redis가 직접 fsync를 호출하지 않고 OS kernel에게 맡긴다. OS는 적당한 타이밍에 flush한다. Redis main thread는 블로킹되지 않고, liveness probe도 정상 응답한다.
단점은 Redis나 NAS 서버가 갑자기 크래시 나면 마지막 몇 초치 write가 날아갈 수 있다는 점이다. 하지만 Sentry Redis는 캐시와 작업 큐 용도라 약간의 유실은 서비스에 치명적이지 않다.
교훈: NFS 위 Redis AOF는 appendfsync everysec/always 설정이 위험하다. appendfsync no로 전환하거나, AOF 자체를 끄는 걸 고려하라.
사건 3: Sentry filestore가 노드에 고정돼 drain이 안 됐다
이건 NFS 때문에 문제가 생긴 게 아니라, NFS를 안 써서 문제가 생긴 케이스다.
Sentry의 파일 저장소(첨부파일, 아이콘 등)를 처음에는 local-path StorageClass로 PVC를 만들었다. local-path는 특정 노드의 로컬 디렉토리를 PVC로 쓰는 방식이다. 빠르고 설정이 간단하지만, 그 노드에 Pod가 고정된다.
문제는 노드 메모리를 증설하기 위해 drain을 해야 하는 상황이 왔을 때다.
$ kubectl drain k3s-gs-worker1 --ignore-daemonsets --delete-emptydir-data
error: cannot delete Pods with local storage
local-path PVC를 쓰는 Pod는 drain 자체가 안 된다. 로컬 데이터를 잃을 수 있으니 kubelet이 강제로 막는다.
결국 PVC를 nfs-gs(NFS StorageClass)로 교체해야 했다. 마이그레이션이 생각보다 귀찮았다. 기존 PVC를 삭제하고 Helm으로 재설치해야 했고, Terminating 상태가 데드락에 걸려서 force delete까지 동원했다.
교훈: 공유가 필요하거나, 노드 이동 가능성이 있는 데이터는 처음부터 NFS에 올려라. local-path는 정말 그 노드에 고정해도 되는 경우에만 써라.
사건 4: Vault는 NFS에서 local-path로 탈출했다
반대 방향의 사례도 있다. Vault는 원래 NFS PVC를 쓰고 있었는데, 새 클러스터로 마이그레이션하면서 local-path로 바꿨다.
Vault는 Raft consensus 프로토콜로 클러스터를 구성한다. Raft도 내부적으로 WAL 파일에 fsync를 호출한다. NFS 위에서 Raft가 느리면 leader election이 지연되고, 클러스터 전체가 불안정해질 수 있다.
더 중요한 건, Vault의 HA는 Raft 자체가 보장한다. 노드 3개에 분산해서 배포하면, 한 노드가 죽어도 나머지 2개로 quorum을 유지한다. 굳이 NFS로 공유 스토리지를 쓸 필요가 없다. 오히려 local-path에 빠른 SSD를 쓰는 게 Raft 성능에 훨씬 좋다.
구 클러스터: Vault StatefulSet × NFS PVC → fsync 느림
신 클러스터: Vault StatefulSet × local-path (SSD) → Raft 빠름
교훈: 애플리케이션 자체가 HA를 보장한다면(Raft, 복제 등), 공유 스토리지 없이 local-path가 더 낫다. NFS는 공유가 진짜 필요한 곳에만 써라.
정리: NFS에서 살아남는 법
경험을 통해 정리한 기준이다.
NFS에 올려도 되는 것
| 유형 | 이유 |
|---|---|
| 정적 파일, 미디어 | fsync 빈도 낮음, 공유 필요 |
| 여러 Pod가 읽는 공유 설정 | 쓰기 드묾 |
| 노드 이동이 필요한 데이터 | NFS만이 줄 수 있는 이점 |
NFS에 올리면 위험한 것
| 유형 | 이유 | 대안 |
|---|---|---|
| SQLite (WAL 모드) | 트랜잭션마다 fsync | emptyDir (영속성 불필요 시) |
| Redis AOF | 1초마다 fsync, main thread 블로킹 | appendfsync no 또는 AOF 비활성화 |
| Raft 기반 DB (Vault, etcd) | leader election fsync 민감 | local-path SSD |
| 쓰기 빈도 높은 DB | fsync 누적 → I/O 포화 | 로컬 디스크 또는 전용 스토리지 |
체크리스트
- 이 데이터, 진짜 공유가 필요한가? → 아니면 local-path 또는 emptyDir
- 이 소프트웨어가 fsync를 얼마나 자주 호출하나? → DB, 큐 시스템은 항상 의심
- 데이터 유실이 얼마나 치명적인가? → 캐시/큐는
appendfsync no도 괜찮음 - liveness probe timeout이 충분한가? → fsync 지연 시 probe 여유 필요
NFS는 편리하다. 하지만 fsync에 민감한 소프트웨어를 NFS 위에 올리면 언제 터질지 모르는 시한폭탄이 된다. 처음 설계할 때 한 번만 더 생각하면, 새벽 장애 호출을 피할 수 있다.