Postgres 메모리 누수 디버깅하기: heaptrack 편

글쓴이: Phil Eaton
작성일: 2025년 5월 22일

이번 글에서는 Postgres에 인위적으로 두 가지 메모리 누수를 주입한 뒤, heaptrack을 사용하여 이를 디버깅하는 과정을 소개합니다. 지난 포스트에서 다뤘던 memleak처럼 heaptrack 또한 리눅스 환경이 필요합니다. 다만, Mac 사용자도 리눅스 VM을 사용하면 충분히 실습해볼 수 있습니다 (저도 Mac에서 VM을 사용했습니다).

Postgres를 기반 코드로 사용하긴 하지만, 이 글에서 소개하는 기법은 C, C++, Rust 프로젝트 전반에 걸쳐 메모리 누수를 추적할 때 유용합니다. 포스트 리뷰를 도와준 동료 Jacob Champion에게 감사드립니다.

Postgres 빌드 준비

먼저 Postgres 코드를 클론하고 디버깅 옵션을 활성화하여 빌드합니다:

$ git clone https://github.com/postgres/postgres
$ cd postgres
$ git checkout REL_17_STABLE
$ ./configure --enable-debug \
--prefix=$(pwd)/build \
--libdir=$(pwd)/build/lib
$ make -j16 && make install

사례 1: 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(80);
+ *s = 12;
+ MemoryContextSwitchTo(old);
/*
* Create backend data structure. Better before the fork() so we can

Postgres는 MemoryContext라는 구조로 메모리를 할당합니다. 최상위 컨텍스트인 TopMemoryContext는 프로세스 종료 시 메모리가 정리되므로 valgrind memcheckLeakSanitizer는 이를 감지하지 못할 수 있습니다. 그러나 프로세스가 살아 있는 동안에는 실제로 누수가 발생한 상태입니다.

누수를 유발하는 가장 쉬운 방법

다수의 psql 클라이언트를 생성해 많은 백엔드 프로세스를 실행시키면 위의 누수가 발생합니다:

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

Postgres 빌드 및 실행

변경된 코드를 기반으로 다시 빌드합니다:

$ make -j16 && make install

서버가 다음과 같이 실행됩니다:

$ ./build/bin/initdb testdb
$ ./build/bin/postgres -D $(pwd)/testdb
2025-05-22 12:05:15.995 EDT [260576] LOG: starting PostgreSQL 17.5 on aarch64-unknown-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
2025-05-22 12:05:15.996 EDT [260576] LOG: listening on IPv6 address "::1", port 5432
2025-05-22 12:05:15.996 EDT [260576] LOG: listening on IPv4 address "127.0.0.1", port 5432
2025-05-22 12:05:15.997 EDT [260576] LOG: listening on Unix socket "/tmp/.s.PGSQL.5432"
2025-05-22 12:05:16.001 EDT [260579] LOG: database system was shut down at 2025-05-22 11:37:53 EDT
2025-05-22 12:05:16.004 EDT [260576] LOG: database system is ready to accept connection
s

출력된 로그에서 대괄호 안에 있는 숫자가 postmaster의 PID입니다. 해당 PID를 사용해 heaptrack을 attach 합니다:

$ sudo heaptrack -p 260576

해당 프로세스는 Postgres 서버가 종료될 때까지 실행됩니다.

이제 또 다른 터미널에서 누수 유발 작업을 실행합니다:

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

원한다면 또 다른 터미널에서 top 명령어로 메모리 사용량이 점점 증가하는 것을 확인할 수 있습니다.

heaptrack 결과 확인

작업이 완료되면, Postgres 프로세스를 Ctrl + C로 종료하고 heaptrack 결과를 분석합니다:

$ sudo heaptrack -p 260576
heaptrack output will be written to "/home/phil/postgres/heaptrack.postgres.260645.zst"
injecting heaptrack into application via GDB, this might take some time...
warning: 44 ./nptl/cancellation.c: No such file or directory
injection finished
heaptrack stats:
allocations: 19
leaked allocations: 19
temporary allocations: 0
Heaptrack finished! Now run the following to investigate the data:
heaptrack --analyze "/home/phil/postgres/heaptrack.postgres.260645.zst"

heaptrack이 생성하는 로그 파일 이름에는 우리가 추적 대상으로 지정한 PID와는 다른 PID가 포함되어 있을 수 있습니다. 그러나 이는 정상이며 문제되지 않습니다. 이제 heaptrack_print를 사용해 결과를 보기 좋게 출력해 보겠습니다.

$ heaptrack_print --print-leaks 1 ./heaptrack.postgres.260645.zst > leak.txt

이제 leak.txt 파일을 열어 MEMORY LEAKS 섹션을 찾아보면 다음과 같은 내용을 확인할 수 있습니다:

MEMORY LEAKS
16.77M leaked over 11 calls from
AllocSetAllocFromNewBlock
at /home/phil/postgres/src/backend/utils/mmgr/aset.c:908
in /home/phil/postgres/build/bin/postgres
16.77M leaked over 11 calls from:
ServerLoop::BackendStartup
at /home/phil/postgres/src/backend/postmaster/postmaster.c:3554
in /home/phil/postgres/build/bin/postgres
ServerLoop
at /home/phil/postgres/src/backend/postmaster/postmaster.c:1676
PostmasterMain
at /home/phil/postgres/src/backend/postmaster/postmaster.c:1374
in /home/phil/postgres/build/bin/postgres
main
at /home/phil/postgres/src/backend/main/main.c:199
in /home/phil/postgres/build/bin/postgres

이는 우리가 앞서 코드에 삽입한 메모리 누수와 정확히 일치하는 결과입니다.

정말로 누수가 존재하는지 검증하기

혹시 우리가 착각하고 있는 건 아닌지 확인하기 위해, 누수를 유발한 palloc 코드를 주석 처리한 후 동일한 작업과 heaptrack 실행을 반복해 보았습니다. 결과 파일인 leak.txt는 다음과 같이 나옵니다:

$ cat leak.txt
reading file "./heaptrack.postgres.504295.zst" - please wait, this might take some time...
Debuggee command was: ./build/bin/postgres -D /home/phil/postgres/testdb
finished reading file, now analyzing data:

MOST CALLS TO ALLOCATION FUNCTIONS

PEAK MEMORY CONSUMERS

MEMORY LEAKS

MOST TEMPORARY ALLOCATIONS

total runtime: 146.664000s.
calls to allocation functions: 0 (0/s)
temporary memory allocations: 0 (0/s)
peak heap memory consumption: 0B
peak RSS (including heaptrack overhead): 21.93M
total memory leaked: 0

이번에는 메모리 누수가 전혀 없음을 확인할 수 있습니다.


또 다른 Postgres 프로세스에서의 메모리 누수 실험

이번에는 클라이언트 백엔드 프로세스에서 메모리 누수를 일으키고 이를 추적할 수 있는지 살펴보겠습니다.

클라이언트 백엔드에서의 누수

클라이언트 백엔드 프로세스는 세션 단위로 실행되고 종료되기 때문에, 메모리 누수를 추적하기 까다로운 편입니다. 하지만 해당 프로세스의 PID를 알 수 있다면 heaptrack을 attach하여 추적이 가능합니다.

이번엔 random() 함수 구현 내부에서 TopMemoryContext를 사용해 메모리 누수를 유도해보겠습니다.

$ 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(120);
+ MemoryContextSwitchTo(old);
+ *s = 90;
initialize_prng();

random() 함수를 여러 번 호출하면 이 누수가 발생합니다. 예를 들어 다음 쿼리를 통해 테스트할 수 있습니다:

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

앞서 수정한 코드를 기반으로 Postgres를 다시 빌드하고 설치합니다.

$ make -j16 && make install

그 다음, 이전에 생성한 testdb 데이터 디렉토리를 기반으로 Postgres 서버를 시작합니다.

$ ./build/bin/postgres -D $(pwd)/testdb
2025-05-22 14:37:11.322 EDT [704381] LOG: starting PostgreSQL 17.5 on aarch64-unknown-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
2025-05-22 14:37:11.323 EDT [704381] LOG: listening on IPv6 address "::1", port 5432
2025-05-22 14:37:11.323 EDT [704381] LOG: listening on IPv4 address "127.0.0.1", port 5432
2025-05-22 14:37:11.324 EDT [704381] LOG: listening on Unix socket "/tmp/.s.PGSQL.5432"
2025-05-22 14:37:11.327 EDT [704384] LOG: database system was shut down at 2025-05-22 14:31:00 EDT
2025-05-22 14:37:11.329 EDT [704381] LOG: database system is ready to accept connections

클라이언트 백엔드 PID 확인 및 heaptrack 연결

다른 터미널에서 psql 클라이언트를 실행한 뒤, 해당 세션의 백엔드 프로세스 PID를 확인합니다:

$ ./build/bin/psql postgres
psql (17.5)
Type "help" for help.
postgres=# select pg_backend_pid();
pg_backend_pid
----------------
704389
(1 row)
postgres=#

이 세션을 종료하지 말고 유지한 상태로, 새로운 터미널을 열어 heaptrack을 해당 PID에 attach 합니다:

$ sudo heaptrack -p 704389
heaptrack output will be written to "/home/phil/heaptrack.postgres.704409.zst"
injecting heaptrack into application via GDB, this might take some time...
warning: 44 ./nptl/cancellation.c: No such file or directory
injection finished

이제 다시 앞서 열어둔 704389번 psql 세션으로 돌아가 메모리 누수를 유발하는 워크로드를 실행합니다.

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

쿼리가 완료되면 Ctrl + D를 눌러 psql 세션을 정상 종료합니다.

heaptrack 결과 확인

heaptrack이 attach되어 있던 터미널에서 분석 결과가 출력됩니다:



$ sudo heaptrack -p 704389
heaptrack output will be written to "/home/phil/heaptrack.postgres.704409.zst"
injecting heaptrack into application via GDB, this might take some time...
warning: 44 ./nptl/cancellation.c: No such file or directory
injection finished
heaptrack stats:
allocations: 206
leaked allocations: 177
temporary allocations: 8
removing heaptrack injection via GDB, this might take some time...
ptrace: No such process.
No symbol table is loaded. Use the "file" command.
The program is not being run.
Heaptrack finished! Now run the following to investigate the data:
heaptrack --analyze "/home/phil/heaptrack.postgres.704409.zst"

heaptrack_print 를 이전과 같이 실행합니.

$ heaptrack_print --print-leaks 1 ./heaptrack.postgres.704409.zst > leak.txt

leak.txt 파일의 MEMORY LEAKS 섹션을 열어보면 다음과 같은 결과가 나타납니다:

MEMORY LEAKS
1.37G leaked over 180 calls from
AllocSetAllocFromNewBlock
at /home/phil/postgres/src/backend/utils/mmgr/aset.c:908
in /home/phil/postgres/build/bin/postgres
1.37G leaked over 170 calls from:
drandom
at /home/phil/postgres/src/backend/utils/adt/pseudorandomfuncs.c:92
in /home/phil/postgres/build/bin/postgres
ExecInterpExpr
at /home/phil/postgres/src/backend/executor/execExprInterp.c:740
in /home/phil/postgres/build/bin/postgres
ExecAgg::agg_retrieve_direct::advance_aggregates::ExecEvalExprSwitchContext
at ../../../src/include/executor/executor.h:356
in /home/phil/postgres/build/bin/postgres
ExecAgg::agg_retrieve_direct::advance_aggregates
at /home/phil/postgres/src/backend/executor/nodeAgg.c:820
ExecAgg::agg_retrieve_direct
at /home/phil/postgres/src/backend/executor/nodeAgg.c:2454
ExecAgg
at /home/phil/postgres/src/backend/executor/nodeAgg.c:2179
standard_ExecutorRun::ExecutePlan::ExecProcNode

우리가 의도적으로 random() 함수 안에 삽입한 메모리 누수가 정확하게 탐지된 것을 확인할 수 있습니다.

참고 사항

memleak과 달리, heaptrack은 추적 중인 프로세스가 종료된 후에야 메모리 누수 보고서를 출력합니다. 따라서 실시간으로 상태를 보고 싶거나, 프로세스를 종료시키기 어려운 상황에서는 다소 불편할 수 있습니다.

하지만 제 경험상 heaptrack은 다양한 환경에서 스택 트레이스와 코드 라인 번호를 memleak보다 훨씬 더 안정적이고 정확하게 제공해 줍니다.

요약하면, 프로그램 종료 시 자동으로 메모리가 정리되어버리는 환경에서는 Valgrind memcheckLeakSanitizer보다 heaptrackmemleak이 더 적합합니다.

결론적으로, heaptrackmemleak은 C/C++ 또는 PostgreSQL 기반 시스템을 다룰 때 꼭 도구 상자에 넣어두어야 할 훌륭한 메모리 분석 도구입니다.

본문: Debugging memory leaks in Postgres, heaptrack edition

이메일: salesinquiry@enterprisedb.com