Skip to content

Real-Time AIS Vessel Tracking Pipeline on Serverless

A production AIS vessel tracking pipeline ingests raw NMEA !AIVDM sentences into a streaming primitive — Kinesis Data Streams on AWS or Pub/Sub on GCP — then fans them out to Lambda or Cloud Function consumers that decode positions, run point-in-polygon zone checks with shapely, and persist geohashed points to a spatial store. The core design rule is: partition every record by vessel MMSI so per-vessel ordering is preserved end to end, and process each shard with a checkpointed consumer that can sustain 1,000+ position reports per second at sub-second latency. This recipe belongs to the broader family of event-driven geospatial processing patterns, applied here to an unbounded, latency-sensitive stream rather than a batch of files.

Why Streaming AIS Tracking Matters for Geospatial Workloads

AIS (Automatic Identification System) transponders broadcast a vessel’s position, course, speed over ground (SOG), and identity several times per minute. A regional coastal feed easily produces several thousand position reports per second, and a global satellite-AIS feed produces far more. This is a fundamentally different workload from processing a dropped shapefile or a nightly raster batch: the data never stops, ordering matters per vessel, and a stalled consumer means the live map goes stale within seconds.

The decision between streaming and batch is the first architectural fork. If your product answers “where is this vessel right now” or “which vessels just entered this exclusion zone,” you need a streaming path. If it answers “what was the average traffic density last quarter,” a batch path over object storage is cheaper. The tradeoffs are laid out in detail in batch vs stream geospatial processing, and the specific case for AIS — where a few seconds of latency separates a useful collision-avoidance alert from a useless one — is covered in when to use batch vs streaming for real-time AIS tracking.

The geospatial work inside each consumer is deceptively light per message but heavy in aggregate. Decoding an AIVDM payload is a bit-unpacking exercise; the expensive part is the point-in-polygon test against zone geometries (ports, traffic separation schemes, marine protected areas). Loading those polygons and building a spatial index at cold start is where the cold start behavior of Python GDAL and shapely directly affects your per-shard throughput ceiling.

The diagram below shows the full data flow from transponder to spatial store:

Path of a single AIS position report through the pipelineSequence diagram with four participants — the AIS producer, one Kinesis shard, the consumer Lambda and DynamoDB. The producer puts the raw sentence on the stream keyed by MMSI, the shard delivers a batch of up to 10,000 records, the consumer calls its own STRtree zone check, writes the position keyed by MMSI and geohash, and finally reports only the failed sequence numbers back to the shard.One AIS sentence, transponder feed to spatial storeAIS producerKinesis shardConsumer LambdaDynamoDBput_recordPartitionKey = MMSIGetRecordsbatch of up to 10,000 recordszones_for(lon, lat)STRtree built once at cold startput_itemmmsi partition key, geohash sortkeybatchItemFailuresonly the failed sequencenumbers
Per-vessel ordering survives end to end only because the producer keys by MMSI: every report for one vessel lands on one shard and one consumer. The checkpoint advances for every record except the sequence numbers returned in batchItemFailures.

Platform-by-Platform Limits

The streaming primitive and its consumer runtime are chosen together. These are the constraints that decide how many vessels and messages per second a single deployment can sustain.

Constraint AWS (Kinesis + Lambda) GCP (Pub/Sub + Cloud Functions 2nd gen) Azure (Event Hubs + Functions Consumption)
Streaming primitive Kinesis Data Streams Pub/Sub (with ordering keys) Event Hubs (Kafka-compatible)
Ordering guarantee Per shard (partition key = MMSI) Per ordering key Per partition
Throughput unit 1 MB/s or 1,000 records/s per shard ~unlimited, autoscaled 1 MB/s per throughput unit
Consumer invocation model Event source mapping, batch of up to 10,000 records Push subscription or triggered function Event Hubs trigger, batched
Consumer max timeout 15 min 60 min 10 min
Consumer max memory 10 GB (10,240 MB) 32 GB 1.5 GB
Ephemeral /tmp 10 GB 32 GB ~1.5 GB
Default concurrency 1,000 (per region, soft) 3,000 ~200
Partial-batch retry ReportBatchItemFailures ack/nack per message checkpoint per partition
Native spatial store DynamoDB (geohash), Aurora, BigQuery via Firehose BigQuery GIS, Firestore Cosmos DB, Azure SQL geography

Kinesis binds consumer parallelism directly to shard count: one shard maps to at most one concurrent Lambda per consumer (with standard iterators), so you scale by resharding. Pub/Sub decouples this — it autoscales consumers independently of any partition count, but you must set an ordering key to get the per-MMSI guarantee Kinesis gives for free through the partition key. Azure Functions on the Consumption plan is the most constrained target here; its 10-minute timeout and 1.5 GB memory ceiling make it suitable for lightweight decode-and-forward consumers but not for heavy in-function spatial joins.

Streaming primitive and consumer runtime on AWS, GCP and AzureThree panels comparing the AWS Kinesis and Lambda pairing, the GCP Pub/Sub and Cloud Functions pairing, and the Azure Event Hubs and Functions Consumption pairing, across how consumer parallelism is obtained, how ordering is guaranteed, and the timeout and memory ceilings of each consumer runtime.How each platform hands you parallelism and orderingAWS — Kinesis + LambdaOne shard serves at most oneconcurrent consumerOrdering is free: PartitionKey =MMSIConsumer ceiling 15 min, 10,240MBScale by splitting hot shardsGCP — Pub/Sub + CloudFunctionsAutoscales consumers with no shardmathOrdering only with an explicitordering_keyConsumer ceiling 60 min, 32,768MBCost follows message count, notprovisioned capacityAzure — Event Hubs +FunctionsCheckpoint per partitionOrdering per partitionConsumer ceiling 10 min, 1,536 MBDecode-and-forward only, notin-function spatial joinsAzure Consumption's 1,536 MB is a tenth of the Lambda ceiling — enough to decode AIVDM, not enough to hold a nationalzone-polygon index in memory.
Kinesis ties consumer parallelism to shard count, so a hot shard is fixed by resharding rather than by raising concurrency. Pub/Sub decouples the two but only orders when you ask it to.

Step-by-Step Implementation

Step 1: Ingest AIS NMEA Sentences with a Partition Key of MMSI

The producer reads AIS off a TCP feed, extracts the MMSI from each sentence, and puts the raw payload onto the stream keyed by MMSI. Keying by MMSI is the single most important decision in the pipeline — it is what keeps a vessel’s reports ordered.

python
# producer.py — forward raw AIS sentences onto Kinesis, keyed by MMSI
import boto3
from pyais import decode  # lightweight AIVDM decoder

kinesis = boto3.client("kinesis")
STREAM = "ais-positions"

def forward(nmea_line: str) -> None:
    # Decode only far enough to read the MMSI for partitioning.
    # Full decoding happens in the consumer to keep the producer thin.
    try:
        msg = decode(nmea_line)
    except Exception:
        return  # skip fragments we cannot key; multipart handled upstream

    mmsi = str(msg.mmsi)
    kinesis.put_record(
        StreamName=STREAM,
        Data=nmea_line.encode("utf-8"),
        PartitionKey=mmsi,  # all reports for one vessel land on one shard
    )

On GCP, the equivalent uses the Pub/Sub publisher with enable_message_ordering=True and an ordering_key=mmsi. The routing tradeoffs between a single stream and per-priority streams are discussed in SQS and Pub/Sub queue routing strategies.

Step 2: Decode AIVDM Payloads in the Consumer

The consumer receives a batch of records, decodes each AIVDM payload into a structured position, and skips non-positional message types. Wrap every record so one malformed sentence cannot fail the batch.

python
# consumer.py — Lambda handler bound to the Kinesis stream
import base64
import json
from pyais import decode

# Position report message types carry lat/lon/SOG/COG
POSITION_TYPES = {1, 2, 3, 18, 19}

def parse_records(event):
    for rec in event["Records"]:
        seq = rec["kinesis"]["sequenceNumber"]
        raw = base64.b64decode(rec["kinesis"]["data"]).decode("utf-8")
        try:
            msg = decode(raw)
            if msg.msg_type not in POSITION_TYPES:
                continue
            yield seq, {
                "mmsi": msg.mmsi,
                "lat": float(msg.lat),
                "lon": float(msg.lon),
                "sog": float(getattr(msg, "speed", 0.0)),  # knots
                "cog": float(getattr(msg, "course", 0.0)),
                "ts": rec["kinesis"]["approximateArrivalTimestamp"],
            }
        except Exception as exc:
            # Surface the sequence number so we can report it as failed
            yield seq, {"_error": str(exc), "_raw": raw}

Step 3: Run Point-in-Polygon Zone Checks with shapely

Load your zone polygons once at module scope (cold start) and index them with a shapely.STRtree so each position is a fast bounding-box lookup followed by an exact contains test. Building the index per invocation would dominate latency.

python
# zones.py — module-scope so it survives across warm invocations
import json
from shapely.geometry import shape, Point
from shapely import STRtree

with open("zones.geojson") as f:
    _features = json.load(f)["features"]

_polys = [shape(f["geometry"]) for f in _features]
_names = [f["properties"]["name"] for f in _features]
_tree = STRtree(_polys)  # built once at cold start

def zones_for(lon: float, lat: float) -> list[str]:
    p = Point(lon, lat)
    hits = []
    # STRtree returns candidate indices by bounding box; confirm with contains
    for idx in _tree.query(p):
        if _polys[idx].contains(p):
            hits.append(_names[idx])
    return hits

Keeping the polygon set and index in module scope is the same warm-reuse pattern that makes provisioned concurrency effective; see reducing Python GDAL cold starts with provisioned concurrency for pinning consumers warm during peak traffic.

Step 4: Write Geohashed Positions to the Spatial Store

Encode each position as a geohash and write it to DynamoDB with the MMSI as partition key. The geohash sort key enables both “latest position per vessel” reads and prefix-scan proximity queries.

python
# store.py — write latest position keyed by MMSI + geohash
import boto3
import pygeohash as pgh

_table = boto3.resource("dynamodb").Table("vessel_positions")

def write_position(pos: dict, zones: list[str]) -> None:
    gh = pgh.encode(pos["lat"], pos["lon"], precision=7)  # ~150 m cells
    _table.put_item(Item={
        "mmsi": pos["mmsi"],           # partition key
        "geohash": gh,                 # sort key
        "lat": str(pos["lat"]),
        "lon": str(pos["lon"]),
        "sog": str(pos["sog"]),
        "zones": zones,
        "ts": int(pos["ts"]),
    })

For the analytical layer, stream the same records to BigQuery GIS via a Firehose-equivalent or a direct insert_rows_json, storing geometry as ST_GEOGPOINT(lon, lat) so you can later run ST_DWithin and ST_Intersects joins across historical tracks. Contrast this discrete write path with the file-batched approach in chunked I/O for large satellite imagery — AIS writes are tiny and frequent, so the store, not the payload, is the bottleneck.

Step 5: Report Partial Failures and Route Poison Records

Assemble the handler so successfully processed sequence numbers are checkpointed and only genuinely failed records are re-delivered or dead-lettered.

python
# handler.py — glue with partial-batch failure reporting
from consumer import parse_records
from zones import zones_for
from store import write_position

def handler(event, context):
    failures = []
    for seq, pos in parse_records(event):
        if "_error" in pos:
            failures.append({"itemIdentifier": seq})  # retry / DLQ this one
            continue
        try:
            hits = zones_for(pos["lon"], pos["lat"])
            write_position(pos, hits)
        except Exception:
            failures.append({"itemIdentifier": seq})
    # Kinesis re-delivers only these sequence numbers, not the whole batch
    return {"batchItemFailures": failures}

Configure the event source mapping with FunctionResponseTypes=["ReportBatchItemFailures"], a bisect-on-error setting, and a maximum retry count, after which records spill to a dead-letter queue. The full DLQ design — redrive policy, poison detection, and replay — is covered in implementing dead-letter queues for failed vector jobs.

Lifecycle of an AIS record under ReportBatchItemFailuresState machine with four states: a record sitting in the shard, a record decoded and stored, a record reported as failed by sequence number, and a record dead-lettered. Delivery moves a record from the shard to storage; a decode or write error moves it to the reported-failed state, from which it either succeeds on redelivery or is dead-lettered once the retry count is exhausted.What partial-batch reporting does to one recordIn the shardsequence number, keyed byMMSIDecoded and storedzone check, then put_itemReported faileditemIdentifier inbatchItemFailuresDead-letteredpast the maximum retry countGetRecordsdecode or write errorretries exhaustedsucceeds on redeliveryBisect-on-error narrows a poisoned batch until the offending record is isolated, so the surrounding positions still checkpoint.
Without this state machine there are only two states — the whole batch succeeds or the whole batch is retried forever — and one malformed sentence blocks the shard iterator behind it.

Measurement and Verification

Validate ordering and latency before going live. Emit the arrival-to-store latency per record and confirm per-MMSI monotonic ordering with a synthetic replay.

python
# verify_latency.py — replay a captured feed and measure end-to-end latency
import time, boto3

logs = boto3.client("logs")
# Query CloudWatch for the custom EMF metric emitted by the handler
resp = logs.start_query(
    logGroupName="/aws/lambda/ais-consumer",
    startTime=int(time.time()) - 900,
    endTime=int(time.time()),
    queryString="fields storeLatencyMs | stats avg(storeLatencyMs), pct(storeLatencyMs, 99)",
)

Expected steady-state numbers for a single-shard consumer at ~800 records/s on a 1,024 MB Lambda:

json
{
  "avg_store_latency_ms": 42,
  "p99_store_latency_ms": 180,
  "iterator_age_ms": 250,
  "records_per_second": 800
}

The most important health metric is GetRecords.IteratorAgeMilliseconds (Kinesis) or subscription backlog (Pub/Sub). A steadily rising iterator age means the consumer cannot keep up and you must add shards or raise consumer memory. An iterator age near zero with p99 latency under a few hundred milliseconds is a healthy live pipeline.

Steady-state usage against the per-shard and per-function ceilingsThree meters for a healthy single-shard AIS consumer: 800 records per second against the 1,000 records per second shard ceiling, 1,024 MB of configured memory against the 10,240 MB Lambda ceiling, and a 180 millisecond p99 store latency against a few-hundred-millisecond health target.A single-shard consumer at steady stateRecords per second1,000/s ceiling on one shard800 rec/sConsumer memory1,024 MB of a 10,240 MB max1,024 MBp99 store latencyhealthy is a few hundred ms180 msAn iterator age near zero alongside these numbers is the signature of a consumer keeping pace; a steadily climbing iterator age is the only oneof the four that means the design, not the tuning, is wrong.
The shard, not the account, is the wall. At 800 records per second there is 20% of headroom left on the ingest side while the function still sits at a tenth of its memory ceiling — so the fix for a rising iterator age is another shard, never more concurrency.

Failure Modes and Debugging

Iterator age climbing without bound: The consumer is slower than the ingest rate. Each Kinesis shard is served by one concurrent Lambda, so a single overloaded shard cannot be helped by raising account concurrency — you must reshard (split hot shards) or move the expensive zone check to a lighter algorithm. Confirm with the IteratorAgeMilliseconds metric per shard.

All reports for one vessel arriving out of order: The producer used a non-MMSI partition key (a timestamp or random value), scattering one vessel across shards. Fix the PartitionKey to the MMSI and, on Pub/Sub, set ordering_key plus enable_message_ordering.

One malformed sentence stalls a shard forever: Without ReportBatchItemFailures, a record that always throws causes Kinesis to retry the whole batch until it expires, blocking the shard. Enable partial-batch responses and set a maximum record age / retry count with a DLQ target.

Zone checks slow and CPU-bound: The STRtree is being rebuilt every invocation instead of at module scope, or the polygon set is enormous. Move index construction to import time, simplify polygons with shapely.simplify, and consider pre-filtering by geohash cell before the exact contains test.

Multipart AIVDM sentences dropped: Type 5 and long messages span multiple NMEA fragments. If the producer keys fragments independently they can split across shards and never reassemble. Reassemble multipart sentences in the producer before keying, or key all fragments of a group by MMSI.

Cost and Scaling Considerations

Kinesis bills per shard-hour plus per million PUT payload units, so cost scales with provisioned shard count rather than actual message volume — right-size shards to your peak records-per-second and use on-demand mode if traffic is spiky. Lambda cost is dominated by invocation count and duration; batching up to several hundred records per invocation amortizes the per-invocation overhead and keeps the per-message cost in the microdollar range. Because the zone-check CPU work scales with memory, the memory and CPU allocation model for raster workloads applies directly: more memory buys more vCPU, which shortens the shapely pass and can lower total cost by processing batches faster.

Pub/Sub’s autoscaling model removes shard math but shifts cost to per-message delivery and to the consumers Cloud Run or Cloud Functions spins up — watch concurrent instance count during traffic spikes. For the analytical layer, BigQuery streaming inserts are billed per byte ingested; if you do not need per-second freshness in the warehouse, micro-batch positions into files and load them, which is cheaper. The windowed aggregation of AIS positions with Kinesis recipe shows how to collapse thousands of raw reports into per-cell summaries, cutting downstream storage and query cost by one to two orders of magnitude.

Frequently Asked Questions

Why use Kinesis or Pub/Sub instead of an S3 event trigger for AIS data?

AIS feeds are continuous, unbounded, and ordered per vessel — thousands of position reports per second over TCP. A streaming primitive like Kinesis Data Streams or Pub/Sub preserves per-shard ordering, buffers bursts, and lets many consumers process shards in parallel with checkpointing. An S3 event trigger only fires on discrete object writes, which forces you to batch AIS into files first and adds minutes of latency — fine for backfill, wrong for live tracking.

How do I keep positions for the same vessel in order?

Use the vessel MMSI as the partition key when you put records onto the stream. Kinesis and Pub/Sub ordering keys guarantee that all records for one MMSI land on the same shard and are delivered in arrival order. Never use a random or timestamp partition key for AIS, or a single vessel’s reports scatter across shards and arrive out of order.

What spatial store should I use for live vessel positions?

For low-latency lookups of the latest position per vessel, DynamoDB with an MMSI partition key and a geohash sort key gives single-digit-millisecond reads and proximity scans by geohash prefix. For historical analytics and heavy spatial joins across millions of positions, stream into BigQuery GIS and query with ST_ functions. Many pipelines write to both: DynamoDB for the live layer, BigQuery for analytics.

How do I handle a Lambda that fails to decode a malformed AIS message?

Do not let one bad NMEA sentence poison the whole batch. Wrap per-record decoding in a try/except and use ReportBatchItemFailures so Kinesis re-delivers only the specific failed sequence numbers rather than the entire batch. Route records that fail repeatedly to a dead-letter queue for offline inspection instead of blocking the shard iterator.


Related

Back to Serverless Geospatial Pipeline Recipes

Guides in this topic