Articles/AWS/Package a Python Lambda as a Docker Image

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.

April 8, 2026·10 min read

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.

PYTHON
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:

DOCKERFILE
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:

BASH
# 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:

BASH
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:

BASH
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:

JSON
{
  "width": 120,
  "height": 120,
  "thumbnail_base64": "iVBORw0KGgo..."
}

To actually see the thumbnail, decode that field to a file:

BASH
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.

BASH
# 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.

BASH
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:

BASH
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:

PYTHON
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:

PHP
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.