Containerizing GDAL Workers With Docker

The image is where a spatial worker’s behaviour is actually decided. GDAL, PROJ, GEOS and the transformation grids determine what a reprojection returns, and Python’s role is to call them — so the Dockerfile is closer to a specification of correctness than to packaging. Build it in stages ordered by how often each part changes, bake the grids, install exactly what the lock file names, and finish with a smoke test that runs inside the image rather than beside it.

When to Use This Pattern

  • Workers run reprojections, warps or format conversions, which is every spatial pipeline.
  • More than one worker exists, so consistency between them stops being automatic.
  • The pipeline publishes something whose registration against other data matters.
  • Builds happen often and a two-minute rebuild for a code change is worth engineering for.

Complete Working Example

Four stages, ordered so that the fastest-changing thing is last.

# syntax=docker/dockerfile:1.7
ARG GDAL_DIGEST=sha256:8c1f...            # resolved in CI, recorded in the build record
FROM ghcr.io/osgeo/gdal@${GDAL_DIGEST} AS base

ENV PROJ_NETWORK=OFF \
    PROJ_DATA=/usr/share/proj \
    GDAL_CACHEMAX=512 \
    GDAL_NUM_THREADS=2 \
    GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
    CPL_VSIL_CURL_ALLOWED_EXTENSIONS=.tif,.tiff,.vrt,.gpkg,.fgb \
    VSI_CACHE=TRUE \
    VSI_CACHE_SIZE=26214400 \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

# ---- grids: change perhaps twice a year -----------------------------------
FROM base AS grids
RUN projsync --system-directory --bbox -12,49,3,61 --quiet

# ---- python deps: change monthly ------------------------------------------
FROM base AS deps
COPY requirements.lock /tmp/requirements.lock
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --require-hashes --no-deps -r /tmp/requirements.lock

# ---- runtime: application code changes several times a day ----------------
FROM base AS runtime
COPY --from=grids /usr/share/proj /usr/share/proj
COPY --from=deps  /usr/lib/python3/dist-packages /usr/lib/python3/dist-packages
RUN useradd --uid 10001 --create-home worker
COPY --chown=worker:worker src/ /app/src/
USER worker
WORKDIR /app
HEALTHCHECK --interval=60s --timeout=10s CMD python -m src.health
ENTRYPOINT ["python", "-m", "src.worker_boot"]

The smoke test is a build stage, so a bad image cannot be published:

FROM runtime AS smoke
USER root
COPY tests/fixtures/ /fixtures/
RUN set -eu; \
    gdalinfo --formats | grep -q " GTiff "; \
    gdalinfo --formats | grep -q " GPKG "; \
    python - <<'PY'
import pyproj, rasterio
from rasterio.warp import calculate_default_transform

# 1. The bindings and the system library must agree.
assert rasterio.__gdal_version__ == "3.9.2", rasterio.__gdal_version__

# 2. A transformation with a known answer, to prove the grid is present.
t = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:27700")
e, n = t.transform(55.9533, -3.1883)
assert abs(e - 325863.108) < 0.01 and abs(n - 673795.836) < 0.01, (e, n)

# 3. A real warp, because reading a header is not the same as resampling.
with rasterio.open("/fixtures/scene.tif") as src:
    calculate_default_transform(src.crs, "EPSG:3857", src.width, src.height, *src.bounds)
print("smoke ok")
PY
Layers ordered by how often they changeThe base and grid layers change twice a year, dependencies monthly and application code daily. Ordering them this way means a code change rebuilds only the top layer.base — GDAL, PROJ, GEOS by digestchanges twice a yeargrids — projsync for the service areachanges twice a yeardeps — from the lock file, no resolutionchanges monthlycode — a few megabyteschanges dailyA code change rebuilds the green layer only, so the native stack below it is byte-identical between deployments.
The ordering is a build-speed optimisation and a correctness guarantee at the same time: an unrebuilt layer cannot have changed.

GDAL_NUM_THREADS=2 is the setting most often left at its default and most often wrong on a worker. GDAL will happily use every core it can see for a single warp, which is excellent on a workstation and counterproductive on a worker running four concurrent tiles — four warps each claiming sixteen cores produces contention, not throughput, and inflates memory as each thread takes its own buffers. Setting it explicitly to a small number, and letting the orchestrator’s concurrency provide the parallelism, gives predictable per-task behaviour, which is what the memory arithmetic in right-sizing workers depends on.

Parameter & Option Reference

Setting Value Spatial notes
Base image @sha256: digest Resolve the tag in CI and pass it as a build argument. Tags move.
PROJ_NETWORK OFF With grids baked in. ON means an offline worker silently degrades its accuracy.
PROJ_DATA /usr/share/proj Explicit, because the default varies between builds and distributions.
GDAL_NUM_THREADS 1–2 Parallelism belongs to the orchestrator; per-warp threads fight each other.
GDAL_CACHEMAX 512 (MiB) Per process. The default can be a large fraction of the host.
pip install --require-hashes --no-deps Stops a wheel bringing a second bundled native stack.
Non-root user uid 10001 Nothing in a worker needs root, and scratch directories should be owned by it.
HEALTHCHECK 60 s Checks the process, not the stack; the stack is asserted once at boot.
What layer order costs on an ordinary changeWith code copied last, a code change rebuilds in about fifty seconds. With code copied before the dependency install, the same change rebuilds everything.rebuild after a one-line change in src/code copied last50 s — one layercode copied first11 min — grids, deps and everything above itThe second case also re-runs projsync, so two builds of the same commit can differ if the gridarchive was updated between them.
The reproducibility argument is the stronger one: a layer that is rebuilt is a layer that can change.

Verification & Testing

The smoke stage above is the primary gate. These run in CI against the built image and cover what it cannot.

import subprocess


def run_in_image(digest: str, script: str) -> str:
    return subprocess.run(
        ["docker", "run", "--rm", "--network", "none", digest, "python", "-c", script],
        capture_output=True, text=True, check=True,
    ).stdout


def test_no_network_needed_for_a_transformation(image_digest) -> None:
    # --network none: proves the grids are baked rather than fetched.
    out = run_in_image(image_digest, TRANSFORM_PROBE)
    assert "325863.108" in out


def test_bindings_and_system_library_agree(image_digest) -> None:
    out = run_in_image(image_digest,
                       "import rasterio;print(rasterio.__gdal_version__)")
    cli = subprocess.run(["docker", "run", "--rm", image_digest, "gdalinfo", "--version"],
                         capture_output=True, text=True).stdout
    assert out.strip() in cli


def test_image_runs_as_non_root(image_digest) -> None:
    out = subprocess.run(["docker", "run", "--rm", image_digest, "id", "-u"],
                         capture_output=True, text=True).stdout
    assert out.strip() == "10001"


def test_no_credentials_baked_in(image_digest) -> None:
    layers = subprocess.run(["docker", "history", "--no-trunc", image_digest],
                            capture_output=True, text=True).stdout
    assert "AWS_SECRET" not in layers and "PGPASSWORD" not in layers

The --network none test is the sharpest of the four. A transformation that works with networking enabled proves nothing about the image, because PROJ may have fetched the grid it needed from a CDN at run time — which will also happen in production, until the day the worker has no egress or the CDN is slow, at which point the transformation silently becomes less accurate. Running the probe with networking disabled is the only way to prove the grid is in the image.

What network isolation revealsWith networking enabled the probe passes because PROJ fetched the grid. With networking disabled it fails, showing the grid was never in the image.the same probe, two network settingsnetwork enabledpasses — grid fetchedand cached, invisiblynetwork disabledfails — 1.9 m outthe truth about the imageIn production the first row is what happens most of the time and the second is what happens on theworker in the private subnet, on the night the CDN is slow.
Testing with the network available tests the internet as much as the image, which is not what the build is trying to establish.

A last word on the environment block, because its placement is not incidental. Every one of those variables changes what the library does — how much it caches, how many threads it uses, whether it lists a prefix before opening a file, which extensions it will fetch over HTTP — and every one of them has a default that is reasonable for an interactive workstation and wrong for a worker running four tasks at once. Putting them in the image means they are part of the artefact that gets promoted, tested and rolled back as a unit. Putting them in the flow means a new code path can omit one, and the omission shows up as a cost or a memory problem weeks later with nothing connecting it to a change.

The corollary is that the image, not the code, is where the pipeline’s operational tuning lives, and it deserves to be reviewed as such. A change from GDAL_CACHEMAX=512 to 2048 is a change to every worker’s memory arithmetic and belongs in a pull request that says so, rather than in a hurried edit to fix one slow layer. Treating the Dockerfile as configuration that happens to be code — reviewed, versioned, promoted by digest — is the practice that keeps a fleet’s behaviour explicable.

Common Pitfalls

  • Installing the native stack at container start. Every worker then resolves independently, and the fleet drifts within a day.
  • Copying application code before installing dependencies. Every code change rebuilds everything, including the grid sync, so two builds of one commit can differ.
  • Leaving PROJ_NETWORK=ON with no baked grids. Accuracy then depends on egress, and the degradation is silent.
  • Letting pip resolve dependencies. A wheel with a bundled GDAL beside the system one produces a worker with two native stacks.
  • Default GDAL_NUM_THREADS. One warp claims every core, and four concurrent warps contend rather than scale.
  • Running as root. Nothing needs it, and scratch directories written as root are awkward for everything that follows.

Frequently Asked Questions

`ubuntu-small` or the full OSGeo image?

Start with ubuntu-small and add what is missing. The full image carries drivers most pipelines never use, which is several hundred megabytes of pull time per worker and additional parser surface at the ingest boundary. Adding a driver deliberately is better than inheriting a hundred and forty.

How large should the image be?

Under about 1.5 GiB is comfortable; beyond 3 GiB, pull time starts to matter on a scaling fleet. The grids are usually the largest optional component, which is why projsync --bbox for the service area rather than the whole world is worth doing.

Should the fixtures live in the image?

In the smoke stage, not the runtime stage. Copying them in a stage that is discarded keeps the published image free of test data while still proving the image can process real files.

What about conda?

Conda handles the native stack well and produces larger images and slower builds. Where the dependency set is complex enough that pip and system packages fight, it is the right tool; see pinning geospatial dependencies with conda-lock.

Does the same image serve development?

It should, with the source mounted rather than copied. That is the cheapest way to get parity between a laptop and the fleet; see environment parity for spatial pipelines.

Deployment & CI/CD for Spatial Workers