Building Minimal Docker Images with Alpine and GDAL
Use a two-stage alpine:3.19 build: compile GDAL and Python bindings in stage 1, then COPY only the runtime .so files and site-packages into a clean stage 2. Strip debug symbols with strip --strip-unneeded before the copy. The result fits comfortably under 250 MB uncompressed, which is safe for AWS Lambda (10 GB image limit), GCP Cloud Run (32 GB), and Azure Container Apps — while keeping cold-start latency well below 1.5 s.
Context
Docker Container Optimization for GIS sets the container size budget and explains how deployment package size directly drives cold-start latency. Within that constraint, Alpine-based images are the most aggressive size-reduction option available — a naive python:3.11 base with GDAL installed runs past 1 GB, while the Alpine multi-stage approach described here typically lands around 180–220 MB.
The failure mode this technique prevents is exceeding platform deployment limits or triggering cold starts long enough to breach function timeout budgets. Cold Start Mapping for Python GDAL documents that libgdal.so alone accounts for 40–60 % of the shared-library resolution phase — so cutting image size and stripping debug symbols directly reduces that phase duration. On AWS Lambda, a 10 GB /tmp ephemeral store (Ephemeral Storage Limits in AWS Lambda) is not consumed by the image itself, but the container pull still blocks the first invocation until the image layer is cached in the execution environment.
Prerequisites
- Docker 24+ (multi-stage
COPY --fromis stable since Docker 17.05, but BuildKit is enabled by default from 24+) alpine:3.19oralpine:3.20pinned in both stages — do not floatlatest- GDAL 3.7+ available in Alpine’s
communityrepository (already enabled in 3.19) PROJ_LIBandGDAL_DATAwritable by the runtime user; no IAM permissions required for local builds, but see IAM Security Boundaries for Cloud GIS when pulling from a private registry- Python 3.11+ (
py3-gdalon Alpine 3.19 ships against Python 3.11) - No
manylinuxwheels — Alpine usesmusllibc; see the Gotchas section below
Implementation
The Dockerfile below is production-ready. Read the inline comments — several of them call out decisions that are easy to get wrong.
# ─── Stage 1: builder ─────────────────────────────────────────────────────────
FROM alpine:3.19 AS builder
# Build toolchain + all GDAL and Python development headers.
# apk resolves PROJ, GEOS, SQLite, TIFF, JPEG, PNG, WebP, and CURL transitively.
RUN apk add --no-cache \
build-base cmake python3-dev py3-pip \
proj-dev sqlite-dev libtiff-dev libjpeg-turbo-dev \
libpng-dev libwebp-dev curl-dev zlib-dev \
geos-dev expat-dev \
gdal gdal-dev py3-gdal
# Strip debug symbols from the shared library *before* COPY.
# This cuts libgdal.so size by 30–40 % with no runtime impact.
RUN strip --strip-unneeded /usr/lib/libgdal.so.* 2>/dev/null || true
# ─── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM alpine:3.19
# Only runtime packages — no compilers, no *-dev headers.
# These packages resolve the same .so chain that libgdal was compiled against.
RUN apk add --no-cache \
python3 py3-pip \
libtiff libjpeg-turbo libpng libwebp curl \
sqlite-libs proj geos expat
# Detect the Python minor version in one RUN to avoid hard-coding 3.11.
# Alpine 3.19 ships 3.11; Alpine 3.20 ships 3.12. Parameterise to be safe.
ARG PY_VER=3.11
# Copy stripped GDAL shared library.
COPY --from=builder /usr/lib/libgdal.so.* /usr/lib/
# Copy the Python GDAL bindings from builder's site-packages.
COPY --from=builder /usr/lib/python${PY_VER}/site-packages/osgeo \
/usr/lib/python${PY_VER}/site-packages/osgeo
COPY --from=builder /usr/lib/python${PY_VER}/site-packages/gdal.py \
/usr/lib/python${PY_VER}/site-packages/gdal.py
# Copy GDAL and PROJ data directories — required for CRS lookups and drivers.
# Without /usr/share/proj, ImportFromEPSG() raises a proj.db not found error.
COPY --from=builder /usr/share/gdal /usr/share/gdal
COPY --from=builder /usr/share/proj /usr/share/proj
# These env vars are mandatory — never leave them to be set externally.
# GDAL and PROJ cannot locate their data files without them in a container.
ENV GDAL_DATA=/usr/share/gdal \
PROJ_LIB=/usr/share/proj \
PYTHONPATH=/usr/lib/python${PY_VER}/site-packages \
PYTHONUNBUFFERED=1
# GDAL serverless tuning: disable filesystem scans and HTTP caching
# that waste cycles in stateless, ephemeral execution environments.
ENV GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
CPL_VSIL_CURL_CACHE_SIZE=0 \
VSI_CACHE=FALSE
WORKDIR /app
COPY requirements.txt .
# Install only pure-Python or musl-compatible wheels from requirements.txt.
# Do NOT include rasterio or fiona here via PyPI — use apk or compile from source.
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "handler.py"]
Why strip must run inside stage 1, not stage 2
Once files are copied into the runtime stage the builder context is gone. Run strip in stage 1 so the slimmed .so is what gets transferred. On a typical GDAL 3.7 build, libgdal.so drops from ~85 MB to ~52 MB after stripping.
Verification
Run each check in order. A failure in step 2 usually points to a missing COPY or a wrong PY_VER argument; a failure in step 3 means /usr/share/proj was not copied correctly.
# 1. Size gate — must be under 250 MB uncompressed
docker images --format "{{.Repository}}:{{.Tag}} {{.Size}}" | grep your-image-name
# 2. GDAL import test — confirms libgdal.so and site-packages are wired correctly
docker run --rm your-image:latest python3 -c "
from osgeo import gdal
print('GDAL version:', gdal.__version__)
print('GDAL_DATA:', gdal.GetConfigOption('GDAL_DATA'))
"
# Expected output:
# GDAL version: 3.7.x
# GDAL_DATA: /usr/share/gdal
# 3. CRS round-trip — confirms PROJ data and proj.db are present
docker run --rm your-image:latest python3 -c "
from osgeo import osr
srs = osr.SpatialReference()
srs.ImportFromEPSG(4326)
wkt = srs.ExportToWkt()
assert 'WGS 84' in wkt, 'CRS lookup failed — check PROJ_LIB'
print('CRS OK:', wkt[:40], '...')
"
# 4. Cold-start benchmark — target under 1.5 s
time docker run --rm your-image:latest python3 handler.py
Gotchas and Edge Cases
-
manylinuxwheels silently link againstglibcsymbols.pip install rasterioinside an Alpine container downloads amanylinux_2_17_x86_64wheel that callslibm.so.6— a symbol that does not exist in musl. The error surfaces asError loading shared library libm.so.6: No such file or directoryat import time, not at install time. Fix: useapk add py3-rasterio py3-fionato install Alpine-native builds, or add--no-binary rasterioto force a source compile inside the builder stage. -
Python version mismatch between stages. If you
apk add python3in the runtime stage but Alpine upgrades the minor version between the builder pull and the runtime pull (e.g., builder got 3.11, runtime got 3.12 from a later image cache), theCOPYpath/usr/lib/python3.11/site-packages/osgeowill not exist in the runtime layer. Pinapk add python3=3.11.*explicitly or passARG PY_VERto both stages. -
PROJ_LIBset to the wrong path kills all CRS operations. PROJ 9 looks forproj.dbinside$PROJ_LIB. If the variable is unset or points at an empty directory, everyImportFromEPSG()call raisesPROJ: proj_create_from_database: Cannot find proj.db. TheENVline in the Dockerfile is mandatory — do not rely on PROJ’s compiled-in default paths, which differ between Alpine and the base image used in local development. -
docker layer cachingonCOPY . .invalidates the size optimizations. PlaceCOPY . .last, after allapk,pip, andCOPY --from=builderinstructions. A source-code change then only invalidates the final layer, keeping the heavy GDAL layers cached across builds.
Frequently Asked Questions
Why do manylinux wheels fail inside Alpine containers?
Alpine uses musl libc instead of glibc. Pre-built manylinux wheels link against glibc symbols (libm.so.6, libc.so.6) that do not exist in musl. You must either install Alpine-native packages via apk (py3-gdal, py3-rasterio) or compile from source inside the Alpine build stage.
What is the safe uncompressed size target for serverless container images?
Target under 250 MB uncompressed. AWS Lambda container images support up to 10 GB, GCP Cloud Functions 2nd gen up to 32 GB, and Azure Container Apps up to several GB — but smaller images reduce cold-start latency and registry pull time regardless of hard limits.
Which Alpine version should I pin for GDAL 3.x?
Pin alpine:3.19 or alpine:3.20. Both carry GDAL 3.7+ in the community repository and use PROJ 9.x, satisfying the GDAL 3+ requirement for PROJ 6+. Avoid floating the latest tag — a minor Alpine bump can pull a newer GDAL that changes driver registration order or drops deprecated options.
Related
- Docker Container Optimization for GIS — size budget rationale, registry pull latency analysis, and platform comparison
- Python Layer Management and Size Reduction — Lambda layer packaging as an alternative to container images when image sizes exceed pipeline limits
- Stripping Unnecessary Python Packages from AWS Lambda Layers — same strip-and-prune philosophy applied to zip-based Lambda layers
- Cold Start Mapping for Python GDAL — measures how image size and shared-library count translate to cold-start durations
- Native Library Compilation for Serverless — when Alpine’s
apkpackages are insufficient and you must compile GDAL from source
Back to Packaging & Dependency Management for Serverless GIS