Containerizing Geospatial Python Environments
A geospatial Python stack is unusually sensitive to its environment. rasterio sits on GDAL, GDAL sits on PROJ, and PROJ consults a database of coordinate operations and, sometimes, downloadable grid files. Change any of those and the same script can produce a different answer — not an error, a different answer. Containers are how that stack is made reproducible, and this topic covers building them well, inside the wider context of Cloud Execution & Orchestration.
The problem is not “how do I write a Dockerfile”. It is that the obvious Dockerfile produces a two-gigabyte image that takes forty seconds to pull, contains a compiler toolchain nobody needs at runtime, and pins nothing below the Python layer — so the image built in March and the image built in September behave differently.
Prerequisites
pip install "rasterio>=1.3.0" "pyproj>=3.4" "rioxarray>=0.15"
| Component | Version | Why it matters |
|---|---|---|
| Docker or Podman | ≥24 | Multi-stage builds and BuildKit caching |
| Python base image | 3.11-slim | Small, current, wheel-compatible |
rasterio wheels |
≥1.3.0 | Bundle GDAL and PROJ, removing the system dependency |
| GDAL (if system) | ≥3.6 | Needed only when the wheels do not cover a driver you use |
Conceptually you need the read-path settings from How to Read COG Headers Without Downloading Full Files and the job model from Processing COGs on AWS Batch with Docker, because both decide what the image has to contain.
The two ways to get GDAL into an image
Almost every geospatial Dockerfile is one of two shapes, and choosing between them early saves a rebuild later.
The wheel route is the right default. It is smaller, it builds without a compiler, and — most importantly — the GDAL and PROJ versions are determined by the wheel, so pinning rasterio==1.3.9 pins the whole stack. The system route earns its size when the pipeline shells out to gdalwarp, needs a driver the wheels omit, or has to match a specific GDAL version used elsewhere.
Step-by-step workflow
1. Start from a pinned, slim base
# syntax=docker/dockerfile:1
FROM python:3.11-slim-bookworm AS runtime
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
Pin the minor version of Python and the distribution release. python:3.11-slim alone floats to a new Debian base eventually, which is exactly the kind of silent change containers exist to prevent.
2. Install dependencies before code
COPY requirements.lock /tmp/requirements.lock
RUN pip install --require-hashes -r /tmp/requirements.lock
COPY src/ /app/src/
Dependencies change rarely and code changes constantly, so installing dependencies in an earlier layer means a code edit rebuilds one small layer instead of the whole image. --require-hashes turns the lock file into a guarantee rather than a suggestion.
3. Bake the GDAL configuration into 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 \
CPL_TMPDIR=/tmp \
PROJ_NETWORK=OFF
Every one of these affects how many requests a remote read costs, and setting them in the image means every job inherits them without remembering. PROJ_NETWORK=OFF is deliberate: it prevents PROJ from silently downloading grids at runtime, which is both a reproducibility and a network-egress problem.
4. Add a build-time smoke test
RUN python - <<'PY'
import rasterio, pyproj
print("rasterio", rasterio.__version__, "GDAL", rasterio.__gdal_version__)
print("PROJ", pyproj.proj_version_str)
with rasterio.open("/vsicurl/https://example.com/tiny_cog.tif") as src:
assert src.profile["tiled"], "sanity read failed"
PY
A smoke test that fails the build is worth far more than a README note. It catches a wheel that resolved differently, a missing certificate bundle, and a base image whose network configuration blocks remote reads — all of which otherwise surface as a failed job hours later.
5. Record what the image contains
LABEL org.opencontainers.image.title="raster-pipeline" \
org.opencontainers.image.revision="${GIT_SHA}"
RUN python -c "import rasterio, pyproj, json, sys; \
json.dump({'python': sys.version.split()[0], 'rasterio': rasterio.__version__, \
'gdal': rasterio.__gdal_version__, 'proj': pyproj.proj_version_str}, \
open('/app/versions.json','w'))"
Writing the resolved versions into the image means a job’s output can record which stack produced it, which is what makes a numeric difference between two runs diagnosable rather than mysterious.
Parameter reference
| Setting | Where | Typical | Why |
|---|---|---|---|
GDAL_DISABLE_READDIR_ON_OPEN |
image env | EMPTY_DIR |
Removes a sibling listing on every remote open |
GDAL_CACHEMAX |
image env | 256–1024 (MB) | Block cache; too large starves the worker’s own memory |
PROJ_NETWORK |
image env | OFF |
Stops runtime grid downloads; ship grids instead |
PROJ_DATA |
image env | bundled path | Points at the PROJ database the image actually contains |
--require-hashes |
pip | always | Turns the lock file into an integrity guarantee |
| base image tag | Dockerfile | fully pinned | A floating tag defeats the point of the container |
Image size, and why it is a runtime cost
Three techniques account for most of the reduction. A multi-stage build compiles anything that needs compiling in a builder stage and copies only the resulting site-packages into the runtime stage, leaving the toolchain behind. Installing with --no-cache-dir avoids shipping pip’s cache. And ordering layers from least to most volatile means the expensive dependency layer is reused across code changes.
What belongs in the image, and what does not
An image is a contract about the environment, not a place to keep the pipeline’s inputs. Three things belong in it and three do not, and the boundary matters more as the fleet grows.
Belongs: the interpreter, the libraries, and the configuration that must be identical everywhere. That includes the GDAL environment variables above, the PROJ database, any datum grids the transformations depend on, and the pipeline code itself. All of these are the same for every job, they are small relative to imagery, and having them baked in means a job needs nothing but an item identifier to run.
Does not belong: credentials, imagery, and per-run configuration. Credentials should arrive from the platform’s identity mechanism at runtime, never from a layer — an image is copied, cached and shared, and a secret in one is a secret in all of them. Imagery is orders of magnitude larger than the code and changes constantly, so it belongs in object storage and is read with the windowed patterns from Reading a COG over S3 Without Downloading. Per-run configuration — dates, areas, thresholds — belongs in the job’s parameters, because baking it in means a new image for every run and destroys the cache.
The interesting boundary case is reference data: a small DEM, a coastline, a set of training polygons. If it is small, changes rarely, and every job needs it, baking it in removes a download per task and is worth the megabytes. If it is large or updated independently of the code, keep it in storage and read it like any other input. A useful test is whether an update to that file should require a new image build — if the answer is no, it does not belong in the image.
One more rule earns its keep at scale: one image per pipeline, not one per step. Separate images for “download”, “process” and “summarise” triple the number of things to pin, rebuild and pull, while the code they contain is nearly identical. A single image with several entry points keeps the environment identical across steps, which is exactly the property containers were adopted for.
Registries, caching and the cost of a rebuild
Where the image lives is as operationally significant as what is in it, and three properties of the registry decide how much time a fleet spends waiting.
Region proximity comes first. Pulling a 500 MB image from a registry on another continent costs both latency and inter-region transfer, per container. For an array job of ten thousand tasks that is a line item, not a rounding error, and the remedy is the same as for imagery: put the bytes near the compute, as argued in Reducing S3 Egress Costs in Raster Pipelines.
Layer reuse comes second. Compute fleets cache layers, so a fleet that has already run yesterday’s image pulls only the layers that changed. This is why the layer ordering above pays twice: it speeds up the build, and it means a code-only change ships a few megabytes to every node instead of the whole image. Rebuilding from a different base, or reordering the Dockerfile, invalidates the cache for everyone at once.
Tag discipline comes third and is the one most often skipped. A job definition that references
:latest is not reproducible: two tasks in the same run can pull different images if a build lands
between them, and re-running last month’s job silently uses this month’s code. Tag with the commit
SHA, reference that tag in the job definition, and let :latest exist only as a convenience for
humans.
A related question is when to rebuild at all. Three triggers are worth automating: a change to the lock file, a change to the pipeline code, and a periodic rebuild — monthly is common — to pick up base-image security updates. The third is the one that surprises teams, because it is the only one that can change behaviour without anyone editing a file. Running the smoke test on every rebuild is what turns that from a risk into a caught regression.
Finally, keep the build reproducible enough to be diagnostic. If an image built today behaves
differently from one built in March, the fastest way to find out why is to compare the two
versions.json files rather than to bisect the Dockerfile. That file costs one build step and has
answered more “why did this change?” questions than any amount of logging — the same argument for
recording provenance made in
Writing and Validating Cloud-Optimized GeoTIFFs.
Running the same image locally and in the fleet
A container is only useful as a reproducibility tool if the environment developers use is the one that runs in production. Two habits keep them aligned.
Develop inside the image rather than beside it. Mounting the working tree into the container and running the pipeline there means the interpreter, GDAL and PROJ are the production ones, so an environment problem surfaces on a laptop instead of in a job log. It also removes the class of bug where code works locally because a system library happens to be installed.
Pass configuration the same way in both places. If the job scheduler supplies an item identifier through an environment variable, the local run should use the same variable rather than a command-line argument, so there is one code path. The array-index pattern in Processing COGs on AWS Batch with Docker is easy to reproduce locally by setting the variable by hand, and doing so catches entry-point bugs before they reach a fleet.
Verification and testing
Test the image, not the code. Three checks are worth automating:
# 1. The stack is what you think it is
docker run --rm raster-pipeline:latest cat /app/versions.json
# 2. A remote read works from inside the image, with the baked settings
docker run --rm raster-pipeline:latest python -c "
import rasterio
with rasterio.open('/vsicurl/https://example.com/tiny_cog.tif') as s:
print(s.profile['tiled'], s.block_shapes[0], s.overviews(1))"
# 3. Two builds of the same commit produce the same versions
docker build -t a . && docker build -t b . && diff <(docker run --rm a cat /app/versions.json) \
<(docker run --rm b cat /app/versions.json)
The third is the one that catches unpinned dependencies, and it is the difference between an image that is reproducible and one that merely worked yesterday.
Troubleshooting
PROJ: proj_create_from_database: Cannot find proj.db
PROJ_DATA points at a path the runtime stage does not contain — usually because a multi-stage build copied site-packages but not the PROJ data directory. Copy both, or let the wheel’s bundled data be found by not setting the variable at all.
The image works locally and fails in the job scheduler
Architecture mismatch: an image built on an arm64 laptop will not run on amd64 compute. Build with --platform linux/amd64 explicitly, which is the CannotPullContainerError case in Processing COGs on AWS Batch with Docker.
Reprojections differ between the container and a workstation
Different PROJ versions, or the container lacks a datum grid the workstation has. Pin PROJ, ship the grids, and keep PROJ_NETWORK=OFF so behaviour does not depend on network reachability — the reproducibility argument in Transforming Point Coordinates with pyproj.
Every job spends a minute in STARTING
The image is large and not cached on the compute fleet. Slim the image, and prefer a registry in the same region as the compute so the pull is not crossing a network boundary.
pip install succeeds locally and fails in the build
A dependency needs a compiler that the slim base does not have. Either use a multi-stage build with a builder that does, or find a wheel — for the geospatial stack, a missing wheel usually means the version pin is older than the wheel coverage.
Frequently Asked Questions
Q: Wheels or system GDAL? Binary wheels for most pipelines: rasterio ships its own GDAL and PROJ, so the image is smaller, the build is faster and the versions are pinned by the wheel. Use a system GDAL image when you need the command-line tools, a driver the wheel omits, or a GDAL version the wheels do not offer.
Q: Why does the same code give different numbers in the container? Almost always a different PROJ version or a missing datum grid, which changes the transformation path chosen for a reprojection. Pin PROJ and ship the grids you depend on rather than relying on network access at runtime.
Q: Does image size actually matter? It matters most for array jobs, where every container pulls the image before doing any work. Shaving a 2 GB image to 600 MB removes tens of seconds per task, which across ten thousand tasks is hours of billed time.
Related
- Building a Slim GDAL Docker Image — the multi-stage build that produces the smaller image above.
- Pinning GDAL and PROJ Versions Reproducibly — making two builds of the same commit identical.
- Caching PROJ Data and GDAL Config in Containers — the runtime settings and grid files this topic bakes in.
- Processing COGs on AWS Batch with Docker — where these images actually run.
- Optimizing Pipeline Cost and Performance — how image pull time fits into the wider cost picture.