+1 (726) 207-9872

Beating the 250 MB Wall: Lambda Container Images, Layers, and Zip Packaging with Serverless Framework V4

Sooner or later a serverless project hits the wall: the deployment package is too big. A Python service pulls in pandas, pyarrow and a couple of ML libraries; a Node service needs headless Chromium for PDF rendering; a media pipeline needs ffmpeg. The zip blows past Lambda's 250 MB unzipped limit, and the team starts asking whether serverless was the right call at all.

It was. You just need a different packaging strategy. Lambda supports three of them — plain zip, zip plus layers, and OCI container images up to 10 GB — and Serverless Framework V4 can deploy all three. This post covers when each one is right, how to configure it, and what each choice costs you in cold start time and build complexity.

None of this is about writing different code. The handler stays the same; only how the bytes get to Lambda changes.

The limits that actually bind

Before choosing, know the numbers you are working against:

LimitZip packageContainer image
Deployment size50 MB zipped (direct), 250 MB unzipped, layers included10 GB image
Layers5 per function, counted in the 250 MBn/a
/tmp writable space512 MB, configurable to 10,240 MBsame
Console editingYes, under 3 MBNo
Cold startFastestSlightly slower on first pull, comparable after

Two things surprise people. First, the 250 MB is unzipped and includes your layers — stacking five layers does not buy you 250 MB each. Second, container images are not the cold-start disaster they were in 2020. Lambda caches and de-duplicates image layers, so a well-built image starts within tens of milliseconds of an equivalent zip. Image size matters far less than what your init code does.

Option 1: Stay on zip, and get smaller

Most "too big" packages are not actually big — they are careless. Before reaching for containers, spend an hour here.

Exclude what the runtime already provides. The AWS SDK v3 ships in the Node.js runtimes. Bundling it adds tens of megabytes for nothing:

build:
  esbuild:
    bundle: true
    minify: true
    target: node22
    exclude:
      - '@aws-sdk/*'

Package per function, not per service. By default every function in a service gets the same artifact, so your tiny cron handler ships the PDF renderer too. Turn on individual packaging and each function only carries what it imports:

package:
  individually: true
  patterns:
    - '!tests/**'
    - '!docs/**'
    - '!**/*.md'

Check the result, do not assume it. serverless package writes artifacts to .serverless/; inspect them:

serverless package
ls -lhS .serverless/*.zip
unzip -l .serverless/api.zip | sort -k1 -n -r | head -20

For Python, the equivalent wins come from installing dependencies with --only-binary=:all: for the right platform and stripping tests, __pycache__, and .dist-info directories from site-packages. We have taken a 340 MB "impossible" Python package down to 90 MB this way more than once.

Option 2: Layers for shared, slow-moving dependencies

A layer is a zip that Lambda mounts at /opt before your handler runs. Layers are worth it when several functions share the same large, rarely-changing dependency set — because the layer is uploaded once and skipped on subsequent deploys of your fast-moving handler code.

layers:
  vendor:
    path: layers/vendor          # layers/vendor/python/... or /nodejs/node_modules/...
    name: ${self:service}-vendor-${sls:stage}
    description: Shared runtime dependencies
    compatibleRuntimes:
      - python3.12
    retain: false

functions:
  report:
    handler: src/report.handler
    runtime: python3.12
    layers:
      - !Ref VendorLambdaLayer

Note the reference form: Serverless Framework exposes a layer named vendor as the CloudFormation logical ID VendorLambdaLayer. Getting that wrong is the most common layer error people hit.

Where layers disappoint:

  • They still count against the 250 MB unzipped budget, so they do not solve the size problem — they solve the deploy speed and duplication problem.
  • Local development diverges from production unless you replicate /opt in your test harness.
  • Cross-account sharing needs an explicit layer permission, and layer versions are immutable, so consumers pin to a version and drift.

Use layers for ffmpeg, a shared internal SDK, or the Powertools distribution. Do not use them as a general dependency manager.

Option 3: Container images, done properly

When the honest total exceeds 250 MB — most ML inference, anything with Chromium, large geospatial or scientific stacks — go to container images. Serverless Framework V4 builds the image, pushes it to ECR, and wires the function to it.

service: media-pipeline

provider:
  name: aws
  runtime: nodejs22.x
  architecture: arm64
  ecr:
    scanOnPush: true
    images:
      renderer:
        path: ./
        file: Dockerfile
        platform: linux/arm64
        buildArgs:
          NODE_ENV: production

functions:
  renderer:
    image:
      name: renderer
    memorySize: 3008
    timeout: 120
    ephemeralStorageSize: 4096

Two details to get right. platform must match architecture — building an amd64 image on an Apple Silicon laptop for an arm64 function produces a function that fails at runtime with an exec format error, and the error message is not obvious. And ephemeralStorageSize is how you get more than the default 512 MB of /tmp, which matters for anything that writes intermediate files.

A workable Dockerfile, using the AWS base image so the runtime interface client is already present:

FROM public.ecr.aws/lambda/nodejs:22 AS build
WORKDIR /build
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src

FROM public.ecr.aws/lambda/nodejs:22
COPY --from=build /build/node_modules ${LAMBDA_TASK_ROOT}/node_modules
COPY --from=build /build/src ${LAMBDA_TASK_ROOT}/src
CMD ["src/renderer.handler"]

Order your layers from least to most frequently changed — dependencies before application code — so that a code-only change re-pushes a few megabytes instead of the whole image.

For Python with heavy wheels, the same shape applies with public.ecr.aws/lambda/python:3.12, plus a --no-cache-dir install and a cleanup pass that deletes tests and cached bytecode.

What container images cost you

Be honest with your team about the trade-offs before committing:

  • Build time. A zip build is seconds. An image build with a cold Docker cache is minutes, and CI runners without layer caching will feel it on every push. Enable buildx cache or a registry cache in your pipeline.
  • ECR lifecycle. Every deploy pushes a new image. Without a lifecycle policy your repository grows forever and you pay storage on it. Set a rule that expires untagged images after 14 days.
  • A second supply chain. You now own base image patching. Rebuild on a schedule, not only when the application changes, and turn on scanOnPush.
  • Slower first invocation after a deploy. Lambda pulls and caches image layers on first use per environment. It is tens to a few hundred milliseconds, and it disappears once cached, but it is real for latency-critical paths.

If your service is under 250 MB after a genuine trimming pass, none of this is worth paying for.

Choosing, in one paragraph

Start with zip and individual packaging — it is the fastest to build, the fastest to start, and the easiest to debug. Add a layer when three or more functions share the same big, stable binary or dependency set and you want deploys to stop re-uploading it. Move to a container image only when the unzipped total genuinely cannot fit in 250 MB, or when you already run a container build pipeline and want one artifact format across Lambda, Fargate and ECS. Mixing is fine and common: one service can have zip-packaged API handlers and a single container-packaged worker, and Serverless Framework V4 will deploy both from the same serverless.yml.

Verify before you celebrate

Whichever route you take, confirm the reality in production rather than trusting the config:

# Package type and size as Lambda sees it
aws lambda get-function-configuration --function-name media-pipeline-dev-renderer \
  --query '{Package:PackageType,Memory:MemorySize,Arch:Architectures,Tmp:EphemeralStorage}'

# Init duration on real invocations
aws logs start-query --log-group-name /aws/lambda/media-pipeline-dev-renderer \
  --start-time $(date -d '1 hour ago' +%s) --end-time $(date +%s) \
  --query-string 'filter @type="REPORT" | stats pct(@initDuration,95) as p95Init, max(@duration) as maxDur'

If p95 init got worse after a packaging change, the cause is almost always initialization code — a model loaded at module scope, a config file parsed on every cold start — not the packaging format itself.

Heavy-dependency Lambdas are where serverless projects most often get abandoned prematurely. If you are staring at a 300 MB package and deciding whether to move the workload to containers on ECS, get in touch — packaging, build pipelines and cold-start budgets for exactly these workloads are what our team does every day.