Using Spot Instances for Interruptible Raster Jobs

A spot instance is a discount in exchange for a promise you will not mind being interrupted, and a raster pipeline can honour that promise cheaply — the work is naturally divisible, idempotent when content-keyed, and resumable when checkpointed. What turns the discount into a saving is handling the reclamation notice properly, keeping the checkpoint interval well under the notice period, and being honest about which stages must not run on interruptible capacity at all.

When to Use This Pattern

  • The job is divisible into units of a few minutes, so an interruption loses minutes rather than hours.
  • Checkpointing is already in place, as in checkpointing large raster mosaics.
  • The deadline has slack — a nightly build that must finish by 06:00 and takes three hours has room; one that takes five and a half does not.
  • Compute is a visible cost and the fleet is large enough for a sixty to eighty per cent discount to matter.

Complete Working Example

Two parts: a handler that reacts to the reclamation notice, and a fleet split that keeps the uninterruptible stages safe.

from __future__ import annotations

import signal
import threading
import time
from pathlib import Path

import requests
from prefect import flow, task, get_run_logger

RECLAIM_URL = "http://169.254.169.254/latest/meta-data/spot/instance-action"
_draining = threading.Event()


def watch_for_reclamation(poll_seconds: int = 5) -> None:
    """Set the drain flag as soon as the notice appears. Runs in a daemon thread."""
    while not _draining.is_set():
        try:
            if requests.get(RECLAIM_URL, timeout=2).status_code == 200:
                _draining.set()
        except requests.RequestException:
            pass                                  # no notice yet: the endpoint 404s
        time.sleep(poll_seconds)


def _on_sigterm(_signum, _frame) -> None:
    _draining.set()                               # the orchestrator's own drain signal


signal.signal(signal.SIGTERM, _on_sigterm)
threading.Thread(target=watch_for_reclamation, daemon=True).start()


@task(retries=3, retry_delay_seconds=[5, 20, 60], timeout_seconds=900, tags=["tile"])
def build_block(block: Block, run_id: str, scratch: Path) -> str | None:
    if _draining.is_set():
        # Refuse new work rather than starting something that cannot finish.
        get_run_logger().info("draining: declining block %s", block.key)
        return None

    key = block_key(run_id, block, RECIPE)
    if (done := verified_checkpoint(key, scratch)) is not None:
        return done

    path = warp_block(block, scratch)             # ~90 s: well inside a 2-minute notice
    write_checkpoint(key, path, scratch)          # digest sidecar written last
    return path


@flow(name="mosaic-spot", timeout_seconds=21600, retries=2)
def mosaic(blocks: list[Block], run_id: str, scratch: Path) -> Path:
    states = build_block.map(blocks, run_id=run_id, scratch=scratch, return_state=True)
    built = [s.result() for s in states if s.is_completed() and s.result()]
    if len(built) < len(blocks):
        # A flow-level retry picks up the declined blocks on surviving workers.
        raise RuntimeError(f"{len(blocks) - len(built)} block(s) outstanding — retrying")
    return assemble(built, scratch / "mosaic.tif")

The fleet split is configuration rather than code, and it is what keeps the reduction safe:

# The wide, interruptible stage.
work_pool: spot-tiles
  instance_market: spot
  capacity_rebalance: true       # replace before reclamation where the platform offers it
  max_price: on-demand           # never pay more than the alternative
  tags: [tile]

# The single long reduction and the publish step: on-demand, small, always there.
work_pool: reduce-ondemand
  instance_market: on-demand
  tags: [reduce, publish]
What fits inside the noticeThe reclamation notice gives about two minutes. A ninety-second block finishes and checkpoints; the next block is declined rather than started, so nothing is lost.notice arrives at t = 0block in flight — 90 s leftckptnext block declinedinstance terminated at t = 120 sA 4-minute block would not have finished, and its work would be lost.Rule: keep the unit of work comfortably under half the notice period, and decline new work while draining.
The declined block is not lost — it is simply not started, and the flow-level retry picks it up on a worker that still exists.

Declining new work while draining is the part most implementations leave out, and it matters more than the checkpoint. Without it, a worker that has received its notice cheerfully starts a fresh ninety-second block with sixty seconds to live, guaranteeing that the work is lost and, worse, that the block’s slot in the concurrency limit is occupied by something that cannot succeed. With the drain flag, the worker finishes what it has, writes its checkpoint and exits cleanly, and every declined block is picked up elsewhere.

Parameter & Option Reference

Setting Typical Spatial notes
Unit duration ≤ 45 s of a 2-minute notice Comfortably finishable inside the notice, with the checkpoint write included.
Notice poll interval 5 s Cheap, local, and the difference between reacting at 5 s and at 60 s.
SIGTERM handler required Kubernetes and most schedulers signal before killing; treat it exactly like a reclamation notice.
Flow retries 2 Recovers the declined blocks. With checkpoints each retry is nearly free.
Spot fraction of fleet 70–90% Keep a small on-demand core so a full reclamation event does not stall the run entirely.
Reduction and publish on-demand only One long sequential stage with no checkpointing is exactly the wrong thing to interrupt.
max_price on-demand rate Prevents a capacity crunch turning a discount into a premium.
Which stages may be interruptedInspect, plan and the wide tile stage run on spot capacity. The reduction and the publish step run on demand because each is a single long sequential operation.inspectspotplanspot412 tilesspot, checkpointedreduceon demandpublishon demandThe two red stages are perhaps four per cent of the compute and all of the risk: one is an hour-longsequential pass and the other is the commit point.Putting them on demand costs almost nothing and removes the case where a discount loses a night.
Spot capacity suits the wide stage precisely because it is the stage designed to lose a unit without consequence.

Verification & Testing

Reclamation is testable by simulating the notice, which is the only way to find out whether the handler works before it matters.

def test_draining_declines_new_work(monkeypatch, tmp_path) -> None:
    _draining.set()
    try:
        assert build_block.fn(BLOCK, run_id="r1", scratch=tmp_path) is None
    finally:
        _draining.clear()


def test_sigterm_sets_the_drain_flag() -> None:
    _draining.clear()
    os.kill(os.getpid(), signal.SIGTERM)
    time.sleep(0.1)
    assert _draining.is_set()


def test_in_flight_block_still_checkpoints(tmp_path, monkeypatch) -> None:
    def slow_warp(block, scratch):
        _draining.set()                      # notice arrives mid-block
        return write_block(block, scratch)

    monkeypatch.setattr("mymodule.warp_block", slow_warp)
    build_block.fn(BLOCK, run_id="r2", scratch=tmp_path)
    assert verified_checkpoint(block_key("r2", BLOCK, RECIPE), tmp_path) is not None


def test_reduce_is_not_tagged_for_spot() -> None:
    assert "tile" not in reduce.tags, "the reduction would be scheduled on spot capacity"

Running a real reclamation drill once is worth more than all four tests together. Most platforms offer a way to trigger an interruption deliberately, and doing it during a nightly run — once, deliberately, while somebody is watching — answers questions the unit tests cannot: whether the drain propagates, whether the flow retry actually recovers the declined blocks, and how long the run takes when a third of the fleet disappears. Teams that have never run the drill usually find one thing that does not work, and they find it at three in the morning otherwise.

Three reclamations, two outcomesWith checkpoints, three reclamations extend the run modestly and the discount holds. Without them, each reclamation restarts the work and the run misses its window.a night with three reclamationscheckpointed3 h 12 m, 72% savednot checkpointedreworkThe second row costs more than on-demand would have, because the discount is smaller than the rework.Spot without checkpointing is not a cheaper pipeline; it is a lottery whose expected value is negative.
The prerequisite is genuinely a prerequisite — this is one of the few optimisations that makes things worse when applied out of order.

One consequence of the drain flag is worth designing around rather than discovering: a worker that declines work but stays alive will keep declining until the platform terminates it, and during that window it still holds whatever concurrency slots the orchestrator believes it owns. On most platforms the gap is short — a couple of minutes — and harmless. On a scheduler that reuses the node rather than terminating it, or where the notice is retracted, a permanently draining worker becomes a slot that never frees. Exiting the worker process once the in-flight block has checkpointed avoids the whole question and costs nothing, because the fleet is going to replace the node anyway.

The other operational habit worth adopting is logging reclamations as a metric rather than as an incident. Each one is expected — that is the deal — so an alert per reclamation trains everyone to ignore the channel. What deserves an alert is the rate: a fleet that normally sees two reclamations a night and suddenly sees thirty is telling you that the instance family is under pressure, and the correct response is a configuration change rather than an investigation of the pipeline. That distinction is only available if the ordinary case was counted rather than paged on.

Common Pitfalls

  • Starting new work while draining. The block cannot finish, its slot is wasted, and the loss was avoidable.
  • Running the reduction on spot. One long sequential pass with no checkpointing is the worst possible interruptible workload.
  • Units longer than the notice. Anything that cannot finish in the notice period loses its work every single time.
  • Ignoring SIGTERM. Container schedulers signal before killing, and treating that as an ordinary crash discards a graceful exit you were offered.
  • A hundred per cent spot fleet. A capacity event can then stall the run entirely; a small on-demand core keeps it moving.
  • No max_price cap. Spot pricing can exceed on-demand during a crunch, which turns the whole exercise upside down.

Frequently Asked Questions

How much is actually saved?

Sixty to eighty per cent of instance price for the interruptible portion, less whatever rework the interruptions cause. With units well inside the notice period the rework is close to zero, which is what makes the discount close to the saving.

What about a pipeline with a hard deadline?

Reserve slack for it. A three-hour run in a six-hour window can absorb reclamation comfortably; a five-and-a-half-hour run in the same window cannot, and should stay on demand. The slack, not the discount, is what determines whether this is available.

Do diversified instance types help?

Considerably. Reclamation correlates within an instance type, so a pool spanning several families and availability zones is much less likely to lose everything at once. It is a configuration change with no downside for stateless tile work.

Does this work on Kubernetes?

Yes, and the mechanics are cleaner: node drain sends SIGTERM with a grace period you configure, so the notice and the handler are both first-class. Set terminationGracePeriodSeconds above the unit duration and the same drain logic applies unchanged.

What if reclamation becomes frequent?

Treat rising reclamation as a platform signal rather than a pipeline problem. Diversify the pool first; if it persists, the instance family is under pressure and moving a portion of the fleet to on-demand costs less than the rework.

Cost Optimization for Spatial Compute