AWS Serverless

AWS Lambda Layers & Packaging: Managing Dependencies the Right Way

Your function is 40 lines of Python. It imports pandas, does a groupby, writes a CSV to S3. It runs on your laptop in half a second. You pip install pandas, zip the folder, upload it, hit Test, and Lambda answers with Runtime.ImportModuleError: Unable to import module 'lambda_function': No module named 'pandas._libs.window.aggregations'. Nothing is wrong with your code. Everything is wrong with your deployment package — you built it on a machine that is not the machine your function runs on, and a compiled C extension inside pandas is now the wrong shape for the CPU and the C library Lambda actually has. This single failure, in a dozen disguises, is the reason “just add the dependency” is the hardest easy task in serverless.

This is the reference for packaging Lambda correctly, end to end. You get exactly two ways to ship code and its dependencies to Lambda: a zip deployment package (up to 50 MB uploaded directly, 250 MB unzipped once you include layers, more if you stage it in S3) or a container image (up to 10 GB, stored in Amazon ECR). Around those sit layers — shared zip archives extracted to the magic /opt directory, up to five per function, sharing that same 250 MB unzipped budget, versioned and addressable by ARN, shareable across accounts. And underneath all of it sits the one law that breaks more first deployments than any other: a native binary — a wheel, a .so, a JAR with a JNI stub — must be compiled for the runtime’s operating system (Amazon Linux 2 or Amazon Linux 2023, which differ in glibc version) and its CPU architecture (x86_64 or arm64/Graviton), or the import fails at cold start no matter how correct your code is.

By the end you will never be surprised by a packaging error again. You will know when a layer is the right tool and when a container image is, the precise /opt path each runtime scans, how to build a compiled dependency inside a Lambda-like container so the ELF header matches, the exact aws lambda and aws ecr commands plus the Terraform to wire all of it, and a symptom→cause→confirm→fix playbook for every way packaging fails. Read the prose once; keep the tables open the next time ImportModuleError lights up your logs.

What problem this solves

Lambda gives you no build server. There is no apt-get, no compile step in the cloud, no “it’ll sort the dependencies out on deploy.” Whatever your function needs at runtime, you must assemble into an artifact and hand over — correctly built, correctly laid out, under the size limits. The platform then drops that artifact into a locked-down execution environment and runs your handler. If a library isn’t there, or is there but built wrong, you find out at the first invocation, in production, as a cold-start error.

What breaks without getting this right: the classic is the wrong-architecture / wrong-glibc native dependency — you pip install on macOS or a fresh Ubuntu, the wheel that comes down is a Mach-O binary or references GLIBC_2.34, and Lambda (running Amazon Linux) throws ImportModuleError or invalid ELF header. Next is layout: you put your packages in /opt but under the wrong subdirectory, so the runtime never finds them. Then size: you blow past 250 MB unzipped and the publish fails, or past 50 MB on direct upload and get RequestEntityTooLargeException. Then too many layers, version drift between two layers that both ship numpy, and cold-start bloat from a 200 MB layer that Lambda has to download and unzip before your handler runs.

Who hits this: everyone shipping anything beyond the standard library. It bites hardest on data/ML functions (numpy, pandas, scipy, Pillow, cryptography, psycopg2, lxml — all have compiled parts), on teams moving to arm64/Graviton for the cost saving, and on anyone who develops on a Mac and deploys to Linux. The fix is never “hope” — it is “build the artifact in the same shape as the runtime, lay it out where the runtime looks, and stay under the limits.”

Here is the whole field on one screen — the packaging choices this article covers, when each fits, and the wall you hit if you pick wrong:

Packaging choice Max size Where it lives Best for The wall you hit
Zip, direct upload 50 MB zipped Uploaded via API/Console Small functions, few pure-Python deps RequestEntityTooLargeException over 50 MB
Zip, via S3 250 MB unzipped S3 object → Lambda Bigger zips still under 250 MB unzipped InvalidParameterValueException over 250 MB unzipped
Layer Shares 250 MB unzipped Own zip, extracted to /opt Shared deps across functions, faster deploys Max 5 layers; shared 250 MB budget
Container image 10 GB Private ECR repo Big ML deps, custom system libs, existing Dockerfile ECR auth, platform match, cold-pull latency
Runtime /tmp download 512 MB–10 GB ephemeral Downloaded at runtime Last resort only (models, rarely-used blobs) Cold-start download cost, no caching guarantee

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be able to create a basic Lambda function and invoke it — if not, start with AWS Lambda: Your First Function, Hands-On. You need the aws CLI configured with credentials, docker installed locally for the compiled-dependency and container-image work, and a working knowledge of your language’s package manager (pip, npm, Maven, Bundler). Familiarity with IAM basics (the function’s execution role) and reading CloudWatch Logs helps, because that is where every packaging error surfaces.

This sits in the Serverless track, immediately after “make a function run” and immediately before “make it fast and reliable.” Packaging is upstream of performance: a 200 MB layer is also a slow cold start, so this pairs directly with AWS Lambda: Memory, Timeout & Concurrency Tuning. When a deploy that “worked yesterday” starts throwing import errors, the diagnosis lives in Troubleshooting AWS Lambda: Errors, Timeouts & Cold Starts. The broader compute trade-off — when Lambda itself is the wrong tool versus ECS/EKS/Fargate — is in AWS Compute: EC2 vs Lambda vs ECS vs EKS, and the event wiring that triggers these functions is in AWS Lambda Patterns: Event-Driven Functions.

A quick map of who owns which layer of the packaging problem, so you know where to look when it breaks:

Concern Where it’s decided Who usually owns it Failure it causes
Which packaging shape Architecture / repo layout Dev + architect Wrong tool → fighting size limits
How deps are built Build step / CI / Dockerfile Dev + platform Wrong arch/glibc → ImportModuleError
Where files land in /opt Layer zip structure Dev Module not found despite attach
Size under quota Build output + layer split Dev Publish/upload rejected
ECR repo + auth Registry + IAM Platform / DevOps Image pull denied
Function config wiring Terraform / SAM / Console Dev + DevOps Layer not attached, wrong arch set

Core concepts

Six ideas make every later decision obvious.

A deployment package is the code plus everything it imports, in one shape Lambda understands. That shape is either a zip archive (your handler file at the root, dependencies alongside or in layers) or a container image (an OCI image in ECR that implements the Lambda Runtime API). There is no third option and no in-between. The function’s PackageType is Zip (the default, which needs a Runtime and Handler) or Image (which needs an ImageUri and no runtime/handler — the image provides both).

A layer is a zip archive that Lambda extracts to /opt before your handler runs. It is not magic — it is a second zip whose contents are merged into the /opt directory of the execution environment, on top of your function code which lands in the task root (/var/task). Layers exist so multiple functions can share the same heavy dependency set without each carrying its own copy, and so you can update dependencies without redeploying function code. You can attach up to five layers to one function.

/opt is only useful if you use the path the runtime scans. Each managed runtime adds specific subdirectories of /opt to its module search path. Python searches /opt/python and /opt/python/lib/python3.x/site-packages; Node.js searches /opt/nodejs/node_modules; Java searches /opt/java/lib; Ruby searches /opt/ruby/gems/x.y.0 and /opt/ruby/lib. For all runtimes, /opt/bin is added to PATH and /opt/lib to LD_LIBRARY_PATH. Put your packages one directory too high or too low and the runtime will not find them — the layer attaches fine, the import fails.

Everything unzipped shares one 250 MB budget. The hard quota is not on any single zip — it is 250 MB unzipped for the function code plus all attached layers combined (262,144,000 bytes exactly). Direct upload of the function zip is separately capped at 50 MB compressed; go over that and you must stage the zip in S3 first. The container-image path sidesteps both numbers with its own 10 GB ceiling.

Native code must match the runtime’s OS and CPU. A pure-Python or pure-JavaScript dependency is portable — it runs anywhere. A dependency with compiled parts (a C extension .so, a JNI library, a Rust/Go binary) is an ELF file built for one CPU architecture and linked against one C library version. Lambda runs on Amazon Linux — either Amazon Linux 2 (glibc 2.26) or Amazon Linux 2023 (glibc 2.34) depending on the runtime — on either x86_64 or arm64 (Graviton). Build the binary for the wrong CPU or a newer glibc and you get invalid ELF header, cannot import name, or version GLIBC_2.34 not found at import time.

Layers cost you cold-start time. At a cold start Lambda must download and unzip every layer into /opt before invoking your handler. A 5 MB layer is invisible; a 200 MB layer adds real init latency. Container images are cached and start-optimised (Lambda loads image chunks lazily), so the first pull is slower but steady-state is fast. Packaging is therefore also a performance decision, not just a mechanics one.

Here is the packaging vocabulary in one table — every moving part, defined once:

Term What it is Why it matters
Deployment package The zip or image you deploy The unit Lambda runs; sets PackageType
Task root (/var/task) Where your function code extracts First on the module search path
Layer Shared zip extracted to /opt Reuse deps; up to 5; shares 250 MB
/opt Where all layers merge Runtime scans specific subdirs of it
Layer version Immutable numbered publish of a layer You reference :N; publishing bumps it
Layer ARN arn:aws:lambda:region:acct:layer:name:version How a function attaches a layer
Container image OCI image in ECR implementing Runtime API The 10 GB path; needs RIC
RIC Runtime Interface Client Lets a container talk to the Lambda Runtime API
RIE Runtime Interface Emulator Test a container image locally
Ephemeral storage (/tmp) 512 MB–10 GB scratch disk Runtime downloads, unpacking, caches
manylinux glibc-compatibility tag on Python wheels Picks a wheel that runs on Amazon Linux
glibc The GNU C library the binary links against AL2 = 2.26, AL2023 = 2.34; mismatch = fail

Size limits and quotas — the numbers you must not hand-wave

Every packaging decision is really a decision about which limit you refuse to hit. Learn these cold; they are the most-quoted numbers in the whole topic and they appear on the DVA-C02 exam.

Quota Value Applies to If you exceed it
Direct zip upload 50 MB (compressed) update-function-code/console upload with inline zip RequestEntityTooLargeException → upload via S3
Deployment package unzipped 250 MB (262,144,000 bytes) Function code + all layers, uncompressed InvalidParameterValueException: Unzipped size must be smaller than 262144000 bytes
Console inline editor 3 MB Editing code in the browser Editor becomes read-only; deploy another way
Layers per function 5 Attached layers at most 5 layers on the 6th
Container image 10 GB PackageType=Image Reduce image; no larger tier
Ephemeral storage (/tmp) 512 MB default, up to 10,240 MB Scratch disk per environment No space left on device → raise EphemeralStorage
Function memory 128 MB–10,240 MB Runtime RAM (CPU scales with it) OOM kill; unrelated to package size
Environment variables 4 KB total All env vars combined InvalidParameterValueException
S3 object for code Effectively bounded by 250 MB unzipped Staged zip Same 250 MB unzipped rule still binds

Three subtleties that trip people up. First, the 250 MB is unzipped and shared — a 60 MB compressed layer can unzip to 240 MB and consume nearly the whole budget by itself. Second, the 50 MB direct-upload limit is about the wire, not storage: stage the same zip in S3 and Lambda accepts it, as long as the unzipped total is under 250 MB. Third, /tmp is not part of the package budget — it is runtime scratch space, so downloading a 300 MB model into /tmp at runtime is a legitimate way to dodge the 250 MB package wall (at the cost of cold-start download time).

Zip versus container image — the first fork

Before you touch a dependency, decide the shape. The zip path is the default and the lightest; the container path trades a heavier toolchain for headroom and control.

Dimension Zip package (+ layers) Container image
Max size 250 MB unzipped 10 GB
Where stored Lambda service / S3 Private ECR repo (same account + region)
PackageType Zip (default) Image
Needs Runtime + Handler ImageUri (+ optional ImageConfig)
Build toolchain zip / pip / npm / SAM Docker + ECR
Runtime provided by AWS managed runtime Your image (AWS base image or custom + RIC)
Local testing SAM local / unit tests RIE (Runtime Interface Emulator)
Cold start Fast (small) → slow (big layer) First pull slower; steady-state optimised
System libraries Only what you ship in /opt/lib Anything you can dnf install in the image
Best when Small–medium deps, fast iteration >250 MB deps, custom libs, existing Dockerfile
Update deps without code Yes (bump a layer) No (rebuild the image)

The zip + layers path wins for the common case: a handful of dependencies, quick iteration, small cold starts, and the ability to bump a shared layer without redeploying every function. The container image path wins when you are already fighting the 250 MB wall (large ML stacks), when you need system-level libraries that are painful to ship in /opt/lib (a specific libgeos, ImageMagick, a database client), or when you already have a Dockerfile and want one build story across Lambda, ECS and local.

For the zip path, know precisely where each size boundary lives:

Scenario Compressed limit Unzipped limit How to deploy
Tiny function, no deps 50 MB 250 MB Console / --zip-file fileb://
Function + pure-Python deps 50 MB 250 MB --zip-file if <50 MB
Function + deps > 50 MB zipped n/a (over wire limit) 250 MB Stage in S3, use --s3-bucket/--s3-key
Deps shared across many functions per layer 250 MB total Publish a layer, attach by ARN
Deps unzip to > 250 MB over limit Switch to container image

For the container path, the essentials:

Aspect Detail
Registry Private ECR in the same account and region as the function
Base image public.ecr.aws/lambda/<runtime> (includes the RIC) or any base + awslambdaric
Handler Set as the image CMD (e.g. lambda_function.handler)
Architecture Image platform (linux/amd64 or linux/arm64) must match function arch
Local test Run with the RIE; curl the local invoke endpoint
Auth to push aws ecr get-login-password | docker login --username AWS --password-stdin
Cross-region ECR replication or push to a repo in each region

Layers, in depth

A layer is the single most useful and most misunderstood packaging feature. Get four things right — the path, the size, the version, and the sharing — and layers pay for themselves.

The rules of layers

Rule Value / behaviour Consequence
Layers per function Up to 5 Sixth attach fails
Size Counts toward 250 MB unzipped shared budget A fat layer starves function code
Immutability Each publish creates a new version; versions never change Pin :N; deleting a version breaks functions still on it
Extract location /opt, merged across all layers Later layer in the list wins on file collisions
Runtime path Runtime-specific subdir of /opt Wrong dir = module not found
Compatible runtimes Optional metadata list Advisory; doesn’t enforce, but tooling reads it
Compatible architectures x86_64 and/or arm64 A layer built for one arch won’t work on the other
Cross-account Grant lambda:GetLayerVersion via resource policy Share without copying
Region Layer must be in the same region as the function No cross-region attach

The runtime-specific /opt path — get this exactly right

This table is the one people screenshot. Zip your layer so that these are the top-level directories inside the zip:

Runtime Put dependencies under (top of zip) Extracted path Lambda scans
Python python/ or python/lib/python3.11/site-packages/ /opt/python, /opt/python/lib/python3.x/site-packages
Node.js nodejs/node_modules/ /opt/nodejs/node_modules (+ /opt/nodejs/node18/node_modules)
Java java/lib/ (JARs) /opt/java/lib on the classpath
Ruby ruby/gems/3.2.0/ or ruby/lib/ /opt/ruby/gems/3.2.0, /opt/ruby/lib
.NET assemblies referenced from code /opt (add to probing path)
Any runtime — executables bin/ /opt/bin added to PATH
Any runtime — shared libs lib/ /opt/lib added to LD_LIBRARY_PATH

The mistake is nearly universal on the first try: you pip install -t . requests at the layer root, zip it, and get No module named 'requests' — because Python only scans /opt/python, not /opt. The fix is one directory: pip install -t python/ requests, then zip so python/ is at the top.

Layer operations — the CLI you actually run

Operation Command
Publish a version aws lambda publish-layer-version --layer-name deps --zip-file fileb://layer.zip --compatible-runtimes python3.11 --compatible-architectures arm64
Publish from S3 (big layer) aws lambda publish-layer-version --layer-name deps --content S3Bucket=b,S3Key=layer.zip ...
List versions aws lambda list-layer-versions --layer-name deps
Get one version (ARN, size) aws lambda get-layer-version --layer-name deps --version-number 3
Attach to a function aws lambda update-function-configuration --function-name f --layers arn:aws:lambda:...:layer:deps:3
Detach all layers aws lambda update-function-configuration --function-name f --layers (empty)
Share cross-account aws lambda add-layer-version-permission --layer-name deps --version-number 3 --statement-id acct123 --action lambda:GetLayerVersion --principal 111122223333
Share org-wide ... --principal '*' --organization-id o-abc123
Revoke sharing aws lambda remove-layer-version-permission --layer-name deps --version-number 3 --statement-id acct123
Delete a version aws lambda delete-layer-version --layer-name deps --version-number 3

Attaching layers replaces the whole list — update-function-configuration --layers A B sets exactly [A, B], dropping anything not listed. To add one, pass the full desired set. Order matters: on a file collision, the layer later in the list wins.

The layer ARN, dissected

ARN segment Example value Meaning
Partition aws Standard vs GovCloud/China
Service lambda
Region ap-south-1 Layer + function must match
Account 123456789012 Owner (or sharer) account
Resource type layer
Name pandas-deps Layer name
Version :7 Immutable; omit and you reference the layer, not a usable version

Managed layers you don’t have to build

Layer / source Provides Notes
AWS Parameters and Secrets Local cache for SSM/Secrets Manager AWS-published ARN per region
Lambda Insights Enhanced CloudWatch metrics agent AWS-published; adds a extension
AWS X-Ray SDK (some stacks) Tracing helpers Or bundle in your own layer
AWS Data Wrangler / awswrangler pandas + Arrow prebuilt for Lambda AWS SDK for pandas; correct binaries, saves the ELF fight
Third-party (e.g. Klayers) Community prebuilt Python deps Public ARNs; verify trust before using

awswrangler (now the AWS SDK for pandas) deserves a call-out: it is a managed layer that already contains pandas, numpy and pyarrow compiled for Lambda, for both architectures. If your whole problem is “I need pandas and the ELF header is wrong,” attaching that layer solves it without you building anything.

Packaging native and binary dependencies correctly — the ELF/glibc problem

This is the section that saves your afternoon. A native dependency ships compiled machine code, and machine code is specific to a CPU architecture and linked against a C library version. Lambda runs Amazon Linux; your laptop probably does not. When the two disagree, the import fails.

Why “it works on my machine” is exactly the problem

Three axes must all match between where you build and where Lambda runs:

Axis Your dev machine (typical) Lambda runtime Symptom if mismatched
OS / libc macOS (Mach-O), or Ubuntu 24 (glibc 2.39) Amazon Linux 2 (glibc 2.26) or 2023 (glibc 2.34) invalid ELF header; GLIBC_2.34 not found
CPU architecture Apple Silicon arm64, or Intel x86_64 Whatever you set: x86_64 or arm64 cannot import, wrong-arch ELF
Python/Node version e.g. Python 3.12 local e.g. python3.11 runtime ABI-tagged wheel won’t load

A wheel filename encodes all three: numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl means CPython 3.11, glibc ≥ 2.17 (manylinux2014), x86_64. If pip on your Mac hands you ...macosx_11_0_arm64.whl and you zip that, Lambda cannot load it.

Which runtimes are Amazon Linux 2 versus 2023

The glibc version depends on the runtime’s base OS. This matters because a binary built against glibc 2.34 (AL2023) will not run on an AL2 runtime:

Runtime family Amazon Linux 2 (glibc 2.26) Amazon Linux 2023 (glibc 2.34)
Python python3.9, python3.10, python3.11 python3.12, python3.13
Node.js nodejs16.x nodejs18.x, nodejs20.x, nodejs22.x
Java java11, java17, java8.al2 java21
.NET dotnet6 dotnet8
Ruby ruby3.2 ruby3.3
Custom (OS-only) provided.al2 provided.al2023

Read it this way: if you build a compiled dependency against the newer glibc (AL2023 or a recent Ubuntu) and attach it to an older runtime (python3.11 on AL2), you get version 'GLIBC_2.34' not found. Building on AL2 and running on AL2023 is safe (older glibc is forward-compatible); the reverse is not.

The three ways to get correct binaries

Method How Best for Caveat
pip --platform prebuilt wheels pip install --platform manylinux2014_x86_64 --target python --implementation cp --python-version 3.11 --only-binary=:all: numpy pandas Pure-wheel deps (numpy, pandas, requests) Fails if a dep has no matching wheel; --only-binary=:all: forces wheels, no local compile
Build in a Lambda base / AL2023 container docker run --rm -v "$PWD":/var/task public.ecr.aws/lambda/python:3.11 pip install -t python/ -r requirements.txt Deps that must compile (some C extensions) Needs Docker; match the image tag to your runtime
sam build --use-container SAM builds each function inside public.ecr.aws/sam/build-<runtime> Whole-app builds with SAM Requires SAM CLI + Docker

For arm64/Graviton, swap the platform tag and image:

Target arch pip platform tag Docker --platform Lambda base image
x86_64 manylinux2014_x86_64 linux/amd64 public.ecr.aws/lambda/python:3.11
arm64 (Graviton) manylinux2014_aarch64 linux/arm64 public.ecr.aws/lambda/python:3.11 (multi-arch)

The most robust recipe, and the one I reach for by default, is the Docker build in a Lambda base image — it guarantees the same OS, glibc and CPU the function will run on. On an Apple Silicon Mac targeting arm64, docker builds arm64 natively; targeting x86_64 you add --platform linux/amd64 and Docker emulates.

The classic failures, by library

Library Typical error Root cause Fix
numpy / pandas No module named 'numpy.core._multiarray_umath' or invalid ELF header macOS/wrong-arch wheel pip --platform manylinux2014_<arch> --only-binary=:all:, or use the AWS SDK for pandas layer
psycopg2 No module named 'psycopg2._psycopg' C extension not built for Linux Use psycopg2-binary, or build in a Lambda container, or aws-psycopg2
cryptography version 'GLIBC_2.34' not found Built on newer glibc than the runtime Build in the matching AL container / base image
Pillow (PIL) cannot import name '_imaging' Wrong-arch/OS binary Prebuilt manylinux wheel for the target arch
lxml ImportError: libxml2.so.2: cannot open shared object file Missing system lib Ship libxml2 in /opt/lib, or use a container image
grpcio / protobuf undefined symbol / ELF error Compiled for wrong platform manylinux wheel for the exact arch + Python version

Ephemeral /tmp as a last resort

When a dependency or asset simply will not fit — a 400 MB ML model, a large font bundle — you can download it into /tmp at runtime instead of packaging it. Raise ephemeral storage (up to 10 GB), fetch on cold start, cache in a module-global so warm invocations reuse it:

import os, boto3
MODEL_PATH = "/tmp/model.bin"
_model = None

def _load():
    global _model
    if _model is None:
        if not os.path.exists(MODEL_PATH):
            boto3.client("s3").download_file("my-models", "v3/model.bin", MODEL_PATH)
        _model = open(MODEL_PATH, "rb").read()
    return _model

The trade-off is explicit: you dodge the 250 MB package wall, but every cold start pays the download, and /tmp is per-environment scratch with no guaranteed persistence. Use it for large blobs, never for code you import.

Per-runtime dependency install and build tooling

The layout differs by language, but the principle is identical: install into the runtime’s /opt subdirectory, built for the target OS + arch.

Runtime Install command (into layer root) Layer top-level dir Bundler alternative
Python (pip) pip install -t python/ -r requirements.txt python/ poetry export -f requirements.txt | pip install -t python/ -r /dev/stdin
Node.js (npm) cd nodejs && npm install --omit=dev nodejs/node_modules/ esbuild to bundle into the function zip instead
Java (Maven) mvn package then copy JARs java/lib/ Gradle shadowJar (fat JAR in the function, not a layer)
Ruby (Bundler) bundle install --path ruby ruby/gems/x.y.0/
Go GOOS=linux GOARCH=arm64 go build function binary (no layer) Single static binary, provided.al2023
.NET dotnet publish -c Release assemblies in zip

Two build-strategy notes. For Node.js, many teams skip layers entirely and use esbuild to tree-shake and bundle dependencies straight into the function zip — smaller than a full node_modules, faster cold starts, and no /opt/nodejs path to get wrong; layers still win when several functions share a big SDK. For Python with Poetry or pipenv, export a requirements.txt first, then pip install -t python/ so the layout matches Lambda’s — do not copy your local virtualenv, which contains host-built binaries.

SAM build and Terraform packaging

Manual zip is fine for learning; production wants the artifact built reproducibly and wired declaratively.

Tool Builds deps Handles native/arch Publishes layer Best for
Manual zip + CLI You do it You do it (--platform/Docker) publish-layer-version Learning, one-offs
AWS SAM sam build (+--use-container) Yes, via build image AWS::Lambda::LayerVersion SAM/serverless apps
Serverless Framework Plugins Plugin-dependent Yes Node-heavy teams
Terraform + archive_file External build step Your build step aws_lambda_layer_version IaC-first teams
terraform-aws-modules/lambda Can build in Docker Yes (build_in_docker) Yes Terraform + reproducible builds
Container image (any) Dockerfile Dockerfile --platform ECR push Big/custom deps

Terraform, the resources you wire

Resource Purpose Key arguments
data "archive_file" Zip a dir into an artifact type="zip", source_dir, output_path
aws_lambda_layer_version Publish a layer filename/s3_*, compatible_runtimes, compatible_architectures, source_code_hash
aws_lambda_layer_version_permission Cross-account share action="lambda:GetLayerVersion", principal, organization_id
aws_lambda_function (zip) The function filename, handler, runtime, architectures, layers, source_code_hash
aws_lambda_function (image) Container function package_type="Image", image_uri, architectures
aws_ecr_repository Registry for the image image_scanning_configuration, image_tag_mutability
null_resource + local-exec Run the build (pip/docker) triggers to rebuild on change

A minimal Terraform layer-plus-function, arm64, built by a null_resource:

resource "null_resource" "build_layer" {
  triggers = { reqs = filemd5("${path.module}/src/requirements.txt") }
  provisioner "local-exec" {
    command = <<-EOT
      rm -rf build/python && mkdir -p build/python
      docker run --rm --platform linux/arm64 \
        -v ${path.module}/build:/out \
        -v ${path.module}/src:/src \
        public.ecr.aws/lambda/python:3.11 \
        pip install -t /out/python -r /src/requirements.txt
    EOT
  }
}

data "archive_file" "layer" {
  type        = "zip"
  source_dir  = "${path.module}/build"
  output_path = "${path.module}/build/layer.zip"
  depends_on  = [null_resource.build_layer]
}

resource "aws_lambda_layer_version" "deps" {
  layer_name               = "pandas-deps"
  filename                 = data.archive_file.layer.output_path
  source_code_hash         = data.archive_file.layer.output_base64sha256
  compatible_runtimes      = ["python3.11"]
  compatible_architectures = ["arm64"]
}

resource "aws_lambda_function" "app" {
  function_name    = "report-builder"
  role             = aws_iam_role.lambda.arn
  runtime          = "python3.11"
  handler          = "lambda_function.handler"
  architectures    = ["arm64"]
  filename         = data.archive_file.fn.output_path
  source_code_hash = data.archive_file.fn.output_base64sha256
  layers           = [aws_lambda_layer_version.deps.arn]
}

The container-image function is even shorter once the image is in ECR:

resource "aws_ecr_repository" "app" {
  name                 = "report-builder"
  image_tag_mutability = "IMMUTABLE"
  image_scanning_configuration { scan_on_push = true }
}

resource "aws_lambda_function" "app_image" {
  function_name = "report-builder-img"
  role          = aws_iam_role.lambda.arn
  package_type  = "Image"
  image_uri     = "${aws_ecr_repository.app.repository_url}:latest"
  architectures = ["arm64"]
}

When container images beat layers

Layers are the default, but there is a clear line past which a container image is simply the right tool. Use this decision table:

If you see / need… It’s probably… Do this
Deps unzip to > 250 MB (torch, tensorflow, transformers) The 250 MB wall Container image (10 GB)
A specific system library (libGL, libgeos, ImageMagick, oracle client) Missing /opt/lib pain Container: dnf install it in the image
An existing Dockerfile and one build story Toolchain duplication Container image, reuse the Dockerfile
Frequent dep changes shared by many functions Reuse + fast deploys Layer (bump one version)
Small function, few pure-Python deps Simplicity Zip (no layer even needed)
Need to test the exact prod artifact locally Parity Container + RIE
Strict image scanning / provenance in CI Supply-chain control Container in ECR with scan-on-push

The container base-image choices:

Base image What you get When
public.ecr.aws/lambda/<runtime> AWS base with the RIC built in Default; least friction
Distroless / Alpine + awslambdaric Minimal image, you add the RIC Tight image-size control
Your existing app image + RIC + RIE Reuse an existing Dockerfile Already containerised
public.ecr.aws/lambda/provided:al2023 OS-only, bring your own runtime Custom/other-language runtimes

A minimal Lambda Dockerfile for Python, arm64-friendly:

FROM public.ecr.aws/lambda/python:3.11
COPY requirements.txt ./
RUN pip install -r requirements.txt -t "${LAMBDA_TASK_ROOT}"
COPY lambda_function.py "${LAMBDA_TASK_ROOT}"
CMD ["lambda_function.handler"]

Because the build runs inside the Lambda base image, every compiled dependency is automatically built for the right OS, glibc and (with --platform) architecture — the container path makes the ELF problem disappear by construction.

Architecture at a glance

The diagram traces one dependency set from your machine all the way to a successful import, and shows the two shapes it can take on the way. Read it left to right. On the left, in DEPS + BUILD, your requirements.txt and any native .so libraries meet a build step — and this is where the whole article’s central decision is made: you must build for the runtime’s OS (Amazon Linux 2 vs 2023, i.e. glibc 2.26 vs 2.34) and CPU architecture (x86_64 vs arm64), using a Lambda base image or a --platform wheel install, not your laptop’s toolchain. Badge 1 marks this hop because it is where the number-one failure is born.

From there the dependencies take one of two shapes in the PACKAGE zone: a layer .zip laid out with the runtime’s top-level directory (python/, nodejs/node_modules/) and living inside the shared 250 MB unzipped budget (badge 2), or a container image built from a Dockerfile, up to 10 GB, carrying the Runtime Interface Client (badge 3). The layer path flows into PUBLISH as an immutable, ARN-addressable layer version you can share cross-account; the image path flows into a private ECR repository in the same account and region, where authentication and platform-match are the two things that bite (badge 4). Both then converge on the LAMBDA MOUNT zone: at a cold start the function extracts every layer into /opt (badge 5 marks the runtime-specific path that must be exact) and lands your code in the task root. Finally, in RUNTIME IMPORT, the runtime resolves your import — either it finds the module on /opt/python and returns import OK, or the binary is the wrong architecture or glibc and you get Runtime.ImportModuleError (badge 6, the failure this whole article exists to prevent).

AWS Lambda packaging and layers reference architecture: dependencies and native libraries pass through a build step (Amazon Linux 2023 or Docker, arm64 platform) into one of two package shapes — a layer zip laid out under the runtime-specific directory within the 250 MB unzipped budget, or a container image up to 10 GB with the Runtime Interface Client — then to publish (an immutable, ARN-addressable layer version or a private same-region ECR repository), then into the Lambda function which mounts layers into /opt and code into the task root, and finally to runtime import which either succeeds on the correct /opt path or fails with Runtime.ImportModuleError from a wrong architecture or glibc, with six numbered badges marking build-for-the-runtime, the layer size budget, when a container image wins, ECR auth and platform match, the runtime-specific /opt path, and the ImportModuleError failure

Real-world scenario

MedSignalAnalytics, a fictional but very typical health-data startup in Bengaluru, ran a Lambda that ingested wearable-device CSVs from S3, resampled them with pandas, and wrote Parquet back. It worked in dev for months. Then a platform engineer, chasing the ~20% Graviton cost saving, flipped the function’s architecture from x86_64 to arm64 in Terraform and redeployed. Every invocation began failing at cold start: Runtime.ImportModuleError: Unable to import module 'lambda_function': No module named 'pandas._libs.window.aggregations'.

The on-call engineer’s first instinct was wrong — she assumed a bad deploy and rolled back the function code. The code was fine; the code had never been the problem. The pandas layer had been built, once, months earlier, by pip install -t python/ pandas on an x86_64 CI runner. It carried x86_64 ELF binaries. The moment the function became arm64, those binaries were the wrong architecture, and pandas’ compiled _libs could not load. The layer’s compatible_architectures metadata still said x86_64 — advisory only, so nothing had blocked the mismatched attach.

The fix took three commands and a rebuild. They rebuilt the layer inside the arm64 Lambda base image — docker run --rm --platform linux/arm64 -v "$PWD":/lo public.ecr.aws/lambda/python:3.11 pip install -t /lo/python pandas pyarrow — republished it as a new version with --compatible-architectures arm64, and pointed the function at the new ARN. Cold starts went green immediately. Total downtime: 90 minutes, of which 80 was chasing the wrong hypothesis. The retro produced three standing rules: (1) an architecture change is a rebuild, never just a config flip; (2) build every layer inside the Lambda base image for the target arch, in CI, so it can never drift from a developer’s laptop; (3) evaluate the AWS SDK for pandas managed layer, which ships pandas + pyarrow prebuilt for both architectures and would have made the whole incident impossible. Six months later, when they later hit a 300 MB PyTorch requirement for an anomaly-detection model, the same team skipped layers entirely and shipped that function as a container image — 1.9 GB, well under the 10 GB ceiling, impossible to package as a layer at all.

Advantages and disadvantages

Layers:

Advantages Disadvantages
Share heavy deps across many functions Count against the 250 MB unzipped budget
Update deps without redeploying code Max 5 per function
Smaller, faster function-code deploys A big layer slows every cold start
Immutable, versioned, ARN-addressable Wrong /opt path = silent module-not-found
Cross-account / org sharing Same-region only; arch/glibc must still match

Container images:

Advantages Disadvantages
10 GB ceiling — fits big ML stacks Heavier toolchain (Docker + ECR)
Install any system library in the image First-pull cold start is slower
Reuse an existing Dockerfile / build story Must rebuild the image to change a dep
Test the exact prod artifact locally with RIE Private ECR, same region, platform must match
Native image scanning + provenance in ECR ECR storage cost + auth/lifecycle to manage

When each matters: reach for layers when several functions share the same dependency set and you iterate on it often — one publish-layer-version updates them all. Reach for a container image the moment your unzipped dependencies cross 250 MB, or you need a system package that is painful to hand-carry in /opt/lib. For a small function with a couple of pure-Python deps, do neither — just zip the deps in with the code.

Hands-on lab

You will build a Python layer containing a compiled dependency (cryptography) for arm64, built inside a Lambda-like container so the ELF header is right, publish and attach it, prove the import works, then build the same function as a container image and compare. Everything here is free-tier friendly, but ⚠️ ECR storage and any leftover functions can cost money — do the teardown.

Prerequisites: aws CLI configured, docker running, zip, and a region (ap-south-1 used below).

Step You will Costs money?
1–4 Build + publish a layer, attach, test No (within free tier)
5–7 Build + push a container image, deploy ECR storage (tiny), delete after
8 Teardown

Step 1 — Create the function and an execution role

REGION=ap-south-1
ACCT=$(aws sts get-caller-identity --query Account --output text)

# Trust policy + role
cat > trust.json <<'EOF'
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}
EOF
aws iam create-role --role-name lab-layer-role \
  --assume-role-policy-document file://trust.json
aws iam attach-role-policy --role-name lab-layer-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

Write the handler that imports the compiled dependency:

mkdir -p fn && cat > fn/lambda_function.py <<'EOF'
from cryptography.fernet import Fernet
def handler(event, context):
    k = Fernet.generate_key()
    token = Fernet(k).encrypt(b"hello from a compiled dep")
    return {"ok": True, "token_len": len(token)}
EOF
(cd fn && zip -r ../fn.zip .)

Create the function on arm64:

aws lambda create-function --function-name lab-layer-fn \
  --runtime python3.11 --architectures arm64 \
  --handler lambda_function.handler \
  --role arn:aws:iam::$ACCT:role/lab-layer-role \
  --zip-file fileb://fn.zip --region $REGION

Expected: JSON with "State": "Pending" then Active. Invoke it now — it will fail, proving the point:

aws lambda invoke --function-name lab-layer-fn --region $REGION out.json; cat out.json

Expected: Runtime.ImportModuleError: No module named 'cryptography' — the dependency isn’t there yet.

Step 2 — Build the layer inside the arm64 Lambda base image

This is the crux. Build cryptography for arm64, on Amazon Linux, so the ELF matches:

mkdir -p layer
docker run --rm --platform linux/arm64 \
  --entrypoint /bin/bash \
  -v "$PWD/layer":/lo \
  public.ecr.aws/lambda/python:3.11 \
  -c "pip install -t /lo/python cryptography"

Expected: pip installs cryptography and its arm64 .so files under layer/python/. Verify the layout:

ls layer/python/ | head    # expect: cryptography  cffi  ... (packages at top of python/)

Zip it with python/ at the top of the archive:

(cd layer && zip -r ../layer.zip python)

Step 3 — Publish and attach the layer

LAYER_ARN=$(aws lambda publish-layer-version \
  --layer-name lab-crypto-arm64 \
  --zip-file fileb://layer.zip \
  --compatible-runtimes python3.11 \
  --compatible-architectures arm64 \
  --region $REGION --query LayerVersionArn --output text)
echo "$LAYER_ARN"

aws lambda update-function-configuration \
  --function-name lab-layer-fn \
  --layers "$LAYER_ARN" --region $REGION

Expected: an ARN ending :layer:lab-crypto-arm64:1, then the function config shows the layer attached.

Step 4 — Prove the import works

aws lambda invoke --function-name lab-layer-fn --region $REGION out.json; cat out.json

Expected: {"ok": true, "token_len": 140} — the compiled dependency now imports because it was built for the function’s OS and architecture. If you instead built the layer without --platform linux/arm64 on an x86_64 machine, this is exactly where you’d still see ImportModuleError.

Step 5 — Build the same function as a container image

cat > Dockerfile <<'EOF'
FROM public.ecr.aws/lambda/python:3.11
RUN pip install cryptography -t "${LAMBDA_TASK_ROOT}"
COPY fn/lambda_function.py "${LAMBDA_TASK_ROOT}"
CMD ["lambda_function.handler"]
EOF

aws ecr create-repository --repository-name lab-layer-img --region $REGION
aws ecr get-login-password --region $REGION \
  | docker login --username AWS --password-stdin $ACCT.dkr.ecr.$REGION.amazonaws.com

docker buildx build --platform linux/arm64 -t lab-layer-img:latest --load .
docker tag lab-layer-img:latest $ACCT.dkr.ecr.$REGION.amazonaws.com/lab-layer-img:latest
docker push $ACCT.dkr.ecr.$REGION.amazonaws.com/lab-layer-img:latest

Expected: docker login prints Login Succeeded; the push shows layer digests uploaded.

Step 6 — Deploy the container function

aws lambda create-function --function-name lab-img-fn \
  --package-type Image \
  --code ImageUri=$ACCT.dkr.ecr.$REGION.amazonaws.com/lab-layer-img:latest \
  --architectures arm64 \
  --role arn:aws:iam::$ACCT:role/lab-layer-role --region $REGION

Step 7 — Invoke and compare

aws lambda invoke --function-name lab-img-fn --region $REGION outimg.json; cat outimg.json

Expected: the same {"ok": true, "token_len": 140}. You now have the identical behaviour two ways — a 2 MB zip + layer, and a ~250 MB image. For this tiny dependency the layer wins (smaller, faster cold start); the image would only earn its keep if cryptography were instead a multi-hundred-MB ML stack or needed system packages.

Step 8 — Teardown (do this — ECR and functions can cost money)

aws lambda delete-function --function-name lab-layer-fn --region $REGION
aws lambda delete-function --function-name lab-img-fn --region $REGION
aws lambda delete-layer-version --layer-name lab-crypto-arm64 --version-number 1 --region $REGION
aws ecr delete-repository --repository-name lab-layer-img --force --region $REGION
aws iam detach-role-policy --role-name lab-layer-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name lab-layer-role
rm -rf fn layer *.zip out*.json trust.json Dockerfile

Expected: each delete returns cleanly; aws ecr describe-repositories no longer lists lab-layer-img.

Common mistakes & troubleshooting

This is the section you keep open during an incident. The playbook first (symptom → root cause → how to confirm → fix), then an error-string reference, then a decision table.

# Symptom Root cause Confirm (exact command / path) Fix
1 Runtime.ImportModuleError: No module named 'X._libs...' / invalid ELF header Native dep built for wrong CPU arch (x86_64 wheel on arm64 fn) CloudWatch Logs error string; aws lambda get-function-configuration --query Architectures vs how the layer was built Rebuild for the function arch: --platform linux/arm64 / manylinux2014_aarch64, republish layer
2 version 'GLIBC_2.34' not found Binary built on newer glibc (AL2023/Ubuntu) than the runtime (AL2) Runtime OS from the runtimes table; check where you built Build in the matching Lambda base image (public.ecr.aws/lambda/python:3.11 for AL2 runtimes)
3 No module named 'requests' even though the layer is attached Files under wrong /opt path (e.g. at /opt not /opt/python) unzip -l layer.zip | head — is python/ at the top? Rezip so python/ (or nodejs/node_modules/) is the top-level dir
4 InvalidParameterValueException: Unzipped size must be smaller than 262144000 bytes Function + layers exceed 250 MB unzipped Sum unzipped sizes of code + all layers Drop dev deps, split/slim layers, or switch to a container image
5 RequestEntityTooLargeException on upload Zip over 50 MB on direct upload Zip file size > 50 MB Upload via S3: --s3-bucket/--s3-key
6 InvalidParameterValueException adding a 6th layer / at most 5 layers More than 5 layers attached get-function-configuration --query Layers (length) Consolidate deps into fewer layers
7 No module named 'psycopg2._psycopg' C extension not compiled for Linux Logs; check the wheel you shipped Use psycopg2-binary, aws-psycopg2, or build in a Lambda container
8 Container deploy: denied / no basic auth credentials on push Not logged into ECR docker push error aws ecr get-login-password | docker login --username AWS --password-stdin <acct>.dkr.ecr.<region>.amazonaws.com
9 Container function fails to start / exec format error Image platform ≠ function arch docker inspect --format '{{.Architecture}}' vs function Architectures Rebuild docker buildx --platform linux/arm64 (or amd64) to match
10 Source image ... is not valid on create Image not in private ECR same account/region, or from Docker Hub ECR repo region + account Push to a private ECR repo in the function’s region
11 “Works locally, fails in Lambda” You bundled host-built binaries (macOS/venv) file layer/python/*/*.so shows Mach-O or wrong arch Rebuild in a Lambda base image / --platform; never copy a local virtualenv
12 Cold starts suddenly 3–8s slower A huge layer (100–200 MB) is downloaded+unzipped each cold start Init Duration in logs; layer size via get-layer-version Slim the layer, split rarely-used deps, or move to a container image (cached)
13 Two layers, ImportError/wrong version of a shared dep Version conflict — both layers ship the same package unzip -l both layers; note which is later in --layers Keep one copy; later-listed layer wins on collision — order intentionally
14 Layer update didn’t take effect Function still pinned to the old layer version ARN get-function-configuration --query Layers shows :N-1 update-function-configuration --layers <new ARN>; ARNs are version-pinned

Error / status reference

Error string Meaning Most likely cause Fix
Runtime.ImportModuleError Handler’s module/import failed at init Missing dep or wrong-arch/glibc binary Fix packaging (arch/glibc/path)
invalid ELF header Binary is not a valid Linux ELF for this CPU macOS/wrong-arch binary shipped Rebuild for target arch on Amazon Linux
version 'GLIBC_2.XX' not found Binary needs newer glibc than runtime has Built on AL2023/newer Ubuntu, run on AL2 Build in matching base image
cannot import name '_imaging' / _psycopg Compiled C extension missing/wrong Native dep not built for Lambda Prebuilt manylinux wheel or container build
Unzipped size must be smaller than 262144000 bytes Over 250 MB unzipped Fat layers + code Slim or go container
RequestEntityTooLargeException Over 50 MB on direct upload Big zip uploaded inline Use S3
at most 5 layers Sixth layer attached Too many layers Consolidate
denied: requested access ... is denied ECR push/pull auth failed Not logged in / no IAM perms docker login via ECR + IAM policy
exec format error Wrong CPU arch for the image Image platform ≠ function arch Rebuild with matching --platform
No space left on device /tmp full Big downloads into 512 MB /tmp Raise EphemeralStorage up to 10240

Decision table — localise the failure fast

If you see… It’s probably… Do this first
Import fails only after an arch flip Wrong-arch native binary Rebuild the layer for the new arch
Import fails only after a runtime upgrade glibc/ABI change (AL2→AL2023) Rebuild in the new base image
Import fails immediately, first deploy Wrong /opt path or missing dep unzip -l the layer; check the top dir
Publish/update rejected Size limit (50 MB or 250 MB) Check zipped vs unzipped totals
Container won’t create/start ECR/region/platform Verify private ECR, same region, matching arch
Works cold sometimes, slow always Oversized layer Measure Init Duration; slim the layer

The two nastiest real failures deserve prose. First, the wrong-architecture native dependency — it is nasty because nothing warns you. The layer’s compatible_architectures is advisory metadata, not an enforced gate, so an x86_64-built layer attaches happily to an arm64 function and only detonates at the first cold-start import. The tell is that the failure appeared right after someone changed architectures, and the fix is always a rebuild for the new arch, never a code change. Second, the glibc mismatch — you upgraded from python3.11 (Amazon Linux 2, glibc 2.26) to python3.12 (Amazon Linux 2023, glibc 2.34), or the reverse, and a compiled dependency that was fine now throws GLIBC_... not found. The mechanism: binaries built against a newer glibc cannot run on an older one. The fix is to rebuild the dependency inside the base image that matches the target runtime, which is precisely why building in public.ecr.aws/lambda/<runtime> — rather than on your laptop — is the habit that prevents both classes at once.

Best practices

Security notes

Packaging is a supply-chain surface, so treat artifacts like the sensitive things they are.

Concern Practice
Least-privilege sharing Grant lambda:GetLayerVersion only to the specific accounts/org that need a shared layer; avoid --principal '*' without an --organization-id
No secrets in the package Never bake API keys or DB passwords into a layer or image; use Secrets Manager / SSM Parameter Store at runtime
Image scanning Enable ECR scan-on-push and act on CVEs; use immutable tags so a digest can’t be silently swapped
Provenance Prefer AWS-published or your-own-built layers over unverified public ARNs; pin exact versions
Dependency integrity Pin versions (requirements.txt with hashes / lockfiles); a compromised transitive dep runs in your function’s role
Execution role The function’s IAM role is what a malicious dependency would inherit — keep it minimal, no wildcards
Private registries Container images pull from your private ECR; don’t depend on public images at runtime
Region/account isolation Keep prod layers/images in prod accounts; cross-account grants are explicit and auditable

The subtle risk is transitive trust: a layer you attach runs with your function’s permissions. A popular but compromised PyPI package, shipped in a shared layer used by fifty functions, executes in fifty roles. Pin versions, scan images, prefer first-party or self-built layers, and keep execution roles least-privilege so the blast radius of a bad dependency is small.

Cost & sizing

Packaging choices touch the bill in three places: storage, cold-start compute, and architecture.

Cost driver How it’s charged Guidance
Layer storage Counts toward Lambda storage quota (default 75 GB/account of functions+layers) Delete unused versions; they accumulate silently
ECR storage ~USD 0.10 / GB-month (~₹8.5) Lifecycle-policy old image tags; a 2 GB image ≈ USD 0.20/mo
Cold-start compute Init Duration is billed as part of duration Slim layers / cache images to cut it
Architecture arm64 ≈ 20% cheaper per GB-second than x86_64 Move eligible functions to Graviton
Data transfer (pull) ECR cross-region/out charges apply Keep the repo in the function’s region
/tmp runtime downloads Adds billed duration on cold start Cache in module-global; size ephemeral to need

Rough figures. Lambda storage for functions and layers has a default account quota of 75 GB — plenty, but old layer versions and image tags pile up, so prune them. ECR storage is about USD 0.10/GB-month (~₹8.5): a single 2 GB container image costs roughly USD 0.20/month (~₹17) to store — trivial, but multiply by dozens of tags across environments and a lifecycle policy earns its keep. The real money is compute: a 200 MB layer that adds 3 seconds of Init Duration to every cold start, on a function memory of 1 GB invoked tens of thousands of times a day, is a measurable line item — slimming that layer or moving to a cached container image is a direct saving. And the easy 20% is Graviton: once your dependencies build for arm64, the per-GB-second rate is about a fifth cheaper for the same work.

Free-tier note: Lambda’s perpetual free tier (1M requests + 400,000 GB-seconds/month) covers all the lab work above; ECR’s free tier includes 500 MB-month of private storage for 12 months, so the lab image sits inside it if you delete it promptly.

Interview & exam questions

Q1. What are the Lambda deployment-package size limits? 50 MB for a direct zip upload, 250 MB unzipped for function code plus all layers combined (262,144,000 bytes), and 10 GB for a container image in ECR. To exceed 50 MB on the zip path you stage the artifact in S3; the 250 MB unzipped rule still applies. (DVA-C02, SAA-C03)

Q2. How many layers can a function have, and what do they share? Up to five, and they share the single 250 MB unzipped budget with the function code. Layers extract to /opt and are merged in list order (later wins on file collisions). (DVA-C02)

Q3. A function imports pandas and fails with ImportModuleError after moving to arm64. Why? The pandas layer was built for x86_64; its compiled C extensions are the wrong CPU architecture for the arm64 runtime. The compatible_architectures field is advisory, so the mismatch wasn’t blocked. Rebuild the layer for arm64 (--platform linux/arm64 / manylinux2014_aarch64) and republish. (DVA-C02)

Q4. What is /opt and why does the path inside a layer matter? /opt is where Lambda extracts all attached layers. Each runtime scans a specific subdirectory — /opt/python, /opt/nodejs/node_modules, /opt/java/lib — so files must sit under the right top-level directory in the layer zip or the runtime won’t find them. /opt/bin and /opt/lib are added to PATH and LD_LIBRARY_PATH for all runtimes. (DVA-C02)

Q5. When does a container image beat layers? When unzipped dependencies exceed 250 MB (large ML stacks), when you need system libraries that are painful to ship in /opt/lib, or when you already have a Dockerfile. Images go to 10 GB and let you dnf install system packages. (SAA-C03, DVA-C02)

Q6. What causes version 'GLIBC_2.34' not found? A native binary compiled against a newer glibc (Amazon Linux 2023, glibc 2.34, or a recent Ubuntu) attached to a runtime on Amazon Linux 2 (glibc 2.26). Build the dependency inside the base image that matches the target runtime. (DVA-C02)

Q7. What must a container image provide that a zip package doesn’t? It must implement the Lambda Runtime API, via the Runtime Interface Client (RIC) — AWS base images include it; custom base images must add it. The image also carries the handler as its CMD and needs no separate Runtime/Handler. (DVA-C02)

Q8. How do you share a layer with another account? Grant lambda:GetLayerVersion on the layer version via add-layer-version-permission, specifying the principal account (or * with an --organization-id). The consuming function references the full layer ARN and must be in the same region. (DVA-C02)

Q9. Why prefer arm64/Graviton, and what’s the catch? ~20% cheaper per GB-second and often faster; the catch is that every native dependency must be built for arm64 — pure-Python/JS code is portable, compiled code is not. (SAA-C03)

Q10. Your zip is 70 MB and the CLI rejects it. What now? The 50 MB limit is on direct upload. Upload the zip to S3 and reference it with --s3-bucket/--s3-key (or S3Bucket/S3Key in a layer publish); the 250 MB unzipped rule still binds. (DVA-C02)

Q11. How do you update a shared dependency across many functions at once? Publish a new layer version and repoint each function’s --layers to the new ARN (ARNs are version-pinned; publishing does not auto-upgrade attached functions). This is the main reason to use a layer instead of bundling. (DVA-C02)

Q12. Where does /tmp fit in packaging? It is 512 MB (up to 10 GB) ephemeral runtime scratch, not part of the deployment package. Downloading a large model into /tmp at cold start is a legitimate way to dodge the 250 MB package limit, at the cost of cold-start download time. (DVA-C02)

Quick check

  1. What is the exact unzipped size limit for function code plus all layers, and how many layers max?
  2. Where must Python dependencies sit inside a layer zip for the runtime to find them?
  3. You built a numpy layer on your Apple-Silicon Mac and the function (x86_64) throws invalid ELF header. What’s wrong and how do you fix it?
  4. Name two situations where a container image is the right choice over layers.
  5. How do you deploy a zip that is 60 MB compressed?

Answers

  1. 250 MB unzipped (262,144,000 bytes) shared across code and all layers; 5 layers max.
  2. Under python/ at the top of the zip (extracts to /opt/python, which the runtime scans); python/lib/python3.x/site-packages/ also works.
  3. The wheel is arm64 (built on Apple Silicon) but the function is x86_64 — wrong CPU architecture. Rebuild for x86_64: pip install --platform manylinux2014_x86_64 --only-binary=:all: -t python numpy, or build in the x86_64 Lambda base image, and republish.
  4. Any two: dependencies exceed 250 MB unzipped (large ML stacks); you need custom system libraries; you already have a Dockerfile; you want to test the exact prod artifact locally with RIE.
  5. It exceeds the 50 MB direct-upload limit — stage it in S3 and deploy with --s3-bucket/--s3-key (still under 250 MB unzipped).

Glossary

Term Definition
Deployment package The artifact you deploy to Lambda: a zip or a container image
Layer A zip archive of shared code/deps extracted to /opt; up to 5 per function
Layer version An immutable, numbered publish of a layer, addressed by ARN
/opt The directory where all attached layers are merged at runtime
Task root (/var/task) Where the function’s own code is extracted
PackageType Zip (runtime + handler) or Image (ECR image URI)
Container image An OCI image in ECR implementing the Lambda Runtime API; up to 10 GB
ECR Elastic Container Registry — private registry Lambda pulls images from
RIC Runtime Interface Client — lets a container talk to the Lambda Runtime API
RIE Runtime Interface Emulator — run/test a Lambda container image locally
manylinux A wheel-compatibility tag encoding the minimum glibc a Python wheel needs
glibc The GNU C library; Amazon Linux 2 has 2.26, Amazon Linux 2023 has 2.34
ELF Executable and Linkable Format — the Linux binary format; arch- and libc-specific
Graviton / arm64 AWS Arm CPUs; ~20% cheaper per GB-second, requires arm64-built native deps
Ephemeral storage (/tmp) 512 MB–10 GB per-environment scratch disk, separate from the package

Next steps

AWSLambdaLayersContainer ImagesECRPackagingServerlessGraviton
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading