Skip to content

· updated · skunxicat

On this page

S3 → Lambda Thumbnail Processing

Shell-first image processing at scale

Thumbnail generation is inherently an OS-level task: a file comes in, a utility processes it, files come out. There’s no application logic, no framework, no state — just a pipeline of tools. Shell scripts are the natural fit.

This guide walks through a production-ready S3 → Lambda → S3 thumbnail pipeline using bash functions, vipsthumbnail, and composable Lambda layers. The function itself is ~40 lines of shell. Everything else — runtime, image processing, JSON parsing — comes from pre-built layers.

The Challenge

Traditional serverless image processing faces several issues:

  • Heavy runtimes: Node.js/Python with image libraries consume 300-500MB memory
  • Complex dependencies: ImageMagick, Sharp, or Pillow with native bindings
  • Slow cold starts: 500ms+ initialization times
  • Event parsing complexity: Nested JSON structures require careful handling

The Shell-First Solution

Our approach uses:

Architecture Overview

S3 Source Bucket → S3 Event → Lambda (Shell + Layers) → vipsthumbnail → S3 Thumbnails Bucket

The complete pipeline processes images automatically when uploaded to the source bucket, generates thumbnails using the lightning-fast vipsthumbnail from a pre-built layer, and stores results in a separate thumbnails bucket.

The Evolution: From Container Images to Composable Layers

The original version of this pipeline used a custom container image (lambda-shell-runtime:full at 417MB) with vips compiled inside it. That approach worked, but it had drawbacks:

  • Slow Docker builds when iterating on the handler
  • Large ECR images to push on every deploy
  • vips compiled per-project instead of shared
  • 155ms cold starts (good for containers, but layers do better)

The layer-based approach eliminates all of these:

Container Image (original)Composable Layers (current)
Cold start155ms~21ms
Deploy artifact417MB container~1KB zip (handler only)
vipsCompiled per-projectShared pre-built layer
Build timeMinutes (Docker build + push)Seconds (zip + upload)
DockerfileRequiredNone
ECRRequiredNot needed

Infrastructure Setup

Terraform Configuration

The entire infrastructure is declarative — runtime, tools, function, buckets, and triggers:

# Shell runtime layer (Go bootstrap, sub-25ms cold starts)
module "shell_runtime" {
  source  = "ql4b/lambda-shell-runtime-layer/aws"
  version = "~> 1.0"

  name         = "shell-runtime"
  architecture = "arm64"
}

# vipsthumbnail layer (pre-built from lambda-shell-layers releases)
module "vipsthumbnail" {
  source  = "ql4b/lambda-layer/aws"
  version = "~> 1.0"

  name       = "vipsthumbnail"
  source_url = "https://github.com/ql4b/lambda-shell-layers/releases/download/v0.2.0/vipsthumbnail-arm64-layer.zip"

  compatible_architectures = ["arm64"]
  enable_ssm_parameters    = false
}

# jq layer for JSON parsing
module "jq" {
  source  = "ql4b/lambda-layer/aws"
  version = "~> 1.0"

  name       = "jq"
  source_url = "https://github.com/ql4b/lambda-shell-layers/releases/download/v0.2.0/jq-arm64-layer.zip"

  compatible_architectures = ["arm64"]
  enable_ssm_parameters    = false
}

# The Lambda function — just shell scripts
module "thumbnail_function" {
  source  = "ql4b/lambda-function/aws"
  version = "~> 1.0"

  source_dir   = "./app"
  name         = "thumbnail-processor"
  runtime      = "provided.al2023"
  handler      = "handler.thumb"
  architecture = "arm64"
  memory_size  = 512
  timeout      = 60

  layers = [
    module.shell_runtime.layer_arn,
    module.vipsthumbnail.layer_arn,
    module.jq.layer_arn,
  ]
}

S3 Buckets and Event Triggers

module "source_bucket" {
  source  = "cloudposse/s3-bucket/aws"
  version = "~> 4.0"

  context    = module.label.context
  attributes = ["source"]

  versioning_enabled = false
  force_destroy      = true
}

module "thumbnails_bucket" {
  source  = "cloudposse/s3-bucket/aws"
  version = "~> 4.0"

  context    = module.label.context
  attributes = ["thumbnails"]

  versioning_enabled = false
  force_destroy      = true
}

# S3 trigger for Lambda
resource "aws_s3_bucket_notification" "image_upload" {
  bucket = module.source_bucket.bucket_id

  lambda_function {
    lambda_function_arn = module.thumbnail_function.function_arn
    events              = ["s3:ObjectCreated:*"]
    filter_suffix       = ".jpg"
  }

  lambda_function {
    lambda_function_arn = module.thumbnail_function.function_arn
    events              = ["s3:ObjectCreated:*"]
    filter_suffix       = ".png"
  }

  lambda_function {
    lambda_function_arn = module.thumbnail_function.function_arn
    events              = ["s3:ObjectCreated:*"]
    filter_suffix       = ".webp"
  }
}

IAM Permissions

# Function needs read access to source bucket and write access to thumbnails bucket
{
  Effect = "Allow"
  Action = ["s3:GetObject"]
  Resource = "${module.source_bucket.bucket_arn}/*"
},
{
  Effect = "Allow"
  Action = ["s3:ListBucket"]
  Resource = module.source_bucket.bucket_arn
},
{
  Effect = "Allow"
  Action = ["s3:PutObject"]
  Resource = "${module.thumbnails_bucket.bucket_arn}/*"
}

Key insight: AWS CLI’s s3 cp requires s3:ListBucket on the bucket ARN (without /*) to verify object existence before downloading. This commonly overlooked permission causes mysterious timeouts that look like network issues.

Shell Handler Implementation

The complete image processing logic — this is the entire function deployment package:

#!/bin/bash
# handler.sh

set -euo pipefail

# Parse S3 event and extract bucket/key
parse_s3_event() {
    local event="$1"
    echo "$event" | jq -r '.Records[0].s3.bucket.name + "|" + (.Records[0].s3.object.key | @uri | gsub("%2F"; "/"))'
}

# Generate thumbnail using vipsthumbnail from the layer
generate_thumbnail() {
    local input_file="$1"
    local output_file="$2"
    local size="${3:-200x200}"

    vipsthumbnail "$input_file" -s "$size" -o "${output_file}[Q=80,strip]"
}

# Main handler function
thumb() {
    local event="$1"
    echo "Processing S3 event..." >&2

    # Parse S3 event
    local bucket_key
    bucket_key=$(parse_s3_event "$event")
    local source_bucket="${bucket_key%%|*}"
    local object_key="${bucket_key#*|}"

    echo "Processing: s3://$source_bucket/$object_key" >&2

    # Download image from S3
    local input_file="/tmp/input_$(basename "$object_key")"
    curl --aws-sigv4 "aws:amz:${AWS_REGION}:s3" \
        --user "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" \
        -H "x-amz-security-token: ${AWS_SESSION_TOKEN}" \
        -sSf "https://${source_bucket}.s3.${AWS_REGION}.amazonaws.com/${object_key}" \
        -o "$input_file"

    echo "Downloaded $(wc -c < "$input_file") bytes" >&2

    # Generate thumbnail
    local thumbnail_file="/tmp/thumb_$(basename "$object_key")"
    generate_thumbnail "$input_file" "$thumbnail_file" "200x200"
    echo "Thumbnail: $(wc -c < "$thumbnail_file") bytes" >&2

    # Upload thumbnail to destination bucket
    local thumbnail_key="thumbnails/${object_key}"
    local content_type="image/jpeg"
    [[ "$object_key" == *.png ]] && content_type="image/png"
    [[ "$object_key" == *.webp ]] && content_type="image/webp"

    curl --aws-sigv4 "aws:amz:${AWS_REGION}:s3" \
        --user "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" \
        -H "x-amz-security-token: ${AWS_SESSION_TOKEN}" \
        -H "Content-Type: ${content_type}" \
        -sSf -X PUT \
        --data-binary "@${thumbnail_file}" \
        "https://${THUMBNAILS_BUCKET}.s3.${AWS_REGION}.amazonaws.com/${thumbnail_key}"

    echo "Uploaded to: s3://${THUMBNAILS_BUCKET}/$thumbnail_key" >&2

    # Cleanup
    rm -f "$input_file" "$thumbnail_file"

    echo "{\"source\": \"$source_bucket/$object_key\", \"thumbnail\": \"${THUMBNAILS_BUCKET}/$thumbnail_key\"}"
}

Note: this uses curl --aws-sigv4 which is available in provided.al2023 out of the box — no AWS CLI needed. This eliminates the need for the heavy full runtime variant and keeps the function dependency-free beyond its layers.

Performance

With the layer-based architecture on arm64:

Cold Start:    ~21ms init
Memory:        ~80MB / 512MB (15%)
Processing:    ~1.5s (download + thumbnail + upload for a 3MB JPEG)
Deploy size:   ~1KB (handler.sh only)

Compared to the original container image approach:

MetricContainer ImageLayersImprovement
Cold start155ms~21ms86% faster
Memory137MB~80MB42% less
Deploy artifact417MB~1KB99.99% smaller
Build timeMinutesSecondsOrders of magnitude

Why Layers Beat Container Images Here

The complete journey showed that the runtime-as-a-layer architecture delivers sub-25ms cold starts with zero overhead from layer separation. Adding tool layers (jq, vipsthumbnail) doesn’t measurably increase init time — Lambda loads layers efficiently regardless of count.

Container images make sense when you need:

  • Complex system-level configuration
  • Tightly coupled dependencies
  • Full control over the OS

For image thumbnails, you need exactly three things beyond the OS: a bootstrap, a JSON parser, and an image resizer. Three layers. No Dockerfile.

vipsthumbnail Layer

The vipsthumbnail layer is built from lambda-shell-layers and ships:

  • vipsthumbnail binary (libvips 8.16.1)
  • Shared libraries (libjpeg-turbo, libpng, libwebp, libtiff, glib2)
  • Supports JPEG, PNG, WebP, TIFF input; JPEG, PNG, WebP output
# Fit within 200x200, preserving aspect ratio
vipsthumbnail input.jpg -s 200x200 -o output.jpg[Q=80,strip]

# Output as WebP
vipsthumbnail input.jpg -s 300x300 -o output.webp[Q=75]

# Smart crop to exact dimensions
vipsthumbnail input.jpg -s 200x200 --smartcrop attention -o thumb.jpg

vipsthumbnail uses a streaming pixel pipeline — it doesn’t load the full image into memory. This is why memory usage stays low even for large source images.

Testing the Pipeline

# Upload test image
aws s3 cp test-image.jpg s3://your-source-bucket/photos/test.jpg

# Watch logs
aws logs tail /aws/lambda/thumbnail-processor --follow

# Verify thumbnail
aws s3 ls s3://your-thumbnails-bucket/thumbnails/photos/

Project Structure

The function deployment is minimal:

app/
└── handler.sh    # ~40 lines of shell

That’s it. The runtime, jq, and vipsthumbnail all come from layers. Your deployment package is just your business logic.

Key Learnings

1. curl —aws-sigv4 Replaces AWS CLI

The provided.al2023 runtime ships curl with SigV4 support. For simple S3 get/put operations, this eliminates the need for the AWS CLI entirely — and avoids the binary data corruption issues we found with awscurl.

2. IAM Permission Precision

s3:ListBucket on the bucket ARN (not /*) is required for AWS CLI’s s3 cp. With curl you technically don’t need it, but include it if you plan to use AWS CLI for debugging or if your function does HEAD checks.

3. Layer Count Doesn’t Affect Cold Starts

The runtime layer benchmarks show that going from 1 to 3+ layers adds no measurable init overhead. Lambda loads all layers during environment creation before the bootstrap runs.

4. Memory Efficiency

vipsthumbnail’s streaming architecture means a 10MB source image doesn’t need 10MB of heap. The function processes images well within 512MB regardless of input size (up to Lambda’s /tmp limits).

Conclusion

The layer-based approach to shell thumbnail processing delivers:

  • Sub-25ms cold starts — 86% faster than the container image approach
  • ~1KB deploy packages — just your shell script
  • Composable architecture — swap vipsthumbnail for ffmpeg, add more tools without rebuilding
  • No Docker in the deploy path — no builds, no ECR, no image tags to manage
  • Production-ready Terraform — three modules, fully declarative

The original container image version proved shell-based image processing works. The layer evolution proves it can be fast, composable, and operationally simple.


This example uses the cloudless shell-first ecosystem: