Skip to content

Python Layer Management and Size Reduction for Serverless GIS

AWS Lambda enforces a hard 250 MB unzipped limit on the combined function code and attached layers. A bare pip install rasterio shapely pyproj fiona routinely produces 320–380 MB before any business logic is added. Closing that gap requires a disciplined four-stage pipeline: deterministic dependency resolution, architecture-matched wheel selection, aggressive binary pruning, and container-validated packaging. Teams that treat this pipeline as a one-time setup rather than a repeatable build step end up with environment drift, silent ABI mismatches, and deployment failures at the worst possible moment.

Why Layer Size Matters for Geospatial Workloads

Geospatial Python packages are not pure Python. rasterio, fiona, and pyproj each wrap compiled C/C++ extensions (libgdal, libgeos, libproj) and bundle supporting data: coordinate reference system grids, PROJ databases, and GDAL format drivers. These binary payloads inflate layer footprints far beyond what pip show suggests because pip show reports only the installed size on disk after native linking — it does not show what the linker resolved from the OS or what debug symbols remain embedded in each .so file.

The problem compounds with transitive dependencies. rasterio pulls numpy (typically 60–80 MB unzipped), certifi, click, attrs, and sometimes scipy depending on optional feature groups. Without explicit dependency isolation, a single pip install can introduce dozens of packages that are never imported by your function.

Beyond raw size, cold-start latency for Python GDAL stacks is directly proportional to the number of .so files the dynamic linker must resolve at initialization. A bloated layer with 800+ shared objects can add 4–8 seconds to every cold start. Stripping and pruning improve both package size and initialization time.

Ephemeral storage limits in AWS Lambda create a second pressure: large layers are extracted to /opt at function startup, which counts against the 512 MB default /tmp budget (expandable to 10 GB). If your function also writes intermediate GeoTIFFs to /tmp, a bloated layer can exhaust scratch space before processing begins.

Platform-by-Platform Limits

Platform Deployment unit Unzipped limit Architecture options Key config knob
AWS Lambda Layer (up to 5 per function) 250 MB total (code + layers) x86_64, arm64 --compatible-architectures on publish
GCP Cloud Functions 2nd gen Source archive or container 500 MB zipped source; container images up to 10 GB x86_64, arm64 --runtime flag selects glibc version
Azure Functions Consumption Deployment package (zip deploy) 500 MB; no explicit file-count limit x86_64 (Consumption), arm64 (Premium EP3) WEBSITE_RUN_FROM_PACKAGE=1 reduces extraction overhead
AWS Lambda (container) ECR image 10 GB x86_64, arm64 Base image must match runtime glibc
GCP Cloud Run Container image 10 GB x86_64, arm64 --memory flag controls eviction threshold

All three providers bill on memory allocation, not disk footprint — but a larger layer forces higher minimum memory to avoid OOM kills during GDAL driver registration. AWS Lambda automatically allocates CPU proportional to memory, so over-sized layers that require 2048 MB to avoid extraction failures also double the per-GB-second billing rate relative to a properly trimmed 1024 MB allocation.

Step-by-Step Implementation

1. Dependency Pinning and Isolation

Isolate geospatial requirements into a dedicated file, separate from application-level dependencies. Mixing boto3, requests, and rasterio in a single requirements.txt makes it impossible to reason about what belongs in the layer versus the function package.

python
# requirements-geo.txt — layer dependencies only
rasterio==1.4.3
shapely==2.0.6
pyproj==3.7.0
fiona==1.10.1
numpy==1.26.4

Compile to a lockfile using uv (preferred for speed) or pip-compile:

bash
uv pip compile requirements-geo.txt \
  --python-version 3.11 \
  --output-file requirements-geo.lock

The lockfile pins every transitive dependency, including hash digests. Every CI/CD run that builds the layer uses this file, not the loose requirements-geo.txt. This is the foundation of reproducibility in the broader Packaging & Dependency Management for Serverless GIS workflow.

2. Architecture-Matched Wheel Resolution

Serverless runtimes execute on specific Linux ABI targets. AWS Lambda Python 3.11 on x86_64 runs on Amazon Linux 2023 (glibc 2.34, manylinux_2_28 compatible). GCP Cloud Functions 2nd gen uses Debian 11 (glibc 2.31). Azure Functions Consumption uses Ubuntu 20.04 (glibc 2.31).

Use pip with explicit platform and ABI tags to download only the binary wheels required for the target runtime — never resolve against your local machine’s architecture:

bash
mkdir -p layer/python

pip install \
  --only-binary=:all: \
  --platform manylinux_2_28_x86_64 \
  --python-version 3.11 \
  --implementation cp \
  --abi cp311 \
  --target layer/python \
  --no-deps \
  -r requirements-geo.lock

The --no-deps flag is intentional here because the lockfile already captured the full dependency closure. Using --no-deps against a verified lockfile prevents pip from re-resolving and potentially pulling a newer transitive dependency that conflicts with the pinned versions.

For arm64 targets (Lambda Graviton3, GCP arm64 preview, Azure Premium EP3), swap manylinux_2_28_x86_64 for manylinux_2_28_aarch64. Graviton3 instances typically show 15–20% lower cold-start latency and ~20% better cost efficiency for compute-bound raster operations.

If a package requires custom C extensions not available as precompiled wheels — for example, a GDAL build with proprietary format drivers — consult the Native Library Compilation for Serverless patterns for sysroot alignment and static linking to avoid runtime symbol failures.

3. Binary Pruning and Symbol Stripping

After wheel download, the layer/python directory typically holds 280–350 MB of content. Pruning reduces this by 30–50%.

Remove non-runtime artifacts first:

bash
cd layer/python

# Remove Python bytecache, dist metadata, and test suites
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "tests" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "test" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete
find . -type f -name "*.c" -delete
find . -type f -name "*.h" -delete

# Remove duplicate PROJ databases (keep only the one pyproj expects)
# pyproj bundles proj.db; fiona may ship a second copy — confirm paths first
find . -path "*/fiona/proj_data/proj.db" -delete 2>/dev/null || true

Strip debug symbols from every compiled extension:

bash
find . -name "*.so" -exec strip --strip-unneeded {} + 2>/dev/null || true

strip --strip-unneeded removes debugging symbols and local symbol table entries while preserving the symbol table entries needed for dynamic linking. On a typical geospatial stack, this step alone reduces .so file sizes by 40–60%, often saving 60–90 MB.

Validate that no symbols went missing:

bash
find . -name "*.so" -exec ldd {} + 2>/dev/null | grep "not found"

A clean output (no lines printed) confirms all .so dependencies are either self-contained or available in the Lambda runtime OS. Libraries like libz, libcurl, libsqlite3, and libssl are present in Amazon Linux 2023 and do not need to be bundled.

For geospatial projection data, set explicit environment variables rather than bundling duplicate data files. The Docker Container Optimization for GIS multi-stage build patterns show how to isolate these data directories cleanly; the same approach applies to layer builds:

python
# In your Lambda handler — set before any geospatial import
import os
os.environ.setdefault("PROJ_LIB", "/opt/python/pyproj/proj_data")
os.environ.setdefault("GDAL_DATA", "/opt/python/rasterio/gdal_data")
os.environ.setdefault("GDAL_DRIVER_PATH", "")  # disable scanning for unused drivers
os.environ.setdefault("CPL_VSIL_USE_TEMP_FILE_FOR_RANDOM_WRITE", "NO")

Detailed methodologies for safely identifying and removing unused binaries without breaking import chains are in Stripping Unnecessary Python Packages from AWS Lambda Layers.

4. Layer Assembly and Local Validation

Package the pruned directory:

bash
cd layer
zip -r9 ../geospatial-layer.zip python/
ls -lh ../geospatial-layer.zip   # should be under 100 MB compressed
unzip -l ../geospatial-layer.zip | tail -1  # confirm unzipped total < 250 MB

Validate imports inside a container that mirrors the production runtime before any cloud upload:

bash
docker run --rm \
  -v "$(pwd)/geospatial-layer.zip:/opt/geospatial-layer.zip:ro" \
  public.ecr.aws/sam/build-python3.11:latest \
  bash -c "
    unzip -q /opt/geospatial-layer.zip -d /opt
    export PROJ_LIB=/opt/python/pyproj/proj_data
    export GDAL_DATA=/opt/python/rasterio/gdal_data
    python3 -c '
import rasterio
import shapely.geometry
import fiona
import pyproj
# Verify a live coordinate transformation — not just import
t = pyproj.Transformer.from_crs(\"EPSG:4326\", \"EPSG:3857\", always_xy=True)
x, y = t.transform(-74.006, 40.713)
assert abs(x - -8238310) < 1000, f\"Unexpected x: {x}\"
print(\"All imports and projection transform: OK\")
'
  "

This validation step catches ABI mismatches, missing .so files, incorrect PROJ_LIB paths, and GDAL driver registration failures before any bytes leave your machine.

5. Deployment and Runtime Routing

AWS Lambda:

bash
LAYER_ARN=$(aws lambda publish-layer-version \
  --layer-name geospatial-core-py311 \
  --zip-file fileb://geospatial-layer.zip \
  --compatible-runtimes python3.11 \
  --compatible-architectures x86_64 \
  --query 'LayerVersionArn' \
  --output text)

echo "Published: $LAYER_ARN"

# Attach to function
aws lambda update-function-configuration \
  --function-name my-geo-function \
  --layers "$LAYER_ARN" \
  --memory-size 1024 \
  --environment "Variables={PROJ_LIB=/opt/python/pyproj/proj_data,GDAL_DATA=/opt/python/rasterio/gdal_data}"

GCP Cloud Functions 2nd gen:

bash
gcloud functions deploy geo-processor \
  --gen2 \
  --runtime python311 \
  --region us-central1 \
  --source . \
  --entry-point handler \
  --memory 1024MB \
  --set-env-vars PROJ_LIB=/usr/local/lib/python3.11/site-packages/pyproj/proj_data,GDAL_DATA=/usr/local/lib/python3.11/site-packages/rasterio/gdal_data

Azure Functions (zip deploy):

bash
# Azure expects site-packages in the zip root, not under python/
mkdir -p azure-package
cp -r layer/python/. azure-package/

cd azure-package && zip -r9 ../azure-geo-package.zip . && cd ..

az functionapp deployment source config-zip \
  --resource-group geo-rg \
  --name my-geo-func \
  --src azure-geo-package.zip

az functionapp config appsettings set \
  --resource-group geo-rg \
  --name my-geo-func \
  --settings \
    PROJ_LIB="/home/site/wwwroot/pyproj/proj_data" \
    GDAL_DATA="/home/site/wwwroot/rasterio/gdal_data"

Measurement and Verification

Confirm the optimization worked by measuring initialization duration and import time, not just package size.

AWS Lambda — measure cold-start init time:

python
import time, os, json

def handler(event, context):
    start = time.perf_counter()
    import rasterio
    import shapely
    import pyproj
    elapsed = time.perf_counter() - start
    return {
        "statusCode": 200,
        "body": json.dumps({
            "import_ms": round(elapsed * 1000, 1),
            "mem_mb": int(context.memory_limit_in_mb),
            "remaining_ms": context.get_remaining_time_in_millis()
        })
    }

Expected output after a trimmed layer: import_ms below 800 on a cold start at 1024 MB. A value above 3000 ms signals that the layer is still too large or that GDAL_DATA is causing a filesystem scan.

CloudWatch metric filter for initialization duration:

code
fields @timestamp, @initDuration
| filter @type = "REPORT"
| stats avg(@initDuration), max(@initDuration) by bin(5m)

A successful reduction should drop avg(@initDuration) from the 3000–5000 ms range to below 1200 ms on a 1024 MB function. If init duration remains high, enable GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR and CPL_VSIL_CURL_ALLOWED_EXTENSIONS=.tif,.tiff to suppress expensive filesystem probes.

Benchmark layer size across iterations:

bash
python3 - <<'EOF'
import subprocess, json

result = subprocess.run(
    ["aws", "lambda", "get-layer-version",
     "--layer-name", "geospatial-core-py311",
     "--version-number", "1",
     "--output", "json"],
    capture_output=True, text=True
)
data = json.loads(result.stdout)
content = data.get("Content", {})
zipped_mb = content.get("CodeSize", 0) / 1_048_576
print(f"Zipped layer size: {zipped_mb:.1f} MB")
# Unzipped is roughly 2.5-3x compressed for .so-heavy packages
print(f"Estimated unzipped: {zipped_mb * 2.7:.0f} MB")
EOF

Failure Modes and Debugging

ImportError: libgdal.so.X.Y: cannot open shared object file

The wheel was resolved for a different glibc version than the runtime. Reproduce with: find layer/python -name "*.so" | head -5 | xargs file — if output shows ELF 64-bit LSB shared object, x86-64 with a GLIBC reference newer than the runtime’s glibc, you must re-resolve wheels against the correct manylinux tag (manylinux_2_17, _2_28, etc.).

proj_create: Cannot find proj.db

PROJ_LIB is unset or points to the wrong path. After layer extraction on Lambda, pyproj’s data lands at /opt/python/pyproj/proj_data/proj.db. Set PROJ_LIB=/opt/python/pyproj/proj_data in the function environment variables, not just in handler code — the Lambda init phase resolves this before your handler runs.

OSError: [Errno 28] No space left on device during extraction

The combined unzipped size of your layers plus function code exceeds the /tmp ephemeral budget (512 MB default). Either trim the layer further, raise the ephemeral storage limit (up to 10 GB on Lambda), or switch to a container image. Managing /tmp storage limits for GeoTIFF extraction covers /tmp partitioning strategies when both layer extraction and raster I/O compete for the same scratch space.

Cold-start init duration spike after a dependency update

A new transitive dependency introduced additional .so files. Run pip show <package> | grep Requires against the updated lockfile to identify what changed, then re-run the prune + strip pipeline against the new install. Track @initDuration in CloudWatch as a deployment quality gate.

ModuleNotFoundError for a package you know is in the layer

The layer directory structure is wrong for the runtime. Lambda expects dependencies under python/ or python/lib/python3.11/site-packages/ when using --target. If you used a virtual environment layout (lib/python3.11/site-packages/), Lambda will not add it to sys.path automatically. Verify with aws lambda invoke --log-type Tail and decode the base64 log to see the actual sys.path at runtime.

strip: Unable to recognise the format of the input file

This is a harmless warning for pure-Python .pyc files or archive files that find located before the .so files. Redirect stderr with 2>/dev/null and confirm the exit code was 0 for genuine .so files. The warning does not indicate a failed strip on binary extensions.

Cost and Scaling Considerations

Layer vs container image decision tree for geospatial Python stacks A flowchart showing when to use a Lambda layer (under 200 MB after stripping), a container image (over 200 MB or custom driver builds), or a shared EFS layer (team sharing the same stack across many functions). Unzipped stack size after pruning? (code + all layers combined) Under 200 MB Lambda Layer Fast deploy, no ECR, version-locked ARN Over 200 MB Custom GDAL drivers or proprietary formats? Yes / Either Container Image ECR + full OS control Many functions Shared EFS Mount One install, many funcs

Cost-per-invocation math (AWS Lambda example):

Config Memory Init duration GB-seconds per 1 M cold starts Monthly cost at $0.0000166667/GB-s
Unoptimized layer 2048 MB 4800 ms 9830 GB-s ~$0.164
Trimmed layer (stripped) 1024 MB 1100 ms 1126 GB-s ~$0.019
Container image 1024 MB 2400 ms 2458 GB-s ~$0.041

The trimmed layer scenario costs roughly 88% less per cold start than the unoptimized baseline. At scale — 10 M invocations per month with a 5% cold-start ratio — that difference is ~$725/month before accounting for the memory reduction’s effect on warm-invocation cost.

When to prefer container images over layers:

  • Unzipped stack exceeds 200 MB after aggressive stripping
  • Custom GDAL builds with HDF5, NetCDF, or proprietary format drivers
  • Multi-language runtimes (Python + Node.js side processes)
  • Teams that need reproducible base OS environments across dev/staging/prod

When to prefer EFS-backed dependency shares:

If you have 20+ Lambda functions that all import the same geospatial stack, mounting a shared Amazon EFS access point at /mnt/geo-packages eliminates redundant layer extraction per function. EFS adds ~5–10 ms of network latency per cold start but removes the 250 MB cap entirely and allows hot-swapping packages without redeployment. This is most effective for event-driven pipelines where SQS and Pub/Sub queue routing strategies fan work out to many identical consumer functions.

Frequently Asked Questions

Why does a plain pip install of rasterio exceed the AWS Lambda 250 MB limit?

A standard rasterio install pulls compiled GDAL binaries, PROJ data grids, numpy, certifi, click, and their transitive C extensions. Unzipped, this typically reaches 320–380 MB. The fix is to target manylinux wheels, strip debug symbols from every .so file, and remove dist-info, test directories, and duplicate PROJ databases.

Can I reuse a layer built on macOS for AWS Lambda?

No. macOS produces Mach-O binaries with Darwin system paths. AWS Lambda runs Amazon Linux 2023 (glibc 2.34). Wheels must be downloaded targeting manylinux_2_28_x86_64 or manylinux_2_28_aarch64 to avoid ImportError or OSError at runtime.

Should I use a Lambda layer or a container image for a 400 MB geospatial stack?

Container images (up to 10 GB) remove the 250 MB layer cap and give you full OS control, but add 1–3 seconds of cold-start overhead and require ECR management. For stacks that compress below 200 MB after stripping, layers deploy faster and cost nothing extra. For heavier stacks or custom GDAL driver builds, container images are the better choice. The building minimal Docker images with Alpine and GDAL guide covers the container path in detail.


Related

Back to Packaging & Dependency Management for Serverless GIS