by skunxicat

Testing Lambda Functions Locally with the Runtime Emulator

Run your Lambda functions in an environment identical to AWS, right on your laptop


Lambda development often involves a slow deploy-test-debug cycle that makes iteration painful. The Lambda Runtime Interface Emulator (RIE) lets you run your functions locally in an environment that’s nearly identical to the actual Lambda runtime.

This approach works for all Lambda runtimes (Python, Node.js, Go, etc.) and any event trigger (S3, SQS, CloudWatch, etc.) - you just craft the appropriate event payload. We’ll cover the most common runtimes with practical examples.

Lambda’s Event-Driven Foundation

AWS Lambda was designed from the ground up as an event-driven compute service. Unlike traditional servers that run continuously, Lambda functions are stateless and ephemeral - they exist only when responding to events.

Events can trigger Lambda functions in two ways:

  • Push model: AWS services directly invoke your function (API Gateway, S3, SNS)
  • Pull model: Lambda polls event sources and invokes your function (SQS, DynamoDB Streams)

Each event source sends a JSON payload with a specific structure. Your function receives this event object as its first parameter, processes it, and returns a response. The event structure varies dramatically between services - an S3 event contains bucket/object details, while an API Gateway event includes HTTP request data.

This event-driven model is why local testing requires crafting realistic event payloads that match your production triggers.

The Process

The workflow is consistent across all runtimes:

  1. Start with AWS base image - Use the official runtime image from public.ecr.aws/lambda/
  2. Copy your code - Use ${LAMBDA_TASK_ROOT} (which points to /var/task) as the destination
  3. Set the handler - Specify which function to invoke via CMD
  4. Build and run - Docker builds the image, RIE runs it locally

The ${LAMBDA_TASK_ROOT} environment variable is set to /var/task in all Lambda base images - this is where AWS expects your function code to live. You can copy files directly there in the Dockerfile, or mount your local directory at runtime for faster iteration.

Runtime Examples

All AWS Lambda base images include the Lambda Runtime Interface Emulator at /usr/local/bin/aws-lambda-rie. Here’s how to set up local testing for the most common runtimes:

Node.js Runtime

# Dockerfile
FROM public.ecr.aws/lambda/nodejs:22

COPY hello.js ${LAMBDA_TASK_ROOT}

CMD ["hello.handler"]
// hello.js
exports.handler = async (event) => {
    console.log('Event:', JSON.stringify(event, null, 2));
    return {
        statusCode: 200,
        body: JSON.stringify({
            message: 'Hello from Node.js Lambda!',
            input: event
        })
    };
};

Python Runtime

# Dockerfile
FROM public.ecr.aws/lambda/python:3.12

COPY lambda_function.py ${LAMBDA_TASK_ROOT}

CMD ["lambda_function.lambda_handler"]
# lambda_function.py
import json

def lambda_handler(event, context):
    print(f"Event: {json.dumps(event, indent=2)}")
    return {
        'statusCode': 200,
        'body': json.dumps({
            'message': 'Hello from Python Lambda!',
            'input': event
        })
    }

Java Runtime

# Dockerfile
FROM public.ecr.aws/lambda/java:21

COPY target/lambda-function.jar ${LAMBDA_TASK_ROOT}

CMD ["example.Handler::handleRequest"]
// Handler.java
package example;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import java.util.Map;

public class Handler implements RequestHandler<Map<String,Object>, String> {
    @Override
    public String handleRequest(Map<String,Object> event, Context context) {
        System.out.println("Event: " + event.toString());
        return "Hello from Java Lambda!";
    }
}

Custom Runtime (Shell/Go)

# Dockerfile
FROM public.ecr.aws/lambda/provided:al2023

WORKDIR /opt
RUN mkdir -p bin lib

# Copy your layer binaries
COPY layers/jq/bin/jq bin/jq
COPY layers/uuidgen/bin/uuidgen bin/uuidgen
COPY layers/http-cli/bin/http-cli bin/http-cli

# Copy helper libraries
COPY layers/helpers/lib/helpers.sh lib/helpers.sh

WORKDIR /var/task

# Set the same PATH as Lambda
ENV PATH="/opt/bin:/usr/local/bin:/usr/bin:/bin"

# Copy common runtime files
COPY src/common/* ./

# Your function files (or mount them at runtime)
COPY src/handler/*.sh .

Running Your Function Locally

The approach varies slightly by runtime:

For Managed Runtimes (Node.js, Python, Java)

#!/bin/bash
TAG="my-lambda-function"

# Build the runtime image
build() {
    docker build --tag="$TAG" .
}

# Run with the Lambda Runtime Interface Emulator
run() {
    docker run --rm \
        --entrypoint /usr/local/bin/aws-lambda-rie \
        -p 9000:8080 \
        --name "$TAG" \
        "$TAG" \
        /lambda-entrypoint.sh handler
}

For Custom Runtimes (provided:al2023)

#!/bin/bash
TAG="my-lambda-function"
HANDLER="/var/task/handler.sh"

# Build the runtime image
build() {
    docker build --tag="$TAG" .
}

# Run with the Lambda Runtime Interface Emulator
run() {
    docker run --rm \
        --entrypoint /usr/local/bin/aws-lambda-rie \
        -p 9000:8080 \
        --name "$TAG" \
        --volume ./src/handler:/var/task:rw \
        --env HANDLER="$HANDLER" \
        "$TAG" \
        /var/task/bootstrap
}

The key difference: managed runtimes use /lambda-entrypoint.sh, while custom runtimes use your bootstrap script.

Testing Your Function

Understanding Event Payloads

The event payload your Lambda function receives depends entirely on the trigger you configure in AWS (API Gateway, S3, SNS, SQS, etc.). To test locally, you need to craft payloads that match your production triggers.

Each AWS service sends a different event structure - API Gateway includes HTTP details, S3 sends bucket/object info, SQS provides message queues. You must know your trigger’s payload format to test effectively.

Once running, your function is available at http://localhost:9000/2015-03-31/functions/function/invocations:

# Simple test
curl -d '{"test": "data"}' \
    "http://localhost:9000/2015-03-31/functions/function/invocations"

# API Gateway event simulation
http_req() {
    local data="${1:-$(cat)}"
    jq -nr --argjson data "$data" '{
        requestContext: {
            httpMethod: "POST"            
        },
        httpMethod: "POST",
        body: $data|tostring,
        isBase64Encoded: false
    }'
}

# Test with API Gateway event structure
data='{"name":"world","message":"hello"}'
event=$(http_req "$data")
curl -d "$event" "http://localhost:9000/2015-03-31/functions/function/invocations"

# Test S3 event
s3_event='{
  "Records": [{
    "s3": {
      "bucket": {"name": "my-bucket"},
      "object": {"key": "file.json"}
    }
  }]
}'
curl -d "$s3_event" "http://localhost:9000/2015-03-31/functions/function/invocations"

# Test SQS event
sqs_event='{
  "Records": [{
    "body": "{\"message\": \"hello\"}",
    "messageId": "test-123"
  }]
}'
curl -d "$sqs_event" "http://localhost:9000/2015-03-31/functions/function/invocations"

What Makes This Powerful

1. Identical Environment

Your function runs in the exact same base image AWS uses, with the same file system layout, environment variables, and runtime behavior.

2. Layer Testing

All your Lambda layers are available at the correct paths (/opt/bin/, /opt/lib/), so you can test layer interactions locally.

3. Real Runtime API

The emulator implements the actual Lambda Runtime API, so your bootstrap code works identically to production.

4. Fast Iteration

Mount your source directory with --volume ./src:/var/task:rw to see code changes immediately without rebuilding the image.

Advanced Usage

Hot Reloading

For Node.js/Python:

# Mount source for instant updates
run() {
    docker run --rm \
        --entrypoint /usr/local/bin/aws-lambda-rie \
        -p 9000:8080 \
        --volume ./src:/var/task:rw \
        "$TAG" \
        /lambda-entrypoint.sh handler
}

For Custom Runtimes:

# Mount source for instant updates
run() {
    docker run --rm \
        --entrypoint /usr/local/bin/aws-lambda-rie \
        -p 9000:8080 \
        --volume ./src/handler:/var/task:rw \
        --env HANDLER="/var/task/handler.sh" \
        "$TAG" \
        /var/task/bootstrap
}

Multiple Functions

# Test different functions by changing the handler
test_api() {
    HANDLER="/var/task/api.sh" run  # Custom runtime
}

test_worker() {
    docker run --rm \
        --entrypoint /usr/local/bin/aws-lambda-rie \
        -p 9000:8080 \
        "$TAG" \
        /lambda-entrypoint.sh worker.handler  # Managed runtime
}

Environment Variables

# Add Lambda environment variables
run() {
    docker run --rm \
        --entrypoint /usr/local/bin/aws-lambda-rie \
        -p 9000:8080 \
        --env HANDLER="/var/task/handler.sh" \
        --env AWS_REGION="us-east-1" \
        --env STAGE="local" \
        "$TAG" \
        /var/task/bootstrap
}

Real-World Example

Here’s a complete workflow for testing functions across different runtimes:

# 1. Build the runtime
build

# 2. Start the function
run &

# 3. Test with different event types
# Simple test
curl -d '{"test": "data"}' \
    "http://localhost:9000/2015-03-31/functions/function/invocations"

# API Gateway event
api_event='{
  "httpMethod": "POST",
  "body": "{\"name\":\"world\"}",
  "headers": {"Content-Type": "application/json"}
}'
curl -d "$api_event" \
    "http://localhost:9000/2015-03-31/functions/function/invocations"

# S3 event
s3_event='{
  "Records": [{
    "s3": {
      "bucket": {"name": "my-bucket"},
      "object": {"key": "file.json"}
    }
  }]
}'
curl -d "$s3_event" \
    "http://localhost:9000/2015-03-31/functions/function/invocations"

Runtime-Specific Tips

Node.js

  • Use --volume ./node_modules:/var/task/node_modules:ro for dependencies
  • CommonJS: exports.handler = async (event) => {}
  • ES6: Use .mjs extension or "type": "module" in package.json

Python

  • Use --volume ./requirements.txt:/var/task/requirements.txt:ro
  • Install deps: RUN pip install -r requirements.txt
  • Handler format: filename.function_name

Java

  • Build with Maven/Gradle first: mvn package
  • Handler format: package.Class::method
  • Use multi-stage builds for smaller images

Custom Runtime

  • Perfect for shell scripts, Go binaries, or any executable
  • Requires bootstrap script implementing Lambda Runtime API
  • Most flexible but requires more setup

Why This Matters

This approach eliminates the deploy-test-debug cycle that slows down Lambda development. You get:

  • Faster feedback loops - Test changes in seconds, not minutes
  • Confident deployments - If it works locally, it works in AWS
  • Better debugging - Use local tools and debuggers
  • Cost savings - No AWS charges for testing

The Foundation

The Lambda Runtime Interface Emulator bridges the gap between local development and cloud execution. By replicating the exact runtime environment locally, you can develop Lambda functions with the same confidence as traditional applications.

This approach works across all Lambda runtimes, from simple Node.js APIs to complex shell-based automation. The consistent Docker-based workflow means you can develop any Lambda function with confidence, regardless of the runtime.


Important Note

This Docker-based approach is purely for local testing - it replicates the Lambda runtime environment on your machine. Your actual Lambda deployment can still use traditional zip packages or layers.

If you’re interested in deploying Lambda functions as container images (a different topic), see the AWS documentation on container image deployment.

The Lambda Runtime Interface Emulator is available in all AWS Lambda base images for local development and testing.