Scaling Geospatial Pipelines: From 10 Files to 10,000 in AWS Batch
The production lessons behind moving an imagery workflow from local scripts to an observable, permission-safe, highly parallel cloud pipeline.

Scaling a geospatial pipeline is rarely only a coding problem. It is an infrastructure problem involving memory, identity, concurrency, observability, and cost. When transitioning from processing local geotiffs on a workstation to handling tens of thousands of satellite tiles on AWS Batch, standard batch setups fail in subtle ways.
Here is the behind-the-scenes architecture and hard-won production lessons we use at Infryne TechWorks to turn fragile geospatial scripts into resilient, high-throughput cloud processing systems.
Treat "Essential Container Exited" as a Starting Point
Initial AWS Batch runs for satellite imagery pipelines invariably stumble into the opaque Essential container in task exited failure state. In distributed systems, this message is a symptom, not a diagnosis. The actionable diagnostic data lives inside Linux exit codes, kernel ring buffers, and container cgroup memory metrics:
Kernel Invoked OOM Killer
Raster decoding decompresses LZW/DEFLATE GeoTIFFs into raw memory. If the container memory limit is reached, the host Linux kernel issues a non-catchable SIGKILL. Profile uncompressed footprint before configuring container definitions.
Python Exceptions & Silent Buffering
Unhandled exceptions (missing bands, invalid projection transforms) crash the process. If Python stdout/stderr buffering is enabled, logs flush after termination, resulting in empty CloudWatch logs.
Spot Interruption or Batch Timeout
The host EC2 instance was reclaimed by AWS EC2 Spot market or the task exceeded jobTimeout. The process has a 2-minute window to checkpoint state before hard termination.
To guarantee every diagnostic trace reaches CloudWatch prior to task termination, configure your container runtime for unbuffered streaming and structured JSON output:
# Dockerfile hardening for geospatial pipelines
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.9.0
# Prevent Python from writing .pyc files and buffer stdout/stderr
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
GDAL_CACHEMAX=512 \
CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif,.tiff,.vrt,.json,.parquet"
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENTRYPOINT ["python", "-u", "-m", "pipeline.worker"]Windowed Raster Reads: Eliminating the Exit Code 137 Trap
The root cause of 90% of Exit Code 137 failures is naive whole-file ingestion. A Sentinel-2 scene compressed to 180 MB on S3 decompresses into 12 spectral bands at 10,980 × 10,980 pixels. Loaded into float32 NumPy arrays:
When running on multi-core compute environments with concurrent worker threads, memory multiplies linearly until cgroups kill the container. The production solution is generator-based windowed readingusing internal GeoTIFF block structures (typically 512 × 512 or 1024 × 1024 pixels):
import rasterio
from rasterio.windows import Window
import numpy as np
def process_raster_in_windows(src_path: str, dst_path: str, block_size: int = 1024):
"""Processes large rasters in bounded memory windows, preventing Exit Code 137."""
with rasterio.open(src_path) as src:
profile = src.profile.copy()
profile.update(
tiled=True,
blockxsize=512,
blockysize=512,
compress="deflate",
predictor=2,
bigtiff="yes"
)
with rasterio.open(dst_path, "w", **profile) as dst:
# Iterate through bounded coordinate windows
for col_off in range(0, src.width, block_size):
for row_off in range(0, src.height, block_size):
window = Window(
col_off=col_off,
row_off=row_off,
width=min(block_size, src.width - col_off),
height=min(block_size, src.height - row_off)
)
# Peak memory bounded to block_size * block_size * bands * dtype
data = src.read(window=window).astype(np.float32)
# Compute localized geospatial index or inference
result = (data[3] - data[2]) / (data[3] + data[2] + 1e-6)
dst.write(result.astype(rasterio.float32), 1, window=window)
del data, result # Explicit GC releaseSeparate Platform Identity from Workload Identity
A common operational vulnerability in enterprise geospatial setups is attaching administrative S3 policies to the EC2 host instance profile. AWS Batch requires a strict bifurcation between Execution Roles and Job Roles:
- ECR token authentication and layer pulls (
ecr:GetAuthorizationToken) - Initializes container stdout/stderr log stream in CloudWatch
- Retrieves secure parameter credentials from AWS SSM Parameter Store
- Granted to:
ecs-tasks.amazonaws.com
- Scoped strictly to input/output S3 bucket URIs
- Publish status events to Amazon SQS / SNS DLQ
- Zero access to underlying EC2 instance metadata or IAM APIs
- Assumed by container process at runtime via AWS STS
// Least-privilege IAM Policy attached to AWS Batch Job Role
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowImageryIngest",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::sentinel-cogs/*",
"arn:aws:s3:::sentinel-cogs"
]
},
{
"Sid": "AllowProductionCOGPublish",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:AbortMultipartUpload"],
"Resource": "arn:aws:s3:::infryne-production-features/*"
}
]
}Coordinate 10,000 Independent Tiles with Array Jobs
Submitting 10,000 individual Batch API calls triggers AWS API rate limits (TooManyRequestsException) and degrades queue scheduling. AWS Batch Array Jobs condense up to 10,000 child jobs into a single declarative submission.
Each child container receives the injected environment variable AWS_BATCH_JOB_ARRAY_INDEX (integers 0 through 9,999). We map this index directly to a pre-validated, immutable manifest in S3:
# Step 1: Submit single Array Job handle representing 10,000 parallel tiles
aws batch submit-job \
--job-name sentinel-tile-pipeline-202607 \
--job-queue geospatial-compute-spot \
--job-definition tile-processor:4 \
--array-properties size=10000 \
--container-overrides '{
"environment": [
{"name": "MANIFEST_S3_URI", "value": "s3://infryne-jobs/manifests/2026-run.jsonl"}
]
}'EC2 Spot Interruption Handling & Checkpointing
Geospatial batch processing on on-demand instances is cost-prohibitive at scale. Running on EC2 Spot provides a 70% to 90% cost reduction, but requires engineering for abrupt reclamation. When AWS EC2 requires instance capacity back, it sends an interruption notification via instance metadata two minutes prior to termination.
By capturing the POSIX SIGTERM signal emitted by the ECS agent, our workers checkpoint intermediate state or flush in-flight S3 multipart chunk uploads:
import signal
import sys
import boto3
class SpotGracefulTerminator:
def __init__(self, tile_id: str, checkpoint_bucket: str):
self.tile_id = tile_id
self.bucket = checkpoint_bucket
self.interrupted = False
# Intercept POSIX signals emitted during Spot reclamation
signal.signal(signal.SIGINT, self.handle_sigterm)
signal.signal(signal.SIGTERM, self.handle_sigterm)
def handle_sigterm(self, signum, frame):
print(f"[SHUTDOWN] Captured signal {signum}. Evacuating state for {self.tile_id}...")
self.interrupted = True
self.save_checkpoint_to_s3()
sys.exit(143) # Exit code 143 signals Spot interruption to AWS Batch
def save_checkpoint_to_s3(self):
s3 = boto3.client("s3")
s3.put_object(
Bucket=self.bucket,
Key=f"checkpoints/{self.tile_id}.state",
Body=b'{"status": "interrupted", "stage": "inference"}'
)In the AWS Batch Job Definition, configure a declarative retryStrategy with evaluateOnExit so that host terminations do not deplete the standard application retry quota:
"retryStrategy": {
"attempts": 3,
"evaluateOnExit": [
{
"onStatusReason": "Host EC2 (instance *) terminated.",
"action": "RETRY"
},
{
"onExitCode": "143",
"action": "RETRY"
},
{
"onExitCode": "1",
"action": "EXIT"
}
]
}S3 Concurrency, HTTP 503 SlowDown & Prefix Hashing
Amazon S3 partitions handle 3,500 PUT/POST/DELETE and 5,500 GET requests per second per prefix. If 10,000 child workers start in lockstep and write results into a single flat path like s3://bucket/processed/tile_XXXX.tif, S3 throttles traffic with HTTP 503 SlowDown errors.
s3://bucket/output/scene_00002.tif
Collapses all 10,000 workers into a single S3 partition. Triggers aggressive throttling and connection timeouts.
s3://bucket/tiles/841f1/841f123ffffffff/output.tif
Distributes writes across independent S3 partition prefixes using Uber H3 hexagonal indexes or hash digests.
import os, time, random
from botocore.config import Config
import boto3
# Bounded startup jitter prevents synchronized thundering herd on S3
jitter_seconds = random.uniform(0.5, 90.0)
time.sleep(jitter_seconds)
# Configure boto3 client with aggressive exponential backoff
boto_config = Config(
retries={
"max_attempts": 10,
"mode": "adaptive" # Automatically rate-limits based on server 503 responses
},
max_pool_connections=50
)
s3_client = boto3.client("s3", config=boto_config)Stabilize GDAL & Rasterio in Headless Environments
GDAL's default configuration assumes a local workstation with disk caching and single-threaded operations. In a containerized cloud environment reading Cloud Optimized GeoTIFFs (COGs) over HTTP/S3, default settings cause excessive HTTP range requests and file descriptor leaks.
| GDAL Config Option | Recommended Value | Impact on AWS Batch |
|---|---|---|
| GDAL_DISABLE_READDIR_ON_OPEN | EMPTY_DIR | Prevents GDAL from listing the entire S3 directory looking for sidecar .aux.xml files, saving 1–3 roundtrips per tile. |
| CPL_VSIL_CURL_CHUNK_SIZE | 65536 (64 KB) | Optimizes HTTP GET byte-range payload size when fetching sparse raster overviews or isolated windows. |
| VSI_CACHE | TRUE | Enables in-memory LRU caching of recently fetched byte chunks, avoiding redundant requests for TIFF headers. |
| GDAL_CACHEMAX | 512 (MB) | Caps internal raster block cache to prevent silent memory accumulation leading to OOM 137. |
import rasterio
from rasterio.env import Env
# Set production GDAL virtual file system environment variables
with Env(
GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR",
CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif",
VSI_CACHE=True,
VSI_CACHE_SIZE=67108864, # 64 MB
GDAL_HTTP_MERGE_CONSECUTIVE_RANGES="YES"
):
with rasterio.open("s3://sentinel-cogs/T43RER_20260714.tif") as src:
# Reads only the required bytes via HTTP Range headers
ndvi_window = src.read(1, window=Window(0, 0, 512, 512))Handling Poison Pill Tiles with Dead-Letter Queues (DLQs)
In a 10,000-tile batch run, roughly 0.05% to 0.2% of satellite scenes suffer from corrupted byte streams, zero-byte uploads, or invalid geotransforms. If unhandled, these "poison pill" tiles will burn through all retry attempts, consume expensive compute time, and fail the parent pipeline.
We isolate corrupt tiles into an Amazon SQS Dead-Letter Queue with full error diagnostics, allowing the batch job to complete with a 99.9% success SLA while quarantining defective tiles for triage:
import json
import boto3
import rasterio
from rasterio.errors import RasterioIOError
def safe_tile_executor(tile_uri: str, sqs_dlq_url: str):
try:
with rasterio.open(tile_uri) as src:
if src.width == 0 or src.height == 0:
raise ValueError("Raster has zero spatial extent")
return process_tile(src)
except (RasterioIOError, ValueError, Exception) as err:
print(f"[POISON PILL] Quarantining corrupt tile {tile_uri}: {err}")
sqs = boto3.client("sqs")
sqs.send_message(
QueueUrl=sqs_dlq_url,
MessageBody=json.dumps({
"tile_uri": tile_uri,
"error_type": type(err).__name__,
"error_message": str(err),
"timestamp": time.time()
})
)
# Return cleanly so parent batch array job continues processing
return NoneCompute Architecture: AWS Batch vs. AWS Lambda vs. EKS
Choosing the right compute abstraction determines development velocity, runtime resilience, and total infrastructure spend:
| Dimension | AWS Batch (EC2 Spot) | AWS Lambda | Amazon EKS (Kubeflow/Argo) |
|---|---|---|---|
| Max Execution Time | Unlimited (Days) | 15 minutes hard limit | Unlimited |
| Memory & Storage Limit | Up to 768 GB RAM, Terabytes EBS | 10 GB RAM, 10 GB /tmp | Custom cluster capacity |
| GPU Acceleration | Native (NVIDIA G4dn/G5 instances) | No GPU support | Native with GPU Operator |
| Cost Efficiency (10k Tiles) | Highest (Spot 70–90% off) | Moderate (GB-second billing) | High, but high control-plane overhead |
| Operational Overhead | Low (Managed queue & scaling) | Lowest (Fully serverless) | High (K8s upgrades, CNI, ingress) |
The Final Production Workflow
| Stage | Action | Outcome |
|---|---|---|
| Debug | Forced unbuffered stdout logs & JSON structured format | Isolated missing dependency and corrected Docker context. |
| Permissions | Assigned execution vs job IAM roles with STS scoping | Resolved S3 auth with zero credential leaks. |
| Memory | Generator-based windowed reads via rasterio.windows | Completely eliminated Exit Code 137 OOM kills. |
| Resilience | SIGTERM checkpointing & evaluateOnExit Spot strategy | 70%+ cost reduction with zero lost worker cycles. |
| Scale | H3 prefix hashing & random startup jitter on S3 writes | Sustained 10k concurrency with zero HTTP 503 errors. |
| Operate | Granular CloudWatch alarms + SQS Dead-Letter Queue | Full observability and automated single-tile triage. |
Frequently Asked Questions
Why does AWS Batch exit with code 137 on geospatial raster jobs?
Exit code 137 indicates the Linux Out-Of-Memory (OOM) killer terminated the container process (SIGKILL, 128 + 9). In geospatial processing, uncompressing multi-band GeoTIFFs into NumPy arrays expands compressed files from ~150MB on S3 to ~1.6GB+ in RAM. When multiplied by concurrency or intermediate calculations, memory exceeds the container limit. Use windowed reading with rasterio.windows.Windowto process rasters in 512×512 chunks.
How do you prevent S3 503 SlowDown errors during 10,000 concurrent AWS Batch array jobs?
Amazon S3 partitions handle 3,500 PUT and 5,500 GET requests per second per prefix. When thousands of batch workers start simultaneously and write to the same output prefix, S3 throttles them. Prevent this by applying bounded random startup jitter (0–90 seconds), implementing exponential backoff in boto3, and partitioning target S3 prefixes using spatial cell tokens (like H3 indexes) rather than sequential IDs.
How should you configure AWS Batch to handle EC2 Spot interruptions?
EC2 Spot instances provide 70–90% cost savings. When an interruption notice is delivered (2-minute warning via EC2 metadata), intercept SIGTERM in Python, serialize progress to S3, and exit with code 143. In the AWS Batch job definition, configure retryStrategy with evaluateOnExit to automatically re-queue the task when the host terminates, without counting against application bug limits.
What is the difference between AWS Batch job execution role and job role?
The execution role is used by the ECS container agent on the EC2 host to pull images from ECR and send logs to CloudWatch. The job role is assumed directly by your application container code (Python/GDAL/Rasterio) to access application resources like reading input S3 buckets, writing output COGs, or publishing SQS status events.
Planning a Large Cloud Run?
Design the Failure Path Before the Scale Test
We help teams turn fragile geospatial scripts into observable cloud pipelines with clear resource, security, and cost boundaries.
Primary Sources & Datasets
Continue Reading
More engineering research from Infryne TechWorks
Wildfire burn scar mapping using Sentinel-2 and Landsat 8/9 — NBR, BAIS2, RBR spectral indices, NASA FIRMS active fire integration, and the Infryne TechWorks satellite analytics platform.
A 10-year Landsat archive study overlaying land surface temperature with census wards, income proxies, and tree canopy to map spatial heat burden in Karachi.
A production walkthrough of Sentinel-2 L2A, cloud masking, median composites, spatial validation, CNN inference, and field-ready crop stress alerts.
Random train/test splits can inflate GeoAI accuracy. Learn how spatial autocorrelation causes leakage and how to validate properly.
Render 100,000+ geometries at 60 FPS in MapLibre GL JS without freezing the UI. Master vector tiles, PMTiles, WebGL clustering, and feature-state memory tuning.
How to design scalable web maps with PostGIS, vector tiles, PMTiles, and MapLibre. Architecture patterns, trade-offs, and costs.
A field guide to PostGIS storefront architecture, ST_DWithin indexing, and the decisions that pull spatial systems back from the edge.