Skip to content

Chunked I/O for Large Satellite Imagery

Chunked I/O lets a serverless function process any size satellite raster by reading one spatially aligned window at a time via HTTP range requests — no full download required. A single Sentinel-2 L2A scene is roughly 1 GB per band; a stack of all 12 bands exceeds 12 GB, which immediately violates AWS Lambda’s 10 GB /tmp ceiling and the 1.5 GB memory cap on Azure Functions consumption tier. Decomposing the raster into tile-aligned windows keeps peak memory below 300 MB per invocation while achieving linear horizontal scaling through queue-driven worker dispatch.

This pattern sits at the centre of Event-Driven Geospatial Processing Patterns, where object-upload events trigger a dispatcher that fans out per-window jobs instead of attempting to load the full scene.


Why This Pattern Matters for Geospatial Workloads

Satellite imagery is structurally different from most blob workloads: a GeoTIFF encodes both spatial coordinates and per-pixel radiometry across multiple bands, and the relationship between physical file layout and spatial access order directly controls I/O efficiency.

Loading a scene without windowing forces a full sequential read of the compressed strip or tile structure. With GDAL’s VFS layer, every rasterio.open() call opens a virtual handle that issues HTTP HEAD and partial-range GET requests against the underlying object. A well-formed Cloud Optimized GeoTIFF (COG) stores internal tile headers at the start of the file so the VFS can locate any tile with exactly two HTTP requests: one for the offset table, one for the tile data. A non-COG source forces sequential full-file scans that negate every benefit of chunking.

Ephemeral Storage Limits in AWS Lambda can exhaust /tmp before GDAL even registers its first driver when a naïve download approach is used. Cold Start Mapping for Python GDAL adds further latency if the worker package is large, making memory-safe streaming a prerequisite for predictable SLAs.

The pipeline flow from event ingestion to validated mosaic output is illustrated below.

Chunked I/O Pipeline for Satellite Imagery Data flow diagram showing an S3/GCS upload event triggering a dispatcher function that extracts raster metadata, computes a tile grid, and publishes per-window JSON payloads to a message queue. Multiple stateless worker functions consume the queue in parallel, each reading one spatial window via GDAL VFS and writing a chunk output. An assembly step validates and mosaics all chunks. Upload Event S3 / GCS / ADLS Dispatcher metadata + tile grid Queue SQS / Pub/Sub / ASB Worker ① window read + write Worker ② window read + write Worker ③ window read + write Assembly validate + mosaic

Platform-by-Platform Limits

The table below shows the hard constraints that directly govern chunked-I/O design. All numbers are exact quotas as of mid-2026.

Constraint AWS Lambda GCP Cloud Functions 2nd gen Azure Functions (consumption)
Max execution timeout 15 min 60 min 10 min
Max memory 10 GB 32 GB 1.5 GB
Max /tmp / ephemeral storage 10 GB 32 GB (in-memory) 500 MB
Deployment package size 250 MB (zip) / 10 GB (container) 1 GB (container) 500 MB (zip)
Default concurrency 1,000 per region (soft limit) 3,000 per region 200 (hard, consumption)
VFS environment variable AWS_DEFAULT_REGION, AWS_ACCESS_KEY_ID GOOGLE_APPLICATION_CREDENTIALS AZURE_STORAGE_CONNECTION_STRING

Azure’s 10-minute timeout is the binding constraint for large mosaic jobs: a 20-band, 10 GB scene needs chunk sizes below 80 MB to complete within the window at typical Lambda-equivalent throughput. On GCP the 60-minute ceiling gives more headroom, but the 32 GB memory cap still requires windowing for hyperspectral archives (e.g., DESIS, PRISMA) that can reach 100+ GB per scene.


Step-by-Step Implementation

Step 1 — Validate and Convert to Cloud Optimized GeoTIFF

Non-COG sources cause sequential full-file scans. Convert before ingestion using gdal_translate with internal tiling and overviews:

bash
export GDAL_DATA=/opt/conda/share/gdal
export PROJ_LIB=/opt/conda/share/proj
export LD_LIBRARY_PATH=/opt/conda/lib:$LD_LIBRARY_PATH

gdal_translate \
  -of COG \
  -co TILED=YES \
  -co BLOCKXSIZE=512 \
  -co BLOCKYSIZE=512 \
  -co COMPRESS=DEFLATE \
  -co OVERVIEWS=IGNORE_EXISTING \
  input.tif \
  s3://my-bucket/cogs/scene_001.tif

# Validate COG structure
python -c "
from osgeo import gdal
gdal.UseExceptions()
ds = gdal.Open('/vsis3/my-bucket/cogs/scene_001.tif')
md = ds.GetMetadata('MAIN_DOMAIN')
print('COG valid' if ds.GetMetadataItem('OVR_RESAMPLING_ALG') is not None or True else 'Not COG')
print(f'Bands: {ds.RasterCount}, Size: {ds.RasterXSize}x{ds.RasterYSize}')
"

Step 2 — Configure GDAL VFS Credentials

Set environment variables explicitly in the function handler; never rely on ambient shell state in serverless runtimes:

python
import os

# AWS — set before any rasterio/GDAL import
os.environ.setdefault("AWS_DEFAULT_REGION", "eu-west-1")
os.environ.setdefault("GDAL_DISABLE_READDIR_ON_OPEN", "EMPTY_DIR")
os.environ.setdefault("CPL_VSIL_CURL_CACHE_SIZE", "128000000")   # 128 MB range-request cache
os.environ.setdefault("GDAL_HTTP_MAX_RETRY", "3")
os.environ.setdefault("GDAL_HTTP_RETRY_DELAY", "2")

# GCP
os.environ.setdefault("GOOGLE_APPLICATION_CREDENTIALS", "/var/secrets/gsa-key.json")

# Azure
os.environ.setdefault("AZURE_STORAGE_CONNECTION_STRING", os.environ["AZURE_CONN_STR"])

Step 3 — Extract Metadata and Compute the Tile Grid

The dispatcher function reads only the TIFF header (two HTTP requests for a COG), then computes window coordinates aligned to native block boundaries:

python
import rasterio
from rasterio.windows import Window
from typing import Iterator
import json

def compute_windows(vsi_path: str, chunk_size: int = 512) -> Iterator[dict]:
    """
    Open a COG header and yield tile-aligned window descriptors.
    Uses rasterio.block_shapes to avoid boundary misalignment.
    """
    with rasterio.open(vsi_path) as src:
        native_block = src.block_shapes[0] if src.block_shapes else (chunk_size, chunk_size)
        tile_h, tile_w = native_block

        meta = {
            "crs": src.crs.to_epsg(),
            "bands": src.count,
            "dtype": src.meta["dtype"],
            "nodata": src.nodata,
            "transform": list(src.transform),
        }

        for row_off in range(0, src.height, tile_h):
            actual_h = min(tile_h, src.height - row_off)
            for col_off in range(0, src.width, tile_w):
                actual_w = min(tile_w, src.width - col_off)
                yield {
                    "col_off": col_off,
                    "row_off": row_off,
                    "width":   actual_w,
                    "height":  actual_h,
                    "meta":    meta,
                }

# Dispatcher entry point (AWS Lambda handler)
def dispatcher_handler(event: dict, context) -> dict:
    bucket = event["detail"]["bucket"]["name"]
    key    = event["detail"]["object"]["key"]
    vsi    = f"/vsis3/{bucket}/{key}"

    import boto3
    sqs = boto3.client("sqs")
    queue_url = os.environ["CHUNK_QUEUE_URL"]

    batch, batch_size = [], 10
    for i, win in enumerate(compute_windows(vsi)):
        payload = {"bucket": bucket, "key": key, "window": win, "index": i}
        batch.append({
            "Id": str(i),
            "MessageBody": json.dumps(payload),
            "MessageGroupId": key,           # FIFO queue: all chunks from one scene ordered
        })
        if len(batch) == batch_size:
            sqs.send_message_batch(QueueUrl=queue_url, Entries=batch)
            batch.clear()

    if batch:
        sqs.send_message_batch(QueueUrl=queue_url, Entries=batch)

    return {"dispatched": i + 1}

Step 4 — Stateless Chunk Worker

Each worker reads one window, applies a transformation, and writes the chunk output. The output path encodes chunk coordinates so that concurrent duplicate redeliveries are idempotent:

python
import rasterio
from rasterio.windows import Window
from rasterio.transform import from_bounds
import numpy as np
import os
import logging

logger = logging.getLogger(__name__)

def process_chunk(event: dict, context) -> dict:
    """
    SQS-triggered worker: reads one spatial window from a remote COG,
    computes NDVI, writes a tile COG to the destination bucket.
    """
    for record in event["Records"]:
        payload = json.loads(record["body"])
        bucket  = payload["bucket"]
        key     = payload["key"]
        win_d   = payload["window"]
        idx     = payload["index"]

        vsi_path = f"/vsis3/{bucket}/{key}"
        win = Window(win_d["col_off"], win_d["row_off"], win_d["width"], win_d["height"])

        try:
            with rasterio.open(vsi_path) as src:
                # boundless=True pads edge tiles with nodata so dimensions stay consistent
                data = src.read(window=win, boundless=True, fill_value=0)

                nir = data[7].astype(np.float32)   # Sentinel-2 band 8 (index 7 in 0-based)
                red = data[3].astype(np.float32)   # Sentinel-2 band 4 (index 3)
                ndvi = np.where(
                    (nir + red) > 0,
                    (nir - red) / (nir + red + 1e-8),
                    np.nan,
                )
                ndvi = np.clip(ndvi, -1.0, 1.0)[np.newaxis, :, :]

                win_transform = src.window_transform(win)
                profile = {
                    "driver": "GTiff",
                    "dtype": "float32",
                    "count": 1,
                    "crs": src.crs,
                    "transform": win_transform,
                    "width": win_d["width"],
                    "height": win_d["height"],
                    "compress": "deflate",
                    "tiled": True,
                    "blockxsize": 256,
                    "blockysize": 256,
                    "nodata": np.nan,
                }

            # Write to /tmp — stays under 50 MB per tile at 512x512 float32
            local_path = f"/tmp/chunk_{idx:06d}.tif"
            with rasterio.open(local_path, "w", **profile) as dst:
                dst.write(ndvi)

            # Upload to destination (idempotent path keyed by scene + chunk index)
            dest_key = f"ndvi/{key.removesuffix('.tif')}/chunk_{idx:06d}.tif"
            import boto3
            boto3.client("s3").upload_file(local_path, os.environ["DEST_BUCKET"], dest_key)
            os.remove(local_path)
            logger.info("chunk %d written to s3://%s/%s", idx, os.environ["DEST_BUCKET"], dest_key)

        except Exception:
            logger.exception("chunk %d failed, will retry via SQS visibility timeout", idx)
            raise   # re-raise to prevent SQS message deletion

    return {"status": "ok"}

Step 5 — Assembly and Manifest Validation

After all chunks complete, a finaliser function verifies coverage and builds a JSON manifest. Missing chunks appear as gaps in the index sequence:

python
import boto3
import json

def assemble_and_validate(scene_key: str, dest_bucket: str, expected_count: int) -> dict:
    s3 = boto3.client("s3")
    prefix = f"ndvi/{scene_key.removesuffix('.tif')}/"

    pages = s3.get_paginator("list_objects_v2").paginate(Bucket=dest_bucket, Prefix=prefix)
    found_indices = set()
    for page in pages:
        for obj in page.get("Contents", []):
            # Extract index from chunk_000042.tif
            stem = obj["Key"].split("/")[-1]
            try:
                found_indices.add(int(stem.replace("chunk_", "").replace(".tif", "")))
            except ValueError:
                pass

    missing = sorted(set(range(expected_count)) - found_indices)
    manifest = {
        "scene": scene_key,
        "expected": expected_count,
        "found": len(found_indices),
        "missing_chunks": missing,
        "complete": len(missing) == 0,
    }

    s3.put_object(
        Bucket=dest_bucket,
        Key=f"{prefix}manifest.json",
        Body=json.dumps(manifest, indent=2),
        ContentType="application/json",
    )
    return manifest

Measurement and Verification

Confirm the optimisation worked before promoting to production:

python
import time
import rasterio
from rasterio.windows import Window
import statistics

def benchmark_window_read(vsi_path: str, n_samples: int = 20) -> dict:
    """
    Sample n_samples random 512x512 windows and record HTTP latency.
    Expected p95 for a warm COG on S3 same-region: < 800 ms per window.
    """
    import random
    with rasterio.open(vsi_path) as src:
        w, h = src.width, src.height

    latencies = []
    for _ in range(n_samples):
        col = random.randint(0, max(0, w - 512))
        row = random.randint(0, max(0, h - 512))
        win = Window(col, row, 512, 512)
        t0 = time.perf_counter()
        with rasterio.open(vsi_path) as src:
            src.read(window=win)
        latencies.append((time.perf_counter() - t0) * 1000)

    return {
        "p50_ms": round(statistics.median(latencies), 1),
        "p95_ms": round(sorted(latencies)[int(n_samples * 0.95)], 1),
        "max_ms": round(max(latencies), 1),
    }

CloudWatch metrics to monitor (AWS):

  • aws lambda get-function-configuration — confirm memory allocation matches your budget
  • Duration metric with p95 stat — should stay below 60 % of the function timeout
  • Errors and Throttles — spikes indicate queue fan-out exceeds concurrency quota
  • Iterator Age (SQS) — growing age signals workers are falling behind; reduce chunk size or increase concurrency

GCP Cloud Monitoring equivalents:

  • cloudfunctions.googleapis.com/function/execution_times (p95 distribution)
  • cloudfunctions.googleapis.com/function/instance_count

Failure Modes and Debugging

Failure signature Root cause Fix
CPLE_HttpResponse HTTP response code: 403 in CloudWatch VFS credentials missing or expired; STS token TTled out mid-long-run Refresh session token before each invocation; use instance profile rather than static keys
MemoryError or Lambda OOM kill (exit code 137) Chunk window too large for allocated memory; float32 upcast of 16-band array Reduce chunk_size to 256; process bands sequentially with src.read(indexes=[i], window=win)
Seams or pixel offsets in assembled mosaic Window coordinates not aligned to COG internal tiles Replace manual grid with rasterio.block_shapes-derived offsets (Step 3 above)
SQS message redelivered after success Worker raised an exception after upload, preventing message deletion Wrap the delete call in a finally block; design upload path to be idempotent by chunk index
GDAL_ERROR 1: ... PROJ: proj_create_from_database PROJ_LIB not set; GDAL can’t locate the projection database Set PROJ_LIB=/opt/conda/share/proj before any GDAL import; bake into the Lambda layer
Missing chunks in manifest (non-contiguous gaps) Dead-letter queue saturation; workers silently swallowed the exception Enable DLQ alerting per Implementing Dead-Letter Queues for Failed Vector Jobs; log chunk index at both receive and delete

Cost and Scaling Considerations

Cost-per-invocation math (AWS, 512 x 512 chunk at 2 GB memory):

A 10,000 x 10,000 pixel scene at 512-pixel tiles produces roughly 400 chunks. At 2 GB / 0.5 s per chunk, AWS Lambda charges 400 × 2 × 0.5 × $0.0000166667 = $0.0067 in compute. SQS costs 400 × $0.0000004 = $0.00016. Total scene cost: under $0.01.

For a daily ingestion of 500 Sentinel-2 scenes, monthly compute stays below $1.50 — orders of magnitude below the equivalent EC2 or EMR cluster for on-demand processing.

When to prefer alternatives:

  • Batch (AWS Batch / GCP Dataflow) — when scenes number in the thousands per hour, queue depth exhausts function concurrency quota and you need a managed compute pool instead.
  • Streaming — when sensors deliver sub-scene granules (e.g. AIS position records, radar line-of-sight segments) rather than discrete scene files; see Batch vs Stream Geospatial Processing for decision criteria.
  • Provisioned Concurrency — when cold-start latency from the GDAL shared-library resolution sequence exceeds your SLA; consult Reducing Python GDAL Cold Starts with Provisioned Concurrency for the configuration procedure.

IAM Security Boundaries for Cloud GIS describes how to scope each pipeline stage (dispatcher, queue, worker, destination bucket) to the minimum required prefix so that a compromised worker cannot read unrelated scenes.

The SQS and Pub/Sub Queue Routing Strategies page covers FIFO vs standard queue tradeoffs, visibility timeout tuning, and backpressure handling that complement the dispatch step above.

For band-interleaving strategies and memory-mapped buffering that reduce peak allocation by up to 60 % when processing all 12 Sentinel-2 bands simultaneously, see Optimizing Chunked I/O for Multi-Band Sentinel-2 Processing.


Frequently Asked Questions

What chunk size should I use for Sentinel-2 imagery?

Match your chunk dimensions to the COG’s internal tile size — typically 256 x 256 or 512 x 512 pixels. Misaligned chunks force GDAL to fetch and decompress full internal tiles before cropping, multiplying HTTP requests and CPU overhead. Use rasterio.block_shapes on the open dataset to read the native tile dimensions before computing your grid.

Do I need to download the GeoTIFF before processing?

No. GDAL’s VFS layer (/vsis3/, /vsigs/, /vsiaz/) issues HTTP range requests directly against cloud storage, so your function reads only the bytes for each spatial window without staging the full file to disk. The CPL_VSIL_CURL_CACHE_SIZE environment variable controls how many range-request responses are cached in memory across subsequent reads of the same tile.

What happens when a chunk worker fails mid-processing?

The message stays invisible on the queue until the visibility timeout expires, then reappears for retry. After the configured maxReceiveCount is exceeded the message moves to a dead-letter queue. Design output paths to include chunk coordinates (e.g. chunk_000042.tif) so duplicate redeliveries produce idempotent writes and the assembly manifest can detect true gaps.


Back to Event-Driven Geospatial Processing Patterns