배치 잡 하나가 무한 루프에 빠지거나 애플리케이션이 메모리를 계속 붙잡으면, 그 프로세스 하나 때문에 같은 서버에 얹힌 다른 서비스까지 전부 느려지거나 죽는다. SSH조차 안 붙어서 콘솔로 들어가 본 경험이 있는 운영자라면 익숙한 그림이다. 이럴 때 매번 nice나 ulimit로 땜질하는 대신, 서비스 단위로 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 show와 systemd-cgtop, memory.events로 실제 제한과 사용량을 확인한다. 이렇게 해두면 프로세스 하나가 폭주해도 사고가 그 서비스 안에서 멈춘다.