AWS Advanced #3: Lambda Basics
#1 ECS / Fargate and #2 ECR were the model where a container is always running. Even at zero traffic, at least one container is alive. When traffic is bursty, the work is short, or you want to push ops down further, a different tool fits — Lambda.
This post covers Lambda’s place, the model (runtime / handler / event), invocation styles, cold start, concurrency and limits, and logging — all in one piece.
Where Lambda fits #
AWS Lambda is a serverless function execution platform. An event comes in, the function wakes up, and when it’s done it disappears. At zero traffic, cost is zero.
event (HTTP / S3 upload / SQS message / cron)
│
▼
Lambda spins up a container hot or cold
│
▼
your handler runs (ms ~ 15 minutes)
│
▼
response / result → caller
│
▼
container dies after some idle timeWhen Lambda is the right fit #
Where Lambda fits #
- Event-driven — S3 upload → thumbnail, SQS message → processing, EventBridge schedule → batch
- Bursty traffic — usually zero, sometimes a spike. No charge while idle
- Short work — typically seconds to minutes
- Side / auxiliary workloads — helpers next to the main system
- Part of an API — not every API has to be Lambda. Just the parts that fit
Where Lambda doesn’t fit #
| Situation | Why |
|---|---|
| Always-on big API | Concurrency / cold start / cost — ECS wins |
| Work over 15 minutes | One Lambda invocation is capped at 15 minutes |
| Very large memory / GPU | Lambda max is 10 GB memory, no GPU |
| Stateful connections (WebSocket backends) | Possible but complex to design |
| Always-up DB connection pool | Connections re-established every call |
Comparison #
| EC2 | ECS / Fargate | Lambda | |
|---|---|---|---|
| Time alive | Always | Always (Service) | Per-invocation |
| Ops burden | High | Medium (Fargate small) | Low |
| Cold start | None | Small | Yes (tens of ms ~ seconds) |
| Time limit | Unlimited | Unlimited | 15 minutes |
| Cost at zero traffic | High | Medium | 0 |
| Concurrent handling | OS-level | Many in one container | One per function instance |
The last row matters: a Lambda function instance handles only one invocation at a time. N concurrent calls → N instances, auto-scaled.
Your first Lambda — Hello, World #
Create the function (console) #
Console → Lambda → “Create function” → Author from scratch → Python 3.13.
def lambda_handler(event, context):
return {
"statusCode": 200,
"body": "Hello, Lambda"
}Save and “Test” — invoke with an empty event. CloudWatch Logs auto-creates a log group (/aws/lambda/<function-name>).
Via CLI #
Zip the code:
zip function.zip lambda_function.py
aws lambda create-function \
--function-name hello \
--runtime python3.13 \
--role arn:aws:iam::123456789012:role/lambda-basic-role \
--handler lambda_function.lambda_handler \
--zip-file fileb://function.zipIaC (a little Terraform) #
resource "aws_lambda_function" "hello" {
function_name = "hello"
runtime = "python3.13"
handler = "lambda_function.lambda_handler"
role = aws_iam_role.lambda.arn
filename = "function.zip"
source_code_hash = filebase64sha256("function.zip")
memory_size = 256
timeout = 10
}The model — Runtime / Handler / Event / Context #
Four keywords summarize the Lambda model.
Runtime #
Language + version. Managed runtimes:
- Python (3.10 – 3.13)
- Node.js (18, 20, 22)
- Java (8, 11, 17, 21)
- .NET, Ruby, Go (on
provided.al2023) - Custom Runtime — Rust / Zig / Swift, etc.
Or container image (ECR) — connects naturally to #2 ECR. Useful when you have large dependencies (zip is capped at 250 MB; container image at 10 GB).
Handler #
The name of the function Lambda calls. Format: <file>.<function>.
def main(event, context):
...→ Handler setting: myapp.handler.main
Event #
The data the caller passed in. A JSON object. Shape varies by source.
{
"version": "2.0",
"routeKey": "GET /hello",
"headers": {...},
"queryStringParameters": {...},
"body": "..."
}{
"Records": [
{
"eventSource": "aws:s3",
"s3": {
"bucket": {"name": "my-bucket"},
"object": {"key": "uploads/photo.jpg"}
}
}
]
}Libraries like Lambda Powertools (Python / TypeScript / Java) handle these events with type safety — heavily used in production.
Context #
Runtime info. Time remaining (get_remaining_time_in_millis()), request id, function name, etc.
def handler(event, context):
print(context.aws_request_id)
print(context.function_name)
print(context.get_remaining_time_in_millis()) # ms remainingInvocation styles — sync / async / stream #
Behavior changes by invocation source.
1) Synchronous #
Caller waits for the result. Blocks until response.
| Source |
|---|
| API Gateway |
| ALB |
| Cognito |
Direct Invoke API |
aws lambda invoke \
--function-name hello \
--payload '{"name": "world"}' \
--cli-binary-format raw-in-base64-out \
out.json2) Asynchronous #
The caller fires the event and continues immediately. Lambda processes it in the background. Failures auto-retry (default 2 times) and can route to a DLQ (Dead Letter Queue).
| Source |
|---|
| S3 ObjectCreated |
| SNS |
| EventBridge |
InvocationType=Event Invoke |
aws lambda invoke \
--function-name hello \
--invocation-type Event \
--payload '{"key": "value"}' \
--cli-binary-format raw-in-base64-out \
out.json3) Stream / poll #
Lambda auto-polls a queue / stream.
| Source |
|---|
| SQS |
| DynamoDB Streams |
| Kinesis |
| MSK (Managed Kafka) |
Configure once and Lambda picks up new messages in batches as they arrive. Failures retry + DLQ.
Concurrency #
Lambda’s most important single concept. A function instance handles only one invocation, so concurrent invocations = number of instances spun up.
10 calls/sec, 1 sec each → ~10 instances
100 calls/sec, 100ms each → ~10 instancesAccount concurrency #
Per-region default 1,000. Production often needs more — request increase via Service Quotas.
Reserved Concurrency #
Guarantee + cap “max N” for a specific function.
aws lambda put-function-concurrency \
--function-name hello \
--reserved-concurrent-executions 100Use cases:
- Block runaway concurrency on dangerous functions (e.g. one calling a paid external API)
- Leave headroom for other important functions (so this one can’t take the whole 1,000)
- Protect downstream RDS connection pools
Provisioned Concurrency — avoid cold starts #
Pre-warm N instances. Up to N concurrent invocations have zero cold start.
aws lambda put-provisioned-concurrency-config \
--function-name hello \
--qualifier prod \
--provisioned-concurrent-executions 10Cost: you pay for warmed instance time (slightly cheaper unit rate). Worth considering for an API entrypoint where cold starts hit user experience directly.
Cold start — the most common pitfall #
The setup cost when a new instance comes up. Two phases.
[INIT] — once
├─ Container environment ready
├─ Runtime startup
├─ Handler module import
└─ Code outside the handler runs (globals)
[INVOKE] — every call
└─ Handler function runsINIT runs only on the instance’s first call. The next call to the same instance skips INIT (warm).
Cold start times #
Rough numbers:
| Language / Form | INIT |
|---|---|
| Python (small deps) | ~150 ms |
| Python (heavy deps, e.g. boto3 + pandas) | ~1–2 s |
| Node.js (small) | ~100 ms |
| Java | ~500 ms ~ several seconds |
| Container image (large) | several seconds |
Reducing cold start #
- Increase memory — Lambda CPU scales with memory. 256MB → 1024MB alone speeds up INIT
- Slim dependencies — drop unused packages, tree-shake
- Use globals — objects created at module level (boto3 client, DB connection) are reused by warm instances
- Provisioned Concurrency — as above
- Lambda SnapStart (Java) — snapshot INIT state for fast recovery. Java only
The globals pattern #
import boto3
# Once per Lambda instance — INIT phase
s3 = boto3.client("s3")
def handler(event, context):
# Keep the handler clean — don't recreate the client every call
return s3.list_buckets()def handler(event, context):
# boto3 client every invocation — slow
s3 = boto3.client("s3")
return s3.list_buckets()Memory and time limits #
| Resource | Limit |
|---|---|
| Memory | 128 MB – 10,240 MB (1 MB steps) |
| Time | 1 s – 900 s (15 minutes) |
Temp disk (/tmp) | 512 MB – 10 GB |
| Environment variables | 4 KB |
| Payload (sync) | 6 MB |
| Payload (async) | 256 KB |
| Zip (source) | 50 MB (compressed) |
| Zip (uncompressed) | 250 MB |
| Container image | 10 GB |
Memory is tied to vCPU allocation. At 1,769 MB you get one full vCPU. Raising memory often speeds the function up enough that total billed duration drops — so raising memory can actually lower cost (Lambda Power Tuning finds the sweet spot).
Logging — CloudWatch Logs #
Lambda’s stdout / stderr go automatically to CloudWatch Logs under /aws/lambda/<function-name>.
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def handler(event, context):
logger.info("Hello %s", event.get("name"))
return "ok"For production, structured logs are recommended — JSON output lets CloudWatch Logs Insights query by field.
import json, logging
logger = logging.getLogger()
def handler(event, context):
logger.info(json.dumps({
"request_id": context.aws_request_id,
"user_id": event.get("user_id"),
"action": "process",
"duration_ms": 42
}))Powertools Logger does this in one line.
Layers — code reuse #
When several functions share dependencies / utilities, separate them into a Lambda Layer.
# python deps
mkdir -p python
pip install requests -t python/
zip -r layer.zip python
aws lambda publish-layer-version \
--layer-name my-utils \
--zip-file fileb://layer.zip \
--compatible-runtimes python3.13aws lambda update-function-configuration \
--function-name hello \
--layers arn:aws:lambda:ap-northeast-2:123456789012:layer:my-utils:1Pros: smaller function zip, deps updated in one place. Cons: too many become hard to track. 5-layer cap.
Lambda cost #
per-invoke = (request count × $0.0000002)
+ (GB-seconds executed × $0.0000166667)For 1M invocations / month at 100ms each, 256MB:
- Request cost: 1M × $0.0000002 = $0.20
- Time cost: 1M × 0.1s × 0.25GB × $0.0000166667 = $0.42
- Total: ~$0.62 / month
Very cheap. The free tier is 1M invocations / 400,000 GB-seconds per month. Small workloads are essentially free.
Costs grow when:
- Functions run long with high memory (e.g. 5 min × 3GB)
- Functions sit at high concurrency continuously — ECS / Fargate may be cheaper
Common pitfalls #
1) First call slow due to cold start #
API entrypoint Lambdas can take 0.5–2 s on a cold start. Users won’t tolerate that. Apply the five tips above and consider Provisioned Concurrency.
2) Creating objects inside the handler every call #
def handler(event, context):
db = create_db_connection()
boto = boto3.client("s3")
...Wastes 100 ms per call. Pull to module-level globals.
3) RDS connection pool flood #
Lambda concurrency 100 → 100 DB connections → DB connection limit hit. Mitigations:
- RDS Proxy — shared connection pool for Lambdas
- Reserved Concurrency to cap function concurrency
- Use serverless DBs like DynamoDB
4) Async failures silently disappearing #
S3 → Lambda failures don’t surface to the caller. Capture with DLQ (SQS) or Lambda Destination.
aws lambda put-function-event-invoke-config \
--function-name hello \
--maximum-retry-attempts 2 \
--destination-config '{"OnFailure":{"Destination":"arn:aws:sqs:...:dlq"}}'5) Cut off by the 15-minute limit #
Heavy work in a single function — terminated at 14:59. Split long work with #7 Step Functions or use ECS / Fargate.
6) Payload over 6MB #
API Gateway → Lambda sync invocation cap. Use the S3 presigned URL pattern for large files — Lambda issues a presigned URL, the client uploads to S3 directly.
7) Secrets in environment variables #
Plain-text DB passwords in env vars → exposure via logs / console. Move to #6 Secrets Manager.
Wrap-up #
Here is what this post covered:
- Where Lambda fits — event-driven, bursty traffic, short work, side workloads. Zero traffic = zero cost
- Where Lambda doesn’t — always-on big traffic, > 15 min, big memory / GPU, stateful
- The model — Runtime / Handler / Event / Context. Event shape depends on the source
- Invocation styles — sync / async / stream. Async retries automatically + DLQ
- Concurrency — one instance handles one invocation. Concurrent calls scale instances automatically
- Reserved Concurrency — cap runaways + protect other functions
- Provisioned Concurrency — avoid cold start
- Cold start — the INIT cost. Mitigate with memory, slim deps, globals, SnapStart, Provisioned
- Limits — 10 GB memory, 15 min, 6 MB payload, 10 GB container image
- Logging — CloudWatch Logs automatically. Use structured JSON logs in production
- Layers — share deps (5-layer cap)
- Cost — request + duration. Tiny workloads are nearly free
- Pitfalls — cold start, recreating clients in the handler, RDS flood (RDS Proxy), async failures lost (DLQ), 15-min limit, 6MB payload, secrets in env vars
Up next — API Gateway + Lambda #
Functions alone don’t have a place to be called from. Next we cover the most common pattern — calling Lambda over HTTP via API Gateway.
#4 API Gateway + Lambda covers REST API vs HTTP API differences, Lambda integration, routes / methods, authorization (IAM / Cognito / Lambda authorizer), stages / deploys — the serverless API, all in one go.