Skip to content

How to Configure 10 GB Memory for AWS Lambda Raster Processing

Set MemorySize to 10240 and EphemeralStorage.Size to 10240 in your Lambda function configuration. At this ceiling AWS allocates approximately 5.79 vCPUs — enabling parallel tile decoding, vectorized NumPy band math, and concurrent GDAL reprojection threads. Pair maximum memory with windowed I/O and explicit GDAL cache limits to prevent out-of-memory crashes that occur even at 10 GB when full rasters are loaded naively.

Context

The core tension in serverless raster processing is that Memory and CPU Allocation for Raster Workloads on AWS Lambda couples vCPU count directly to RAM tier: 1,769 MB per full vCPU. Below the 3,008 MB mark your function operates on a fraction of a single core, making CPU-bound operations like reprojection and band algebra painfully slow regardless of dataset size. At 10,240 MB you reach the current hard ceiling — roughly 6 vCPUs and network/disk I/O bandwidth that scales proportionally.

The failure mode that drives engineers to this configuration is predictable: a multi-band Sentinel-2 mosaic or a DEM hydrological conditioning job runs fine on a 3 GB memory tier for small tiles but OOMs or times out on production-scale inputs. Allocating 10 GB removes the compute bottleneck while Ephemeral Storage Limits in AWS Lambda — kept at the default 512 MB — silently starves GDAL’s disk cache and intermediate VRT staging. Both parameters must be raised together.

The diagram below shows how memory tier maps to vCPU allocation and where the 10 GB configuration sits relative to common raster workload sizes:

Lambda Memory to vCPU Scaling for Raster Workloads Bar chart showing vCPU allocation at 1769 MB (1 vCPU), 3538 MB (2 vCPU), 5308 MB (3 vCPU), 7077 MB (4 vCPU), 8846 MB (5 vCPU), and 10240 MB (5.79 vCPU, the hard ceiling). Annotations mark where single-band GeoTIFF, multi-band Sentinel-2, and DEM hydrological conditioning workloads typically require. vCPUs allocated 1 2 3 4 5 1769 MB 1 vCPU 3538 MB 2 vCPU 5308 MB 3 vCPU 7077 MB 4 vCPU 8846 MB 5 vCPU 10240 MB ≈5.8 vCPU Hard ceiling Sentinel-2 mosaic

Prerequisites

Before raising the memory ceiling, confirm the following are in place:

  • Runtime: Python 3.11 or 3.12. Earlier runtimes lack the improved gc behaviour needed for reliable C-extension deallocation.
  • IAM permission: lambda:UpdateFunctionConfiguration on the target function ARN. Without this the AWS CLI and Terraform will silently fall back to current settings.
  • Environment variables set explicitly: GDAL_DATA, PROJ_LIB, and LD_LIBRARY_PATH must point to your Lambda layer paths. Never rely on defaults inside the execution environment.
  • Dependency versions: rasterio>=1.3, numpy>=1.24, GDAL>=3.6. Earlier versions have known memory-leak paths in windowed write operations.
  • Service quota: Verify your account’s Lambda function-and-layer-storage and per-function memory quota via the Service Quotas console. New accounts are sometimes capped below 10 GB until a quota increase is approved.
  • EphemeralStorage configured alongside MemorySize: As explained under Ephemeral Storage Limits in AWS Lambda, the /tmp partition does not expand automatically when you raise RAM — it must be provisioned separately.

Implementation

The following snippets show the three most common configuration paths. After configuration, the Python handler demonstrates the windowed-I/O pattern that stabilises peak memory at 10 GB without OOM.

AWS CLI

bash
aws lambda update-function-configuration \
  --function-name raster-processor \
  --memory-size 10240 \
  --timeout 900 \
  --ephemeral-storage '{"Size": 10240}' \
  --environment 'Variables={
    GDAL_CACHEMAX=2048,
    GDAL_NUM_THREADS=4,
    GDAL_DATA=/opt/share/gdal,
    PROJ_LIB=/opt/share/proj,
    LD_LIBRARY_PATH=/opt/lib,
    GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR
  }'

Terraform

hcl
resource "aws_lambda_function" "raster_processor" {
  function_name = "raster-processor"
  runtime       = "python3.11"
  handler       = "handler.process_tile"
  memory_size   = 10240
  timeout       = 900

  ephemeral_storage {
    size = 10240
  }

  environment {
    variables = {
      # Hard limits: GDAL cache capped at 2 GB to leave headroom for numpy buffers
      GDAL_CACHEMAX                  = "2048"
      GDAL_NUM_THREADS               = "4"
      # Explicit paths — never rely on container defaults
      GDAL_DATA                      = "/opt/share/gdal"
      PROJ_LIB                       = "/opt/share/proj"
      LD_LIBRARY_PATH                = "/opt/lib"
      GDAL_DISABLE_READDIR_ON_OPEN   = "EMPTY_DIR"
    }
  }
}

AWS SAM

yaml
Resources:
  RasterProcessor:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.11
      Handler: handler.process_tile
      MemorySize: 10240
      Timeout: 900
      EphemeralStorage:
        Size: 10240
      Environment:
        Variables:
          GDAL_CACHEMAX: "2048"
          GDAL_NUM_THREADS: "4"
          GDAL_DATA: "/opt/share/gdal"
          PROJ_LIB: "/opt/share/proj"
          LD_LIBRARY_PATH: "/opt/lib"
          GDAL_DISABLE_READDIR_ON_OPEN: "EMPTY_DIR"

Production handler with windowed I/O

python
import os
import gc
import uuid
import logging
import rasterio
from rasterio.windows import Window
import numpy as np

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)


def _check_tmp_headroom(required_mb: int = 2048) -> None:
    """Abort early if /tmp does not have enough free space."""
    stat = os.statvfs("/tmp")
    free_mb = (stat.f_frsize * stat.f_bavail) / (1024 * 1024)
    if free_mb < required_mb:
        raise RuntimeError(
            f"/tmp headroom too low: {free_mb:.0f} MB free, {required_mb} MB required"
        )


def process_tile(event: dict, context) -> dict:
    """
    Process a raster tile from S3 using windowed reads.
    Requires MemorySize=10240, EphemeralStorage=10240, and explicit GDAL env vars.
    """
    # Convert s3:// URI to the GDAL virtual filesystem path so rasterio
    # streams byte ranges directly without a full download to /tmp.
    src_path = event["s3_uri"].replace("s3://", "/vsis3/")

    # Isolate each invocation under a unique subdirectory so warm-start
    # container reuse does not corrupt previous outputs.
    invocation_dir = f"/tmp/{uuid.uuid4().hex}"
    os.makedirs(invocation_dir, exist_ok=True)
    dst_path = os.path.join(invocation_dir, os.path.basename(src_path))

    # Guard against /tmp exhaustion before writing any data.
    _check_tmp_headroom(required_mb=2048)

    window_size = 1024  # pixels per tile dimension; align with COG block size

    with rasterio.open(src_path) as src:
        profile = src.profile.copy()
        profile.update(
            dtype=rasterio.uint16,
            count=1,
            compress="deflate",
            tiled=True,
            blockxsize=512,
            blockysize=512,
            # Ensure GDAL writes the COG-compatible layout
            interleave="band",
        )

        with rasterio.open(dst_path, "w", **profile) as dst:
            for row_off in range(0, src.height, window_size):
                for col_off in range(0, src.width, window_size):
                    width  = min(window_size, src.width  - col_off)
                    height = min(window_size, src.height - row_off)
                    window = Window(col_off, row_off, width, height)

                    # Read only the current 1024×1024 window — not the full raster.
                    # At 10240 MB, ~6 vCPUs decode tiles in parallel across bands.
                    data = src.read(window=window)

                    # Band normalisation: scale uint16 to 0-100 integer range
                    processed = np.clip(
                        (data.astype(np.float32) / 65535.0 * 100),
                        0, 100
                    ).astype(np.uint16)

                    dst.write(processed, window=window)

                    # Explicit deallocation releases C-extension buffers before
                    # gc runs automatically — critical for back-to-back windows.
                    del data, processed
                    gc.collect()

    logger.info("Processed %s → %s", src_path, dst_path)
    return {"statusCode": 200, "output_path": dst_path}

Verification

Run the following after deployment to confirm the memory tier is active and peak usage is within bounds:

bash
# Invoke the function with a representative test event and capture the log output
aws lambda invoke \
  --function-name raster-processor \
  --cli-binary-format raw-in-base64-out \
  --payload '{"s3_uri":"s3://your-bucket/test-tile.tif"}' \
  --log-type Tail \
  response.json \
  --query 'LogResult' --output text | base64 --decode

Expected output excerpt:

code
REPORT RequestId: … Duration: 4321.00 ms  Billed Duration: 4400 ms
Memory Size: 10240 MB  Max Memory Used: 3847 MB  Init Duration: 2100.00 ms

Max Memory Used should stay below 8,000 MB under normal raster workloads. If it regularly exceeds 9,000 MB, reduce the window_size from 1024 to 512 pixels per dimension and verify GDAL_CACHEMAX is honoured:

python
import os
from osgeo import gdal

# Confirm GDAL sees the cache cap at runtime
cache_mb = gdal.GetCacheMax() / (1024 * 1024)
print(f"GDAL cache cap: {cache_mb:.0f} MB")  # Expected: 2048 MB
assert cache_mb == 2048, f"GDAL_CACHEMAX not applied — got {cache_mb} MB"

To query CloudWatch directly:

bash
aws cloudwatch get-metric-statistics \
  --namespace AWS/Lambda \
  --metric-name MaxMemoryUsed \
  --dimensions Name=FunctionName,Value=raster-processor \
  --start-time "$(date -u -d '-1 hour' '+%Y-%m-%dT%H:%M:%SZ')" \
  --end-time "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
  --period 300 \
  --statistics Maximum \
  --output table

Gotchas and Edge Cases

  • GDAL_NUM_THREADS=ALL_CPUS causes thread contention. Lambda’s execution environment shares host-level CPU resources. Pinning to an explicit integer (4 for 10 GB, 2 for tiers below 7 GB) prevents the thread pool from spawning more workers than vCPUs actually available to your container slice.

  • Warm-start /tmp accumulation. Lambda reuses containers across invocations. If your handler writes to a fixed /tmp/output.tif path, a second invocation on the same container can inherit stale data. Use the uuid.uuid4().hex subdirectory pattern shown above and delete the directory at the end of the handler.

  • InitDuration spikes at 10 GB. Cold-start overhead increases slightly at higher memory tiers because AWS provisions a larger execution environment. The Cold Start Mapping for Python GDAL sequence shows that GDAL shared-library resolution accounts for 60–80% of InitDuration. Use Lambda Layers with pre-built GDAL binaries and enable Provisioned Concurrency for latency-sensitive pipelines.

  • Service quota is per-region. A quota increase in us-east-1 does not propagate to eu-west-1. If you deploy to multiple regions, raise the quota in each independently via the AWS Service Quotas console before applying Terraform.

Frequently Asked Questions

Does raising MemorySize increase my per-invocation cost? Yes. Lambda billing is Duration × MemorySize in GB-seconds. At 10 GB, a 5-second invocation costs 50 GB-seconds versus 25 GB-seconds at 5 GB. The cost-per-tile crossover depends on how much the extra vCPUs reduce duration. For CPU-bound reprojection jobs, measured speedup typically reaches 2–3× at the 10 GB tier, making the net cost flat or slightly lower despite the higher per-ms rate. For I/O-bound workloads (streaming COGs from S3), memory above 5–6 GB rarely improves duration enough to justify the cost.

Can I use multiprocessing instead of windowed reads to saturate the 6 vCPUs? Avoid multiprocessing.Pool in Lambda. Forking inside the execution environment is unreliable — child processes inherit file descriptors, GDAL drivers, and PROJ context that were initialised for the parent, leading to silent corruption. Use concurrent.futures.ThreadPoolExecutor instead; rasterio releases the GIL during file I/O, allowing multiple threads to download and decode windows concurrently across the available vCPUs.


Back to Serverless Geospatial Architecture & Platform Limits