Linux 커널 내부 동작: CFS 스케줄러·페이지 캐시·VFS·시스템 콜과 운영 트러블슈팅
이 글의 핵심
리눅스는 사용자 공간 애플리케이션과 커널이 시스템 콜로 만나고, 스케줄러·가상 메모리(페이지 캐시·스왑)·VFS·cgroups가 한데 얽힌 운영체제입니다. 이 글은 내부 동작을 개념적으로 짚고, 프로덕션에서 쓰는 한계·관측·장애 패턴까지 연결합니다.
들어가며
운영자와 백엔드 엔지니어가 “왜 이런 증상이 나오지?”를 설명하려면 몇 가지 그림을 머릿속에 두어야 합니다. 사용자 공간의 스레드는 시스템 콜로 커널에 진입하고, 커널은 스케줄러로 CPU 시간을 나눠 주며, 파일 데이터를 페이지 단위로 캐시하고, 메모리가 부족하면 회수와 스왑으로 압력을 흘려보내며, 모든 파일 시스템을 VFS라는 공통 계층 아래에서 다룹니다.
이 글은 명령어 치트시트가 아니라 이 내부 개념을 정확히 맞추고 운영 증상과 연결하는 것을 목표로 합니다. 디스크·inode 이슈는 Linux 디스크 full vs inode와 함께 보시고, 사용자 공간에서의 시스템 콜 관점은 C++ 리눅스 시스템 프로그래밍과 연결하면 이해가 빨라집니다.
용어를 먼저 맞춰 두겠습니다. 사용자 공간(user space)은 애플리케이션과 라이브러리가 비특권 모드로 실행되는 영역이고, 커널 공간(kernel space)은 CPU·메모리·디바이스를 관리하는 특권 모드 영역입니다. 시스템 콜은 사용자 공간이 커널 서비스를 요청하는 공식 경로입니다. 커널의 스케줄링 단위는 태스크(task)이며 보통 스레드 하나에 대응합니다. 프로세스는 주소 공간을 공유하는 스레드 그룹으로 볼 수 있습니다.
CPU 스케줄링
한 CPU 코어는 한 시점에 하나의 스레드만 실행하지만, 커널은 시간 분할로 여러 태스크가 동시에 실행되는 것처럼 보이게 합니다. 스케줄러는 어떤 태스크를 얼마나 오래 실행할지 결정하며, 선점(preemption), CPU별 런큐(runqueue), 우선순위, cgroup 한도와 맞물려 동작합니다.
CFS의 직관
일반 태스크(SCHED_NORMAL)를 위한 CFS(Completely Fair Scheduler)는 가상 실행 시간(vruntime)으로 공정성을 근사합니다. 태스크가 CPU를 쓸수록 vruntime이 증가하고, 스케줄러는 vruntime이 가장 작은 태스크를 다음에 실행합니다. 장기적으로는 모든 태스크의 vruntime이 비슷해지도록, 즉 CPU 시간이 가중치에 비례해 나뉘도록 동작합니다.
가중치는 nice 값과 cgroup의 cpu.weight에서 옵니다. nice가 낮을수록(우선순위가 높을수록) 같은 실제 시간을 써도 vruntime이 덜 늘어나 더 많은 CPU 시간을 받습니다. nice 한 단계 차이는 대략 10% 정도의 CPU 몫 차이가 나도록 가중치 표가 설계되어 있습니다. 이것은 지연 보장이 아니라 비율의 공정성이라는 점이 중요합니다.
CPU마다 CFS 런큐(cfs_rq)가 있고, 실행 가능한 태스크는 vruntime 순으로 레드-블랙 트리에 정렬되어 있습니다. 최솟값을 캐시해 두므로 다음 후보는 O(1)로, 삽입·삭제는 O(log n)으로 처리됩니다.
CFS에서 EEVDF로
Linux 6.6부터 일반 태스크의 스케줄링 알고리즘은 CFS에서 EEVDF(Earliest Eligible Virtual Deadline First)로 바뀌었습니다. vruntime과 가중치 기반의 공정성이라는 틀은 유지되지만, 각 태스크에 “가상 데드라인”을 매겨 받을 몫보다 덜 받은(eligible) 태스크 중 데드라인이 가장 이른 것을 고릅니다. 그 결과 짧게 자주 깨어나는 지연 민감 태스크를 다루는 방식이 CFS의 휴리스틱보다 명확해졌습니다. 커널 소스와 문서에서는 여전히 “CFS”라는 이름과 cfs_rq 같은 구조체 이름이 쓰이므로, 이 글에서도 일반 스케줄러를 CFS로 부르겠습니다.
예전 CFS의 튜닝 파라미터였던 sched_latency_ns와 sched_min_granularity_ns는 5.13부터 /proc/sys에서 debugfs(/sys/kernel/debug/sched/)로 옮겨졌고, EEVDF 이후에는 base_slice_ns가 기본 시간 조각을 정합니다. 인터넷의 오래된 튜닝 글이 말하는 sysctl 경로가 현재 커널에는 없을 수 있다는 점에 주의해야 합니다.
cgroup과 CPU 한도
컨테이너와 systemd 서비스는 CPU cgroup 아래에 묶입니다. cpu.weight는 경쟁이 있을 때 형제 cgroup끼리 CPU를 나누는 비율이고, cpu.max(예: 50000 100000)는 주기(period)마다 쓸 수 있는 CPU 시간의 상한(quota)입니다. 스케줄링은 계층적으로 이뤄지므로, 상위 cgroup이 이미 제한된 상태라면 하위 프로세스는 그 한도를 넘을 수 없습니다. “프로세스 하나만 보면 멀쩡한데 느리다”는 증상에서는 프로세스 트리와 cgroup 트리를 함께 봐야 합니다.
quota를 다 쓴 cgroup은 다음 주기가 시작될 때까지 런큐에서 빠집니다(throttling). 멀티스레드 애플리케이션은 여러 코어에서 동시에 quota를 소모하므로, 평균 CPU 사용률은 한도보다 낮은데도 주기 초반에 quota를 다 써서 나머지 시간 동안 멈추는 일이 생깁니다. cpu.stat의 nr_throttled와 throttled_usec가 늘고 있다면 이 패턴을 의심합니다.
NUMA나 멀티소켓 환경에서는 로드 밸런서가 태스크를 다른 CPU로 옮길 때 캐시 지역성을 잃는 비용이 있어, 균형과 지역성 사이의 트레이드오프가 생깁니다.
실시간·데드라인 클래스
SCHED_FIFO와 SCHED_RR은 고정 우선순위 클래스로, 실행 가능한 실시간 태스크가 있으면 일반 태스크는 실행되지 않습니다. 실시간 태스크가 무한 루프에 빠지면 일반 태스크가 굶게 되므로, 기본 설정은 sched_rt_runtime_us/sched_rt_period_us(기본 0.95초/1초)로 실시간 태스크가 쓸 수 있는 시간을 제한합니다. SCHED_DEADLINE은 런타임·주기·데드라인 파라미터로 지연을 보장하지만 설정이 까다롭습니다. 웹·API 서버의 일반 워크로드는 대부분 CFS 위에서 동작한다고 보면 됩니다.
운영에서의 함정
락 경합, 지나치게 많은 스레드, 바쁜 대기(busy polling)는 문맥 전환을 폭증시켜 CPU를 쓰면서도 실제 처리량은 늘지 않게 만듭니다. vmstat 1의 cs 열이나 pidstat -w로 문맥 전환 빈도를 보고, perf sched로 스케줄링 지연을 확인할 수 있습니다.
메모리: 페이지 캐시·스왑·OOM
리눅스는 가상 메모리를 페이지(x86-64 기본 4KiB) 단위로 관리합니다. 힙·스택 같은 익명(anonymous) 메모리와 파일에 대응하는 파일 기반 페이지는 회수 방식이 다르며, 페이지 캐시는 파일 I/O 성능의 중심입니다.
페이지 캐시(page cache)
일반적인(O_DIRECT가 아닌) 파일 읽기와 쓰기는 페이지 캐시를 거칩니다. 같은 파일 영역을 다시 읽으면 디스크 대신 캐시에서 가져옵니다. 쓰기는 캐시의 페이지를 수정해 더티(dirty) 페이지로 표시한 뒤 바로 반환하고, 실제 디스크 기록은 커널의 writeback 작업(저장 장치별 kworker 스레드, 예전 pdflush는 2.6.32에서 제거됨)이 나중에 처리합니다.
“메모리가 찼다”는 알람이 떠도 실제로는 회수 가능한 캐시가 많아서 정상인 경우가 많습니다. /proc/meminfo의 MemAvailable은 스왑 없이 새로 할당할 수 있는 메모리를 추정한 값이므로 MemFree보다 이 값을 보는 것이 맞습니다. 반대로 캐시 미스가 잦으면 I/O 지연이 커지므로, 성능 문제는 iostat, vmstat, 파일 접근 패턴을 함께 봐야 합니다.
회수와 kswapd
커널은 페이지를 active/inactive LRU 리스트로 관리하며(6.1부터는 선택적으로 MGLRU), 최근에 쓰인 페이지를 남기고 오래된 페이지부터 회수하려 합니다. 여유 메모리가 low 워터마크 아래로 내려가면 백그라운드의 kswapd가 깨어나 high 워터마크까지 회수합니다. 할당 속도가 이보다 빠르면 메모리를 요청한 스레드가 직접 회수(direct reclaim)를 수행하게 되는데, 이 작업은 요청 경로에서 동기적으로 일어나므로 지연 스파이크의 원인이 됩니다. /proc/vmstat의 allocstall 계열 카운터가 늘고 있다면 직접 회수가 일어나고 있다는 뜻입니다.
더티 비율과 writeback
vm.dirty_background_ratio(또는 _bytes)는 더티 페이지가 이 비율을 넘으면 백그라운드 writeback을 시작하는 기준이고, vm.dirty_ratio는 이 비율을 넘으면 쓰기를 하는 프로세스 자신이 writeback을 기다리게(throttle) 되는 기준입니다. 비율의 분모는 전체 RAM이 아니라 회수 가능한 메모리입니다. 메모리가 큰 서버에서 기본 비율을 그대로 두면 수 GB의 더티 페이지가 쌓였다가 한꺼번에 기록되면서 쓰기 스톨이 생길 수 있어, 로그나 배치 워크로드에서는 바이트 기반 값으로 낮게 잡기도 합니다.
스왑과 압력
메모리가 부족하면 커널은 회수를 시도합니다. 깨끗한 파일 페이지는 버리기만 하면 되고(다시 필요하면 파일에서 읽으면 됨), 더티 파일 페이지는 기록한 뒤 버립니다. 익명 페이지는 돌아갈 파일이 없으므로 스왑 영역에 써야만 회수할 수 있습니다. 스왑이 없으면 익명 페이지는 회수할 수 없어, 파일 캐시만 계속 줄어들다가 OOM에 이릅니다.
vm.swappiness는 익명 페이지를 스왑하는 비용과 파일 페이지를 회수하는 비용의 상대적 가중치를 조정합니다(0~200, 기본 60). 데이터베이스처럼 자체 캐시가 큰 워크로드는 스왑 인이 일어나는 순간 지연이 크게 튀므로, 메모리 한도와 스왑 사용량, major fault를 함께 모니터링합니다. 압력의 정도는 PSI(/proc/pressure/memory)로 “메모리 때문에 태스크가 멈춰 있던 시간의 비율”로 직접 확인할 수도 있습니다.
OOM Killer
회수로도 메모리를 마련하지 못하면 OOM killer가 프로세스를 하나 골라 종료합니다. 선택 기준은 /proc/<pid>/oom_score로, 주로 해당 프로세스가 쓰는 메모리(RSS, 스왑, 페이지 테이블)에 비례하며 oom_score_adj(-1000~1000)로 조정할 수 있습니다. cgroup의 memory.max에 걸린 경우에는 시스템 전체가 아니라 그 cgroup 안에서 희생자를 고르므로, 노드에는 메모리가 남아 있는데 컨테이너만 OOM으로 죽는 상황이 생깁니다. 어느 쪽이든 dmesg나 journalctl -k에 기록이 남습니다.
운영 시 주의점
drop_caches는 벤치마크나 디버깅에서 캐시를 비울 때 쓰지만, 직후 모든 읽기가 디스크로 가므로 서비스 지연을 유발합니다. “메모리가 부족해 보여서” 주기적으로 실행하는 것은 대개 MemAvailable을 잘못 읽은 결과입니다. vm.min_free_kbytes를 올리면 워터마크가 올라가 할당 실패와 직접 회수를 줄일 수 있지만, 너무 크게 잡으면 그만큼 메모리를 쓰지 못하게 되고 회수가 더 일찍 시작됩니다.
파일 시스템 스택과 VFS
애플리케이션의 open/read/write는 먼저 VFS(가상 파일 시스템) 계층으로 들어갑니다. VFS는 ext4, XFS, NFS, overlayfs, procfs 같은 구체 파일 시스템 위에 공통 인터페이스를 제공합니다.
dentry와 inode 캐시
inode는 파일 하나의 메타데이터(권한, 크기, 타임스탬프)와 데이터 블록 위치(익스텐트 등)를 담는 객체입니다. dentry(directory entry)는 경로의 각 구성 요소(usr, bin 등)를 inode에 연결하며, 커널은 이를 dentry 캐시에 보관해 경로 해석 결과를 재사용합니다. 존재하지 않는 경로를 조회한 결과도 negative dentry로 캐시됩니다.
자주 접근하는 경로는 dentry 캐시에 남아 반복적인 stat/open 비용이 줄어듭니다. 반대로 수백만 개의 작은 파일을 훑는 워크로드는 dentry·inode 캐시를 크게 늘려 slab 메모리를 차지하고, 디스크 쪽에서는 inode 고갈과 맞물리기도 합니다.
VFS 객체: struct file과 file_operations
open()이 성공하면 프로세스의 FD 테이블 항목이 struct file을 가리키게 됩니다. 이 객체에는 현재 파일 오프셋, 열 때의 플래그(O_APPEND, O_NONBLOCK 등), inode에 대한 참조, 그리고 파일 시스템별 연산 테이블(f_op)이 들어 있습니다. read/write/mmap은 f_op를 통해 ext4·XFS·tmpfs 같은 실제 구현으로 디스패치됩니다. 그래서 같은 read(2)라도 대상이 블록 장치 위의 파일인지, 네트워크 파일 시스템인지, 파이프나 소켓인지에 따라 커널 내부 경로가 완전히 달라집니다. fork()한 자식이 같은 struct file을 공유하므로 오프셋도 공유된다는 점은 이 구조에서 나옵니다.
경로 탐색(path walk)과 RCU
경로 이름을 inode까지 풀어내는 과정을 path walk라고 합니다. 커널은 먼저 락을 잡지 않는 RCU-walk 모드로 dentry 캐시만 따라가 보고, 캐시 미스나 경합이 있으면 참조 카운트를 잡는 ref-walk 모드로 전환합니다. 캐시 미스가 나면 구체 파일 시스템이 디렉터리 블록을 읽어야 하므로 그만큼 I/O가 발생합니다. 메타데이터를 대량으로 다루는 워크로드(빌드 시스템, 백업, 크롤러)에서 CPU와 I/O가 함께 높아지는 이유입니다.
페이지 캐시와 블록 계층
파일 시스템은 페이지 캐시의 내용을 블록 I/O 요청으로 만들어 블록 계층(I/O 스케줄러, 다중 큐)을 거쳐 스토리지 드라이버로 넘깁니다. write()가 반환됐다고 데이터가 디스크에 있는 것은 아니며, 내구성이 필요하면 fsync()나 fdatasync()로 명시적으로 기록을 기다려야 합니다. 데이터베이스와 분산 스토리지가 커밋마다 fsync 비용을 어떻게 관리하는지가 성능과 내구성의 트레이드오프를 결정합니다.
네트워크 파일 시스템과 컨테이너
NFS나 FUSE 기반 파일 시스템은 VFS 아래에 네트워크 왕복과 사용자 공간 왕복을 더하므로, 메타데이터 연산 하나하나가 로컬보다 훨씬 비쌉니다. 캐시 일관성도 로컬 파일 시스템보다 약합니다. overlayfs 기반 컨테이너에서는 하위 레이어의 파일을 처음 수정할 때 파일 전체가 상위 레이어로 복사(copy-up)되므로, 큰 파일을 조금씩 수정하는 워크로드에서 예상치 못한 I/O가 생길 수 있습니다. “로컬에선 빠른데 컨테이너에서만 느리다”는 증상에서는 이 스택을 의심합니다.
시스템 콜 인터페이스
x86-64에서 시스템 콜은 syscall 명령으로 진입합니다. 번호는 rax에, 인자는 rdi, rsi, rdx, r10, r8, r9에 넣고, 커널은 시스템 콜 테이블에서 해당 핸들러를 찾아 실행한 뒤 결과를 rax로 돌려줍니다. 실패하면 커널은 음수 에러 코드를 반환하고, libc 래퍼가 이를 -1과 errno로 바꿉니다. 32비트 시절의 int 0x80이나 고정 주소의 vsyscall 페이지는 호환성 때문에 남아 있을 뿐, 현재 64비트 사용자 공간의 주 경로는 syscall 명령입니다.
시스템 콜은 같은 스레드가 사용자 모드에서 커널 모드로 전환하는 것이므로 다른 스레드로 바뀌는 문맥 전환과는 다르지만, 모드 전환과 Spectre/Meltdown 완화 조치 때문에 비용이 무시할 수준은 아닙니다. vDSO는 clock_gettime, gettimeofday 같은 일부 호출을 커널이 사용자 공간에 매핑해 둔 코드로 처리해 모드 전환 자체를 없앱니다.
진입과 복귀 경로에는 syscalls:sys_enter_*/sys_exit_* 트레이스포인트가 있어 perf와 eBPF로 관찰하기 좋습니다.
관찰과 디버깅
strace -c -p PID는 어떤 시스템 콜이 몇 번 호출됐고 얼마나 실패했는지 요약해 줍니다. 다만 ptrace 기반이라 대상 프로세스를 크게 느리게 만들므로 운영 중에는 짧게만 씁니다. perf trace는 같은 정보를 훨씬 적은 오버헤드로 보여 주고, eBPF 도구(bpftrace, bcc의 opensnoop, execsnoop 등)는 execve, connect, openat 같은 특정 호출을 필터링하거나 지연 분포를 집계할 수 있습니다.
seccomp와 컨테이너
컨테이너 런타임은 seccomp 프로파일로 허용할 시스템 콜 집합을 줄여 공격면을 낮춥니다. Docker의 기본 프로파일은 허용되지 않은 호출에 대해 EPERM을 반환하도록 되어 있어, 애플리케이션 입장에서는 “권한 없음” 에러로 보입니다. 프로파일에 따라 프로세스를 즉시 종료(SCMP_ACT_KILL)하게 할 수도 있습니다. 새 라이브러리나 런타임 버전이 예상 밖의 시스템 콜(예: 새로운 clone3, io_uring 계열)을 쓰기 시작하면 컨테이너에서만 실패하는 문제가 생기므로, 배포 파이프라인에서 프로파일을 함께 검증하는 것이 좋습니다.
프로덕션 Linux 패턴
cgroups v2와 systemd
cgroups v2는 CPU·메모리·I/O·PID 컨트롤러를 하나의 계층 트리로 묶습니다. systemd는 서비스와 슬라이스마다 cgroup을 만들고, 유닛 파일의 CPUQuota=, MemoryMax= 같은 설정을 cgroup 파일에 반영합니다. 프로세스는 살아 있는데 느리다면 CPU throttling을, 갑자기 죽는다면 메모리 한도와 OOM을 먼저 의심합니다.
메모리: memory.max와 memory.high
memory.max는 하드 리밋입니다. 사용량이 한도에 닿으면 커널이 그 cgroup 안에서 회수를 시도하고, 그래도 부족하면 cgroup 안의 프로세스를 OOM kill합니다. memory.high는 스로틀 기준으로, 이를 넘으면 커널이 할당하는 태스크를 지연시키며 적극적으로 회수하지만 OOM kill은 하지 않습니다. 그래서 memory.high를 memory.max보다 조금 낮게 두면 죽기 전에 먼저 느려지는 단계를 만들 수 있습니다. Kubernetes의 메모리 limit은 컨테이너 cgroup의 memory.max로 설정되므로, 노드에는 여유가 있어도 파드가 OOMKilled되는 상황이 이 구조에서 나옵니다.
PID 한도
포크 폭탄이나 스레드 누수가 생기면 pids 컨트롤러의 pids.max에 걸려 fork()/clone()이 EAGAIN으로 실패합니다. 애플리케이션에서는 “Resource temporarily unavailable”이나 스레드 생성 실패로 나타나 원인을 바로 알아보기 어렵습니다. pids.current와 pids.events를 확인하면 한도에 걸렸는지 알 수 있습니다.
리소스 한계
ulimit -n(RLIMIT_NOFILE)은 프로세스당 열 수 있는 파일 디스크립터 수로, 리버스 프록시나 커넥션이 많은 서버에서 자주 부딪힙니다. systemd 서비스는 셸의 ulimit이 아니라 유닛의 LimitNOFILE=을 따릅니다. fs.file-max는 시스템 전체 상한입니다. net.core.somaxconn이나 net.ipv4.tcp_tw_reuse 같은 네트워크 파라미터는 커널 버전마다 기본값과 의미가 달라졌으므로, 다른 곳의 설정을 그대로 복사하기보다 측정 후 조정해야 합니다.
관측
CPU는 사용률뿐 아니라 포화도(런큐 대기, PSI의 cpu 압력, throttling)를 함께 봐야 하고, 메모리는 MemAvailable과 major fault, 디스크는 사용률과 대기 시간, 네트워크는 재전송을 봅니다. journalctl로 부팅·크래시·OOM의 시간대를 맞추고, perf나 eBPF로 커널과 사용자 공간 스택을 함께 프로파일링하면 원인을 좁히기 쉽습니다.
증상별 진단 출발점
장애가 나면 먼저 CPU·메모리·디스크·네트워크 중 어디가 병목인지 나누고, cgroup·ulimit·클라우드 인스턴스 크레딧·Kubernetes limit 같은 한도에 걸렸는지 확인한 뒤, dmesg에서 OOM이나 파일 시스템 에러를 보고, 최근 배포나 커널 업데이트 같은 변경 이력을 대조하는 순서가 일반적입니다. 아래 표는 흔한 증상별 출발점입니다.
| 증상 | 살펴볼 내부 지점 | 시작 명령 |
|---|---|---|
전체적으로 느리고 st 값이 높음 | 하이퍼바이저의 CPU 스틸 | vmstat 1, 호스트·인스턴스 크레딧 |
| 평균 CPU는 낮은데 응답이 끊김 | cgroup CPU throttling | cpu.stat의 nr_throttled |
| 메모리는 남는데 앱이 느림 | 페이지 캐시 미스·I/O 대기 | iostat -xz 1, 파일 접근 패턴 |
| 스왑 사용 증가, 지연 스파이크 | 회수·스왑 압력 | vmstat 1(si/so), /proc/pressure/memory |
| 파일 열기·stat 폭주 | dentry/inode 캐시·메타데이터 | strace -c, slabtop |
| 컨테이너만 OOM | cgroup memory.max | memory.events, dmesg |
이 글이 다루지 않은 서버 보안
이 글은 커널 내부 구조를 중심으로 다뤘기 때문에 SSH 설정, 방화벽, 권한 최소화 같은 운영 보안은 거의 다루지 않았습니다. 그 부분은 Linux 서버 보안 강화(영문)에서 이어서 볼 수 있습니다.