Caching PROJ Data and GDAL Config in Containers
A handful of environment variables decide how many requests a remote read costs and whether coordinate transformations are reproducible. Set them once, in the image:
ENV GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
GDAL_INGESTED_BYTES_AT_OPEN=65536 \
GDAL_HTTP_MULTIRANGE=YES \
GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES \
GDAL_CACHEMAX=512 \
VSI_CACHE=TRUE \
VSI_CACHE_SIZE=50000000 \
PROJ_NETWORK=OFF
These are the runtime half of Containerizing Geospatial Python Environments — the build decides what is installed, this decides how it behaves.
Why This Arises in Remote Sensing Workflows
GDAL’s defaults were chosen for local files. On a local disk, listing a directory is free, reading a few extra kilobytes is free, and caching is a minor optimisation. Against object storage every one of those assumptions is wrong: a listing is a request, extra bytes are billed egress, and a cache miss is a network round trip.
The result is that an unconfigured container reads remote data several times slower and more expensively than a configured one, with identical code. The difference is not subtle — a sibling listing on every open can double the request count for a job that touches ten thousand files, and an over-sized block cache in a many-worker container looks exactly like a memory leak.
Because these are environment variables, they can be set anywhere: in the shell, in the job definition, in a rasterio.Env block. Setting them in the image is what makes them consistent, so a pipeline behaves the same whether it is launched by a scheduler, a notebook or a developer.
Environment & Setup
| Setting | Typical | What it changes |
|---|---|---|
GDAL_DISABLE_READDIR_ON_OPEN |
EMPTY_DIR |
Removes the sibling listing on every open |
GDAL_INGESTED_BYTES_AT_OPEN |
32768–65536 | Header arrives in the first response |
GDAL_HTTP_MULTIRANGE |
YES |
Allows several byte ranges per request |
GDAL_HTTP_MERGE_CONSECUTIVE_RANGES |
YES |
Combines adjacent tile ranges |
GDAL_CACHEMAX |
256–512 (MB) | Per-process block cache |
VSI_CACHE / VSI_CACHE_SIZE |
TRUE / 50 MB |
Per-file read cache for repeated access |
PROJ_NETWORK |
OFF |
Prevents runtime grid downloads |
PROJ_DATA |
bundled path | Where proj.db and grids are found |
CPL_TMPDIR |
/tmp |
Where GDAL writes temporary files |
pip install "rasterio>=1.3.0" "pyproj>=3.4"
Complete Working Example
Rather than scattering these across a codebase, define them once and apply them from a single place — the image for defaults, and a helper for anything that depends on the worker.
"""gdal_env.py — one definition of how this pipeline talks to object storage."""
import os
import rasterio
# Always true for this pipeline, regardless of where it runs
STATIC = {
"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
"GDAL_INGESTED_BYTES_AT_OPEN": "65536",
"GDAL_HTTP_MULTIRANGE": "YES",
"GDAL_HTTP_MERGE_CONSECUTIVE_RANGES": "YES",
"GDAL_HTTP_MAX_RETRY": "5",
"GDAL_HTTP_RETRY_DELAY": "1",
"CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tif,.TIF,.tiff,.jp2",
"PROJ_NETWORK": "OFF",
}
def worker_env(memory_mb: int, threads: int = 1) -> dict:
"""Settings that depend on the container's size, not on the pipeline."""
# The block cache is PER PROCESS: divide the container's memory by the worker count
per_worker = max(64, int(memory_mb * 0.15 / max(threads, 1)))
return STATIC | {
"GDAL_CACHEMAX": str(per_worker),
"VSI_CACHE": "TRUE",
"VSI_CACHE_SIZE": str(min(100_000_000, per_worker * 100_000)),
"GDAL_NUM_THREADS": str(threads),
}
def open_remote(uri: str, **overrides):
"""Open a remote raster with the pipeline's standard configuration."""
cfg = worker_env(
memory_mb=int(os.environ.get("WORKER_MEMORY_MB", "2048")),
threads=int(os.environ.get("WORKER_THREADS", "1")),
) | overrides
env = rasterio.Env(**cfg)
env.__enter__() # caller closes via the returned context manager
return env, rasterio.open(uri)
if __name__ == "__main__":
import time
env, src = open_remote("https://example-bucket.s3.amazonaws.com/scene_cog.tif")
try:
start = time.perf_counter()
print("tiled:", src.profile.get("tiled"), "blocks:", src.block_shapes[0])
print("overviews:", src.overviews(1))
print(f"metadata read in {time.perf_counter() - start:.3f}s")
finally:
src.close()
env.__exit__(None, None, None)
The worker_env function is the part worth copying. GDAL_CACHEMAX being per-process is the single most misunderstood setting in the list: a container with eight worker processes and GDAL_CACHEMAX=1024 is asking for eight gigabytes of cache, and the resulting pressure surfaces as workers being killed rather than as anything mentioning GDAL — the profile described in Diagnosing Dask Memory Spills in Raster Workflows.
Variant Patterns
1. PROJ data placement
import pyproj
print(pyproj.datadir.get_data_dir()) # what PROJ will actually use
Printing this in the build smoke test converts a whole class of runtime failure into a build failure.
2. Retries and timeouts for flaky storage
ENV GDAL_HTTP_MAX_RETRY=5 \
GDAL_HTTP_RETRY_DELAY=1 \
GDAL_HTTP_TIMEOUT=60 \
GDAL_HTTP_CONNECTTIMEOUT=15
GDAL’s own retries handle transient 5xx responses below the Python layer, which means a task-level retry policy only sees failures that survived several attempts. That composition is deliberate: keep GDAL’s retries short and few, and let the orchestration layer handle the rest with backoff, as set out in Retrying Failed Raster Tasks in a Prefect Pipeline.
3. Restricting what GDAL will probe
CPL_VSIL_CURL_ALLOWED_EXTENSIONS tells GDAL not to probe for sidecar files it will never find. On archives with many auxiliary files this removes several requests per open.
ENV CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif,.TIF,.tiff,.jp2,.vrt"
Set it to the extensions your pipeline actually reads. Too narrow a list makes a legitimate sidecar invisible, which surfaces as a missing mask rather than as an error.
Verifying the Configuration
Two checks, both runnable inside the image.
Count the requests on an open. Enable CPL_CURL_VERBOSE and read the log: a configured container should show one or two requests to open a COG and read its profile. Three or more means the listing is still happening or the ingest size is too small.
Time a windowed read on a cold container. Compare against the same read with the defaults; a factor of two to three is normal for a small window, and no difference at all means the environment variables are not reaching GDAL — usually because they were set after the process started or in a subshell that the worker does not inherit.
It is worth asserting the important ones at startup, because a missing variable is silent:
import os
REQUIRED = {"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR", "PROJ_NETWORK": "OFF"}
missing = {k: v for k, v in REQUIRED.items() if os.environ.get(k) != v}
if missing:
raise SystemExit(f"container is misconfigured: {missing}")
Common Errors
Workers are killed and the pipeline looks like it leaks memory
GDAL_CACHEMAX is being applied per process while sized for the whole container. Divide by the worker count, and remember that threads inside one process share a cache while processes do not.
PROJ: Cannot find proj.db
PROJ_DATA points somewhere the image does not contain — often left over from a multi-stage build that copied site-packages without the data directory. Unset it and let pyproj’s bundled copy be found.
Reads are still slow after setting the variables
They are set in the wrong place: variables exported in a shell that the worker does not inherit, or set after GDAL has initialised. Put them in the image’s ENV, or pass them through rasterio.Env at the call site.
Frequently Asked Questions
Q: Which single setting matters most?
GDAL_DISABLE_READDIR_ON_OPEN set to EMPTY_DIR. Without it GDAL lists the containing prefix on every open, which on object storage adds a request — and sometimes a very slow one — to every file the job touches.
Q: How large should GDAL_CACHEMAX be? A few hundred megabytes per process, not per machine. The cache is per-process, so eight workers with a 1 GB cache each will try to use 8 GB, and the resulting pressure looks like a memory leak in the pipeline.
Q: Should these be set in the image or per job? In the image for anything that is always true — the read-path settings and PROJ placement. Per job for anything that depends on the worker’s memory or the storage backend, since those vary between deployments of the same image.
Related
- Containerizing Geospatial Python Environments — the parent topic, including what belongs in the image.
- How to Read COG Headers Without Downloading Full Files — the request-count effects of these settings, measured.
- Pinning GDAL and PROJ Versions Reproducibly — shipping the PROJ data this page relies on.
- Reducing S3 Egress Costs in Raster Pipelines — what those saved requests are worth.