OS와 프로세스를 모르면 백엔드 장애의 절반은 안 보인다
백엔드 지식 지도 6편. 프로세스, 신호, 파일 디스크립터, 이벤트 루프, GIL, OOM, cgroup을 운영 장애 관점에서 정리한다.
Series
백엔드 지식 지도백엔드 코드는 프레임워크 위에서 작성하지만, 실제로는 OS 위에서 돈다.
요청을 처리하는 것은 추상적인 “서버”가 아니다. 프로세스다. 소켓은 파일 디스크립터고, 컨테이너는 작은 VM이 아니라 네임스페이스와 cgroup이며, 종료는 시그널로 시작된다.
이 층을 모르면 이런 장애가 안 보인다.
- 컨테이너가 아무 로그도 없이 죽었다.
- CPU가 남는데 응답이 느리다.
- async로 바꿨는데 처리량이 늘지 않는다.
docker stop이 항상 10초를 다 쓰고 죽는다.- 어느 순간부터
Too many open files가 터진다.
OS 지식은 커널 개발자가 되기 위한 지식이 아니다. 코드가 실제로 어떤 자원 위에서 도는지 보기 위한 지식이다.
프로세스와 스레드는 격리와 공유의 선택이다
프로세스는 독립된 주소 공간을 가진다. 한 프로세스가 메모리를 망가뜨려도 다른 프로세스는 비교적 안전하다. 대신 서로 데이터를 주고받으려면 비용이 든다.
스레드는 같은 프로세스 안에서 메모리를 공유한다. 통신은 쉽지만 동기화 책임이 생긴다. 같은 데이터를 동시에 바꾸면 락, 큐, 원자적 연산 같은 도구가 필요하다.
즉, 프로세스와 스레드는 단순 구현 방식이 아니다.
프로세스 = 격리를 사고 통신 비용을 낸다
스레드 = 공유를 사고 동기화 책임을 진다워커 시스템이 잡마다 fork하는 이유도 여기에 있다. 잡이 메모리를 많이 쓰거나 이상한 상태로 빠져도 부모 프로세스가 살아남으면 다음 잡을 계속 처리할 수 있다. 격리를 프로세스 경계로 사는 것이다.
fork, exec, waitpid는 워커의 기본 문법이다
Unix 프로세스 모델에는 중요한 동작 세 가지가 있다.
fork는 현재 프로세스를 복제한다. exec는 그 프로세스 자리에 다른 프로그램을 얹는다. waitpid는 부모가 자식의 종료 상태를 회수하는 일이다.
부모가 자식의 종료 상태를 거두지 않으면 좀비 프로세스가 남는다. 좀비는 이미 실행은 끝났지만, 종료 상태가 회수되지 않아 프로세스 테이블에 남아 있는 상태다.
웹 서버, 워커, supervisor를 이해할 때 이 모델이 계속 나온다.
특히 “잡마다 프로세스” 모델에서는 부모가 자식의 종료 상태를 정확히 봐야 한다. 정상 종료인지, 예외인지, SIGKILL인지에 따라 실패 기록이 달라진다.
종료는 SIGTERM에서 시작되어야 한다
운영에서 가장 중요한 시그널은 SIGTERM과 SIGKILL이다.
SIGTERM은 정리하고 나가라는 요청이다. 애플리케이션은 이 신호를 받아 새 요청을 막고, 진행 중 요청을 마무리하고, 커넥션을 닫고, 종료할 수 있다.
SIGKILL은 즉시 종료다. 무시할 수 없고, 핸들러도 실행되지 않는다. 애플리케이션은 아무것도 정리하지 못한다.
docker stop은 보통 이렇게 동작한다.
SIGTERM 전송
→ 기본 10초 대기
→ 아직 살아 있으면 SIGKILL따라서 서비스는 SIGTERM을 제대로 처리해야 한다. 그렇지 않으면 배포 때마다 10초를 다 쓰고, 결국 SIGKILL로 죽는다. 진행 중 요청과 잡은 정리 기회를 잃는다.
컨테이너에서는 PID 1 문제도 있다. 셸 스크립트가 PID 1이 되어 신호를 자식에게 전달하지 않으면, 실제 앱은 SIGTERM을 못 받을 수 있다. 그래서 exec로 앱을 띄우거나 tini 같은 init을 쓰는 패턴이 나온다.
파일 디스크립터 누수는 소켓 장애로 보인다
Linux에서 열린 파일, 소켓, 파이프는 모두 파일 디스크립터다.
프로세스마다 열 수 있는 fd 수에는 한도가 있다. 이 한도를 넘으면 Too many open files가 난다.
소켓 누수는 처음에는 잘 보이지 않는다. 연결을 열고 닫지 않는 코드가 조금씩 fd를 쌓는다. 어느 순간 새 파일도 못 열고, 새 소켓도 못 열고, 로그 파일도 못 열 수 있다.
증상은 다양하다.
- 외부 API 연결 실패
- DB 연결 실패
- 정적 파일 열기 실패
- 로그 기록 실패
원인은 하나다. fd가 고갈됐다.
운영에서는 프로세스의 열린 fd 수, 소켓 상태, ulimit -n을 볼 수 있어야 한다. 네트워크 장애처럼 보이는 문제가 사실 프로세스 자원 누수일 수 있다.
이벤트 루프는 블로킹 호출 하나에 멈춘다
async는 마법이 아니다.
이벤트 루프는 한 스레드에서 많은 I/O를 번갈아 처리한다. 소켓 A를 기다리는 동안 소켓 B를 처리하고, DB 응답을 기다리는 동안 다른 요청을 처리한다.
하지만 루프 안에서 블로킹 호출을 하면 전체가 멈춘다.
async def handler():
result = requests.get("https://example.com") # blocking
return result.text이 코드는 async 함수 안에 있지만 루프를 막는다. 동기 DB 드라이버, CPU 무거운 JSON 파싱, 이미지 처리, time.sleep도 마찬가지다.
해법은 블로킹 I/O를 async 지원 라이브러리로 바꾸거나, thread pool/process pool executor로 밀어내는 것이다.
중요한 것은 스레드 수를 늘리는 것이 항상 답이 아니라는 점이다. 이벤트 루프가 막혀 있으면 병목은 다른 곳에 있다.
Python GIL은 CPU 바운드 작업에서 드러난다
Python에서는 GIL 때문에 한 프로세스에서 Python 바이트코드가 한 번에 하나만 실행된다.
I/O 바운드 작업은 스레드나 async로 이득을 볼 수 있다. 대부분의 시간을 네트워크나 디스크 응답을 기다리기 때문이다.
하지만 CPU 바운드 작업은 다르다. 순수 Python으로 무거운 계산을 한다면 스레드를 늘려도 총 처리량이 크게 늘지 않을 수 있다.
CPU 바운드라면 프로세스를 나누거나, C 확장과 벡터화된 라이브러리를 쓰거나, 별도 워커로 분리해야 한다.
“CPU가 높은데 스레드를 늘리자”는 처방은 Python 백엔드에서는 자주 틀린다.
앱 로그 없이 죽었다면 OOM을 먼저 의심하자
컨테이너가 아무 로그도 남기지 않고 재시작됐다면 OOM killer를 먼저 의심해야 한다.
컨테이너 메모리 한도를 넘으면 커널이 프로세스를 죽인다. 애플리케이션은 자기 죽음을 기록할 기회가 없다. 그래서 앱 로그에는 아무것도 없을 수 있다.
확인은 앱 로그가 아니라 호스트나 컨테이너 런타임 쪽에서 해야 한다.
dmesg- Kubernetes event
- container exit code
- cgroup memory stats
“로그가 없다”는 것은 정보가 없는 것이 아니라 단서다. 정상 예외였다면 로그가 남았을 가능성이 높다. 아무것도 없이 사라졌다면 프로세스 바깥의 힘을 봐야 한다.
CPU가 남는데 느리다면 cgroup throttling을 보자
컨테이너의 CPU 제한은 단순히 “CPU를 N개 준다”가 아니다. 일정 주기마다 사용할 수 있는 CPU 시간을 정하고, 그 할당량을 다 쓰면 남은 주기 동안 강제로 대기시킨다.
이게 CPU throttling이다.
호스트 전체 CPU 사용률이 낮아도, 특정 컨테이너는 자기 quota를 다 써서 대기 중일 수 있다. 그래서 “서버 CPU는 남는데 앱은 느린” 상황이 생긴다.
확인은 cgroup의 cpu.stat이나 container metrics에서 throttled time/count를 보면 된다.
이 문제는 애플리케이션 로그에 잘 안 남는다. 코드 입장에서는 그냥 시간이 늦게 흐르는 것처럼 보인다.
시간 측정에는 wall clock이 아니라 monotonic clock을 써야 한다
타임아웃과 경과 시간을 잴 때는 벽시계를 쓰면 안 된다.
벽시계는 NTP 보정이나 운영자 변경으로 앞뒤로 움직일 수 있다. 시간이 뒤로 가면 timeout이 이상해지고, 시간이 갑자기 앞으로 가면 아직 기다려야 할 작업이 만료된 것처럼 보일 수 있다.
경과 시간 측정에는 monotonic clock을 써야 한다. Python이면 time.monotonic() 계열, Go면 time 패키지의 monotonic component가 붙은 duration 계산을 쓰는 식이다.
분산 시스템에서 deadline을 다룰 때도 이 차이가 중요하다.
마무리
OS와 프로세스 지식은 낮은 수준의 취미 지식이 아니다.
백엔드가 실제로 도는 자리를 보는 눈이다.
프로세스와 스레드는 격리와 공유의 선택이고, SIGTERM과 SIGKILL은 배포 안정성을 결정한다. fd 누수는 네트워크 장애처럼 보이고, 이벤트 루프는 블로킹 호출 하나에 멈춘다. Python GIL은 CPU 바운드 처리량을 제한하고, OOM과 cgroup throttling은 앱 로그 바깥에서 발생한다.
코드는 추상화 위에서 쓰지만, 장애는 추상화 아래에서 올라온다.
다음 편에서는 더 아래로 내려가 네트워크를 본다.
핵심 질문은 이것이다.
“연결이 안 된다”를 어떻게 여러 실패로 나눌 것인가.