Running OSM Diff Sync in Docker with a Persistent State Volume Jump to heading
Containerise a minutely update loop so a container restart costs a minute, not a full reimport — and so two containers can never apply diffs to the same file at once.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Containers are ephemeral by design and a diff-sync loop is defined by its state. Reconciling those is a matter of being explicit about which is which.
The checkpoint is the piece that matters. The working extract is expensive to lose and can be re-downloaded; the checkpoint cannot be reconstructed from anything, and losing it means either a rebuild from a fresh extract or, worse, a guess that silently skips or replays diffs — the failure the sequence discipline in Replication Sequence Numbers & State Tracking exists to prevent.
The lock deserves the same care as the checkpoint, and for the same reason. flock on a path inside the container’s own filesystem is invisible to any other container, so two replicas each take “the lock” successfully and both apply diffs to the same file.
Runnable solution Jump to heading
# Dockerfile
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
osmium-tool python3 python3-pyosmium ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# Run as a fixed non-root uid so the volume's ownership is predictable across hosts.
RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin osm
WORKDIR /app
COPY --chown=osm:osm sync.py /app/sync.py
USER osm
ENV STATE_DIR=/state \
REPL_BASE=https://planet.osm.org/replication/minute/ \
PYTHONUNBUFFERED=1
# stdout only: let the container runtime collect and rotate the logs.
ENTRYPOINT ["python3", "/app/sync.py"]
# compose.yaml
services:
osm-sync:
build: .
restart: unless-stopped
volumes:
- osm-state:/state # the extract, the checkpoint and the lock
environment:
EXTRACT: /state/ireland.osm.pbf
SLEEP_SECONDS: "60"
healthcheck:
# Unhealthy once the checkpoint stops advancing, not merely when the
# process dies — a wedged loop keeps its process alive.
test: ["CMD", "python3", "/app/sync.py", "--healthcheck"]
interval: 120s
timeout: 10s
retries: 3
start_period: 300s
stop_grace_period: 120s # let an in-flight apply finish rather than be killed
deploy:
resources:
limits: { memory: 4G }
volumes:
osm-state:
#!/usr/bin/env python3
"""Containerised OSM diff-sync. All durable state lives under $STATE_DIR."""
from __future__ import annotations
import fcntl
import json
import logging
import os
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
logging.basicConfig(level=logging.INFO, stream=sys.stdout,
format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("osm-sync")
STATE_DIR = Path(os.environ.get("STATE_DIR", "/state"))
EXTRACT = Path(os.environ.get("EXTRACT", str(STATE_DIR / "data.osm.pbf")))
CHECKPOINT = STATE_DIR / "checkpoint.json"
LOCK_PATH = STATE_DIR / "sync.lock" # on the volume, not in the container
SLEEP_SECONDS = int(os.environ.get("SLEEP_SECONDS", "60"))
STALE_AFTER = int(os.environ.get("STALE_AFTER", "900"))
_shutdown = False
def _request_shutdown(signum, _frame) -> None:
"""SIGTERM sets a flag; the loop finishes the current iteration and exits."""
global _shutdown
logger.info("signal %d received — finishing the current iteration", signum)
_shutdown = True
@dataclass(frozen=True)
class Checkpoint:
sequence: int
updated_at: float
@classmethod
def read(cls) -> "Checkpoint | None":
if not CHECKPOINT.exists():
return None
data = json.loads(CHECKPOINT.read_text())
return cls(sequence=int(data["sequence"]), updated_at=float(data["updated_at"]))
def write(self) -> None:
"""Temp file plus atomic rename, both on the volume, then fsync the dir."""
tmp = CHECKPOINT.with_suffix(".tmp")
tmp.write_text(json.dumps({"sequence": self.sequence,
"updated_at": self.updated_at}))
with tmp.open("rb") as handle:
os.fsync(handle.fileno())
tmp.replace(CHECKPOINT)
dir_fd = os.open(STATE_DIR, os.O_RDONLY)
try:
os.fsync(dir_fd) # the rename itself must reach the disk
finally:
os.close(dir_fd)
def healthcheck() -> int:
"""Healthy only while the checkpoint is advancing."""
checkpoint = Checkpoint.read()
if checkpoint is None:
logger.warning("no checkpoint yet")
return 1
age = time.time() - checkpoint.updated_at
if age > STALE_AFTER:
logger.error("checkpoint is %.0f s old (limit %d s)", age, STALE_AFTER)
return 1
logger.info("healthy: sequence %d, %.0f s old", checkpoint.sequence, age)
return 0
def apply_next(sequence: int) -> bool:
"""Apply one diff. Returns False when already current."""
result = subprocess.run(
["pyosmium-up-to-date", "--verbose", "--size", "100",
"--server", os.environ["REPL_BASE"], str(EXTRACT)],
capture_output=True, text=True)
if result.returncode not in (0, 3): # 3 = already up to date
logger.error("apply failed (%d): %s", result.returncode, result.stderr.strip())
raise RuntimeError("apply-changes failed")
return result.returncode == 0
def iteration() -> None:
checkpoint = Checkpoint.read()
sequence = checkpoint.sequence if checkpoint else 0
if apply_next(sequence):
# Work first, checkpoint second — a crash between them replays, never skips.
Checkpoint(sequence=sequence + 1, updated_at=time.time()).write()
logger.info("applied; checkpoint now %d", sequence + 1)
else:
Checkpoint(sequence=sequence, updated_at=time.time()).write()
logger.info("already current at %d", sequence)
def main() -> int:
if "--healthcheck" in sys.argv:
return healthcheck()
STATE_DIR.mkdir(parents=True, exist_ok=True)
signal.signal(signal.SIGTERM, _request_shutdown)
signal.signal(signal.SIGINT, _request_shutdown)
with LOCK_PATH.open("w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
logger.error("another container holds the state lock — exiting")
return 1
logger.info("lock acquired; syncing %s", EXTRACT)
while not _shutdown:
started = time.monotonic()
try:
iteration()
except Exception:
logger.exception("iteration failed; retrying after the interval")
elapsed = time.monotonic() - started
for _ in range(int(max(0.0, SLEEP_SECONDS - elapsed))):
if _shutdown:
break
time.sleep(1) # 1 s granularity so SIGTERM is responsive
logger.info("shut down cleanly")
return 0
if __name__ == "__main__":
sys.exit(main())
Step-by-step walkthrough Jump to heading
The lock file lives at /state/sync.lock, on the volume. That is the single most important line in the file: a lock on the volume is shared by every container that mounts it, which is precisely the set of processes that could collide. flock also releases automatically when the process dies, so a hard kill does not leave a stale lock behind.
Checkpoint.write does temp file, fsync, rename, then fsync of the directory. The directory fsync is the step usually missed — without it the rename can be lost in a power failure even though the data survived, leaving the checkpoint at its previous value and replaying one diff. That is the safe direction, which is why the ordering also puts the apply first.
The USER with a fixed uid matters more in containers than elsewhere. A volume created by one image and mounted by another with a different uid gives permission errors that look like corruption; pinning 10001 makes the ownership predictable across rebuilds and hosts.
The healthcheck asserts that the checkpoint is advancing, not that the process is alive. A loop wedged on a socket read keeps its process, its port and its liveness, and only the checkpoint’s age reveals it — the same distinction drawn in Replication Monitoring & Lag Alerting.
stop_grace_period: 120s with a SIGTERM flag lets an in-flight apply finish. The default ten seconds is far too short for a diff apply on a country extract, and a SIGKILL partway through leaves a partially rewritten file.
The sleep is a one-second loop rather than one long time.sleep, so a shutdown signal is acted on within a second instead of up to a minute.
Verification Jump to heading
Prove the state survives the container, which is the whole point:
docker compose up -d && sleep 180
docker compose exec osm-sync cat /state/checkpoint.json
docker compose down && docker compose up -d # container replaced
docker compose logs --tail 5 osm-sync # expect it to resume, not restart
The second checkpoint read must show a sequence at or above the first. A sequence of zero means the volume is not mounted where the code expects.
Prove the lock actually excludes a second container:
docker compose up -d
docker compose run --rm osm-sync # a second one, same volume
# expect: "another container holds the state lock — exiting", exit 1
If the second container starts syncing, the lock is on a container-local path.
Prove graceful shutdown does not truncate an apply:
docker compose stop -t 120 osm-sync
docker compose logs --tail 3 osm-sync # expect "shut down cleanly"
osmium fileinfo /var/lib/docker/volumes/.../ireland.osm.pbf # must still parse
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
| Full reimport after every restart | Checkpoint written inside the container | Put it on the mounted volume |
| Two containers applying diffs together | Lock on a container-local path | flock a file on the volume |
| Permission denied on the volume | uid differs between image builds | Pin a fixed uid in the Dockerfile |
| Healthy container, stale data | Healthcheck only tests liveness | Assert the checkpoint’s age |
| Corrupt PBF after a deploy | SIGKILL mid-apply |
Raise stop_grace_period; handle SIGTERM |
| Container OOM-killed | No memory limit, or too low for the node cache | Set a limit above the cache size |
| Restart storm | Fail-fast script with restart: always |
Add a backoff, or run a long-lived loop |
Frequently Asked Questions Jump to heading
Named volume or bind mount?
A named volume for anything managed by the container runtime, because it handles ownership and lifecycle. A bind mount when the extract must be readable by processes outside the container — a tile server, a PostGIS import — which is common enough that bind mounts are the pragmatic default in a mixed deployment. The locking and checkpoint discipline is identical either way.
Should the loop be inside the container or a CronJob?
If you already run Kubernetes, a CronJob with concurrencyPolicy: Forbid gives overlap prevention from the scheduler and one fewer thing to get right. On a single host, an in-container loop with a flock is simpler and has fewer moving parts than adding a scheduler. Both are correct; what must not happen is two mechanisms both thinking they own the cadence.
Can several containers sync different regions on one volume?
Yes, with a lock file per region rather than one global lock. Each container locks /state/<region>.lock and touches only its own extract and checkpoint. What must not be shared is the extract: two containers applying diffs to one file corrupt it regardless of how careful each one is on its own.
How do I back this up?
Snapshot the volume with the container stopped, or at minimum while the lock is held by your backup process rather than by the sync. Copying the extract and the checkpoint at different moments produces a backup whose checkpoint does not describe its data, which restores as a silently wrong state — the checkpoint-ahead-of-data case that Recovering from a Replication Sequence Gap shows cannot be repaired by replay.
Specification reference Jump to heading
flock(2)advisory locks are associated with the open file description and are released automatically when the last descriptor is closed, including on process death. Locks are visible to any process that opens the same file through the same filesystem, which for containers means the lock file must live on a shared mount rather than inside a container’s own writable layer.
Related Jump to heading
- Building a Minutely Update Pipeline — the topic this deployment packages.
- Scheduling OSM Diff Sync with systemd Timers — the non-container equivalent, with the same lock problem.
- Replication Sequence Numbers & State Tracking — the checkpoint the volume exists to preserve.
- Replication Monitoring & Lag Alerting — why the healthcheck tests progress rather than liveness.
- Resuming an Interrupted OSM Import — the same commit ordering, for batch jobs.
Up one level: Building a Minutely Update Pipeline.