Dash0 Raises $110M Series B at $1B Valuation

  • 12 min read

How to Run Docker Containers on AWS Lambda

Lambda has run container images since 2020, so you can package a function as a Docker image up to 10 GB and pull it from Amazon Elastic Container Registry (ECR) instead of cramming code and dependencies into a 250 MB ZIP. The constraint people miss is that Lambda doesn't run an arbitrary container. It runs your handler in response to events, and the image has to implement the Lambda Runtime API for that to happen.

What follows is the full loop from Dockerfile to live function, how to make a non-AWS base image work, and the gotchas that break container deployments without telling you why.

Build and deploy a container image

The fast path is an AWS base image. It ships with the language runtime, the Lambda Runtime Interface Client (RIC) that brokers requests between Lambda and your handler, and a Runtime Interface Emulator (RIE) for local testing. You add your code, and it works.

Here's a minimal Python function and its Dockerfile. The CMD points at your handler in module.function form, and ${LAMBDA_TASK_ROOT} is where Lambda looks for code.

python
12345
# lambda_function.py
import sys
def handler(event, context):
return f"Hello from Lambda on Python {sys.version}"
dockerfile
12345678
FROM public.ecr.aws/lambda/python:3.13
COPY requirements.txt ${LAMBDA_TASK_ROOT}
RUN pip install -r requirements.txt
COPY lambda_function.py ${LAMBDA_TASK_ROOT}
CMD [ "lambda_function.handler" ]

The sample handler imports only the standard library, but the Dockerfile still copies a requirements.txt, so create an empty one next to your code before building (touch requirements.txt). Skip it and the COPY step fails with a "not found" error. Real functions list their dependencies in that file.

You don't need a USER instruction. Lambda runs your container as a least-privileged Linux user it defines itself, which is different from Docker's default of running as root.

Building and deploying needs the AWS CLI v2 and Docker 25.0.0 or later with the buildx plugin. Build the image with buildx, where two flags are not optional:

bash
1
docker buildx build --platform linux/amd64 --provenance=false -t docker-image:test .

--platform forces a single target architecture, because Lambda rejects multi-architecture images. --provenance=false stops buildx from attaching a provenance attestation, which turns the output into a multi-manifest image that Lambda also rejects. Skip that flag and your create-function call fails later with an opaque error, so it's worth getting right up front. If you're targeting Graviton, build with linux/arm64 instead and use the matching base image tag.

Before pushing anything, test locally. The AWS base image has the emulator baked in, so you can run the container and hit it like the real Runtime API:

bash
1
docker run --platform linux/amd64 -p 9000:8080 docker-image:test

From another terminal, post an event to the local invocation endpoint:

bash
1
curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}'

You'll get the handler's return value back:

text
1
"Hello from Lambda on Python 3.13.1 (main) ..."

Once that works, authenticate Docker to ECR, create a repository, tag the image, and push it. Replace 111122223333 with your account ID and pick the region you want the function in.

bash
12345678
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin 111122223333.dkr.ecr.us-east-1.amazonaws.com
aws ecr create-repository --repository-name hello-world --region us-east-1 \
--image-scanning-configuration scanOnPush=true
docker tag docker-image:test 111122223333.dkr.ecr.us-east-1.amazonaws.com/hello-world:latest
docker push 111122223333.dkr.ecr.us-east-1.amazonaws.com/hello-world:latest

The ECR repository has to live in the same region as the function. With the image pushed, create the function against its URI, passing --package-type Image:

bash
12345
aws lambda create-function \
--function-name hello-world \
--package-type Image \
--code ImageUri=111122223333.dkr.ecr.us-east-1.amazonaws.com/hello-world:latest \
--role arn:aws:iam::111122223333:role/lambda-ex

Invoke it to confirm:

bash
1
aws lambda invoke --function-name hello-world response.json
text
1234
{
"ExecutedVersion": "$LATEST",
"StatusCode": 200
}

The handler's output lands in response.json. That's the whole loop.

Using a non-AWS or OS-only base image

The AWS base image is convenient, but you don't have to use it. Maybe you want a Debian or Alpine base for a smaller footprint, a language version AWS doesn't publish, or a compiled runtime like Rust or Go. The rule is simple: if the base image isn't an AWS Lambda image, you have to add the runtime interface client yourself, because that's the piece implementing the Runtime API.

For Python the client is the awslambdaric package. A multi-stage build keeps the final image lean by installing the client in a build stage and copying only what's needed into a slim runtime stage:

dockerfile
12345678910
FROM python:3.13 AS build
WORKDIR /function
COPY . .
RUN pip install --target /function awslambdaric
FROM python:3.13-slim
WORKDIR /function
COPY --from=build /function /function
ENTRYPOINT [ "/usr/local/bin/python", "-m", "awslambdaric" ]
CMD [ "lambda_function.handler" ]

This slim build works because awslambdaric ships prebuilt wheels for standard glibc-based images like this one, so pip never has to compile anything. A musl-based image such as Alpine has no compatible wheel, so you'd need to add a C/C++ build toolchain (g++, make, cmake, libcurl4-openssl-dev) in the build stage for pip to build the client from source.

The catch with non-AWS images is local testing. They don't include the emulator, so you download the Runtime Interface Emulator separately and mount it as the entrypoint when you run the container. It's the same curl invocation against port 9000 once it's running. AWS publishes a runtime interface client for Node.js, Java, .NET, Go, Ruby, and Rust as well, so the same pattern carries across languages.

When Lambda containers make sense versus ECS or EKS

Packaging as a container doesn't turn Lambda into a general-purpose container runtime, and this is where teams get the model wrong. Lambda still bills per invocation, scales to zero, caps execution at 15 minutes, and only runs in response to an event. Containers change the packaging format, not the execution model.

Reach for Lambda container images when you have an event-driven, bursty workload that happens to need a large artifact: an inference function pulling in PyTorch, a media job shelling out to ffmpeg, anything that blows past the 250 MB ZIP ceiling. You get scale-to-zero economics with a 10 GB envelope and standard Docker tooling.

Reach for Amazon ECS or EKS when the workload is a long-running service rather than a reaction to events. A web server handling steady traffic, anything that needs to run longer than 15 minutes, a process that holds persistent connections, or a workload where you want fine-grained control over networking, sidecars, and scheduling all point away from Lambda. If your function is warm essentially all the time, you're paying Lambda's premium for elasticity you aren't using, and a Fargate task is usually cheaper and simpler.

Common pitfalls

The biggest surprise is that re-pushing an image to the same tag does not update your function. Lambda resolves the tag to a specific image digest at deploy time and pins to it. Push a new :latest and the function keeps running the old digest until you explicitly redeploy:

bash
1234
aws lambda update-function-code \
--function-name hello-world \
--image-uri 111122223333.dkr.ecr.us-east-1.amazonaws.com/hello-world:latest \
--publish

SnapStart is the other one that catches people. It's the most effective cold-start mitigation for Java, Python, and .NET, and it does not work with container images at all. If your latency budget depends on SnapStart, you have to ship a ZIP. Provisioned Concurrency does work with containers, so that's the lever you're left with for predictable cold starts.

On cold starts generally, the fear that a 10 GB image means a 40x slower cold start is mostly unfounded. AWS solved the data-movement problem with on-demand, block-level loading, deduplicating popular base layers and streaming only the chunks a function actually touches on startup. Image size still matters, just far less than the raw number suggests. The practical takeaway is to put your dependency layers above your application code layer in the Dockerfile so the expensive layers stay cached across deploys.

One more lifecycle quirk: after you push, the function sits in Pending while Lambda optimizes the image, and it only becomes invokable once it reaches Active. A function left idle for several weeks goes Inactive, and its first invocation after that gets rejected while Lambda re-optimizes. Build that re-warm behavior into any health check that pokes rarely-used functions.

Final thoughts

Running Docker on Lambda comes down to four things: build for a single architecture with --provenance=false, test against the emulator before you push, redeploy explicitly after every image change, and remember that Lambda stays event-driven and capped at 15 minutes no matter how you package it. If your function is essentially always warm, that's the signal to look at ECS or EKS instead.

Once these functions are in production, the next problem is seeing what they do, since container cold starts, init duration, and per-invocation latency are invisible without instrumentation. Dash0 is OpenTelemetry-native, so its infrastructure monitoring tracks Lambda execution alongside real-time logs and distributed traces without a proprietary agent to wire in, and you can connect a slow cold start to the request that triggered it from one place. Start a free trial to see your functions, logs, and traces in a single view. No credit card required.