Setting Memory Limits for GeoPandas Containers to Prevent OOM Kills
Measure the real resident set of your GeoDataFrame load, set the container --memory limit above that measured peak, and add an in-process soft limit just below it so the process raises a catchable MemoryError instead of being silently OOM-killed with exit code 137.
Jump to heading Why this matters
Exit code 137 is the single most common way a spatial dashboard dies in production. The kernel’s out-of-memory killer terminates the container the instant its resident set exceeds the cgroup limit, and it leaves no Python traceback — just a pod restart and a cryptic OOMKilled status. The trap is that geometry memory is invisible on disk: a 200 MB GeoParquet file balloons three to five times when it becomes live GeoDataFrame objects, and a single to_crs reprojection allocates a whole second coordinate array on top. Guessing the limit from file size therefore guarantees either wasteful over-allocation or a mid-session kill. This task closes that gap by measuring first and limiting deliberately. It is the runtime counterpart to Docker containerization for spatial workloads and applies the same discipline as memory limit management.
Jump to heading Prerequisites
- Docker 24+ (or a Kubernetes cluster) able to enforce cgroup memory limits.
- A representative dataset — for example a national GeoParquet layer such as
uk_lsoa_2021.parquetin EPSG:27700. geopandas>=0.14,psutil>=5.9, and Python’s standard-libraryresourcemodule (POSIX).
Jump to heading Step-by-step solution
Jump to heading Step 1 — Measure the real resident set
Load the dataset exactly as production does — including the reprojection — and record peak RSS. Do not estimate from file size.
import os
import resource
import geopandas as gpd
import psutil
proc = psutil.Process(os.getpid())
rss_start = proc.memory_info().rss
gdf = gpd.read_parquet("data/uk_lsoa_2021.parquet") # ~200 MB on disk
gdf = gdf.to_crs("EPSG:3857") # reprojection doubles coord arrays
_ = gdf.geometry.area # force geometry materialisation
rss_peak_mb = proc.memory_info().rss / 1_048_576
# ru_maxrss is in kilobytes on Linux, bytes on macOS
maxrss_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
print(f"delta RSS: {(proc.memory_info().rss - rss_start)/1_048_576:.0f} MB")
print(f"current RSS: {rss_peak_mb:.0f} MB peak (maxrss): {maxrss_mb:.0f} MB")
# Typical result: a 200 MB file peaks near 750-950 MB after load + reproject
Record the maxrss figure — that peak, not the steady state, is what the limit must clear. The gap between current RSS and peak (maxrss) is the transient allocation the load passed through: read_parquet holds the Arrow table and the resulting GeoDataFrame at the same instant, and to_crs briefly keeps both the source and destination coordinate arrays alive. That momentary spike is invisible once the load settles, but it is exactly the moment the kernel measures against the cgroup limit, so a limit set only to the steady-state figure will still be tripped by the peak.
Run the measurement on the container’s own base image, not on your workstation, because a different glibc or a different GDAL build can shift the peak by a hundred megabytes or more. If several layers load concurrently at startup, measure the combined peak by loading them together in one process rather than summing individual figures, which understates the simultaneous allocation.
Jump to heading Step 2 — Set the container memory limit
Set the limit above the measured peak with headroom for the interpreter and concurrent sessions. For a single-session peak near 900 MB serving a few analysts, a 3 GB limit is a reasonable start. Pin swap equal to memory so the kernel cannot mask the pressure by paging.
docker run --rm \
--memory=3g --memory-swap=3g \
-e MALLOC_ARENA_MAX=2 \
-p 8501:8501 \
spatial-dashboard:latest
In a compose file, declare it under deploy.resources.limits:
services:
dashboard:
image: spatial-dashboard:latest
environment:
MALLOC_ARENA_MAX: "2"
deploy:
resources:
limits:
memory: 3g
reservations:
memory: 1500M
mem_swappiness: 0
restart: on-failure
On a Kubernetes cluster the same intent is a resources.limits.memory of 3Gi with a matching requests.memory.
Jump to heading Step 3 — Set a matching in-process soft limit
A container limit alone gives you an OOM kill with no diagnostics. Add a soft address-space limit just below the container ceiling so the process raises MemoryError — which you can catch and log — before the kernel intervenes:
import resource
def apply_soft_memory_limit(container_limit_gb: float, fraction: float = 0.88) -> None:
"""Cap process address space below the container limit for a catchable failure."""
soft_bytes = int(container_limit_gb * (1024 ** 3) * fraction)
_, hard = resource.getrlimit(resource.RLIMIT_AS)
resource.setrlimit(resource.RLIMIT_AS, (soft_bytes, hard))
# Call once at startup, matching the container's --memory=3g
apply_soft_memory_limit(container_limit_gb=3.0)
At 88% of a 3 GB container, the process raises MemoryError around 2.6 GB — comfortably before the cgroup limit trips the OOM killer at 3 GB.
Jump to heading Step 4 — Handle the failure gracefully
Wrap large spatial loads so a MemoryError sheds that one request instead of taking down the whole dashboard, and let the orchestrator restart a truly wedged replica via its restart policy:
import gc
import streamlit as st
import geopandas as gpd
def safe_load(path: str, crs: str = "EPSG:3857") -> gpd.GeoDataFrame | None:
try:
gdf = gpd.read_parquet(path).to_crs(crs)
return gdf
except MemoryError:
gc.collect()
st.error("Dataset too large for this session's memory budget — "
"narrow the region or lower the resolution.")
st.stop()
layer = safe_load("data/uk_lsoa_2021.parquet")
Jump to heading Verification
Confirm the limit is enforced and that failure is now catchable rather than silent:
# Inspect the enforced cgroup limit from inside the container
docker exec <id> cat /sys/fs/cgroup/memory.max # cgroup v2 -> 3221225472
# Watch live usage while exercising the dashboard
docker stats --no-stream <id> # MEM USAGE / LIMIT column
Then force the boundary in a test to prove you get a MemoryError, not exit 137:
# Run inside the limited container; should print "caught" not be OOM-killed
try:
big = [bytearray(50_000_000) for _ in range(200)] # ~10 GB request
except MemoryError:
print("caught MemoryError — soft limit works")
If the container dies with exit code 137 instead of printing caught, the in-process soft limit is unset or set above the container limit.
Jump to heading Edge cases and gotchas
- glibc arena fragmentation inflates RSS. Under Streamlit’s threaded sessions, glibc can allocate one memory arena per thread, so resident set grows far beyond live objects and trips the limit early. Set
MALLOC_ARENA_MAX=2in the image to hold the footprint near the true working set. RLIMIT_AScaps virtual address space, not resident memory. Libraries thatmmaplarge files or reserve big virtual regions (somerasterioand Arrow paths) can hitRLIMIT_ASeven when physical RSS is modest. If you see spuriousMemoryErrors on memory-mapped reads, relax the fraction toward 0.92 or switch to a resident-set watchdog that samplespsutilRSS and sheds load, rather than a hard address-space cap.- PyArrow allocates off-heap. Arrow buffers backing a GeoParquet read live outside the Python heap, so
tracemallocunder-reports them. Always corroborate with process-level RSS frompsutilorgetrusage, which the kernel counts against the cgroup limit.
Jump to heading FAQ
Why does my GeoPandas container get OOM-killed even though the file is only 200 MB?
On-disk size is not resident set size. Reading a 200 MB GeoParquet into a GeoDataFrame decompresses it, materialises Shapely geometry objects, and creates Arrow-to-pandas conversion intermediates, so peak RSS can be three to five times the file size. Reprojecting with to_crs allocates a second coordinate array on top. Measure RSS during a real load rather than trusting the file size, then set the container limit above that measured peak.
Should the in-process RLIMIT_AS match the Docker memory limit exactly?
Set the in-process soft limit slightly below the container limit — roughly 85 to 90 percent — so the process raises a catchable MemoryError before the kernel OOM-killer terminates the container with exit code 137. A MemoryError leaves a stack trace and lets you shed the request cleanly, whereas an OOM kill gives you nothing to log.
Does MALLOC_ARENA_MAX actually reduce GeoPandas memory usage?
It reduces apparent resident set growth caused by glibc allocating many per-thread arenas, which is common under Streamlit’s threaded session model. Setting MALLOC_ARENA_MAX=2 does not shrink live objects but curbs fragmentation-driven RSS inflation, so the measured footprint stays closer to the working set and you can set a tighter, safer limit.
Back to Docker Containerization for Spatial Workloads
Related
- Docker Containerization for Spatial Workloads — the multi-stage image and non-root runtime this limit protects
- Pinning GDAL and PROJ Versions in Spatial Docker Images — the other half of a reproducible spatial image
- Memory Limit Management — eviction policies and RSS monitoring across the caching tier
- Deployment, Scaling & Production Operations — where per-replica memory sizing fits the wider scaling picture