Redis 레이턴시 스파이크와 리눅스 커널: 몇 가지 자세한 이야기
오늘 m3.medium EC2 인스턴스로 Redis 레이턴시를 테스트하고 있었습니다. BGSAVE 시 프로세스가 fork되고 자식 프로세스가 데이터셋을 디스크에 저장하기 시작할 때 흔히 나타나는 레이턴시 스파이크를 재현할 수 있었습니다. 그런데 예상과 다른 점이 있었습니다. 스파이크는 디스크 I/O 때문도 아니었고, fork() 호출 자체 때문에 발생한 것도 아니었습니다.
테스트는 메모리에 1GB의 데이터를 올려둔 상태에서 진행했습니다. 별도의 EC2 인스턴스에서 500만 개 키에 균등하게 분산된 초당 15만 건의 쓰기를 발생시켰고, 파이프라인은 4개 커맨드로 설정했습니다. 이를 redis-benchmark 명령줄로 옮기면 다음과 같습니다.
./redis-benchmark -P 4 -t set -r 5000000 -n 1000000000
BGSAVE가 트리거될 때마다 원인을 알 수 없는 약 300밀리초짜리 레이턴시 스파이크가 관찰됐는데, fork 자체는 6밀리초밖에 걸리지 않았습니다. 다행히 Redis에는 레이턴시 이벤트 발생 시 프로세스의 스택 트레이스를 뽑아내는 소프트웨어 워치독 기능이 있습니다. 아주 단순한 트릭이지만 효과는 훌륭합니다. 커널이 SIGALRM을 전달하도록 설정해 두는 방식입니다. serverCron() 함수가 호출될 때마다 예약된 시그널을 해제하므로, 제어가 충분히 빨리 Redis 프로세스로 복귀하면 실제로 시그널을 받는 일은 없습니다. 반면 블로킹 상태가 발생하면 커널이 시그널을 전달하고, 시그널 핸들러가 스택 트레이스를 출력합니다.
fork 호출이 포함된 스택 트레이스가 나올 거라 예상했지만, 프로세스는 fork 직후 부모 프로세스 컨텍스트에서 MOV* 연산 근처에 항상 블로킹되어 있었습니다. 저는 리눅스가 어떤 식으로든 ‘지연 fork’를 하고 있는 게 아닐까 하는 가설을 세우기 시작했습니다. 실제로 무거운 작업은 나중에 메모리에 접근해 페이지를 copy-on-write 해야 할 때 일어나는 것이었습니다.
다음 단계는 리눅스 커널의 fork() 구현을 읽어보는 것이었습니다. 이 시스템 콜이 하는 일은 실제로 매핑된 모든 영역(vm_area_struct 구조체)을 복사하는 것입니다. 하지만 전통적인 구현이라면 이 시점에서 PTE도 함께 복제했을 것이고, 그 작업은 전통적으로 copy_page_range()가 담당했습니다. 그런데 몇 년 전 최적화로 무언가가 바뀌었습니다. 이제 리눅스는 대부분의 최신 커널처럼 지연 페이지 복사만 수행하는 것이 아닙니다. PTE 역시 폴트 발생 시 지연 방식으로 복사됩니다. copy_page_range() 상단에 있는 주석은 다음과 같습니다.
* Don't copy ptes where a page fault will fill them correctly.
* Fork becomes much lighter when there are big shared or private
* readonly mappings. The tradeoff is that copy_page_range is more
* efficient than faulting.
기본적으로 부모 프로세스가 자식 프로세스와 공유 중인 영역에 접근하는 순간, 페이지 폴트가 일어나는 동안 리눅스는 fork가 건너뛰었던 대량의 작업을 수행하게 되고, 그래서 스택 트레이스에서 항상 MOV 명령어가 보였던 것입니다.
모든 PTE를 한 번에 복사하는 것이 더 효율적이기 때문에 이 동작은 Redis에는 좋지 않지만, POSIX 시스템에서 fork()의 전통적인 사용 사례, 즉 새로운 프로세스를 생성하기 위한 fork()+exec*()에는 훨씬 더 유리합니다.
이 문제는 EC2에만 국한된 것이 아니지만, 가상화 인스턴스는 PTE 복사가 더 느리기 때문에 물리 서버에서는 문제가 덜 두드러집니다.
하지만 이야기는 여기서 끝나지 않습니다. 제 리눅스 박스에서 이 부분을 테스트하다가, 예전에 특정 조건에서는 jemalloc 대신 libc malloc을 썼을 때 레이턴시 스파이크가 더 적게 측정됐던 기억이 떠올랐습니다. 그래서 관련이 있는지 확인해 보기로 했습니다.
실제로 MALLOC=libc로 컴파일하면 물리 서버에서는 레이턴시가 전혀 측정되지 않았지만, jemalloc에서는 EC2 인스턴스에서 관찰된 것과 동일한 현상이 나타났습니다. 차이를 더 잘 이해하기 위해 1,500만 개 키와 더 큰 파이프라인으로 시스템을 더 강하게 압박하고, 모든 mmap된 영역에 대한 페이지 폴트가 아주 짧은 시간 안에 일어날 가능성을 높이도록 테스트를 구성했습니다. 그리고 동일한 테스트를 jemalloc과 libc malloc으로 반복했습니다.
bare metal, 675k/sec writes to 15 million keys, jemalloc: max spike 339 milliseconds.
bare metal, 675k/sec writes to 15 million keys, malloc: max spike 21 milliseconds.
EC2에서도 같은 결과를 재현해 보려고 빠르게 시도해 봤는데, 역시 malloc 쪽의 스파이크가 훨씬 작았습니다.
이 결과를 확인한 뒤 논리적인 다음 단계는 libc malloc으로 동작하는 Redis와 jemalloc으로 동작하는 Redis의 메모리 레이아웃 차이를 살펴보는 것이었습니다. 리눅스 proc 파일시스템은 프로세스 내부를 들여다보기에 편리합니다(이 경우에는 /proc/<pid>/smaps 파일을 사용했습니다).
Jemalloc 메모리는 다음 영역에 할당되어 있습니다.
7f8002c00000-7f8062400000 rw-p 00000000 00:00 0
Size: 1564672 kB
Rss: 1564672 kB
Pss: 1564672 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 1564672 kB
Referenced: 1564672 kB
Anonymous: 1564672 kB
AnonHugePages: 1564672 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Locked: 0 kB
VmFlags: rd wr mr mw me ac sd
반면 libc의 큰 영역은 이렇게 생겼습니다.
0082f000-8141c000 rw-p 00000000 00:00 0 [heap]
Size: 2109364 kB
Rss: 2109276 kB
Pss: 2109276 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 2109276 kB
Referenced: 2109276 kB
Anonymous: 2109276 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Locked: 0 kB
VmFlags: rd wr mr mw me ac sd
여기서 몇 가지 다른 점이 보입니다.
1) 첫 줄의 [heap] 표기는 libc malloc에서만 나타납니다.
2) AnonHugePages 필드는 libc malloc에서는 0이지만, jemalloc의 경우 해당 영역 크기와 같은 값으로 설정되어 있습니다.
<this is wrong>
기본적으로 레이턴시 차이는 malloc이 transparent huge pages를 사용하기 때문인 것으로 보입니다. 이는 여러 개의 일반 4k 페이지를 2048k 크기의 거대한 페이지 몇 개로 투명하게 묶어주는 커널 기능입니다. 덕분에 이 영역들의 PTE 복사가 훨씬 빨라집니다.
</this is wrong>
EDIT: 안타깝게도 제가 완전히 잘못 봤다는 걸 방금 발견했습니다. huge pages는 jemalloc에서만 사용되는 것으로 보입니다. 너무 당연해 보여서 출력을 잘못 읽었던 것입니다. 오히려 반대로, 높은 레이턴시는 알 수 없는 이유로 huge pages 때문인 것으로 보입니다. 즉, huge pages를 사용하지 않는 malloc 쪽이 실제로 훨씬 더 빠르게 동작하고 있습니다. 여기서 무슨 일이 일어나고 있는지 전혀 모르겠으니, 위 결론은 무시해 주시기 바랍니다.
<wrong advice, read later EDIT2>
당분간 낮은 레이턴시가 필요한 애플리케이션이라면 “make MALLOC=libc”로 Redis를 빌드하는 것을 고려해 볼 수 있습니다. 다만 그 전에 반드시 “make distclean”을 실행해야 하며, 워크로드에 따라서는 libc malloc이 jemalloc보다 단편화를 더 많이 겪는다는 점을 유의해야 합니다.
</wrong>
곧 추가 소식 전하겠습니다…
EDIT2: 아, 잠깐… 문제가 huge pages라면 이건 훨씬 더 다행입니다. 비활성화할 수 있기 때문입니다. 그리고 방금 직접 확인해보니 실제로 효과가 있습니다.
echo never > /sys/kernel/mm/transparent_hugepage/enabled
이제 이것이 Redis의 새로운 정석이 된 셈입니다.
UPDATE: 제게는 비현실적으로 보였지만, 실험을 통해 확인한 바로는 huge pages로 인한 메모리 스파이크는 50개의 클라이언트가 동시에 쓰기를 수행하고 각각 N개의 요청을 큐에 쌓아 둔 상황에서, Redis 프로세스가 *단 한 번의 이벤트 루프 반복* 안에 프로세스의 모든 페이지를 건드릴 수 있기 때문에 발생합니다. 결국 프로세스 주소 공간 전체를 copy-on-write 하게 되는 것입니다. 이는 huge pages가 레이턴시 측면에서도 최악일 뿐만 아니라 메모리 사용량 측면에서도 최악이라는 의미입니다.글을 무작위로 읽기