Comparing Layers, Container Images, and EFS for GDAL
A zip layer gives you 250 MB unzipped across the function and all five layers combined, mounted at /opt; a container image gives you 10 GB but pays for it in deploy time and a chunk-faulting first invocation; an EFS access point gives you one shared copy at /mnt/geo and drags the function into a VPC. For a stripped GDAL stack under about 200 MB the layer wins on every axis. Past 250 MB the choice is between an image and a share, and it is decided by how many functions read the same GDAL — one or two, take the image; twenty, take the share.
Context
The Python layer management and size reduction workflow exists to keep a geospatial stack inside a single hard number: 250 MB unzipped, counted across the function’s own code and every layer attached to it, with at most five layers per function and everything mounted at /opt. Two techniques buy headroom under that number — stripping unnecessary packages and deduplicating NumPy across layers — and both stop working at the same point. When the stripped, deduplicated stack still does not fit, no further pruning helps, because the remaining bytes are the ones the handler actually calls.
That is the moment this page addresses. The failure it prevents is a team spending a week shaving megabytes off a package that was never going to fit, when the correct move was to change the delivery mechanism. AWS offers three, and they are not variations on a theme: they differ in where the bytes live, when they are paid for, and what else changes when you adopt them.
The stack that forces the choice
Take a concrete example. A hydrology pipeline needs rasterio, fiona, pyproj, shapely, scipy, and a GDAL built with the NetCDF, HDF5, and GRIB drivers so it can read forecast grids directly. After strip --strip-unneeded, after deleting test trees, and after collapsing NumPy into a single shared copy, the tree unzips to 412 MB. There is nothing left to remove — the NetCDF driver alone is the reason the pipeline exists.
The zip route does not fail gracefully. UpdateFunctionConfiguration rejects the layer attachment outright, with no partial mode and no way to page bytes in on demand. The image route and the share route both have room; they simply move the cost somewhere else, and the rest of this page is about where.
The three mechanisms compared
Every figure below was measured on a 1,769 MB x86_64 function in eu-west-1 running the same handler against the same 412 MB stack, so the columns are comparable to each other rather than to a vendor benchmark.
| Zip layers | Container image | EFS access point | |
|---|---|---|---|
| Size ceiling | 250 MB unzipped across function + 5 layers | 10 GB image | No practical ceiling; the share is the limit |
| Where it mounts | /opt |
The image’s own filesystem | /mnt/geo (you choose the path) |
| Cold start, first environment | 1.9 s | 2.3 s | 4.1 s |
| Cold start, once warm on the host | 1.9 s | 1.4 s | 2.8 s |
| Deploy time for a dependency bump | ~40 s (zip, S3, publish-layer-version) | 3–6 min (build, push ~1.2 GB to ECR) | ~90 s to sync; no function deploy at all |
| Operational cost | S3 storage of the zip only | ECR at $0.10 per GB-month | EFS at $0.30 per GB-month, plus VPC and NAT |
| Extra infrastructure | None | An ECR repository per image | VPC, subnets, security groups, mount targets |
| Blast radius of a bad build | One layer version; roll back the ARN | One image tag; roll back the tag | Every function on the share, immediately |
The last row is the one teams under-weight. A layer version and an image tag are both immutable artifacts you roll back by pointing at the previous one. An EFS share is mutable shared state: overwrite libgdal.so.35 on the share and every function reading it changes on its next cold start, with no version to revert to. That is the same property that makes the share attractive for twenty functions and dangerous for two.
Outside AWS this comparison mostly collapses. Neither Cloud Run nor Cloud Functions 2nd gen has a layer mechanism, so the container image is the only route there — with a far more generous envelope of 60 minutes, up to 32 GiB and 8 vCPU, as the multi-stage Cloud Run recipe sets out. Azure Functions on the Consumption plan (10 min, 1,536 MB) is tight enough that a full GDAL stack rarely belongs there at all.
Choosing between them
The decision is not really about size. Size only tells you whether the layer is still available. Past that point the question is how many functions read the same bytes: an image duplicates the whole GDAL stack into every function that needs it, and a share stores it once. At two functions the duplication is free and the VPC is not; at twenty the arithmetic reverses.
Prerequisites
- Runtime: Python 3.11 on
x86_64, function memory 1,769 MB, timeout 300 s (the ceiling is 15 min) - Zip route: a published layer ARN, built as in the Amazon Linux 2023 layer recipe;
lambda:PublishLayerVersionands3:PutObjecton the staging bucket - Image route: an ECR repository in the same region as the function;
ecr:BatchGetImage,ecr:GetDownloadUrlForLayeron the repository for the Lambda service principal, andecr:PutImagefor the deployer - EFS route: a VPC with at least two private subnets, an EFS file system with a mount target per subnet, an access point pinning
uid/gid1000, andelasticfilesystem:ClientMountplusClientWriteon the access point; the function role also needsec2:CreateNetworkInterface,DescribeNetworkInterfaces, andDeleteNetworkInterface - Gateway VPC endpoint for S3 if the function reads imagery from S3, because a VPC-attached function has no default egress
- Pinned versions:
rasterio==1.4.3,fiona==1.10.1,pyproj==3.7.0,shapely==2.0.6,scipy==1.13.1, GDAL 3.9.0 - Runtime environment variables, which differ per mechanism and are the single most common migration bug:
# zip layers GDAL_DATA=/opt/share/gdal PROJ_LIB=/opt/share/proj LD_LIBRARY_PATH=/opt/lib # container image GDAL_DATA=/usr/local/share/gdal PROJ_LIB=/usr/local/share/proj LD_LIBRARY_PATH=/usr/local/lib # EFS access point mounted at /mnt/geo GDAL_DATA=/mnt/geo/share/gdal PROJ_LIB=/mnt/geo/share/proj LD_LIBRARY_PATH=/mnt/geo/lib
Implementation
One SAM template defines all three so the differences are visible side by side rather than spread across three repositories. Deploy it once and you can invoke the same handler through each path and compare Init Duration directly in CloudWatch.
# template.yaml — the same GDAL handler delivered three ways.
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Parameters:
GdalLayerArn:
Type: String # arn:aws:lambda:eu-west-1:123456789012:layer:gdal-3-9-py311:7
EfsAccessPointArn:
Type: String
EfsFileSystemId:
Type: String # fs-0a1b2c3d4e5f67890
PrivateSubnetIds:
Type: List<AWS::EC2::Subnet::Id>
LambdaSecurityGroupId:
Type: AWS::EC2::SecurityGroup::Id
Globals:
Function:
Runtime: python3.11
MemorySize: 1769
Timeout: 300
Architectures: [x86_64]
EphemeralStorage:
Size: 4096 # /tmp: 512 MB by default, 10,240 MB maximum
Resources:
# --- 1. Zip package + layers. Ceiling is 250 MB unzipped across the
# function code and every attached layer, five layers maximum.
ZipLayerFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: handler/
Handler: app.lambda_handler
Layers: [!Ref GdalLayerArn]
Environment:
Variables:
GDAL_DATA: /opt/share/gdal # layers always mount at /opt
PROJ_LIB: /opt/share/proj
LD_LIBRARY_PATH: /opt/lib
GDAL_PAM_ENABLED: "NO" # /opt and /var/task are read-only
# --- 2. Container image. Ceiling is a 10 GB image. Note the absence of a
# Layers key: an Image function cannot attach one, and /opt is empty.
ImageFunction:
Type: AWS::Serverless::Function
Properties:
PackageType: Image
ImageUri: 123456789012.dkr.ecr.eu-west-1.amazonaws.com/gdal-lambda:3.9.0
ImageConfig:
Command: ["app.lambda_handler"]
Environment:
Variables:
GDAL_DATA: /usr/local/share/gdal # wherever the image installed it
PROJ_LIB: /usr/local/share/proj
LD_LIBRARY_PATH: /usr/local/lib
GDAL_PAM_ENABLED: "NO"
# --- 3. EFS access point. No size ceiling, but the function now lives in a
# VPC and every shared object is read over NFS at first touch.
EfsFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: handler/ # handler only — no dependencies
Handler: app.lambda_handler
VpcConfig:
SubnetIds: !Ref PrivateSubnetIds
SecurityGroupIds: [!Ref LambdaSecurityGroupId]
FileSystemConfigs:
- Arn: !Ref EfsAccessPointArn
LocalMountPath: /mnt/geo # must start with /mnt
Environment:
Variables:
GDAL_DATA: /mnt/geo/share/gdal
PROJ_LIB: /mnt/geo/share/proj
LD_LIBRARY_PATH: /mnt/geo/lib
PYTHONPATH: /mnt/geo/python/lib/python3.11/site-packages
GDAL_PAM_ENABLED: "NO"
Policies:
- EFSWriteAccessPolicy:
FileSystem: !Ref EfsFileSystemId
AccessPoint: !Ref EfsAccessPointArn
The EFS route is the only one that changes the shape of a cold start rather than just its length. Before a single byte of GDAL is read, Lambda has to attach a network interface into your VPC and complete an NFS mount, and only then does the dynamic linker start resolving libgdal.so.35 over the network.
Verification
Whichever mechanism you pick, verify where libgdal actually came from rather than that import rasterio succeeded. The same handler works against all three, and the resolved path is what tells them apart.
# app.py — prints the mechanism-independent facts that actually differ.
import os
import time
from ctypes.util import find_library
def lambda_handler(event, context):
t0 = time.perf_counter()
import rasterio
from rasterio.crs import CRS
from osgeo import gdal
gdal.UseExceptions()
# Resolve the real on-disk location of the loaded libgdal: /opt/lib for a
# layer, /usr/local/lib inside an image, /mnt/geo/lib over EFS.
libgdal = find_library("gdal") or "bundled in the wheel"
proj_db = os.path.join(os.environ["PROJ_LIB"], "proj.db")
return {
"package_type": os.environ.get("AWS_LAMBDA_FUNCTION_NAME", "?"),
"libgdal_path": libgdal,
"ld_library_path": os.environ.get("LD_LIBRARY_PATH", "<unset>"),
"proj_db_present": os.path.exists(proj_db),
"gdal_version": gdal.VersionInfo("RELEASE_NAME"),
"driver_count": gdal.GetDriverCount(),
"epsg_3857_units": CRS.from_epsg(3857).linear_units,
"import_ms": round((time.perf_counter() - t0) * 1000, 1),
}
Expected output from the EFS-backed function on a genuinely cold environment:
{
"package_type": "hydro-efs",
"libgdal_path": "/mnt/geo/lib/libgdal.so.35",
"ld_library_path": "/mnt/geo/lib",
"proj_db_present": true,
"gdal_version": "3.9.0",
"driver_count": 251,
"epsg_3857_units": "metre",
"import_ms": 2180.4
}
An import_ms near 2,200 on EFS against roughly 700 on a layer is the shared-object read cost, not a bug. proj_db_present: false with a healthy driver count is the classic half-migrated state: LD_LIBRARY_PATH was moved to the new mechanism and PROJ_LIB was not, so reprojection silently falls back to a null datum shift.
Gotchas and Edge Cases
- An image function cannot attach a layer, and
/optis empty. SettingPackageType: Imagesilently drops any layer you were relying on — CloudFormation will reject theLayerskey outright, but a hand-migrated function simply starts failing at import. MoveGDAL_DATA,PROJ_LIB, andLD_LIBRARY_PATHoff/optin the same change that switches the packaging type, never in a follow-up. - EFS costs you default internet egress. Attaching the function to a VPC removes its route to the public internet. Reads from S3 need a gateway VPC endpoint, a public STAC catalogue needs a NAT gateway at roughly $32 per month per availability zone before data charges, and neither failure appears until the handler makes its first outbound call — long after the mount succeeded.
- The share is mutable, so treat writes to it as a deploy. Sync a new GDAL onto
/mnt/geoand every function reading it picks up the change on its next cold start, with warm environments still holding the old one. That mixed state is the worst possible way to discover an ABI break. Write versioned directories (/mnt/geo/3.9.0/lib) and move the environment variables, so a rollback is an environment change rather than a second sync. /tmpis unchanged by all three. Every mechanism still gives the function 512 MB of/tmpby default and up to 10,240 MB when configured. Delivering GDAL from a 10 GB image does not give the handler more scratch space for intermediate GeoTIFFs, which is a separate budget covered in ephemeral storage limits in AWS Lambda.
Frequently Asked Questions
Does the 250 MB limit apply to Lambda container images?
No. The 250 MB unzipped ceiling governs zip-packaged functions and counts the function code plus every attached layer together, with a maximum of five layers. A container-image function is bounded instead by a 10 GB image, which is why a GDAL build carrying NetCDF, HDF5, and GRIB drivers usually ends up as an image. Nothing else about the execution envelope changes: the same 15 minute timeout, the same 10,240 MB memory ceiling, and the same 10,240 MB of /tmp apply to both packaging types.
Does mounting EFS put a Lambda function in a VPC?
Yes, and that is the real price of the share. A mount requires the function to sit in subnets with a security group that can reach a mount target, and a VPC-attached function loses default internet egress. S3 then needs a gateway endpoint and anything public needs NAT. Design the VPC before the storage, because the networking work usually exceeds the storage work by an order of magnitude.
Can I mix a zip layer with a container image?
No. Layers apply only to zip-packaged functions. A function with PackageType: Image cannot attach one and /opt stays empty unless the image itself creates it. Everything a layer provided has to be baked into the image or mounted from EFS — which is exactly why the migration is a packaging change and a configuration change at the same time.
Related
- Python Layer Management and Size Reduction — the pruning workflow that comes before any mechanism change
- Stripping Unnecessary Python Packages from AWS Lambda Layers — how far pruning gets you before 250 MB stops being reachable
- Cold Start Tuning for GDAL Container Images on Lambda — what the image column’s 2.3 s and 1.4 s figures are actually made of
- Docker Container Optimization for GIS — building the image the middle column assumes
- Ephemeral Storage Limits in AWS Lambda — the
/tmpbudget none of the three mechanisms changes