AWS Advanced #3: Lambda Basics

10 min read

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

the Lambda picture
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 time

When 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 #

SituationWhy
Always-on big APIConcurrency / cold start / cost — ECS wins
Work over 15 minutesOne Lambda invocation is capped at 15 minutes
Very large memory / GPULambda max is 10 GB memory, no GPU
Stateful connections (WebSocket backends)Possible but complex to design
Always-up DB connection poolConnections re-established every call

Comparison #

EC2ECS / FargateLambda
Time aliveAlwaysAlways (Service)Per-invocation
Ops burdenHighMedium (Fargate small)Low
Cold startNoneSmallYes (tens of ms ~ seconds)
Time limitUnlimitedUnlimited15 minutes
Cost at zero trafficHighMedium0
Concurrent handlingOS-levelMany in one containerOne 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.

lambda_function.py (default in console)
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 + create
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.zip

IaC (a little Terraform) #

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

myapp/handler.py
def main(event, context):
    ...

→ Handler setting: myapp.handler.main

Event #

The data the caller passed in. A JSON object. Shape varies by source.

event from API Gateway
{
  "version": "2.0",
  "routeKey": "GET /hello",
  "headers": {...},
  "queryStringParameters": {...},
  "body": "..."
}
event from S3 ObjectCreated
{
  "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.

context
def handler(event, context):
    print(context.aws_request_id)
    print(context.function_name)
    print(context.get_remaining_time_in_millis())  # ms remaining

Invocation 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.json

2) 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.json

3) 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.

concurrency flow
10 calls/sec, 1 sec each → ~10 instances
100 calls/sec, 100ms each → ~10 instances

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

this function: max 100
aws lambda put-function-concurrency \
  --function-name hello \
  --reserved-concurrent-executions 100

Use 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 10

Cost: 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.

anatomy of cold start
[INIT] — once
  ├─ Container environment ready
  ├─ Runtime startup
  ├─ Handler module import
  └─ Code outside the handler runs (globals)
[INVOKE] — every call
  └─ Handler function runs

INIT runs only on the instance’s first call. The next call to the same instance skips INIT (warm).

Cold start times #

Rough numbers:

Language / FormINIT
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 #

  1. Increase memory — Lambda CPU scales with memory. 256MB → 1024MB alone speeds up INIT
  2. Slim dependencies — drop unused packages, tree-shake
  3. Use globals — objects created at module level (boto3 client, DB connection) are reused by warm instances
  4. Provisioned Concurrency — as above
  5. Lambda SnapStart (Java) — snapshot INIT state for fast recovery. Java only

The globals pattern #

good — create client at module level
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()
bad pattern
def handler(event, context):
    # boto3 client every invocation — slow
    s3 = boto3.client("s3")
    return s3.list_buckets()

Memory and time limits #

ResourceLimit
Memory128 MB – 10,240 MB (1 MB steps)
Time1 s – 900 s (15 minutes)
Temp disk (/tmp)512 MB – 10 GB
Environment variables4 KB
Payload (sync)6 MB
Payload (async)256 KB
Zip (source)50 MB (compressed)
Zip (uncompressed)250 MB
Container image10 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>.

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

JSON logs
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.

create a 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.13
attach to a function
aws lambda update-function-configuration \
  --function-name hello \
  --layers arn:aws:lambda:ap-northeast-2:123456789012:layer:my-utils:1

Pros: smaller function zip, deps updated in one place. Cons: too many become hard to track. 5-layer cap.

Lambda cost #

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.

async failures to SQS DLQ
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.

X