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:
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 \
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 /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 /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 CMDshell forms andsh -centrypoints 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/amd64andlinux/arm64in onebuildxrun 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.
Related
- Pinning GDAL & PROJ Versions in Docker Images — sibling page; pin the stack and keep the grids you must not prune away
- Containerizing Marine Geospatial Pipelines with Docker — parent workflow for building marine geospatial containers
- Kubernetes Job Scheduling for Bathymetric Processing — where smaller images pay off as faster pod cold-starts across many workers