파일 디스크립터를 안 닫는 코드 한 줄, 무한 재귀로 메모리를 다 먹는 버그 하나가 서버 프로세스 전체를 멎게 만드는 경우가 있다. 셸의 ulimit 대신, 파이썬 프로세스 내부에서 동적으로 조회하거나 특정 자식 프로세스에만 상한을 걸고 싶을 때는 표준 라이브러리 resource 모듈을 쓴다. 이 글에서는 resource로 RLIMIT을 조회·설정하고, 상한을 넘겼을 때 커널이 실제로 어떻게 반응하는지 직접 확인한다.
핵심 개념
| RLIMIT | 제한 대상 |
|---|---|
RLIMIT_NOFILE | 프로세스가 동시에 열 수 있는 파일 디스크립터 개수 |
RLIMIT_AS | 가상 주소 공간 총량(바이트) — malloc/mmap이 이 한도를 넘으면 실패 |
RLIMIT_CPU | 누적 CPU 시간(초) — 초과 시 SIGXCPU, 계속 넘기면 SIGKILL |
RLIMIT_NPROC | 해당 사용자가 띄울 수 있는 프로세스/스레드 개수 |
각 리밋은 soft(실제 적용되는 상한)/hard(CAP_SYS_RESOURCE 없이 넘길 수 없는 상한) 값 한 쌍이다.
실전: RLIMIT 조회와 상한 초과 재현
먼저 현재 프로세스의 기본 리밋 값을 조회한다.
import resource
def show(name):
res = getattr(resource, name)
soft, hard = resource.getrlimit(res)
print(f"{name}: soft={soft}, hard={hard}")
for name in ["RLIMIT_NOFILE", "RLIMIT_AS", "RLIMIT_CPU", "RLIMIT_NPROC"]:
show(name)$ python3 show_rlimits.py
RLIMIT_NOFILE: soft=1048576, hard=1048576
RLIMIT_AS: soft=-1, hard=-1
RLIMIT_CPU: soft=-1, hard=-1
RLIMIT_NPROC: soft=62143, hard=62143
-1은 RLIM_INFINITY(제한 없음)다. RLIMIT_NOFILE을 5로 낮추고 파일을 반복해서 열어본다.
import resource
resource.setrlimit(resource.RLIMIT_NOFILE, (5, 5))
opened = []
try:
for i in range(20):
opened.append(open("/etc/hostname"))
except OSError as e:
print(f"{len(opened)}개 연 뒤 실패: {e!r}")$ python3 nofile_limit.py
2개 연 뒤 실패: OSError(24, 'Too many open files')
표준입출력(fd 0~2)이 이미 3개를 쓰고 있어서 soft limit 5 중 실제로 새로 열 수 있는 건 2개뿐이다. 이번엔 RLIMIT_AS로 가상 메모리 할당을 막아본다.
import resource
resource.setrlimit(resource.RLIMIT_AS, (200 * 1024 * 1024, 200 * 1024 * 1024)) # 200MB
try:
buf = bytearray(500 * 1024 * 1024) # 500MB 할당 시도
print(f"할당 성공: {len(buf)} bytes (예상과 다름)")
except MemoryError:
print("MemoryError 발생: RLIMIT_AS(200MB) 초과로 500MB 할당 거부됨")$ python3 as_limit.py
MemoryError 발생: RLIMIT_AS(200MB) 초과로 500MB 할당 거부됨
가상 주소 공간 상한을 낮추면 malloc/mmap 기반 할당인 bytearray가 커널의 ENOMEM을 파이썬 MemoryError로 그대로 받는다. 마지막으로 RLIMIT_CPU를 낮추고 SIGXCPU를 직접 처리한다.
import resource, signal, time, sys
def handler(signum, frame):
print(f"SIGXCPU 수신 (signum={signum}) — CPU 시간 초과로 커널이 시그널을 보냄")
sys.exit(1)
signal.signal(signal.SIGXCPU, handler)
resource.setrlimit(resource.RLIMIT_CPU, (1, 3)) # soft=1초, hard=3초
start = time.process_time()
x = 0
while True:
x += 1
if x % 10_000_000 == 0 and time.process_time() - start > 3:
break$ python3 cpu_limit.py
SIGXCPU 수신 (signum=24) — CPU 시간 초과로 커널이 시그널을 보냄
soft(1초)에 도달하면 커널이 SIGXCPU를 보내고, 핸들러가 이를 잡아 sys.exit(1)로 마무리한다.
실전: 자식 프로세스에만 상한 적용하기
fork 이후에 setrlimit을 호출하면 부모 프로세스는 영향을 받지 않는다.
import resource, os
pid = os.fork()
if pid == 0:
resource.setrlimit(resource.RLIMIT_NOFILE, (5, 5))
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
print(f"자식(PID {os.getpid()}): RLIMIT_NOFILE soft={soft}")
os._exit(0)
else:
os.waitpid(pid, 0)
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
print(f"부모(PID {os.getpid()}): RLIMIT_NOFILE soft={soft} (자식의 변경에 영향 안 받음)")$ python3 fork_child_limit.py
자식(PID 4418): RLIMIT_NOFILE soft=5
부모(PID 4417): RLIMIT_NOFILE soft=1048576 (자식의 변경에 영향 안 받음)
주의사항
RLIMIT_CPU의 soft/hard를 같은 값으로 두면SIGXCPU와 거의 동시에SIGKILL이 와서 핸들러가 실행되기 전에 죽는다 — graceful 종료가 필요하면 hard를 soft보다 여유 있게 잡는다.- 한 번 낮춘 hard limit은
CAP_SYS_RESOURCE없이 그 프로세스(와 자식)에서 되돌릴 수 없다 — 부모에 영향 없이 자식만 제한하려면 위처럼fork직후에 설정한다. RLIMIT_AS는 가상 주소 공간 예약량 기준이라mmap(MAP_NORESERVE)처럼 실제 커밋 없이 예약만 하는 할당에도 걸릴 수 있어 물리 메모리 사용량과 항상 일치하지는 않는다.setrlimit은 프로세스 단위로 적용되며 특정 스레드에만 걸 수는 없다.
마무리
Docker의 --ulimit, systemd의 LimitNOFILE=도 결국 이 setrlimit 시스템 콜을 대신 호출해주는 것이다. resource 모듈을 직접 쓰면 특정 자식 프로세스에만 세밀하게 상한을 걸 수 있고, 상한 초과 시 어떤 예외/시그널이 오는지 미리 알아두면 장애 원인 파악이 빨라진다.