SD cloning · data integrity
An SD clone that boots is not an SD clone that works
2026-07-26 · HardStacked · 7 min read
You migrate your Raspberry Pi to an endurance card using the bundled tool. The new card
boots — but the password screen is slow to appear, and once the password is in, a long
black screen goes by before the desktop shows up. The reflex, “the card is
slow”, points in the wrong direction.
We ran this migration on a Pi 5 under Debian 13, from a generic V30 to a SanDisk High
Endurance 128 GB, measuring every step: the hot clone that failed, the byte-by-byte
autopsy, the cold clone that worked. Here are the numbers, our own mistakes included,
because that is where the lessons live.
1. The stopwatch said faster, the screen said otherwise
piclone (the “SD Card Copier” preinstalled on Raspberry Pi OS)
is not a bit-for-bit dd. Inside the binary: a dd if=/dev/zero over
the target's first sector, an mkfs.ext4, then a cp -ax of a
live system — file by file, while the OS keeps writing.
The result boots, and systemd-analyze reports 24.5 s and 29.8 s
across two runs, against 39.029 s for the source. On screen, the exact opposite: the
wait is noticeably longer than before, both before and after the password.
Both are true. systemd-analyze stops its stopwatch when
graphical.target is reached; the greeter, then the session, start after that
— outside its scope. That is exactly where the failure lives. With the suspect card
mounted read-only from a healthy system, the journal names it:
Failed to start gvfs-daemon.service
Failed to activate service 'org.gtk.vfs.Daemon':
timed out (service_start_timeout=120000ms) ← 120 s of waiting, every session
The greeter asks for gvfs, then the user session asks in turn: D-Bus waits
120 seconds each time. Hence the two waits — the one delaying the password,
with its error at the top of the screen, and the one after login. The second is black
because pcmanfm --desktop is what draws the wallpaper and the icons: while it
waits on gvfs, it draws nothing. Why gvfs dies:
$ stat -c %s usr/libexec/gvfsd
67760 ← exactly the right size
$ md5sum usr/libexec/gvfsd
593a1ef6a5d338baa4fecb47ce98e886 ← dpkg expected 100c13dbd4613636479dc979b274b6d8
Read three times with drop_caches in between: same sum every time. The bad
data is written on the card; this is not a flaky read.
The habit
“It boots” proves nothing, and a stopwatch that contradicts what you see on
screen does not win the argument: start by looking at what it measures, and where it stops.
Then mount the clone read-only from a healthy system, read its journal, and compare
binaries against dpkg's md5 sums before calling the copy good.
2. Wrong bytes have a shape, and that shape clears the card
The full sweep found 69 files with wrong content, scattered with no logical
connection: gvfsd, libcrypto.a, libnssckbi.so, a
Wi-Fi firmware, icons, kernel headers. The apparent disorder hid a perfect pattern:
$ cmp -l gvfsd.src gvfsd.clone | awk '{print $3}' | sort -u
0 ← 100% of diverging bytes are 0x00
Contiguous ranges, ending either at end of file or exactly on 2 MiB
(2,097,152). For gvfsd, 67,760 bytes: the divergence runs from byte 63,673 to
67,745 — a tail of zeros.
Failing media returns random noise. A lost write returns aligned zeros. The card and
the USB reader are therefore cleared: it was the hot copy that dropped writes in
flight.
The habit
Before blaming hardware, look at the shape of the wrong bytes with cmp -l.
Random noise: the media. Aligned contiguous zeros: the copy tool.
3. Our first sweep was off by a factor of 300
We'll own this one: the first md5sum -c ran without root. Verdict:
21,513 “failures”. The real number: 69. The other 21,444 were simply
unreadable — HIDS files restricted to root, compilers at -rwx------.
md5sum -c mixes “wrong content” and “could not open”
into the same stream; we nearly concluded the card was massively rotten.
The habit
Sweep as root, and always separate read failures from content failures.
4. Our cold clone nearly reproduced the same failure, in the script
The method that works: source unmounted, e2fsck -fn,
e2image -ra -p (copies used blocks only), dd conv=fsync writing in
full, e2fsck -fp.
Second confession: the first draft carried dd conv=sparse, caught on
re-reading. sparse skips blocks of zeros: the target's old corrupted content
would never be overwritten, and a file of genuine zeros would read back the stale data.
The same failure, by another road. blkdiscard was refused by the USB
reader — without consequence, precisely because the write is a full one.
The habit
On an already-written target, full writes only — re-read your dd
line, never conv=sparse.
5. A “zero divergence” verdict would have rejected the perfect clone
Verifying the cold clone: 193,497 package md5 sums, 849 divergences. On a perfect
clone. All of them legitimate: 845 .pyc files from a HIDS that recompiles them
at runtime, 2 configs from the same, an EXTERNALLY-MANAGED for which dpkg
keeps two sums (the stdlib one and the Raspberry Pi package that overwrites it — a
structural false positive), one g_ether.conf. Plus 229 “open
errors”: that many nonexistent files (install scripts removed after deployment).
A script demanding “0 divergences” would have printed “COPY NOT SOUND,
DO NOT boot it” on a perfectly good clone. The count of divergences does not
measure corruption; the signature from section 2 does: a contiguous block of zeros of
64 KiB or more, or a tail of zeros of 64 KiB or more. On the cold clone: 0 files
flagged.
And the detector has to test itself before it judges, because a broken detector returns
“0” too. The test: a file full of zeros must be flagged, a healthy file with a
100k-zero tail must be flagged, a random file must not. Verified by deliberately sabotaging
the threshold: the refusal to render a verdict fires.
The habit
Judge a copy on the signature of the failure you are hunting, not on a count of
divergences. And make your detector fail on purpose once — if it cannot say “I
am broken”, its “0” is worth nothing.
6. The boot was 13 seconds faster, and that still needed proving
After the migration: 25.948 s (1.700 s kernel + 24.247 s userspace),
against 39.029 s before. The gain is real — but the corrupted clone from section
1 already showed 24-29 s on the same stopwatch, on a machine whose desktop never
appeared. A good boot number can coexist with a broken system: it does not measure
health.
The control that settles it: count enabled and masked systemd units, before and after.
Here 47 and 21, identical. Same service count, less time: a genuine I/O gain. Confirmed
entry by entry with systemd-analyze blame: the HIDS service, I/O-hungry at
startup, drops from 21.8 s to 12.937 s — roughly 9 of the 13 seconds come
from it alone.
The habit
A boot-time gain is only worth celebrating after you recount enabled and
masked units. Otherwise your “faster card” may be a service that no
longer starts.
7. Wear counters describe the source, not the card
$ dumpe2fs -h <partition> | grep 'Lifetime writes'
Lifetime writes: 1188 GB ← on a card manufactured two months earlier
This dumpe2fs counter lives in the superblock — copied along with
everything else. After a clone, any counter stored inside a copied structure describes
the source. The reassuring cross-check: 364,661 inodes and 13,564,397 used blocks,
counted independently on two machines, identical.
The habit
Wear is read from the controller (/sys/block/mmcblk0/device/:
cid, name, date), never from the filesystem.
Five rules for an honest clone
- Cold only. A copy of a live system drops writes in flight, and the result
boots anyway.
- Write the target in full, never
conv=sparse: skipped blocks of
zeros leave the old corrupted content in place.
- The verdict rests on a signature, not on a count. A perfect clone shows
hundreds of legitimate divergences; a rotten clone is recognized by its ranges of
zeros.
- Separate “wrong content” from “could not read”.
Mixed into one stream, they inflate the verdict by a factor of 300.
- Self-test the detector. Broken, it returns “0” as well —
and a “0” you have never managed to make fail is worth nothing.
What a real clone looks like
Measured on a Pi 5 under Debian 13, generic V30 to SanDisk High Endurance 128 GB.
The hot clone booted with 69 wrong files, all in contiguous zeros; the cold clone, verified
across 193,497 md5 sums, comes out with 0 files flagged and a double inode/block count
identical on two machines.
One last consequence, counter-intuitive and easy to forget: a cold clone freezes the
source at time T. Anything written after the copy starts does not exist on the card you
burned — including the report of the copy itself, which stayed on the machine driving
it. We went looking for that file on the wrong card before working it out.
The same lesson runs through the whole migration: “it boots” is not “it
works”, “faster” is not “healthier”, “zero errors”
is not “clean”. The number is the instrument, not the health — and
every verdict has to come out of a measurement capable of telling things apart, rendered by
an instrument you have made fail on purpose at least once.
The deeper lesson
A clone that boots, a stopwatch going down, a sweep returning zero: three reassuring
signals, and three instruments that were not measuring what we assumed. The discipline
that exposes them is the same everywhere — know what the number measures, hunt the
signature of the failure rather than a count, and make the instrument fail before you
trust it.
We are a security shop, so our incentives point the same way yours do: nothing we sell
depends on your configuration staying weak. That alignment is exactly what the big
platforms cannot offer, and it is verifiable, not a slogan. Scan
hardstacked.net yourself, right now, on the same scanner.
The same philosophy drives HardVps: it applies a reviewed
hardening baseline to your server, captures every original file first, and verifies any
restore byte-exact by SHA-256. Strictness you can roll back is strictness you will
actually deploy.
See what HardVps does →
On a Raspberry Pi? Start free with
audit-pi, our open-source security audit.