Skip to content

Native Library Compilation for Serverless GIS

Compiling GDAL, PROJ, GEOS, and PDAL for serverless runtimes requires strict OS alignment and explicit binary packaging — a mismatched glibc version causes an immediate GLIBC_X.XX not found failure at invocation with no fallback. The core rule is: always compile inside a Docker container that exactly mirrors the target runtime, then validate every shared object with ldd before packaging. This page is part of the broader Packaging & Dependency Management for Serverless GIS discipline, where deterministic, reproducible builds are a hard requirement, not a preference.

Why Native Library Compilation Matters for Geospatial Workloads

Geospatial libraries are C/C++ extensions at their core. rasterio, shapely, pyproj, and fiona are all thin Python wrappers around compiled shared objects (.so files). When these run on a traditional VM, the OS package manager handles installation of compatible binaries. Serverless runtimes eliminate that assumption entirely: the function container is immutable, read-only except for /tmp, and pre-seeded only with Python itself plus a minimal set of system libraries.

For raster workloads, a single GDAL driver registration sweep loads dozens of shared objects at cold-start time. Each missing .so aborts the import chain and raises ImportError before your function handler executes. The cold start sequence for Python GDAL is dominated by this shared-library resolution step — which is why binary size and link-time decisions made during compilation directly affect every invocation’s initialization latency.

Vector pipelines face the same constraint: GEOS operations in shapely require a compatible libgeos_c.so, and PROJ coordinate transformations require libproj.so plus a complete proj.db SQLite database. The ephemeral storage limits in AWS Lambda add another constraint — /tmp is capped at 10 GB, so any extraction or decompression of compiled artifacts during initialization must stay within that ceiling.

The diagram below shows where native compilation fits in the full serverless GIS packaging pipeline:

Native Library Compilation Pipeline Flow diagram showing five stages: Runtime-Matched Build Container, then Dependency Resolution (PROJ, GEOS, sqlite3), then Compile + Strip Symbols, then ldd Validation, then Package into Lambda Layer or Container Image. Runtime-Matched Build Container AL2023 / Ubuntu 22.04 Dependency Resolution PROJ, GEOS, sqlite3 Compile + Strip Symbols static + -fPIC + strip ldd Validation + auditwheel no "not found" symbols Lambda Layer / Container Image ≤250 MB unzipped 1 2 3 4 5 Native geospatial library compilation pipeline — every stage must complete without errors before the next begins

Platform-by-Platform Limits

Compilation targets and packaging constraints differ meaningfully across cloud providers. These exact limits govern whether your compiled artifact fits within the deployment budget.

Constraint AWS Lambda GCP Cloud Functions 2nd gen Azure Functions (Consumption)
Base OS Amazon Linux 2023 (glibc 2.34) Ubuntu 22.04 (glibc 2.35) Debian 12 / Ubuntu 22.04
Max deployment package (zipped) 50 MB direct upload; S3 source has no size gate 100 MB (source archive to GCS) 500 MB
Max unzipped deployment 250 MB per layer; 5 layers max 1 GB 500 MB unzipped
Ephemeral /tmp for extraction 10 GB 32 GB 5.5 GB
Max memory 10 GB 32 GB 1.5 GB
Max timeout 15 min 60 min 10 min
ARM64 support Yes (Graviton2/3, arm64) Yes (T2A Arm, preview) No (x86_64 only)
Layer / extension mechanism Lambda Layers (/opt) Not available; use container image Extension bundles or container image

For GCP Cloud Functions and Azure Functions, Docker container optimization for GIS becomes the primary deployment strategy because neither platform supports Lambda-style layers — your compiled binaries must live inside the container image itself.

Step-by-Step Implementation

Step 1: Provision a Runtime-Matched Build Environment

Never compile geospatial libraries on a macOS or Windows workstation. Use a Docker container that mirrors the exact target OS. For AWS Lambda, pull public.ecr.aws/lambda/python:3.11 (Amazon Linux 2023). For GCP and Azure, use ubuntu:22.04.

bash
# AWS Lambda target — Amazon Linux 2023
docker run --rm -it \
  -v "$(pwd)/build":/workspace \
  -w /workspace \
  public.ecr.aws/lambda/python:3.11 bash

# GCP / Azure target — Ubuntu 22.04
docker run --rm -it \
  -v "$(pwd)/build":/workspace \
  -w /workspace \
  ubuntu:22.04 bash

For Lambda Graviton2/3 (arm64) targets, add --platform linux/arm64 to the docker run command. On an x86 host, also install qemu-user-static for cross-architecture emulation.

Step 2: Resolve Transitive Geospatial Dependencies

Geospatial stacks have deep dependency trees. PROJ requires sqlite3 and libtiff. GDAL requires PROJ, GEOS, libcurl, zlib, and libpng. Install all -dev packages inside the build container before touching any ./configure step.

bash
# Amazon Linux 2023 (dnf)
dnf install -y gcc gcc-c++ cmake make pkgconfig \
  sqlite-devel libtiff-devel libcurl-devel zlib-devel \
  libpng-devel proj-devel geos-devel patch git

# Ubuntu 22.04 (apt)
apt-get update && apt-get install -y --no-install-recommends \
  gcc g++ cmake make pkg-config \
  libsqlite3-dev libtiff-dev libcurl4-openssl-dev zlib1g-dev \
  libpng-dev libproj-dev libgeos-dev patch git

After installing system headers, verify the dependency graph with pkg-config:

bash
pkg-config --libs --cflags gdal
pkg-config --modversion proj

An empty output means the header or .pc file is missing — installing the package does not always install the -dev variant. This is the leading cause of GDAL not found errors during pip install rasterio --no-binary rasterio on clean containers.

Step 3: Configure Cross-Compilation Flags

Serverless environments strip many system paths, so binaries must be self-contained. Set CFLAGS, CXXFLAGS, and LDFLAGS explicitly before calling ./configure or cmake.

bash
export PREFIX=/workspace/dist
export CFLAGS="-O2 -fPIC -static-libgcc -static-libstdc++"
export CXXFLAGS="${CFLAGS}"
export LDFLAGS="-static-libstdc++ -Wl,-rpath,/var/task/lib:/opt/lib"
export PKG_CONFIG_PATH="${PREFIX}/lib/pkgconfig"
export LD_LIBRARY_PATH="${PREFIX}/lib"

-fPIC is mandatory for shared libraries. -Wl,-rpath embeds the library search path into each binary so the dynamic linker finds your bundled .so files under /var/task/lib (Lambda) or /opt/lib (Lambda Layer) without relying on system paths.

Step 4: Build with Static Linking and Strip Debug Symbols

Compile each dependency from lowest-level upward: sqlite3zliblibtiff → PROJ → GEOS → GDAL. Use --enable-static --disable-shared on autoconf projects and -DBUILD_SHARED_LIBS=OFF on CMake projects.

bash
# Build PROJ 9.x from source (CMake)
cmake -S proj-9.4.0 -B build-proj \
  -DCMAKE_INSTALL_PREFIX="${PREFIX}" \
  -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_SHARED_LIBS=OFF \
  -DBUILD_TESTING=OFF \
  -DSQLITE3_INCLUDE_DIR=/usr/include \
  -DSQLITE3_LIBRARY=/usr/lib64/libsqlite3.so
cmake --build build-proj --parallel "$(nproc)"
cmake --install build-proj

# Build GEOS 3.12.x from source (CMake)
cmake -S geos-3.12.1 -B build-geos \
  -DCMAKE_INSTALL_PREFIX="${PREFIX}" \
  -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_SHARED_LIBS=OFF
cmake --build build-geos --parallel "$(nproc)"
cmake --install build-geos

# Build GDAL 3.9.x from source (CMake)
cmake -S gdal-3.9.0 -B build-gdal \
  -DCMAKE_INSTALL_PREFIX="${PREFIX}" \
  -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_SHARED_LIBS=ON \
  -DPROJ_ROOT="${PREFIX}" \
  -DGEOS_ROOT="${PREFIX}"
cmake --build build-gdal --parallel "$(nproc)"
cmake --install build-gdal

GDAL is built as a shared library (BUILD_SHARED_LIBS=ON) because the Python extension _gdal.cpython-311-x86_64-linux-gnu.so links against libgdal.so at import time. Strip debug symbols after installation to reduce payload size — this directly lowers cold-start I/O overhead:

bash
find "${PREFIX}/lib" -name "*.so*" -exec strip --strip-unneeded {} +
find "${PREFIX}/lib" -name "*.a"   -exec strip --strip-debug {} +
find "${PREFIX}/bin" -type f       -exec strip --strip-all {} + 2>/dev/null || true

Stripping typically reduces libgdal.so from ~70 MB to ~22 MB and the full dist directory from ~300 MB to ~90 MB — the difference between exceeding and fitting the 250 MB unzipped Lambda Layer limit.

Step 5: Validate Binary Compatibility and Runtime Paths

Before packaging, confirm that all shared objects resolve and that no external system paths are embedded.

bash
ldd "${PREFIX}/lib/libgdal.so" | grep "not found"

Any not found output identifies a missing transitive dependency. For libraries that cannot be statically linked (typically libcurl and OpenSSL on Lambda), bundle them in a lib/ directory alongside your package:

bash
# Copy runtime-required .so files into the package
mkdir -p /workspace/package/lib
cp "${PREFIX}/lib/libgdal.so.35" /workspace/package/lib/
cp "${PREFIX}/lib/libproj.so.25" /workspace/package/lib/
# Copy proj.db — required for coordinate transformations
cp -r "${PREFIX}/share/proj" /workspace/package/

# If RPATH points to absolute paths, rewrite with patchelf
pip install patchelf-wrapper  # or install patchelf via dnf/apt
patchelf --set-rpath '$ORIGIN/../lib' \
  /workspace/package/lib/libgdal.so.35

Set these environment variables explicitly in your Lambda function configuration — never leave them implicit:

bash
GDAL_DATA=/var/task/share/gdal
PROJ_LIB=/var/task/share/proj
LD_LIBRARY_PATH=/var/task/lib:/opt/lib
GDAL_HTTP_UNSAFESSL=YES   # only if libcurl SSL paths diverge from system

The Python layer management and size reduction workflow handles stripping unnecessary Python packages after this step — combine both to stay under the 250 MB layer ceiling.

Step 6: Build the Python Extension Wheel

With native libraries in place, build a platform-specific wheel inside the same container:

python
# build_wheel.py — run inside the build container
import subprocess
import os

env = os.environ.copy()
env.update({
    "GDAL_CONFIG": "/workspace/dist/bin/gdal-config",
    "PROJ_DIR": "/workspace/dist",
    "GEOS_CONFIG": "/workspace/dist/bin/geos-config",
    "LD_LIBRARY_PATH": "/workspace/dist/lib",
})

subprocess.run(
    [
        "pip", "wheel",
        "--no-deps",
        "--wheel-dir", "/workspace/wheels",
        "rasterio==1.3.10",
        "fiona==1.9.6",
        "pyproj==3.6.1",
        "shapely==2.0.5",
    ],
    env=env,
    check=True,
)
bash
python build_wheel.py
# Verify wheel tag matches the Lambda runtime
ls /workspace/wheels/
# Expected: rasterio-1.3.10-cp311-cp311-linux_x86_64.whl

Run auditwheel show to confirm the wheel’s external shared object dependencies:

bash
pip install auditwheel
auditwheel show /workspace/wheels/rasterio-1.3.10-cp311-cp311-linux_x86_64.whl

An auditwheel output listing only libgdal.so and libgeos_c.so as external (not bundled) is acceptable if those are included in your Lambda Layer. If it lists libm.so or libc.so, those are always available on the Lambda runtime — no action needed.

Step 7: Automate via CI/CD

Manual compilation is unsustainable. Automate with GitHub Actions, referencing the CI/CD pipeline sync for geo-dependencies patterns for caching and artifact management:

yaml
# .github/workflows/build-gdal-layer.yml
name: Build GDAL Lambda Layer

on:
  push:
    paths: ['layers/gdal/**']
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        arch: [x86_64, arm64]
    steps:
      - uses: actions/checkout@v4

      - name: Cache compiled artifacts
        uses: actions/cache@v4
        with:
          path: build/dist
          key: gdal-3.9-proj-9.4-geos-3.12-${{ matrix.arch }}-${{ hashFiles('layers/gdal/versions.txt') }}

      - name: Build inside Lambda container
        run: |
          docker run --rm \
            --platform linux/${{ matrix.arch == 'arm64' && 'arm64' || 'amd64' }} \
            -v "${{ github.workspace }}/build":/workspace \
            public.ecr.aws/lambda/python:3.11 \
            bash /workspace/build.sh

      - name: Publish Lambda Layer
        env:
          AWS_REGION: us-east-1
        run: |
          aws lambda publish-layer-version \
            --layer-name "gdal-3-9-py311-${{ matrix.arch }}" \
            --zip-file fileb://build/gdal-layer-${{ matrix.arch }}.zip \
            --compatible-runtimes python3.11 \
            --compatible-architectures ${{ matrix.arch }}

Measurement and Verification

After packaging and deploying, confirm the optimization worked with a minimal cold-start benchmark:

python
# lambda_handler.py — include in your test function
import time
import os

def handler(event, context):
    t0 = time.perf_counter()

    # Trigger GDAL driver registration (the expensive initialization step)
    from osgeo import gdal, osr
    gdal.AllRegister()

    t1 = time.perf_counter()

    # Confirm environment variable paths are set
    gdal_data = gdal.GetConfigOption("GDAL_DATA")
    proj_lib = os.environ.get("PROJ_LIB")

    # Verify PROJ can perform a coordinate transformation
    srs = osr.SpatialReference()
    srs.ImportFromEPSG(4326)

    t2 = time.perf_counter()

    return {
        "gdal_version": gdal.VersionInfo("RELEASE_NAME"),
        "gdal_data": gdal_data,
        "proj_lib": proj_lib,
        "driver_registration_ms": round((t1 - t0) * 1000, 1),
        "proj_roundtrip_ms": round((t2 - t1) * 1000, 1),
    }

Expected output on a well-optimized Lambda (1769 MB, x86_64, static-linked PROJ/GEOS):

json
{
  "gdal_version": "3.9.0",
  "gdal_data": "/var/task/share/gdal",
  "proj_lib": "/var/task/share/proj",
  "driver_registration_ms": 380,
  "proj_roundtrip_ms": 12
}

A driver_registration_ms above 1500 ms indicates oversized or dynamically-linked binaries. Check CloudWatch Init Duration in the Lambda invocation log — values above 5 seconds suggest the deployment package is too large or RPATH resolution is chaining through multiple dynamic lookups. The reducing Python GDAL cold starts with provisioned concurrency guide covers the provisioned concurrency configuration that eliminates this latency for latency-sensitive workloads.

Failure Modes and Debugging

GLIBC_X.XX not found: The build environment used a newer glibc than the Lambda runtime. Rebuild using public.ecr.aws/lambda/python:3.11 exactly — do not substitute an amazonlinux:2023 base image, which can differ in minor library versions. Confirm with strings /lib64/libc.so.6 | grep GLIBC_ inside the container.

ImportError: libgdal.so.35: cannot open shared object file: LD_LIBRARY_PATH is not set in the Lambda environment, or the .so file is missing from the Layer. Confirm the Layer ARN is attached to the function and run aws lambda get-function-configuration to verify environment variables. Add LAMBDA_LAYER_DEBUG=1 to get verbose path resolution in CloudWatch.

ImportError: undefined symbol: GEOSGeom_destroy_r: GDAL was compiled against a different GEOS version than the one bundled in the Layer. Clean the build directory (rm -rf build-gdal), reinstall GEOS headers, and recompile GDAL with make clean. Mismatched GEOS symbol versions are a common consequence of mixing system-package GEOS with a source-built GDAL.

Permission denied writing to /var/task: Lambda mounts the deployment package read-only. Any GDAL operation that writes a .aux.xml sidecar file, a log, or a temporary tile will fail with EACCES. Set GDAL_PAM_ENABLED=NO to disable .aux.xml generation and redirect all temporary files to /tmp.

Cold-start timeout (>10 s): The unzipped deployment package exceeds 250 MB or dynamic linking is loading dozens of .so files sequentially. Audit with time python3 -c "from osgeo import gdal" inside a fresh container, then use strace -e openat python3 -c "from osgeo import gdal" 2>&1 | grep "\.so" to count dynamic library loads. Each missed static-link opportunity adds 5–50 ms of open/mmap overhead multiplied by the number of drivers registered.

RPATH misconfigurationldd shows absolute system paths: If ldd libgdal.so reports /usr/lib64/libproj.so.25 rather than /var/task/lib/libproj.so.25, the binary will fail when those system paths do not exist in Lambda. Fix with: patchelf --set-rpath '$ORIGIN/../lib' libgdal.so.35. Re-validate with ldd after patching.

Cost and Scaling Considerations

Static linking increases binary size by roughly 15–30 MB over dynamic linking but eliminates all runtime dlopen latency. For functions invoked thousands of times per minute this is an unambiguous win: the one-time cost of a larger download at cold start is offset by zero dynamic linker overhead on every warm invocation.

For Lambda pricing, the tradeoff is between memory allocation (which drives per-GB-second cost) and initialization duration. A 512 MB function with a 3-second cold start costs more per invocation than a 1769 MB function with a 0.8-second cold start when invocations are infrequent — but at high concurrency the memory multiplier dominates. Run the memory and CPU allocation model for raster workloads math before committing to a memory tier.

For GCP Cloud Functions 2nd gen (32 GB ceiling, 60 min timeout), container image deployment removes the 250 MB unzipped constraint entirely. Build the full gdal:3.9-python3.11 image once, push to Artifact Registry, and reference it in your Cloud Function — no layer splitting required. Azure Functions on the consumption plan (1.5 GB memory, 10 min timeout) is the most constrained target; prefer static linking and aggressive symbol stripping to keep the bundle under 500 MB.

When deploying the same compiled artifact across environments, use Lambda Layers with explicit ARN version pinning in Terraform or CDK to ensure architecture, Python version, and library version all align. Attach layers via IaC rather than the console to prevent manual drift:

python
# cdk_stack.py — explicit layer version pinning
from aws_cdk import aws_lambda as lambda_

gdal_layer = lambda_.LayerVersion.from_layer_version_arn(
    self, "GdalLayer",
    layer_version_arn="arn:aws:lambda:us-east-1:123456789012:layer:gdal-3-9-py311-x86_64:7",
)

fn = lambda_.Function(
    self, "GeoProcessor",
    runtime=lambda_.Runtime.PYTHON_3_11,
    layers=[gdal_layer],
    environment={
        "GDAL_DATA": "/opt/share/gdal",
        "PROJ_LIB": "/opt/share/proj",
        "LD_LIBRARY_PATH": "/opt/lib",
        "GDAL_PAM_ENABLED": "NO",
    },
    memory_size=1769,
    timeout=Duration.minutes(5),
)

Frequently Asked Questions

Why do geospatial libraries compiled on macOS fail in AWS Lambda?

macOS uses Apple’s libSystem, not GNU glibc. AWS Lambda runs Amazon Linux 2023 with glibc 2.34. Any symbol resolved at compile time against the macOS ABI produces GLIBC_X.XX not found or undefined symbol at Lambda invocation. Always compile inside a Docker container that matches the exact target OS.

Should I statically or dynamically link GDAL for Lambda?

Prefer static linking for core dependencies — PROJ, GEOS, sqlite3 — to eliminate runtime path resolution overhead. Bundle unavoidably dynamic libraries (libcurl, OpenSSL) alongside your package in a /lib directory and set LD_LIBRARY_PATH=/var/task/lib:/opt/lib explicitly in the Lambda environment configuration. This keeps cold-start latency predictable and avoids missing .so failures.

What is the maximum compiled binary size for AWS Lambda?

AWS Lambda enforces a 50 MB zipped / 250 MB unzipped limit per deployment package uploaded directly, and the same 250 MB cap per Lambda Layer. You can attach up to five layers per function, giving a practical ceiling of 1.25 GB unzipped across all layers. For GCP Cloud Functions 2nd gen the limit is 1 GB source archive; Azure Functions consumption plan limits the deployment package to 500 MB.

How do I fix RPATH errors after moving compiled binaries to /var/task?

Use patchelf to rewrite absolute library paths to $ORIGIN-relative ones: patchelf --set-rpath '$ORIGIN/../lib' libgdal.so.35. Then confirm with ldd that all shared objects resolve to your bundled /lib directory, not system paths like /usr/lib64.


Related

Back to Packaging & Dependency Management for Serverless GIS