Skip to content

Commit 1389332

Browse files
committed
Initial revision
0 parents  commit 1389332

File tree

14 files changed

+531
-0
lines changed

14 files changed

+531
-0
lines changed

.github/workflows/benchmark.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: Run Benchmark
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
aws-region:
7+
description: 'AWS Region'
8+
required: true
9+
default: 'us-east-1'
10+
otel-layer:
11+
description: 'OTEL Lambda Layer'
12+
required: true
13+
default: 'arn:aws:lambda:us-east-1:184161586896:layer:opentelemetry-collector-amd64-0_13_0:1'
14+
rotel-layer:
15+
description: 'Rotel Lambda Layer'
16+
required: true
17+
default: 'arn:aws:lambda:us-east-1:418653438961:layer:rotel-extension-amd64-alpha:12'
18+
python-version:
19+
description: 'Python version to use'
20+
required: true
21+
default: '3.13'
22+
23+
jobs:
24+
run-benchmark:
25+
runs-on: ubuntu-latest
26+
27+
permissions:
28+
id-token: write
29+
contents: read
30+
31+
steps:
32+
- name: Checkout repository
33+
uses: actions/checkout@v4
34+
35+
- name: Install uv
36+
uses: astral-sh/setup-uv@v5
37+
38+
- name: Set up Python
39+
uses: actions/setup-python@v5
40+
with:
41+
python-version: ${{ github.event.inputs.python-version }}
42+
43+
- name: Verify UV installation
44+
run: uv --version
45+
46+
- name: Install deps
47+
run: |
48+
cd benchmark && uv sync
49+
50+
- name: Configure AWS credentials
51+
uses: aws-actions/configure-aws-credentials@v4
52+
with:
53+
role-to-assume: ${{ secrets.AWS_LAMBDA_BENCHMARK_ROLE_ARN }}
54+
aws-region: us-east-1
55+
56+
- name: run
57+
env:
58+
AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
59+
AWS_REGION: ${{ github.event.inputs.aws-region }}
60+
OTEL_LAYER: ${{ github.event.inputs.otel-layer }}
61+
ROTEL_LAYER: ${{ github.event.inputs.rotel-layer }}
62+
run: |
63+
./scripts/benchmark-coldstart.sh

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
function.zip
2+
rotel-layer.zip
3+
tmp/
4+
outputfile.txt
5+
package/
6+
lambda_benchmark_results.json

Makefile

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
.PHONY: bundle deps
2+
3+
SHELL := /bin/bash
4+
5+
deps:
6+
rm -rf package && \
7+
mkdir -p package && \
8+
pip install -q -r requirements.txt --target ./package
9+
10+
function.zip: deps SimpleLambda.py collector.yaml rotel.env
11+
rm -f function.zip && \
12+
cd package && zip -q -r ../function.zip . && \
13+
cd .. && zip -q function.zip SimpleLambda.py collector.yaml rotel.env
14+
15+
bundle: function.zip

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# python-lambda-benchmark
2+
3+
Small repo to benchmark Lambda runtime execution across different layers.
4+
5+
Tests:
6+
* [AWS OpenTelemetry Collector layer](https://github.com/open-telemetry/opentelemetry-lambda)
7+
* [Rotel collector layer](https://github.com/streamfold/rotel-lambda-extension)
8+
9+
## Usage
10+
11+
Use Github to invoke a workflow to run the tests.

SimpleLambda.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import boto3
2+
import json
3+
import os
4+
5+
from opentelemetry import trace
6+
from opentelemetry.sdk.resources import SERVICE_NAME, Resource, DEPLOYMENT_ENVIRONMENT
7+
from opentelemetry.sdk.trace import TracerProvider
8+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
9+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
10+
11+
# Set up the OpenTelemetry tracer provider with a resource name.
12+
resource = Resource(attributes={
13+
SERVICE_NAME: "python-lambda-example",
14+
DEPLOYMENT_ENVIRONMENT: "dev",
15+
})
16+
provider = TracerProvider(resource=resource)
17+
18+
span_processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces"))
19+
provider.add_span_processor(span_processor)
20+
21+
trace.set_tracer_provider(provider)
22+
23+
tracer = trace.get_tracer("python-lambda.tracer")
24+
25+
def echo(payload):
26+
return payload
27+
28+
def list_buckets(payload):
29+
client = boto3.client("s3")
30+
buckets = client.list_buckets()
31+
bucket_names = list(map(lambda b: b['Name'], buckets['Buckets']))
32+
33+
return {
34+
"statusCode": 200,
35+
"headers": {
36+
"Content-Type": "application/json"
37+
},
38+
"body": json.dumps({
39+
"Buckets": bucket_names,
40+
"Region ": os.environ['AWS_REGION']
41+
})
42+
}
43+
44+
operations = {
45+
'echo': echo,
46+
'list_buckets': list_buckets,
47+
}
48+
49+
def lambda_handler(event, context):
50+
'''Provide an event that contains the following keys:
51+
- operation: one of the operations in the operations dict below
52+
- payload: a JSON object containing parameters to pass to the
53+
operation being performed
54+
'''
55+
56+
with tracer.start_as_current_span(f"op-{event['operation']}") as span:
57+
operation = event['operation']
58+
payload = event['payload']
59+
60+
if operation in operations:
61+
return operations[operation](payload)
62+
else:
63+
raise ValueError(f'Unrecognized operation "{operation}"')
64+

benchmark/.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.13

benchmark/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# benchmark
2+
3+
Python benchmark to run a function multiple times and measure the coldstart time.

benchmark/main.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
from operator import contains
4+
5+
import boto3
6+
import json
7+
import time
8+
import os
9+
import uuid
10+
from botocore.exceptions import ClientError
11+
12+
def parse_args():
13+
parser = argparse.ArgumentParser(description='Deploy a function as AWS Lambda multiple times and benchmark')
14+
parser.add_argument('--path', required=True, help='Path to the function.zip file')
15+
parser.add_argument('--count', type=int, default=10, help='Number of functions to deploy (default: 10)')
16+
parser.add_argument('--function-name', default='test-function', help='Base name for the Lambda functions')
17+
parser.add_argument('--role-arn', required=True, help='ARN of the IAM role for Lambda execution')
18+
parser.add_argument('--region', default='us-east-1', help='AWS region (default: us-east-1)')
19+
parser.add_argument('--handler', default='SimpleLambda.lambda_handler', help='Lambda function handler')
20+
parser.add_argument('--runtime', default='python3.13', help='Lambda runtime (default: python3.13)')
21+
parser.add_argument('--environment', help='Environment')
22+
parser.add_argument('--layer', help="ARN of layer to include")
23+
return parser.parse_args()
24+
25+
def delete_lambda_function(lambda_client, function_name):
26+
try:
27+
lambda_client.delete_function(FunctionName=function_name)
28+
print(f"Deleted Lambda function: {function_name}")
29+
except ClientError as e:
30+
if e.response['Error']['Code'] == 'ResourceNotFoundException':
31+
print(f"Lambda function not found: {function_name}")
32+
else:
33+
raise
34+
35+
def create_lambda_function(lambda_client, function_name, environment, zip_path, role_arn, handler, runtime, layer):
36+
try:
37+
with open(zip_path, 'rb') as zip_file:
38+
zip_content = zip_file.read()
39+
40+
response = lambda_client.create_function(
41+
FunctionName=function_name,
42+
Environment={
43+
'Variables': environment,
44+
},
45+
Runtime=runtime,
46+
Role=role_arn,
47+
Handler=handler,
48+
Code={'ZipFile': zip_content},
49+
Timeout=10,
50+
MemorySize=128,
51+
Layers=[layer] if layer else [],
52+
)
53+
print(f"Created Lambda function: {function_name}")
54+
# Wait for function to be fully initialized
55+
time.sleep(5)
56+
return response['FunctionArn']
57+
except ClientError as e:
58+
print(f"Error creating Lambda function {function_name}: {e}")
59+
return None
60+
61+
def invoke_lambda_function(lambda_client, function_name, payload):
62+
try:
63+
start_time = time.time()
64+
response = lambda_client.invoke(
65+
FunctionName=function_name,
66+
InvocationType='RequestResponse',
67+
LogType='Tail',
68+
Payload=json.dumps(payload)
69+
)
70+
duration = (time.time() - start_time) * 1000 # Convert to milliseconds
71+
72+
# Get the Lambda execution duration from the response headers
73+
response_payload = json.loads(response['Payload'].read().decode('utf-8'))
74+
status_code = response['StatusCode']
75+
execution_log = None
76+
if 'LogResult' in response:
77+
import base64
78+
execution_log = base64.b64decode(response['LogResult']).decode('utf-8')
79+
80+
# Try to extract the actual Lambda execution duration from logs
81+
lambda_init_duration = None
82+
if execution_log:
83+
for line in execution_log.split('\n'):
84+
if 'Init Duration:' in line:
85+
if 'Extension.Crash' in line:
86+
print(f"Exception crashed, full execution log: {execution_log}")
87+
raise Exception("Lambda extension has crashed")
88+
try:
89+
duration_part = line.split('Init Duration:')[1].split('ms')[0].strip()
90+
lambda_init_duration = float(duration_part)
91+
break
92+
except (IndexError, ValueError):
93+
pass
94+
if not lambda_init_duration:
95+
raise Exception("Could not extract the actual Lambda init duration from logs")
96+
97+
print(f"Invoked {function_name}:")
98+
print(f" HTTP Status: {status_code}")
99+
print(f" Client-side duration: {duration:.2f} ms")
100+
101+
if lambda_init_duration:
102+
print(f" Lambda-reported init duration: {lambda_init_duration:.2f} ms")
103+
104+
return {
105+
'function_name': function_name,
106+
'status_code': status_code,
107+
'client_duration_ms': duration,
108+
'init_duration_ms': lambda_init_duration,
109+
'response': response_payload
110+
}
111+
except ClientError as e:
112+
print(f"Error invoking Lambda function {function_name}: {e}")
113+
return None
114+
115+
def main():
116+
args = parse_args()
117+
118+
environment = {}
119+
if args.environment:
120+
for val in args.environment.split(','):
121+
key, value = val.split('=')
122+
environment[key] = value
123+
124+
# Validate the ZIP file exists
125+
if not os.path.isfile(args.path):
126+
print(f"Error: ZIP file not found at {args.path}")
127+
return
128+
129+
# Create AWS clients
130+
lambda_client = boto3.client('lambda', region_name=args.region)
131+
132+
# Deploy multiple Lambda functions
133+
functions = []
134+
results = []
135+
136+
print(f"Deploying {args.count} Lambda functions...")
137+
for i in range(1, args.count + 1):
138+
function_name = f"{args.function_name}-{i}"
139+
# Try to delete it first
140+
delete_lambda_function(lambda_client, function_name)
141+
function_arn = create_lambda_function(
142+
lambda_client,
143+
function_name,
144+
environment,
145+
args.path,
146+
args.role_arn,
147+
args.handler,
148+
args.runtime,
149+
args.layer,
150+
)
151+
if function_arn:
152+
functions.append(function_name)
153+
154+
print(f"\nSuccessfully deployed {len(functions)} Lambda functions")
155+
156+
# Invoke each function with a simple payload
157+
test_payload = {
158+
'operation': "list_buckets",
159+
'payload': {
160+
'dog': "boxer",
161+
'cat': "siamese",
162+
'timestamp': int(time.time()),
163+
}
164+
}
165+
166+
print("\nInvoking each function and recording durations...")
167+
for function_name in functions:
168+
result = invoke_lambda_function(lambda_client, function_name, test_payload)
169+
if result:
170+
results.append(result)
171+
172+
# Write results to file
173+
output_file = 'lambda_benchmark_results.json'
174+
with open(output_file, 'w') as f:
175+
json.dump(results, f, indent=2)
176+
177+
print(f"\nResults have been saved to {output_file}")
178+
179+
# Print summary
180+
print(f"\nSummary of lambda init durations (cold start) for {args.function_name}:")
181+
182+
sorted_results = sorted(results, key=lambda x: x['init_duration_ms'])
183+
sorted_durations = list(map(lambda x: str(x['init_duration_ms']), sorted_results))
184+
print(f"Durations: {", ".join(sorted_durations)}")
185+
186+
if __name__ == "__main__":
187+
main()

benchmark/pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[project]
2+
name = "benchmark"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
dependencies = [
8+
"boto3"
9+
]

0 commit comments

Comments
 (0)