meilisearch에서 jemalloc, bumpalo, mimalloc, 그리고 LMDB가 얽힌 메모리 누수 추적과 RSS 사용량 최적화 과정을 다룹니다.
Clément Renault
2026년 3월 20일 — 댓글 0개
얼마 전부터 몇몇 오픈소스 사용자들이 meilisearch가 메모리를 누수하고 있다고 보고했습니다. 저는 누수가 숨어 있을 만한 곳이 어디인지 보려고 코드베이스를 검토하는 데 시간을 들였지만, 충분히 의심스러운 것은 끝내 찾지 못했습니다. 사실 저는 그런 주장 자체도 꽤 의심하고 있었습니다. 저희는 Cloud에서 수천 개의 meilisearch 인스턴스를 운영하고 있는데, 운영체제가 인스턴스를 죽일 정도의 누수 문제를 겪은 적이 없었기 때문입니다...
...사실, 네, v1.25에서 누수가 있었습니다. 하지만 그건 당시 제가 개발하던 LMDB 기능과 관련된 것이었고, 이 이야기는 다른 블로그 글에서도 다뤘습니다. 지금은 수정되었고, 복잡한 C 코드베이스에 뛰어들어 충분한 테스트 없이 으스스한 변경을 배포한 건 전적으로 제 잘못이었습니다.
저는 Cloud에서의 meilisearch도 조사했는데, 언제나 meilisearch가 어떤 식으로든 메모리를 누수하고 있지는 않다는 결론이 나왔습니다. 아래에서 볼 수 있듯이, 이는 임베딩과 키워드를 매우 많이 인덱싱하는 한 고객의 메모리 사용량입니다. 이 머신에는 수천만 개의 문서가 있고 샤딩 클러스터의 일부이지만, 눈에 띄는 누수는 보이지 않습니다.
하지만 어느 날, 보고된 것과 비슷한 현상을 보게 되었습니다. meilisearch가 점점 더 많은 resident memory를 사용하고 있었던 것입니다. RSS라고도 하는 resident memory는 OS가 할당해 RAM에 상주하는 메모리입니다. 반면 virtual(VRT) memory는 보통 메모리 매핑된 파일에서 오며 필요할 때 할당됩니다. RSS 메모리는 MAP_ANONYMOUS 옵션과 함께 mmap 시스템 콜을 호출해서 생깁니다. 이미 할당된 페이지가 없다면, 여러분이 무언가를 malloc할 때 할당자가 바로 이런 일을 합니다.
수백만 개의 작은 문서를 meilisearch에 인덱싱할 때 제가 본 것이 바로 이것이었습니다. 그래서 저는 이 토끼굴로 뛰어들어 이 누수를 찾아내기로 했습니다. Rust에서는 누수를 감지하기가 아주 명확하다고 생각했습니다. 보통 mem::forget 함수를 명시적으로 호출하는 경우가 많기 때문입니다. 안타깝게도 저희 코드에는 그런 명시적 호출이 없었습니다.
저희는 mimalloc로 전환하기 전까지 오랫동안 jemalloc을 사용했습니다. 주된 이유는 성능과 Windows 및 macOS 호환성이었습니다. mimalloc은 Microsoft 프로젝트이니 Windows와 더 잘 맞을 거라고 생각했지만, 결국 그 생각이 틀렸음이 드러났고 Windows에서는 mimalloc을 비활성화했습니다. 그래도 이번에는 macOS에서 mimalloc을 사용할 수 있었습니다. 역사는 반복되고, Windows에서 meilisearch는 기본 할당자를 사용합니다.
그렇지만 jemalloc은 매우 성능이 좋은 할당자입니다. 특히 macOS 기본 할당자보다는 모든 면에서 낫다고 확신합니다 👓. 그리고 무엇보다도 누수와 힙 할당을 추적하는 데 아주 유용한 도구들을 갖추고 있습니다. 저는 meilisearch의 전역 할당자를 다시 jemalloc으로 바꾸고, profiling 기능을 활성화한 뒤, 메모리 추적 SVG 출력을 몇 개(사실 너무 많이) 생성했습니다.
그 SVG는 제가 최근 수정한 코드의 어떤 부분에서 약 100 MiB가 누수되고 있음을 보여주었습니다. 새 코드는 흔히 새 버그의 원인이 되므로, 저는 제 코드를 더 철저히 검토했고, 여러분도 꼭 그렇게 해보시길 권합니다. 제가 꽤 자랑스럽기 때문입니다. 짧게 말하면, 제 PR이 메모리 누수를 도입한 것은 아니었지만, 그 보고서는 여전히 이 코드를 가리키고 있었습니다.
저는 AI Agent의 아주 열성적인 사용자는 아닙니다. Zed를 쓰기는 합니다. 그렇다고 AI 관련 기능 때문에 쓰는 건 아니고, 제가 Rust를 좋아하고 이 에디터가 꽤 좋기 때문입니다. Zed 팀도 Zed 코드베이스 작업에 Zed를 사용하고 있기 때문에, Rust analyzer 통합과 디버거에 많은 공을 들였습니다. Sublime Text의 좋지 않은 Rust 통합이나 VSCode의 브라우저 같은 느림과 리소스 사용에 비하면 아주 쾌적합니다.
여담은 여기까지. 그래도 어디로 이어질지 보기 위해 LLM의 도움을 조금 받아보기로 했습니다. jemalloc 힙 분석에서 텍스트 보고서를 생성해 Zed의 Claude Sonnet 4.5 Agent에게 줬습니다. 놀랍게도 코드베이스에서 여러 누수, 대략 네 개를 찾아냈습니다. 안심하셔도 됩니다. 실제 누수는 하나뿐이었습니다. 네... AI잖아요. 그래도 그 하나는 실제 누수였습니다.
Bumpalo를 아실 수도 있습니다. bump allocator 라이브러리입니다. 주된 목적은 Bump 객체가 드롭될 때만 해제되는 메모리 청크를 할당함으로써 할당자(예: mimalloc, jemalloc)에 가해지는 압력을 줄이는 것입니다. 저희는 meilisearch에서 이를 사용해 많은 작은 할당(예: 문서 ID, 필드 이름)을 수행하고 인덱싱 전반에 걸쳐 유지합니다. 이렇게 하면 캐시 친화적이지 않은 패턴과 느려짐으로 이어질 수 있는 수많은 작고 흩어진 할당을 피할 수 있습니다.
bumpalo의 가장 큰 함정은 bumpalo::Vec::into_bump_slice를 사용하는 방식에 있습니다. 이 메서드는 할당된 벡터를 받아 내부 슬라이스로 변환합니다. 이 슬라이스는 안전하게 사용할 수 있고, 심지어 누수로 간주되지도 않습니다. 그 수명이 Bump와 같기 때문입니다. 즉, Bump가 소유한 기반 메모리 청크가 드롭되면 슬라이스도 해제됩니다. 하지만 여기엔 함정이 있습니다. bumpalo::Vec를 슬라이스로 변환할 때, 그 슬라이스는 drop glue를 절대 실행하지 않는데, 저희는 전역 할당자가 뒷받침하는 자료구조, 즉 std::Vec를 이 bumpalo::Vec 안에 저장하고 있었습니다. 정말 큰 실수였습니다. AI의 도움으로 발견된 누수에 대한 패치를 검토해보실 수 있습니다.
마침내 이 오래된 버그를 고친 뒤에는 정말 안도했습니다. 이 버그는 v1.12부터 있었는데, 대략 1.5년 전입니다. 그 긴 시간 동안 아무도 이걸 눈치채지 못했다니요. 저희는 여기서 bumpalo에 "책임"이 있는지, 그리고 bumpalo::Vec가 특정 bumpalo::Drop 트레이트를 구현한 구조체만 저장할 수 있어야 하는지 고민하기도 했습니다. 하지만 그렇게 구현하는 건 매우 복잡해 보였습니다. 그래서 저는 명백한 누수를 수정한 뒤 다시 meilisearch의 메모리 사용량을 측정하기 시작했습니다...
이게 대체 뭐죠?! 패치 이전 바이너리를 쓰고 있는지 확인했지만, 아니었습니다. 메모리 사용량은 여전히 크게 증가하고 있었습니다. 제 패치는 사실상 아무것도 바꾸지 못했습니다. 누구라도 치매에 걸릴 것 같았겠지만, 저는 진정하고 제 "초강력" AI agent를 다시 써보기로 했습니다. 이번에는 아무것도 찾아내지 못했습니다.
Meilisearch는 내부 인덱스를 저장하고 조작하기 위해 매우 빠른 메모리 매핑 키-값 저장소인 LMDB를 사용합니다. 검색 요청을 처리하기 위해 검색 엔진의 역색인을 저장하고, multiple-view concurrency control(MVCC) 구현 덕분에 내부 데이터 구조를 갱신하는 동안에도 검색 요청을 처리할 수 있습니다. 아마 엔진에서 가장 중요한 의존성일 것이고, 저희는 여기에 아주 큰 부하를 걸고 있습니다. 참고로 이 라이브러리는 C로 작성되어 있습니다.
지금 C라고 했나요? 네, 그리고 그게 바로 이 메모리 누수 문제의 답의 일부입니다. Meilisearch는 LMDB와 정적으로 링크되어 있고, 동시에 전역 할당자로 mimalloc도 사용합니다. 하지만 LMDB는 mimalloc을 사용하지 않고 기본 할당자, 즉 시스템 할당자에 의존합니다. 짧게 말하면, LMDB는 meilisearch가 재사용할 수 없는 페이지를 할당하고 해제하고 있었던 것입니다. 두 할당자가 서로 협력하지 않기 때문이며, 이것이 이 미스터리의 핵심 단서였습니다.
저는 meilisearch에서 커스텀 전역 할당자를 제거하고 LD_PRELOAD(다른 라이브러리보다 먼저 공유 라이브러리를 로드하는 데 사용되는 환경 변수로, 여기서는 할당자를 교체하는 역할을 합니다)를 설정한 뒤 profiling이 활성화된 jemalloc을 사용하기로 했습니다. 그래야 제 "초강력" AI agent에게 줄 만한 좋은 보고서를 얻을 수 있기 때문입니다. 예전에도 LMDB 내부로 깊이 들어가 본 적이 있었고, 할당 관련 일부 부분은 꽤 으스스해 보였습니다. 저는 이런 종류의, 페이지 크기 할당이 무한정 이어지는 연결 리스트를 별로 좋아하지 않습니다.
와! 목표는 jemalloc을 사용해 메모리 할당을 극적으로 줄이는 것이 아니라 보고서를 추출하는 것이었습니다. 그런데 이건 매우 흥미롭습니다. jemalloc을 사용할 때는 누수가 전혀 나타나지 않는다는 것을 명확히 볼 수 있기 때문입니다. 여기서의 주요 변경점은 meilisearch와 LMDB 모두에 단일 메모리 할당자를 사용한 것입니다. 저는 이전까지 meilisearch 애플리케이션에 대해서만 jemalloc profiling을 활성화하고 있었기 때문에, LMDB 감사도 가능하게 하려는 목적이었습니다. 둘을 통합하니 누수가 해결된 것처럼 보였습니다. 보고서를 추출해 보니, 아까 이야기했던 으스스한 연결 리스트조차 나타나지 않았고, 어떤 종류의 실제 누수도 없었습니다.
그래서 jemalloc 대신 mimalloc을 사용해 meilisearch가 LMDB와 할당을 공유할 때 어떻게 동작하는지 확인해 봤습니다. 그리고... 결과는 그다지 설득력 있지 않았습니다. 누수가 다시 나타난 것입니다. 문제는 jemalloc이 아니라 mimalloc에서만 발생하는 것처럼 보였습니다. 인터넷을 찾아보니, mimalloc 뒤에 있는 훌륭한 팀이 실제로 v3를 작업 중이라는 걸 알게 되었습니다. 저희는 main에서 사용 가능한 버전인 v2를 사용하고 있었습니다.
저는 mimalloc v3를 컴파일해 meilisearch에 LD_PRELOAD로 주입했고, 결과는 훨씬, 훨씬 더 좋아졌습니다. 보시다시피 이제 jemalloc의 메모리 사용량에 가까워졌습니다. 나중에 알고 보니 mimalloc의 override 기능이 있었고, 그 목적은 모든 할당자 호출이 mimalloc에 링크되도록 강제하고 이를 활성화하는 것이었습니다. 또한 nm 명령어를 사용해 서로 다른 malloc 심볼들이 mi_malloc 심볼을 가리키는지도 확인했습니다.
문서에 따르면, mimalloc v3는 스레드 간 메모리 공유를 훨씬 더 잘 처리하며 특정 대규모 워크로드에서 (훨씬) 더 낮은 메모리 사용량을 약속합니다. 제 생각엔 meilisearch에서 문서를 인덱싱하는 일이 여기에 해당하는 것 같습니다. 또한 단순화된 lock-free 설계와 진정한 first-class heap 지원 같은 다른 기능의 이점도 아마 누리고 있을 것입니다. 마지막 두 기능이 정확히 무엇을 하는지는 잘 모르겠지만, 저희 워크로드에는 매우 유익해 보입니다.
모든 것이 괜찮은지 보기 위해 몇 가지 벤치마크를 돌렸고, 그 결과 매우 좋은 성능 향상이 나왔습니다. 어떤 경우에는 약 13% 향상이 있었고, 어떤 경우에는 약 9% 손해가 있었지만, 이런 것은 필요한 절충입니다. 참고로 이 벤치마크는 전용 NVMe 머신에서 실행됩니다. Cloud에서는 고객 대부분이 네트워크 디스크를 사용하므로, 대기 시간이 더 높은 디스크, 예를 들어 AWS EBS에서는 성능 향상이 훨씬 더 두드러질 것입니다.
가장 중요한 점은 메모리 사용량이 이전보다 훨씬 낮아졌다는 것입니다. 저희 시스템은 RAM이 제한적이고 디스크는 느립니다. 특히 네트워크 디스크는 더 그렇습니다. 페이지에 접근할 때마다 매번 디스크에서 가져오기보다는 페이지 캐시에 의존하는 편이 낫습니다. 그래서 RSS를 줄이면 페이지 캐시를 위한 메모리가 더 많이 확보되고 디스크 읽기가 줄어들어, 궁극적으로 성능이 향상됩니다. 물론 메모리를 너무 자주 혹은 너무 일찍 munmap하는 것과 메모리를 계속 유지하는 것 사이에는 여전히 절충이 존재하지만, mimalloc v3는 이 절충을 관리하는 데 매우 효율적인 것처럼 보입니다.
mimalloc v3로 한동안 엔진을 테스트한 뒤에도, 특히 인덱싱이 매우 무거운 구간에서는 여전히 약간의 스파이크가 보입니다. 하지만 이전처럼 RSS 노이즈가 크지 않아서, 이제는 어디에서 엔진을 개선할 수 있는지 훨씬 더 쉽게 볼 수 있습니다. 이야기는 계속되겠지만, 이제 초점은 엔진에 대한 "메타" 변경보다는 할당 부하를 줄이기 위한 엔진 측 최적화에 더 맞춰질 것입니다.
이 블로그 글은 제 자신의 표현과 생각으로 손수 작성했습니다 🌱
Clément Renault 소개
저는 @meilisearch의 공동 창립자이자 CTO입니다. 코딩은 파리의 @42school에서 배웠습니다. 저는 파리에 살고 있으며 비디오 게임을 좋아합니다.
최신 업데이트와 글을 받아보려면 제 RSS/Atom 피드를 구독하세요.