Optimizing Chunked I/O for Multi-Band Sentinel-2 Processing
Set CHUNK_SIZE = 512 to match Sentinel-2 COG internal block geometry, read each spectral band with a windowed src.read(band, window=win) call, and upload results via io.BytesIO — no /tmp staging required. A single Lambda invocation processing a 512×512 window of four 10m bands (B02, B03, B04, B08) at float32 consumes roughly 4 MB of working memory, well inside the 128 MB minimum allocation and leaving headroom for GDAL internal caches.
Why This Technique Is Needed
Sentinel-2 L2A granules are delivered as 10980×10980 pixel arrays per band. Naively calling src.read() without a window loads the entire array into RAM: at float32, a single 10m band occupies 460 MB. Four bands for an NDVI computation exceed 1.8 GB — beyond the default Lambda memory allocation and approaching the limit on GCP Cloud Functions 1st generation. Naive multi-band loads also force the GDAL driver to decompress every internal COG tile in the scene, multiplying CPU overhead proportionally.
The Chunked I/O for Large Satellite Imagery pattern resolves this by treating the raster as a coordinate-indexed matrix and dispatching per-tile tasks via a message queue. This page covers the Sentinel-2-specific details: cross-resolution band alignment, correct block_shapes alignment, and zero-disk-staging output pipelines that prevent ephemeral storage exhaustion in AWS Lambda.
When the processing backlog grows, routing tile payloads through SQS and Pub/Sub queue routing strategies with dead-letter isolation ensures deterministic retry without losing tile coordinates on transient failures.
Chunk-to-COG alignment: the core principle
The diagram below shows how a 512×512 chunk window maps to the internal 512×512 block structure of a Sentinel-2 COG. An aligned read fetches exactly one HTTP range request per block; a misaligned read crosses boundaries and forces two fetches plus partial decompression of adjacent blocks.
Prerequisites
Before implementing this pattern, confirm the following are in place:
- Python 3.11+ with
rasterio>=1.3.9,numpy>=1.26,boto3>=1.34(orgoogle-cloud-storage>=2.14for GCP) - GDAL 3.7+ compiled with
ENABLE_IPV6=OFFandCURL_ENABLED=YES— required for reliable/vsis3/range requests inside Lambda VPCs - Environment variables set explicitly in your function configuration — never rely on ambient shell state:
GDAL_DATA=/opt/gdal/share/gdal PROJ_LIB=/opt/share/proj GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES GDAL_HTTP_MULTIPLEX=YES CPL_VSIL_CURL_CACHE_SIZE=200000000 AWS_DEFAULT_REGION=eu-central-1 - IAM permissions scoped to the exact S3 prefixes involved:
s3:GetObjecton the source Sentinel-2 bucket prefix,s3:PutObjecton the destination prefix. Broads3:*grants violate the least-privilege model described in IAM Security Boundaries for Cloud GIS - Source data in COG format: verify with
gdal_info -json <path> | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['metadata'].get('LAYOUT','not-COG'))"— value should beCOG - Lambda memory allocation: 1024 MB minimum for 512×512 windows across four 10m bands; 2048 MB for NDWI/SAVI indices that create additional float32 intermediates
Implementation
The function below handles the full Sentinel-2 multi-band use case: it queries native block geometry, aligns windows, reads cross-resolution bands using out_shape resampling, computes NDVI, and streams the output GeoTIFF directly to S3 via io.BytesIO — no /tmp write, no intermediate disk staging.
import io
import os
import logging
import numpy as np
import rasterio
from rasterio.windows import Window
from rasterio.enums import Resampling
import boto3
# ---------------------------------------------------------------------------
# Explicit environment — do not rely on shell inheritance in Lambda
# ---------------------------------------------------------------------------
os.environ.setdefault("GDAL_DATA", "/opt/gdal/share/gdal")
os.environ.setdefault("PROJ_LIB", "/opt/share/proj")
os.environ.setdefault("GDAL_HTTP_MERGE_CONSECUTIVE_RANGES", "YES")
os.environ.setdefault("GDAL_HTTP_MULTIPLEX", "YES")
# 200 MB in-process COG tile cache — keeps decompressed blocks for the window
os.environ.setdefault("CPL_VSIL_CURL_CACHE_SIZE", "200000000")
# Sentinel-2 10m band indices in a standard COG (B02=1, B03=2, B04=3, B08=4)
BAND_BLUE = 1 # B02 10m
BAND_GREEN = 2 # B03 10m
BAND_RED = 3 # B04 10m
BAND_NIR = 4 # B08 10m
CHUNK_SIZE = 512 # Must match COG internal block size — confirmed via block_shapes
logger = logging.getLogger(__name__)
s3 = boto3.client("s3")
def get_chunk_size(bucket: str, key: str) -> int:
"""
Query the COG's native block size and return the safe CHUNK_SIZE.
Falls back to 512 if block_shapes is not available (non-tiled GeoTIFF).
Call this once in the dispatcher, not per worker invocation.
"""
url = f"/vsis3/{bucket}/{key}"
with rasterio.open(url) as src:
shapes = src.block_shapes # list of (rows, cols) per band
if shapes:
row_block, col_block = shapes[0]
logger.info("Native COG block size: %d×%d", row_block, col_block)
return col_block # typically 256 or 512 for Sentinel-2 COGs
return 512
def read_band_aligned(
src: rasterio.DatasetReader,
band_index: int,
window: Window,
target_size: int,
use_bilinear: bool = True,
) -> np.ndarray:
"""
Read a single band into a (target_size, target_size) array.
Works for 10m bands (native) and 20m/60m bands (resampled in-memory).
Only the compressed blocks intersecting `window` are fetched.
"""
resampling = Resampling.bilinear if use_bilinear else Resampling.nearest
return src.read(
band_index,
window=window,
out_shape=(target_size, target_size),
resampling=resampling,
masked=False,
).astype(np.float32)
def process_sentinel2_chunk(
bucket: str,
key_10m: str, # COG containing 10m bands (B02, B03, B04, B08)
tile_x: int,
tile_y: int,
output_prefix: str = "processed",
) -> str:
"""
Read a single CHUNK_SIZE×CHUNK_SIZE tile from a Sentinel-2 10m COG,
compute NDVI, and upload the result to S3 without writing to /tmp.
Returns the S3 key of the written output tile.
"""
col_off = tile_x * CHUNK_SIZE
row_off = tile_y * CHUNK_SIZE
window = Window(col_off, row_off, CHUNK_SIZE, CHUNK_SIZE)
output_key = (
f"{output_prefix}/"
f"{key_10m.rsplit('/', 1)[-1].replace('.tif', '')}"
f"_x{tile_x:04d}_y{tile_y:04d}_ndvi.tif"
)
url = f"/vsis3/{bucket}/{key_10m}"
with rasterio.open(url) as src:
# ------------------------------------------------------------------
# Guard: skip tiles that fall entirely outside the scene footprint.
# Sentinel-2 granules are not rectangular — edge tiles are nodata.
# ------------------------------------------------------------------
actual_w = min(CHUNK_SIZE, src.width - col_off)
actual_h = min(CHUNK_SIZE, src.height - row_off)
if actual_w <= 0 or actual_h <= 0:
logger.info("Tile (%d, %d) outside scene extent — skipping", tile_x, tile_y)
return ""
# Lazy, windowed reads — only bands needed for NDVI
red = read_band_aligned(src, BAND_RED, window, CHUNK_SIZE)
nir = read_band_aligned(src, BAND_NIR, window, CHUNK_SIZE)
# Safe NDVI: suppress divide-by-zero on nodata / water / cloud pixels
with np.errstate(divide="ignore", invalid="ignore"):
ndvi = np.where(
(red + nir) == 0,
np.float32(-9999), # nodata sentinel — not 0, which is a valid NDVI
(nir - red) / (nir + red),
)
# ------------------------------------------------------------------
# Stream output directly to S3 — no /tmp write.
# io.BytesIO stays in Lambda process memory; typical tile is ~1 MB.
# ------------------------------------------------------------------
out_transform = src.window_transform(window)
out_profile = {
"driver": "GTiff",
"dtype": "float32",
"width": CHUNK_SIZE,
"height": CHUNK_SIZE,
"count": 1,
"crs": src.crs,
"transform": out_transform,
"nodata": -9999.0,
"compress": "deflate",
"predictor": 3, # horizontal differencing for float data
"tiled": True,
"blockxsize": 256,
"blockysize": 256,
"interleave": "band",
}
with io.BytesIO() as buf:
with rasterio.open(buf, "w", **out_profile) as dst:
dst.write(ndvi, 1)
buf.seek(0)
s3.put_object(
Bucket=bucket,
Key=output_key,
Body=buf,
ContentType="image/tiff",
StorageClass="INTELLIGENT_TIERING",
)
logger.info("Written: s3://%s/%s", bucket, output_key)
return output_key
def lambda_handler(event: dict, context) -> dict:
"""
SQS-triggered entry point. Each message carries one tile coordinate.
Dead-letter routing is handled at the queue level after maxReceiveCount
retries — no manual DLQ logic required here.
"""
results = []
for record in event.get("Records", []):
import json
payload = json.loads(record["body"])
output_key = process_sentinel2_chunk(
bucket=payload["bucket"],
key_10m=payload["key"],
tile_x=payload["tile_x"],
tile_y=payload["tile_y"],
output_prefix=payload.get("output_prefix", "processed"),
)
results.append(output_key)
return {"statusCode": 200, "processed": results}
Verification
Run the following against a known Sentinel-2 COG tile to confirm block alignment, correct NDVI range, and the absence of /tmp writes:
import rasterio
import boto3
import json
BUCKET = "your-sentinel2-bucket"
KEY = "S2A_MSIL2A_20240910T100601_N0511_R122_T32UND_20240910T163845_10m.tif"
# 1. Confirm COG block geometry matches CHUNK_SIZE
with rasterio.open(f"/vsis3/{BUCKET}/{KEY}") as src:
shapes = src.block_shapes
print("block_shapes:", shapes) # expect [(512, 512), ...]
print("band_count:", src.count) # expect 4 for a standard 10m COG
print("dtype:", src.dtypes[0]) # expect uint16 before float32 cast
print("crs:", src.crs.to_epsg()) # expect 32632 for UTM zone 32N
# 2. Process tile (0, 0) and verify the output exists in S3
from optimizing_chunked_io import process_sentinel2_chunk
result_key = process_sentinel2_chunk(BUCKET, KEY, tile_x=0, tile_y=0)
print("Output key:", result_key)
s3 = boto3.client("s3")
head = s3.head_object(Bucket=BUCKET, Key=result_key)
print("Output size (bytes):", head["ContentLength"]) # typically 200 KB–1.5 MB
print("Content-Type:", head["ContentType"]) # image/tiff
# 3. Spot-check NDVI range
with rasterio.open(f"/vsis3/{BUCKET}/{result_key}") as out:
ndvi_tile = out.read(1)
valid = ndvi_tile[ndvi_tile != -9999.0]
print(f"NDVI min={valid.min():.3f} max={valid.max():.3f} mean={valid.mean():.3f}")
# Expected: min near -0.3 (bare soil / water), max near 0.85 (dense vegetation)
Expected output for a vegetated summer scene:
block_shapes: [(512, 512), (512, 512), (512, 512), (512, 512)]
band_count: 4
dtype: uint16
crs: 32632
Output key: processed/S2A_MSIL2A_..._x0000_y0000_ndvi.tif
Output size (bytes): 418720
Content-Type: image/tiff
NDVI min=-0.182 max=0.847 mean=0.421
Gotchas and Edge Cases
-
Non-square COG blocks in older Sentinel-2 archives. Pre-2021 L2A COGs distributed via the Copernicus Open Access Hub may use 256×256 internal tiles while newer ESA CDSE-sourced assets use 512×512. Always call
src.block_shapesin the dispatcher rather than hardcodingCHUNK_SIZE = 512in the Lambda environment variable. Mismatches cause 4× fetch overhead on every read. -
Band index differences between 10m and 20m COGs. ESA distributes separate COG files per resolution tier. The 10m file contains B02 (index 1), B03 (index 2), B04 (index 3), B08 (index 4). The 20m file contains B05, B06, B07, B8A, B11, B12 at indices 1–6. Passing a 20m band index to a 10m COG raises
rasterio.errors.WindowError: band out of range. Validatesrc.countagainst the expected band list before dispatching tasks. -
predictor=3requires float data. The DEFLATE predictor type 3 (horizontal differencing for floating-point) only applies tofloat32/float64output. Writing integer or uint16 data withpredictor=3corrupts the output silently on some GDAL versions. Usepredictor=2(integer horizontal differencing) for integer bands andpredictor=3exclusively for float32 NDVI/NDWI outputs. -
Lambda cold starts extend HTTP handshake time for the first VFS range request. The reducing Python GDAL cold starts with provisioned concurrency pattern applies here: enabling provisioned concurrency for the dispatcher function (not the per-tile workers) eliminates the GDAL driver registration delay on first invocation, keeping P99 latency predictable under burst load.
Frequently Asked Questions
What chunk size should I use for Sentinel-2 COG processing?
Use 512×512 for 10m bands and 256×256 for 20m/60m bands on AWS Lambda. Both match the internal tile size used by the Sentinel-2 L2A COG distribution on ESA CDSE, which eliminates partial-block HTTP fetches. Confirm the actual block shape with rasterio.open(path).block_shapes before hardcoding.
How do I align 20m and 60m Sentinel-2 bands with 10m bands in a serverless chunk?
Anchor your Window coordinates to the 10m pixel grid. When reading a 20m or 60m band, pass the same Window plus out_shape=(1, CHUNK_SIZE, CHUNK_SIZE) and resampling=Resampling.bilinear. rasterio fetches only the compressed blocks intersecting that window, upsamples in-memory, and discards the extra data — peak memory stays proportional to CHUNK_SIZE², not the full scene.
Why is writing to /tmp before uploading slower than streaming to S3?
AWS Lambda /tmp is backed by ephemeral SSD but adds a second sequential write–read cycle. Using io.BytesIO with rasterio.MemoryFile eliminates the disk round-trip: the GeoTIFF is serialised directly into RAM, then uploaded with a single put_object call. This halves wall-clock time for small tiles and avoids the storage exhaustion failure mode described in managing /tmp storage limits for GeoTIFF extraction when many tiles run in parallel.
Related
- Chunked I/O for Large Satellite Imagery — parent guide covering COG block geometry, dispatcher design, and tile grid calculation
- SQS and Pub/Sub Queue Routing Strategies — queue-based tile dispatch, backpressure tuning, and dead-letter configuration
- Implementing Dead-Letter Queues for Failed Vector Jobs — DLQ pattern that applies equally to failed raster tile tasks
- Ephemeral Storage Limits in AWS Lambda — why
/tmpstaging is dangerous for parallel tile workers - Reducing Python GDAL Cold Starts with Provisioned Concurrency — keep dispatcher latency predictable under burst tile loads