Python worker memory duplication: 먼저 확인할 것들
마지막 업데이트

Python worker memory duplication: 먼저 확인할 것들


Python worker 하나만 보면 메모리가 버틸 만한데 worker 수를 늘리자마자 node 전체 메모리가 급격히 커진다면, 한 프로세스의 전형적인 leak보다 worker 간 duplication이 핵심인 경우가 많습니다.

짧게 말하면, 먼저 프로세스 하나가 계속 커지는 문제인지 여러 worker가 같은 큰 데이터를 반복해서 들고 있는 문제인지 구분한 뒤, preload 동작과 process model, 큰 shared object를 확인해야 합니다.


먼저 한 프로세스가 큰 건지, 많은 프로세스가 같은 데이터를 반복하는 건지 구분하세요

이 구분이 조사 방향을 크게 바꿉니다.

worker 하나가 계속 커진다면 memory leak, cache growth, retained object를 의심해야 합니다. 반대로 worker 각각은 안정적인데 worker 수만큼 총 메모리가 거의 선형으로 증가한다면 duplication 쪽일 가능성이 큽니다.

이 패턴은 Gunicorn, Celery prefork pool, multi-process Python 배포에서 특히 흔합니다.

worker memory duplication이 생기는 흔한 이유

1. 각 worker가 같은 큰 데이터를 따로 로드함

모델, lookup table, in-memory cache, 큰 config, precomputed data가 worker마다 독립적으로 올라오는 경우가 많습니다.

worker 하나만 보면 괜찮아 보여도 4개, 8개로 늘리면 전체 footprint는 빠르게 커집니다.

2. preload 동작을 잘못 이해하고 있음

preload를 쓰면 duplication이 자동으로 사라질 거라고 기대하기 쉽지만, 실제로는 fork 전에 로드되고 이후 거의 read-only로 유지되는 객체에서만 도움이 큽니다. 이후에 mutation이 일어나거나 worker별로 새 상태가 붙으면 per-worker memory는 다시 커질 수 있습니다.

즉 preload는 만능 해결책이 아니라 메모리 모양을 바꾸는 도구에 가깝습니다.

3. worker 수가 memory budget보다 빨리 늘어남

애플리케이션이 2 worker에서는 멀쩡하다가 8 worker에서 node memory incident를 만드는 것은 전혀 이상한 일이 아닙니다.

worker 증설은 throughput knob이기도 하지만, 동시에 memory budget을 직접 소모하는 변화입니다.

4. cache와 connection-local state가 worker마다 따로 생김

database client, request cache, LRU store, framework cache는 process-local 상태로 worker마다 따로 존재하는 경우가 많습니다.

어느 정도는 정상 동작이지만 운영 관점에서는 여전히 중요한 비용입니다.

5. duplication과 일반 memory growth가 함께 존재함

각 worker가 큰 기본 footprint를 가진 상태에서 cache churn이나 retained object 때문에 시간이 지나며 더 커지는 혼합 패턴도 자주 나옵니다.

그래서 duplication을 찾았다고 해서 조사를 바로 끝내면 안 됩니다.

실전 점검 순서

1. worker별 메모리와 host 전체 메모리를 같이 보세요

worker 하나가 400MB이고 worker가 8개라면, node cost는 400MB가 아닙니다. 곱해진 footprint와 추가 overhead를 같이 봐야 합니다.

많은 incident가 per-process 숫자만 보다가 node-level 총량에서 갑자기 크게 느껴집니다.

2. fork 전과 worker 시작 후 무엇이 로드되는지 확인하세요

아래 질문이 핵심입니다.

  • module import 단계에서 어떤 객체가 생성되는가
  • worker startup hook에서 무엇이 초기화되는가
  • 첫 요청이나 첫 task 이후에 lazy 하게 무엇이 만들어지는가

이 지점에서 preload의 의미가 생깁니다. fork 전에 로드된 read-only 객체는 더 효율적으로 공유될 수 있지만, worker 시작 후 생성되거나 자주 변경되는 객체는 그렇지 않습니다.

3. worker 수 증가와 memory incident 시점을 비교하세요

concurrency 증가 직후 memory pressure가 시작됐다면, leak보다 multiplication이 더 큰 원인일 가능성이 높습니다.

생각보다 자주 놓치는 부분입니다.

4. 가장 큰 shared object와 cache를 찾으세요

특히 아래 항목을 확인하세요.

  • 큰 ML model
  • lookup table
  • in-memory result cache
  • ORM metadata나 큰 config tree
  • 큰 request 또는 task payload retention

핵심 질문은 “무엇이 큰가”뿐 아니라 “그것을 모든 worker가 따로 들고 있는가”입니다.

5. 해법이 worker 감축인지, 객체 축소인지, 다른 concurrency 모델인지 결정하세요

어떤 경우엔 worker 수를 줄이는 게 가장 낫고, 어떤 경우엔 큰 데이터를 per-worker memory 밖으로 빼는 게 더 낫습니다. 또 preload 방식이나 process 전략을 바꾸는 편이 좋은 경우도 있습니다.

정답은 throughput과 memory efficiency 중 무엇이 더 중요한지에 따라 달라집니다.

패턴을 찾은 뒤 어떻게 바꿀지

모든 worker가 같은 큰 데이터를 로드하는 경우

per-worker footprint를 줄이고, 큰 optional feature를 분리하고, 공유 상태를 매 worker 메모리 밖의 서비스나 저장소로 옮기는 방법을 검토하세요.

preload가 일부만 도움이 되는 경우

가능하면 객체를 read-only에 가깝게 유지하고, fork 이후 큰 shared structure를 자주 mutation 하지 않도록 설계하는 편이 copy-on-write 효과를 더 오래 유지하는 데 도움이 됩니다.

worker 수가 node budget에 비해 너무 높은 경우

worker 수를 줄이거나 더 큰 node로 옮겨야 합니다. memory budget을 넘긴 상태에서 worker를 계속 늘리는 것은 지속 가능한 해결책이 아닙니다.

각 프로세스 내부 memory growth도 함께 큰 경우

이때는 duplication만이 아니라 일반 메모리 증가도 함께 보는 편이 맞습니다. Python memory usage high 글과 같이 보세요.

빠른 체크리스트

worker memory duplication이 의심될 때는 아래 순서가 가장 실용적입니다.

  1. worker별 메모리와 host 전체 메모리를 같이 본다
  2. 큰 객체가 모든 worker에 존재하는지 확인한다
  3. preload와 post-fork initialization 동작을 점검한다
  4. worker 수 증가와 incident 시점을 비교한다
  5. per-worker state 축소와 worker 감축 중 무엇이 맞는지 결정한다

FAQ

Q. 이건 항상 leak인가요?

아닙니다. process-based concurrency에서 생기는 예상 가능한 duplication인 경우가 많습니다.

Q. preload가 항상 해결해주나요?

아닙니다. 이후 mutation과 process-local state가 여전히 중요합니다.

Q. 가장 빠른 첫 단계는 무엇인가요?

worker 하나의 메모리와 현재 worker 수를 함께 봐서 node 전체 비용을 계산하는 것입니다.

Q. 코드 변경이 없었는데 worker를 늘리자마자 메모리가 튄 이유는 뭔가요?

중복된 in-memory state가 worker 수만큼 거의 선형으로 늘어났기 때문일 가능성이 큽니다.

Sources:

먼저 읽어볼 가이드

검색 유입이 많은 핵심 글부터 이어서 보세요.