요약

SUMMARY

사내 클러스터의 DB를 백업본에서 복구하던 중 임시파일 하나를 만드는 데 수 분씩 걸려 복구가 멈췄다. 원인은 NFS 서버의 sync export와 동시 쓰기 과부하였다. exports를 async(+no_wdelay)로 바꾸고 nfsd 스레드 수를 늘린 뒤, 클라이언트 마운트 옵션(rsize/wsize/hard/timeo)까지 조정하여 쓰기 지연을 제거했다. 대신 async가 지니는 데이터 손실 위험은 UPS·애플리케이션 fsync·백업으로 인지하고 감수하기로 했다.

우리 온프레미스 클러스터는 스토리지를 NFS 한 대에 몰아서 사용하고 있었다. DB 데이터를 NFS에 두는 것이 이상적인 구성이 아니라는 것은 알고 있었지만(랜덤 I/O가 많은 DB와 네트워크 파일시스템은 잘 맞지 않는다) 당장 별도 블록 스토리지가 없는 환경이라 그렇게 운영되고 있던 상황이었다. 문제는 MariaDB Galera를 물리 백업본에서 복구하던 날 발생했다.

1. 복구 중 임시파일 하나에 수 분

mariadb-backup --copy-back으로 백업 데이터를 데이터 디렉토리에 기록하는 단계에서 복구 Job이 진행되지 않았다. 실패한 것은 아니고 단순히 느린 상태였다. 수십 GB짜리 데이터 파일을 푸는 과정에서 파일 하나를 만들고 채우는 데 체감상 수 분이 걸렸다. 복구가 언제 끝날지 가늠할 수 없는 속도였다.

서버에 붙어 상태를 봤더니 그림이 명확했다.

  • vmstat에서 CPU의 I/O 대기(wa)가 10%대로 붙어 있고, 로드 애버리지가 20을 넘겼다.
  • 커널 로그(dmesg)에 ext4_buffered_write_iter에서 블록되는 로그가 계속 찍혔다. 쓰기가 디스크 I/O를 기다리다 막히고 있다는 뜻이다.
  • 하필 그 시점에 Elasticsearch와 MariaDB의 데이터가 모두 같은 NFS 볼륨을 쓰고 있었다. 쓰기 요청이 한 디스크로 몰렸다.

즉 디스크 자체의 한계도 있었지만, 그 위에서 NFS가 쓰기를 처리하는 방식이 지연을 증폭시키고 있었다.

2. 왜 느렸는가

NFS export에는 syncasync 두 모드가 있다. 당시 서버는 안전한 기본값인 sync로 구성되어 있었는데, sync는 클라이언트가 쓰기를 보내면 그 데이터가 디스크에 실제로 기록된 뒤에야 완료 응답을 돌려준다. 데이터 안전성은 좋지만, 작은 쓰기가 대량으로 발생하는 워크로드에서는 매 요청이 디스크 flush를 기다리며 대기하게 된다.

DB 복구는 정확히 그런 워크로드였다. 백업을 해제하는 동안 작은 파일 생성과 메타데이터 갱신, 데이터 쓰기가 폭발적으로 발생하는데, sync에서는 그 하나하나가 디스크 기록 확정을 기다렸다. 거기에 디스크는 이미 다른 워크로드로 포화 상태였다. 지연이 중첩된 것이다.

수정해야 할 곳은 세 곳이었다. 서버가 쓰기를 확정하는 방식(sync), 요청을 받아 처리하는 nfsd 스레드 수, 그리고 클라이언트가 요청을 나누어 보내고 재시도하는 방식이었다. 하나만 수정해서는 부족했다.

3. 서버 쪽: export async와 nfsd 스레드 상향

가장 큰 병목이 sync였으니 여기부터 바꿨다. exports를 async로 전환한다.

# /etc/exports — 마스킹된 예시 경로/대역
# 변경 전: 쓰기를 디스크에 확정한 뒤 응답 (안전하지만 느림)
/srv/nfs   10.10.20.0/24(rw,sync,no_subtree_check,no_root_squash)
 
# 변경 후: 쓰기를 메모리에 받은 즉시 응답, 디스크 반영은 뒤로 (빠름)
/srv/nfs   10.10.20.0/24(rw,async,no_wdelay,no_subtree_check,no_root_squash)

async는 클라이언트 쓰기를 서버 메모리(페이지 캐시)에 받는 순간 완료로 응답하고, 실제 디스크 기록은 커널이 알아서 뒤로 미룬다. 매 요청이 디스크를 기다리던 대기열이 사라지므로 쓰기 응답이 즉시 돌아온다. no_wdelay도 같이 붙였는데, 사실 이 옵션은 async가 활성화되어 있으면 이미 쓰기 지연 병합을 하지 않으므로 기능적으로는 중복에 가깝다. “쓰기를 모았다가 내보내지 않겠다”는 의도를 명시해두는 용도로 남겨두었다.

바꾼 뒤 반영한다.

exportfs -ra   # /etc/exports 재적용

두 번째로 nfsd 스레드를 올렸다. NFS 서버는 커널 스레드 풀로 요청을 처리하는데 기본값이 낮으면(배포판에 따라 8 안팎) 동시 연결이 늘 때 요청이 스레드를 기다린다. 당시 활성 연결이 10개 안팎이었어서 여유를 둬 16으로 잡았다.

# 즉시 반영 (런타임)
echo 16 > /proc/fs/nfsd/threads
 
# 영구 반영 — Rocky 9 기준 /etc/nfs.conf 의 [nfsd] 섹션
# [nfsd]
# threads=16

여기에 서버의 더티 페이지가 한꺼번에 몰려 기록되며 다시 멈추는 현상을 막으려고 vm.dirty_background_ratio를 낮춰(백그라운드 flush를 더 일찍·자주 시작하도록 설정) 쓰기를 작은 단위로 지속적으로 처리하도록 했다. async 때문에 캐시에 쌓이는 데이터가 늘어난 만큼, 이 부분을 방치하면 주기적으로 대량 flush가 발생하여 지연이 되돌아온다.

4. 클라이언트 쪽 마운트 옵션

서버만 수정한다고 끝나지 않는다. 클라이언트가 요청을 어떻게 나누어 보내고, 응답이 늦을 때 어떻게 행동하는지도 지연에 직접 영향을 준다. 마운트 옵션을 다음과 같이 설정했다.

# 클라이언트 마운트 옵션 예시
mount -t nfs -o rsize=65536,wsize=65536,hard,timeo=14,retrans=2 \
  10.10.20.10:/srv/nfs  /mnt/nfs
  • rsize/wsize를 65536으로 늘렸다. 한 번에 주고받는 읽기·쓰기 블록이 커지면 대용량 순차 전송에서 왕복 횟수가 줄어 처리량이 올라간다. 백업 해제처럼 큰 파일을 연속해서 쓰는 작업에 특히 유리하다.
  • timeo=14(1.4초)·retrans=2로 재전송 타이밍을 명시했다.

hard는 의도적으로 그대로 두었다. 초기 분석에서는 타임아웃을 허용하는 soft로 바꾸자는 의견도 나왔다. 응답이 늦으면 클라이언트가 포기하고 넘어가니 프로세스가 멈추지는 않는다. 그런데 soft는 서버가 잠깐 느려졌을 때 쓰기가 알려지지 않은 채 실패할 수 있고, 그것이 DB 데이터 위에서 일어나면 파일이 깨진다. 성능 문제가 아니라 데이터 무결성 문제이므로, DB 워크로드에서는 hard를 유지하는 것이 맞다고 판단했다. 느리더라도 실패를 실패로 알리는 쪽을 택한 것이다.

5. async의 대가

async는 공짜가 아니다. 클라이언트에게는 “다 썼다”고 응답했지만 실제로는 아직 서버 메모리에만 있는 데이터가 생긴다. 이 상태에서 NFS 서버가 정전이나 커널 패닉으로 갑자기 죽으면, 클라이언트는 성공했다고 아는데 디스크에는 없는 데이터가 날아간다. sync가 원래 막아주던 게 바로 이거였다.

그래서 성능만 챙기고 위험을 외면하지는 않았다. 감당 가능한 수준까지 완화책을 마련했다.

  • UPS: async의 손실 창(window)은 결국 “데이터가 캐시에는 있고 디스크에는 없는 짧은 시간”이므로, 급작스러운 전원 차단만 막아도 현실적인 위험이 크게 줄어든다. 그래서 NFS 서버에 무정전 전원을 두는 것을 최우선 대비책으로 삼았다.
  • 애플리케이션 레벨 fsync: DB는 커밋마다 fsync로 데이터를 flush한다. 다만 서버 export가 async이면 서버는 이 flush(NFS COMMIT) 요청조차 디스크에 기록을 확정하기 전에 완료로 응답한다. 즉 async는 애플리케이션 fsync의 내구성 보장까지 약화시키므로, 서버가 그 순간 중단되면 커밋한 데이터도 잃을 수 있다. 그래도 클라이언트에 쌓인 쓰기를 서버까지 밀어내므로 손실 창을 줄이는 효과는 있다. 실질적인 안전망은 아래의 백업이다.
  • 백업: 애초에 이 장애가 백업본 복구 중에 발생했다. 정기 물리 백업 체계가 최후의 보루다. 최악의 경우에도 마지막 백업 시점으로 되돌릴 수 있다면, async가 잃을 수 있는 범위는 그 사이의 증분으로 한정된다.

결국 syncasync 선택은 “얼마나 안전하게 할 것인가”와 “얼마나 빠르게 할 것인가” 사이의 균형 조정이다. 우리 환경에서는 복구가 현실적인 시간 안에 끝나는 것이 당장 급했고, 손실 위험은 위 세 가지 대책으로 감당 가능한 수준까지 낮췄다고 판단하여 async로 전환했다. 다른 환경이었다면 결론이 달랐을 수도 있다.

NOTE

근본적으로 DB 데이터를 NFS에 두는 구성 자체가 임시방편이다. 지금은 튜닝으로 버티고 있지만, 결국에는 DB는 로컬 SSD 기반 블록 스토리지로, NFS는 공유 파일·백업 같은 순차 워크로드로 역할을 나누는 것이 맞다. 이 글은 그 이전 단계의, 기존 자원으로 버텨낸 기록에 가깝다.

참고