왜 CPU 과부하와 메모리 부족(OOM)을 먼저 잡아야 할까
서버 장애의 상당수는 리소스 고갈에서 시작합니다. CPU가 과부하 상태로 장시간 머물거나, 메모리가 부족해 OOM(Out-Of-Memory) 킬러가 작동하면 서비스 지연, 요청 타임아웃, 프로세스 크래시가 연쇄적으로 발생합니다. 특히 컨테이너 기반 인프라(Kubernetes)에서는 단일 파드의 리미트 초과가 빠르게 전체 워크로드에 파급될 수 있죠.
이 글에서는 운영자가 실전에 바로 적용할 수 있도록 다음을 다룹니다.
- 무엇을 어떻게 모니터링해야 하는가(핵심 지표, 해석법, 임계치)
- CLI/에이전트/클라우드 기반 도구로 빠르게 진단하는 방법
- Prometheus/Grafana와 같은 모니터링 스택에서 쓸 수 있는 구체적 알림 규칙
- 컨테이너·Kubernetes·systemd/cgroup에서 리소스 고갈을 방지하는 설정과 패턴
- CPU 과부하와 OOM 상황에서의 즉각 대응(runbook)과 장기적 예방 전략
CPU 모니터링: 단순 “사용률”을 넘어 병목을 읽어라
CPU는 단순히 “% 사용률”만 보면 오판하기 쉽습니다. 다음 지표를 함께 보고 상황을 입체적으로 파악하세요.
핵심 지표
- 총 CPU 사용률 및 모드별 분포
- user, system, iowait, steal, softirq/irq
- iowait가 높다면 디스크/네트워크 I/O 병목일 가능성
- steal이 높은 VM 환경은 하이퍼바이저 레벨 스케줄링 경쟁 신호
- Load Average(1/5/15분)
- 단순 CPU 사용률이 아니라 “실행 대기 중인 태스크”까지 포함된 혼잡도의 근사치
- 일반적으로 “코어 수당 Load 1.0 초과”가 지속되면 과부하 신호
- 런큐 길이(run queue length)
- 실행 가능한 태스크가 CPU 코어 수보다 많으면 대기가 발생
- 컨텍스트 스위치/인터럽트
- 초당 컨텍스트 스위치가 과도하게 증가하면 스레드 폭증, 락 경합 등의 신호
- CPU 쓰로틀링/주파수 하락
- 노트북/클라우드 환경에서 열/전력 제한으로 실효 성능 급락 가능
- PSI(Pressure Stall Information) - CPU
- /proc/pressure/cpu에서 태스크가 CPU를 기다리느라 멈춘 시간 비율
- 순간 스파이크보다 avg10(10초 평균), avg60(1분 평균)을 보세요
실무 임계치 가이드(초기 기준)
- 5분 평균 CPU 사용률 > 85%가 10분 이상 지속
- Load Average/코어 수 > 1.0이 10분 이상 지속
- iowait > 15~20% 지속 → CPU가 아니라 I/O 병목 의심
- steal > 5% 지속(가상화 환경) → 상위 호스트 리소스 경쟁
- PSI cpu avg10 > 0.2(즉, 10초 동안 20% 시간 대기) 이상 지속 시 과부하 의심
이 수치는 절대값이 아니라 “초기 탐지 기준”입니다. 업무 특성/시스템 구조에 따라 베이스라인을 측정해 조정하세요.
메모리 모니터링: “Free”가 아니라 “Available”을 보라
Linux는 사용 가능한 메모리를 페이지 캐시로 적극 활용합니다. 그래서 free 메모리가 낮아도 정상일 수 있습니다. 진짜 중요한 건 “애플리케이션이 추가 메모리를 필요로 할 때 즉시 쓸 수 있는 여유(Available)”와 “스왑/페이지 폴트/압박(PSI)”입니다.
핵심 지표
- MemAvailable
- /proc/meminfo의 MemAvailable 또는 node_exporter의 node_memory_MemAvailable_bytes
- free가 아닌 available을 사용해야 정확
- 워킹셋(Working Set)
- container_memory_working_set_bytes(컨테이너): 실제 활성 사용량
- RSS(Resident Set Size) vs 캐시/버퍼 구분 필수
- 페이지 폴트
- major/minor page faults: major가 급증하면 페이지 재로딩 비용 급증
- 스왑 사용량/스왑 인/아웃
- swap이 빠르게 증가하면 지연이 급증, CPU iowait 상승과 연동
- PSI memory
- /proc/pressure/memory: 메모리 대기/스톨 시간을 보여주는 궁극 지표
- OOM 신호
- 커널 로그(dmesg -T | grep -i oom), Kubernetes 이벤트(OOMKilled), systemd-oomd
- 캐시 회수(reclaim) 속도
- 페이지 캐시가 공격적으로 회수되는지, slab 캐시가 비정상적으로 큰지(slabtop)
실무 임계치 가이드(초기 기준)
- MemAvailable < 총 메모리의 10% 또는 < 512MB가 5~10분 지속
- PSI memory avg10 > 0.1 이상 지속(메모리 압박) → avg60 추이도 확인
- major page faults 초당 수가 베이스라인 대비 3배 이상 급증
- swap in/out이 초당 수천 페이지 이상으로 확대
- 컨테이너 워킹셋이 limit의 90% 이상에서 5분 이상 유지
- OOM 발생 카운트 > 0 → 즉시 경고(즉시 대응)
현장에서 바로 쓰는 CLI 진단 레시피
다음 명령은 장애 대응 시 1차 스크리닝용으로 매우 유용합니다.
# CPU 혼잡도
uptime # load average 확인
mpstat -P ALL 1 # 코어별 사용률 및 iowait, steal
pidstat -u 1 # 프로세스별 CPU 사용률
top -H -p <PID> # 프로세스 스레드별 CPU 사용률
perf top # CPU 핫스팟 함수 파악(루트 권한)
# 런큐/컨텍스트 스위치
vmstat 1 # r(런큐), cs(컨텍스트 스위치), si/so(swap in/out)
# 메모리/스왑/페이지 폴트
free -h # MemAvailable 확인(최근 배포판)
cat /proc/meminfo | egrep 'MemAvailable|MemFree|Buffers|Cached'
vmstat -s | egrep 'pgmajfault|pswpin|pswpout'
smem -r | head # 프로세스별 RSS/PPSS 등
pmap -x <PID> | tail # 프로세스 메모리 상세
# PSI(Pressure Stall Information)
cat /proc/pressure/cpu
cat /proc/pressure/memory
cat /proc/pressure/io
# OOM / 커널 로그
dmesg -T | egrep -i 'oom|out of memory'
journalctl -k -g OOM --since "1 hour ago"
# I/O 병목 확인
iostat -x 1 # 디스크 대기시간, util
iotop # 프로세스별 I/O
컨테이너/Kubernetes 환경에서는 다음도 함께:
# 컨테이너별 메모리/CPU
docker stats
crictl stats
# Kubernetes 파드 자원
kubectl top pods -A
kubectl describe pod <pod> | egrep 'CPU|Memory|OOMKilled'
kubectl get events -A | grep -i oom
# cgroup v2에서 파드 제한 확인
cat /sys/fs/cgroup/<...>/memory.max
cat /sys/fs/cgroup/<...>/memory.current
Prometheus/Grafana로 구현하는 실전 모니터링
수집해야 할 대표 메트릭
- 노드/OS
- node_cpu_seconds_total, node_load1/5/15
- node_memory_MemAvailable_bytes, node_memory_SwapFree_bytes
- node_vmstat_pgmajfault, node_vmstat_pswpin, node_vmstat_pswpout
- node_pressure_cpu_waiting_seconds_total
- node_pressure_memory_stalled_seconds_total
- node_disk_io_time_seconds_total, node_disk_io_time_weighted_seconds_total
- 컨테이너/Kubernetes(cAdvisor, kube-state-metrics)
- container_cpu_usage_seconds_total
- container_memory_working_set_bytes
- container_spec_memory_limit_bytes
- kube_pod_container_status_last_terminated_reason
- kube_pod_container_resource_requests/limits
주의: node_exporter 버전에 따라 PSI 지표 이름이 다를 수 있습니다. 최신 버전에서는 pressure_seconds_total 형태로 노출됩니다. 대시보드에서 실제 노출 이름을 확인하세요.
예시 알림 규칙(Alertmanager용)
groups:
- name: cpu.memory.alerts
rules:
- alert: HighCpuSustained
expr: avg(rate(node_cpu_seconds_total{mode!="idle"}[5m])) by (instance) > 0.85
for: 10m
labels:
severity: warning
annotations:
summary: 인스턴스 {{ $labels.instance }} CPU 과부하
description: 5분 평균 CPU 사용률이 85% 초과 상태가 10분 이상 지속 중.
- alert: HighLoadPerCore
expr: (node_load5 / count(count(node_cpu_seconds_total{mode="idle"}) by (cpu,instance)) by (instance)) > 1.0
for: 10m
labels:
severity: warning
annotations:
summary: {{ $labels.instance }} Load/코어 과다
description: 5분 평균 Load/코어가 1.0 초과.
- alert: HighIOWait
expr: avg(rate(node_cpu_seconds_total{mode="iowait"}[5m])) by (instance) > 0.2
for: 10m
labels:
severity: warning
annotations:
summary: {{ $labels.instance }} I/O 대기 과다
description: CPU가 I/O를 기다리느라 바쁨. 디스크/네트워크 병목 검토 필요.
- alert: LowMemAvailable
expr: (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) < 0.1
for: 5m
labels:
severity: critical
annotations:
summary: {{ $labels.instance }} 메모리 가용량 부족
description: MemAvailable이 10% 미만.
- alert: HighMemoryPressure
expr: rate(node_pressure_memory_stalled_seconds_total{scope="some"}[1m]) > 0.1
for: 5m
labels:
severity: critical
annotations:
summary: {{ $labels.instance }} 메모리 압박(PSI) 발생
description: 최근 1분간 메모리 스톨 비율이 높음.
- alert: ContainerNearMemLimit
expr: (container_memory_working_set_bytes / container_spec_memory_limit_bytes) > 0.9
and on (container, pod, namespace) container_spec_memory_limit_bytes > 0
for: 5m
labels:
severity: warning
annotations:
summary: 컨테이너 메모리 제한 임박
description: 워킹셋이 리미트의 90% 초과.
- alert: OOMKilledDetected
expr: increase(kube_pod_container_status_terminated_reason{reason="OOMKilled"}[10m]) > 0
for: 0m
labels:
severity: critical
annotations:
summary: OOMKilled 발생
description: 최근 10분 내 OOMKilled 이벤트 감지.
PSI 메트릭은 환경별 라벨/이름이 다르므로, 대시보드에서 실제 시계열을 선택해 expr를 조정하세요.
Kubernetes/컨테이너에서 꼭 해야 할 설정
Requests/Limits로 QoS 계층 제어
- requests는 스케줄링과 HPA의 기준, limits는 실제 상한선입니다.
- requests를 실제 평균 사용량보다 약간 높게, limits는 피크 대비 20~30% 버퍼를 남기는 것이 일반적입니다.
- QoS 클래스
- Guaranteed: 리소스 예측성 높음(요청=리미트)
- Burstable: 기본값, 변동성 있음
- BestEffort: 압박 시 가장 먼저 축출(eviction)
OOMKilled와 Eviction 구분
- OOMKilled: 컨테이너가 cgroup 메모리 제한 초과로 커널에 의해 종료
- Eviction: 노드 레벨 메모리 압박 시 Kubelet이 우선순위에 따라 파드 축출
둘다 경고와 원인 분석이 필요합니다. kubelet 로그, 이벤트를 반드시 수집하세요.
HPA/VPA/클러스터 오토스케일러
- HPA: CPU, 메모리, 큐 길이(커스텀 메트릭) 기반으로 수평 확장
- VPA: 요청/리미트를 자동 추천/갱신(주의: 급변하는 트래픽에 대한 민감도 조절)
- 클러스터 오토스케일러: 노드 증설/축소로 리소스 압박 완화
큐 기반 워크로드(예: 소비자/워커)는 CPU 대신 “큐 지연/깊이” 메트릭으로 HPA를 트리거하면 더 안정적입니다.
systemd/cgroup로 안전장치 만들기(베어메탈/VM)
systemd 서비스 단위에 자원 상한을 명시해 프로세스 오폭주가 시스템 전체에 영향을 주지 않도록 합니다.
# /etc/systemd/system/myapp.service
[Service]
ExecStart=/usr/local/bin/myapp
# CPU 제한: 200% = 2 vCPU 상한
CPUQuota=200%
# 메모리 상한 및 스왑 차단
MemoryMax=4G
MemorySwapMax=0
# OOM 시 즉시 재시작
Restart=on-failure
RestartSec=5
[Unit]
StartLimitIntervalSec=0
cgroup v2에서는 메모리 압박 신호와 PSI를 연동해 systemd-oomd가 선제적으로 프로세스를 정리할 수 있습니다. 고가용성 서비스에는 영향이 적은 백그라운드 잡부터 타겟팅하도록 Slice/Unit를 분리하세요.
애플리케이션 레벨에서의 예방: 패턴과 튜닝
공통 패턴
- 동시성 상한 설정: 요청 처리 워커 수, 스레드/고루틴/이벤트루프 큐에 상한을 두기
- 백프레셔와 대기열 보호: 큐 길이 초과 시 즉시 거절(HTTP 429) 또는 지수 백오프
- 연결/리소스 풀 튜닝: DB 커넥션 풀 최대치 제한, keepalive/타임아웃 균형
- 캐시 전략: 핫 키 캐시, 캐시 폭주 방지(single-flight), 가비지 데이터 청소
- 메모리 안전 가드: 대용량 업로드/버퍼에 하드 리미트, 스트리밍 처리
- 타임아웃/서킷 브레이커: 느린 하류 의존성으로 인한 스레드 고갈 방지
- 메모리 프로파일링과 릭 탐지: 정기 CI/CD에 포함
언어/런타임별 힌트
- JVM
- -Xms/-Xmx의 일관성, GC 로그 활성화, 메타스페이스/스레드 스택 고려
- 컨테이너에서는 -XX:MaxRAMPercentage, -XX:InitialRAMPercentage 활용
- G1/Parallel/ ZGC 선택은 힙 크기와 지연 목표에 맞춰 결정
- Go
- GOMAXPROCS를 코어 수/CPU쿼터에 맞게 조정
- GOMEMLIMIT로 컨테이너 메모리 상한 인지(Go 1.19+)
- pprof/allocs/heapprof로 워킹셋과 릭 점검
- Node.js
- --max-old-space-size로 힙 상한 조정
- heap snapshots/clinic.js로 릭 분석
- Python
- tracemalloc, objgraph로 오브젝트 증가 추적
- 멀티프로세싱/프리포킹 모델로 GIL 우회
I/O 병목이 CPU/메모리 지표에 미치는 영향
- 높은 iowait는 종종 CPU 과부하처럼 보이지만, 디스크나 네트워크 병목일 수 있습니다.
- 페이지 캐시 적중률 저하는 major page faults 증가 → 지연 급증
- 대안
- 디스크: NVMe로 전환, 큐 심도 튜닝, 파일시스템 마운트 옵션 재검토
- 네트워크: RPS/RFS, 소켓 버퍼, 커넥션 풀 최적화
- 애플리케이션: 배치 I/O, 스트리밍, 버퍼 크기 최적화
실전 시나리오 1: CPU 과부하 폭주 대응
- 증상
- 응답 시간 급증, 5xx 증가, Load/코어가 2.0 상회, iowait는 낮음
- 즉각 조치
- 트래픽 조절: API 게이트웨이에서 rate limit/서킷 브레이커 활성화
- 배포 롤백 또는 핫픽스 디스에이블(문제 기능 토글)
- 스케일 아웃: HPA 수동 상향 또는 추가 인스턴스 기동
- 진단
- pidstat/top으로 CPU 상위 프로세스/스레드 파악
- perf top/flamegraph로 핫스팟 함수 추적(정렬/정규식/JSON 파싱 폭주 등)
- 런큐와 컨텍스트 스위치가 비정상적으로 높다면 락 경합/스레드 과다 의심
- 근본 원인 및 개선
- N+1 쿼리, 잘못된 캐시 무효화 → 캐시 계층 도입/수정
- 정렬/압축 등 CPU 집약 작업을 백그라운드로 분리
- 동시성 상한/큐 길이 상한 설정
- JIT/GC 튜닝, 알고리즘/데이터 구조 개선
실전 시나리오 2: 메모리 부족(OOM) 발생 대응
- 증상
- 파드가 OOMKilled, dmesg에 Out of memory: Kill process 로그
- PSI memory avg10이 상승, major faults 급증, swap in/out 증가
- 즉각 조치
- 문제 파드 재시작, 레플리카 임시 증설로 트래픽 분산
- 임계 트래픽을 줄이기 위해 캐시 TTL 상향, 비핵심 기능 제한
- 노드 메모리 압박이면 우선순위 낮은 잡 일시 중지/축출
- 진단
- 컨테이너 워킹셋과 limit 대비 비율 분석
- heapprof, pprof, JVM heap dump로 릭 여부 확인
- 특정 요청/작업에서 대용량 버퍼/배치로 튀는지 추적
- 근본 원인 및 개선
- 릭: DoS성 누수(맵/리스트 축적), 파일 핸들 미해제 → 릴리즈/스코프 관리
- 단일 요청 메모리 폭주 → 스트리밍 처리/청크 분할/하드 리미트 적용
- 캐시 전략 개선(크기 제한, eviction 정책, 비율 기반)
- Kubernetes 리퀘스트/리미트 재산정, VPA 도입
- systemd MemoryMax, MemorySwapMax=0로 스왑 의존 제거
경고 튜닝: “소음” 줄이고 “의미” 키우기
- 시간 윈도우 적용: 1분 스파이크 대신 5~10분 지속 시 경고
- 지표 조합: CPU >85% AND Load/코어 >1.0 같이 복합 조건
- 베이스라인 학습: 낮/밤 패턴, 배치 시간대 제외 룰
- 중복 억제와 합리적 라우팅: 서비스/팀별 라벨로 소유권 명확화
- 경고 → 런북 링크: 알림에 즉시 실행 명령과 대시보드 URL 첨부
대시보드 설계 체크리스트
- 노드 패널
- CPU 사용률(모드별), Load/코어, 런큐
- PSI(cpu/memory/io) avg10/avg60
- MemAvailable, 워킹셋, swap in/out, major faults
- 디스크 레이턴시, 네트워크 대역폭/에러
- 워크로드/네임스페이스별 패널
- 컨테이너 워킹셋 vs limit, CPU 사용률 vs quota
- OOMKilled 이벤트, 재시작 횟수, 스로틀링 비율
- 트레이싱/프로파일링 연동
- 레이턴시 p95/p99, 에러율과 리소스 지표를 같은 시간축으로 비교
- 지속적으로 핫스팟을 추적할 수 있는 프로파일링 패널(pprof, eBPF)
사전 예방: 용량 계획과 부하 테스트
- 용량 계획
- 피크 트래픽 대비 여유율 30% 이상 확보(업무 특성에 맞게 조정)
- 신규 기능/릴리스 전 리소스 임팩트 추정
- 부하 테스트
- k6, JMeter, wrk, vegeta로 p95/p99, 에러율, CPU/메모리/PSI 반응 관찰
- 실제와 유사한 데이터/시나리오, 캐시 워밍/콜드 스타트 모두 평가
- 카나리/점진적 롤아웃
- 새로운 코드가 리소스 곡선을 바꾸는지 작은 트래픽으로 먼저 검증
스왑과 zram: 써야 할까?
- 지연에 민감한 온라인 서비스
- swap을 최소화하거나 비활성화, zram으로 제한된 완충만 제공
- 배치/백그라운드 워크로드
- 제한적 스왑 허용으로 OOM 대신 성능 저하 선택 가능
- 핵심은 “의도된 정책”
- vm.swappiness, MemorySwapMax 조절로 일관된 동작 보장
운영 체크리스트(런북 템플릿)
- 경고 수신 시 즉각 확인
- 어떤 알림(지표/임계치/기간)인가?
- 해당 인스턴스/파드/네임스페이스는?
- 영향 범위 파악
- 에러율/지연 p95/p99 증가?
- 특정 엔드포인트/잡/릴리스와 연관?
- 1차 완화
- 트래픽 제한, 비핵심 기능 비활성화, 스케일 아웃
- 문제 파드/인스턴스 재시작(장애 범위를 줄이기 위해 롤링)
- 근본 원인 분석
- CPU: 핫스팟 코드, 스레드/락, JIT/GC 영향
- 메모리: 릭, 워킹셋 점프, OOM 로그, PSI 추세
- I/O: iowait, 디스크 레이턴시, 네트워크 병목
- 사후 조치
- 알림 임계치/대시보드 보완
- 테스트 케이스 추가(부하/프로파일링)
- 리소스 한도/오토스케일 정책 업데이트
작은 설정이 큰 차이를 만든다: 실용 예시 모음
Prometheus 레이블링으로 소유권 명확화
- instance, job 외에 service, team 라벨을 추가해 알림 라우팅/대시보드 필터에 활용
- Kubernetes에서는 namespace/owner(kind, name) 라벨을 프로메테우스 리레이블링으로 주입
NGINX/리버스 프록시로 백프레셔 구현
# 대기열 상한과 타임아웃으로 애플리케이션 보호
proxy_connect_timeout 2s;
proxy_read_timeout 10s;
keepalive_requests 1000;
# 동시 연결/요청 제한(예: limit_req_zone)
limit_req_zone $binary_remote_addr zone=req:10m rate=100r/s;
limit_req zone=req burst=50 nodelay;
애플리케이션 동시성 상한(예: Node.js)
// 간단한 세마포어로 동시 처리 제한
const pLimit = require('p-limit');
const limit = pLimit(50); // 동시 50개
app.post('/heavy', (req, res) => {
limit(() => heavyWork(req.body))
.then(result => res.send(result))
.catch(err => res.status(503).send('Busy'));
});
Go 서비스 메모리 상한 인지
export GOMEMLIMIT=3GiB # Go 1.19+
export GOMAXPROCS=2 # CPUQuota에 맞춤
흔한 함정과 오해
- “free 메모리=좋음, 낮으면 나쁨” → 오해. Linux는 캐시를 적극 사용하므로 MemAvailable을 보자.
- “CPU 100%는 항상 문제” → 계산 집약적인 배치엔 정상일 수 있다. 레이턴시/런큐/PSI와 함께 판단.
- “스왑은 무조건 나쁘다” → 워크로드 성격에 따라 제한적 스왑이 OOM보다 안전할 수 있다.
- “알림은 많을수록 좋다” → 과다 알림은 무시를 부른다. 지속성/복합 조건/베이스라인으로 정밀화.
결론: 관측, 제한, 완화 — 세 박자를 갖춰라
CPU 과부하와 메모리 부족은 단일 지표로 판단하기 어렵습니다. 하지만 다음 3가지를 꾸준히 실천하면 리스크를 크게 낮출 수 있습니다.
- 관측: CPU 모드 분해, Load/런큐, PSI, MemAvailable/워킹셋/페이지 폴트, OOM 이벤트까지 전방위 수집과 대시보드화
- 제한: Kubernetes Requests/Limits, systemd cgroup 상한, 애플리케이션 동시성/버퍼 상한으로 폭주를 격리
- 완화: Rate limiting, 서킷 브레이커, 오토스케일링, 캐시/스트리밍으로 피크를 흡수
여기에 알림 규칙의 정밀화와 부하 테스트/프로파일링을 정기적으로 병행하면, 장애는 “갑작스러운 사고”가 아니라 “예측 가능한 이벤트”가 됩니다. 오늘 소개한 지표, 명령, 설정, 규칙 템플릿을 바로 적용해 서버 안정성을 한 단계 끌어올리세요.