fork는 정말 메모리를 복사할까: copy-on-write의 실제 동작
전편에서 프로세스 생성은 비싸다고 했습니다. 그런데 이상합니다. 유닉스에서 새 프로세스를 만드는 방법은 fork(), 즉 지금 프로세스를 통째로 복제하는 것뿐인데, 수 GB 메모리를 쓰는 프로세스도 fork는 순식간에 끝납니다. 기가바이트를 복사하는 데 밀리초일 리가 없습니다. 답은 “복사한다고 말만 하고, 실제로는 복사하지 않는 것"입니다. 이 장치가 copy-on-write(COW, 쓸 때 복사)이고, 리눅스 프로세스 모델의 핵심 트릭입니다.
fork의 의미: 복제로 시작하는 프로세스 #
fork()를 호출하면 호출한 프로세스(부모)와 거의 똑같은 자식 프로세스가 생깁니다. 같은 코드, 같은 변수 값, 같은 열린 파일을 가집니다. 반환값만 달라서(부모는 자식의 PID, 자식은 0) 둘은 자기가 누구인지 압니다. 새 프로그램을 실행하고 싶으면 자식이 이어서 exec()로 자기 몸을 새 프로그램으로 갈아 끼웁니다. 셸이 명령을 실행하는 방식이 정확히 이 fork + exec 조합입니다.
여기서 의문이 생깁니다. 어차피 exec로 몸을 갈아 끼울 거면, 부모의 메모리를 복사하는 것은 순수한 낭비 아닌가요? 맞습니다. 그래서 실제로는 복사하지 않습니다.
copy-on-write: 쓰는 쪽이 복사 비용을 냅니다 #
프로세스의 메모리는 페이지(보통 4KB) 단위로 관리되고, 각 프로세스는 “가상 주소 → 물리 메모리” 대응표인 페이지 테이블을 가집니다. fork가 실제로 복사하는 것은 메모리가 아니라 이 페이지 테이블입니다.
- fork 직후, 부모와 자식의 페이지 테이블은 같은 물리 페이지들을 가리킵니다. 기가바이트짜리 프로세스라도 복사된 것은 대응표뿐이라 fork가 빠른 것입니다.
- 대신 커널은 공유된 페이지들을 양쪽 모두에서 쓰기 금지로 표시해 둡니다.
- 어느 쪽이든 그 페이지에 쓰기를 시도하는 순간, CPU가 페이지 폴트를 일으키고, 커널이 그 시점에 그 페이지 하나만 복사해 쓰는 쪽에 붙여 줍니다. 이후 둘은 그 페이지에 한해 딴살림입니다.
즉 복사는 “fork 시점에 전부"가 아니라 “실제로 고쳐 쓰는 페이지만, 고쳐 쓰는 순간에” 일어납니다. fork + exec가 싼 이유도 자명해집니다. 자식이 아무것도 안 고치고 바로 exec하면, 복사는 거의 일어나지 않습니다.
실전에서 COW를 만나는 곳 #
COW는 커널 내부 지식으로 끝나지 않고, 서버 운영의 여러 장면에서 얼굴을 내밉니다.
- Redis의 스냅숏(RDB 저장): Redis는 저장 시점에 fork해서, 자식이 그 순간의 메모리 상태를 천천히 디스크에 씁니다. COW 덕에 fork는 즉시 끝나고, 부모는 계속 요청을 받습니다. 대신 저장 중에 부모가 데이터를 많이 고치면 고친 페이지마다 복사가 일어나 메모리 사용량이 그만큼 불어납니다. “저장 중 메모리가 두 배 가까이 뛸 수 있다"는 Redis 운영 상식이 바로 COW의 청구서입니다.
- 프리포크(pre-fork) 서버: gunicorn, uWSGI 등은 애플리케이션을 다 로드한 마스터가 워커들을 fork합니다. 워커들이 코드·라이브러리 페이지를 물리적으로 공유하므로, 워커 10개의 실제 메모리는 10배보다 훨씬 작습니다. 반대로 워커가 저마다 데이터를 고쳐 쓰기 시작하면 공유가 깨지며 사용량이 늘어납니다. 파이썬처럼 객체마다 참조 카운트를 고쳐 쓰는 런타임은 읽기만 해도 페이지가 더러워져 공유 이득이 줄어드는 것으로 유명합니다.
- 컨테이너 이미지의 레이어: 파일시스템 차원이지만 같은 사상입니다. 이미지 레이어를 공유하다 고치는 파일만 위 레이어로 복사(copy-up)하는 오버레이 파일시스템도 “쓸 때 복사"라는 같은 원리로 돌아갑니다.
메모리 계산이 헷갈려지는 이유: 그리고 오버커밋 #
COW의 부작용은 “이 프로세스가 메모리를 얼마나 쓰는가?“라는 질문을 어렵게 만든다는 것입니다. ps의 RSS를 워커 10개에 대해 더하면 공유 페이지가 10번 중복 계산됩니다. 공유를 고려한 지표(PSS, smem 도구나 /proc/<pid>/smaps_rollup)를 봐야 실제에 가깝습니다. 메모리 편에서 본 “합계가 안 맞는” 상황의 흔한 원인 하나가 이 중복 계산입니다.
또 하나, fork는 “약속만 하고 복사는 나중에” 하는 구조라서, 커널은 물리 메모리보다 많은 약속을 해 줄 수 있습니다(오버커밋). 8GB를 쓰는 프로세스가 fork하는 순간 이론상 16GB의 약속이 생기지만 실제 사용은 8GB+α입니다. 오버커밋을 금지(vm.overcommit_memory=2)한 시스템에서 대형 프로세스의 fork가 메모리 부족으로 실패하는 사고, Redis 문서가 오버커밋 허용을 권고하는 이유가 다 여기서 나옵니다.
정리 #
- fork는 프로세스를 통째로 복제하지만, 실제로 복사되는 것은 페이지 테이블뿐입니다. 물리 페이지는 쓰기 금지로 공유됩니다.
- 복사는 고쳐 쓰는 페이지만, 고쳐 쓰는 순간에 일어납니다(copy-on-write). 그래서 fork + exec는 거의 공짜입니다.
- Redis 스냅숏의 메모리 증가, 프리포크 서버의 메모리 절약(과 그 붕괴)은 전부 COW의 직접 결과입니다.
- 워커들의 RSS 합산은 공유 페이지를 중복 계산합니다. 실제에 가까운 값은 PSS로 봅니다.
- fork는 약속(가상)과 사용(물리)을 분리합니다. 오버커밋 설정과 대형 프로세스의 fork 실패는 이 분리에서 나오는 운영 이슈입니다.
다음 편은 리눅스 지표 중 가장 자주 오독되는 숫자, Load Average입니다.