core dump 분석으로 크래시 원인 파악하기

프로덕션에서 프로세스가 Segmentation fault로 죽었는데 로그에는 아무것도 안 남았다. 재현도 안 되고, 붙어 있던 디버거도 없다. 이럴 때 유일한 단서가 코어 덤프(core dump)다. 그런데 Ubuntu 24.04를 기본 설정 그대로 쓰면 직접 빌드한 바이너리는 코어 파일이 아예 생기지 않는다. 이 글에서는 왜 코어가 안 남는지 확인하고, 코어를 확실히 받도록 설정한 뒤 gdb로 크래시 지점까지 짚어내는 과정을 정리한다.

재현용 크래시 프로그램

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct node {
    int id;
    char name[16];
    struct node *next;
};

static void fill(struct node *n, const char *s)
{
    n->id = 42;
    strcpy(n->name, s);
}

static void walk(struct node *head)
{
    struct node *p = head;
    while (p) {
        printf("id=%d name=%s\n", p->id, p->name);
        p = p->next;
    }
}

int main(int argc, char **argv)
{
    struct node *head = malloc(sizeof(*head));
    fill(head, argc > 1 ? argv[1] : "ok");
    head->next = (struct node *)0x10;   /* 유효하지 않은 주소 */
    walk(head);
    return 0;
}

walk()가 두 번째 노드로 넘어가면서 0x10을 역참조해 SIGSEGV로 죽는다. 디버그 정보를 남겨 빌드하고 실행한다.

gcc -g -O0 -fno-omit-frame-pointer -o crash crash.c
./crash test
ls -l core*
Segmentation fault (core dumped)
ls: cannot access 'core*': No such file or directory

셸은 (core dumped)라고 보고하는데 정작 코어 파일은 없다. 원인은 두 곳에 있다.

Ubuntu 기본값에서 코어가 사라지는 두 가지 이유

cat /proc/sys/kernel/core_pattern
ulimit -c
tail -2 /var/log/apport.log
|/usr/share/apport/apport -p%p -s%s -c%c -d%d -P%P -u%u -g%g -F%F -- %E
0
INFO: apport (pid 3080): executable: /home/noble/coredemo/crash (command line "./crash test")
ERROR: apport (pid 3080): executable does not belong to a package, ignoring

core_pattern|로 시작하면 커널은 코어를 파일이 아니라 그 프로그램의 표준 입력으로 흘려보내는데, Ubuntu가 지정한 apport는 데비안 패키지에 속하지 않는 실행 파일을 그냥 버린다. 여기에 ulimit -c까지 0이라, 파일로 받도록 바꾸기만 해서는 크기 0으로 잘린다.

설정Ubuntu 기본값코어를 받으려면
kernel.core_patternapport 파이프파일 경로 패턴 또는 systemd-coredump
ulimit -c (RLIMIT_CORE)0unlimited
fs.suid_dumpable2그대로 (root만 읽기 가능)

core_pattern을 파일로 바꿔 직접 받기

sudo mkdir -p /var/lib/coredumps
sudo chmod 1777 /var/lib/coredumps
sudo sysctl -w kernel.core_pattern='/var/lib/coredumps/core.%e.%p.%t'

# ulimit 0 상태 그대로 실행
(ulimit -c 0; ./crash test); ls /var/lib/coredumps/

# 제한을 풀고 실행
(ulimit -c unlimited; ./crash test); ls -l /var/lib/coredumps/
Segmentation fault
(비어 있음)

Segmentation fault (core dumped)
-rw------- 1 noble noble 446464 Aug  8 20:42 core.crash.3311.1786189346

ulimit -c 0일 때는 셸 메시지에서 (core dumped)가 빠진다 — 커널이 덤프 자체를 건너뛰었다는 뜻이다. 설정을 영구화하려면 /etc/sysctl.d//etc/security/limits.conf에 함께 넣어야 한다.

패턴의미
%e실행 파일 이름 (경로 제외)
%pPID (네임스페이스 기준)
%P글로벌 PID (컨테이너 안에서 유용)
%t덤프 시각 (epoch 초)
%s덤프를 유발한 시그널 번호
%h호스트명

gdb로 크래시 지점 찾기

gdb -q -batch \
    -ex 'bt' \
    -ex 'frame 0' -ex 'info locals' -ex 'print p' \
    -ex 'print $_siginfo._sifields._sigfault.si_addr' \
    ./crash /var/lib/coredumps/core.crash.3311.1786189346
Core was generated by `./crash test'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x00005c2a8ab441e7 in walk (head=0x5c2aae8452a0) at crash.c:21
21	        printf("id=%d name=%s\n", p->id, p->name);
#1  0x00005c2a8ab44275 in main (argc=2, argv=0x7ffef6b79e88) at crash.c:31

#0  0x00005c2a8ab441e7 in walk (head=0x5c2aae8452a0) at crash.c:21
p = 0x10
$1 = (struct node *) 0x10
$2 = (void *) 0x10

si_addr가 실제로 접근을 시도한 주소다. 여기서는 p의 값과 si_addr가 모두 0x10으로 일치하므로, 포인터가 오염된 게 아니라 애초에 0x10이 대입된 것이 원인이라고 좁힐 수 있다.

심볼이 없으면 무엇이 보이는가

gcc -O2 -o crash-nodbg crash.c && strip crash-nodbg
(ulimit -c unlimited; ./crash-nodbg test)
gdb -q -batch -ex 'bt' ./crash-nodbg /var/lib/coredumps/core.crash-nodbg.*
#0  0x00005ed8209210f8 in ?? ()
#1  0x000074b647a2a1ca in __libc_start_call_main (main=main@entry=0x5ed8209210a0, ...) at ../sysdeps/nptl/libc_start_call_main.h:58
#2  0x000074b647a2a28b in __libc_start_main_impl (main=0x5ed8209210a0, argc=2, ...) at ../csu/libc-start.c:360
#3  0x00005ed820921165 in ?? ()

애플리케이션 프레임이 전부 ??로 나온다. 배포 바이너리는 작게 유지하면서 분석 가능성도 남기려면 디버그 정보를 별도 파일로 떼어내고 gnu_debuglink로 연결한다.

gcc -g -O2 -o crash-sep crash.c
objcopy --only-keep-debug crash-sep crash-sep.debug
objcopy --strip-debug --add-gnu-debuglink=crash-sep.debug crash-sep
ls -l crash-sep crash-sep.debug
(ulimit -c unlimited; ./crash-sep test)
gdb -q -batch -ex 'bt' ./crash-sep /var/lib/coredumps/core.crash-sep.*
-rwxrwxr-x 1 noble noble 16016 Aug  8 20:43 crash-sep
-rwxrwxr-x 1 noble noble  8000 Aug  8 20:43 crash-sep.debug
#0  0x00005c6f618710f8 in walk (head=<optimized out>) at crash.c:21
21	        printf("id=%d name=%s\n", p->id, p->name);
#1  main (argc=<optimized out>, argv=0x7fffa584b368) at crash.c:31

함수명과 소스 라인은 되살아났지만 -O2 때문에 인자가 <optimized out>으로 나오고 walk()가 인라인되어 프레임도 하나 줄었다. 변수 값까지 봐야 한다면 -Og 정도로 낮추는 편이 낫다.

systemd-coredump와 coredumpctl

코어 파일을 직접 관리하는 대신 systemd에 맡기면 압축 저장·메타데이터 기록·자동 정리를 한 번에 해결할 수 있다. 설치하면 core_pattern이 자동으로 교체된다.

sudo apt install -y systemd-coredump
cat /proc/sys/kernel/core_pattern
(ulimit -c unlimited; ./crash test)
coredumpctl list
|/usr/lib/systemd/systemd-coredump %P %u %g %s %t 9223372036854775808 %h %d
Segmentation fault (core dumped)

TIME                         PID  UID  GID SIG     COREFILE EXE                         SIZE
Sat 2026-08-08 20:44:56 KST 5131 1000 1000 SIGSEGV present  /home/noble/coredemo/crash 17.2K

파일로 받았을 때 446KB였던 코어가 17.2KB로 줄었다 — systemd가 zstd로 압축해 /var/lib/systemd/coredump/에 넣기 때문이다. coredumpctl info는 코어를 풀지 않고도 스택 트레이스와 실행 환경을 보여준다.

coredumpctl info crash
           PID: 5131 (crash)
           UID: 1000 (noble)
        Signal: 11 (SEGV)
     Timestamp: Sat 2026-08-08 20:44:56 KST (6s ago)
  Command Line: ./crash test
    Executable: /home/noble/coredemo/crash
 Control Group: /user.slice/user-1000.slice/session-21.scope
       Storage: /var/lib/systemd/coredump/core.crash.1000.7742....5131.1786189496000000.zst (present)
  Size on Disk: 17.2K
       Message: Process 5131 (crash) of user 1000 dumped core.

                Stack trace of thread 5131:
                #0  0x00006272109a21e7 n/a (/home/noble/coredemo/crash + 0x11e7)
                #1  0x00006272109a2275 n/a (/home/noble/coredemo/crash + 0x1275)

coredumpctl debug는 압축 해제와 gdb 실행을 한 번에 처리한다. 배치로 돌리면 CI에서도 그대로 쓸 수 있다.

coredumpctl debug crash --debugger-arguments="-q -batch -ex bt -ex 'print p'"
coredumpctl dump crash --output=/tmp/crash.core   # 원본 코어를 파일로 추출
#0  0x00006272109a21e7 in walk (head=0x62723e8162a0) at crash.c:21
21	        printf("id=%d name=%s\n", p->id, p->name);
#1  0x00006272109a2275 in main (argc=2, argv=0x7ffc1a917038) at crash.c:31
$1 = (struct node *) 0x10

죽지 않은 프로세스 덤프하기 (gcore)

행(hang)이 걸렸거나 메모리가 계속 늘어나는 프로세스는 죽이지 않고 스냅샷만 떠서 분석한다.

sleep 300 &
gcore -o /tmp/live $!
ptrace: Inappropriate ioctl for device.
gcore: failed to create /tmp/live.4198

Yama LSM의 ptrace_scope가 1이면 자기 자손이 아닌 프로세스에는 attach할 수 없다. sudo로 올리거나 값을 임시로 0으로 내리면 된다.

cat /proc/sys/kernel/yama/ptrace_scope
sudo gcore -o /tmp/live 4198
gdb -q -batch -ex 'bt' /usr/bin/sleep /tmp/live.4198
1
Saved corefile /tmp/live.4198
[Inferior 1 (process 4198) detached]

#0  0x000078096d0ecb7a in __GI___clock_nanosleep (clock_id=0, flags=0, req=0x7fff4cfd0fb0, rem=0x7fff4cfd0fa0) at ../sysdeps/unix/sysv/linux/clock_nanosleep.c:78
#1  0x000078096d0f9b27 in __GI___nanosleep (req=<optimized out>, rem=<optimized out>) at ../sysdeps/unix/sysv/linux/nanosleep.c:25
#2  0x000064c57c70ca7f in ?? ()

gcore는 대상 프로세스를 잠깐 멈춘 뒤 재개시키므로, 메모리가 큰 프로세스에서는 덤프를 쓰는 동안 수 초간 정지할 수 있다.

setuid 프로그램의 코어는 어떻게 되는가

setuid 바이너리의 코어에는 권한 상승된 상태의 메모리가 그대로 들어 있어 별도 규칙이 적용된다. “setuid는 코어를 안 남긴다”고 알려져 있지만, systemd가 fs.suid_dumpable을 2로 올려두는 요즘 Ubuntu에서는 사실이 아니다.

sudo cp crash crash-suid && sudo chown root:root crash-suid && sudo chmod 4755 crash-suid
sysctl fs.suid_dumpable
(ulimit -c unlimited; ./crash-suid test)
sudo ls -l /var/lib/systemd/coredump/
sudo getfacl -p /var/lib/systemd/coredump/core.crash*.zst | grep -E '^# file|^user'
fs.suid_dumpable = 2
Segmentation fault (core dumped)

-rw-r-----+ 1 root root 17697 Aug  8 20:44 core.crash.1000.7742...5131....zst
-rw-r----- 1 root root 17783 Aug  8 20:45 core.crash-suid.1000.7742...5384....zst

# file: core.crash.1000....zst
user::rw-
user:noble:r--
# file: core.crash-suid.1000....zst
user::rw-

일반 프로그램의 코어에는 소유자에게 읽기 권한을 주는 ACL(user:noble:r--)이 붙지만, setuid 프로그램의 코어에는 그 ACL이 없다. 그래서 코어를 만든 당사자도 읽지 못한다.

coredumpctl info crash-suid | grep Storage
       Storage: /var/lib/systemd/coredump/core.crash-suid.1000....zst (inaccessible)

fs.suid_dumpable을 0으로 되돌리면 그때는 코어가 아예 생기지 않고 셸 메시지에서도 (core dumped)가 사라진다.

주의사항

  • 컨테이너 안에서 core_pattern을 바꿔도 소용없다. 이 값은 호스트 커널 전역이라 컨테이너의 코어도 호스트 설정을 따른다. 파이프 핸들러는 호스트 네임스페이스에서 실행되므로 경로 패턴에는 %P(글로벌 PID)를 넣어야 대상을 특정할 수 있다.
  • systemd 서비스로 뜬 프로세스는 셸의 ulimit과 무관하다. 유닛 파일에 LimitCORE=infinity를 넣어야 한다.
  • systemd-coredump/etc/systemd/coredump.confProcessSizeMax·ExternalSizeMax(64비트 기본 각각 32G)를 넘으면 코어를 저장하지 않고 coredumpctl listCOREFILE 칸에 none만 남긴다. 디스크 여유가 없을 때도 KeepFree에 걸려 조용히 버려지므로, 코어가 안 보이면 journalctl -u systemd-coredump를 먼저 확인한다.
  • 코어에는 메모리에 있던 비밀번호·토큰·개인정보가 그대로 들어간다. 저장 디렉터리 권한을 조이고 MaxUse/MaxRetentionSec로 보존 기간을 제한한다.
  • 덤프 대상 메모리 영역은 /proc/PID/coredump_filter로 조절한다. 공유 메모리나 파일 매핑까지 포함시키면 코어가 수십 GB로 불어날 수 있다.
  • 코어를 다른 장비에서 분석하려면 실행 파일뿐 아니라 크래시 당시의 공유 라이브러리도 같은 버전이어야 한다. 버전이 어긋나면 백트레이스가 조용히 엉뚱하게 나온다.

마무리

정리하면 코어를 확보하는 데 필요한 건 세 가지다. core_pattern을 파일이나 systemd-coredump로 지정하고, RLIMIT_CORE를 풀고, 디버그 심볼을 남겨두는 것. 이 셋 중 하나만 빠져도 정작 사고가 났을 때 ??만 잔뜩 찍힌 백트레이스를 보게 된다. 운영 장비라면 사고가 나기 전에 일부러 한 번 죽여보고 코어가 실제로 떨어지는지 확인해두는 편이 좋다.

참고

답글 남기기