프로세스(Process) 관리 (Process Management)
Linux 커널의 프로세스 관리 전반을 task_struct 생명주기 기준으로 다룹니다. fork/clone/execve 경로와 Copy-on-Write, PID 및 네임스페이스(Namespace), cgroup 제어와 스케줄링 클래스(Scheduling Class) 상호작용, 컨텍스트 스위칭(Context Switching)/시그널(Signal)/종료 처리, CPU affinity와 로드 밸런싱, 실전 트레이싱 포인트까지 심층적으로 정리합니다.
핵심 요약
- task_struct — 커널이 프로세스/스레드(Thread)를 관리하는 핵심 자료구조. 상태, PID, 메모리, 파일 정보를 모두 담습니다.
- fork() — 현재 프로세스를 복제하여 자식 프로세스를 생성합니다. Copy-on-Write로 효율적입니다.
- exec() — 프로세스의 메모리 이미지를 새 프로그램(ELF)으로 교체합니다.
- 컨텍스트 스위치 — CPU 레지스터(Register)를 저장/복원하여 다른 프로세스로 전환하는 과정입니다.
- 커널 스레드(Kernel Thread) — 사용자 공간(User Space) 없이 커널 내에서만 동작하는 스레드입니다 (kworker, kswapd 등).
단계별 이해
- task_struct 파악 —
ps aux로 보이는 모든 프로세스는 커널 내부에서task_struct하나로 표현됩니다./proc/<pid>/status에서 프로세스의 상태, 메모리, 스레드 정보를 확인할 수 있습니다. - fork 이해 —
fork()는 부모의 페이지 테이블(Page Table)을 복사하되, 실제 메모리는 Write 시에만 복사합니다(CoW).이 덕분에 fork는 매우 빠르고, 자식이 즉시
exec()하면 복사가 거의 발생하지 않습니다. - exec 이해 —
execve()는 ELF 파일을 파싱하여 코드/데이터 세그먼트를 새로 매핑(Mapping)합니다.기존 메모리 매핑은 모두 해제되고 새 프로그램의 진입점(
_start)부터 실행됩니다. - 프로세스 계층 — 모든 프로세스는 PID 1(init/systemd)을 루트로 하는 트리 구조를 형성합니다.
pstree명령어로 프로세스 트리를 시각적으로 확인할 수 있습니다.
task_struct 구조체 (Task Descriptor)
Linux 커널에서 프로세스(또는 스레드)를 표현하는 핵심 자료구조는 struct task_struct입니다. 이 구조체는 include/linux/sched.h에 정의되어 있으며, 커널이 프로세스를 관리하는 데 필요한 모든 정보를 담고 있습니다. 크기가 수 킬로바이트에 달하는 대형 구조체로, 프로세스의 상태, 스케줄링 정보, 메모리 디스크립터, 파일 디스크립터(File Descriptor) 테이블, 시그널 핸들러(Handler), credentials 등을 포함합니다.
핵심 필드 분석 (Key Fields)
다음은 task_struct의 주요 필드들을 기능별로 분류한 것입니다.
| 필드 | 타입 | 설명 |
|---|---|---|
state / __state |
unsigned int |
프로세스의 현재 상태 (TASK_RUNNING, TASK_INTERRUPTIBLE 등) |
flags |
unsigned int |
프로세스 플래그 (PF_EXITING, PF_KTHREAD 등) |
prio / static_prio / normal_prio |
int |
스케줄링 우선순위 (동적/정적/정규 우선순위) |
sched_class |
const struct sched_class * |
해당 태스크(Task)가 사용하는 스케줄링 클래스 |
se |
struct sched_entity |
CFS 스케줄링 엔티티 (vruntime 등 포함) |
mm |
struct mm_struct * |
메모리 디스크립터 (유저 프로세스의 주소 공간) |
active_mm |
struct mm_struct * |
실제 사용 중인 mm (커널 스레드는 이전 태스크의 mm을 빌림) |
pid / tgid |
pid_t |
프로세스 ID / 스레드 그룹 ID |
real_parent / parent |
struct task_struct * |
실제 부모 프로세스 / ptrace 부모 |
children / sibling |
struct list_head |
자식 프로세스 리스트 / 형제 프로세스 리스트 |
files |
struct files_struct * |
열린 파일 디스크립터 테이블 |
fs |
struct fs_struct * |
파일시스템(Filesystem) 정보 (root, pwd) |
signal |
struct signal_struct * |
시그널 핸들러 정보 (스레드 그룹 공유) |
nsproxy |
struct nsproxy * |
네임스페이스 프록시 (PID/mount/net/user NS 등) |
thread_info |
struct thread_info |
아키텍처 의존적 스레드 정보 (커널 스택과 연결) |
cred |
const struct cred * |
프로세스 자격 증명 (UID, GID, capabilities) |
task_struct 할당 (Allocation)
task_struct는 slab allocator를 통해 할당됩니다. 커널 초기화 시 task_struct 전용 kmem_cache가 생성되며, 새로운 프로세스 생성 시 alloc_task_struct_node()를 통해 할당합니다. 커널 스택은 별도로 할당되며, x86_64의 경우 기본 16KB(4페이지(Page))입니다. thread_info 구조체는 최근 커널에서 task_struct 내부에 임베딩되어 있습니다.
/* task_struct 할당 - kernel/fork.c */
static struct kmem_cache *task_struct_cachep;
void __init fork_init(void)
{
task_struct_cachep = kmem_cache_create("task_struct",
arch_task_struct_size, align,
SLAB_PANIC|SLAB_ACCOUNT, NULL);
}
/* 현재 프로세스의 task_struct에 접근 */
struct task_struct *current = get_current();
printk(KERN_INFO "PID: %d, comm: %s\\n",
current->pid, current->comm);
current 매크로(Macro): current는 현재 CPU에서 실행 중인 프로세스의 task_struct 포인터를 반환하는 매크로입니다. x86_64에서는 per-CPU 변수인 current_task를 통해 접근하며, GS 세그먼트 레지스터를 활용합니다.
프로세스 상태 (Process States)
Linux 커널의 프로세스는 다음과 같은 상태를 가질 수 있으며, task_struct.__state 필드에 비트마스크로 저장됩니다.
| 상태 | 값 | 설명 |
|---|---|---|
TASK_RUNNING |
0x00000000 |
실행 중이거나 런큐(Runqueue)에서 실행 대기 중. CPU를 할당받을 수 있는 유일한 상태 |
TASK_INTERRUPTIBLE |
0x00000001 |
슬립(Sleep) 상태. 시그널 또는 대기 조건 충족 시 깨어남 |
TASK_UNINTERRUPTIBLE |
0x00000002 |
시그널로 깨울 수 없는 슬립 상태. 디스크 I/O 대기 시 주로 사용 (D state) |
__TASK_STOPPED |
0x00000004 |
SIGSTOP/SIGTSTP 등에 의해 중지된 상태 |
__TASK_TRACED |
0x00000008 |
ptrace에 의해 트레이싱 중인 상태 |
EXIT_DEAD |
0x00000010 |
최종 종료 상태. task_struct가 곧 해제됨 |
EXIT_ZOMBIE |
0x00000020 |
종료되었으나 부모가 wait()을 호출하지 않은 상태 |
TASK_KILLABLE |
TASK_WAKEKILL | TASK_UNINTERRUPTIBLE |
치명적 시그널(SIGKILL)에만 반응하는 슬립 상태 |
상태 전이 다이어그램 (State Transition Diagram)
D state 주의: TASK_UNINTERRUPTIBLE(D state)인 프로세스는 kill -9로도 종료시킬 수 없습니다. 이 상태가 장기간 지속되면 시스템 문제를 의심해야 합니다. 커널 5.14 이후에는 TASK_KILLABLE을 적극 활용하여 이 문제를 완화하고 있습니다.
커널 스택과 주소 공간 (Kernel Stack and Address Space)
모든 프로세스(Process)는 사용자 공간 스택과 별도로 커널 스택(Kernel Stack)을 갖습니다. 커널 스택은 시스템 콜, 인터럽트(Interrupt) 처리 등 커널 모드 실행 시 사용되며, 프로세스의 가상 주소 공간(Virtual Address Space) 레이아웃은 메모리 보호와 효율적 관리를 위해 엄격히 구분됩니다.
커널 스택 레이아웃 (Kernel Stack Layout)
커널 스택의 크기는 아키텍처에 따라 다르며, x86_64에서는 THREAD_SIZE가 16KB(4페이지), 32비트에서는 8KB(2페이지)입니다. 초기 Linux 커널에서는 thread_info 구조체가 스택 하단에 위치했지만, 현대 커널(4.9+)에서는 thread_info가 task_struct에 임베드(embed)되어 보안이 강화되었습니다.
CONFIG_VMAP_STACK이 활성화되면 커널 스택은 vmalloc 영역에 할당되며, 스택 양 끝에 가드 페이지(Guard Page)가 배치됩니다. 스택 오버플로(Stack Overflow) 발생 시 가드 페이지 접근으로 페이지 폴트(Page Fault)가 발생하여 즉시 탐지할 수 있습니다.
/* arch/x86/kernel/traps.c - 스택 오버플로 탐지 */
static void handle_stack_overflow(const char *message,
struct pt_regs *regs,
unsigned long fault_address)
{
pr_emerg("BUG: stack-protector: %s", message);
die("stack-protector", regs, 0);
}
/* CONFIG_VMAP_STACK: vmalloc 기반 스택 할당 */
/* 스택 양 끝에 guard page → 오버플로 시 page fault → 즉시 탐지 */
VMAP_STACK 이전: CONFIG_VMAP_STACK 이전에는 물리적으로 연속된 페이지에 스택을 할당했기 때문에, 오버플로 시 인접 메모리를 조용히 덮어쓰는 심각한 보안 취약점이 발생할 수 있었습니다. 현대 커널에서는 VMAP_STACK이 기본으로 활성화됩니다.
프로세스 주소 공간 레이아웃 (Process Address Space Layout)
x86_64에서 프로세스는 48비트(또는 5-level 페이징 시 57비트) 가상 주소 공간을 사용합니다. 사용자 공간과 커널 공간은 정규 주소 구멍(Canonical Hole)으로 분리되며, ASLR(Address Space Layout Randomization)을 통해 주요 영역의 시작 주소가 무작위화됩니다.
mm_struct — 프로세스 메모리 디스크립터
프로세스의 전체 가상 주소 공간은 mm_struct로 기술됩니다. 이 구조체는 페이지 테이블(Page Table) 루트, 각 영역(text, data, heap, stack)의 범위, 그리고 VMA(Virtual Memory Area) 관리 트리를 포함합니다.
/* include/linux/mm_types.h - mm_struct 핵심 필드 */
struct mm_struct {
struct maple_tree mm_mt; /* VMA 관리 (6.1+, 기존 rb_tree 대체) */
unsigned long mmap_base; /* mmap 시작 주소 */
unsigned long task_size; /* 사용자 주소 공간 크기 */
pgd_t *pgd; /* 페이지 글로벌 디렉토리 */
atomic_t mm_users; /* 사용자 수 (스레드) */
atomic_t mm_count; /* 참조 수 (active_mm 포함) */
unsigned long start_code, end_code; /* .text 영역 */
unsigned long start_data, end_data; /* .data 영역 */
unsigned long start_brk, brk; /* heap 영역 */
unsigned long start_stack; /* 스택 시작 */
unsigned long arg_start, arg_end; /* argv 영역 */
unsigned long env_start, env_end; /* envp 영역 */
};
VMA — 가상 메모리 영역 (Virtual Memory Area)
각 연속된 가상 주소 범위는 vm_area_struct(VMA)로 표현됩니다. 텍스트 세그먼트, 데이터 세그먼트, 힙, 스택, mmap된 파일, 공유 라이브러리 등이 각각 별도의 VMA로 관리됩니다. 커널 6.1부터는 VMA 검색에 메이플 트리(Maple Tree)를 사용하여 성능이 향상되었습니다.
/* include/linux/mm_types.h - vm_area_struct 핵심 필드 */
struct vm_area_struct {
unsigned long vm_start; /* VMA 시작 가상 주소 */
unsigned long vm_end; /* VMA 끝 가상 주소 (exclusive) */
pgoff_t vm_pgoff; /* 파일 매핑 시 오프셋 */
struct file *vm_file; /* 매핑된 파일 (NULL이면 익명) */
vm_flags_t vm_flags; /* VM_READ, VM_WRITE, VM_EXEC 등 */
const struct vm_operations_struct *vm_ops;
};
mm_users vs mm_count: mm_users는 사용자 공간을 공유하는 스레드 수입니다. mm_count는 mm_struct 자체의 참조 수로, mm_users(양수면 1로 카운트)와 active_mm을 사용하는 lazy TLB 커널 스레드를 합산합니다. mm_users가 0이 되면 주소 공간(페이지 테이블, VMA)이 해제되고, mm_count가 0이 되면 mm_struct 자체가 해제됩니다.
프로세스 생성 (Process Creation)
Linux에서 새로운 프로세스는 항상 기존 프로세스를 복제하여 생성됩니다. 최초의 프로세스(PID 0, swapper/idle)만 커널 부팅 시 정적으로 생성되고, 이후 PID 1(init/systemd) 등은 모두 fork 계열 시스템 콜(System Call)을 통해 생성됩니다.
fork() 시스템 콜
fork()는 호출한 프로세스의 거의 완전한 복사본을 생성합니다. 커널 내부에서는 kernel_clone() 함수(과거 do_fork())를 통해 처리됩니다. 주요 처리 과정은 다음과 같습니다:
- task_struct 할당:
dup_task_struct()로 새로운task_struct와 커널 스택을 할당합니다. - 리소스 복제:
copy_files(),copy_fs(),copy_sighand(),copy_signal(),copy_mm(),copy_namespaces()등을 호출하여 각 리소스를 복제합니다. - 스케줄러 초기화:
sched_fork()로 새 태스크의 스케줄링 정보를 초기화합니다. - PID 할당:
alloc_pid()로 새 PID를 할당합니다. - 런큐 추가:
wake_up_new_task()로 새 프로세스를 런큐에 넣습니다.
/* kernel/fork.c - kernel_clone() 핵심 흐름 (간략화) */
pid_t kernel_clone(struct kernel_clone_args *args)
{
struct task_struct *p;
pid_t nr;
/* 1. task_struct + 커널 스택 복제 */
p = copy_process(NULL, 0, args);
if (IS_ERR(p))
return PTR_ERR(p);
/* 2. PID 가져오기 */
nr = pid_vnr(get_task_pid(p, PIDTYPE_PID));
/* 3. 새 프로세스를 런큐에 넣고 실행 가능하게 만듦 */
wake_up_new_task(p);
return nr;
}
vfork() / clone() / clone3()
Linux는 프로세스 생성을 위해 여러 시스템 콜을 제공하며, 내부적으로는 모두 kernel_clone()을 호출합니다.
| 시스템 콜 | 특징 | 주요 용도 |
|---|---|---|
fork() |
부모 프로세스의 완전한 복제. COW 사용 | 일반적인 프로세스 생성 |
vfork() |
부모가 자식이 exec/exit 할 때까지 블록됨. 주소 공간 공유 | exec 직전 사용 (성능 최적화, 현재는 거의 불필요) |
clone() |
플래그로 공유 리소스를 세밀하게 제어 | 스레드 생성 (pthread_create 내부) |
clone3() |
확장 가능한 구조체 기반 인터페이스. 커널 5.3+ | 새로운 기능 (cgroup, PID fd 등) 지원 |
/* clone() 호출 시 주요 플래그 */
#define CLONE_VM 0x00000100 /* 주소 공간 공유 */
#define CLONE_FS 0x00000200 /* 파일시스템 정보 공유 */
#define CLONE_FILES 0x00000400 /* 파일 디스크립터 테이블 공유 */
#define CLONE_SIGHAND 0x00000800 /* 시그널 핸들러 공유 */
#define CLONE_THREAD 0x00010000 /* 같은 스레드 그룹 */
#define CLONE_NEWNS 0x00020000 /* 새 마운트 네임스페이스 */
#define CLONE_NEWPID 0x20000000 /* 새 PID 네임스페이스 */
/* 스레드 생성 시 사용되는 플래그 조합 (glibc pthread_create) */
const int thread_flags = CLONE_VM | CLONE_FS | CLONE_FILES |
CLONE_SIGHAND | CLONE_THREAD |
CLONE_SYSVSEM | CLONE_SETTLS |
CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID;
Copy-on-Write (COW) 메커니즘
Copy-on-Write는 fork()의 효율성을 크게 높이는 핵심 메커니즘입니다. fork() 시점에 부모와 자식은 같은 물리 페이지를 공유하되, 페이지 테이블 엔트리를 읽기 전용(Read-Only)으로 표시합니다. 이후 둘 중 하나가 해당 페이지에 쓰기를 시도하면 페이지 폴트(page fault)가 발생하고, 커널이 해당 페이지를 복사한 뒤 쓰기를 허용합니다.
COW의 핵심 처리 흐름:
fork()시copy_mm()->dup_mmap()에서 부모의 페이지 테이블을 복제합니다.copy_present_pte()에서 쓰기 가능 페이지를 읽기 전용으로 변경하고, 참조 카운트(Reference Count)를 증가시킵니다.- 쓰기 시 페이지 폴트 ->
do_wp_page()에서 COW 처리를 수행합니다. - 참조 카운트가 1이면 복사 없이 쓰기 권한만 복원하고, 2 이상이면 새 페이지를 할당하여 복사합니다.
COW의 장점: 대부분의 fork() 호출은 직후에 exec()를 수행하여 주소 공간을 완전히 교체합니다. COW 덕분에 이런 fork-exec 패턴에서 불필요한 메모리 복사가 발생하지 않아 성능이 크게 향상됩니다.
Android 프로세스 모델
Android는 fork()의 COW 메커니즘을 최대한 활용하는 Zygote 패턴을 사용합니다. Zygote 프로세스가 ART(Android Runtime)와 공통 프레임워크 클래스를 미리 로드한 뒤, 앱 시작 요청 시 fork()로 빠르게 자식 프로세스를 생성합니다.
/* Zygote fork 흐름 (커널 관점) */
/* Zygote: ART + 프레임워크 클래스 미리 로드된 상태 */
pid = fork(); /* copy_process() → COW 페이지 테이블 복사 */
if (pid == 0) {
/* 자식: 앱 프로세스 */
setuid(app_uid); /* 앱별 고유 UID (앱 샌드박스) */
prctl(PR_SET_SECCOMP); /* seccomp 필터 */
selinux_android_setcontext(...); /* SELinux 컨텍스트 전환 */
/* exec() 없이 ART에서 바로 앱 코드 실행 → COW 페이지 공유 유지 */
}
일반적인 fork-exec 패턴과 달리 Zygote는 exec()를 호출하지 않으므로, 부모와 공유하는 COW 페이지(ART, 프레임워크 코드)가 장기간 유지되어 메모리 절약 효과가 큽니다. Android 10+에서는 USAP(Unspecialized App Process) 풀을 미리 fork하여 cold start 시간을 더욱 단축합니다. 자세한 내용은 Android 커널 — Zygote를 참고하십시오.
fork() + exec() 흐름 시각화
exec() 계열과 ELF 로딩 (exec and ELF Loading)
exec() 계열 시스템 콜은 현재 프로세스의 주소 공간을 새로운 프로그램으로 대체합니다. 커널 내부에서는 do_execveat_common()을 통해 처리됩니다. Linux에서 사용자 공간 프로그램의 표준 실행 파일 형식은 ELF(Executable and Linkable Format)이며, 커널은 fs/binfmt_elf.c에서 이를 처리합니다.
exec() 내부 처리 흐름
execve() 시스템 콜이 호출되면 커널은 다음 단계를 수행합니다:
- 파일 열기 및 권한 검사:
do_open_execat()으로 실행 파일을 열고, 실행 권한을 확인합니다. - binfmt 핸들러 탐색:
search_binary_handler()에서 등록된 바이너리 형식 핸들러를 순회하며, 파일 매직 넘버를 확인하여 적절한 핸들러를 선택합니다. - 기존 주소 공간 해제:
exec_mmap()에서 기존mm_struct를 새 것으로 교체합니다. 이 시점에서 기존 메모리 매핑이 모두 해제됩니다. - ELF 세그먼트 매핑:
load_elf_binary()에서 PT_LOAD 세그먼트를 새 주소 공간에 매핑합니다. - 동적 링커(Linker) 로드: 동적 링크 실행 파일이면
PT_INTERP에 지정된 동적 링커(ld-linux.so)를 함께 로드합니다. - 스택/auxv 설정:
create_elf_tables()에서 argv, envp, auxiliary vector를 사용자 스택에 배치합니다. - 진입점 설정: 레지스터를 초기화하고, ELF 진입점(또는 동적 링커의 진입점)으로 점프합니다.
/* fs/exec.c - do_execveat_common() 핵심 흐름 (간략화) */
static int do_execveat_common(int fd, struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp, int flags)
{
struct linux_binprm *bprm;
int retval;
bprm = alloc_bprm(fd, filename, flags);
/* 1. 실행 파일 열기 */
retval = bprm_execve(bprm);
/* bprm_execve 내부: */
/* → do_open_execat() : 파일 열기 + 권한 검사 */
/* → prepare_binprm() : 매직 넘버 읽기, credentials 계산 */
/* → exec_binprm() : search_binary_handler() 호출 */
/* → load_elf_binary() : ELF 파싱 + 주소 공간 구축 */
/* → exec_mmap() : 기존 mm 해제, 새 mm 할당 */
/* → elf_map() : PT_LOAD 세그먼트 매핑 */
/* → create_elf_tables() : auxv/argv/envp 스택 배치 */
/* → START_THREAD() : 진입점으로 IP 설정 */
return retval;
}
/* ELF auxiliary vector 주요 항목 */
/* AT_PHDR — ELF 프로그램 헤더 테이블 주소 */
/* AT_ENTRY — ELF 진입점 주소 */
/* AT_BASE — 동적 링커 로드 주소 */
/* AT_RANDOM — 16바이트 난수 (stack canary 등에 사용) */
/* AT_EXECFN — 실행 파일명 */
ELF 상세 문서: ELF 포맷의 전체 구조(헤더, 세그먼트, 섹션, 심볼 테이블(Symbol Table), 재배치(Relocation), 동적 링킹(Dynamic Linking), GOT/PLT, TLS, 코어 덤프(Core Dump), 커널 모듈(Kernel Module) ELF, binfmt 핸들러, 심볼 버저닝, .eh_frame/DWARF, 링커 스크립트, 보안 강화)에 대한 심층 분석은 ELF (Executable and Linkable Format) 페이지를 참고하십시오.
exec() 보안 처리
exec() 실행 시 커널은 여러 보안 관련 처리를 수행합니다:
- SUID/SGID 처리: 실행 파일에 setuid/setgid 비트가 설정되어 있으면,
prepare_binprm()에서 프로세스의 effective UID/GID를 파일 소유자의 것으로 변경합니다. - Capabilities 계산:
cap_bprm_creds_from_file()에서 파일의 security xattr과 SUID 비트를 기반으로 새로운 capability 세트를 계산합니다. - LSM 검사:
security_bprm_check()을 통해 SELinux, AppArmor 등 LSM 모듈이 실행을 허용하는지 확인합니다. - close-on-exec:
O_CLOEXEC플래그가 설정된 파일 디스크립터를 모두 닫습니다. - 시그널 핸들러 초기화: 사용자 정의 시그널 핸들러가 모두
SIG_DFL로 초기화됩니다 (핸들러 코드가 있던 주소 공간이 사라지므로).
SUID와 ptrace: SUID 실행 파일을 exec()할 때 해당 프로세스가 ptrace 중이면, 보안상 SUID 비트가 무시됩니다. 이는 디버거가 권한 상승된 프로세스를 제어하는 것을 방지합니다. /proc/sys/kernel/yama/ptrace_scope로 ptrace 접근 범위를 추가로 제한할 수 있습니다.
스케줄링 개요 (Scheduling Overview)
Linux 커널 스케줄러는 schedule() → __schedule() → pick_next_task() 경로를 통해 다음 실행할 태스크를 선택합니다. 스케줄러는 sched_class 계층 구조(stop → deadline → rt → fair → idle)를 순회하며, 각 클래스의 정책에 따라 우선순위가 결정됩니다.
일반 프로세스는 EEVDF(Earliest Eligible Virtual Deadline First) 알고리즘(v6.6+, 이전 CFS)으로 공정하게 CPU 시간을 분배받고, 실시간(Real-time) 태스크는 SCHED_FIFO/SCHED_RR/SCHED_DEADLINE 정책으로 우선 처리됩니다. 커널의 선점(Preemption) 모델(NONE/VOLUNTARY/FULL/RT/LAZY)은 응답성과 처리량(Throughput)의 트레이드오프를 결정하며, 로드 밸런싱은 sched_domain 계층을 통해 CPU 간 부하를 균등하게 분산합니다.
스케줄링 상세 문서: 프로세스 스케줄링의 각 주제는 전용 페이지에서 심층적으로 다룹니다.
- 프로세스 스케줄러 — sched_class 계층, 런큐, sched_ext, 디버깅(Debugging), 커널 설정
- CFS/EEVDF 스케줄러 상세 — vruntime 수식, PELT 부하 추적, 로드 밸런싱 심층, cgroup 대역폭(Bandwidth)
- preempt_count & 선점 모델 — 선점 카운터, PREEMPT_DYNAMIC, 선점 모델 비교
- Real-Time Linux & PREEMPT_RT — RT 스케줄링 정책, sleeping spinlock, PI chain
- cpusets & CPU Isolation — CPU Affinity, isolcpus, nohz_full, HPC/RT 격리(Isolation)
- Context Switching — __switch_to(), 레지스터/TLB/FPU 전환 비용
컨텍스트 스위칭 (Context Switch)
컨텍스트 스위칭은 현재 실행 중인 태스크를 다른 태스크로 전환하는 과정입니다. context_switch() 함수에서 수행되며, 크게 두 단계로 나뉩니다.
- 주소 공간 전환:
switch_mm_irqs_off()에서 페이지 테이블(CR3 레지스터)을 새 태스크의 것으로 교체합니다. 커널 스레드 간 전환이면 이 단계를 건너뜁니다. - 레지스터/스택 전환:
switch_to()에서 커널 스택 포인터, 프로그램 카운터, 범용 레지스터 등을 전환합니다. 이 작업은 아키텍처 의존적인 어셈블리(Assembly) 코드로 구현됩니다.
컨텍스트 스위칭 과정 다이어그램
/* kernel/sched/core.c - context_switch() 핵심 (간략화) */
static void context_switch(struct rq *rq,
struct task_struct *prev, struct task_struct *next)
{
/* 1. 주소 공간 전환 */
if (!next->mm) {
/* 커널 스레드: 이전 태스크의 mm을 빌림 (lazy TLB) */
next->active_mm = prev->active_mm;
} else {
/* 유저 프로세스: 페이지 테이블 전환 */
switch_mm_irqs_off(prev->active_mm, next->mm, next);
}
/* 2. 레지스터 + 스택 전환 (아키텍처 의존) */
switch_to(prev, next, prev);
/* 이 시점부터 next 태스크의 컨텍스트에서 실행 */
finish_task_switch(prev);
}
Lazy TLB: 커널 스레드는 자체 유저 주소 공간이 없으므로(mm == NULL), 이전 태스크의 mm을 빌려 사용합니다. 이를 통해 불필요한 TLB flush를 방지하여 성능을 향상시킵니다.
스레드 (Threads)
Linux에서 스레드는 프로세스와 본질적으로 같은 객체입니다. 둘 다 task_struct로 표현되며, 스레드는 같은 스레드 그룹에 속한 task_struct들이 주소 공간, 파일 디스크립터, 시그널 핸들러 등을 공유하는 것일 뿐입니다.
커널 스레드 (Kernel Threads)
커널 스레드는 유저 공간 주소 공간 없이(mm == NULL) 커널 공간(Kernel Space)에서만 실행되는 태스크입니다. kthreadd(PID 2)가 모든 커널 스레드의 부모이며, kthread_create()/kthread_run() API로 생성합니다. 대표적으로 ksoftirqd, kworker, migration, kswapd 등이 있습니다.
커널 스레드의 생성/종료 API, per-CPU 스레드, kthread_worker, smpboot 프레임워크, 주요 커널 스레드 종합 분석은 Kernel Threads (커널 스레드) 전용 페이지를 참고하세요.
PID / TID 관계 (PID and TID Relationship)
Linux에서 PID와 TID의 관계는 다음과 같습니다:
- TID (Thread ID):
task_struct.pid필드. 각 태스크(스레드)마다 고유합니다.gettid()시스템 콜로 확인 가능합니다. - PID (Process ID):
task_struct.tgid(Thread Group ID) 필드. 같은 프로세스의 모든 스레드는 동일한 tgid를 가집니다.getpid()는 tgid를 반환합니다. - 메인 스레드의 경우
pid == tgid이고, 추가 스레드는pid != tgid입니다.
| 필드 | 메인 스레드 | 추가 스레드 1 | 추가 스레드 2 |
|---|---|---|---|
pid (= TID) |
1000 | 1001 | 1002 |
tgid (= PID) |
1000 | 1000 | 1000 |
getpid() |
1000 | 1000 | 1000 |
gettid() |
1000 | 1001 | 1002 |
PID 네임스페이스: PID는 네임스페이스에 따라 다른 값을 가질 수 있습니다. 컨테이너(Container) 안에서 PID 1로 보이는 프로세스가 호스트에서는 전혀 다른 PID를 가집니다. task_struct 내부의 struct pid는 각 네임스페이스 수준별 PID 번호를 보관합니다.
스레드와 프로세스의 리소스 공유 (Resource Sharing between Threads and Processes)
Linux에서 스레드(Thread)는 본질적으로 프로세스와 동일한 task_struct로 표현되며, clone() 시스템 콜에 전달하는 CLONE_* 플래그에 따라 어떤 리소스를 공유할지 결정됩니다. fork()는 대부분의 리소스를 복사하고, pthread_create()는 내부적으로 clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD, ...)를 호출하여 주소 공간, 파일 디스크립터, 시그널 핸들러 등을 공유합니다.
| 리소스 | fork() (새 프로세스) | clone(CLONE_THREAD) (새 스레드) | CLONE 플래그 |
|---|---|---|---|
| 주소 공간 | 복사 (COW) | 공유 | CLONE_VM |
| 파일 디스크립터 | 복사 | 공유 | CLONE_FILES |
| 시그널 핸들러 | 복사 | 공유 | CLONE_SIGHAND |
| 파일시스템 정보 | 복사 | 공유 | CLONE_FS |
| PID | 새로 할당 | 새로 할당 (TID) | — |
| TGID | 새로 할당 | 부모와 동일 | CLONE_THREAD |
| 커널 스택 | 새로 할당 | 새로 할당 | — |
| Credentials | 복사 | 복사 | — |
| 네임스페이스 | 상속 | 공유 | CLONE_NEW* |
TGID와 getpid(): 사용자 공간에서 getpid()가 반환하는 값은 실제로 tgid(Thread Group ID)입니다. 같은 프로세스의 모든 스레드는 동일한 tgid를 공유합니다. 개별 스레드의 고유 ID(TID)를 얻으려면 gettid()를 사용해야 합니다. 커널 내부에서 task_struct->pid는 TID이고, task_struct->tgid가 사용자 공간의 PID에 해당합니다.
프로세스 그룹과 세션 (Process Groups and Sessions)
리눅스에서 프로세스들은 프로세스 그룹(Process Group)과 세션(Session)이라는 계층적 구조로 조직됩니다. 이 구조는 터미널 기반의 잡 컨트롤(Job Control)과 시그널 전달의 핵심 메커니즘입니다.
프로세스 그룹 (Process Group)
프로세스 그룹은 관련된 프로세스들의 집합입니다. 셸에서 파이프라인으로 연결된 명령들(ls | grep foo | sort)은 하나의 프로세스 그룹을 형성합니다. 프로세스 그룹은 PGID(Process Group ID)로 식별되며, 그룹의 첫 번째 프로세스(그룹 리더)의 PID가 PGID가 됩니다.
세션 (Session)
세션은 하나 이상의 프로세스 그룹의 집합으로, 제어 터미널(Controlling Terminal)과 연결됩니다. 사용자가 터미널에 로그인하면 셸이 새 세션을 생성하고, 이 셸이 세션 리더(Session Leader)가 됩니다. 세션에는 하나의 포그라운드 프로세스 그룹(Foreground Process Group)과 여러 백그라운드 프로세스 그룹(Background Process Group)이 존재할 수 있습니다.
포그라운드 프로세스 그룹만이 제어 터미널에서 입력을 읽을 수 있으며, 백그라운드 프로세스 그룹이 터미널 입력을 시도하면 SIGTTIN 시그널을 받아 중지됩니다.
관련 시스템 콜
| 시스템 콜 | 설명 |
|---|---|
setsid() |
새 세션 생성 (SID = PGID = 호출자 PID). 호출자가 프로세스 그룹 리더이면 실패 |
setpgid(pid, pgid) |
프로세스 그룹 변경. 같은 세션 내의 프로세스만 대상 가능 |
getpgid(pid) |
프로세스 그룹 ID 조회 |
getsid(pid) |
세션 ID 조회 |
tcsetpgrp(fd, pgid) |
터미널 fd의 포그라운드 프로세스 그룹 설정 |
tcgetpgrp(fd) |
터미널 fd의 포그라운드 프로세스 그룹 조회 |
커널 내부 구현
/* kernel/sys.c - setsid() 핵심 구현 (간략화) */
pid_t setsid(void)
{
struct task_struct *group_leader = current->group_leader;
struct pid *sid;
/* 현재 프로세스가 프로세스 그룹 리더이면 실패 (-EPERM) */
if (group_leader->pid == group_leader->tgid &&
!list_empty(&group_leader->pids[PIDTYPE_PGID].pid_list))
return -EPERM;
/* 새 세션 생성: SID = PGID = PID */
sid = task_pid(group_leader);
group_leader->signal->leader = 1;
set_special_pids(sid); /* PGID와 SID 모두 설정 */
/* 제어 터미널 분리 */
group_leader->signal->tty = NULL;
return pid_vnr(sid); /* 네임스페이스 상대 PID 반환 */
}
/* kernel/sys.c - setpgid() 핵심 (간략화) */
long do_setpgid(pid_t pid, pid_t pgid)
{
/* 같은 세션 내의 프로세스만 대상 가능 */
/* exec()를 이미 호출한 자식에 대해서는 실패 */
/* 세션 리더의 PGID는 변경 불가 */
/* pgid == 0이면 대상 PID를 PGID로 사용 */
change_pid(p, PIDTYPE_PGID, find_vpid(pgid));
return 0;
}
데몬(Daemon) 프로세스 생성 패턴: 서비스 프로세스를 터미널에서 완전히 분리하려면 "이중 fork" 패턴을 사용합니다.
/* 데몬 프로세스 생성 — 이중 fork 패턴 */
pid_t pid = fork();
if (pid > 0) exit(0); /* 1단계: 부모 종료 → 고아가 됨 */
setsid(); /* 2단계: 새 세션 생성 (터미널 분리) */
pid = fork(); /* 3단계: 다시 fork */
if (pid > 0) exit(0); /* 세션 리더 종료 → 터미널 재획득 방지 */
chdir("/"); /* 작업 디렉터리를 루트로 */
umask(0); /* 파일 생성 마스크 초기화 */
/* stdin/stdout/stderr → /dev/null 리다이렉트 */
int fd = open("/dev/null", O_RDWR);
dup2(fd, STDIN_FILENO);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
if (fd > 2) close(fd);
현대 시스템에서는 systemd가 데몬 관리를 담당하므로, 직접 이 패턴을 구현할 필요는 줄었지만, 커널 관점에서 세션과 프로세스 그룹의 동작 원리를 이해하는 데 중요합니다.
시그널 전달 메커니즘 (Signal Delivery Mechanism)
시그널(Signal)은 프로세스 간 또는 커널과 프로세스 간의 비동기 알림 메커니즘입니다. 리눅스 커널에서 시그널의 생성부터 핸들러 실행, 컨텍스트 복원까지의 전체 전달 경로를 살펴봅니다.
시그널 생성 (Signal Generation)
시그널은 다양한 경로로 생성됩니다:
- 사용자 공간:
kill(),tkill(),tgkill(),raise()시스템 콜 - 커널 내부: 하드웨어 예외(SIGSEGV, SIGFPE), 타이머 만료(SIGALRM), 파이프 닫힘(SIGPIPE)
- 터미널 드라이버: Ctrl+C(SIGINT), Ctrl+\(SIGQUIT), Ctrl+Z(SIGTSTP)
시그널 대기열 (Signal Pending)
시그널은 두 가지 대기열에 저장됩니다:
- 스레드별 대기열 (
task_struct->pending):tkill()이나tgkill()로 특정 스레드에 보낸 시그널 - 프로세스(공유) 대기열 (
signal_struct->shared_pending):kill()로 프로세스에 보낸 시그널. 아무 스레드나 처리 가능
표준 시그널(1~31)은 비트마스크로 관리되므로 동일 시그널이 중복 대기되지 않습니다. 반면 실시간 시그널(RT Signal, SIGRTMIN~SIGRTMAX)은 큐에 개별 항목으로 저장되어 여러 개가 누적되고, 부가 데이터(siginfo_t)도 전달할 수 있습니다.
시그널 전달 흐름
send_signal() 핵심 구현
/* kernel/signal.c - send_signal_locked() 핵심 (간략화) */
static int send_signal_locked(int sig, struct kernel_siginfo *info,
struct task_struct *t,
enum pid_type type)
{
struct sigpending *pending;
struct sigqueue *q;
/* 스레드별 vs 프로세스(공유) 시그널 대기열 선택 */
pending = (type != PIDTYPE_PID) ?
&t->signal->shared_pending : &t->pending;
/* 표준 시그널: 이미 대기 중이면 무시 (중복 방지) */
if (sig < SIGRTMIN && sigismember(&pending->signal, sig))
goto out;
/* sigset 비트마스크에 시그널 비트 설정 */
sigaddset(&pending->signal, sig);
/* RT 시그널이거나 siginfo가 있는 경우: sigqueue 할당하여 큐잉 */
if (sig >= SIGRTMIN || info != SEND_SIG_NOINFO) {
q = __sigqueue_alloc(sig, t, GFP_ATOMIC, override_rlimit);
if (q) {
list_add_tail(&q->list, &pending->list);
q->info = *info; /* siginfo 복사 */
}
}
out:
/* TIF_SIGPENDING 설정 및 대상 프로세스 깨우기 */
signal_wake_up(t, sig == SIGKILL);
return 0;
}
시그널 프레임과 sigreturn
커널이 사용자 시그널 핸들러를 호출할 때, 현재 실행 컨텍스트(레지스터, 플래그, 명령 포인터 등)를 사용자 스택에 저장합니다. 이 영역을 시그널 프레임(Signal Frame)이라 합니다.
/* arch/x86/kernel/signal.c - 시그널 프레임 구성 (x86_64, 간략화) */
static int setup_rt_frame(struct ksignal *ksig,
struct pt_regs *regs)
{
struct rt_sigframe __user *frame;
/* 사용자 스택에 시그널 프레임 공간 할당 */
frame = get_sigframe(ksig, regs, sizeof(*frame));
/* ucontext 저장 (레지스터, 시그널 마스크 등) */
copy_to_user(&frame->uc.uc_mcontext, regs, ...);
/* siginfo 복사 */
copy_siginfo_to_user(&frame->info, &ksig->info);
/* 반환 주소를 vDSO의 __restore_rt(sigreturn 호출)로 설정 */
put_user(vdso->sigreturn_addr, &frame->pretcode);
/* 레지스터 조작: 핸들러가 실행되도록 설정 */
regs->ip = (unsigned long)ksig->ka.sa.sa_handler;
regs->sp = (unsigned long)frame;
regs->di = ksig->sig; /* 첫 번째 인자: 시그널 번호 */
regs->si = &frame->info; /* 두 번째 인자: siginfo_t* */
regs->dx = &frame->uc; /* 세 번째 인자: ucontext_t* */
return 0;
}
/* 핸들러 실행 후: vDSO의 __restore_rt가 sigreturn 시스템 콜 호출 */
/* → 커널이 시그널 프레임에서 원래 컨텍스트를 복원하고 */
/* → 시그널 발생 직전 지점부터 실행 재개 */
주요 시그널 목록
| 시그널 | 번호 | 기본 동작 | 용도 |
|---|---|---|---|
SIGKILL |
9 | 종료 (차단 불가) | 강제 종료. 핸들러 등록/차단/무시 모두 불가 |
SIGSTOP |
19 | 중지 (차단 불가) | 프로세스 일시 중지. SIGCONT로 재개 |
SIGTERM |
15 | 종료 | 정상 종료 요청. 핸들러에서 정리 작업 가능 |
SIGCHLD |
17 | 무시 | 자식 프로세스 상태 변경 (종료, 중지, 재개) |
SIGSEGV |
11 | 코어 덤프 | 잘못된 메모리 접근 (세그멘테이션 폴트) |
SIGPIPE |
13 | 종료 | 닫힌 파이프나 소켓에 쓰기 시도 |
SIGALRM |
14 | 종료 | alarm() 타이머 만료 |
SIGURG |
23 | 무시 | 소켓의 긴급(Out-of-Band) 데이터 도착 |
SIGCONT |
18 | 계속 | SIGSTOP으로 중지된 프로세스 재개 |
SIGHUP |
1 | 종료 | 제어 터미널 종료 또는 세션 리더 사망 |
SIGINT |
2 | 종료 | 터미널 인터럽트 (Ctrl+C) |
SIGKILL vs SIGTERM — 올바른 종료 관행: 프로세스를 종료할 때는 항상 SIGTERM(15)을 먼저 보내야 합니다. SIGTERM은 핸들러에서 열린 파일 닫기, 임시 파일 삭제, 네트워크 연결 정리 등의 클린업(Cleanup) 작업을 수행할 기회를 줍니다. SIGKILL(9)은 이러한 기회 없이 즉시 종료시키므로, 데이터 손실이나 리소스 누수를 유발할 수 있습니다. SIGKILL은 SIGTERM에 응답하지 않는 프로세스에 대한 최후의 수단으로만 사용해야 합니다.
SIGKILL과 SIGSTOP은 커널이 특별 처리하는 시그널로, signal()이나 sigaction()으로 핸들러를 등록하거나, sigprocmask()로 차단하거나, SIG_IGN으로 무시할 수 없습니다.
프로세스 Credentials (Process Credentials)
프로세스의 권한과 신원 정보는 struct cred 구조체로 관리됩니다. 이 구조체는 참조 카운트 기반으로 관리되며, 변경 시 copy-on-write 방식을 사용합니다. task_struct는 두 개의 cred 포인터를 갖습니다: real_cred(실제 자격 증명)와 cred(유효 자격 증명).
/* include/linux/cred.h - struct cred 핵심 필드 */
struct cred {
atomic_long_t usage;
kuid_t uid; /* 실제 UID */
kgid_t gid; /* 실제 GID */
kuid_t suid; /* 저장된(saved) UID */
kgid_t sgid; /* 저장된(saved) GID */
kuid_t euid; /* 유효(effective) UID */
kgid_t egid; /* 유효(effective) GID */
kuid_t fsuid; /* 파일시스템 UID */
kgid_t fsgid; /* 파일시스템 GID */
unsigned securebits;
kernel_cap_t cap_inheritable; /* 상속 가능 capabilities */
kernel_cap_t cap_permitted; /* 허용된 capabilities */
kernel_cap_t cap_effective; /* 유효 capabilities */
kernel_cap_t cap_bset; /* capability bounding set */
kernel_cap_t cap_ambient; /* ambient capabilities */
struct user_namespace *user_ns; /* 소속 user namespace */
struct group_info *group_info; /* 보조 그룹 목록 */
...
};
Credentials 변경 패턴
커널 내부에서 credentials를 변경할 때는 반드시 copy-alter-commit 패턴을 따라야 합니다:
/* Credentials 변경 패턴 (copy-alter-commit) */
struct cred *new_cred;
/* 1. Copy: 현재 cred 복사 */
new_cred = prepare_creds();
if (!new_cred)
return -ENOMEM;
/* 2. Alter: 복사본 수정 */
new_cred->euid = make_kuid(new_cred->user_ns, 0);
/* 3. Commit: 원자적으로 교체 */
commit_creds(new_cred);
/* 실패 시: abort_creds(new_cred); */
| API | 설명 |
|---|---|
prepare_creds() |
현재 태스크의 cred를 복사하여 수정 가능한 새 cred 반환 |
commit_creds() |
수정된 cred를 현재 태스크에 원자적(Atomic)으로 적용 (RCU 기반) |
abort_creds() |
수정을 취소하고 복사본 해제 |
override_creds() |
임시로 다른 cred를 사용 (커널 데몬 등에서 파일 접근 시) |
revert_creds() |
override_creds() 이전의 cred로 복원 |
Capabilities vs. root: 현대 Linux에서는 root(UID 0) 대신 세분화된 capabilities 시스템을 권장합니다. 예를 들어 네트워크 패킷(Packet) 캡처에는 CAP_NET_RAW만 필요하고, 시스템 시간 변경에는 CAP_SYS_TIME만 필요합니다. 컨테이너 환경에서는 --cap-drop=ALL --cap-add=NET_BIND_SERVICE와 같이 최소 권한만 부여하는 것이 보안 모범 사례입니다.
PID 할당과 네임스페이스 (PID Allocation and Namespaces)
Linux 커널은 struct pid 구조체를 통해 프로세스 식별자를 관리합니다. PID 네임스페이스를 지원하기 위해 각 PID는 다수의 네임스페이스 수준별 번호를 가질 수 있으며, IDR(ID Radix tree)을 사용하여 할당됩니다.
PID 할당 메커니즘
alloc_pid() 함수는 새 프로세스를 위한 PID를 할당합니다. PID 번호는 네임스페이스 계층의 각 수준에서 독립적으로 할당되며, 최상위 네임스페이스(init_pid_ns)에서 항상 고유합니다.
/* kernel/pid.c - PID 할당 핵심 구조체 */
struct pid {
refcount_t count;
unsigned int level; /* 네임스페이스 깊이 */
spinlock_t lock;
struct hlist_head tasks[PIDTYPE_MAX]; /* PID/TGID/PGID/SID 타입별 */
struct hlist_head inodes;
struct upid numbers[]; /* 각 네임스페이스별 PID 번호 */
};
struct upid {
int nr; /* 해당 네임스페이스에서의 PID 번호 */
struct pid_namespace *ns; /* 소속 네임스페이스 */
};
/* 네임스페이스별 PID 번호 조회 */
pid_t pid_vnr(struct pid *pid); /* 현재 ns에서의 번호 */
pid_t pid_nr_ns(struct pid *pid,
struct pid_namespace *ns); /* 특정 ns에서의 번호 */
PID 네임스페이스 계층
PID 네임스페이스는 중첩(nested) 구조로 동작합니다. 자식 네임스페이스의 프로세스는 부모 네임스페이스에서도 보이지만, 부모의 프로세스는 자식 네임스페이스에서 보이지 않습니다.
/* PID 네임스페이스 생성 - clone3() 또는 unshare() 사용 */
#define _GNU_SOURCE
#include <sched.h>
#include <unistd.h>
/* unshare()로 새 PID namespace 생성 */
if (unshare(CLONE_NEWPID) == 0) {
pid_t child = fork();
if (child == 0) {
/* 자식: 새 PID NS에서 PID 1 */
printf("My PID: %d\n", getpid()); /* 출력: 1 */
execlp("/bin/sh", "sh", NULL);
}
}
pidfd: Linux 5.3에서 도입된 pidfd는 PID 재사용 경쟁 조건(Race Condition)을 해결하는 파일 디스크립터 기반 프로세스 참조입니다. pidfd_open(), pidfd_send_signal(), waitid(P_PIDFD, ...)를 사용하여 PID 번호 대신 fd로 프로세스를 안전하게 참조할 수 있습니다. 자세한 내용은 네임스페이스 문서를 참고하세요.
/proc 프로세스 인터페이스 (Process Information via /proc)
/proc 파일시스템은 커널이 프로세스 정보를 사용자 공간에 노출하는 주요 인터페이스입니다. 각 프로세스는 /proc/[pid]/ 디렉토리를 가지며, 다양한 가상 파일을 통해 프로세스 상태를 조회하고 일부 속성을 변경할 수 있습니다.
| 파일 | 설명 | 예시 용도 |
|---|---|---|
/proc/[pid]/status |
프로세스 상태 요약 (이름, 상태, PID/TGID, UID, 메모리 사용량, 스레드 수, capabilities) | 프로세스 기본 정보 확인 |
/proc/[pid]/stat |
프로세스 상태 (한 줄 형식). 스케줄링 통계, CPU 시간, 우선순위 등 | ps, top 명령의 데이터 소스 |
/proc/[pid]/maps |
프로세스의 메모리 매핑 (가상 주소(Virtual Address) 범위, 권한, 매핑된 파일) | 메모리 레이아웃 디버깅, 공유 라이브러리(Shared Library) 확인 |
/proc/[pid]/fd/ |
열린 파일 디스크립터 심볼릭 링크 | 파일/소켓(Socket) 누수 탐지 |
/proc/[pid]/sched |
스케줄러 통계 (vruntime, 컨텍스트 스위칭 횟수, 런큐 대기 시간(Latency)) | 스케줄링 지연(Latency) 분석 |
/proc/[pid]/stack |
커널 스택 트레이스 | D state 프로세스 디버깅 |
/proc/[pid]/task/ |
각 스레드의 개별 정보 (TID별 디렉토리) | 멀티스레드 프로세스 분석 |
/proc/[pid]/ns/ |
프로세스가 속한 네임스페이스 (PID, mount, net 등) | 컨테이너 네임스페이스 확인 |
/proc/[pid]/cgroup |
프로세스가 속한 cgroup 경로 | 리소스 제한 확인 |
/proc/[pid]/oom_score |
OOM Killer 점수 (0~1000) | OOM 희생자 예측 |
# /proc를 활용한 프로세스 디버깅 실전 예제
# 1. D state(TASK_UNINTERRUPTIBLE) 프로세스 찾기
ps aux | awk '$8 ~ /D/ {print $2, $11}'
# 2. D state 프로세스의 커널 스택 확인
cat /proc/1234/stack
# [<0>] io_schedule+0x46/0x70
# [<0>] blk_mq_get_tag+0x12a/0x2e0
# [<0>] __blk_mq_alloc_requests+0x1a6/0x2c0
# 3. 프로세스의 열린 파일 디스크립터 수 확인
ls /proc/1234/fd | wc -l
# 4. 프로세스의 메모리 사용량 상세 확인
grep -E "^(VmSize|VmRSS|VmSwap|Threads)" /proc/1234/status
# VmSize: 123456 kB ← 가상 메모리 총 크기
# VmRSS: 56789 kB ← 실제 물리 메모리 사용량
# VmSwap: 1234 kB ← 스왑 사용량
# Threads: 8 ← 스레드 수
# 5. 프로세스의 스케줄링 정보 확인
cat /proc/1234/sched | grep -E "(nr_switches|se.vruntime|wait_sum)"
실전 팁: /proc/[pid]/wchan 파일은 프로세스가 슬립 중인 커널 함수 이름을 한 단어로 보여줍니다. D state 문제를 빠르게 파악할 때 stack보다 간편합니다. ps -eo pid,stat,wchan,comm으로 전체 프로세스의 wait channel을 한 번에 확인할 수 있습니다.
프로세스 종료 (Process Termination)
프로세스 종료는 do_exit() 함수에서 처리됩니다. 종료 과정은 다음과 같습니다:
- PF_EXITING 플래그 설정: 이중 종료 방지를 위해 플래그를 설정합니다.
- 리소스 해제: 타이머(Timer), 시그널 핸들러, 파일 디스크립터, 메모리 디스크립터, 세마포어(Semaphore) 등을 해제합니다.
- exit_code 설정: 종료 코드를
task_struct.exit_code에 저장합니다. - exit_notify(): 부모에게 SIGCHLD 시그널을 보내고, 자식 프로세스를 입양(reparenting) 처리합니다.
- ZOMBIE 상태 전환:
EXIT_ZOMBIE상태로 전환하고schedule()을 호출합니다. 이 태스크는 다시 스케줄되지 않습니다.
/* kernel/exit.c - do_exit() 핵심 흐름 (간략화) */
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;
tsk->flags |= PF_EXITING;
exit_signals(tsk); /* 시그널 처리 정리 */
exit_mm(); /* 주소 공간 해제 */
exit_sem(tsk); /* SysV 세마포어 정리 */
exit_files(tsk); /* 파일 디스크립터 닫기 */
exit_fs(tsk); /* 파일시스템 참조 해제 */
exit_task_namespaces(tsk);
tsk->exit_code = code;
exit_notify(tsk, group_dead);
tsk->__state = TASK_DEAD;
__schedule(SM_NONE); /* 다시는 돌아오지 않음 */
BUG(); /* 도달 불가 */
}
wait()과 좀비 수거 (Zombie Reaping)
부모 프로세스가 wait()/waitpid()/waitid()를 호출하면 커널은 do_wait()을 통해 좀비 상태의 자식을 찾아 종료 상태를 수집하고, release_task()로 task_struct를 최종 해제합니다.
부모가 wait()을 호출하지 않으면 자식은 좀비 상태로 남아 task_struct 메모리를 계속 점유합니다. 부모가 먼저 종료되면 고아(orphan) 프로세스는 init(PID 1)에게 입양되며, init이 주기적으로 wait()을 호출하여 좀비를 수거합니다.
좀비 프로세스 누수: 자식 프로세스를 생성하는 데몬에서 wait()를 적절히 호출하지 않으면 좀비 프로세스가 누적되어 PID 고갈 문제가 발생할 수 있습니다. SIGCHLD 핸들러에서 waitpid(-1, &status, WNOHANG)를 루프로 호출하거나, signal(SIGCHLD, SIG_IGN)으로 자동 수거를 활성화하세요.
종료 흐름 다이어그램 (Exit Flow Diagram)
프로세스가 종료되는 전체 경로를 시각화하면 다음과 같습니다. 사용자 공간에서 exit()를 호출하거나, 치명적 시그널을 받거나, main()에서 반환하는 등 다양한 진입점이 있지만, 최종적으로는 모두 do_exit()로 수렴합니다.
exit() vs _exit(): C 라이브러리의 exit()는 atexit() 핸들러 실행, stdio 버퍼 플러시 등 정리 작업을 수행한 후 _exit()를 호출합니다. _exit()는 직접 sys_exit_group() 시스템 콜을 호출하여 스레드 그룹 전체를 종료합니다. fork() 직후 자식에서는 _exit()를 사용하는 것이 안전합니다.
고아 프로세스 입양 (Orphan Process Reparenting)
부모 프로세스가 자식보다 먼저 종료되면, 남겨진 자식 프로세스는 고아(orphan)가 됩니다. 커널은 exit_notify() 내부에서 forget_original_parent()를 호출하여 이 고아들에게 새 부모를 찾아줍니다. 새 부모를 결정하는 핵심 함수가 find_new_reaper()입니다.
find_new_reaper()는 다음 순서로 새 부모를 탐색합니다:
- 같은 스레드 그룹의 다른 스레드: 아직 종료되지 않은 형제 스레드가 있으면 그 스레드가 새 부모가 됩니다.
- 가장 가까운 subreaper 조상: 조상 프로세스 중
PR_SET_CHILD_SUBREAPER로 표시된 프로세스를 찾습니다. - init (PID 1): subreaper가 없으면 최종적으로 init 프로세스가 새 부모가 됩니다.
/* kernel/exit.c - find_new_reaper() (간략화) */
static struct task_struct *find_new_reaper(struct task_struct *father,
struct task_struct *child_reaper)
{
struct task_struct *thread, *reaper;
/* 1. 같은 스레드 그룹의 다른 스레드 */
for_each_thread(father, thread) {
if (thread == father || !(thread->flags & PF_EXITING))
return thread;
}
/* 2. 가장 가까운 subreaper 조상 */
for (reaper = father->real_parent;
reaper != &init_task;
reaper = reaper->real_parent) {
if (reaper->signal->is_child_subreaper)
break;
}
return reaper; /* 최종적으로 init_task */
}
Subreaper는 prctl(PR_SET_CHILD_SUBREAPER)로 설정할 수 있으며, 해당 프로세스 아래의 모든 고아 프로세스가 init 대신 이 subreaper에게 입양됩니다. systemd, Docker의 init 프로세스, 각종 서비스 매니저가 이 기능을 활용합니다.
/* prctl(PR_SET_CHILD_SUBREAPER, 1) */
/* systemd, Docker init 등에서 사용 */
/* 해당 프로세스가 하위 고아의 새 부모가 됨 */
prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0);
컨테이너와 subreaper: 컨테이너 환경에서 PID 1 프로세스가 좀비 수거를 하지 않으면 좀비가 누적됩니다. 이를 방지하기 위해 Docker는 --init 옵션으로 tini 같은 경량 init을 PID 1로 실행하고, 이 프로세스가 PR_SET_CHILD_SUBREAPER를 설정하여 고아 프로세스를 수거합니다.
release_task() — 최종 자원 해제
부모 프로세스가 wait() 계열 시스템 콜을 호출하면, 커널은 좀비 상태의 자식에서 종료 코드를 수집한 후 release_task()를 호출하여 task_struct와 관련 자원을 최종 해제합니다. 이 시점에서 프로세스의 흔적이 커널에서 완전히 사라집니다.
release_task()의 핵심 동작:
- PID 해제:
unhash_pid()로 PID 해시 테이블에서 제거하여 PID를 재사용 가능하게 합니다. - 태스크 리스트 제거:
list_del_rcu()로 전역 태스크 리스트에서 제거합니다. - 참조 카운트 감소:
__put_task_struct()로 참조 카운트를 감소시키고, 0이 되면 실제 메모리를 해제합니다. - RCU 지연 해제:
task_struct와 커널 스택은 RCU grace period 이후에 해제되어, 동시에 해당 구조체를 읽고 있는 다른 CPU의 안전을 보장합니다.
/* kernel/exit.c - release_task() 핵심 (간략화) */
void release_task(struct task_struct *p)
{
__exit_signal(p);
unhash_pid(p); /* PID 해시에서 제거 */
list_del_rcu(&p->tasks); /* 전역 태스크 리스트에서 제거 */
__put_task_struct(p); /* 참조 카운트 감소 → 0이면 해제 */
/* task_struct + 커널 스택 = RCU grace period 후 해제 */
}
task_struct 접근 시 주의: release_task() 이후에도 RCU read-side critical section 내에서는 task_struct 포인터가 유효합니다. 그러나 RCU grace period가 지나면 메모리가 해제되므로, rcu_read_lock() / rcu_read_unlock() 없이 task_struct를 참조하면 use-after-free가 발생할 수 있습니다. get_task_struct() / put_task_struct()로 참조 카운트를 관리하세요.
Wait Queue 메커니즘 (Wait Queue Mechanism)
Wait Queue(대기 큐)는 커널에서 특정 조건이 충족될 때까지 태스크를 슬립시키고, 조건이 만족되면 깨우는 메커니즘입니다. 프로세스 관리의 핵심 기반 구조로, I/O 완료 대기, 자원 가용성 대기, 이벤트 알림 등 커널 전반에서 광범위하게 사용됩니다.
Wait Queue 구조
Wait Queue는 wait_queue_head_t(큐 헤드)와 wait_queue_entry(엔트리)로 구성됩니다. 각 엔트리는 대기 중인 태스크와 대기 상태(인터럽트 가능 여부), 그리고 exclusive 플래그를 포함합니다.
기본 사용 패턴
Wait Queue의 가장 일반적인 사용 패턴은 wait_event*()와 wake_up*()의 조합입니다. 소비자(consumer)는 조건이 만족될 때까지 슬립하고, 생산자(producer)는 조건을 변경한 후 대기 중인 태스크를 깨웁니다.
/* include/linux/wait.h */
/* 기본 사용 패턴 */
DECLARE_WAIT_QUEUE_HEAD(my_wq);
int condition = 0;
/* 슬립 측 (소비자) */
wait_event_interruptible(my_wq, condition != 0);
/* 깨우기 측 (생산자) */
condition = 1;
wake_up_interruptible(&my_wq);
wait_event_interruptible 내부 구현
wait_event_interruptible() 매크로가 내부적으로 어떻게 동작하는지 살펴보면, 커널의 슬립/웨이크업 메커니즘을 이해할 수 있습니다:
/* wait_event_interruptible 매크로 확장 */
#define wait_event_interruptible(wq_head, condition) \
({ \
int __ret = 0; \
if (!(condition)) \
__ret = __wait_event_interruptible(wq_head, \
condition); \
__ret; \
})
/* __wait_event_interruptible 내부 (간략화) */
for (;;) {
set_current_state(TASK_INTERRUPTIBLE);
if (condition)
break;
if (signal_pending(current)) {
ret = -ERESTARTSYS;
break;
}
schedule();
}
__set_current_state(TASK_RUNNING);
이 코드의 핵심 패턴은 lost wakeup 방지입니다. set_current_state(TASK_INTERRUPTIBLE)로 상태를 먼저 변경한 후 조건을 확인하므로, 조건 확인과 schedule() 사이에 다른 CPU에서 wake_up()이 호출되어도 깨어남이 보장됩니다.
Wait Queue API 비교
| API | 동작 |
|---|---|
wait_event(wq, cond) | 조건 만족까지 UNINTERRUPTIBLE 대기 |
wait_event_interruptible(wq, cond) | 시그널로 중단 가능한 대기 (-ERESTARTSYS 반환) |
wait_event_timeout(wq, cond, timeout) | 타임아웃 포함 대기 (jiffies 단위) |
wait_event_interruptible_timeout() | 시그널 + 타임아웃 |
wake_up(wq) | INTERRUPTIBLE + UNINTERRUPTIBLE 모두 깨움 |
wake_up_interruptible(wq) | INTERRUPTIBLE만 깨움 |
wake_up_nr(wq, nr) | 최대 nr개 태스크 깨움 |
Thundering Herd(천둥 떼) 문제: wake_up()은 기본적으로 모든 non-exclusive 대기자를 깨웁니다. 다수의 태스크가 동일 자원을 기다리는 경우, 한 번에 모두 깨어나 경쟁하다가 하나만 자원을 획득하고 나머지는 다시 슬립하는 비효율이 발생합니다. 이를 방지하려면 WQ_FLAG_EXCLUSIVE 플래그로 exclusive waiter를 등록하거나, prepare_to_wait_exclusive()를 사용하여 하나의 대기자만 깨우도록 해야 합니다. 커널의 accept() 구현이 대표적인 exclusive waiter 활용 사례입니다.
저수준 API: prepare_to_wait() / finish_wait()
wait_event*() 매크로가 적합하지 않은 복잡한 대기 로직에서는 prepare_to_wait() / finish_wait()를 직접 사용할 수 있습니다:
/* include/linux/wait.h — prepare_to_wait/finish_wait 저수준 패턴 */
DEFINE_WAIT(wait);
while (!condition) {
prepare_to_wait(&my_wq, &wait, TASK_INTERRUPTIBLE);
if (!condition)
schedule();
if (signal_pending(current)) {
ret = -ERESTARTSYS;
break;
}
}
finish_wait(&my_wq, &wait);
wait_event vs prepare_to_wait: 대부분의 경우 wait_event*() 매크로를 사용하는 것이 안전하고 간결합니다. prepare_to_wait()는 대기 루프 내에서 락을 잡거나 해제해야 하는 등 세밀한 제어가 필요한 경우에만 사용하세요. finish_wait()를 호출하지 않으면 wait queue에 잔여 엔트리가 남아 메모리 누수가 발생합니다.
코드 예제 (Code Examples)
커널 모듈에서 커널 스레드 생성
커널 스레드 코드 예제(기본 kthread, per-CPU 스레드, kthread_worker, freezable 패턴 등)는 Kernel Threads — 코드 예제로 이동했습니다.
프로세스 리스트 순회 (Traversing the Task List)
/* include/linux/sched/signal.h — 프로세스/스레드 리스트 순회 */
#include <linux/sched/signal.h>
/* 모든 프로세스 순회 */
struct task_struct *task;
for_each_process(task) {
pr_info("[%5d] %s state=%u prio=%d\\n",
task->pid, task->comm,
task->__state, task->prio);
}
/* 특정 프로세스의 모든 스레드 순회 */
struct task_struct *thread;
struct task_struct *leader = current->group_leader;
for_each_thread(leader, thread) {
pr_info(" thread: pid=%d comm=%s\\n",
thread->pid, thread->comm);
}
/* PID로 task_struct 찾기 */
struct task_struct *found;
rcu_read_lock();
found = find_task_by_vpid(1234);
if (found)
pr_info("found: %s\\n", found->comm);
rcu_read_unlock();
스케줄링 파라미터 확인 및 변경
/* 유저 공간에서 스케줄링 정책/우선순위 변경 */
#include <sched.h>
/* SCHED_FIFO로 변경, 우선순위 50 */
struct sched_param param;
param.sched_priority = 50;
sched_setscheduler(0, SCHED_FIFO, ¶m);
/* SCHED_DEADLINE 설정 (clone3 또는 sched_setattr 필요) */
struct sched_attr attr = {
.size = sizeof(attr),
.sched_policy = SCHED_DEADLINE,
.sched_runtime = 10000000, /* 10ms */
.sched_deadline = 30000000, /* 30ms */
.sched_period = 30000000, /* 30ms */
};
sched_setattr(0, &attr, 0);
RT 프로세스 주의: SCHED_FIFO나 SCHED_RR 정책의 프로세스가 무한 루프에 빠지면 해당 CPU의 다른 일반 프로세스는 실행되지 못합니다. 커널 설정 CONFIG_RT_GROUP_SCHED와 /proc/sys/kernel/sched_rt_runtime_us(기본 950000, 즉 1초당 최대 950ms)로 RT 프로세스의 CPU 독점을 제한할 수 있습니다.
프로세스 관리 관련 주요 버그 사례
리눅스 커널의 프로세스 관리 서브시스템에서 발견된 주요 보안 취약점(Vulnerability)과 버그 사례를 분석합니다. 이러한 사례를 이해하면 커널 코드 작성 시 유사한 실수를 방지할 수 있습니다.
Dirty Pipe (CVE-2022-0847) — pipe 버퍼(Buffer) 플래그 오류
splice() 시스템 콜로 pipe에 데이터를 넣을 때, pipe 버퍼의 PIPE_BUF_FLAG_CAN_MERGE 플래그가 올바르게 초기화되지 않는 버그입니다. 이로 인해 pipe 버퍼가 페이지 캐시(page cache) 페이지를 참조하면서도 쓰기 가능한 상태가 되어, 읽기 전용 파일이나 SUID 바이너리를 덮어쓸 수 있는 로컬 권한 상승 취약점이 발생했습니다.
심각도: CRITICAL — Linux 5.8부터 5.16.10까지 영향을 받으며, 비특권 사용자가 읽기 전용 파일을 수정하여 root 권한을 획득할 수 있습니다. 발견자: Max Kellermann. CVSS 점수 7.8.
취약점의 핵심은 copy_page_to_iter_pipe() 함수에서 새로 할당된 pipe 버퍼의 flags 필드를 초기화하지 않은 것입니다. 이전에 PIPE_BUF_FLAG_CAN_MERGE가 설정된 버퍼가 재사용되면, splice()로 매핑된 페이지 캐시 페이지에 일반 write()로 데이터를 병합(merge)할 수 있게 됩니다.
/* 취약한 코드 (fs/pipe.c) — 플래그 미초기화 */
static size_t copy_page_to_iter_pipe(struct page *page,
size_t offset, size_t bytes,
struct iov_iter *i)
{
struct pipe_inode_info *pipe = i->pipe;
struct pipe_buffer *buf;
unsigned int p_tail = pipe->tail;
...
buf = &pipe->bufs[p_tail & (pipe->ring_size - 1)];
buf->page = page;
buf->offset = offset;
buf->len = bytes;
/* BUG: buf->flags 초기화 누락!
* 이전 사용에서 PIPE_BUF_FLAG_CAN_MERGE가 남아 있으면
* 페이지 캐시 페이지에 쓰기가 가능해짐 */
...
}
/* 수정된 코드 — 플래그를 명시적으로 초기화 */
buf->flags = 0; /* PIPE_BUF_FLAG_CAN_MERGE 제거 */
공격 시나리오: 공격자는 (1) pipe를 생성하고 버퍼를 모두 채운 뒤 비워서 PIPE_BUF_FLAG_CAN_MERGE를 설정하고, (2) splice()로 대상 파일의 페이지 캐시를 pipe에 매핑한 후, (3) write()로 원하는 데이터를 덮어씁니다. 이 과정은 /etc/passwd나 SUID 바이너리에 적용하여 root 쉘을 획득할 수 있습니다.
ELF 로더(Loader) 취약점 — Stack Clash (CVE-2017-1000364)
리눅스 커널의 ELF 로더가 사용자 공간 스택을 설정할 때, guard page가 충분하지 않아 스택이 다른 메모리 영역(heap, mmap 등)과 충돌하는 취약점입니다. 재귀 호출이나 큰 로컬 변수 할당을 통해 guard page를 한 번에 건너뛸 수 있었습니다.
심각도: HIGH — 스택과 heap/mmap 영역의 경계 보호가 단일 guard page(4KB)에만 의존했기 때문에, 4KB 이상의 스택 프레임(Stack Frame)을 할당하는 함수 호출로 guard page를 건너뛰어 인접 메모리 영역을 덮어쓸 수 있었습니다. 이를 통해 로컬 권한 상승이 가능했습니다.
/* 취약점 원리: 스택 가드 페이지 우회 */
/* 높은 주소 */
/* ┌─────────────────────┐ */
/* │ User Stack │ ← 아래로 성장 */
/* ├─────────────────────┤ */
/* │ Guard Page (4KB) │ ← 단일 페이지, 건너뛸 수 있음! */
/* ├─────────────────────┤ */
/* │ mmap / heap │ ← 위로 성장 */
/* └─────────────────────┘ */
/* 낮은 주소 */
/* 공격 코드 예시: 큰 로컬 변수로 guard page 건너뛰기 */
void exploit(void) {
char buf[1024 * 1024]; /* 1MB — guard page(4KB)를 한 번에 건너뜀 */
buf[0] = 'A'; /* heap/mmap 영역에 쓰기 발생 */
}
/* 수정: stack_guard_gap 확대 (mm/mmap.c) */
unsigned long stack_guard_gap = 256UL << PAGE_SHIFT; /* 256 페이지 = 1MB */
static int __init cmdline_parse_stack_guard_gap(char *p)
{
unsigned long val;
if (!p)
return -EINVAL;
if (kstrtoul(p, 10, &val))
return -EINVAL;
stack_guard_gap = val << PAGE_SHIFT;
return 0;
}
__setup("stack_guard_gap=", cmdline_parse_stack_guard_gap);
RLIMIT_STACK과의 상호작용: RLIMIT_STACK은 프로세스 스택의 최대 크기를 제한하지만, guard page 크기와는 독립적으로 동작합니다. 수정 후에는 stack_guard_gap(기본 1MB)이 스택 확장 시 인접 VMA와의 최소 거리를 보장합니다. 커널 부트 파라미터 stack_guard_gap=N으로 페이지 단위 조정이 가능합니다.
ASLR 우회 기법과 대응
ASLR(Address Space Layout Randomization)은 프로세스의 메모리 레이아웃을 무작위화하여 공격을 어렵게 만드는 핵심 보안 메커니즘입니다. 그러나 다양한 정보 누출(information leak) 경로를 통해 ASLR이 무력화될 수 있으며, 커널은 이에 대한 대응책을 지속적으로 강화해 왔습니다.
정보 누출 경로: /proc/self/maps 파일은 프로세스의 전체 메모리 맵(Memory Map)을 노출하며, 이를 통해 ASLR이 완전히 무력화됩니다. 또한 커널 로그(dmesg)에 출력되는 커널 포인터 주소도 KASLR 우회에 사용될 수 있습니다.
/* printk의 %p → %pK 변환 (commit 57e734423ad) */
/* 변경 전: 커널 주소가 그대로 노출 */
printk(KERN_INFO "object at %p\\n", obj);
/* 출력: "object at ffff8880123abc00" ← 실제 주소 노출! */
/* 변경 후: 해시된 주소 출력 */
printk(KERN_INFO "object at %p\\n", obj);
/* 출력: "object at 00000000deadbeef" ← 해시값 (비특권 사용자) */
/* 제한된 포인터 출력 (%pK): kptr_restrict에 따라 동작 */
printk(KERN_INFO "symbol at %pK\\n", sym);
/* kptr_restrict=0: 실제 주소 출력 (기본값) */
/* kptr_restrict=1: CAP_SYSLOG 없으면 0 출력 */
/* kptr_restrict=2: 항상 0 출력 */
/* ASLR 강화를 위한 sysctl 설정 */
/* dmesg 접근 제한 */
# sysctl -w kernel.dmesg_restrict=1
/* 커널 포인터 출력 제한 */
# sysctl -w kernel.kptr_restrict=1
/* /proc/sys/kernel/randomize_va_space 설정값 */
/* 0: ASLR 비활성화 */
/* 1: mmap, 스택, VDSO 무작위화 */
/* 2: 1 + brk 무작위화 (기본값, 완전 ASLR) */
Stack canary와 ASLR의 상보적 보호: Stack canary는 버퍼 오버플로(Buffer Overflow)우를 탐지하고, ASLR은 공격 대상 주소의 예측을 방지합니다. 두 메커니즘은 독립적으로 동작하지만, 함께 사용할 때 보호 효과가 극대화됩니다. Stack canary가 우회되더라도 ASLR이 유효하면 ROP 공격이 어렵고, ASLR이 우회되더라도 canary가 스택 기반 공격을 차단합니다. GCC의 -fstack-protector-strong 옵션으로 canary를 활성화할 수 있습니다.
fork() bomb과 PID 고갈
fork()를 무한 반복 호출하여 시스템의 PID를 고갈시키는 fork bomb은 가장 단순하면서도 효과적인 서비스 거부(DoS) 공격입니다. 리눅스 커널은 여러 계층에서 이를 방어하는 메커니즘을 제공합니다.
/* 고전적인 fork bomb */
/* bash: :(){ :|:& };: */
#include <unistd.h>
int main(void)
{
while (1)
fork(); /* 프로세스 수가 기하급수적으로 증가 */
return 0;
}
/* 방어 1: RLIMIT_NPROC — 사용자별 프로세스 수 제한 */
#include <sys/resource.h>
struct rlimit rl;
rl.rlim_cur = 1024; /* 소프트 한계: 1024개 */
rl.rlim_max = 4096; /* 하드 한계: 4096개 */
setrlimit(RLIMIT_NPROC, &rl);
/* /etc/security/limits.conf 설정 */
/* * hard nproc 4096 */
/* * soft nproc 1024 */
/* 방어 2: cgroup pids 컨트롤러 — 그룹 단위 PID 제한 */
/* cgroup v2에서 pids.max 설정 */
# cgroup 생성 및 PID 제한 설정
# mkdir /sys/fs/cgroup/mygroup
# echo 100 > /sys/fs/cgroup/mygroup/pids.max
# echo $$ > /sys/fs/cgroup/mygroup/cgroup.procs
/* 커널 코드: cgroup pids 제한 확인 (kernel/cgroup/pids.c) */
static int pids_can_fork(struct task_struct *task,
int *retval)
{
struct pids_cgroup *pids;
int64_t limit;
pids = css_to_pids(task_css(task, pids_cgrp_id));
limit = atomic64_read(&pids->limit);
if (limit != PIDS_MAX &&
atomic64_read(&pids->counter) >= limit) {
*retval = -EAGAIN;
return 1; /* fork 거부 */
}
atomic64_inc(&pids->counter);
return 0;
}
PID namespace 격리: PID namespace는 컨테이너 환경에서 fork bomb의 영향 범위를 제한하는 핵심 메커니즘입니다. 각 PID namespace는 독립적인 PID 번호 공간을 가지며, namespace 내부의 프로세스 폭주가 호스트의 전체 PID 공간을 고갈시키지 않습니다. cgroup pids.max와 결합하면 namespace당 프로세스 수를 엄격하게 제한할 수 있어, 컨테이너 오케스트레이션 환경(Kubernetes 등)에서는 반드시 설정해야 하는 보안 항목입니다.
do_fork()/copy_process() 호출 체인 심층 분석
프로세스 생성의 핵심 경로는 사용자 공간의 fork()/clone()/clone3()로부터 시작되어 커널 내부의 kernel_clone()(구 do_fork())을 거쳐 copy_process()에서 실제 태스크 복제가 이루어집니다. 커널 5.10부터 do_fork()는 kernel_clone()으로 이름이 변경되었으며, 내부 구조도 struct kernel_clone_args를 사용하는 방식으로 정리되었습니다.
전체 호출 체인 개요
사용자 공간 시스템 콜에서 실제 프로세스 생성까지의 전체 호출 경로입니다:
copy_process() 핵심 경로 분석
copy_process()는 프로세스 복제의 실질적인 핵심 함수로, kernel/fork.c에 정의되어 있습니다. 약 300줄에 달하는 이 함수는 보안 검증, 리소스 복제, 스케줄러 초기화, PID 할당을 순서대로 수행합니다.
/* kernel/fork.c - copy_process() 핵심 경로 (커널 6.x 기준, 간략화) */
static struct task_struct *copy_process(
struct pid *pid,
int trace,
struct kernel_clone_args *args)
{
int retval;
struct task_struct *p;
u64 clone_flags = args->flags;
/* 1단계: 플래그 유효성 검증 */
retval = -EINVAL;
if ((clone_flags & (CLONE_NEWNS | CLONE_FS))
== (CLONE_NEWNS | CLONE_FS))
goto fork_out;
/* 2단계: task_struct + 커널 스택 할당 */
p = dup_task_struct(current, args->stack_size);
if (!p)
goto fork_out;
/* 3단계: cgroup 포크 전 처리 */
retval = cgroup_can_fork(p, args);
if (retval)
goto bad_fork_put_pidfd;
/* 4단계: 자격 증명(Credential) 복제 */
retval = copy_creds(p, clone_flags);
if (retval < 0)
goto bad_fork_free;
/* 5단계: 스케줄러 초기화 */
retval = sched_fork(clone_flags, p);
if (retval)
goto bad_fork_cleanup_policy;
/* 6단계: 리소스 복제 체인 */
retval = copy_files(clone_flags, p); /* 파일 디스크립터 */
retval = copy_fs(clone_flags, p); /* 파일시스템 정보 */
retval = copy_sighand(clone_flags, p); /* 시그널 핸들러 */
retval = copy_signal(clone_flags, p); /* 시그널 상태 */
retval = copy_mm(clone_flags, p); /* 메모리 매핑 (COW) */
retval = copy_namespaces(clone_flags, p); /* 네임스페이스 */
retval = copy_io(clone_flags, p); /* I/O 컨텍스트 */
/* 7단계: 아키텍처별 스레드 복제 */
retval = copy_thread(p, args);
/* 8단계: PID 할당 */
if (pid != &init_struct_pid) {
pid = alloc_pid(p->nsproxy->pid_ns_for_children,
args->set_tid, args->set_tid_size);
}
p->pid = pid_nr(pid);
p->tgid = p->pid;
if (clone_flags & CLONE_THREAD)
p->tgid = current->tgid;
/* 9단계: 프로세스 트리에 연결 */
init_task_pid(p, PIDTYPE_PID, pid);
if (thread_group_leader(p))
init_task_pid(p, PIDTYPE_TGID, pid);
/* 10단계: cgroup 포크 후 처리 */
cgroup_post_fork(p, args);
sched_cgroup_fork(p, args);
return p;
}
코드 설명
- 7행
clone_flags는CLONE_VM,CLONE_FILES등의 비트 조합으로, 부모와 어떤 리소스를 공유할지 결정합니다. - 10~12행
CLONE_NEWNS(새 마운트 네임스페이스)와CLONE_FS(파일시스템 공유)는 상호 배타적이므로 동시 설정 시 거부합니다. - 15행
dup_task_struct()는 부모의task_struct를 메모리 복사하고 새 커널 스택을 할당합니다.thread_info도 이 시점에 초기화됩니다. - 20행
cgroup_can_fork()는 cgroup 컨트롤러(pids 등)가 새 프로세스 생성을 허용하는지 검사합니다.pids.max초과 시 여기서 실패합니다. - 24행
copy_creds()는 부모의 자격 증명(UID, GID, capability 등)을 복제합니다. LSM(Linux Security Module) 훅도 여기서 호출됩니다. - 29행
sched_fork()는 새 태스크의 스케줄링 엔티티를 초기화합니다. vruntime 리셋, 우선순위 상속, 스케줄링 클래스 설정이 이루어집니다. - 33~39행리소스 복제 체인에서 각
copy_*함수는clone_flags를 확인하여 공유(CLONE_*플래그 설정 시)하거나 복제합니다. 예를 들어CLONE_FILES설정 시copy_files()는files_struct의 참조 카운트만 증가시킵니다. - 42행
copy_thread()는 아키텍처(x86, ARM 등)별로 다르며, 레지스터 컨텍스트와 TLS(Thread-Local Storage)를 설정합니다. 자식 프로세스가ret_from_fork에서 실행을 시작하도록 IP를 설정합니다. - 45~50행PID 할당은 대상 PID 네임스페이스를 고려합니다.
CLONE_THREAD설정 시tgid를 부모의 것으로 유지하여 같은 스레드 그룹에 속하게 합니다. - 56행
sched_cgroup_fork()는 새 태스크를 적절한 cgroup의 CPU 대역폭 컨트롤러에 연결합니다.
dup_task_struct() 내부
dup_task_struct()는 새 프로세스의 메모리 기반을 마련하는 첫 단계입니다. 이 함수는 커널 스택을 할당하고, 부모의 task_struct를 바이트 단위로 복사한 후, 필드별 초기화를 수행합니다.
/* kernel/fork.c - dup_task_struct() 핵심 (간략화) */
static struct task_struct *dup_task_struct(
struct task_struct *orig, unsigned long stack_size)
{
struct task_struct *tsk;
void *stack;
int node = tsk_fork_get_node(orig);
/* NUMA 노드를 고려한 task_struct 슬랩 할당 */
tsk = alloc_task_struct_node(node);
if (!tsk)
return NULL;
/* 커널 스택 할당 (VMAP_STACK일 경우 vmalloc 사용) */
stack = alloc_thread_stack_node(tsk, node);
if (!stack)
goto free_tsk;
/* 부모 task_struct를 바이트 단위 복사 */
*tsk = *orig;
/* 새 커널 스택 연결 + thread_info 초기화 */
tsk->stack = stack;
setup_thread_stack(tsk, orig);
/* 참조 카운트 및 독립 필드 초기화 */
refcount_set(&tsk->rcu_users, 2);
refcount_set(&tsk->usage, 1);
tsk->splice_pipe = NULL;
tsk->task_frag.page = NULL;
account_kernel_stack(tsk, 1);
return tsk;
}
코드 설명
- 7행
tsk_fork_get_node()는 NUMA 시스템에서 부모 프로세스가 실행 중인 CPU의 노드를 반환하여 메모리 지역성(Locality)을 최적화합니다. - 10행
alloc_task_struct_node()는task_struct를 슬랩 캐시(task_struct_cachep)에서 할당합니다.task_struct는 약 6~8KB 크기입니다. - 15행
CONFIG_VMAP_STACK설정 시vmalloc()으로 가드 페이지(Guard Page)가 포함된 스택을 할당하여 스택 오버플로를 탐지합니다. - 20행구조체 대입(
*tsk = *orig)으로 부모의 모든 필드를 한번에 복사합니다. 이후 개별 필드를 자식에 맞게 재설정합니다. - 24행
setup_thread_stack()는thread_info를 부모로부터 복사하되, 스택 포인터는 새 스택을 가리키도록 설정합니다. - 27~28행참조 카운트를 초기화하여 새 태스크가 독립적인 생명주기를 갖도록 합니다.
usage는put_task_struct()에서 감소됩니다.
CLONE_THREAD와 tgid: CLONE_THREAD 플래그가 설정되면 자식의 tgid가 부모와 동일하게 유지됩니다. 이것이 리눅스에서 "스레드"의 본질입니다. getpid() 시스템 콜은 실제로 tgid를 반환하므로, 같은 스레드 그룹의 모든 스레드가 동일한 PID를 보게 됩니다. 반면 gettid()는 개별 pid를 반환합니다.
struct task_struct 필드 심층 분석
struct task_struct는 리눅스 커널에서 가장 크고 복잡한 구조체 중 하나로, 프로세스의 모든 상태 정보를 담고 있습니다. include/linux/sched.h에 정의되어 있으며, 커널 6.x 기준으로 약 700줄 이상의 필드를 포함합니다.
주요 필드 그룹별 분석
| 그룹 | 주요 필드 | 설명 |
|---|---|---|
| 상태/플래그 | __state, flags, exit_state |
__state는 TASK_RUNNING 등 실행 상태, flags는 PF_EXITING 등 프로세스 속성 비트마스크 |
| 식별자 | pid, tgid, comm[TASK_COMM_LEN] |
pid는 커널 내부 고유 ID, tgid는 스레드 그룹 ID(사용자 공간의 PID), comm은 16바이트 프로세스 이름 |
| 프로세스 트리 | real_parent, parent, children, sibling |
real_parent는 생물학적 부모, parent는 ptrace 부모(보통 동일). children/sibling은 리스트 헤드로 프로세스 트리 구성 |
| 메모리 | mm, active_mm |
mm은 사용자 공간 메모리 매핑(커널 스레드는 NULL), active_mm은 마지막으로 사용한 mm(TLB 최적화용) |
| 파일시스템 | fs, files |
fs는 root/cwd 정보(struct fs_struct), files는 열린 파일 디스크립터 테이블(struct files_struct) |
| 네임스페이스 | nsproxy |
PID/네트워크/마운트/IPC/UTS/cgroup 네임스페이스 포인터 묶음 |
| 시그널 | signal, sighand, pending |
signal은 스레드 그룹 공유, sighand는 시그널 핸들러 테이블, pending은 태스크별 대기 시그널 |
| 스케줄링 | se, rt, dl, policy, prio |
se는 CFS 스케줄링 엔티티, rt/dl은 RT/DEADLINE 엔티티, policy는 SCHED_* 정책 |
| 우선순위 | static_prio, normal_prio, prio, rt_priority |
static_prio는 nice값 기반(100~139), rt_priority는 RT 우선순위(0~99), prio는 실효 우선순위 |
| 보안 | cred, real_cred |
real_cred는 객관적 자격(누가 이 프로세스인가), cred는 주관적 자격(이 프로세스가 무엇을 할 수 있는가) |
| 스택/아키텍처 | stack, thread_info, thread |
stack은 커널 스택 포인터, thread는 CPU별 레지스터 상태(struct thread_struct) |
| CPU 바인딩 | cpu, nr_cpus_allowed, cpus_mask |
현재 실행 CPU, 허용된 CPU 수, CPU affinity 마스크 |
task_struct 관계 다이어그램
task_struct 초기화 과정
copy_process() 내에서 dup_task_struct() 직후 수행되는 주요 초기화 코드입니다. 부모로부터 복사된 필드 중 자식에 맞게 재설정해야 하는 항목들이 여기서 처리됩니다.
/* kernel/fork.c - copy_process() 내 task_struct 초기화 (간략화) */
/* 플래그 초기화: 부모의 일시적 플래그 제거 */
p->flags &= ~(PF_SUPERPRIV | PF_WQ_WORKER | PF_IDLE |
PF_NO_SETAFFINITY);
p->flags |= PF_FORKNOEXEC;
/* 시그널 관련 초기화 */
INIT_LIST_HEAD(&p->children);
INIT_LIST_HEAD(&p->sibling);
p->did_exec = 0;
/* 락/동기화 관련 초기화 */
spin_lock_init(&p->alloc_lock);
init_sigpending(&p->pending);
/* 타이밍 초기화 */
p->utime = p->stime = p->gtime = 0;
p->start_time = ktime_get_ns();
p->start_boottime = ktime_get_boottime_ns();
/* 프로세스 계층 구조 설정 */
p->real_parent = current;
p->parent = current;
if (clone_flags & (CLONE_PARENT | CLONE_THREAD))
p->real_parent = current->real_parent;
/* CPU affinity 상속 */
p->nr_cpus_allowed = current->nr_cpus_allowed;
cpumask_copy(&p->cpus_mask, ¤t->cpus_mask);
코드 설명
- 4~6행
PF_SUPERPRIV(수퍼유저 권한 사용),PF_WQ_WORKER(워크큐 워커),PF_IDLE(idle 태스크) 플래그를 제거하고PF_FORKNOEXEC을 설정하여 아직 exec 하지 않은 새 프로세스임을 표시합니다. - 9~11행자식 프로세스와 형제 리스트를 빈 상태로 초기화합니다. 부모로부터 복사된 리스트 포인터는 더 이상 유효하지 않으므로 반드시 재설정해야 합니다.
- 17~19행CPU 시간 누적 카운터를 0으로 리셋하고,
start_time에 현재 시각을 기록합니다. 이 값이/proc/[pid]/stat의 시작 시각으로 보입니다. - 22~25행
CLONE_PARENT또는CLONE_THREAD설정 시real_parent를 현재 프로세스가 아닌 현재 프로세스의 부모로 설정합니다. 이렇게 하면 스레드가 프로세스 트리에서 형제로 배치됩니다. - 28~29행CPU affinity 마스크를 부모로부터 상속합니다. 부모가
sched_setaffinity()로 특정 CPU에 바인딩되어 있으면 자식도 동일한 제약을 받습니다.
real_parent vs parent: 일반적인 상황에서 real_parent와 parent는 동일합니다. 차이가 발생하는 경우는 ptrace로 프로세스를 추적할 때입니다. ptrace 시 parent는 디버거 프로세스로 변경되지만, real_parent는 원래 부모를 유지합니다. 디버거가 종료되면 parent가 다시 real_parent로 복원됩니다.
프로세스 상태 전이 코드 분석
프로세스 상태 전이는 커널의 여러 경로에서 발생하며, set_current_state(), __set_current_state(), try_to_wake_up() 등의 함수가 핵심 역할을 합니다. 이 섹션에서는 상태 변경의 내부 메커니즘을 커널 소스 수준에서 분석합니다.
set_current_state() / __set_current_state() 분석
프로세스 상태를 변경하는 두 가지 API의 핵심 차이는 메모리 배리어(Memory Barrier) 유무입니다.
/* include/linux/sched.h - 상태 설정 매크로 */
/* 메모리 배리어 포함 - 슬립 루프에서 사용 */
#define set_current_state(state_value) \
smp_store_mb(current->__state, (state_value))
/* 메모리 배리어 없음 - 이미 직렬화된 컨텍스트에서 사용 */
#define __set_current_state(state_value) \
do { WRITE_ONCE(current->__state, (state_value)); } while (0)
/* 전형적인 슬립 패턴 */
for (;;) {
set_current_state(TASK_INTERRUPTIBLE); /* smp_mb() 포함 */
if (condition)
break;
schedule(); /* CPU 양보 */
}
__set_current_state(TASK_RUNNING); /* 배리어 불필요 */
코드 설명
- 4~5행
set_current_state()는smp_store_mb()를 사용하여 상태 변경 후 풀 메모리 배리어를 삽입합니다. 이는try_to_wake_up()의 조건 확인과 올바르게 순서가 보장되어야 하기 때문입니다. - 8~9행
__set_current_state()는WRITE_ONCE()만 사용하여 컴파일러 최적화 방지만 합니다. 이미 직렬화가 보장된 컨텍스트(예: 스케줄러 내부)에서만 사용해야 합니다. - 12~17행슬립 루프의 정형화된 패턴입니다. 상태를
TASK_INTERRUPTIBLE로 설정한 후 조건을 확인합니다. 조건 확인 전에 배리어가 있어야 wake-up을 놓치지 않습니다. 조건이 만족되지 않으면schedule()로 CPU를 양보합니다. - 18행깨어난 후
TASK_RUNNING으로 복원할 때는 배리어가 필요 없습니다.schedule()에서 돌아온 시점에 이미 RUNNING 상태이며,__set_current_state()로 충분합니다.
try_to_wake_up() 호출 체인
try_to_wake_up()은 슬립 중인 프로세스를 깨우는 핵심 함수입니다. kernel/sched/core.c에 정의되어 있으며, 다음과 같은 호출 체인을 거칩니다:
/* kernel/sched/core.c - try_to_wake_up() 핵심 경로 (간략화) */
static int try_to_wake_up(struct task_struct *p,
unsigned int state, int wake_flags)
{
unsigned long flags;
int cpu, success = 0;
/* 1. 현재 상태와 요청 상태 매칭 확인 */
if (!(p->__state & state))
goto out;
/* 2. smp_mb() - set_current_state()의 배리어와 쌍 */
raw_spin_lock_irqsave(&p->pi_lock, flags);
smp_mb__after_spinlock();
/* 3. 상태를 TASK_RUNNING으로 변경 */
WRITE_ONCE(p->__state, TASK_RUNNING);
/* 4. 최적 CPU 선택 */
cpu = select_task_rq(p, p->wake_cpu, wake_flags);
if (task_cpu(p) != cpu)
set_task_cpu(p, cpu);
/* 5. 런큐에 삽입 (ttwu_do_activate) */
ttwu_queue(p, cpu, wake_flags);
success = 1;
raw_spin_unlock_irqrestore(&p->pi_lock, flags);
out:
return success;
}
코드 설명
- 9~10행프로세스의 현재 상태가 요청된 상태 마스크와 일치하는지 확인합니다. 예를 들어
wake_up()은TASK_INTERRUPTIBLE | TASK_UNINTERRUPTIBLE을 전달합니다. - 13~14행
pi_lock을 획득하고smp_mb__after_spinlock()으로 메모리 배리어를 삽입합니다. 이는set_current_state()의 배리어와 쌍을 이루어, 슬립 조건 변경이 깨우는 쪽에서 올바르게 관찰되도록 보장합니다. - 17행프로세스 상태를
TASK_RUNNING으로 변경합니다. 이 시점부터 프로세스는 스케줄링 대상이 됩니다. - 20~22행
select_task_rq()는 스케줄링 클래스(CFS, RT 등)의select_task_rq콜백을 호출하여 최적의 실행 CPU를 선택합니다. NUMA 거리, 캐시 친화도, CPU 부하를 고려합니다. - 25행
ttwu_queue()는 대상 CPU가 현재 CPU와 다를 경우 IPI(Inter-Processor Interrupt)를 보내 원격 런큐에 삽입을 요청하거나, 같은 CPU면 직접ttwu_do_activate()를 호출합니다.
상태 전이 코드 경로 다이어그램
Lost wake-up 문제: set_current_state() 대신 __set_current_state()를 슬립 루프에서 잘못 사용하면 wake-up 이벤트를 놓칠 수 있습니다. 배리어가 없으면 다른 CPU에서 조건을 변경하고 wake_up()을 호출했더라도, 현재 CPU에서 상태 변경이 조건 확인 후에 관찰될 수 있어 영원히 깨어나지 못하는 버그가 발생합니다. 이것이 커널에서 가장 찾기 어려운 버그 유형 중 하나입니다.
exec() 경로 심층 분석
execve() 시스템 콜은 현재 프로세스의 이미지를 새로운 프로그램으로 완전히 교체합니다. 기존 코드/데이터 세그먼트를 파괴하고, 새 바이너리를 로드하여 실행을 시작합니다. 커널 내부에서는 바이너리 형식 탐지, ELF 파싱, 메모리 매핑, 보안 전환 등 복잡한 과정을 거칩니다.
exec() 호출 체인
전체 호출 경로는 다음과 같습니다:
sys_execve() → do_execveat_common() → bprm_execve() → exec_binprm() → search_binary_handler() → load_elf_binary()
/* fs/exec.c - do_execveat_common() 핵심 경로 (간략화) */
static int do_execveat_common(int fd,
struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp, int flags)
{
struct linux_binprm *bprm;
int retval;
/* 1. linux_binprm 할당 및 초기화 */
bprm = alloc_bprm(fd, filename, flags);
if (IS_ERR(bprm))
return PTR_ERR(bprm);
/* 2. argv, envp 문자열을 커널 공간에 복사 */
retval = copy_string_kernel(bprm->filename, bprm);
retval = copy_strings(bprm->envc, envp, bprm);
retval = copy_strings(bprm->argc, argv, bprm);
/* 3. 실행 파일 열기 및 바이너리 헤더 읽기 */
retval = bprm_execve(bprm);
free_bprm(bprm);
return retval;
}
/* fs/exec.c - bprm_execve() 핵심 (간략화) */
static int bprm_execve(struct linux_binprm *bprm)
{
struct file *file;
int retval;
/* 실행 파일 열기 */
file = do_open_execat(bprm->fd, bprm->filename, bprm->flags);
bprm->file = file;
/* 바이너리 처음 BINPRM_BUF_SIZE(256) 바이트 읽기 */
retval = kernel_read(bprm->file, bprm->buf,
BINPRM_BUF_SIZE, &pos);
/* 보안 모듈 검사 (SELinux, AppArmor 등) */
retval = security_bprm_check(bprm);
/* 바이너리 핸들러 탐색 및 실행 */
retval = exec_binprm(bprm);
/* 성공 시: 이전 mm 해제, creds 커밋 */
return retval;
}
코드 설명
- 11행
alloc_bprm()은struct linux_binprm을 할당하고, 실행 파일 경로, 자격 증명, 보안 컨텍스트를 초기화합니다. - 16~18행사용자 공간의 argv/envp 문자열을 커널 공간의 임시 페이지에 복사합니다. 이 단계에서
ARG_MAX크기 제한이 적용됩니다. - 34행
do_open_execat()은 실행 파일을 열면서 실행 권한(MAY_EXEC)을 검사합니다. noexec 마운트 옵션도 이 시점에 확인됩니다. - 38~39행파일의 처음 256바이트를
bprm->buf에 읽습니다. 이 버퍼로 ELF 매직 넘버(\\x7fELF), 스크립트 해시뱅(#!) 등 바이너리 형식을 판별합니다. - 42행
security_bprm_check()는 LSM 훅을 호출하여 SELinux, AppArmor 등이 실행을 허용하는지 검사합니다. setuid 바이너리의 도메인 전환도 여기서 처리됩니다. - 45행
exec_binprm()은search_binary_handler()를 호출하여 등록된 바이너리 핸들러(elf_format,script_format등)를 순회하며 매칭되는 핸들러를 찾아 실행합니다.
struct linux_binprm 핵심 필드
struct linux_binprm은 exec 과정 전반에 걸쳐 실행 컨텍스트를 전달하는 중간 구조체입니다.
| 필드 | 타입 | 설명 |
|---|---|---|
buf[BINPRM_BUF_SIZE] |
char[256] |
실행 파일의 처음 256바이트. 매직 넘버 판별에 사용 |
file |
struct file * |
열린 실행 파일 구조체 |
cred |
struct cred * |
exec 후 적용할 새 자격 증명 (setuid 반영) |
mm |
struct mm_struct * |
새로 생성된 메모리 디스크립터 |
argc, envc |
int |
인자 및 환경 변수 개수 |
filename |
struct filename * |
실행 파일 경로명 |
interp |
const char * |
실제 인터프리터 경로 (스크립트의 경우 #! 뒤의 경로) |
p |
unsigned long |
새 스택의 현재 위쪽 포인터 (argv/envp 저장 위치) |
per_clear |
unsigned int |
exec 시 제거할 personality 플래그 |
secureexec |
unsigned int |
AT_SECURE auxval (setuid 등으로 권한 상승 시 1) |
load_elf_binary() 분석
load_elf_binary()는 ELF 형식 바이너리의 실제 로딩을 수행하는 핸들러로, fs/binfmt_elf.c에 정의되어 있습니다.
/* fs/binfmt_elf.c - load_elf_binary() 핵심 경로 (간략화) */
static int load_elf_binary(struct linux_binprm *bprm)
{
struct elfhdr *elf_ex = (struct elfhdr *)bprm->buf;
struct elf_phdr *elf_ppnt;
unsigned long elf_entry, load_addr;
/* 1. ELF 헤더 검증 */
if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0)
return -ENOEXEC;
if (elf_ex->e_type != ET_EXEC &&
elf_ex->e_type != ET_DYN)
return -ENOEXEC;
/* 2. 프로그램 헤더 테이블 읽기 */
elf_ppnt = kmalloc(elf_ex->e_phnum *
sizeof(struct elf_phdr), GFP_KERNEL);
elf_read(bprm->file, elf_ppnt, size, elf_ex->e_phoff);
/* 3. PT_INTERP 찾기 (동적 링커 경로) */
for (i = 0; i < elf_ex->e_phnum; i++, elf_ppnt++) {
if (elf_ppnt->p_type == PT_INTERP) {
elf_interpreter = kmalloc(elf_ppnt->p_filesz, GFP_KERNEL);
/* /lib64/ld-linux-x86-64.so.2 등 읽기 */
elf_read(bprm->file, elf_interpreter,
elf_ppnt->p_filesz, elf_ppnt->p_offset);
}
}
/* 4. 이전 주소 공간 파괴 */
retval = begin_new_exec(bprm); /* point of no return */
/* 5. 새 스택 설정 */
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
executable_stack);
/* 6. PT_LOAD 세그먼트 매핑 */
for (i = 0; i < elf_ex->e_phnum; i++) {
if (elf_ppnt->p_type != PT_LOAD)
continue;
elf_map(bprm->file, load_addr + vaddr,
elf_ppnt, elf_prot, elf_flags, total_size);
}
/* 7. 동적 링커 로드 (있는 경우) */
if (elf_interpreter) {
elf_entry = load_elf_interp(interp_elf_ex,
interpreter, load_bias);
} else {
elf_entry = elf_ex->e_entry;
}
/* 8. auxv(Auxiliary Vector) 설정 */
create_elf_tables(bprm, elf_ex, interp_load_addr,
elf_entry, randomize_stack_top(STACK_TOP));
/* 9. 새 프로그램 진입점으로 점프 */
START_THREAD(regs, elf_entry, bprm->p);
return 0;
}
코드 설명
- 9~13행ELF 매직 넘버(
\\x7fELF)와 타입(ET_EXEC=실행 파일,ET_DYN=공유 라이브러리/PIE) 검증을 수행합니다. 일치하지 않으면-ENOEXEC를 반환하고 다음 바이너리 핸들러가 시도됩니다. - 16~18행프로그램 헤더 테이블을 읽습니다. 각
elf_phdr는 세그먼트 타입(PT_LOAD, PT_INTERP 등), 가상 주소, 파일 오프셋, 크기, 권한을 포함합니다. - 21~28행
PT_INTERP세그먼트에서 동적 링커(ld-linux) 경로를 읽습니다. 정적 링크된 바이너리에는 이 세그먼트가 없으며, 이 경우 커널이 직접 진입점을 호출합니다. - 31행
begin_new_exec()는 "point of no return"입니다. 이 호출 이후에는 exec가 실패해도 이전 프로그램으로 돌아갈 수 없습니다. 이전 mm을 해제하고, 시그널 핸들러를 리셋하며, 스레드 그룹의 다른 스레드를 종료합니다. - 34~35행
setup_arg_pages()는 스택의 최종 위치를 설정합니다.randomize_stack_top()은 ASLR(Address Space Layout Randomization)을 적용하여 스택 시작 주소를 무작위화합니다. - 38~43행
PT_LOAD세그먼트를 순회하며elf_map()으로 파일의 코드/데이터 섹션을 가상 메모리에 매핑합니다.mmap()기반으로 동작하며, demand paging이 적용됩니다. - 46~50행동적 링크된 바이너리의 경우 인터프리터(ld-linux)를 별도로 로드하고, 진입점을 인터프리터의
_start로 설정합니다. 인터프리터가 GOT/PLT 재배치를 수행한 후 실제 프로그램의main()을 호출합니다. - 53~54행
create_elf_tables()는 스택에 Auxiliary Vector(AT_PHDR, AT_ENTRY, AT_RANDOM 등)를 설정합니다. 동적 링커와 C 라이브러리가 이 정보를 사용하여 프로그램을 초기화합니다. - 57행
START_THREAD()는 아키텍처별 매크로로, 사용자 공간 레지스터(IP, SP)를 새 프로그램의 진입점과 스택으로 설정합니다.sys_execve()에서 사용자 공간으로 복귀하면 새 프로그램이 실행됩니다.
Point of No Return: begin_new_exec() 호출은 exec 과정에서 가장 중요한 분기점입니다. 이 호출 이전에 오류가 발생하면 -ENOMEM 등을 반환하고 기존 프로그램이 계속 실행됩니다. 그러나 이 호출 이후에는 이전 주소 공간이 이미 파괴되었으므로, 오류 발생 시 프로세스를 SIGKILL로 강제 종료해야 합니다. 이 설계 때문에 커널 개발자들은 begin_new_exec() 이전에 가능한 한 많은 검증을 수행하도록 코드를 구성합니다.
스크립트 실행과 재귀: #!로 시작하는 스크립트 파일의 경우, load_script() 핸들러가 인터프리터 경로를 파싱한 후 bprm->buf를 인터프리터의 헤더로 교체하고 search_binary_handler()를 재호출합니다. 이 재귀를 통해 #!/usr/bin/env python3 같은 다단계 인터프리터도 처리됩니다. 무한 재귀를 방지하기 위해 최대 4회(BINPRM_MAX_RECURSION)로 제한됩니다.
프로세스 추적 & 디버깅 (Process Tracing and Debugging)
프로세스의 생성, 실행, 종료 과정을 추적하고 디버깅하는 것은 성능 분석과 문제 해결의 핵심입니다. 리눅스 커널은 ftrace, perf, strace, bpftrace 등 다양한 도구를 통해 프로세스 이벤트를 관찰할 수 있는 tracepoint를 제공합니다.
ftrace를 이용한 프로세스 이벤트 추적
ftrace의 sched 카테고리 이벤트를 활성화하면 프로세스 생성, exec, 종료를 커널 수준에서 추적할 수 있습니다:
# ftrace: 프로세스 생성 추적
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_process_fork/enable
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_process_exec/enable
echo 1 > /sys/kernel/debug/tracing/events/sched/sched_process_exit/enable
cat /sys/kernel/debug/tracing/trace_pipe
perf를 이용한 프로파일링
perf는 하드웨어/소프트웨어 성능 카운터를 활용하여 컨텍스트 스위칭, CPU 마이그레이션, 스케줄링 지연 등을 정밀하게 분석합니다:
# perf: 컨텍스트 스위칭 프로파일링
perf stat -e context-switches,cpu-migrations,page-faults ./my_program
# perf: 스케줄링 지연 분석
perf sched record -- sleep 5
perf sched latency
perf sched 활용: perf sched latency는 각 태스크의 최대/평균 스케줄링 지연(wakeup에서 실제 실행까지의 시간)을 보여줍니다. perf sched map은 CPU별 태스크 배치를 시각화하며, perf sched timehist는 시간순 스케줄링 이벤트를 출력합니다. RT 태스크의 응답 시간 분석에 특히 유용합니다.
strace를 이용한 시스템 콜 추적
strace는 사용자 공간에서 프로세스의 시스템 콜을 추적합니다. -e trace=process 필터를 사용하면 프로세스 관련 시스템 콜만 볼 수 있습니다:
# strace: 시스템 콜 추적
strace -f -e trace=process -p 1234
# -f: fork된 자식도 추적
# -e trace=process: fork, clone, execve, exit 등
bpftrace를 이용한 실시간 모니터링
bpftrace는 eBPF 기반의 동적 트레이싱 도구로, 커널 tracepoint에 커스텀 로직을 부착하여 실시간으로 프로세스 이벤트를 모니터링할 수 있습니다:
# bpftrace: 프로세스 생성 실시간 모니터링
bpftrace -e 'tracepoint:sched:sched_process_fork {
printf("parent: %s (%d) → child: %d\n",
comm, pid, args->child_pid);
}'
# bpftrace: exec 추적
bpftrace -e 'tracepoint:sched:sched_process_exec {
printf("exec: %s (pid=%d) → %s\n",
comm, pid, str(args->filename));
}'
주요 트레이스포인트 목록
| 트레이스포인트 | 설명 |
|---|---|
sched:sched_process_fork | fork()/clone() 완료 시 발생 |
sched:sched_process_exec | exec() 완료 시 발생 |
sched:sched_process_exit | 프로세스 종료 시 발생 |
sched:sched_switch | 컨텍스트 스위칭 발생 (prev → next) |
sched:sched_wakeup | try_to_wake_up() 호출 시 발생 |
sched:sched_migrate_task | 태스크가 다른 CPU로 마이그레이션 |
signal:signal_generate | 시그널 생성 시 발생 |
signal:signal_deliver | 시그널 전달 시 발생 |
트레이싱 오버헤드: ftrace와 perf의 tracepoint 기반 추적은 매우 낮은 오버헤드로 동작하며, 프로덕션 환경에서도 안전하게 사용할 수 있습니다. 반면 strace는 ptrace 기반으로 모든 시스템 콜마다 두 번의 컨텍스트 스위치가 발생하므로, 성능에 민감한 환경에서는 bpftrace를 대신 사용하는 것이 좋습니다. bpftrace는 커널 내에서 eBPF 프로그램으로 실행되어 컨텍스트 스위치 없이 이벤트를 처리합니다.
참고자료
공식 문서
- Working with the kernel development community — Linux Kernel Documentation — 커널 개발 프로세스 공식 가이드입니다.
- Scheduler — Linux Kernel Documentation — 스케줄러 공식 문서 모음입니다.
- Control Group v2 — Linux Kernel Documentation — cgroup v2의 pids 컨트롤러 및 프로세스 리소스 제한을 설명합니다.
- /proc Filesystem — Linux Kernel Documentation —
/proc/[pid]디렉터리 구조와 프로세스 정보 인터페이스를 설명합니다. - ftrace — Function Tracer — Linux Kernel Documentation — 프로세스 스케줄링 및 컨텍스트 스위치 트레이싱에 사용됩니다.
- pidfd — process file descriptors — Linux Kernel Documentation — PID 재사용 경쟁 조건을 해결하는 pidfd API를 설명합니다.
맨 페이지 (시스템 콜)
- fork(2) — Linux manual page — 프로세스 복제 시스템 콜의 동작과 반환값을 설명합니다.
- clone(2) — Linux manual page — 플래그 기반 프로세스/스레드 생성 시스템 콜입니다.
- clone3(2) — Linux manual page — 확장된 clone 시스템 콜로, 구조체 기반 인자를 사용합니다.
- execve(2) — Linux manual page — 프로세스 이미지 교체 시스템 콜입니다.
- wait(2) / waitpid(2) — Linux manual page — 자식 프로세스 종료 대기 및 상태 수집을 설명합니다.
- exit_group(2) — Linux manual page — 프로세스의 모든 스레드를 종료하는 시스템 콜입니다.
- getpid(2) / getppid(2) — Linux manual page — PID 및 부모 PID 조회 시스템 콜입니다.
- sched_setaffinity(2) — Linux manual page — CPU 친화성(Affinity) 설정 시스템 콜입니다.
- prctl(2) — Linux manual page — 프로세스 속성 제어(이름 설정, Seccomp, 시그널 등)를 설명합니다.
- pidfd_open(2) — Linux manual page — PID 기반 파일 디스크립터 생성 시스템 콜입니다.
- namespaces(7) — Linux manual page — PID 네임스페이스를 포함한 리눅스 네임스페이스 개요입니다.
- credentials(7) — Linux manual page — UID, GID, 프로세스 자격 증명 체계를 설명합니다.
커널 소스 코드
- kernel/fork.c — Bootlin Elixir —
copy_process(),kernel_clone()등 프로세스 생성 핵심 코드입니다. - kernel/exit.c — Bootlin Elixir —
do_exit(),do_group_exit()등 프로세스 종료 코드입니다. - fs/exec.c — Bootlin Elixir —
do_execveat_common(), ELF 바이너리 로딩 코드입니다. - kernel/sched/core.c — Bootlin Elixir —
schedule(),context_switch()등 스케줄러 핵심 코드입니다. - kernel/signal.c — Bootlin Elixir — 시그널 생성 및 전달 메커니즘 코드입니다.
- include/linux/sched.h — Bootlin Elixir —
struct task_struct정의를 포함하는 핵심 헤더입니다. - include/linux/pid.h — Bootlin Elixir —
struct pid, PID 할당 및 해시 구조체 정의입니다. - kernel/pid.c — Bootlin Elixir — PID 네임스페이스별 할당 및 관리 코드입니다.
- mm/memory.c — Bootlin Elixir — Copy-on-Write 페이지 폴트 처리 (
wp_page_copy()) 코드입니다.
주요 참고 글
- The birth of a process (LWN.net) — 프로세스 생성 과정의 커널 내부 동작을 상세히 설명합니다.
- CFS group scheduling (LWN.net) — CFS 그룹 스케줄링의 설계와 구현을 설명합니다.
- Completing the pidfd API (LWN.net) — pidfd 시스템 콜 패밀리의 설계 배경과 구현을 설명합니다.
- clone3() — a new approach to process creation (LWN.net) — clone3 시스템 콜 도입 배경을 설명합니다.
- Namespaces in operation, part 3: PID namespaces (LWN.net) — PID 네임스페이스의 동작 원리를 실습 중심으로 설명합니다.
- Copy-on-write for tasks? (LWN.net) — fork/COW 최적화와 성능 영향을 분석합니다.
- Rethinking vfork() (LWN.net) — vfork의 위험성과 posix_spawn 대안을 논의합니다.
- O(1) scheduler → CFS migration (LWN.net) — O(1) 스케줄러에서 CFS로의 전환 배경을 설명합니다.
- Idle threads and CONFIG_PREEMPT (LWN.net) — idle 프로세스와 선점 모델의 관계를 설명합니다.
- An EEVDF CPU scheduler for Linux (LWN.net) — CFS를 대체하는 EEVDF 스케줄러를 소개합니다.
- The seccomp/sandbox mechanism (LWN.net) — 프로세스 시스템 콜 필터링(Seccomp-BPF)을 설명합니다.
서적 및 심층 자료
- The Linux Kernel Documentation (kernel.org) — 리눅스 커널 공식 문서 최상위 페이지입니다.
- Robert Love, Linux Kernel Development, 3rd Edition — 프로세스 관리, 스케줄링, 시스템 콜 챕터가 핵심입니다.
- Daniel P. Bovet & Marco Cesati, Understanding the Linux Kernel, 3rd Edition — task_struct, 프로세스 전환, 시그널 처리를 깊이 다룹니다.
- Wolfgang Mauerer, Professional Linux Kernel Architecture — 프로세스 생명주기와 스케줄러 내부를 상세히 분석합니다.
관련 문서
프로세스 관리와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.