Streaming COGs Without Touching /tmp
Open the raster as /vsis3/bucket/key.tif and set GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR, CPL_VSIL_CURL_ALLOWED_EXTENSIONS=.tif,.tiff,.ovr, GDAL_CACHEMAX=512 and CPL_VSIL_CURL_CACHE_SIZE=67108864 — GDAL then satisfies every window from HTTP range requests held in process memory, and the function’s ephemeral disk stays at zero bytes used. A 4 GB Sentinel-2 scene read this way costs about 1.4 MB of transferred bytes for a 512 × 512 window, against a 4 GB download that no /tmp allocation on Azure Consumption could hold in the first place. The technique removes ephemeral storage from the sizing conversation entirely, which is the cheapest way to stop worrying about it.
The Failure This Removes
Ephemeral Storage Comparison Across Serverless Platforms sets out the ceilings you are working against: AWS Lambda gives 512 MB of /tmp by default and 10,240 MB if you pay for it, GCP Cloud Functions 2nd gen gives roughly 8 GB of tmpfs that is deducted from the same 32,768 MB the process is running in, and Azure Functions on the Consumption plan gives roughly 1.5 GB of local storage shared across every function in the app. Only one of those numbers is generous, and it is generous because it is really memory.
The classic download-then-open handler collides with all three. It calls s3.download_file() into /tmp/scene.tif, opens the local copy, reads one window, and returns. Three things go wrong. The obvious one is that a 4 GB scene does not fit anywhere except a maximum-size Lambda. The second is that the download is serial and uncached — the function pays full transfer time before the first pixel is decoded, which on a cold container stacks directly on top of the initialisation cost mapped in Cold Start Mapping for Python GDAL. The third is the quiet one: warm containers are reused, so a handler that writes /tmp/scene.tif and does not delete it will fill the disk after four or five invocations and start failing with OSError: [Errno 28] No space left on device on a container that worked fine ten minutes earlier.
Streaming deletes the whole class of problem. A Cloud Optimized GeoTIFF places its IFD headers and tile offset tables at the front of the file, so GDAL can learn the full layout in two or three range requests and then fetch exactly the tiles that intersect the requested window. Nothing is materialised, so nothing needs cleaning up, and the same code runs unchanged on a 512 MB Lambda and a 1,536 MB Azure Consumption function.
/tmp stays empty.Prerequisites
- Runtime: Python 3.11 or 3.12, with
rasterio1.3.9+ (GDAL 3.6+) orGDAL3.6+ Python bindings. GDAL below 3.4 lacks the/vsis3/multi-range coalescing that makes this pattern efficient. - The source must be a real COG. Run
rio cogeo validate scene.tiforgdalinfoand confirm the output reports internal tiling (Block=512x512, notBlock=10980x1) and an overview pyramid. A striped GeoTIFF streams correctly but pathologically — see the decision figure below. - IAM: the execution role needs
s3:GetObjecton the object prefix only.s3:ListBucketis not required onceGDAL_DISABLE_READDIR_ON_OPENis set, and leaving it off is the point of the scoping described in IAM Security Boundaries for Cloud GIS. - Environment variables, set in the function configuration rather than in code:
GDAL_DATA=/opt/share/gdalandPROJ_LIB=/opt/share/proj— resolve the driver metadata and datum grids from the layer, not from a network fetch.LD_LIBRARY_PATH=/opt/lib— wherelibgdal.so,libproj.soandlibcurl.solive in a layer-based deployment.GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR— stops GDAL issuing aListObjectsV2per open to hunt for.ovrand.aux.xmlsidecars.EMPTY_DIRrather thanYESkeeps explicitly named sidecars working.GDAL_CACHEMAX=512— decoded block cache, in megabytes when the value is a bare integer.CPL_VSIL_CURL_CACHE_SIZE=67108864— 64 MB, in bytes, for the raw fetched-chunk cache. The two caches are separate and the units differ.CPL_VSIL_CURL_ALLOWED_EXTENSIONS=.tif,.tiff,.ovr— refuses to probe anything else over HTTP.VSI_CACHE=TRUEandVSI_CACHE_SIZE=26214400— 25 MB per-handle read-ahead cache, again in bytes.PROJ_NETWORK=OFF— otherwise PROJ may try to download a datum grid at transform time and cache it under/tmp, which is exactly the write you are trying to avoid.
- Networking: if the function runs in a VPC, an S3 gateway endpoint must exist or every range request leaves through a NAT gateway at per-GB cost.
Sizing the Two Caches
The mistake that makes streaming look slow is leaving GDAL_CACHEMAX at its default. GDAL’s default is 5% of physical RAM, and on a 1,024 MB function that is about 51 MB — smaller than a single 512 × 512 × 4-band Float32 block set, so every window read evicts the blocks the next window needs. Raising it is free until you take memory the rest of the handler needs.
GDAL_CACHEMAX before raising the memory tier.Budget from the allocation downwards, not from the cache upwards. On a 2,048 MB function: about 190 MB is Python plus the linked GDAL/PROJ/GEOS stack, 512 MB goes to GDAL_CACHEMAX, 64 MB to the /vsicurl/ chunk cache, 25 MB to the per-handle read-ahead, and the remaining ~1,250 MB belongs to your NumPy arrays and whatever the output encoder allocates. If your handler builds a 3-band Float32 mosaic of 4,096 × 4,096 pixels — 201 MB — that fits with room to spare. If it builds ten of them, cut GDAL_CACHEMAX before you raise the allocation. The same arithmetic drives the tiers in Memory and CPU Allocation for Raster Workloads.
GDAL_CACHEMAX also accepts a percentage string. GDAL_CACHEMAX=25% on a Lambda that is later moved from 2,048 MB to 4,096 MB doubles the cache without a config change, which is the safer setting for functions whose allocation is tuned by a cost review.
Implementation
"""Stream a window out of a COG over /vsis3/ with zero ephemeral disk use."""
import json
import logging
import os
import shutil
import numpy as np
import rasterio
from rasterio.session import AWSSession
from rasterio.windows import from_bounds
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# ---------------------------------------------------------------------------
# Module scope: build the GDAL environment once per container. rasterio.Env is
# a context manager over GDAL's config option stack, so entering it per request
# would re-parse and re-push every option on every invocation.
# ---------------------------------------------------------------------------
GDAL_ENV = dict(
# Never list the prefix looking for .ovr/.aux.xml sidecars. EMPTY_DIR keeps
# an explicitly named sidecar usable; YES would break it.
GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR",
# Decoded-block cache, megabytes. Sized against the 2,048 MB allocation.
GDAL_CACHEMAX="512",
# Raw fetched-chunk cache, BYTES (64 MB). Different unit from CACHEMAX.
CPL_VSIL_CURL_CACHE_SIZE="67108864",
# Per-file-handle read-ahead, BYTES (25 MB).
VSI_CACHE="TRUE",
VSI_CACHE_SIZE="26214400",
# Refuse to issue HTTP requests for anything that is not a raster.
CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif,.tiff,.ovr",
# Merge range requests for tiles that are within 64 KB of each other into
# one HTTP call. This is the single largest win on a tiled COG.
GDAL_HTTP_MERGE_CONSECUTIVE_RANGES="YES",
GDAL_HTTP_MULTIPLEX="YES",
GDAL_HTTP_VERSION="2",
# A stalled socket must fail fast enough to retry inside the invocation.
GDAL_HTTP_TIMEOUT="20",
GDAL_HTTP_MAX_RETRY="3",
GDAL_HTTP_RETRY_DELAY="1",
# PROJ must not fetch grids at transform time — that write lands in /tmp.
PROJ_NETWORK="OFF",
)
AWS_SESSION = AWSSession(aws_unsigned=False)
def _tmp_bytes_used() -> int:
"""Ephemeral disk consumed so far. Used as an assertion, not a metric."""
usage = shutil.disk_usage("/tmp")
return usage.used
def handler(event, context):
"""event: {"uri": "s3://bucket/key.tif", "bbox": [w, s, e, n]}"""
uri = event["uri"]
west, south, east, north = event["bbox"]
# s3:// -> /vsis3/ . rasterio accepts either, but the explicit VSI path
# makes it obvious in logs which virtual file system is in play.
vsi_path = uri.replace("s3://", "/vsis3/", 1)
tmp_before = _tmp_bytes_used()
with rasterio.Env(session=AWS_SESSION, **GDAL_ENV):
with rasterio.open(vsi_path) as src:
if src.block_shapes[0][1] == src.width:
# A striped file: every window read pulls whole scanline runs.
# Fail loudly rather than silently issuing 400 range requests.
raise ValueError(
f"{uri} is striped (block {src.block_shapes[0]}), not tiled. "
"Re-encode as a COG before streaming it."
)
window = from_bounds(west, south, east, north, transform=src.transform)
# boundless=False: a bbox partly outside the raster raises here
# rather than allocating a padded array we did not budget for.
data = src.read(window=window, boundless=False, masked=True)
# Real work goes here. Kept trivial so the I/O path stays the point.
band_means = [float(np.ma.mean(band)) for band in data]
profile = {
"driver": src.driver,
"blocks": list(src.block_shapes[0]),
"overviews": src.overviews(1),
"window": [int(v) for v in (window.col_off, window.row_off,
window.width, window.height)],
}
tmp_after = _tmp_bytes_used()
# The whole point of the technique, asserted rather than assumed.
if tmp_after > tmp_before:
logger.error("streaming read wrote %d bytes to /tmp", tmp_after - tmp_before)
return {
"statusCode": 200,
"body": json.dumps({
"band_means": band_means,
"profile": profile,
"tmp_bytes_written": tmp_after - tmp_before,
}),
}
Two details carry most of the weight. GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES lets GDAL coalesce the tile fetches for a window into far fewer HTTP calls — on a 512 × 512 window crossing sixteen internal tiles it typically turns sixteen requests into two or three. And the striped-file guard fails the invocation immediately instead of letting it run for four minutes issuing range requests, which is the difference between a clear error and a timeout you have to profile. The same range-request economics are worked through in Tuning HTTP Range Requests for COG Reads on S3.
Verification
Turn on GDAL’s cURL tracing and count the requests. Run this locally against the same object with the same environment before you deploy:
CPL_CURL_VERBOSE=YES \
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
GDAL_CACHEMAX=512 \
GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES \
python -c "
import rasterio
from rasterio.windows import Window
with rasterio.open('/vsis3/eo-scenes/S2B_33UUP_20260714.tif') as s:
print('blocks', s.block_shapes[0], 'overviews', s.overviews(1))
a = s.read(1, window=Window(4096, 4096, 512, 512))
print('window', a.shape, a.dtype, 'bytes', a.nbytes)
" 2>&1 | grep -c '^> GET'
Expected output on a correctly tiled COG — the read reports its shape, and the request count stays in single digits:
blocks (512, 512) overviews [2, 4, 8, 16]
window (512, 512) uint16 bytes 524288
4
Four GETs is two header reads plus two coalesced tile fetches. If that count comes back as 40 or 400, the file is striped, GDAL_HTTP_MERGE_CONSECUTIVE_RANGES is off, or GDAL_DISABLE_READDIR_ON_OPEN is unset and GDAL is probing for sidecars. In the deployed function, assert the same property directly: shutil.disk_usage("/tmp").used must be identical before and after the read, and the handler above already logs an error when it is not.
When Streaming Loses
Streaming is not free, and there are three shapes of work where downloading the object into /tmp is genuinely faster.
The first is whole-file reads. If you will touch more than roughly 60% of the pixels — a full reprojection, a global statistics pass, an overview build — you pay HTTP framing on every tile for no benefit. One GetObject at line rate beats several hundred range requests, and here the /tmp sizing guidance in Managing /tmp Storage Limits for GeoTIFF Extraction is the right reference instead of this page.
The second is non-COG inputs. A striped GeoTIFF, a JPEG2000 without a tiled codestream, or an ESRI Grid has no layout that lets GDAL fetch a spatial subset cheaply. Convert it once on ingest and stream from the converted copy afterwards.
The third is many small scattered reads from a small file. Below about 200 MB, latency dominates: fifty windows at three range requests each is 150 round trips, where the whole file would have arrived in one. The break-even shifts with object size, so measure it on your own data rather than adopting a number.
Gotchas
-
GDAL_CACHEMAXandCPL_VSIL_CURL_CACHE_SIZEuse different units. A bare integer inGDAL_CACHEMAXis megabytes;CPL_VSIL_CURL_CACHE_SIZEis always bytes. SettingCPL_VSIL_CURL_CACHE_SIZE=64gives you a 64-byte chunk cache and a read pattern that looks like a network fault. -
The chunk cache is per process, not per container. If you fan out with a
ProcessPoolExecutor, each worker gets its ownGDAL_CACHEMAXallocation. Four workers atGDAL_CACHEMAX=512is 2 GB of cache, and on a 2,048 MB function that is an OOM kill with no Python traceback. -
PROJ can still write to disk even when GDAL does not. A transform that needs a datum grid not present in
PROJ_LIBwill, withPROJ_NETWORK=ON, fetch it from the CDN and cache it in$PROJ_USER_WRITABLE_DIRECTORY— which on Lambda defaults under/tmp. SetPROJ_NETWORK=OFFand bake the grids you need into the layer. -
Unsigned reads need to be explicit. Public buckets such as the Sentinel-2 open data archive require
AWS_NO_SIGN_REQUEST=YES(orAWSSession(aws_unsigned=True)); with a signed session against a bucket your role cannot access, GDAL reports a bareAccess Deniedwith no indication that the credentials, rather than the path, were the problem.
Frequently Asked Questions
Does /vsis3/ write anything to /tmp?
No. /vsis3/ and /vsicurl/ hold fetched byte ranges in a process-memory chunk cache sized by CPL_VSIL_CURL_CACHE_SIZE, and decoded raster blocks in the GDAL block cache sized by GDAL_CACHEMAX. Neither touches the filesystem. The only way a streaming read reaches /tmp is if PROJ downloads a datum grid, which PROJ_NETWORK=OFF prevents.
What should GDAL_CACHEMAX be on a 2,048 MB function?
Around 512 MB. Budget roughly 190 MB for the Python and GDAL runtime, 64 MB for the /vsicurl/ chunk cache, and leave at least 40% of the allocation free for the NumPy arrays your own code builds. Setting GDAL_CACHEMAX=25% makes the value track the allocation automatically if the function is later resized.
When is downloading the file faster than streaming it?
When you will read most of the pixels anyway, when the file is not internally tiled, or when you need many small scattered reads from a file below roughly 200 MB. A striped GeoTIFF forces GDAL to fetch whole scanline runs per window, so a single full download beats hundreds of range requests.
Can I stream from GCS and Azure Blob the same way?
Yes — /vsigs/bucket/key.tif and /vsiaz/container/blob.tif use the same block cache, the same CPL_VSIL_CURL_* options and the same coalescing logic. Credentials differ: /vsigs/ picks up the Cloud Functions metadata-server identity automatically, and /vsiaz/ needs AZURE_STORAGE_ACCOUNT plus either AZURE_STORAGE_ACCESS_KEY or AZURE_STORAGE_SAS_TOKEN.
Related
- Ephemeral Storage Comparison Across Serverless Platforms — the
/tmpquotas on each provider that this technique lets you stop sizing - Managing /tmp Storage Limits for GeoTIFF Extraction — the download path, for the whole-file reads where streaming loses
- Tuning HTTP Range Requests for COG Reads on S3 — request coalescing and read-ahead in more depth
- Memory and CPU Allocation for Raster Workloads — the allocation the cache budget is carved out of
- Optimizing Chunked I/O for Multi-Band Sentinel-2 Processing — applying the same reads across a band stack
Back to Ephemeral Storage Comparison Across Serverless Platforms