Packaging a Model Call in a Lambda Container Image
10 min read · updated August 11, 2026
The zip deployment package is the right default until a dependency tree stops fitting in it. Container images raise the ceiling dramatically — AWS documents a maximum uncompressed image size of 10 GB including all layers — and change three things about how the function behaves that are worth understanding before you migrate.
When the zip stops being enough
A model-calling function is usually small. An HTTP client, an SDK, some JSON handling. What makes it large is what arrives with the SDK: gRPC bindings, a protobuf runtime, a tokenizer with compiled extensions, a cryptography wheel, occasionally a numerical stack pulled in transitively by something you did not choose. Any of those can push a package past what a zip can hold.
Two more reasons to reach for an image, both good ones. It gives you system packages — a shared library your wheel needs and pip cannot install. And it makes the build reproducible: the same Dockerfile produces the same artifact locally and in CI, where a zip built on the wrong architecture or the wrong glibc produces an import error only after deployment.
Building the image
Start from the AWS base image for your runtime. It arrives with the language runtime, the runtime interface client and the runtime interface emulator already in it, which means the Dockerfile is three instructions. AWS publishes the canonical form on its deploy Python Lambda functions with container images page.
- Write the Dockerfile. Copy dependencies and code into
LAMBDA_TASK_ROOT, and setCMDto the handler — not to a command. This trips people up:CMDhere is an argument to the runtime, not a process to run.FROM public.ecr.aws/lambda/python:3.12 COPY requirements.txt ${LAMBDA_TASK_ROOT} RUN pip install -r requirements.txt COPY lambda_function.py ${LAMBDA_TASK_ROOT} CMD [ "lambda_function.handler" ] - Write the handler so the expensive work happens at import time, not per invocation. On a container image this matters more than on a zip, because the image is larger and the environment is reused for many invocations once it exists.
import json, os import boto3 # Module scope: runs once per execution environment, during Init. client = boto3.client("bedrock-runtime") MODEL_ID = os.environ["MODEL_ID"] def handler(event, context): body = json.loads(event.get("body") or "{}") resp = client.converse( modelId=MODEL_ID, messages=[{"role": "user", "content": [{"text": body["prompt"]}]}], ) text = resp["output"]["message"]["content"][0]["text"] return {"statusCode": 200, "body": json.dumps({"text": text})} - Build with buildx, both flags set. See the next section for why.
docker buildx build --platform linux/amd64 --provenance=false \ -t model-caller:test .
- Create the ECR repository in the same region as the function, log in, tag and push.
aws ecr create-repository --repository-name model-caller \ --region eu-west-1 --image-scanning-configuration scanOnPush=true aws ecr get-login-password --region eu-west-1 \ | docker login --username AWS --password-stdin \ 111122223333.dkr.ecr.eu-west-1.amazonaws.com docker tag model-caller:test \ 111122223333.dkr.ecr.eu-west-1.amazonaws.com/model-caller:latest docker push 111122223333.dkr.ecr.eu-west-1.amazonaws.com/model-caller:latest - Create the function with
--package-type Image, then invoke it.aws lambda create-function \ --function-name model-caller \ --package-type Image \ --code ImageUri=111122223333.dkr.ecr.eu-west-1.amazonaws.com/model-caller:latest \ --role arn:aws:iam::111122223333:role/lambda-ex \ --timeout 60 --memory-size 1769 aws lambda invoke --function-name model-caller \ --payload '{"body":"{\"prompt\":\"hello\"}"}' response.json
The two flags that break the image
Both produce images that build cleanly and then fail at AWS, which is the worst kind of failure because the error arrives a push cycle later.
--provenance=false is required, and AWS says so directly: “To make your image compatible with Lambda, you must use the --provenance=false option.” Recent buildx versions attach provenance attestations by default, which turns the push into a multi-manifest index. Lambda accepts Docker image manifest V2 schema 2 and OCI v1.0.0 and up, and AWS is explicit that Lambda does not support multi-architecture container images — the image you build must target exactly one architecture.
--platform linux/amd64 matters because your laptop may not be x86. An image built on Apple silicon without this flag is arm64, and deploying it to a function configured for x86_64 fails. Either flag can be set the other way — --platform linux/arm64 with an arm64 function architecture is a perfectly good combination, often a cheaper one — but the two must agree.
Two smaller requirements from AWS’s create a Lambda function using a container image page. The image must run on a read-only filesystem, with /tmp the only writable location — configurable between 512 MB and 10,240 MB. And you should not add a USER instruction: Lambda defines a least-privileged default Linux user, and all files your code needs must be readable by it.
If you use a non-AWS base image — a slim Python image, Alpine, something internal — you must install the runtime interface client yourself. For Python that is pip install awslambdaric, with ENTRYPOINT set to python -m awslambdaric and CMD to the handler.
Testing before you push
Every push-and-see cycle costs minutes. The runtime interface emulator removes them. With an AWS base image it is already present, so running the container gives you a local invocation endpoint:
docker run --platform linux/amd64 -p 9000:8080 model-caller:test
curl "http://localhost:9000/2015-03-31/functions/function/invocations" \
-d '{"body":"{\"prompt\":\"hello\"}"}'The path is not a typo — that is the emulator’s fixed endpoint. With a non-AWS base image the emulator is not in the image and you download it separately, mounting it in and overriding the entrypoint:
mkdir -p ~/.aws-lambda-rie && \
curl -Lo ~/.aws-lambda-rie/aws-lambda-rie \
https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie && \
chmod +x ~/.aws-lambda-rie/aws-lambda-rie
docker run --platform linux/amd64 -d -v ~/.aws-lambda-rie:/aws-lambda -p 9000:8080 \
--entrypoint /aws-lambda/aws-lambda-rie \
model-caller:test \
/usr/local/bin/python -m awslambdaric lambda_function.handlerWhat this catches is the whole class of packaging bugs — a missing shared library, a wheel built for the wrong architecture, a handler path that does not resolve — in seconds rather than in a deploy cycle. What it does not catch is anything about IAM, VPC routing or the real cold start, so it complements a deployed test rather than replacing it.
The lifecycle a zip package does not have
Container functions have states a zip function does not, and all three have operational consequences.
Pendingafter an update. AWS optimises the image before the function can serve invocations and the function staysPendinguntil that finishes. It cannot be invoked during this window, so a deployment pipeline that invokes immediately afterupdate-function-codeneeds to wait forActive.Inactiveafter weeks of no traffic. AWS reclaims the optimised version if a function is not invoked for multiple weeks. The next invocation is rejected, the function returns toPendingwhile the image is re-optimised, and only then does it work again. On a rarely-used function — an internal tool, a monthly job — that manifests as a mysterious first-call failure. A scheduled warm invocation avoids it.Failedif the image disappears. Lambda periodically re-fetches the image from ECR. If the image has been deleted or its permissions revoked, the function moves toFailedand every invocation fails. This is a real risk with an aggressive ECR lifecycle policy that expires untagged images: Lambda resolves the tag to a digest at deploy time, so an “untagged” image can still be the one in production.
That digest resolution has one more consequence worth stating explicitly. Pushing a new image to the same tag does not update the function. You must call update-function-code with the image URI even when the tag has not changed, and --publish if you want a version to point an alias at — which is what provisioned concurrency requires. And since the image is large, think about memory size: more memory means proportionally more CPU during Init, which is where a container function spends its extra startup time.