A pod restarts at 3 AM. Then again at 3:14. By 3:30 it's in a CrashLoopBackOff. The dashboard shows memory climbing in a clean line - no spikes, no sudden jumps - just a slow, relentless climb until the OOM killer steps in.
This is the story of how I tracked down four distinct memory leaks across two Python services running on Kubernetes. Each one was a different flavor of "silent accumulation," and each one taught me something about how easy it is to bleed memory in async Python without any obvious errors.
No names. No product details. Just the bugs, the fixes, and the lessons.
The setup
We had a multi-service platform running on Kubernetes. Two services are relevant here:
-
Service A: A document management service. It handled CRUD operations, search indexing, audit logging, and authorization precomputation. Built with FastAPI and async Python, backed by MongoDB and a vector store.
-
Service B: A PDF processing service. It parsed documents, extracted text via OCR, and broke content into structured clauses. Heavy on PyMuPDF (fitz) for PDF rendering and Tesseract for OCR.
Both services ran as single-container pods with resource limits. Service A had a 2Gi memory limit. Service B had 4Gi.
Both were getting OOMKilled. Regularly.
The symptoms
Service A's memory graph looked like this:
Memory (MB)
2048 | ╱ OOMKilled
| ╱
1536 | ╱
| ╱
1024 | ╱
| ╱
512 | ╱
| ╱
0 |________________________╱
0h 6h 12h 18h 24h
A steady climb at roughly 200MB per hour. No spikes. No correlation with traffic. Just a ramp.
Service B was different - it'd spike during document processing batches and never come back down:
Memory (MB)
4096 | ╱╲ ╱╲
| ╱ ╲ ╱ ╲ ╱ OOMKilled
3072 | ╱ ╲ ╱ ╲ ╱
| ╱ ╲╱ ╱
2048 | ╱ ╱
|╱ ╱
1024 | ╱
| ╱
0 |______________╱
0h 2h 4h 6h 8h 10h
Every batch pushed memory up. It came down a little, but never to baseline. Classic leak with GC partially reclaiming some objects but not the real offender.
Leak #1: The coroutines that never ran
This was the most insidious one. It looked like perfectly normal Python code:
class BaseDoc:
def _write_audit_log(self, action, old_data, new_data):
enqueue_audit_create(self, action, old_data, new_data)
async def save(self, *args, **kwargs):
old = await self.get(self.id)
result = await super().save(*args, **kwargs)
self._write_audit_log("update", old, result)
await create_snapshots(self.id)
await insert_versions_docs(self.id)
return resultSpot the bug?
_write_audit_log calls enqueue_audit_create(), which is an async function. But _write_audit_log is not async, and it doesn't await the call. It just calls it.
When you call an async function without await, Python creates a coroutine object but never schedules it. The coroutine sits in memory, holding references to all its arguments - in this case, entire document objects with old_data and new_data.
Coroutine Object (0x7f...)
├── State: CREATED (never scheduled)
└── Frame Object
├── Arg: self (BaseDoc instance, 2KB)
├── Arg: old_data (Dict, 5MB) <-- LEAKED
└── Arg: new_data (Dict, 5MB) <-- LEAKED
└── Local Vars: (captured closure scope)Python will eventually warn you about this (RuntimeWarning: coroutine was never awaited), but only if you have warnings enabled and you're looking at the right log. In a noisy production service running dozens of requests per second, these warnings get buried.
The same pattern appeared elsewhere:
# In the CRUD endpoints
async def create_document(request):
doc = await Document.create(request.data)
enqueue_audit_create(doc) # ← async, not awaited
qdrant_upserts(doc) # ← async, not awaited
return doc
async def update_document(request):
doc = await Document.update(request.data)
enqueue_audit_update(doc) # ← async, not awaited
qdrant_upserts(doc) # ← async, not awaited
return docEach unawaited call created a coroutine holding the full document. For a service processing hundreds of documents per hour, that's hundreds of document-sized objects pinned in memory with no way to be garbage collected.
The fix
Straightforward once you see it:
class BaseDoc:
async def _write_audit_log(self, action, old_data, new_data):
await enqueue_audit_create(self, action, old_data, new_data)
async def save(self, *args, **kwargs):
old = await self.get(self.id)
result = await super().save(*args, **kwargs)
await self._write_audit_log("update", old, result)
await create_snapshots(self.id)
await insert_versions_docs(self.id)
return resultAnd in the CRUD endpoints:
async def create_document(request):
doc = await Document.create(request.data)
await enqueue_audit_create(doc)
await qdrant_upserts(doc)
return docThe lesson
Unawaited coroutines are silent memory leaks. They don't crash. They don't raise. They just accumulate. And because they hold references to their arguments, they can keep large objects alive long after the request that created them is done.
If you're running async Python, add this to your linting:
# In your pytest conftest.py or startup
import warnings
warnings.filterwarnings("error", category=RuntimeWarning, message="coroutine.*was never awaited")This turns the warning into an exception, which makes it impossible to miss.
Leak #2: Creating a new MongoClient on every authorization check
The authorization layer needed to precompute user access contexts. To do this, it queried MongoDB directly (not through the async ODM) using PyMongo's synchronous MongoClient.
The code looked like this:
class AuthorizationPrecompute:
def get_user_permissions(self, user_id):
client = MongoClient(MONGO_URI)
db = client["main_db"]
permissions = list(db.permissions.find({"user": user_id}))
client.close()
return permissions
def get_role_policies(self, role_id):
client = MongoClient(MONGO_URI)
db = client["main_db"]
policies = list(db.policies.find({"role": role_id}))
client.close()
return policies
def get_org_settings(self, org_id):
client = MongoClient(MONGO_URI)
db = client["main_db"]
settings = db.settings.find_one({"org": org_id})
client.close()
return settings
# ... 10 more methods, each creating its own MongoClientThirteen methods. Thirteen MongoClient() instantiations per authorization check.
Each MongoClient creates a connection pool, background SDAM monitoring threads, and internal caches. Even with client.close(), the cleanup isn't instant - Python's garbage collector has to reclaim the thread stacks, socket buffers, and internal data structures.
And if an exception happens between MongoClient() and client.close(), the client never gets closed at all. The connection pool lives on, holding threads and sockets until the GC eventually (maybe) collects them.
At 50 requests/second, each triggering an auth check, we were creating 650 MongoClient instances per second. Most were closed, but the transient memory pressure and leaked exceptions meant we were always carrying a few hundred orphaned connection pools.
The fix
One shared client with a connection pool:
class AuthorizationPrecompute:
_client = None
@classmethod
def _get_client(cls):
if cls._client is None:
cls._client = MongoClient(
MONGO_URI,
maxPoolSize=20,
serverSelectionTimeoutMS=5000
)
return cls._client
def get_user_permissions(self, user_id):
db = self._get_client()["main_db"]
return list(db.permissions.find({"user": user_id}))
def get_role_policies(self, role_id):
db = self._get_client()["main_db"]
return list(db.policies.find({"role": role_id}))Connection overhead went from ~50ms per call (TCP handshake + auth each time) to ~5ms (reused connection from pool).
The lesson
Each MongoClient is an entire connection pool, not a single connection. Creating one per function call is like starting a new database server for every query. Always share a single client instance (or at most one per thread/worker). And if you can't guarantee close() will be called (i.e., exceptions exist), you definitely need a shared instance.
Leak #3: Cache entries that never expire
The authorization layer also had an in-memory cache for user access contexts:
class UserAccessContextCache:
def __init__(self, ttl_seconds=300):
self._cache = {}
self._ttl = ttl_seconds
def get(self, key):
entry = self._cache.get(key)
if entry and time.time() - entry["created"] < self._ttl:
return entry["value"]
return None
def set(self, key, value):
self._cache[key] = {
"value": value,
"created": time.time()
}See the problem?
When get() finds an expired entry, it just returns None. It doesn't delete the entry. The stale data sits in the dict forever.
And set() only adds entries. Nothing ever removes them.
Over time, this cache grows without bound. Every unique user who hits the service gets a cache entry that is never cleaned up. The access context objects themselves can be large - they contain permission trees, role hierarchies, and org settings.
With 200MB of stale cache entries after a day of operation, this was a significant contributor to the memory ramp.
The fix
Add proactive cleanup:
class UserAccessContextCache:
def __init__(self, ttl_seconds=300, cleanup_interval=60):
self._cache = {}
self._ttl = ttl_seconds
self._cleanup_interval = cleanup_interval
self._last_cleanup = time.time()
def _cleanup_expired(self):
now = time.time()
if now - self._last_cleanup < self._cleanup_interval:
return
self._last_cleanup = now
expired_keys = [
k for k, v in self._cache.items()
if now - v["created"] >= self._ttl
]
for k in expired_keys:
del self._cache[k]
def get(self, key):
self._cleanup_expired()
entry = self._cache.get(key)
if entry and time.time() - entry["created"] < self._ttl:
return entry["value"]
# Delete on read if expired
if key in self._cache:
del self._cache[key]
return None
def set(self, key, value):
self._cleanup_expired()
self._cache[key] = {
"value": value,
"created": time.time()
}Cleanup runs at most once per minute and removes all entries older than TTL.
The lesson
Every in-memory cache needs an eviction strategy. If there's no mechanism to remove old entries, you have a memory leak with extra steps. "TTL on read" is not enough - if users stop requesting a key, that entry stays forever.
Use cachetools.TTLCache or similar if you don't want to roll your own. Better yet, if the cache is big enough to matter, put it in Redis where you can set EXPIRE and forget about it.
Leak #4: PyMuPDF documents that never closed
Service B was the PDF processing service. It used PyMuPDF (fitz) to render pages and extract text. The endpoints looked like this:
@app.post("/parse_pdf")
async def parse_pdf(file: UploadFile):
content = await file.read()
doc = fitz.open(stream=content, filetype="pdf")
pages = []
for page_num in range(len(doc)):
page = doc[page_num]
text = page.get_text()
pix = page.get_pixmap(dpi=300)
image = pix.tobytes("png")
pages.append({"text": text, "image": image})
return {"pages": pages}No doc.close(). No context manager. No try/finally.
PyMuPDF is a C library wrapper. When you call fitz.open(), it allocates memory in C-land for the document structure, page data, fonts, images, and rendering buffers. Python's garbage collector can eventually reclaim the Python wrapper object, but the C-side memory is only freed when doc.close() is explicitly called.
While the wrapper theoretically has a __del__ method, relying on it in a high-throughput async loop is dangerous. Python's GC is lazy and may not run fast enough to keep up with the rate of C-memory allocation, leading to OOMs before the GC even realizes it needs to clean up.
For a 50-page PDF at 300 DPI, the pixmap rendering alone can consume 200–500MB of memory. If an exception occurs mid-processing (corrupt page, unsupported font), the doc handle leaks entirely.
The fix
Two changes: proper resource cleanup, and parallel OCR with controlled concurrency.
@app.post("/parse_pdf")
async def parse_pdf(file: UploadFile):
content = await file.read()
doc = fitz.open(stream=content, filetype="pdf")
try:
pages = []
for page_num in range(len(doc)):
page = doc[page_num]
text = page.get_text()
pix = page.get_pixmap(dpi=300)
image = pix.tobytes("png")
# Explicitly free the pixmap
pix = None
pages.append({"text": text, "image": image})
return {"pages": pages}
finally:
doc.close()This was applied to all four endpoints in the service: /parse_pdf, /process_clauses, /process_clauses_sync, and /extract_links.
We also added parallel strip processing for OCR (controlled by feature flags):
import os
from concurrent.futures import ThreadPoolExecutor
ENABLE_PARALLEL_OCR = os.getenv("ENABLE_PARALLEL_OCR", "false").lower() == "true"
STRIP_OCR_WORKERS = int(os.getenv("STRIP_OCR_WORKERS", "8"))
def process_page_strips(page, strips):
if not ENABLE_PARALLEL_OCR:
return [ocr_strip(page, s) for s in strips]
with ThreadPoolExecutor(max_workers=STRIP_OCR_WORKERS) as pool:
futures = [pool.submit(ocr_strip, page, s) for s in strips]
return [f.result() for f in futures]The deployment
We didn't flip everything on at once:
- Deploy with
ENABLE_PARALLEL_OCR=false- just thedoc.close()fixes - Monitor memory for 24 hours to confirm the leak is fixed
- Bump resource limits from
4Gito6Gito accommodate parallel processing headroom - Enable parallel OCR:
ENABLE_PARALLEL_OCR=true
Memory dropped from a steady 4GB to a stable 1.8GB. That's a 55% reduction from just closing document handles properly.
The lesson
C extension libraries manage their own memory. Python's GC has no visibility into fitz, numpy, Pillow, or any other C-backed library. If the library provides a close(), use it. If it supports context managers, use with. And always wrap C-heavy processing in try/finally.
The combined impact
Here's the before and after across both services:
| Issue | Before | After |
|---|---|---|
| Unawaited coroutines | ~200MB/hour leak | 0 |
| MongoClient per call | 5–15 new connections per request | 1 pooled |
| Stale cache entries | Up to 200MB unbounded | Cleaned every 60s |
| PyMuPDF documents | 4GB+ with no release | Stable at 1.8GB |
| Embedding API calls | 2× per document save | 1× (cached concat) |
Service A went from daily OOMKills to zero restarts. Service B went from crashing every 8–10 hours to running for weeks without a restart.
How I found them
No fancy tooling. Here's the actual process:
-
Look at the memory graph. Linear growth = accumulation leak. Sawtooth that doesn't return to baseline = handle/resource leak.
-
Read the code. Seriously. I read every function in the hot paths and asked "what gets allocated here that doesn't get freed?"
-
grepfor patterns. Once I found one unawaited coroutine, I searched for the pattern across the entire codebase:
# Find async functions called without await
rg "^\s+[a-z_]+\(" --type py | grep -v "await " | grep -v "def " | grep -v "#"Not perfect, but it flagged the 13 MongoClient instantiations immediately.
-
Check warnings. Enabled
RuntimeWarningfiltering and got 6 unawaited coroutine warnings within the first minute of startup. -
Kubernetes events.
kubectl describe podshows OOMKilled with the exact memory at death. Compare that to your resource limits and you know how fast it's growing.
kubectl get events --field-selector reason=OOMKilling \
--sort-by='.lastTimestamp' -n your-namespaceA checklist for your services
If you're running Python services on Kubernetes and seeing memory growth, check these in order:
- Unawaited coroutines. Search for
RuntimeWarning: coroutine.*was never awaitedin your logs. Better yet, make it an error. - MongoClient instantiation.
grep -rn "MongoClient(" your_code/- if it appears in more than one place (especially inside functions, not module level), you probably have a leak. - In-memory caches without eviction. Any
dictthat grows with usage and has nodelor size cap is a leak.TTLCache,LRU, ormaxsize- pick one. - C extension resources. PyMuPDF, Pillow, lxml, pdfplumber - anything that wraps C needs explicit
close()orwithstatements. - Large objects in exception handlers. If your
exceptblock captures the full traceback and logs the local variables, those locals (which may include entire documents) are pinned until the log entry is flushed. - Background tasks holding request data. If
asyncio.create_task()captures a closure over request objects, those objects live until the task completes.
Final thought
None of these bugs produced an error. No stack trace. No warning (well, one did, but it was buried). The services ran fine - they just got slower and slower until Kubernetes killed them, then they restarted and the cycle began again.
Memory leaks in garbage-collected languages are not the "forgot to call free()" kind. They're the "I'm holding a reference I didn't know about" kind. The GC is doing its job perfectly - it's keeping alive exactly what you told it to.
You just told it wrong.