왜 NVMe에서 큐 깊이가 문제가 되는가

NVMe는 SATA/SAS와 달리 하드웨어 큐를 여러 개(최대 64K개, 각 큐당 64K 엔트리) 가질 수 있도록 설계됐다. 그래서 병렬성을 제대로 살리면 수백만 IOPS가 나오지만, 반대로 큐 깊이와 blk-mq 설정이 워크로드와 어긋나면 디바이스는 놀고 있는데 지연(latency)만 튀는 상황이 생긴다. 대표적으로 낮은 동시성 OLTP 워크로드에 깊은 큐를 강제하면 P99 지연이 올라가고, 고동시성 배치에 얕은 큐를 쓰면 처리량이 벽에 부딪힌다.

blk-mq 구조 빠르게 이해하기

커널의 멀티큐 블록 계층(blk-mq)은 소프트웨어 큐(CPU별)와 하드웨어 큐(디바이스 매핑)로 나뉜다. 요청은 제출한 CPU의 소프트웨어 큐에 쌓였다가 하드웨어 큐로 디스패치된다. 핵심 파라미터는 세 가지다.

  • nr_hw_queues: 디바이스가 노출하는 하드웨어 큐 수. 보통 vCPU 수와 연동된다.
  • queue_depth / nr_requests: 큐당 인플라이트 요청 상한.
  • 스케줄러: none, mq-deadline, kyber 중 선택.

현재 상태부터 확인한다

튜닝 전에 무엇이 걸려 있는지 먼저 읽는다. 값이 얼마인지 모른 채 바꾸면 회귀 원인을 못 찾는다.

DEV=nvme0n1
# 하드웨어 큐 수와 큐당 깊이
ls -d /sys/block/$DEV/mq/* | wc -l
cat /sys/block/$DEV/device/queue_count 2>/dev/null

# blk-mq 요청 상한과 스케줄러
cat /sys/block/$DEV/queue/nr_requests
cat /sys/block/$DEV/queue/scheduler

# 실측 큐 깊이/지연 (avgqu-sz, await, %util)
iostat -x 1 5 $DEV

iostat의 aqu-sz가 낮은데 %util이 100%면 큐가 아니라 디바이스 한계이고, aqu-sz가 크고 await가 튀면 큐가 너무 깊은 것이다.

워크로드별 권장 방향

워크로드스케줄러nr_requests 방향목표
OLTP / 낮은 동시성none 또는 mq-deadline낮게(예: 64~128)P99 지연 안정
분석/배치 대용량none높게(예: 512~1023)처리량 극대화
혼합·지연 민감kyber중간지연 상한 유지

대부분의 순수 NVMe에서는 none이 기본으로 가장 무난하다. 스케줄러 오버헤드 없이 디바이스 병렬성을 그대로 활용하기 때문이다. mq-deadline은 읽기 기아를 막아야 하는 경우, kyber는 지연 목표를 강제하고 싶을 때 쓴다.

영구 적용: udev와 부트 파라미터

sysfs에 직접 쓰면 재부팅 시 사라진다. 재현 가능하게 udev 규칙으로 고정한다.

# /etc/udev/rules.d/60-nvme-scheduler.rules
ACTION=="add|change", KERNEL=="nvme[0-9]*n[0-9]*", \
  ATTR{queue/scheduler}="none", \
  ATTR{queue/nr_requests}="256", \
  ATTR{queue/read_ahead_kb}="128", \
  ATTR{queue/nomerges}="1"
# 즉시 재적용
udevadm control --reload
udevadm trigger --sysname-match=nvme0n1

# nr_hw_queues 조정이 필요하면 모듈 파라미터로
# /etc/modprobe.d/nvme.conf
options nvme_core io_queue_depth=256

nr_hw_queues 자체는 런타임에 임의 변경이 어렵고 드라이버·펌웨어·vCPU 매핑에 종속된다. 클라우드 인스턴스에서는 vCPU 수를 늘리면 하드웨어 큐가 함께 늘어나는 경우가 많다.

변경 효과를 수치로 검증한다

감으로 판단하지 말고 동일 조건에서 fio로 전후를 비교한다. 특히 지연 분포(P99)를 반드시 본다.

# 낮은 동시성 지연 확인
fio --name=lat --filename=/dev/nvme0n1 --rw=randread \
    --bs=4k --iodepth=1 --numjobs=1 --time_based --runtime=30 \
    --ioengine=io_uring --direct=1 --percentile_list=99:99.9

# 높은 동시성 처리량 확인
fio --name=tput --filename=/dev/nvme0n1 --rw=randread \
    --bs=4k --iodepth=128 --numjobs=8 --time_based --runtime=30 \
    --ioengine=io_uring --direct=1 --group_reporting

iodepth를 1, 8, 32, 128로 스윕하면 지연이 급격히 꺾이는 지점이 보인다. 그 직전이 해당 워크로드의 실용적 큐 깊이 상한이다.

주의점

  • 프로덕션 디바이스에 파괴적 fio 금지. --rw=write나 --filename을 마운트된 장치에 직접 지정하면 데이터가 날아간다. 반드시 유휴 장치나 파일 기반으로 테스트한다.
  • 한 번에 하나씩만 바꾼다. 스케줄러, nr_requests, read_ahead를 동시에 바꾸면 어느 것이 효과였는지 알 수 없다.
  • NUMA와 IRQ 친화성. 하드웨어 큐가 많아도 인터럽트가 특정 CPU에 몰리면 병렬성이 죽는다. /proc/interrupts로 nvme IRQ 분산을 확인한다.
  • 가상화/클라우드 편차. 게스트에서 본 NVMe는 하이퍼바이저가 매개하는 경우가 많아 물리 장치와 튜닝 반응이 다르다. 인스턴스 타입별로 다시 측정한다.
  • 스케줄러 변경은 무중단이지만 진행 중 IO에 영향. 트래픽이 낮은 시간대에 적용하고 지표를 지켜본다.