Chasing 10 milliseconds: time sync done properly on a Raspberry Pi 5

Video 01 · published [DATE] · tools: [REPO LINK — time_sync_monitoring]

A system I work with professionally has a hard requirement: its clock must stay within ten milliseconds of true time, and that has to be provable. This write-up takes the same standard to a £75 Raspberry Pi 5, from a fresh SD card: measure the stock setup, replace it with chrony, feed chrony measured servers instead of a pool lottery ticket, and pin the winners at boot. Every step is graphed against the ±10 ms band, and all the raw logs are linked at the bottom.

TL;DR — stock Pi OS: median offset [MEASURED]; chrony out of the box: [MEASURED]; chrony with measured, pinned sources: [MEASURED]. And the same machine on WiFi missed the band on [MEASURED]% of samples — one ethernet cable fixed more than every config change combined.

Measuring without installing anything

chrony already contains the instrument. Two config lines make it journal every exchange with every server — computed offset, round-trip time, applied frequency correction:

# /etc/chrony.conf
log measurements statistics tracking
logdir /var/log/chrony

While measuring, pin the polling to 16–64 s (chrony's default backs off to ~17 minutes once it is happy — fine for syncing, useless for watching): add minpoll 4 maxpoll 6 to the server lines. plot_chrony.py from the toolkit turns the logs into the charts below, with the target band drawn on.

Honesty clause: most of these numbers are the clock reporting on itself. Each run is cross-checked against servers that are not steering the clock; true ground truth needs independent hardware, which is the next video (a £15 GPS with a pulse-per-second output).

Rung 0 — stock Raspberry Pi OS, measured

Fresh image syncs with systemd-timesyncd: an SNTP client that periodically asks one server and nudges the clock; between check-ins you ride the bare crystal. Over [N h]: median [MEASURED], worst [MEASURED], [MEASURED]% of samples outside the band.

[CHART: rung 0 offset vs time, ±10 ms band]

Rung 1 — chrony, defaults

apt install chrony. The difference is philosophy: timesyncd corrects the clock; chrony models it — continuously estimating this particular crystal's frequency error, consulting several servers, voting out liars. Result over [N h]: median [MEASURED], band misses [MEASURED]%.

[CHART: rung 1 vs rung 0]

Rung 2 — measured server selection

“Use a nearby server” is in every guide; nobody measures which server is actually near. find_closest_ntp.py (stdlib-only) geolocates your public IP, gathers the country and continent pools plus the anycast heavyweights, and probes each candidate with hand-built 48-byte SNTP packets. From the four timestamps of each exchange:

RTT    = (T4 - T1) - (T3 - T2)      # pure network path, server think-time removed
offset = ((T2 - T1) + (T3 - T4)) / 2

Ranking is not on speed alone:

score = rtt_min + 2*jitter + 0.010*loss_fraction + 0.0005*max(stratum-1, 0)

A rock-steady 10 ms server beats a flaky 9 ms one — variance is what poisons a discipline loop; loss is a danger signal worth 10 ms; stratum only breaks ties. Known limitation, on the record: round-trip time cannot see path asymmetry — no client can; it is indistinguishable from clock offset. The mitigation is pinning a set of close servers and letting chrony average the asymmetries out, which is why the tool never recommends a single hero server.

Pinning the winners at boot — and three silent traps

Pool hostnames re-resolve to random servers at every restart, so the measured picks are pinned as IPs into a chrony sourcedir by a systemd ExecStartPre hook; if selection fails at boot the previous set is kept, so chronyd always starts. Deploying that surfaced three traps, each a silent failure — daemon up, zero sources, empty journal:

Results: measured picks vs the pool lottery

Two phases, identical polling, [N h] each, on the Pi 5, wired:

Phase A: measured, pinnedPhase B: stock pool (random draw)
per-source |offset| mean [MEASURED] [MEASURED]
samples > 10 ms [MEASURED] [MEASURED]
system clock p95 [MEASURED] [MEASURED]

The honest verdict: chrony's source selection spotted Phase B's bad draw and voted it out — the final clock met the target on both configs. What measured selection buys is margin: roughly [MEASURED]× cleaner raw data, no wasted source slot, and no bet that the voting always out-runs bad luck. On a system with a real requirement, that margin is the difference between “worked so far” and “provable”.

The WiFi experiment

Same Pi, same pinned servers, moved to WiFi: [MEASURED]% of samples outside the band, with chrony dragged to a phantom frequency correction of [MEASURED] ppm — WiFi's variable latency and asymmetry look exactly like a fast-drifting clock, so chrony faithfully “fixed” a problem that did not exist. Minutes after plugging in ethernet: [MEASURED]% inside the band. If a machine's clock matters, it gets a wire. The chart is the policy document.

[CHART: the WiFi→wired collapse]

Reproduce it

git clone [REPO — time_sync_monitoring]
python3 find_closest_ntp.py              # ranked table + ready-to-paste config
python3 plot_chrony.py ./logs            # your own charts, same ±10 ms band

Note: find_closest_ntp.py makes one HTTP request to ip-api.com to geolocate your public IP for pool selection; it degrades silently to generic pools without it. The probe itself sends only client-mode SNTP queries — it never listens and never sets the clock.

Downloads

Need clocks, logs or uptime that survive an auditor? That is the day job: DoJu Systems — DevOps, SRE and SecOps for UK businesses. And if your clocks are measured in microseconds with a regulator watching — trading systems, market data, MiFID II RTS 25 — that is the specialist end: Corvil Certified Expert, latency analytics for electronic trading. First call is free.