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:
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.
# 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.
# 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:
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.
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: sqlite3 → zlib → libtiff → PROJ → GEOS → GDAL. Use --enable-static --disable-shared on autoconf projects and -DBUILD_SHARED_LIBS=OFF on CMake projects.
# 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:
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.
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:
# 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:
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:
# 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,
)
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:
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:
# .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:
# 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):
{
"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 misconfiguration — ldd 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:
# 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
- Docker Container Optimization for GIS — minimize layer count and image footprint for container-deployed GIS functions
- Python Layer Management and Size Reduction — strip unnecessary packages from Lambda Layers after native compilation
- CI/CD Pipeline Sync for Geo-Dependencies — automate reproducible builds and layer versioning
- Cold Start Mapping for Python GDAL — understand and measure shared-library resolution during Lambda initialization
- Ephemeral Storage Limits in AWS Lambda — manage
/tmpconstraints when extracting or writing compiled artifacts
Back to Packaging & Dependency Management for Serverless GIS