Redis latency spikes and the Linux kernel: a few more details

Salvatore Sanfilippo

Redis 지연 스파이크와 리눅스 커널: 몇 가지 추가 세부 사항

원문은 Salvatore Sanfilippo님이 에 게재했습니다. 이 블로그 구독하기

오늘 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(lazy forking)”를 하고 있고, 실제로 무거운 작업은 나중에 메모리에 접근해 페이지를 copy-on-write 해야 할 때 일어난다는 가설을 세우기 시작했다.

다음 단계는 리눅스 커널의 fork() 구현을 읽어보는 것이었다. 이 시스템 콜이 하는 일은 실제로 매핑된 모든 영역(vm_area_struct 구조체)을 복사하는 것이다. 하지만 전통적인 구현이라면 이 시점에 PTE도 함께 복제했을 것이고, 이는 전통적으로 copy_page_range()가 수행하던 일이었다. 그러나 몇 년 전 최적화로 뭔가 달라졌다. 이제 리눅스는 대부분의 최신 커널처럼 페이지 복사만 지연시키는 것이 아니라, PTE 복사도 폴트 발생 시 지연(lazy) 방식으로 처리한다. 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 인스턴스에서 봤던 것과 동일한 현상을 관찰할 수 있었다. 차이를 더 잘 이해하기 위해 1500만 개 키와 더 큰 파이프라인으로 시스템을 더 강하게 압박하고, 모든 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이 투명한 huge page를 사용하기 때문인 것으로 보인다. 이는 여러 개의 일반적인 4k 페이지를 각각 2048k인 소수의 huge page로 투명하게 합쳐주는 커널 기능이다. 이는 결국 이 영역들에 대한 PTE 복사가 훨씬 빨라진다는 것을 의미한다.
</this is wrong>

EDIT: 안타깝게도 내가 완전히 잘못 봤다는 걸 방금 알아챘다. huge page는 jemalloc에서만 사용되는 것으로 보인다. 너무 당연해 보여서 출력을 잘못 읽은 것이다. 오히려 반대로, 높은 지연은 알 수 없는 이유로 huge page 때문인 것으로 보인다. 그러니까 실제로는 huge page를 *사용하지 않는* malloc 쪽이 훨씬 더 빠르게 동작하고 있는 것이다. 여기서 무슨 일이 일어나고 있는지 전혀 모르겠으니, 위의 결론은 무시해 달라.

<wrong advice, read later EDIT2>
한편 지연에 민감한 애플리케이션이라면 “make MALLOC=libc”로 Redis를 빌드하는 것을 고려해 볼 수 있다. 다만 그 전에 “make distclean”을 꼭 실행해야 하며, 워크로드에 따라서는 libc malloc이 jemalloc보다 단편화를 더 많이 겪는다는 점을 유념해야 한다.
</wrong>

조만간 추가 소식을 전하겠다…

EDIT2: 아, 잠깐... 문제가 huge page라면 이건 훨씬 더 낫다. 비활성화할 수 있기 때문이다. 그리고 방금 해 보니 실제로 동작한다:

echo never > /sys/kernel/mm/transparent_hugepage/enabled

아무래도 이게 이제 Redis의 새로운 정석인 것 같다.

UPDATE: 내게는 비현실적으로 보였지만, 실험적으로 확인한 바로는 huge page 메모리 스파이크는 50개의 클라이언트가 동시에 쓰면서 각각 N개의 요청을 큐에 쌓아 둔 경우, Redis 프로세스가 *단 한 번의 이벤트 루프 반복* 안에 프로세스의 모든 페이지를 건드릴 수 있어 전체 프로세스 주소 공간을 copy-on-write 하게 되기 때문에 발생한다. 이는 huge page가 지연 시간에 끔찍할 뿐만 아니라 메모리 사용량 측면에서도 끔찍하다는 것을 의미한다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글