Processing COGs on AWS Batch with Docker
To run a rasterio job over many Cloud-Optimized GeoTIFFs at scale, bake the job into a Docker image, register it as an AWS Batch job definition, and submit an array job so each child task processes one scene by its AWS_BATCH_JOB_ARRAY_INDEX. The container reads each COG straight from S3 by range and writes its result back:
import os, boto3
batch = boto3.client("batch", region_name="us-west-2")
resp = batch.submit_job(
jobName="cog-batch-run",
jobQueue="raster-queue",
jobDefinition="cog-processor:1",
arrayProperties={"size": 500}, # 500 scenes → 500 child tasks
)
print(resp["jobId"])
This is the containerized batch pattern from the parent guide, Distributed Processing on Coiled & AWS Batch — one image, one command, fanned out across the archive.
Why This Arises in Remote Sensing Workflows
Per-scene raster transforms are embarrassingly parallel: reprojecting, computing an index, or generating a thumbnail for one scene has no dependency on any other scene. That shape maps cleanly onto AWS Batch, which runs a container to completion per job and scales the underlying EC2 fleet to the queue depth. You get horizontal throughput without standing up a scheduler or holding shared cluster state.
The design hinges on reading Cloud-Optimized GeoTIFFs in place. Because a COG’s header and tiles are range-addressable, the container never copies whole files to local disk; GDAL fetches only the bytes a windowed read touches. For interactive, shared-memory Dask work the sibling guide Launching a Coiled Cluster for STAC Processing is the better fit; Batch wins when the work is a fixed script run once per scene. For the wider orchestration picture, see Cloud Execution & Orchestration.
How an array job fans out over scenes
The diagram traces one submit_job call through the queue to N isolated containers, each pinned to a different scene by its array index.
Environment & Setup
Only three moving parts run locally: the Docker CLI to build and push the image, the AWS CLI/boto3 to register the job definition and submit jobs, and rasterio inside the image. Pin versions so the in-container GDAL matches what you tested.
| Package / tool | Version | Why required |
|---|---|---|
rasterio |
1.3.9 | COG range reads via GDAL /vsis3/ inside the container |
boto3 |
1.34+ | Submit array jobs and register job definitions from Python |
| Docker | 24+ | Build the immutable job image |
| base image | ghcr.io/osgeo/gdal:ubuntu-small-3.9.2 |
Prebuilt GDAL 3.9 with libcurl for range reads |
pip install "rasterio==1.3.9" "boto3>=1.34"
Complete Working Example
1. Dockerfile on a GDAL/rasterio base
Start from an official GDAL image so /vsis3/ and range reads work out of the box, then add pinned Python deps and the job script. The image is immutable — rebuild and push a new tag when dependencies change.
# Prebuilt GDAL 3.9 with libcurl → COG range reads work immediately
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.9.2
# Pin the Python stack so worker GDAL/rasterio matches local testing
RUN pip install --no-cache-dir "rasterio==1.3.9" "numpy==1.26.4" "boto3==1.34.84"
WORKDIR /app
COPY process_scene.py /app/process_scene.py
# GDAL tuning for cloud reads, baked into the image so every task inherits it
ENV GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES \
CPL_VSIL_CURL_ALLOWED_EXTENSIONS=.tif
ENTRYPOINT ["python", "/app/process_scene.py"]
Build and push to ECR:
aws ecr get-login-password --region us-west-2 | docker login --username AWS --password-stdin <acct>.dkr.ecr.us-west-2.amazonaws.com
docker build -t cog-processor:1 .
docker tag cog-processor:1 <acct>.dkr.ecr.us-west-2.amazonaws.com/cog-processor:1
docker push <acct>.dkr.ecr.us-west-2.amazonaws.com/cog-processor:1
2. The in-container job script
The script reads its array index, maps it to a scene key from a manifest in S3, range-reads the COG, applies a transform (here a simple NDVI-style normalized difference on two bands), and writes a Cloud-Optimized GeoTIFF back to the output prefix. No full file is ever copied to disk.
import os
import json
import numpy as np
import rasterio
from rasterio.io import MemoryFile
import boto3
BUCKET = os.environ["SCENE_BUCKET"]
MANIFEST_KEY = os.environ["MANIFEST_KEY"] # JSON list of scene records
OUT_PREFIX = os.environ["OUTPUT_PREFIX"] # e.g. "ndvi/2023/"
def load_manifest() -> list[dict]:
"""Fetch the JSON manifest of scenes this run should process."""
s3 = boto3.client("s3")
body = s3.get_object(Bucket=BUCKET, Key=MANIFEST_KEY)["Body"].read()
return json.loads(body)
def process_one(scene: dict) -> None:
"""Range-read NIR + Red COGs, compute a normalized difference, write result to S3."""
# GDAL reads directly from s3://; only the header + touched blocks transfer
with rasterio.open(scene["nir"]) as nir_src, rasterio.open(scene["red"]) as red_src:
nir = nir_src.read(1).astype("float32")
red = red_src.read(1).astype("float32")
profile = nir_src.profile
denom = nir + red
with np.errstate(divide="ignore", invalid="ignore"):
ndvi = np.where(denom != 0, (nir - red) / denom, np.nan).astype("float32")
profile.update(dtype="float32", count=1, compress="zstd",
tiled=True, blockxsize=512, blockysize=512, nodata=np.nan)
# Serialize the COG in memory, then upload — avoids a local temp file
with MemoryFile() as mem:
with mem.open(**profile) as dst:
dst.write(ndvi, 1)
out_key = f"{OUT_PREFIX}{scene['id']}_ndvi.tif"
boto3.client("s3").put_object(Bucket=BUCKET, Key=out_key, Body=mem.read())
print(f"wrote s3://{BUCKET}/{out_key}")
def main() -> None:
idx = int(os.environ["AWS_BATCH_JOB_ARRAY_INDEX"]) # injected by AWS Batch
scenes = load_manifest()
process_one(scenes[idx]) # this task owns one scene
if __name__ == "__main__":
main()
3. The job definition JSON
The job definition declares the image, resources, environment, and — critically — the job role that grants S3 access. Register it with aws batch register-job-definition --cli-input-json file://job-def.json.
{
"jobDefinitionName": "cog-processor",
"type": "container",
"containerProperties": {
"image": "<acct>.dkr.ecr.us-west-2.amazonaws.com/cog-processor:1",
"vcpus": 2,
"memory": 8192,
"jobRoleArn": "arn:aws:iam::<acct>:role/cog-batch-job-role",
"executionRoleArn": "arn:aws:iam::<acct>:role/cog-batch-exec-role",
"environment": [
{ "name": "SCENE_BUCKET", "value": "my-raster-archive" },
{ "name": "MANIFEST_KEY", "value": "runs/2023/manifest.json" },
{ "name": "OUTPUT_PREFIX", "value": "ndvi/2023/" }
]
}
}
The jobRoleArn needs an IAM policy granting s3:GetObject on the input prefix and s3:PutObject on the output prefix. The separate executionRoleArn only lets Batch pull the ECR image and ship logs to CloudWatch — do not conflate the two.
4. Submit the array job
import boto3
batch = boto3.client("batch", region_name="us-west-2")
def submit_run(n_scenes: int, jobdef: str = "cog-processor:1") -> str:
"""One array job → n_scenes child tasks, each keyed by its array index."""
resp = batch.submit_job(
jobName="cog-ndvi-2023",
jobQueue="raster-queue",
jobDefinition=jobdef,
arrayProperties={"size": n_scenes}, # must match manifest length
# Retry transient spot interruptions; the job is idempotent per scene
retryStrategy={"attempts": 3},
)
return resp["jobId"]
if __name__ == "__main__":
print(submit_run(500))
Set arrayProperties.size to the manifest length so every scene gets exactly one child task. Because each task overwrites a deterministic output key, retries are safe — a re-run of scene 42 produces the same object.
Variant Patterns
Passing the scene list without a manifest
For small runs you can skip the manifest and pass scene keys through job parameters, resolving the index against a comma-separated env var. This avoids an S3 round-trip per task but does not scale past a few hundred entries because of environment-size limits.
import os
scenes = os.environ["SCENE_KEYS"].split(",") # "s3://.../a.tif,s3://.../b.tif"
key = scenes[int(os.environ["AWS_BATCH_JOB_ARRAY_INDEX"])]
Windowed reads for very large scenes
When a single scene is too large to hold in memory, read and write by block instead of read(1). Pair this with the header-first checks from How to Read COG Headers Without Downloading Full Files to size memory before allocating.
import rasterio
from rasterio.windows import Window
with rasterio.open(scene_url) as src:
for _, window in src.block_windows(1): # iterate native COG tiles
block = src.read(1, window=window) # only this tile transfers
# ... transform block, accumulate or stream-write ...
Common Errors
AccessDenied when the container reads or writes S3
The job role, not the execution role, governs application S3 access. Attach an IAM policy to jobRoleArn granting s3:GetObject on the input prefix and s3:PutObject on the output prefix, then resubmit.
RasterioIOError: ... not recognized as a supported file format inside the container
GDAL in the image lacks curl/range support or the URL returned an error page. Confirm the base image is a GDAL build with libcurl and that CPL_VSIL_CURL_ALLOWED_EXTENSIONS includes .tif; test one URL with rasterio.open locally in the same image.
Array task fails with KeyError: 'AWS_BATCH_JOB_ARRAY_INDEX'
You submitted a plain job, not an array job. arrayProperties.size must be set (and at least 2) for Batch to inject the index variable; a single-task run does not get one.
Related
- Distributed Processing on Coiled & AWS Batch — parent guide comparing containerized Batch jobs with managed Coiled clusters
- Understanding Cloud-Optimized GeoTIFF Structure — why in-container range reads avoid copying whole files
- How to Read COG Headers Without Downloading Full Files — inspect CRS and block size before allocating memory in a task
- Optimizing Pipeline Cost and Performance — use spot compute and right-sized memory to cut per-scene cost