Package a Python Lambda as a Docker Image
AWS Lambda is not just zip files. Here is how to package a Python function as a Docker container image, choose between arm64 and x86_64, test it locally with the Runtime Interface Emulator, push it to Amazon ECR, and invoke it directly without any API Gateway in front.
AWS Lambda has a reputation for being all about little zip files, but it has supported Docker container images since 2020, and for a lot of real work they are the better fit. In this series we will build a small but genuinely useful Python function, a thumbnail generator, and take it from a Dockerfile all the way to something you can call over HTTP and even orchestrate into a workflow. This first part is the foundation: package the function as an image, pick an architecture, run it locally, push it to ECR, and invoke it directly.
Container image or zip? When to reach for Docker
By default a Lambda is a .zip: your code plus dependencies, uploaded directly or layered on. That path is wonderful for small, pure-Python functions. It supports Lambda layers, editing in the console, and SnapStart for faster cold starts. The catch is size: the deployment package is capped at 50 MB zipped on a direct upload and 250 MB unzipped including layers.
Container images raise that ceiling to 10 GB and hand you the whole build. Reach for an image when:
- Your dependencies are large (data or machine learning libraries) or need native system libraries (image and video tooling, headless browsers, compiled extensions).
- You want your own base image and a reproducible
docker build, ideally the same one your CI already produces. - Your team simply lives in Docker and would rather not learn the zip-and-layers dance.
The tradeoffs are honest ones: container images do not support SnapStart (that is zip only), and you own patching the base image by rebuilding and redeploying. Our thumbnailer leans on Pillow, which ships native image codecs, so an image is the natural fit and a good excuse to learn the workflow.
The function
Here is the whole thing. It takes an image as base64 or a URL, resizes it while preserving aspect ratio, and returns the result as base64 so it travels happily inside JSON. Save it as app.py.
import base64
import io
from urllib.request import urlopen
from PIL import Image
def handler(event, context):
width = int(event.get("width", 200))
# Accept either a base64-encoded image or a URL to fetch.
if "image_base64" in event:
raw = base64.b64decode(event["image_base64"])
elif "image_url" in event:
with urlopen(event["image_url"]) as response:
raw = response.read()
else:
raise ValueError("Provide image_base64 or image_url")
image = Image.open(io.BytesIO(raw))
image.thumbnail((width, width)) # resize in place, keeping aspect ratio
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return {
"width": image.width,
"height": image.height,
"thumbnail_base64": base64.b64encode(buffer.getvalue()).decode(),
}
A Lambda handler is just a function that takes (event, context). The event is whatever invoked it sends; the context carries runtime metadata. Everything else here is ordinary Python, which is exactly the point.
The Dockerfile
AWS publishes base images that already speak the Lambda Runtime API, so the Dockerfile is short. Put Pillow in a requirements.txt next to app.py, then:
FROM public.ecr.aws/lambda/python:3.13
# Dependencies first, so Docker can cache this layer between builds.
COPY requirements.txt ${LAMBDA_TASK_ROOT}/
RUN pip install --no-cache-dir -r requirements.txt
# Then the function code.
COPY app.py ${LAMBDA_TASK_ROOT}/
# "module.function" is the handler() in app.py.
CMD ["app.handler"]
${LAMBDA_TASK_ROOT} is /var/task, where Lambda looks for your code. The base image already sets the ENTRYPOINT to its runtime bootstrap, so you only supply the handler name as the CMD. Do not override the entrypoint or add a USER; Lambda manages that for you. The 3.13 tag is a solid default (Python 3.13 on Amazon Linux 2023); 3.14 is the newest if you want the latest, and both run on either CPU architecture.
arm64 or x86_64?
Lambda runs on two architectures: x86_64 (the default) and arm64, which is AWS Graviton2. AWS quotes up to 34% better price performance on arm64 along with a lower per-millisecond price, so arm64 is the sensible default unless something stops you.
With containers there is one rule to internalize: an image is built for exactly one architecture, and the function's architecture has to match it. Every native dependency has to have an arm64 build too. Pillow does, as do almost all popular packages now, but it is the thing to check before you switch. You select the architecture at build time:
# Recommended default: arm64 (Graviton)
docker buildx build --platform linux/arm64 -t thumbnailer:arm64 .
# Or x86_64, if a dependency forces your hand
docker buildx build --platform linux/amd64 -t thumbnailer:amd64 .
If you are on an Apple Silicon Mac, arm64 is also your native build, so it is both the faster thing to build locally and the cheaper thing to run. A happy alignment.
Run it locally with the Runtime Interface Emulator
This is the part people miss: the AWS base images bundle the Runtime Interface Emulator (RIE), a tiny local stand-in for the Lambda service. Start the container and it listens like the real thing:
docker run --rm -p 9000:8080 thumbnailer:arm64
Then, in another terminal, post an event to the emulator's invocation endpoint. That URL is fixed; the literal word function is part of it and the emulator ignores the function name:
curl "http://localhost:9000/2015-03-31/functions/function/invocations" \
-d '{"image_url": "https://httpbin.org/image/jpeg", "width": 120}'
You get back the handler's return value as JSON:
{
"width": 120,
"height": 120,
"thumbnail_base64": "iVBORw0KGgo..."
}
To actually see the thumbnail, decode that field to a file:
curl -s "http://localhost:9000/2015-03-31/functions/function/invocations" \
-d '{"image_url": "https://httpbin.org/image/jpeg", "width": 120}' \
| python3 -c "import sys, json, base64; open('thumb.png', 'wb').write(base64.b64decode(json.load(sys.stdin)['thumbnail_base64']))"
Open thumb.png and there is your resized image, produced by the exact bytes you are about to ship. (One gotcha: if you built an arm64 image but are running on an x86 host, add --platform linux/arm64 to the docker run so it emulates the right CPU.)
Push the image to Amazon ECR
Lambda pulls container images from Amazon Elastic Container Registry, in the same Region as the function. Three steps: create a repository, log Docker in, then tag and push.
# 1. Create the repository (once per repo)
aws ecr create-repository --repository-name thumbnailer --region us-east-1
# 2. Authenticate Docker to your private registry
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin 111122223333.dkr.ecr.us-east-1.amazonaws.com
# 3. Tag the local image with the registry URI and push it
docker tag thumbnailer:arm64 111122223333.dkr.ecr.us-east-1.amazonaws.com/thumbnailer:latest
docker push 111122223333.dkr.ecr.us-east-1.amazonaws.com/thumbnailer:latest
Swap 111122223333 for your AWS account ID. The registry URI follows the pattern <account>.dkr.ecr.<region>.amazonaws.com/<repo>:<tag>.
Create the function and invoke it directly
With the image in ECR, create the function from it. You need an execution role; a basic one that allows writing logs is enough to start.
aws lambda create-function \
--function-name thumbnailer \
--package-type Image \
--code ImageUri=111122223333.dkr.ecr.us-east-1.amazonaws.com/thumbnailer:latest \
--role arn:aws:iam::111122223333:role/lambda-thumbnailer-role \
--architectures arm64 \
--memory-size 512 \
--timeout 30
Now invoke it. No API Gateway, no HTTP, just the Lambda API directly, which is perfect for internal tools and scripts:
aws lambda invoke \
--function-name thumbnailer \
--cli-binary-format raw-in-base64-out \
--payload '{"image_url": "https://httpbin.org/image/jpeg", "width": 120}' \
response.json
The --cli-binary-format raw-in-base64-out flag is required on AWS CLI v2 when you pass an inline JSON payload. The command prints {"ExecutedVersion": "$LATEST", "StatusCode": 200} and writes the function's output to response.json. One sharp edge worth knowing: StatusCode is 200 even when your code throws. A handler error shows up as a FunctionError field instead, and --log-type Tail returns the logs so you can see what happened.
From application code it is the same idea. In Python with boto3:
import json
import boto3
lambda_client = boto3.client("lambda")
response = lambda_client.invoke(
FunctionName="thumbnailer",
InvocationType="RequestResponse", # synchronous; use "Event" to fire and forget
Payload=json.dumps({"image_url": "https://httpbin.org/image/jpeg", "width": 120}),
)
result = json.load(response["Payload"])
print(result["width"], result["height"])
And because this is a PHP blog at heart, the AWS SDK for PHP does it just as cleanly. We will wire this into a real Laravel app in part two, but the call itself is a few lines:
use Aws\Lambda\LambdaClient;
$lambda = new LambdaClient(['region' => 'us-east-1', 'version' => 'latest']);
$result = $lambda->invoke([
'FunctionName' => 'thumbnailer',
'Payload' => json_encode(['image_url' => 'https://example.com/cat.jpg', 'width' => 120]),
]);
$thumbnail = json_decode($result->get('Payload')->getContents(), true);
After you change the code, rebuild the image, push it, and tell Lambda to pick up the new version with aws lambda update-function-code --function-name thumbnailer --image-uri ...:latest --publish. Lambda resolves a tag to a specific image digest at deploy time, so pushing a new :latest does not update the function on its own; the update command is what does it.
You now have a containerized Python function tested locally with the very same emulator AWS runs, living in ECR, and callable directly. Invoking by hand or from a script is great for internal and event-driven use, but if you want a real URL that the world can hit, you need a front door. That is part two, where we compare Lambda function URLs against API Gateway and then call the result from a Laravel app.