Chasing 10 milliseconds: time sync done properly on a Raspberry Pi 5
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.
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:
ProtectSystem=fullin the stock unit makes /etc read-only for pre-start hooks — whitelist exactly one directory withReadWritePaths=/etc/chrony.d.mvfrom /tmp drags mode 0600 and the SELinuxtmp_tlabel along — unreadable to the privilege-dropped daemon. Useinstall -m 644(fresh inode), always.- chrony rejects any directive line with a trailing comment — drops the whole line, says nothing. Comments on their own lines, forever.
Results: measured picks vs the pool lottery
Two phases, identical polling, [N h] each, on the Pi 5, wired:
| Phase A: measured, pinned | Phase 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
- [raw chrony logs, all phases (tar.gz)]
- [chrony.conf per rung + systemd drop-in]
- [charts (PNG)]