본문 바로가기
Operating System

폭주한 프로세스가 서버 전체를 잡아먹기 전에 - systemd cgroup v2로 서비스별 CPU, 메모리 상한 걸기

나크나로·2026년 9월 1일·조회 2

배치 잡 하나가 무한 루프에 빠지거나 애플리케이션이 메모리를 계속 붙잡으면, 그 프로세스 하나 때문에 같은 서버에 얹힌 다른 서비스까지 전부 느려지거나 죽는다. SSH조차 안 붙어서 콘솔로 들어가 본 경험이 있는 운영자라면 익숙한 그림이다. 이럴 때 매번 niceulimit로 땜질하는 대신, 서비스 단위로 CPU와 메모리 상한을 아예 못박아 두면 사고 반경이 그 서비스 안에서 끝난다.

결론부터 말하자면, systemd 유닛에 CPUQuota, MemoryHigh, MemoryMax를 걸면 커널 cgroup v2가 강제로 제한한다. CPU는 지정한 비율 이상 못 쓰게 스로틀되고, 메모리는 MemoryHigh에서 회수 압박을 받다가 MemoryMax를 넘으면 그 유닛 안에서 OOM 킬러가 동작한다. 서버 전체가 아니라 폭주한 서비스만 대가를 치른다.

1. cgroup v2와 systemd 리소스 제어

cgroup(control group)은 프로세스 집합에 CPU, 메모리, I/O 같은 자원 한도를 매기는 리눅스 커널 기능이다. v2는 여러 자원을 하나의 통합 계층(unified hierarchy)에서 관리하는 최신 버전으로, 최근 배포판(RHEL 9, Ubuntu 22.04 이상 등)은 기본으로 v2를 쓴다.

systemd는 서비스를 시작할 때 각 유닛을 이 cgroup에 넣는다. 그래서 유닛 파일에 리소스 지시자를 한 줄 넣으면 systemd가 그 값을 커널 cgroup 속성 파일(cpu.max, memory.max 등)에 그대로 반영한다. 별도 도구 없이 서비스 단위 제한이 되는 이유다.

먼저 이 서버가 v2 통합 모드인지 확인한다. cgroup2fs가 나오면 v2다.

$ stat -fc %T /sys/fs/cgroup/
cgroup2fs

tmpfs가 나오면 하이브리드/레거시(v1) 모드다. 이때는 커널 파라미터 systemd.unified_cgroup_hierarchy=1을 부팅 옵션에 넣고 재부팅해야 아래 지시자들이 온전히 동작한다.

2. CPUQuota로 CPU 상한 걸기

CPUQuota는 CPU 코어 1개를 100%로 봤을 때 그 유닛이 쓸 수 있는 최대 CPU 시간을 백분율로 지정한다. CPUQuota=50%는 코어 절반, CPUQuota=200%는 코어 2개 몫이다. 커널 cpu.max에 매핑된다.

CPUWeight와 헷갈리지 않는다. CPUWeight는 경합이 있을 때 상대적 배분 비율(기본 100)이고, 한가할 때는 제한이 없다. CPUQuota는 서버가 놀고 있어도 그 상한을 넘지 못하는 절대 한도다. 폭주 방지가 목적이면 CPUQuota를 쓴다.

실험용으로 CPU를 태우는 유닛을 임시로 만들어 확인한다.

$ systemd-run --unit=burn --property=CPUQuota=50% bash -c 'while :; do :; done'
Running as unit: burn.service

$ systemctl show burn -p CPUQuotaPerSecUSec
CPUQuotaPerSecUSec=500ms

CPUQuotaPerSecUSec=500ms는 매 1초 중 최대 500ms의 CPU 시간, 즉 50%다. top으로 보면 무한 루프인데도 이 프로세스가 50% 근처에서 눌려 있다.

$ top -b -n1 | grep bash
  4821 root      20   0    9936   3200   2944 R  49.8   0.0   0:07.21 bash

확인이 끝나면 임시 유닛을 정리한다.

$ systemctl stop burn

3. MemoryHigh와 MemoryMax의 차이

메모리 제한은 두 단계로 나눠서 이해한다. 이 둘의 동작이 다르다.

MemoryHigh - 부드러운 상한(스로틀)

MemoryHigh를 넘으면 커널이 그 유닛의 메모리를 강하게 회수하면서 프로세스를 심하게 느리게 만든다. 프로세스를 죽이지는 않는다. 즉 압박은 주되 살려두는 1차 방어선이다. memory.high에 매핑된다.

MemoryMax - 단단한 상한(OOM)

MemoryMax는 절대 한도다. 회수로도 못 버티고 이 값을 넘으면 그 유닛 안에서 OOM 킬러가 프로세스를 죽인다. 마지막 방어선으로 두는 값이다. memory.max에 매핑된다.

공식 문서 권장은 MemoryHigh를 주 제어 수단으로, MemoryMax를 최후의 방어선으로 함께 거는 방식이다. 예를 들어 MemoryHigh=1.5G, MemoryMax=2G로 두면, 1.5G부터 느려지며 버티다가 2G에서만 죽는다. MemoryMax 하나만 걸면 상한 직전까지 멀쩡하다가 갑자기 킬당한다.

값은 K, M, G, T 접미사(1024 기준)나 전체 물리 메모리 대비 백분율(MemoryMax=40%), 또는 infinity(무제한)로 쓴다. 스왑까지 막으려면 MemorySwapMax를 함께 건다.

4. 드롭인으로 실제 서비스에 적용하기

운영 서비스에는 유닛 원본을 건드리지 말고 드롭인 오버라이드로 얹는다. 패키지 업데이트로 원본이 갈려도 설정이 살아남는다.

$ sudo systemctl edit sarc-app.service

에디터가 열리면 다음을 넣는다.

[Service]
CPUQuota=150%
MemoryHigh=1.5G
MemoryMax=2G
MemorySwapMax=0
TasksMax=512

저장하면 /etc/systemd/system/sarc-app.service.d/override.conf에 기록된다. 반영은 데몬 리로드 후 재시작이다.

$ sudo systemctl daemon-reload
$ sudo systemctl restart sarc-app.service

한두 값을 즉시 걸고 싶으면 set-property가 편하다. 재시작 없이 바로 적용되고, 기본으로 디스크에 저장돼 재부팅 후에도 유지된다. 이번 부팅에만 적용하려면 --runtime을 붙인다.

$ sudo systemctl set-property sarc-app.service MemoryMax=2G CPUQuota=150%

제한을 풀 때는 빈 값을 할당한다. systemctl set-property sarc-app.service CPUQuota= 처럼 쓴다.

5. 제한이 걸렸는지, 실제로 얼마나 쓰는지 확인

설정값은 systemctl show로 읽는다.

$ systemctl show sarc-app.service -p CPUQuotaPerSecUSec -p MemoryHigh -p MemoryMax
CPUQuotaPerSecUSec=1s 500ms
MemoryHigh=1610612736
MemoryMax=2147483648

현재 사용량은 systemd-cgtop이 서비스별로 CPU와 메모리를 실시간으로 보여준다. 값은 환경에 따라 다르다.

$ systemd-cgtop
Control Group                    Tasks   %CPU   Memory  Input/s Output/s
/                                  148   62.4     3.1G        -        -
system.slice/sarc-app.service       38   58.9     1.4G        -        -
system.slice/postgresql.service     14    3.1   612.0M        -        -

cgroup 속성 파일을 직접 읽어도 된다. memory.current가 현재 사용량, memory.events가 제한에 걸린 횟수다.

$ cat /sys/fs/cgroup/system.slice/sarc-app.service/memory.current
1503657984
$ cat /sys/fs/cgroup/system.slice/sarc-app.service/memory.events
low 0
high 274
max 0
oom 0
oom_kill 0

high 카운터가 올라간다면 MemoryHigh에 자주 부딪혀 스로틀되고 있다는 뜻이다. 상한을 올릴지 애플리케이션을 손볼지 판단하는 근거가 된다. oom_kill이 0보다 크면 MemoryMax에서 프로세스가 죽은 것이다.

6. 현장에서 자주 걸리는 함정

MemoryMax만 걸고 앱이 조용히 죽는 경우. MemoryHigh 없이 MemoryMax만 두면 상한에서 OOM 킬이 나는데, 애플리케이션 로그에는 원인이 안 남을 수 있다. journalctl -u sarc-app에서 커널 OOM 메시지를 찾거나 위의 memory.events oom_kill 값을 확인한다.

$ journalctl -u sarc-app -k | grep -i 'killed process'
kernel: Memory cgroup out of memory: Killed process 4821 (node) ...

CPUQuota를 걸었는데 안 먹는 경우. 서버가 cgroup v1(레거시) 모드면 일부 지시자가 무시된다. 1절의 cgroup2fs 확인이 먼저다.

드롭인 저장 후 재시작을 빼먹는 경우. systemctl edit으로 넣은 값은 daemon-reload + 서비스 재시작을 해야 반영된다. set-property는 즉시 적용이라 이 차이를 헷갈리기 쉽다.

여러 서비스를 묶어서 제한하고 싶을 때. 유닛마다 거는 대신 커스텀 슬라이스(*.slice)를 만들어 여러 유닛을 그 슬라이스에 넣고 슬라이스에 상한을 걸면, 그 그룹 전체가 합산 한도를 공유한다. 배치 잡 여러 개를 한 덩어리로 묶을 때 유용하다.

7. 정리

CPU 폭주는 CPUQuota로 절대 상한을 못박고, 메모리는 MemoryHigh(스로틀)와 MemoryMax(OOM)를 이중으로 걸어 완만하게 눌렀다가 최후에만 죽게 한다. 운영 서비스에는 systemctl edit 드롭인으로 얹고, systemctl showsystemd-cgtop, memory.events로 실제 제한과 사용량을 확인한다. 이렇게 해두면 프로세스 하나가 폭주해도 사고가 그 서비스 안에서 멈춘다.

자주 묻는 질문

MemoryHigh와 MemoryMax는 무엇이 다른가?

MemoryHigh는 넘으면 커널이 메모리를 강하게 회수하며 프로세스를 느리게 만들지만 죽이지는 않는 부드러운 상한이다. MemoryMax는 절대 한도로, 이 값을 넘으면 유닛 안에서 OOM 킬러가 프로세스를 죽인다. MemoryHigh를 주 제어 수단으로, MemoryMax를 최후의 방어선으로 함께 거는 방식이 권장된다.

CPUQuota=200%는 무슨 뜻인가?

CPU 코어 1개를 100%로 봤을 때의 최대 CPU 시간 비율이다. 200%는 코어 2개 몫, 50%는 코어 절반을 의미한다. 서버가 한가해도 이 상한을 넘지 못하는 절대 한도라서 폭주 방지에 적합하다.

CPUQuota와 CPUWeight 중 무엇을 써야 하나?

폭주를 막는 상한이 목적이면 CPUQuota다. CPUQuota는 서버가 놀고 있어도 넘지 못하는 절대 한도다. CPUWeight는 경합이 있을 때만 작동하는 상대적 배분 비율이라, 한가할 때는 제한이 없다.

설정한 제한이 실제로 걸렸는지 어떻게 확인하나?

systemctl show <유닛> -p MemoryMax -p CPUQuotaPerSecUSec로 설정값을 읽고, systemd-cgtop으로 서비스별 실시간 사용량을 본다. cgroup 파일 /sys/fs/cgroup/system.slice/<유닛>/memory.events의 high와 oom_kill 카운터로 제한에 얼마나 부딪혔는지도 확인한다.

이 지시자들이 안 먹으면 무엇을 봐야 하나?

서버가 cgroup v2 통합 모드인지 먼저 확인한다. stat -fc %T /sys/fs/cgroup/ 결과가 cgroup2fs면 v2다. tmpfs면 레거시 모드라 일부 지시자가 무시되므로, 커널 파라미터 systemd.unified_cgroup_hierarchy=1로 v2를 켜고 재부팅해야 한다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.