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.

Memory budget for a GeoPandas containerA horizontal budget from zero to the container memory limit. The measured GeoDataFrame peak sits well within it. An in-process soft limit is placed at about 88 percent of the container limit so the process raises a catchable MemoryError before reaching the container limit, which is the kernel OOM-kill threshold that produces exit code 137.measured GeoDataFrame peak RSSsoft limit (~88%)RLIMIT_AS → MemoryErrorcontainer limitOOM kill · exit 1370headroom

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.parquet in EPSG:27700.
  • geopandas>=0.14, psutil>=5.9, and Python’s standard-library resource module (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.

python
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.

bash
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:

yaml
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:

python
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:

python
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:

bash
# 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:

python
# 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=2 in the image to hold the footprint near the true working set.
  • RLIMIT_AS caps virtual address space, not resident memory. Libraries that mmap large files or reserve big virtual regions (some rasterio and Arrow paths) can hit RLIMIT_AS even when physical RSS is modest. If you see spurious MemoryErrors on memory-mapped reads, relax the fraction toward 0.92 or switch to a resident-set watchdog that samples psutil RSS 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 tracemalloc under-reports them. Always corroborate with process-level RSS from psutil or getrusage, 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