Linux 프로세스·스케줄러 심화 — 태스크 상태 머신, CFS·런큐, NUMA와 프로덕션 튜닝

이 글의 핵심

리눅스 커널은 스레드마다 task_struct를 두고, 우리가 부르는 프로세스는 그 묶음에 가깝습니다. 컨테이너에서 PID 1이 자식을 거두지 않을 때 생기는 문제, D 상태가 kill로 안 풀리는 이유, cgroup 한도 때문에 CPU 여유와 체감 지연이 어긋나는 현상을 다루고, 마지막에 디버깅 일지 형식으로 점검 순서를 정리합니다.

들어가며 — Linux 시리즈 #05

이 글은 Linux 시리즈 다섯 번째로, 좀비·고아 프로세스와 수거 실패부터 짚은 다음 사용자 공간의 “프로세스”를 커널의 태스크(task), 스케줄러, cgroup, 관측 도구와 연결합니다. 순서를 이렇게 잡은 이유는 단순합니다. 보통 문서는 fork와 R/S/D 상태부터 설명하지만, 운영에서 실제로 장애를 일으키는 것은 PID 소진, Z 상태 누적, 자식을 거두지 않는 PID 1 같은 문제인 경우가 많기 때문입니다.

이후 프로세스 개요와 PID, fork/exec, 부모–자식 관계와 systemd, ps의 R/S/D/T/Z 상태 전이, 종료·시그널·IPC, nice와 CFS, ps/top과 /proc, kill 계열, 데몬, 실전 스크립트와 cgroup, 증상별 점검 순서까지 이어갑니다. 메모리와 페이지 폴트는 Linux 메모리·가상 메모리 심화에서 다룹니다.

좀비·고아부터 — 수거되지 않은 자식이 쌓일 때

좀비가 대량으로 쌓이는 전형적인 경로

수명이 짧은 워커를 fork로 계속 띄우는 서비스에서, 부모가 SIGCHLD를 무시하지도 처리하지도 않은 채 wait을 부르지 않는 경로가 생기면 이런 일이 벌어집니다. 자식은 계속 exit하는데 아무도 거두지 않으니, ps에 Z 상태가 줄줄이 늘어납니다. 좀비는 CPU를 쓰지 않으므로 CPU 모니터링에는 아무 이상이 보이지 않습니다. 그러다 PID가 pid_max에 닿거나 사용자별 프로세스 한도(ulimit -u)나 cgroup의 pids.max에 먼저 닿으면 fork가 EAGAIN으로 실패하기 시작하고, 그제야 장애로 드러납니다.

좀비 하나는 메모리를 거의 쓰지 않지만 PID 하나와 커널 태스크 구조를 계속 붙잡습니다. 소수는 흔하고 무해하지만, 수거 누락이 구조적으로 반복되면 결국 한도에 닿습니다. 그래서 설계 리뷰에서 “fork로 워커를 띄운다”는 말이 나오면 누가 언제 wait하는지부터 확인하는 것이 좋습니다.

좀비: 원인과 왜 “문제”인가

자식이 종료하면 커널은 종료 코드와 자원 사용 통계를 부모가 wait/waitpid로 가져갈 때까지 남겨 둡니다. 그 사이 ps는 이 프로세스를 Z로 표시합니다. 원인은 한 가지, 부모가 수거 책임을 지지 않는 설계나 버그입니다.

처리: wait, waitpid, SIGCHLD

부모는 자식이 끝날 때마다 wait 계열로 정리하거나, SIGCHLD 핸들러에서 waitpid(-1, NULL, WNOHANG) 루프를 돌려 끝난 자식을 한 번에 모두 거둡니다. 같은 시그널이 여러 번 와도 하나로 합쳐질 수 있으므로, 한 번 호출로 끝내지 말고 반드시 루프를 돌아야 합니다.

#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <unistd.h>
static void reap_children(int signo) {
    (void)signo;
    while (waitpid(-1, NULL, WNOHANG) > 0) {
        /* 자식 여러 명을 한 시그널 핸들러에서 정리 */
    }
}
int main(void) {
    struct sigaction sa;
    sa.sa_handler = reap_children;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    sigaction(SIGCHLD, &sa, NULL);
    /* ... fork 자식들 ... */
    return 0;
}

실제 코드에서는 핸들러 안에서 waitpid가 errno를 바꿀 수 있으므로 진입 시 errno를 저장했다가 복원하는 것이 안전합니다. 비동기 시그널 안전성은 뒤의 시그널 절에서 다시 다룹니다. 자식의 종료 상태가 필요 없다면 SIGCHLD를 SIG_IGN으로 설정해 커널이 자동으로 거두게 하는 방법도 있습니다. 운영 관점에서는 셸에서 임시로 백그라운드 실행하는 것보다 서비스 매니저가 재시작, 로그, 한도를 관리하게 하는 편이 이런 누락을 줄여 줍니다.

고아(orphan)와 PID 1, 컨테이너

부모가 먼저 죽으면 자식은 고아가 되고, PID 1(또는 PR_SET_CHILD_SUBREAPER로 지정된 가장 가까운 조상)에게 입양됩니다. 입양한 쪽은 그 자식이 끝났을 때 wait으로 거둘 책임이 있습니다. 컨테이너에서는 애플리케이션이 그대로 PID 1이 되는 경우가 많은데, 앱이 자식 수거를 고려하지 않았다면 좀비가 쌓입니다. 또 PID 1은 핸들러를 등록하지 않은 시그널의 기본 동작이 적용되지 않으므로 SIGTERM을 받아도 종료되지 않는 문제도 함께 생깁니다. 이럴 때는 tini나 dumb-init을 PID 1로 두거나(docker run --init), 애플리케이션이 직접 자식을 거두도록 설계합니다.


프로세스 개요: 무엇이며 PID와 생명주기는 어떻게 되는가

프로세스란

프로세스는 실행 중인 프로그램의 인스턴스로, 가상 주소 공간, 열린 파일 디스크립터, 시그널 마스크, 자원 한도 등을 커널이 하나로 묶어 관리하는 단위입니다. 커널은 동시에 수천에서 수만 개의 프로세스를 유지하며, 각각의 스케줄링, 자원 회계, 보안 문맥을 관리합니다.

다중 스레드 프로세스에서는 사용자 입장의 “한 프로세스”가 커널에서는 같은 스레드 그룹으로 묶인 여러 task_struct로 표현됩니다. 스케줄러가 다루는 단위는 스레드(태스크)이므로, 프로세스 단위와 스레드 단위를 혼동하면 CPU 사용률, 락, 스케줄 지연 분석이 어긋납니다.

PID와 PID 네임스페이스

PID는 정수 식별자이며, 부팅 시 가장 먼저 생기는 사용자 프로세스가 PID 1입니다. PID 네임스페이스(컨테이너) 안에서는 같은 번호가 호스트의 다른 프로세스를 가리킬 수 있으므로, 장애를 분석할 때는 어느 네임스페이스의 PID인지 함께 확인합니다.

생명주기

  1. 생성: fork/exec 또는 clone.

  2. 실행: 스케줄러에 의해 런큐와 CPU 사이를 오갑니다.

  3. 대기/차단: I/O나 락을 기다리며 S 또는 D 상태가 됩니다.

  4. 종료: exit 뒤 부모가 wait할 때까지 Z 상태로 남습니다(위 좀비 절).

  5. 정리: 부모나 입양한 PID 1이 wait하면 완전히 사라집니다.


프로세스 생성: fork()와 exec() 계열

fork: 주소 공간 복제와 CoW

fork()는 호출한 프로세스를 복제해 자식을 만듭니다. 주소 공간 전체를 복사하면 비용이 크므로, 리눅스는 Copy-on-Write(CoW)로 페이지를 공유해 두었다가 어느 한쪽이 쓸 때 비로소 복사합니다. 반환값은 자식에게 0, 부모에게 자식의 PID입니다.

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        /* 자식 */
        _exit(0); /* 라이브러리 정리 없이 즉시 종료할 때 */
    }
    /* 부모 */
    wait(NULL);
    return 0;
}

짧게 끝나는 자식을 대량으로 띄우면 fork 비용과 PID 소모가 함께 커집니다. 앞의 좀비 문제와 원리는 같아서, 짧은 자식을 많이 만드는 설계라면 누가 거둘지가 반드시 정해져 있어야 합니다.

exec 계열: 같은 PID, 새 프로그램

fork 직후 execve를 호출하면 PID는 그대로이고 코드와 데이터가 새 프로그램으로 바뀝니다. exec가 성공하면 호출 뒤의 코드는 실행되지 않으므로, 그다음 줄에 오는 코드는 곧 실패 처리 경로입니다. 열린 fd는 O_CLOEXEC가 없으면 새 프로그램으로 그대로 넘어갑니다.

vfork, clone

vfork는 자식이 exec나 _exit를 호출할 때까지 부모를 멈추고 주소 공간을 복사 없이 공유합니다. 직접 쓰는 일은 드물지만 posix_spawn 구현 내부에서 비슷한 방식이 쓰입니다. clone은 주소 공간(CLONE_VM), fd 테이블, 시그널 핸들러, 네임스페이스 등 무엇을 공유하고 무엇을 새로 만들지를 플래그로 지정하는 범용 시스템 콜로, 스레드와 컨테이너 모두 이것으로 만들어집니다.


프로세스 계층: 부모–자식, init과 systemd

부모–자식 트리, 세션, 프로세스 그룹

모든 프로세스는 부모 PID(PPID)를 가지며, pstree나 /proc/PID/status의 PPid로 추적할 수 있습니다. 세션과 프로세스 그룹은 터미널의 Ctrl+C(SIGINT)가 파이프라인 전체에 전달되는 방식과 잡 제어를 결정하고, 데몬이 터미널에서 분리되는 과정과도 연결됩니다.

PID 1과 systemd

systemd는 지지하는 사람도, 비판하는 사람도 많습니다. 유닛 파일 하나에 의존성, 재시작 정책, 자원 한도, 로그를 모아 둘 수 있다는 점은 셸 스크립트와 크론으로 같은 일을 관리해 본 사람에게는 매우 실질적인 이점입니다. 반면 동작이 불투명하고 디버깅이 어렵다는 비판도 이해할 만합니다.

실무에서 중요한 것은 어느 쪽이 더 나은지가 아니라, PID 1과 서비스 경계, 자식 수거, 시그널 처리가 유닛 설정에 명시되어 있는지입니다. PID 1이 좀비를 거두는 것은 설계 철학이 아니라 책임입니다. 앞의 좀비 문제는 부모, PID 1, 수거라는 세 요소 중 하나라도 빠지면 생깁니다.


프로세스 상태: R, S, D, T, Z

ps의 STAT 첫 글자는 커널의 태스크 상태를 한 글자로 요약한 것입니다. 표로 외우기보다 태스크가 무엇을 기다리고 있는지로 읽는 편이 낫습니다.

R (running / runnable) — CPU에서 실행 중이거나, 실행 대기열(런큐)에 올라가 차례를 기다리는 상태입니다. 멀티코어에서는 여러 태스크가 동시에 R일 수 있습니다.

S (interruptible sleep) — 무언가를 기다리지만 시그널이 오면 깨어날 수 있는 상태입니다. 소켓 읽기, futex, 대부분의 블로킹 호출이 여기에 해당합니다.

D (uninterruptible sleep) — 중간에 끊으면 커널 상태가 망가질 수 있는 구간에서 기다리는 상태입니다. 디스크 I/O나 NFS 응답, 일부 드라이버 경로에서 이 상태가 길어지면 프로세스가 멈춘 것처럼 보입니다. kill -9를 보내도 태스크가 D에서 빠져나올 때까지는 처리되지 않으므로, 이때는 CPU보다 스토리지, NFS, 블록 계층을 의심합니다. 최근 커널은 치명적 시그널에는 깨어나는 TASK_KILLABLE 대기를 늘려 가고 있습니다.

T (stopped / traced) — SIGSTOP이나 Ctrl+Z로 정지했거나 디버거(ptrace)가 멈춰 둔 상태입니다. 재개될 때까지 스케줄 대상에서 빠집니다.

Z (zombie) — 이미 종료했지만 부모가 wait하지 않아 종료 정보만 남은 상태입니다(맨 앞 좀비 절).

상태 전이

실행 중이던 태스크가 CPU를 내놓을 때, 무엇을 기다리느냐에 따라 상태가 갈립니다. 시그널로 깨워도 괜찮은 대기라면 S가 되고, 커널이 중간에 깨우면 곤란하다고 판단한 경로라면 D가 됩니다. 기다리던 조건이 충족되면 커널이 대기 큐(wait queue)에서 태스크를 깨워(wake_up) 다시 R로 런큐에 올립니다. Z는 실행 상태가 아니라 수거를 기다리는 잔여 기록입니다.

S와 D의 구분은 체감에 직접 영향을 줍니다. D는 로드 애버리지에도 포함되므로, CPU가 한가한데 로드 애버리지가 높다면 D 상태 태스크가 쌓였는지 확인해 볼 만합니다.


프로세스 종료: exit(), _exit(), atexit()

exit() (라이브러리)

exit()는 atexit로 등록한 핸들러를 역순으로 실행하고 stdio 버퍼를 비운 뒤 _exit로 커널에 종료를 요청합니다.

_exit() / _Exit()

라이브러리 정리 없이 바로 커널로 가는 종료입니다. fork한 자식에서 exit()를 부르면 부모에게서 복사된 stdio 버퍼가 한 번 더 출력될 수 있으므로, 자식은 _exit()로 끝내는 것이 관례입니다.

atexit()

atexit 핸들러는 등록 순서와 전역 객체 소멸 순서가 얽히면 디버깅이 매우 어려워집니다. 핵심 정리 작업은 명시적인 종료 함수로 처리하는 편이 낫습니다.


시그널: SIGTERM, SIGKILL, 처리 모델

시그널은 프로세스에 비동기로 전달되는 이벤트입니다. SIGTERM은 정리하고 종료해 달라는 요청으로, 프로세스가 핸들러로 받아 처리할 수 있으며 종료할 때 먼저 보내는 것이 관례입니다. SIGKILL은 핸들러로 잡을 수 없고 커널이 강제로 종료합니다. SIGSTOP/SIGCONT는 정지와 재개이고, SIGCHLD는 자식의 상태가 바뀌었을 때 부모에게 오는 시그널로 앞의 좀비 수거와 짝을 이룹니다.

SIGKILL이 안 먹는다는 오해는 대개 D 상태에서 나옵니다. 시그널은 전달되었지만, 태스크가 D 대기를 빠져나와야 비로소 처리되기 때문입니다.

핸들러와 비동기 시그널 안전

시그널 핸들러는 메인 코드의 어느 지점에서든 끼어들 수 있으므로, 핸들러 안에서 malloc이나 printf처럼 내부 락을 쓰는 함수를 부르면 교착이나 메모리 손상이 생길 수 있습니다. 보통 핸들러에서는 플래그만 세우고, 실제 정리는 메인 루프에서 합니다.

#include <signal.h>
#include <stdatomic.h>
#include <unistd.h>
static atomic_int stop_flag;
static void onterm(int signo) {
    (void)signo;
    atomic_store(&stop_flag, 1);
}
int main(void) {
    struct sigaction sa;
    sa.sa_handler = onterm;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGTERM, &sa, NULL);
    while (!atomic_load(&stop_flag)) {
        pause(); /* 실제론 epoll/작업 루프 */
    }
    return 0;
}

Kubernetes는 파드를 종료할 때 컨테이너의 PID 1에 SIGTERM을 보내고, terminationGracePeriodSeconds(기본 30초)가 지나도 살아 있으면 SIGKILL을 보냅니다. 앱이 SIGTERM을 처리하지 않으면 매번 30초를 기다린 뒤 강제 종료되므로, 배포가 느려지고 진행 중인 요청이 끊깁니다.


프로세스 간 통신(IPC) 개요: 파이프·소켓·공유 메모리

왜 IPC가 필요한가

프로세스마다 가상 주소 공간이 분리되어 있으므로, 데이터를 주고받으려면 파이프, 소켓, 공유 메모리 같은 커널이 제공하는 통로가 필요합니다.

파이프(anonymous / named FIFO)

익명 파이프는 셸의 |처럼 부모가 만든 fd를 자식이 상속받아 쓰고, FIFO는 파일시스템 경로를 통해 관계없는 프로세스끼리도 연결할 수 있습니다. 둘 다 한 방향 바이트 스트림입니다.

소켓(UNIX / TCP)

UNIX 도메인 소켓은 같은 호스트 안의 프로세스끼리, TCP 소켓은 네트워크 너머와 통신합니다. UNIX 도메인 소켓은 fd 자체를 다른 프로세스에 넘길 수도 있습니다. 둘 다 fd로 다루므로 epoll과 함께 쓰기 좋습니다.

공유 메모리(shm)와 동기화

같은 물리 페이지를 여러 프로세스가 매핑하므로 복사가 없어 가장 빠르지만, 동기화를 직접 해야 합니다. 락이나 원자 연산 없이 쓰면 경쟁 상태가 생깁니다.

System V IPC와 그 밖의 선택지

System V 메시지 큐, 세마포어, 공유 메모리는 레거시 시스템에서 여전히 볼 수 있습니다. ipcs로 남아 있는 객체를 확인할 수 있는데, 프로세스가 죽어도 자동으로 지워지지 않아 누수되기 쉽습니다. 새로 설계한다면 POSIX 공유 메모리나 소켓 기반 프로토콜을 쓰는 경우가 많습니다.


프로세스 우선순위: nice와 renice

nice 값은 -20(가장 높은 우선순위)부터 19(가장 낮음)까지이며, CFS에서 CPU 시간을 나눌 때의 가중치로 쓰입니다. nice 1 차이가 대략 10% 정도의 CPU 몫 차이를 만듭니다. renice는 이미 실행 중인 프로세스의 값을 바꿉니다. 일반 사용자는 값을 올릴(우선순위를 낮출) 수만 있고, 낮추려면 root 권한이 필요합니다.

# 새 프로세스에 nice 10
nice -n 10 ./batch_job
# PID 1234를 nice 5로(권한 필요할 수 있음)
renice -n 5 -p 1234

nice는 CPU 몫에만 영향을 줍니다. 디스크 I/O 우선순위는 ionice나 cgroup의 I/O 컨트롤러, 네트워크는 tc 같은 별도 도구로 조절해야 합니다.


프로세스 스케줄링: CFS, 스케줄러, 선점

리눅스 스케줄러는 여러 스케줄링 클래스(실시간 SCHED_FIFO/SCHED_RR, SCHED_DEADLINE, 일반 SCHED_OTHER 등)를 우선순위대로 쌓아 두고, 일반 태스크는 CFS가 담당합니다(커널 6.6부터는 CFS를 대체한 EEVDF가 같은 역할을 합니다). CFS는 각 태스크가 가중치 대비 얼마나 CPU를 썼는지를 vruntime으로 추적해 가장 적게 쓴 태스크를 먼저 실행합니다. 이것은 장기적인 공정성을 위한 장치일 뿐, p99 지연을 보장하지는 않습니다. 런큐는 CPU마다 따로 있어서, NUMA 노드 간 이동이나 가상화 환경의 steal time도 지연에 영향을 줍니다.


프로세스 모니터링: ps, top, htop

ps

# 모든 프로세스, 트리, 사용자 정의 열
ps auxf
ps -eo pid,ppid,stat,pcpu,pmem,cmd --sort=-pcpu | head
  • aux는 BSD 스타일의 관용 옵션이고, -o로 원하는 열만 뽑으면 스크립트에서 쓰기 편합니다. -L을 주면 스레드(LWP)별로 볼 수 있습니다.

top / htop

top은 주기적으로 갱신되며 1 키로 CPU별, H 키로 스레드별 보기를 켤 수 있습니다. cgroup 단위 사용량은 보여 주지 않으므로 systemd-cgtop이나 eBPF 도구, Kubernetes 메트릭을 함께 봅니다.

pidstat 등 보조 도구

  • pidstat: 프로세스별 CPU, 문맥 전환(-w), I/O(-d)를 시계열로 봅니다.

  • vmstat/iostat: 시스템 전체의 CPU, 메모리, I/O 개요를 봅니다.

  • perf sched/eBPF runqlat: 태스크가 런큐에서 얼마나 기다렸는지 측정합니다.


/proc 파일시스템: 무엇을 보려면 어디를 여는가

/proc은 커널이 상태를 파일 형태로 보여 주는 가상 파일시스템입니다. 항목을 표로 외우기보다 무엇을 알고 싶을 때 어디를 여는지로 기억하는 편이 낫습니다.

이 프로세스가 무엇인지 알고 싶다면 /proc/PID/cmdline과 status에서 명령줄, UID, State, PPid를 봅니다(environ은 권한이 있어야 읽힙니다). 무엇을 열고 있는지는 fd/ 아래의 심볼릭 링크로 확인합니다. 자원 한도는 limits, I/O 누적량은 io, 메모리 사용은 smaps_rollup에서 봅니다. 어느 컨테이너나 cgroup에 속하는지는 cgroup 파일이 알려 줍니다. 시스템 전체로는 /proc/loadavg, /proc/meminfo, /proc/pressure/(PSI, 자원 대기 압력), /proc/sys/kernel/pid_max가 “왜 fork가 실패하는가” 같은 질문에 답해 줍니다.

장애를 볼 때는 살아 있는지, 무엇을 붙잡고 있는지, 어떤 한도에 걸렸는지 순서로 좁혀 가면 위 파일들을 자연스럽게 따라가게 됩니다.


프로세스 제어: kill, pkill, killall

kill 1234
kill -TERM 1234
kill -9 1234   # SIGKILL

kill은 이름과 달리 프로세스를 죽이는 명령이 아니라 시그널을 보내는 명령입니다. 운영 환경에서는 systemctl stop이나 컨테이너 런타임의 stop을 먼저 쓰고, pkill은 패턴이 의도치 않은 프로세스까지 잡을 수 있으므로 pgrep -af로 대상을 먼저 확인합니다.


데몬 프로세스: 백그라운드·systemd 서비스

데몬은 터미널에 묶이지 않고 오래 실행되는 프로세스입니다. nohup이나 &는 임시 방편이고, 프로덕션에서는 유닛 파일에 의존성, 재시작 정책, 로그를 설정해 서비스 매니저가 관리하게 합니다. Kubernetes에서는 컨테이너 런타임과 probe가 비슷한 역할을 합니다.


프로세스 관리 셸 스크립트

#!/usr/bin/env bash
set -euo pipefail
PATTERN='myworker'
GRACE=10
mapfile -t PIDS < <(pgrep -f -- "${PATTERN}" || true)
if ((${#PIDS[@]}==0)); then
  echo "no processes matched: $PATTERN"
  exit 0
fi
printf 'matched PIDs: %s\n' "${PIDS[*]}"
for pid in "${PIDS[@]}"; do
  if kill -0 "$pid" 2>/dev/null; then
    echo "SIGTERM $pid"
    kill -TERM "$pid" || true
  fi
done
for _ in $(seq 1 "$GRACE"); do
  still=()
  for pid in "${PIDS[@]}"; do
    if kill -0 "$pid" 2>/dev/null; then still+=("$pid"); fi
  done
  ((${#still[@]}==0)) && break
  sleep 1
done
for pid in "${PIDS[@]}"; do
  if kill -0 "$pid" 2>/dev/null; then
    echo "SIGKILL $pid (still alive)"
    kill -KILL "$pid" || true
  fi
done

SIGTERM을 먼저 보내고 유예 시간 뒤에도 남은 프로세스에만 SIGKILL을 보내는 구조입니다. 다만 pgrep -f 패턴이 넓으면 엉뚱한 프로세스까지 종료할 수 있고, 유예 시간 사이에 PID가 재사용될 가능성도 있습니다. systemd로 관리하는 서비스라면 이런 스크립트 대신 systemctl을 쓰는 편이 낫습니다.

cgroup CPU 사용량 조회(cgroup v2)

grep . /proc/self/cgroup
# cat /sys/fs/cgroup/$(grep -oP '^0::\K.+' /proc/self/cgroup)/cpu.stat

cpu.stat의 nr_throttled와 throttled_usec가 늘고 있다면 cpu.max 한도에 걸려 강제로 멈추고 있다는 뜻입니다.


성능 최적화: CPU 친화도·cgroups

taskset으로 프로세스를 특정 CPU에 고정하면 캐시 지역성은 좋아질 수 있지만, 그 CPU가 바빠지면 다른 코어가 놀고 있어도 기다려야 합니다. 측정 없이 고정부터 하지는 마세요. cgroup v2의 cpu.weight는 경쟁할 때의 상대적 몫을, cpu.max는 주기당 쓸 수 있는 절대 한도를 정합니다. 상위 cgroup의 한도는 하위가 넘을 수 없으므로, 파드 한도가 넉넉해 보여도 상위 슬라이스에서 막힐 수 있습니다. CPU 격리(isolcpus)나 nohz_full 같은 설정은 최후의 수단이고, 그 전에 런큐 대기, throttle, steal time부터 확인합니다.


증상별 점검 순서

증상-원인-조치를 표로 정리해 두면 깔끔해 보이지만, 실제 장애는 여러 원인이 겹쳐 나타납니다. 그래서 증상에서 출발해 무엇을 먼저 볼지를 순서로 적어 둡니다.

  • 로드는 낮은데 응답만 느리다 — cgroup cpu.max와 cpu.stat의 throttle 수치, eBPF runqlat의 런큐 대기 분포, steal time을 한 화면에 놓고 봅니다. top의 CPU 사용률만 보면 원인을 놓치기 쉽습니다.

  • 특정 프로세스만 멈춘 것 같다 — ps로 State를 보고 D라면 디스크, NFS, 블록 I/O 쪽으로 방향을 돌립니다. cat /proc/PID/stack(root 필요)으로 어디서 기다리는지 볼 수 있습니다. kill -9가 안 먹는다는 말이 나오면 거의 이 경우입니다.

  • 스레드 수가 폭증하는데 CPU는 여유 있다 — pidstat -w로 초당 문맥 전환 수를, perf로 락 경합을 숫자로 확인합니다.

  • 노드는 멀쩡한데 파드만 느리다 — 파드 cgroup의 cpu.stat과 request/limit 설정, QoS 클래스를 확인합니다.

  • Z 상태가 쌓인다 — 누가 wait해야 하는지, PID 1이 자식을 거두는지, SIGCHLD 핸들러가 루프를 도는지 확인합니다(맨 앞 좀비 절).


자주 묻는 질문

  • Q. ps의 D는 디스크 대기만 의미하나요?

    A. 아닙니다. D는 시그널로 깨울 수 없는 대기(uninterruptible sleep)일 뿐이고, 디스크 I/O 외에도 NFS 응답, 일부 드라이버, 커널 락 대기 등에서 나타납니다. 무엇을 기다리는지는 /proc/PID/stack이나 wchan 열로 확인합니다.

  • Q. 서비스는 왜 kill 대신 systemctl로 종료하나요?

    A. systemd는 서비스의 cgroup에 속한 모든 프로세스를 추적하므로, 자식까지 빠짐없이 정해진 순서와 유예 시간으로 종료하고 로그와 의존성도 함께 처리하기 때문입니다.

  • Q. nice를 크게 올리면 I/O가 빨라지나요?

    A. 아닙니다. nice는 CPU 몫에만 영향을 줍니다. I/O 우선순위는 ionice나 cgroup I/O 설정으로 따로 조절해야 합니다.


같이 보면 좋은 글