Cross-Compiling GEOS for arm64 Graviton Lambda
Pull public.ecr.aws/lambda/python:3.11 with --platform linux/arm64, compile GEOS 3.12.1, PROJ 9.4.0, and GDAL 3.9.0 into /opt/lib as libgeos_c.so.1, libproj.so.25, and libgdal.so.35, then install the Python layer with pip --platform manylinux_2_28_aarch64 --abi cp311 --only-binary :all: and publish it with --compatible-architectures arm64. Nothing from an x86_64 layer transfers: every .so is a different ELF machine type, and the aarch64 loader rejects the wrong one before your handler is reached. On a 1,769 MB tiling function the migration takes a million tile invocations from $124.00 to $92.20.
Context
The native library compilation discipline is built around one constraint: the build environment must match the runtime’s C ABI, which on modern Lambda means Amazon Linux 2023 and glibc 2.34. Architecture is the stricter half of that constraint. A glibc mismatch sometimes surfaces as a single missing symbol you can work around; an architecture mismatch cannot be worked around at all. The ELF header of an x86_64 object carries Machine: Advanced Micro Devices X86-64, and /lib/ld-linux-aarch64.so.1 will not map it — the function fails at import with wrong ELF class or cannot open shared object file, exactly the class of failure the Amazon Linux 2023 layer recipe exists to prevent on x86_64.
That makes an arm64 migration a full second build chain rather than a flag on the existing one. The chain below is the same shape as the x86_64 chain, but every stage has to be told the target explicitly — the container platform, the CMake toolchain, the wheel platform tag, and the layer’s compatible-architectures metadata. Miss any one of them and the build still succeeds; it simply produces an artifact that fails at the first cold start.
What you download and what you must rebuild
The useful question is not “how do I compile GEOS for aarch64” but “what actually has no aarch64 wheel”. Since the manylinux_2_28_aarch64 policy stabilised, the mainstream Python geospatial stack publishes Graviton wheels: shapely vendors libgeos_c inside shapely.libs, rasterio vendors a full GDAL inside rasterio.libs, and pyproj vendors PROJ plus its proj.db. For a handler that only calls those three, the correct arm64 build compiles nothing at all — it is the same pip invocation with one tag changed.
You reach for the compiler when something has to link against a GEOS you control rather than a GEOS hidden inside another package’s .libs directory. That is the case for a source-built GDAL, for PDAL, for MapServer or tippecanoe-style binaries, and for the slimmed single-libgdal layout described in GDAL wheel size reduction with pip --no-binary, where the Python bindings link against a system libgdal instead of shipping their own.
Prerequisites
- Runtime: Python 3.11 on
arm64(Graviton2 on all current Lambda regions, Graviton3 where available) - Build image:
public.ecr.aws/lambda/python:3.11pulled with--platform linux/arm64— Amazon Linux 2023,glibc2.34, loader/lib/ld-linux-aarch64.so.1 - Emulation, on an
x86_64host only:docker run --privileged --rm tonistiigi/binfmt --install arm64to register theqemu-aarch64handler with the kernel - Pinned source versions: GEOS 3.12.1, PROJ 9.4.0, GDAL 3.9.0, SQLite 3.45.3 — the same set the CI cache key is built from, with
arm64added to that key - Pinned wheels:
shapely==2.0.6,pyproj==3.7.0,rasterio==1.4.3, all of which publishcp311-cp311-manylinux_2_28_aarch64artifacts - System tools inside the container:
gcc,gcc-c++,cmake,make,binutils,sqlite-devel,libtiff-devel,libcurl-devel,tar - Runtime environment variables (set on the function, not the build):
GDAL_DATA=/opt/share/gdal PROJ_LIB=/opt/share/proj LD_LIBRARY_PATH=/opt/lib - IAM for the publish step:
lambda:PublishLayerVersionon thearm64layer ARN ands3:PutObjecton the staging bucket, since a GDAL layer zip clears the 50 MB direct-upload threshold
Implementation
The script below runs on the host and drives the aarch64 container. It builds the three libraries in dependency order, downloads rather than compiles anything that already ships an aarch64 wheel, and lays out the /opt-shaped directories the layer will mount over.
#!/usr/bin/env bash
# build_arm64_geo_layer.sh — GEOS + PROJ + GDAL for Graviton Lambda.
set -euo pipefail
GEOS_VERSION="3.12.1"
PROJ_VERSION="9.4.0"
GDAL_VERSION="3.9.0"
LAYER_DIR="$(pwd)/layer-arm64"
SITE="python/lib/python3.11/site-packages"
rm -rf "${LAYER_DIR}" && mkdir -p "${LAYER_DIR}"
# On an x86_64 host, register the aarch64 binfmt handler once per boot.
docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null
docker run --rm \
--platform linux/arm64 \
-v "${LAYER_DIR}:/layer" \
public.ecr.aws/lambda/python:3.11 \
bash -c "
set -euo pipefail
dnf install -y gcc gcc-c++ cmake make binutils tar gzip \
sqlite-devel libtiff-devel libcurl-devel zlib-devel >/dev/null
# Inside a --platform arm64 container the whole userland is aarch64 and
# binfmt runs it, so CMake never enters cross-compiling mode and every
# compile-and-run feature probe executes for real. That is why no
# CMAKE_CROSSCOMPILING_EMULATOR or toolchain file appears below.
export PREFIX=/layer
export CFLAGS='-O2 -fPIC'
export CXXFLAGS='-O2 -fPIC'
export PKG_CONFIG_PATH=\"\${PREFIX}/lib/pkgconfig\"
export LD_LIBRARY_PATH=\"\${PREFIX}/lib\"
cd /tmp
curl -sL https://download.osgeo.org/geos/geos-${GEOS_VERSION}.tar.bz2 | tar xj
curl -sL https://download.osgeo.org/proj/proj-${PROJ_VERSION}.tar.gz | tar xz
curl -sL https://github.com/OSGeo/gdal/releases/download/v${GDAL_VERSION}/gdal-${GDAL_VERSION}.tar.gz | tar xz
# GEOS first: PROJ does not need it, but GDAL links against libgeos_c.
# BUILD_GEOSOP=OFF drops a CLI binary that would be dead weight in /opt.
cmake -S geos-${GEOS_VERSION} -B /tmp/b-geos \
-DCMAKE_INSTALL_PREFIX=\"\${PREFIX}\" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_SYSTEM_PROCESSOR=aarch64 \
-DBUILD_SHARED_LIBS=ON -DBUILD_TESTING=OFF -DBUILD_GEOSOP=OFF
cmake --build /tmp/b-geos --parallel \"\$(nproc)\" && cmake --install /tmp/b-geos
# PROJ generates proj.db by executing sqlite3 during the build. Point it at
# the container's own sqlite3 — under emulation that binary is aarch64 too,
# which is why this step works here and breaks in a true cross-toolchain.
cmake -S proj-${PROJ_VERSION} -B /tmp/b-proj \
-DCMAKE_INSTALL_PREFIX=\"\${PREFIX}\" \
-DCMAKE_BUILD_TYPE=Release \
-DEXE_SQLITE3=/usr/bin/sqlite3 \
-DBUILD_SHARED_LIBS=ON -DBUILD_TESTING=OFF -DBUILD_PROJSYNC=OFF
cmake --build /tmp/b-proj --parallel \"\$(nproc)\" && cmake --install /tmp/b-proj
cmake -S gdal-${GDAL_VERSION} -B /tmp/b-gdal \
-DCMAKE_INSTALL_PREFIX=\"\${PREFIX}\" \
-DCMAKE_BUILD_TYPE=Release \
-DPROJ_ROOT=\"\${PREFIX}\" -DGEOS_ROOT=\"\${PREFIX}\" \
-DBUILD_SHARED_LIBS=ON -DBUILD_TESTING=OFF -DBUILD_PYTHON_BINDINGS=OFF
cmake --build /tmp/b-gdal --parallel \"\$(nproc)\" && cmake --install /tmp/b-gdal
# Wheels that already ship aarch64 binaries: download, never compile.
# --platform forces --only-binary, which is exactly the guarantee we want.
pip install \
--only-binary :all: \
--platform manylinux_2_28_aarch64 \
--python-version 3.11 --implementation cp --abi cp311 \
--target /layer/${SITE} \
shapely==2.0.6 pyproj==3.7.0
find \"\${PREFIX}/lib\" -name '*.so*' -exec strip --strip-unneeded {} + 2>/dev/null || true
find /layer/${SITE} -type d -name '__pycache__' -prune -exec rm -rf {} +
"
cd "${LAYER_DIR}" && zip -qr9 ../geo-arm64-layer.zip . && cd ..
aws s3 cp geo-arm64-layer.zip s3://my-lambda-artifacts/layers/geo-arm64.zip
# --compatible-architectures is metadata for the attach-time check; the bytes
# inside the zip are what actually has to be aarch64.
aws lambda publish-layer-version \
--layer-name "geo-3-9-py311-arm64" \
--description "GEOS ${GEOS_VERSION} / PROJ ${PROJ_VERSION} / GDAL ${GDAL_VERSION}, aarch64" \
--content "S3Bucket=my-lambda-artifacts,S3Key=layers/geo-arm64.zip" \
--compatible-runtimes python3.11 \
--compatible-architectures arm64
Verification
Read the ELF header before you read a version string. A version string proves the file parses; the header proves it will load on Graviton at all.
docker run --rm \
--platform linux/arm64 \
-v "$(pwd)/layer-arm64:/opt:ro" \
-e GDAL_DATA=/opt/share/gdal \
-e PROJ_LIB=/opt/share/proj \
-e LD_LIBRARY_PATH=/opt/lib \
-e PYTHONPATH=/opt/python/lib/python3.11/site-packages \
public.ecr.aws/lambda/python:3.11 \
bash -c '
readelf -h /opt/lib/libgeos_c.so.1 | grep -E "Class|Machine"
readelf -h /opt/lib/libgdal.so.35 | grep -E "Machine"
python3 -c "
import shapely, pyproj
from shapely.geometry import Point
print(f\"shapely {shapely.__version__} geos={shapely.geos_version_string}\")
print(f\"pyproj {pyproj.__version__} proj={pyproj.proj_version_str}\")
print(f\"buffer area={Point(0, 0).buffer(1.0).area:.3f}\")
print(\"ARM64 LAYER VERIFIED\")
"'
Expected output:
Class: ELF64
Machine: AArch64
Machine: AArch64
shapely 2.0.6 geos=3.12.1-CAPI-1.18.1
pyproj 3.7.0 proj=9.4.0
buffer area=3.137
ARM64 LAYER VERIFIED
Two things matter in that output. Machine: AArch64 on both the hand-built libgeos_c.so.1 and the hand-built libgdal.so.35 confirms the compiler emitted Graviton code rather than falling back to the host. And geos=3.12.1 reported through shapely.geos_version_string confirms which GEOS actually loaded — if the wheel’s vendored copy shadowed yours, this line reports the wheel’s version, not the one you just compiled, and the rest of the layer is dead weight.
Price and performance on a tiling workload
Graviton’s list price is roughly 20% below x86_64 per GB-second, and that discount is the floor, not the whole story. A tiling pass of the kind described in serverless NDVI tiling from Sentinel-2 is dominated by libtiff decode, resampling, and re-encode — integer and memory-bandwidth work where Graviton2 holds its own and Graviton3 pulls ahead. Measured across a 10,980 × 10,980 granule cut into 512 × 512 tiles, a 1,769 MB function averaged 4.20 s per tile invocation on x86_64 and 3.90 s on arm64, so the duration saving compounds with the rate saving.
Raising memory does not change the ranking. At 3,008 MB both architectures finish faster in wall-clock terms but bill slightly more per invocation, because the extra vCPU share buys less than the extra GB costs on this workload — the same trade the memory and CPU allocation model for raster workloads makes explicit. The 26% gap between the architectures survives the memory change, which is what makes it worth a second build chain.
Gotchas and Edge Cases
- Emulation is correct but slow, and the slowness lands in CI. Inside a
--platform linux/arm64container on anx86_64host, every compiler invocation runs throughqemu-aarch64. The output is genuineaarch64machine code, but the GEOS-plus-PROJ-plus-GDAL build stretches from roughly four minutes native to twenty-five or more. Use emulation to get the recipe right, then move the recurring build to a nativearm64runner or a Graviton CodeBuild project and keep the emulated path as the fallback. - PROJ needs a runnable
sqlite3at build time.proj.dbis generated by executing SQL against a real SQLite binary during the build, so a genuine cross-toolchain —aarch64-linux-gnu-gccrunning on anx86_64host — has to be handed a hostsqlite3through-DEXE_SQLITE3. Under container emulation the container’s ownaarch64sqlite3runs fine, which is the main practical reason to prefer emulation over a cross-toolchain here. --compatible-architecturesis a label, not a check on the bytes. Publishing anx86_64zip under anarm64layer name succeeds, and the failure only appears when a function attaches it and cold-starts. Keepx86_64andarm64as separate layer names, put the architecture in the CI cache key, and never let the two zips share a staging path.- Not every geospatial workload wins, and one platform has no
arm64at all. Code paths that lean on hand-tuned AVX2 kernels — somescipyinterpolation routines, a few resampling implementations — have no Graviton2 equivalent and can lose 10–15% of the duration saving. Benchmark the specific pass before migrating. Azure Functions on the Consumption plan isx86_64only (10 min, 1,536 MB), so a portable pipeline needs thex86_64layer maintained regardless.
Frequently Asked Questions
Does an x86_64 rasterio wheel run on a Graviton Lambda?
No. A manylinux2014_x86_64 or manylinux_2_28_x86_64 wheel contains ELF objects marked EM_X86_64, and the aarch64 dynamic linker refuses them during import with wrong ELF class or cannot open shared object file, before the handler runs. Architecture is not negotiable at load time the way a glibc minor version sometimes is — every wheel and every bundled .so in an arm64 layer must carry the aarch64 tag, which is why readelf -h belongs in the verification step and not just in the debugging session.
Do I need to compile GEOS at all if I only use shapely?
No. Shapely 2.x publishes cp311-cp311-manylinux_2_28_aarch64 wheels that vendor libgeos_c inside shapely.libs, so pip downloads a working GEOS for Graviton with nothing more than the platform tag changed. You compile GEOS yourself only when something outside shapely must link against it — a source-built GDAL, PDAL, or a standalone libgeos_c.so.1 shared across several packages in one layer so it is not duplicated the way NumPy gets duplicated across layers.
Is QEMU emulation good enough for a production arm64 GDAL layer?
The artifact is production-grade: the compiler itself runs as an aarch64 binary and emits real aarch64 code, and nothing about emulation changes the instructions written to the object files. What it changes is build time, by a factor of six to eight. That is tolerable for a monthly layer rebuild and painful for a pipeline that rebuilds on every merge, so treat emulation as the bring-up path and a native arm64 runner as the steady state.
Related
- Native Library Compilation for Serverless — the
x86_64build chain this one mirrors, and the RPATH andldddiscipline it shares - Building Rasterio Lambda Layers on Amazon Linux 2023 — the wheel-only path, which needs one tag changed to target Graviton
- Reproducible GDAL Builds with pip Constraints and Hashes — one lock file that carries hashes for both the
x86_64andaarch64wheel sets - Caching GDAL Build Artifacts in GitHub Actions — why the architecture has to be part of the cache key
- Memory and CPU Allocation for Raster Workloads — the memory-tier arithmetic the Graviton cost comparison sits on top of