장기 실행 스크래퍼에서의 세 가지 메모리 누수 패턴 (그리고 968번의 Trustpilot 실행 후 내가 이를 포착한 방법)
Source: Dev.to
Introduction
스크래퍼에서 메모리 누수는 실행을 중단시키지 않습니다.
조용히 Apify 메모리 제한을 1 GB → 2 GB → 4 GB 로 올리고, 실행당 비용을 두 배로 늘리며, 보통 청구서에 나타나는 컴퓨트‑유닛 비용을 몇 주 뒤에야 발견하게 됩니다.
968개의 Trustpilot 실행(페이지당 80–300개의 리뷰, 누적 150 k 페이지 조회) 후에 나는 매 1 000 페이지마다 RSS를 샘플링하기 시작했습니다. 성장 패턴은 로그와는 다른 이야기를 보여주었습니다. 아래는 제가 32개의 공개된 Apify 액터에서 본 누수의 약 90 %를 차지하는 세 가지 패턴입니다.
1. The unbounded asyncio queue
가장 흔한 패턴. 생산자 코루틴이 소비자보다 URL을 더 빨리 가져오면, 메모리 내 큐가 실행 시간에 따라 선형적으로 증가합니다.
# leaks at high concurrency
queue = asyncio.Queue() # no maxsize
async def producer():
async for url in source:
await queue.put(url) # never blocks
async def consumer():
while True:
url = await queue.get()
await process(url) # slower than source
process()가 source보다 느리면(대부분의 JS‑렌더링 사이트에 해당) 큐가 쌓이게 됩니다. 12 000개의 리뷰를 가진 회사를 크롤링한 Trustpilot 실행에서는 큐가 최대 9 500개의 URL를 보유했으며, 이는 약 380 MB의 바이트 문자열에 해당합니다.
Fix
queue = asyncio.Queue(maxsize=200) # producer blocks at 200
크기 제한이 있는 큐는 생산자를 대기시킵니다. 메모리는 평탄하게 유지되고, 처리량은 약간만 감소합니다.
# Example: detect growth per 1 k pages
first, last = _samples[-3], _samples[-1]
growth_per_1k = (last[1] - first[1]) / ((last[0] - first[0]) / 1000)
if growth_per_1k > 50: # >50 MB per 1 000 pages
print(f"LEAK ALERT: +{growth_per_1k:.1f} MB/1k pages")
1 000 페이지당 50 MB라는 임계값은 보수적인 값이며, 정상 상태 실행에서 20 MB를 초과하면 조사할 가치가 있습니다. 출력은 Apify 데이터셋으로 파이프되므로, 실행 전체에 걸쳐 grep할 수 있습니다.
The cost angle nobody mentions
메모리 누수는 스크래퍼를 충돌시키지는 않습니다. 대신 액터의 메모리 설정을 올리게 만들죠:
- 1 GB → 2 GB: 초당 컴퓨트‑유닛 소비가 두 배가 됩니다
- 2 GB → 4 GB: 비용이 네 배가 되며, 등등
4 GB: quadruples it vs the 1 GB baseline
Apify 요금제에서 4 GB 실행이 $0.0004 /CU‑second 라면, 동일한 실시간 동안 적절히 튜닝된 1 GB 실행 대비 약 4배의 비용이 듭니다. 968개의 Trustpilot 실행을 기준으로 하면, 연간 ≈ $120 정도를 추가로 지불하게 되며, 이는 순전히 운영상의 낭비입니다. 아무도 RSS를 프로파일링하지 않았기 때문이죠.
Conclusion
위 세 가지 패턴은 **생산 환경에서 마주한 누수의 ~90 %**를 설명합니다. 모든 장기 실행 스크래퍼에 RSS 프로브를 추가하고, 누수 임계값을 50 MB / 1k 페이지로 설정하면 다음 청구 주기가 아니라 첫 번째 개발 주기에서 문제를 잡을 수 있습니다.
- More production scraping notes: t.me/scraping_ai. Originally published at blog.spinov.online.