Self-Hosting OSRM with Docker for Fleet-Scale Map Matching
When OSRM integration for fleet map matching moves from a prototype into a pipeline processing millions of daily fixes, the public demo server is off the table (its rate limits forbid bulk use) and a single unmanaged HTTP call per trip leaves the engine idle between requests. This page is the self-hosting recipe: the Docker command sequence that turns an OpenStreetMap extract into a served /match backend, and one copy-paste Python class that holds a bounded connection pool against that container and runs batched, retrying match calls. It extends the parent OSRM guide with the operational layer — dataset builds, memory sizing, and throughput — rather than repeating the request/response mechanics.
Compatibility and Configuration Requirements
| Component | Version / value | Notes |
|---|---|---|
| osrm-backend image | ghcr.io/project-osrm/osrm-backend:v5.27 or later |
Pin a tag; latest drifts across dataset-format changes |
| Docker Engine | 20.10+ | Bind-mount the data directory; no named volume needed for a one-shot build |
| Algorithm | MLD | Prepared with osrm-partition + osrm-customize; served with --algorithm mld |
| RAM (country extract) | 8 – 16 GB | ~2–3× the .osm.pbf size peak during osrm-extract |
| RAM (continent extract) | 64 GB+ | Partition/customize dominate; watch for OOM kills |
| Python | 3.10+ | httpx ≥ 0.27 for pooling and per-request timeouts |
max-matching-size |
100 (default) → raise as needed | Server-side cap on coordinates per /match request |
| Coordinate order | [longitude, latitude] |
Same convention as the parent guide |
Building and Serving the Dataset in Docker
These are shell commands, run once per data refresh. Put the .osm.pbf in a host directory and bind-mount it at /data. The three MLD stages must all complete before the server starts; osrm-routed memory-maps the resulting .osrm files.
# Region extract lives in ./osrm-data on the host, mounted at /data
export OSRM_IMG=ghcr.io/project-osrm/osrm-backend:v5.27
export DATA=$(pwd)/osrm-data
# 1. Extract with a vehicle profile (car.lua ships in the image)
docker run --rm -v "$DATA:/data" $OSRM_IMG \
osrm-extract -p /opt/car.lua /data/region-latest.osm.pbf
# 2. Partition — build the multi-level cell hierarchy (MLD)
docker run --rm -v "$DATA:/data" $OSRM_IMG \
osrm-partition /data/region-latest.osrm
# 3. Customize — compute cell edge weights (MLD)
docker run --rm -v "$DATA:/data" $OSRM_IMG \
osrm-customize /data/region-latest.osrm
# 4. Serve: MLD algorithm, raised matching cap, 4 worker threads
docker run --rm -d -p 5000:5000 -v "$DATA:/data" $OSRM_IMG \
osrm-routed --algorithm mld --max-matching-size 500 \
--threads 4 /data/region-latest.osrm
Note --threads 4: it sets how many requests osrm-routed serves in parallel, and it is the number the client pool below should size itself against. To re-customize edge weights (for example, applying live traffic) without a full rebuild, re-run only osrm-customize — that is the payoff of choosing MLD over CH.
The Self-Hosted Match Client
The class below is the main runnable artifact. It owns a bounded httpx connection pool, submits a batch of trips concurrently, retries transient failures with exponential backoff, and returns results keyed by trip id. Every tuning knob is a constructor argument with an inline comment explaining what raising or lowering it does.
import time
import random
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
import httpx
logger = logging.getLogger("osrm_pool")
@dataclass
class MatchResult:
trip_id: str
ok: bool
confidence: float # best matching confidence, 0.0 if failed
payload: dict # raw OSRM JSON (or {"code": ...} on failure)
class OSRMPool:
"""
Pooled, batched, retrying OSRM /match client for a self-hosted container.
Sizing rule: keep pool_size == osrm-routed --threads. More client
connections than server threads only queue inside the server and raise
tail latency without adding throughput.
"""
def __init__(
self,
base_url: str = "http://localhost:5000",
profile: str = "driving",
pool_size: int = 4, # match osrm-routed --threads; the ceiling
# on useful concurrency against this server
timeout_s: float = 15.0, # per-request timeout; raise for very long
# windows, lower to fail fast on a wedged server
max_retries: int = 3, # attempts per trip on transient errors;
# 0 disables retry, >5 rarely helps
backoff_base_s: float = 0.5, # first backoff delay; doubles each retry
max_coords: int = 500, # must be <= server --max-matching-size;
# longer trips should be windowed upstream
):
self.base_url = base_url.rstrip("/")
self.profile = profile
self.pool_size = pool_size
self.max_retries = max_retries
self.backoff_base_s = backoff_base_s
self.max_coords = max_coords
# One shared client; its pool bounds total in-flight connections.
self._client = httpx.Client(
timeout=timeout_s,
limits=httpx.Limits(
max_connections=pool_size,
max_keepalive_connections=pool_size,
),
)
# -- single trip, with retry ------------------------------------------
def _match_once(self, coords, timestamps, radiuses) -> dict:
pairs = ";".join(f"{lon:.6f},{lat:.6f}" for lon, lat in coords)
url = f"{self.base_url}/match/v1/{self.profile}/{pairs}"
params = {
"geometries": "polyline6",
"overview": "full",
"annotations": "true",
"gaps": "split", # fracture the trace at signal gaps
"tidy": "true", # pre-simplify dense noisy input
"radiuses": ";".join(str(r) for r in radiuses),
"timestamps": ";".join(str(int(t)) for t in timestamps),
}
resp = self._client.get(url, params=params)
resp.raise_for_status()
return resp.json()
def _match_with_retry(self, trip_id, coords, timestamps, radiuses) -> MatchResult:
if len(coords) > self.max_coords:
return MatchResult(trip_id, False, 0.0,
{"code": "TooBig", "n": len(coords)})
last_err = None
for attempt in range(self.max_retries + 1):
try:
data = self._match_once(coords, timestamps, radiuses)
if data.get("code") == "Ok":
best = max((m.get("confidence", 0.0)
for m in data["matchings"]), default=0.0)
return MatchResult(trip_id, True, best, data)
# A well-formed non-Ok (NoMatch, NoSegment) is not retryable.
return MatchResult(trip_id, False, 0.0, data)
except (httpx.TransportError, httpx.HTTPStatusError) as exc:
last_err = exc
if attempt < self.max_retries:
# Exponential backoff with jitter to avoid a retry stampede.
delay = self.backoff_base_s * (2 ** attempt)
delay += random.uniform(0, self.backoff_base_s)
logger.warning("trip %s attempt %d failed: %s; retry in %.2fs",
trip_id, attempt + 1, exc, delay)
time.sleep(delay)
return MatchResult(trip_id, False, 0.0, {"code": "TransportError",
"error": str(last_err)})
# -- batch of trips ---------------------------------------------------
def match_batch(self, trips: dict) -> dict:
"""
trips: {trip_id: (coords, timestamps, radiuses)} where coords is a list
of (lon, lat) tuples. Returns {trip_id: MatchResult}.
Concurrency is bounded by pool_size so the server is saturated but not
overwhelmed; results are collected as each future completes.
"""
results: dict = {}
with ThreadPoolExecutor(max_workers=self.pool_size) as pool:
futures = {
pool.submit(self._match_with_retry, tid, c, t, r): tid
for tid, (c, t, r) in trips.items()
}
for fut in as_completed(futures):
res = fut.result()
results[res.trip_id] = res
return results
def close(self):
self._client.close()
# -------------------------------------------------------------------------
# Usage
# -------------------------------------------------------------------------
# pool = OSRMPool(base_url="http://localhost:5000", pool_size=4)
# batch = {
# "veh1_trip1": (
# [(13.388, 52.517), (13.397, 52.529), (13.428, 52.523)], # lon,lat
# [1700000000, 1700000012, 1700000024], # epoch s
# [10, 10, 12], # radiuses m
# ),
# }
# results = pool.match_batch(batch)
# for tid, r in results.items():
# print(tid, r.ok, round(r.confidence, 3))
# pool.close()
Tuning Knobs
pool_size— the number of concurrent connections, and the single most important throughput knob. Set it equal toosrm-routed --threads. Raising it above the server’s thread count adds queueing latency, not throughput; lowering it below leaves server threads idle. Scale both together, and add moreosrm-routedreplicas behind a load balancer once one container’s threads saturate its CPU.max_coords— must stay at or below the server’s--max-matching-size. It is a client-side guardrail that turns an over-long trip into a fast, explicitTooBigresult instead of a server round-trip. Window long trips (see the parent guide) before they reach the pool.timeout_s— the per-request ceiling. Large windows on a busy server need a higher value; a low value fails fast when a container is wedged. Set it above your P99 match latency plus headroom.max_retries/backoff_base_s— retries cover transient transport errors (a restarting container, a dropped keep-alive), never a well-formedNoMatchorNoSegment, which the client returns immediately. The jittered exponential backoff prevents a synchronized retry stampede after a brief outage. Three retries with a 0.5 s base absorbs a few seconds of unavailability; more than five rarely helps.--max-matching-size(server) — raising it lets longer windows through at the cost of more memory and CPU per request. Keep it aligned with the clientmax_coords.
Common Pitfalls
OOM during a planet or continent extract
osrm-extract and osrm-partition hold large working sets. On a continent or planet extract they can peak well past the .osm.pbf size and the Docker daemon (or the kernel OOM killer) terminates the container mid-build, often leaving partial .osrm files that fail silently at serve time. Budget 2–3× the extract size as RAM, build the smallest regional extract that covers your fleet’s operating area rather than the planet, and delete stale partial artifacts before re-running. For genuinely large regions, build on a temporary high-memory host and ship only the finished .osrm files to the serving host.
Algorithm mismatch: CH dataset served as MLD (or vice versa)
If you prepared the dataset with osrm-contract (Contraction Hierarchies) but launch osrm-routed --algorithm mld, the server refuses to start or errors on the first request; the reverse also fails. The dataset build and the serve flag must agree. For map matching, standardize on MLD end to end: osrm-partition then osrm-customize, served with --algorithm mld. Re-running only osrm-customize refreshes weights without a full rebuild — a CH dataset cannot do this, which is why MLD is preferred for fleet workloads.
Coordinate limit hit despite windowing
A TooBig result after you thought you had windowed usually means the window overlap pushed a chunk one coordinate over max-matching-size, or a de-duplication step ran after windowing and the counts drifted. Enforce max_coords on the client (as above) so the failure is explicit and cheap, size windows to max-matching-size minus the overlap, and de-duplicate the trace before windowing, not after. Confirm the server’s actual cap with a probe request rather than assuming the default of 100.
Up: OSRM Integration for Fleet Map Matching | Routing Engine Integration for Fleet Telematics
Related
- OSRM Integration for Fleet Map Matching — the request/response mechanics, radiuses tuning, and polyline6 decoding this backend serves
- Choosing a Routing Engine: OSRM vs Valhalla vs GraphHopper — whether to self-host OSRM at all versus the alternatives
- Building an HMM-based Map Matcher with OSRM and Python — driving a self-hosted OSRM as a routing oracle from a custom decoder
- Routing Engine Integration for Fleet Telematics — where a self-hosted matching backend sits in the wider integration picture