Infryne TechWorks logo
INFRYNE TechWorks
◈ Cloud Engineering · AWS Batch · Rasterio

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.

Talha WaheedCo-Founder & Head of Engineering9 min read
Diagram of AWS Batch geospatial pipeline from S3 to parallel processing containers
← Back to Blog

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:

Exit Code 137 · OOMKilled (128 + 9)

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.

Exit Code 1 · Application Crash

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.

Exit Code 143 · SIGTERM (128 + 15)

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:

Memory = 10,980 × 10,980 × 12 bands × 4 bytes ≈ 5.78 GB RAM (excluding algorithm overhead)

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 release

Separate 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:

Execution Role (Host Agent)
  • 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
Job Role (Application Workload)
  • 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"}
    ]
  }'
Workflow diagram showing S3 manifest fan-out into 10,000 indexed AWS Batch containers

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.

Anti-Pattern: Sequential Prefix
s3://bucket/output/scene_00001.tif
s3://bucket/output/scene_00002.tif

Collapses all 10,000 workers into a single S3 partition. Triggers aggressive throttling and connection timeouts.

Production Pattern: Spatial Cell Hashing
s3://bucket/tiles/841f9/841f909ffffffff/output.tif
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 OptionRecommended ValueImpact on AWS Batch
GDAL_DISABLE_READDIR_ON_OPENEMPTY_DIRPrevents GDAL from listing the entire S3 directory looking for sidecar .aux.xml files, saving 1–3 roundtrips per tile.
CPL_VSIL_CURL_CHUNK_SIZE65536 (64 KB)Optimizes HTTP GET byte-range payload size when fetching sparse raster overviews or isolated windows.
VSI_CACHETRUEEnables in-memory LRU caching of recently fetched byte chunks, avoiding redundant requests for TIFF headers.
GDAL_CACHEMAX512 (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 None

Compute Architecture: AWS Batch vs. AWS Lambda vs. EKS

Choosing the right compute abstraction determines development velocity, runtime resilience, and total infrastructure spend:

DimensionAWS Batch (EC2 Spot)AWS LambdaAmazon EKS (Kubeflow/Argo)
Max Execution TimeUnlimited (Days)15 minutes hard limitUnlimited
Memory & Storage LimitUp to 768 GB RAM, Terabytes EBS10 GB RAM, 10 GB /tmpCustom cluster capacity
GPU AccelerationNative (NVIDIA G4dn/G5 instances)No GPU supportNative with GPU Operator
Cost Efficiency (10k Tiles)Highest (Spot 70–90% off)Moderate (GB-second billing)High, but high control-plane overhead
Operational OverheadLow (Managed queue & scaling)Lowest (Fully serverless)High (K8s upgrades, CNI, ingress)

The Final Production Workflow

StageActionOutcome
DebugForced unbuffered stdout logs & JSON structured formatIsolated missing dependency and corrected Docker context.
PermissionsAssigned execution vs job IAM roles with STS scopingResolved S3 auth with zero credential leaks.
MemoryGenerator-based windowed reads via rasterio.windowsCompletely eliminated Exit Code 137 OOM kills.
ResilienceSIGTERM checkpointing & evaluateOnExit Spot strategy70%+ cost reduction with zero lost worker cycles.
ScaleH3 prefix hashing & random startup jitter on S3 writesSustained 10k concurrency with zero HTTP 503 errors.
OperateGranular CloudWatch alarms + SQS Dead-Letter QueueFull 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

Remote Sensing
Wildfire Burn Scar Mapping with Sentinel-2 & Landsat

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.

18 min read
Urban Heat
Heat Is Not Distributed Fairly: Mapping Thermal Inequality in Karachi

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.

24 min read
Remote Sensing
From Raw Satellite Pixels to a Working NDVI Alert Pipeline

A production walkthrough of Sentinel-2 L2A, cloud masking, median composites, spatial validation, CNN inference, and field-ready crop stress alerts.

22 min read
GeoAI
Spatial Data Leakage in Machine Learning: How to Fix It

Random train/test splits can inflate GeoAI accuracy. Learn how spatial autocorrelation causes leakage and how to validate properly.

20 min read
Frontend GIS
MapLibre GL JS: Performance Guide for Large Datasets

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.

15 min read
GIS Engineering
Cloud-Native Web Mapping Architecture: A Practical Guide

How to design scalable web maps with PostGIS, vector tiles, PMTiles, and MapLibre. Architecture patterns, trade-offs, and costs.

18 min read
GeoAI
Scaling Spatial Pipelines: Why Your Location Queries Are Crashing

A field guide to PostGIS storefront architecture, ST_DWithin indexing, and the decisions that pull spatial systems back from the edge.

8 min read