서버가 느린 이유 #3 SSD인데도 느릴 때: 쓰기 증폭, fsync, 큐 깊이

6 분 소요

HDD를 SSD로 바꾸면 만사가 해결되던 시절의 감각이 남아 있습니다. 그런데 NVMe SSD를 쓰는 서버에서 1편의 진단을 따라가 보니 iostatawait가 수십 밀리초로 나오는 경우가 있습니다. 스펙시트에는 수십만 IOPS라고 적혀 있는데, 왜 이 SSD는 스펙대로 움직이지 않을까요?

답의 뼈대는 하나입니다. 스펙시트의 숫자는 특정 조건에서만 나오는 최대치이고, 실제 워크로드는 그 조건을 잘 만족하지 않습니다. 조건을 하나씩 무너뜨리는 요인이 이 글의 주제인 fsync, 쓰기 증폭, 큐 깊이입니다. 장치 스펙 읽는 법은 하드웨어 기초 #4, 실측 방법론은 하드웨어 중급 #5에서 다뤘고, 이 글은 “스펙과 실측의 차이가 어디서 나는가"에 집중합니다.

fsync: 내구성을 요구하는 순간 완전히 다른 조건이 됩니다 #

일반 쓰기는 페이지 캐시에 적어 두면 끝이라 메모리 속도로 돌아옵니다. 그런데 데이터베이스의 커밋, 메시지 큐의 저널처럼 “전원이 나가도 남아야 하는” 쓰기는 fsync로 장치까지 내려보내고 완료 확인을 기다립니다. 이 경로는 SSD 내부의 캐시도 우회하거나 플러시해야 해서, 같은 장치에서 자릿수가 다른 지연이 나옵니다.

특히 소비자용 SSD는 fsync 경로가 극단적으로 느린 제품이 많습니다. 전원 손실 보호 커패시터(PLP)가 있는 데이터센터용 SSD는 내부 캐시에 적는 즉시 완료를 보고해도 안전하므로 fsync가 수십 마이크로초에 끝나지만, PLP가 없는 소비자용은 플래시까지 실제로 기록해야 해서 fsync 하나에 밀리초 단위가 걸리기도 합니다. 순차 읽기 벤치마크로는 최상급인 소비자용 SSD가 데이터베이스를 올리는 순간 실망스러운 이유가 대부분 여기 있습니다. 초당 수천 커밋이 필요한 워크로드라면 fsync 지연이 곧 커밋 지연입니다.

fsync 성능은 fio로 직접 잴 수 있습니다.

fio: fsync 지연 측정
$ fio --name=fsynctest --rw=write --bs=4k --size=1g --fdatasync=1
  ...
  fsync/fdatasync/sync_file_range:
    sync (usec): min=280, max=18400, avg=1240.52

평균 1.2ms면 이 장치의 동기 커밋 상한은 초당 800회 언저리입니다. 스펙시트의 “쓰기 500K IOPS"와는 아무 관계가 없는 숫자입니다.

쓰기 증폭: 오래 쓰면 느려지는 구조 #

SSD의 플래시는 덮어쓰기가 안 됩니다. 지우기는 블록 단위로만 되고, 쓰기는 그보다 작은 페이지 단위로 됩니다. 그래서 컨트롤러는 빈 페이지에 새로 적고 옛 페이지를 무효로 표시해 두었다가, 나중에 유효 페이지만 다른 블록으로 옮기고 블록을 지우는 가비지 컬렉션을 돌립니다. 사용자가 1만큼 쓸 때 내부에서는 이사 비용까지 몇 배를 쓰게 되는 것, 이것이 쓰기 증폭(write amplification)입니다.

증폭이 커지는 조건은 빈 블록의 부족입니다. 디스크를 가득 채워 쓰거나, TRIM이 전달되지 않아 파일시스템이 지운 공간을 SSD가 아직 유효 데이터로 알고 있으면, GC가 옮겨야 할 유효 페이지가 많아져 쓰기마다 내부 이사가 따라붙습니다. 여기에 하나 더, 많은 TLC/QLC SSD는 쓰기를 일단 빠른 SLC 모드 영역에 받아 두는데, 지속 쓰기로 이 캐시가 소진되면 속도가 계단식으로 떨어집니다. “복사 시작 직후는 빠른데 몇십 초 지나면 뚝 떨어진다"는 증상이 전형입니다.

운영 쪽 처방은 세 가지입니다. 용량을 80〜90% 아래로 유지해 컨트롤러에 여유 블록을 남기고, fstrim.timer 같은 주기적 TRIM이 실제로 돌고 있는지 확인하고, 지속 쓰기 성능이 중요한 워크로드라면 스펙시트의 순간 최대치가 아니라 지속(sustained) 쓰기 수치와 PLP 유무를 보고 장치를 고릅니다.

큐 깊이: 스펙의 IOPS는 줄을 세워야 나옵니다 #

스펙시트의 “랜덤 읽기 1M IOPS"에는 작은 글씨가 붙어 있습니다. QD32, 즉 큐 깊이 32에 여러 워커를 띄운 조건입니다. SSD는 내부에 수십 개 채널이 있는 병렬 장치라, 요청을 잔뜩 줄 세워야 모든 채널이 일합니다.

그런데 실제 애플리케이션의 접근 패턴 상당수는 QD1에 가깝습니다. B-tree를 따라 내려가는 인덱스 탐색, 포인터를 따라가는 조회처럼 앞 결과를 봐야 다음 요청을 낼 수 있는 의존적 접근은 줄을 세울 수가 없습니다. QD1에서 성능을 정하는 것은 병렬성이 아니라 요청 하나의 왕복 지연이고, 이 값은 최상급 NVMe도 수십 마이크로초 밑으로 잘 내려가지 않습니다. QD1 4K 랜덤 읽기로 환산하면 어떤 SSD든 수만 IOPS 수준으로 수렴한다는 뜻입니다.

그래서 “스펙 1M IOPS인데 실측 20K"는 고장이 아니라 워크로드의 큐 깊이가 얕은 것일 수 있습니다. iostat -xaqu-sz(평균 큐 길이)가 1 언저리라면 장치는 성능을 다 쓰지 못하고 있는 상태입니다. 이 경우 장치를 더 빠른 것으로 바꿔도 이득이 작고, 처방은 애플리케이션 쪽 병렬화나 접근 패턴 개선, 또는 애초에 그 조회를 캐시로 흡수하는 쪽입니다. 큐 깊이별 실측 방법은 하드웨어 중급 #5에서 다뤘습니다.

클라우드 볼륨: 장치가 아니라 계약이 상한입니다 #

클라우드에서는 요인이 하나 더 얹힙니다. EBS 같은 네트워크 블록 스토리지는 볼륨과 인스턴스 각각에 IOPS·처리량 상한이 계약으로 정해져 있습니다. gp3 기본값은 3,000 IOPS라서, 물리 NVMe 감각으로 쓰면 한참 못 미치는 수준에서 await가 치솟습니다. 버스트 크레딧이 있는 볼륨 타입(gp2 등)은 크레딧 소진 순간 성능이 계단식으로 떨어져 “며칠은 멀쩡했는데 갑자기 느려졌다"는 증상을 만듭니다. 모니터링에서 볼륨의 IOPS 소비가 상한에 붙어 있는지부터 확인하고, 상한이면 장치 튜닝이 아니라 볼륨 스펙 변경이 처방입니다.

진단 순서 정리 #

증상이 “SSD인데 디스크가 병목"일 때, 순서는 이렇습니다.

  1. iostat -x: await, aqu-sz, 읽기/쓰기 비율로 증상의 모양을 잡습니다.
  2. 클라우드라면 볼륨 상한부터: IOPS·처리량이 계약 상한이나 버스트 크레딧에 걸려 있는지 확인합니다. 가장 흔하고 가장 빨리 확인됩니다.
  3. fsync 워크로드인지: 데이터베이스·저널 쓰기가 중심이면 fio --fdatasync=1로 동기 쓰기 지연을 실측합니다. 소비자용 장치라면 PLP 있는 장치로의 교체가 처방입니다.
  4. 지속 쓰기로 느려지는지: 초반만 빠르면 SLC 캐시 소진과 쓰기 증폭입니다. 용량 여유와 TRIM을 점검합니다.
  5. aqu-sz가 1 언저리인지: 얕은 큐라면 장치 교체보다 접근 패턴·캐싱이 처방입니다.

정리 #

  • 스펙시트 IOPS는 깊은 큐와 병렬 워커라는 조건의 최대치입니다. 실측과 다른 것은 고장이 아니라 조건 차이입니다.
  • fsync 쓰기는 별개의 영역입니다. PLP 없는 소비자용 SSD는 커밋 워크로드에서 자릿수로 느려집니다.
  • 쓰기 증폭은 빈 블록 부족에서 커집니다. 용량 여유, TRIM 동작, 지속 쓰기 스펙을 봅니다.
  • 의존적 접근은 QD1이라 지연시간이 전부입니다. 얕은 큐에서는 장치 업그레이드의 이득이 작습니다.
  • 클라우드 볼륨은 계약 상한이 장치 성능보다 먼저 옵니다. 튜닝 전에 상한부터 확인합니다.

다음 편은 네트워크입니다. 대역폭은 충분한데 느린 이유를 지연시간, RTT, 재전송으로 추적합니다.

X