Reducing Geospatial Docker Image Size

A marine geospatial image that ships GDAL, PROJ, rasterio, xarray and their compiled dependencies routinely balloons past two or three gigabytes, and on the ephemeral, autoscaling cloud nodes used for the workloads in Containerizing Marine Geospatial Pipelines with Docker that heft is a recurring tax: every cold-start pulls the whole image before a single sounding is processed, and every pull bills registry egress. When a Kubernetes job scales from zero to fifty pods to grind through a survey backlog, a 2.8 GB image can add minutes of pull latency per node and dominate the egress line on the cloud invoice. This guide takes a fat baseline down to a lean runtime with a multi-stage build, BuildKit cache mounts, region-pruned PROJ grids and disciplined cache removal — while proving at each step that rasterio, pyproj and the datum transforms still work.

Root Cause: Why Geospatial Images Are Fat

The bloat is not one thing; it is five compounding ones. A naive single-stage Dockerfile leaves all of them in the shipped layers:

# Anti-pattern baseline: everything in one stage, nothing cleaned
FROM python:3.11
RUN apt-get update && apt-get install -y build-essential gdal-bin libgdal-dev
RUN pip install rasterio pyproj xarray rioxarray geopandas
COPY . /app

Inspecting the result shows where the weight lives — the base image, the compiler toolchain, duplicated GDAL/PROJ data, the pip cache, and the full global PROJ grid set:

docker build -t marine-geo:fat .
docker image ls marine-geo:fat
# REPOSITORY     TAG   SIZE
# marine-geo     fat   2.81GB

# Layer-by-layer breakdown (dive-style): use docker history to see the fat layers
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' marine-geo:fat | head
#  980MB   apt-get install build-essential gdal-bin libgdal-dev   <- toolchain + headers
#  610MB   pip install rasterio pyproj xarray rioxarray geopandas <- wheels + pip cache
#  520MB   proj-data global grid set                              <- most grids unused
#  190MB   /root/.cache/pip                                       <- cache never removed

The five culprits: (1) a full python:3.11 base instead of a slim one; (2) build-essential and -dev headers left in the final layer; (3) GDAL data and PROJ grids duplicated between the system package and the Python wheels; (4) an un-pruned pip cache and __pycache__; and (5) the global PROJ grid set when a single survey region needs a few grids. The pruning of grids interacts directly with correctness, so it must be done by region rather than blindly — the coupling is explained in Pinning GDAL & PROJ Versions in Docker Images.

The diagram contrasts the fat single-stage layout with the target multi-stage one:

Single-Stage Fat Image Versus Multi-Stage Lean Image Left: a single tall bar labelled fat image, split into four segments from bottom to top — full base 1 GB, build toolchain 0.98 GB, wheels plus pip cache 0.8 GB, global PROJ grids 0.52 GB — totalling about 2.8 GB. Right: a builder stage box that installs the toolchain, builds wheels and runs projsync for the region, marked as discarded, with an arrow carrying only the installed environment and regional grids into a short runtime bar built on a slim base, totalling about 0.62 GB. single-stage: ~2.8 GB full base toolchain wheels+cache global grids builder stage (discarded) install toolchain build wheels projsync region thrown away copy env + regional grids runtime: ~0.62 GB slim base installed env region grids

Step 1 — Measure the Baseline

You cannot optimize what you do not measure, and layer-level attribution tells you which of the five culprits dominates your image. Capture size and a per-layer breakdown before changing anything:

# Total size
docker image ls --format '{{.Repository}}:{{.Tag}}  {{.Size}}' marine-geo:fat

# Per-layer attribution; the biggest CreatedBy lines are your targets.
# 'dive' gives an interactive view; docker history is the dependency-free equivalent.
docker history --human --format '{{.Size}}\t{{.CreatedBy}}' marine-geo:fat

Record the number so each later step can be checked against it; a “smaller” image that quietly dropped a grid and now returns wrong depths is a regression, not a win.

Step 2 — Split Into a Builder and a Slim Runtime Stage

The single largest lever is a multi-stage build: compile everything in a throwaway builder that carries the toolchain, then copy only the finished environment into a slim runtime that never sees a compiler. Install into a self-contained virtualenv so the copy is a single directory:

# syntax=docker/dockerfile:1.7
# ---------- builder stage ----------
FROM python:3.11-slim-bookworm AS builder

ENV DEBIAN_FRONTEND=noninteractive
# -dev headers and compilers live ONLY in this stage and are discarded
RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential libgdal-dev gdal-bin \
    && rm -rf /var/lib/apt/lists/*

# Self-contained venv makes the runtime copy a single COPY --from
ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv "$VIRTUAL_ENV"
ENV PATH="$VIRTUAL_ENV/bin:$PATH"

COPY requirements-geo.txt /tmp/
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir -r /tmp/requirements-geo.txt

Step 3 — Cache Wheels With BuildKit, Copy Only the Runtime

The --mount=type=cache above keeps pip’s download/wheel cache outside the image layers, so rebuilds are fast without the 190 MB cache ever being committed. The runtime stage then starts from a fresh slim base and copies only the venv and the runtime GDAL/PROJ shared libraries — no headers, no compiler:

# ---------- runtime stage ----------
FROM python:3.11-slim-bookworm AS runtime

# Runtime shared libs only (libgdal, libproj) — NOT the -dev packages
RUN apt-get update && apt-get install -y --no-install-recommends \
        libgdal32 libproj25 \
    && rm -rf /var/lib/apt/lists/*

# Copy the fully-built environment from the discarded builder
ENV VIRTUAL_ENV=/opt/venv
COPY --from=builder /opt/venv /opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"

Using --no-install-recommends alone typically strips 100–200 MB of pulled-in suggested packages, and dropping build-essential from the final image removes close to a gigabyte.

Step 4 — Prune PROJ Grids by Region

The global PROJ grid set is hundreds of megabytes and most of it is irrelevant to any single survey. In the builder stage, sync only the grids inside your working bounding box, then copy that pruned directory into the runtime. Prune by region, never by deleting grids blindly, because a missing separation grid silently degrades datum transforms:

# In the builder stage: fetch only the grids for the survey footprint
ENV PROJ_DATA=/opt/proj
RUN mkdir -p /opt/proj && \
    projsync --target-dir /opt/proj --bbox -80,36,-72,42 --quiet
# In the runtime stage: carry only the pruned grid set and pin the network off
COPY --from=builder /opt/proj /opt/proj
ENV PROJ_DATA=/opt/proj \
    PROJ_NETWORK=OFF

Which grids are safe to drop is precisely the concern of Pinning GDAL & PROJ Versions in Docker Images; pair region-pruning with the golden-transform self-check described there so a too-aggressive prune fails the build instead of shipping shifted coordinates.

Step 5 — Strip Caches, Tests and Add a .dockerignore

The last few hundred megabytes are byte-compiled caches, bundled test suites inside heavy packages, and build context that should never enter the image at all. Remove them in a single layer, and stop them at the door with .dockerignore:

# Remove __pycache__ and vendored test trees from the copied environment
RUN find /opt/venv -type d -name "__pycache__" -prune -exec rm -rf {} + && \
    find /opt/venv -type d -name "tests" -prune -exec rm -rf {} + && \
    find /opt/venv -type f -name "*.pyc" -delete
# .dockerignore — keep local caches and data out of the build context entirely
.git
__pycache__/
*.pyc
.venv/
tests/
data/*.las
data/*.tif
*.ipynb_checkpoints

Step 6 — Smoke-Test the Slim Image

A smaller image is only correct if the stack still imports and transforms. Bake a smoke test that asserts rasterio and pyproj load and that a region-dependent transform still resolves through the pruned grids:

#!/usr/bin/env python3
"""Smoke-test the slimmed image: assert rasterio/pyproj import and a gridded transform works."""
import logging
import sys

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("image-smoketest")

# Region-dependent transform; must stay within tolerance of the value from the full-grid image.
EXPECTED_X: float = 456834.267100
EXPECTED_Y: float = 4262124.518900
TOLERANCE_M: float = 0.01


def smoke_test() -> None:
    """Import the geospatial stack and assert a gridded transform survived grid pruning."""
    try:
        import rasterio
        import pyproj
        from pyproj import Transformer
    except ImportError as exc:  # a stripped runtime lib or missing wheel dependency
        raise SystemExit(f"Geospatial import failed in slim image: {exc}") from exc

    logger.info("rasterio %s | pyproj %s | grids %s",
                rasterio.__version__, pyproj.__version__, pyproj.datadir.get_data_dir())

    transformer = Transformer.from_crs("EPSG:6318", "EPSG:6347", always_xy=True)
    x, y = transformer.transform(-75.0, 38.5)
    if abs(x - EXPECTED_X) > TOLERANCE_M or abs(y - EXPECTED_Y) > TOLERANCE_M:
        raise SystemExit(
            f"Transform drifted to {x:.4f},{y:.4f} — grid prune removed a needed grid."
        )
    logger.info("Smoke test passed; slim image is functional.")


if __name__ == "__main__":
    try:
        smoke_test()
    except SystemExit as exc:
        logger.error("%s", exc)
        sys.exit(1)

Verification

Confirm both the size win and continued function:

DOCKER_BUILDKIT=1 docker build -t marine-geo:slim .
docker image ls --format '{{.Tag}}  {{.Size}}' marine-geo
# fat    2.81GB
# slim   0.62GB   <- ~78% smaller

docker run --rm marine-geo:slim python /opt/smoke_test.py
# Expect: "Smoke test passed; slim image is functional."

The build is acceptable only when the slim image is materially smaller and the smoke test passes — a shrink that breaks the transform is a failed build, and the size reduction directly shortens the pull time for the parallel workers scheduled by Kubernetes Job Scheduling for Bathymetric Processing.

Edge Cases and Gotchas

  • Distroless and shell-based healthchecks. A distroless runtime has no shell, so HEALTHCHECK CMD shell forms and sh -c entrypoints fail. Either keep a slim (not distroless) base, or ship a static healthcheck binary and use the exec form. Weigh the last ~30 MB of savings against losing shell-based debugging on a production node.

  • Over-pruning PROJ grids breaks datum transforms silently. Trimming grids outside your bounding box is safe; trimming a separation or NADCON grid your transform actually uses makes PROJ fall back to a coarser pipeline with no error. Always run the Step 6 transform assertion after any grid change, and cross-check against Pinning GDAL & PROJ Versions in Docker Images.

  • Multi-arch images can silently pull the wrong wheels. Building linux/amd64 and linux/arm64 in one buildx run can resolve different GDAL/PROJ wheel builds per architecture, so a grid path or shared-lib name that exists on one arch is absent on the other. Run the smoke test in each target platform, not just the host arch.


Up: Containerizing Marine Geospatial Pipelines with Docker