jemalloc으로 Postgres 메모리 누수 디버깅하기

작성자: Phil Eaton
게시일: 2025년 6월 21일

지난 1년간 저는 Postgres의 메모리 누수를 디버깅하는 다양한 도구들에 대해 소개해왔습니다. Valgrind, AddressSanitizer, memleak, heaptrack 등을 다뤘지만, 아직 살펴볼 가치가 있는 도구가 몇 가지 남아 있습니다. 오늘은 Meta가 만든 대체 malloc 구현체인 jemalloc을 활용한 메모리 누수 탐지 방법을 살펴보겠습니다.


jemalloc이란?

Google의 tcmalloc, Microsoft의 mimalloc, Meta의 jemalloc처럼 많은 기업들은 표준 malloc 대신 성능이나 기능이 개선된 자체 구현체를 사용합니다. 이 중 jemalloc은 메모리 누수 탐지 기능이 특히 뛰어납니다. 이유는 AddressSanitizer가 탐지하지 못하는 경우까지 jemalloc은 캐치할 수 있기 때문입니다. 예를 들어 프로세스 종료 시 정리되긴 하지만 OOM(Out of Memory)을 간헐적으로 유발하는 누수는 jemalloc이 보다 잘 잡아냅니다.

참고: FreeBSD, Apache Arrow도 jemalloc을 사용합니다.


실험 시나리오: jemalloc으로 Postgres 메모리 누수 잡기

이전 글에서 우리는 Postgres에 인위적으로 메모리 누수 2가지를 주입하고 heaptrack으로 디버깅을 진행했습니다. 이번에는 jemalloc을 이용해 같은 누수 문제를 잡아보겠습니다.

주의: jemalloc의 힙 프로파일링 기능은 macOS에서 지원되지 않으므로, 반드시 Linux 환경(VM 포함)에서 실험을 진행해야 합니다.


1단계: Postgres와 jemalloc 빌드하기

Postgres 빌드

$ git clone https://github.com/postgres/postgres
$ cd postgres
$ git checkout REL_17_STABLE
$ ./configure --without-zlib --without-icu \
--without-readline --enable-debug --prefix=/usr/local/
$ make -j8 && sudo make install

jemalloc 빌드

$ git clone https://github.com/facebook/jemalloc
$ cd jemalloc
$ ./autogen.sh
$ ./configure --enable-prof --enable-prof-frameptr
$ make -j8 && sudo make install


2단계: postmaster 프로세스에 메모리 누수 주입

Postgres의 모든 프로세스는 postmaster 프로세스에 의해 스케줄링됩니다. 여기에 인위적인 메모리 누수를 주입합니다:

$ git diff src/backend/postmaster
diff --git a/src/backend/postmaster/postmaster.c b/src/backend/postmaster/postmaster.c
index d032091495b..e0bf8943763 100644
--- a/src/backend/postmaster/postmaster.c
+++ b/src/backend/postmaster/postmaster.c
@@ -3547,6 +3547,13 @@ BackendStartup(ClientSocket *client_sock)
       Backend    *bn;                         /* for backend cleanup */
       pid_t           pid;
       BackendStartupData startup_data;
+       MemoryContext old;
+       int *s;
+
+       old = MemoryContextSwitchTo(TopMemoryContext);
+       s = palloc(8321);
+       *s = 12;
+       MemoryContextSwitchTo(old);
       /*
        * Create backend data structure.  Better before the fork() so we can

Postgres는 메모리를 MemoryContext라는 중첩된 메모리 영역(arenas)에 할당합니다. 그중 최상위 영역은 TopMemoryContext이며, 이 컨텍스트는 프로세스 종료 시 전체가 함께 해제됩니다.
따라서 TopMemoryContext에 과도하게 메모리를 할당하더라도 프로세스가 종료되면 해당 메모리는 정리되기 때문에 Valgrind의 memcheck나 LeakSanitizer로는 메모리 누수를 감지하기 어렵습니다. 그러나 프로세스가 살아 있는 동안에는 이 메모리 누수는 실제로 존재하며 영향을 미칠 수 있습니다.

참고로 위 코드에서 palloc 대신 malloc을 사용했다면 LeakSanitizer가 이 누수를 탐지할 수 있습니다. Valgrind memcheck의 경우는 시도하지 않았지만, 아마 감지 가능할 것으로 예상됩니다.

이러한 누수를 손쉽게 유발하는 방법 중 하나는, 수많은 psql 클라이언트를 반복 실행하여 대량의 Postgres 백엔드 프로세스를 생성하는 것입니다.
$ for run in {1..100000}; do psql postgres -c 'select 1'; done



3단계: jemalloc으로 프로파일링 수행

Postgres를 jemalloc과 함께 실행:

$ initdb testdb
$ MALLOC_CONF=prof_leak:true,lg_prof_sample:0,prof_final:true \
LD_PRELOAD=/usr/local/lib/libjemalloc.so \
postgres -D $(pwd)/testdb
2025-06-21 12:25:07.576 EDT [640443] LOG: starting PostgreSQL 17.5 on aarch64-unknown-linux-gnu, compiled by gcc (Debian 12.2.0-14+deb12u1) 12.2.0, 64-bit
2025-06-21 12:25:07.577 EDT [640443] LOG: listening on IPv6 address "::1", port 5432
2025-06-21 12:25:07.577 EDT [640443] LOG: listening on IPv4 address "127.0.0.1", port 5432
2025-06-21 12:25:07.578 EDT [640443] LOG: listening on Unix socket "/tmp/.s.PGSQL.5432"
2025-06-21 12:25:07.582 EDT [640446] LOG: database system was shut down at 2025-06-21 12:24:52 EDT
<jemalloc>: Leak approximation summary: ~423600 bytes, ~109 objects, >= 65 contexts
<jemalloc>: Run jeprof on dump output for leak detail
2025-06-21 12:25:07.586 EDT [640443] LOG: database system is ready to accept connections

터미널에 아래와 같은 jemalloc 로그가 나타납니다:

$ for run in {1..100000}; do psql postgres -c 'select 1'; done

실시간 메모리 사용량 관찰

워크로드를 실행하는 동안 메모리 사용량이 증가하는 모습을 확인하고 싶다면, 다른 터미널에서 top 명령어를 실행하세요.

워크로드가 끝나면 Postgres 프로세스에서 Control-C를 눌러 종료합니다. 이제 jemalloc이 기록한 메모리 힙 정보를 분석할 수 있습니다. 이전에 확인한 postmaster 프로세스의 PID는 640443입니다.
$ jeprof --lines --inuse_space `which postgres` testdb/jeprof.640443.0.f.heap
Using local file /usr/local/bin/postgres.
Using local file testdb/jeprof.640443.0.f.heap.
Welcome to jeprof! For help, type 'help'.
(jeprof)

이제 jemalloc이 측정한 누수의 누적 메모리 사용량을 확인합니다.
(jeprof) top --cum
Total: 976.9 MB
0.0 0.0% 0.0% 976.8 100.0% __libc_init_first@@GLIBC_2.17 ??:?
0.0 0.0% 0.0% 976.8 100.0% __libc_start_main@GLIBC_2.17 ??:?
0.0 0.0% 0.0% 976.8 100.0% _start ??:?
0.0 0.0% 0.0% 976.8 100.0% main /home/phil/postgres/src/backend/main/main.c:199
976.7 100.0% 100.0% 976.7 100.0% AllocSetAllocLarge /home/phil/postgres/src/backend/utils/mmgr/aset.c:715
0.0 0.0% 100.0% 976.6 100.0% PostmasterMain /home/phil/postgres/src/backend/postmaster/postmaster.c:1374
0.0 0.0% 100.0% 976.6 100.0% ServerLoop.isra.0 /home/phil/postgres/src/backend/postmaster/postmaster.c:1676
0.0 0.0% 100.0% 976.6 100.0% BackendStartup (inline) /home/phil/postgres/src/backend/postmaster/postmaster.c:3555
0.0 0.0% 100.0% 0.1 0.0% PostmasterMain /home/phil/postgres/src/backend/postmaster/postmaster.c:585
0.0 0.0% 100.0% 0.1 0.0% MemoryContextAllocExtended /home/phil/postgres/src/backend/utils/mmgr/mcxt.c:1250 (discriminator 5)

여기서 src/backend/postmaster/postmaster.c의 우리가 palloc을 사용해 누수를 유발한 줄에서 명확하게 메모리 사용량이 급증한 것을 확인할 수 있습니다. 정확히 우리가 예상했던 지점입니다!


4단계: 클라이언트 백엔드에 메모리 누수 주입

이제는 Postgres 클라이언트 프로세스 쪽에서도 누수를 유발해보겠습니다. random() 함수 구현에 아래와 같은 코드를 추가합니다:

$ git diff src/backend/utils/
diff --git a/src/backend/utils/adt/pseudorandomfuncs.c b/src/backend/utils/adt/pseudorandomfuncs.c
index 8e82c7078c5..886efbfaf78 100644
--- a/src/backend/utils/adt/pseudorandomfuncs.c
+++ b/src/backend/utils/adt/pseudorandomfuncs.c
@@ -20,6 +20,7 @@
#include "utils/fmgrprotos.h"
#include "utils/numeric.h"
#include "utils/timestamp.h"
+#include "utils/memutils.h"
/* Shared PRNG state used by all the random functions */
static pg_prng_state prng_state;
@@ -84,6 +85,13 @@ Datum
drandom(PG_FUNCTION_ARGS)
{
float8 result;
+ int* s;
+ MemoryContext old;
+
+ old = MemoryContextSwitchTo(TopMemoryContext);
+ s = palloc(100);
+ MemoryContextSwitchTo(old);
+ *s = 90;
initialize_prng();


앞서 random() 함수 내부에 TopMemoryContext에 메모리를 할당하는 코드를 삽입했습니다. 이제 이를 실제로 호출하여 메모리 누수를 유발해 보겠습니다. 다음 쿼리를 실행하면 무수히 많은 random() 호출을 통해 누수가 누적됩니다.

SELECT sum(random()) FROM generate_series(1, 100_0000);

위에서 수정한 코드를 반영하여 Postgres를 다시 빌드하고 설치합니다.

$ make -j16 && make install

앞서 생성해둔 testdb 데이터 디렉터리를 기반으로 Postgres를 jemalloc과 함께 실행합니다:

$ MALLOC_CONF=prof_leak:true,lg_prof_sample:0,prof_final:true \
LD_PRELOAD=/usr/local/lib/libjemalloc.so \
postgres -D $(pwd)/testdb
2025-06-21 13:10:39.766 EDT [845169] LOG: starting PostgreSQL 17.5 on aarch64-unknown-linux-gnu, compiled by gcc (Debian 12.2.0-14+deb12u1) 12.2.0, 64-bit
2025-06-21 13:10:39.767 EDT [845169] LOG: listening on IPv6 address "::1", port 5432
2025-06-21 13:10:39.767 EDT [845169] LOG: listening on IPv4 address "127.0.0.1", port 5432
2025-06-21 13:10:39.767 EDT [845169] LOG: listening on Unix socket "/tmp/.s.PGSQL.5432"
2025-06-21 13:10:39.769 EDT [845172] LOG: database system was shut down at 2025-06-21 13:10:27 EDT
: Leak approximation summary: ~423600 bytes, ~109 objects, >= 65 contexts
: Run jeprof on dump output for leak detail
2025-06-21 13:10:39.771 EDT [845169] LOG: database system is ready to accept connections

이제 새로운 터미널에서 psql을 실행해 백엔드 프로세스의 PID를 확인합니다:

$ psql postgres
psql (17.5)
Type "help" for help.
postgres=# select pg_backend_pid();
pg_backend_pid
----------------
845177
(1 row)
postgres=#

이제 다음 쿼리를 통해 random() 함수를 다수 호출해 누수를 유발합니다:

postgres=# SELECT sum(random()) FROM generate_series(1, 10_000_000);
sum
-------------------
499960.8137393289
(1 row)

리가 완료되면 Ctrl+Dpsql을 종료하고, 실행 중인 postgres 프로세스는 Ctrl+C로 종료합니다.


5단계: jemalloc 결과 분석

jemalloc로 메모리 누수 위치 정확히 추적하기: jeprof 실전 분석

이제 jeprof를 사용해 해당 파일을 로드하고, 메모리 누수가 정확히 어디에서 발생했는지 분석해보겠습니다.

$ jeprof --lines --inuse_space `which postgres` testdb/jeprof.845177.0.f.heap
Using local file /usr/local/bin/postgres.
Using local file testdb/jeprof.845177.0.f.heap.
Welcome to jeprof! For help, type 'help'.
(jeprof)

이전과 같이 top --cum 를 실행합니다.

(jeprof) top --cum
Total: 1305.8 MB
0.0 0.0% 0.0% 1305.7 100.0% __libc_init_first@@GLIBC_2.17 ??:?
0.0 0.0% 0.0% 1305.7 100.0% __libc_start_main@GLIBC_2.17 ??:?
0.0 0.0% 0.0% 1305.7 100.0% _start ??:?
0.0 0.0% 0.0% 1305.7 100.0% main /home/phil/postgres/src/backend/main/main.c:199
0.0 0.0% 0.0% 1305.5 100.0% PostmasterMain /home/phil/postgres/src/backend/postmaster/postmaster.c:1374
0.0 0.0% 0.0% 1305.5 100.0% ServerLoop.isra.0 /home/phil/postgres/src/backend/postmaster/postmaster.c:1676
0.0 0.0% 0.0% 1305.5 100.0% BackendStartup (inline) /home/phil/postgres/src/backend/postmaster/postmaster.c:3603
0.0 0.0% 0.0% 1305.5 100.0% postmaster_child_launch /home/phil/postgres/src/backend/postmaster/launch_backend.c:277
0.0 0.0% 0.0% 1305.4 100.0% BackendMain /home/phil/postgres/src/backend/tcop/backend_startup.c:105
1305.1 100.0% 100.0% 1305.1 100.0% AllocSetAllocFromNewBlock /home/phil/postgres/src/backend/utils/mmgr/aset.c:919

이 출력은 누수된 메모리의 누적 호출 스택 중 상위 10개만 보여주기 때문에, 더 자세한 분석을 위해 top --cum 로 출력 범위를 확장합니다:

(jeprof) top30 --cum
Total: 1305.8 MB
0.0 0.0% 0.0% 1305.7 100.0% __libc_init_first@@GLIBC_2.17 ??:?
0.0 0.0% 0.0% 1305.7 100.0% __libc_start_main@GLIBC_2.17 ??:?
0.0 0.0% 0.0% 1305.7 100.0% _start ??:?
0.0 0.0% 0.0% 1305.7 100.0% main /home/phil/postgres/src/backend/main/main.c:199
0.0 0.0% 0.0% 1305.5 100.0% PostmasterMain /home/phil/postgres/src/backend/postmaster/postmaster.c:1374
0.0 0.0% 0.0% 1305.5 100.0% ServerLoop.isra.0 /home/phil/postgres/src/backend/postmaster/postmaster.c:1676
0.0 0.0% 0.0% 1305.5 100.0% BackendStartup (inline) /home/phil/postgres/src/backend/postmaster/postmaster.c:3603
0.0 0.0% 0.0% 1305.5 100.0% postmaster_child_launch /home/phil/postgres/src/backend/postmaster/launch_backend.c:277
0.0 0.0% 0.0% 1305.4 100.0% BackendMain /home/phil/postgres/src/backend/tcop/backend_startup.c:105
1305.1 100.0% 100.0% 1305.1 100.0% AllocSetAllocFromNewBlock /home/phil/postgres/src/backend/utils/mmgr/aset.c:919
0.0 0.0% 100.0% 1304.0 99.9% PostgresMain /home/phil/postgres/src/backend/tcop/postgres.c:4767
0.0 0.0% 100.0% 1304.0 99.9% PortalRun /home/phil/postgres/src/backend/tcop/pquery.c:766
0.0 0.0% 100.0% 1304.0 99.9% PortalRunSelect /home/phil/postgres/src/backend/tcop/pquery.c:922
0.0 0.0% 100.0% 1304.0 99.9% exec_simple_query /home/phil/postgres/src/backend/tcop/postgres.c:1278
0.0 0.0% 100.0% 1304.0 99.9% ExecAgg /home/phil/postgres/src/backend/executor/nodeAgg.c:2179
0.0 0.0% 100.0% 1304.0 99.9% ExecEvalExprSwitchContext (inline) /home/phil/postgres/src/backend/executor/../../../src/include/executor/executor.h:356
0.0 0.0% 100.0% 1304.0 99.9% ExecInterpExpr /home/phil/postgres/src/backend/executor/execExprInterp.c:740
0.0 0.0% 100.0% 1304.0 99.9% ExecProcNode (inline) /home/phil/postgres/src/backend/executor/../../../src/include/executor/executor.h:274
0.0 0.0% 100.0% 1304.0 99.9% ExecutePlan (inline) /home/phil/postgres/src/backend/executor/execMain.c:1649
0.0 0.0% 100.0% 1304.0 99.9% advance_aggregates (inline) /home/phil/postgres/src/backend/executor/nodeAgg.c:820
0.0 0.0% 100.0% 1304.0 99.9% agg_retrieve_direct (inline) /home/phil/postgres/src/backend/executor/nodeAgg.c:2454
0.0 0.0% 100.0% 1304.0 99.9% drandom /home/phil/postgres/src/backend/utils/adt/pseudorandomfuncs.c:93
0.0 0.0% 100.0% 1304.0 99.9% standard_ExecutorRun /home/phil/postgres/src/backend/executor/execMain.c:361
0.0 0.0% 100.0% 1.3 0.1% PostgresMain /home/phil/postgres/src/backend/tcop/postgres.c:4324
0.0 0.0% 100.0% 0.9 0.1% InitPostgres /home/phil/postgres/src/backend/utils/init/postinit.c:1194 (discriminator 5)
0.0 0.0% 100.0% 0.9 0.1% InitCatalogCachePhase2 /home/phil/postgres/src/backend/utils/cache/syscache.c:187 (discriminator 3)
0.0 0.0% 100.0% 0.9 0.1% RelationCacheInitializePhase3 /home/phil/postgres/src/backend/utils/cache/relcache.c:4372
0.0 0.0% 100.0% 0.6 0.0% RelationBuildDesc /home/phil/postgres/src/backend/utils/cache/relcache.c:1208
0.0 0.0% 100.0% 0.6 0.0% RelationIdGetRelation /home/phil/postgres/src/backend/utils/cache/relcache.c:2116
0.0 0.0% 100.0% 0.6 0.0% index_open /home/phil/postgres/src/backend/access/index/indexam.c:137

누수를 찾았습니다.



문의 메일: salesinquiry@enterprisedb.com