{"id":1212,"date":"2026-07-27T16:52:55","date_gmt":"2026-07-27T14:52:55","guid":{"rendered":"https:\/\/www.distributed-systems.net\/?page_id=1212"},"modified":"2026-07-28T09:03:40","modified_gmt":"2026-07-28T07:03:40","slug":"cloning-a-server","status":"publish","type":"page","link":"https:\/\/www.distributed-systems.net\/index.php\/cloning-a-server\/","title":{"rendered":"Cloning a server"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Cloning <code>zappa<\/code> onto <code>coltrane<\/code> sounds like it should be the easy part \u2014 copy the files across and you&#8217;re done. Copying the <em>files<\/em> is indeed easy. The hard part is making the copy actually <strong>boot<\/strong>, on hardware that isn&#8217;t <code>zappa<\/code>&#8216;s.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why a clone is not a copy<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>zappa<\/code> boots from an NVMe drive; <code>coltrane<\/code> boots from an older SATA disk. A running Linux system carries a boot stack \u2014 the initramfs and the GRUB bootloader \u2014 that is tailored to the machine it was installed on: which disks exist, how they&#8217;re numbered, and, crucially, the UUID of the root filesystem baked into the bootloader. rsync happily copies all of that from <code>zappa<\/code> to <code>coltrane<\/code>, at which point <code>coltrane<\/code> is holding <code>zappa<\/code>&#8216;s boot stack on <code>coltrane<\/code>&#8216;s hardware. It won&#8217;t come up.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So every clone is really two steps: replicate the filesystems, then <strong>re-fit the boot stack<\/strong> for <code>coltrane<\/code> \u2014 rebuild the initramfs (<code>update-initramfs<\/code>) and reinstall GRUB (<code>grub-install<\/code>, <code>update-grub<\/code>) so that <code>coltrane<\/code> boots itself, not a memory of <code>zappa<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The replication itself is a set of per-filesystem <code>rsync -a --delete --one-file-system<\/code> passes, pushed <em>from<\/em> <code>zappa<\/code> to <code>coltrane<\/code> so that data only ever flows one way, with an exclude list for the things that must never be cloned (virtual-machine images, transient mount points, and so on).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The UUID that would not die<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">For the better part of a week, <code>coltrane<\/code> kept booting with <em><code>zappa<\/code>&#8216;s<\/em> root UUID instead of its own. Every configuration file I could grep looked correct \u2014 the live <code>grub.cfg<\/code> pointed at <code>coltrane<\/code>&#8216;s disk, <code>\/proc\/cmdline<\/code> confirmed it \u2014 and yet a fresh clone would quietly boot the wrong disk again. There turned out to be two hiding places, neither of them an ordinary config file.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">First, the UUID was baked into GRUB&#8217;s <strong>EFI core image<\/strong> \u2014 the compiled <code>grubx64.efi<\/code> binary itself, not any <code>.cfg<\/code> file. A <code>grub-install<\/code> run in the distant past, while the root disk was momentarily misidentified, had embedded <code>zappa<\/code>&#8216;s UUID directly into the bootloader binary. No amount of editing text files touches that.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Second, and more insidious, there was an <strong>orphan bootloader config<\/strong> sitting in a non-standard directory on the EFI system partition (<code>\/boot\/efi\/grub\/grub.cfg<\/code>) \u2014 a full, stale menu that neither <code>update-grub<\/code> nor <code>grub-install<\/code> ever writes to, and that the clone never touches because the EFI partition isn&#8217;t part of the rsync. It just sat there, quietly booting <code>zappa<\/code>&#8216;s UUID on every cycle, while every tool I ran \u201cfixed\u201d a different file.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The cure for the second one is the kind of thing that only looks obvious afterwards: turn that orphan into a <em>stub<\/em> that simply chains to the live, always-current config, so it no longer matters which config GRUB loads \u2014 they all end up at <code>coltrane<\/code>&#8216;s real <code>grub.cfg<\/code>. That&#8217;s clone-proof, because a stub carries no UUID of its own.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The lesson I took from this: with UEFI, the thing that actually boots your machine can live in more places than you&#8217;d think, and some of them are binaries, not text. When a boot problem survives every edit you make, you&#8217;re editing the wrong file.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Deleting what the clone should delete<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A subtler wrinkle: rsync&#8217;s <code>--delete<\/code> is deliberately conservative. It only removes files it has actively accounted for during the comparison, and \u2014 importantly \u2014 an <code>--exclude<\/code> rule doesn&#8217;t merely hide a file on the sender, it also <em>protects<\/em> the matching file on the receiver from deletion. So when a directory disappears on <code>zappa<\/code> but <code>coltrane<\/code> still holds excluded files inside it, rsync can&#8217;t empty the directory and leaves it standing. For a clone that&#8217;s meant to be faithful, that&#8217;s a slow accumulation of cruft. I handle it deliberately rather than automatically: a small interactive tool discovers the stuck directories from rsync&#8217;s own output and, in a <code>--why<\/code> mode, shows me exactly which exclude rule is protecting each one before I remove anything by hand.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The power failure<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Then, one day, the power died in the middle of a clone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is the genuinely nasty case, because a power failure isn&#8217;t a clean error \u2014 nothing exits with a non-zero code, no trap fires, no flag gets cleared. The process simply vanishes mid-write. <code>coltrane<\/code> was left with <code>zappa<\/code>&#8216;s boot stack half-written and the re-fit step never run: exactly the unbootable state described above, and now with nothing left running to notice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Recovering it by hand, remotely, through a network-KVM was an education in itself. GRUB dropped to a bare shell and greeted me with <code>invalid sector size 65535<\/code> \u2014 which, it turned out, wasn&#8217;t <code>coltrane<\/code>&#8216;s disk at all, but the remote repair image I&#8217;d attached was presenting bogus geometry that GRUB choked on while enumerating devices. Detaching that image made the error disappear. From the GRUB shell I could then point at <code>coltrane<\/code>&#8216;s real disk and boot it by hand:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>set root=(hd0,gpt2)\nlinux \/boot\/vmlinuz-...  root=\/dev\/sda2 ro\ninitrd \/boot\/initrd.img-...\nboot<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">\u2014 with a detour to shrink GRUB&#8217;s video mode (<code>set gfxmode=800x600<\/code>), because the KVM was only showing me a cropped corner of the screen.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Making interruption survivable<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Getting <code>coltrane<\/code> back was satisfying; never wanting to do it again by hand was the real outcome. The scripts now assume a clone can be interrupted at any instant, and are built so that the <em>next boot<\/em> heals itself:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A <strong>\u201cclone in progress\u201d marker<\/strong> is dropped on <code>coltrane<\/code>&#8216;s disk at the very start of a clone, and removed only after both the boot re-fit and a safety check have passed. A power failure can&#8217;t clear it \u2014 so if the marker is still there on the next boot, <code>coltrane<\/code> <em>knows<\/em> the previous clone was interrupted, shouts about it, and forces a fresh clone and re-fit rather than trusting the half-written system.<\/li>\n\n\n\n<li>A <strong>self-heal check<\/strong>: even with no marker, if the live GRUB config doesn&#8217;t reference <code>coltrane<\/code>&#8216;s own root UUID, <code>coltrane<\/code> concludes it has booted on a stale or foreign boot stack and re-fits itself \u2014 turning \u201cunbootable until a human with a KVM shows up\u201d into \u201csorts itself out on the next wake.\u201d<\/li>\n\n\n\n<li>A <strong>safety gate<\/strong>: <code>coltrane<\/code> refuses to power itself back off unless it has verified that GRUB really does point at its own root. It will never put itself to sleep in a state it can&#8217;t wake up from.<\/li>\n\n\n\n<li>Because <code>coltrane<\/code>&#8216;s own mail and logs don&#8217;t survive a clone-and-power-off, all of this <strong>alerts and logs via <code>zappa<\/code><\/strong>, whose disk is stable across <code>coltrane<\/code>&#8216;s cycles.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">What this actually is<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Stepping back: what I&#8217;ve built is a manual-failover <em>cold standby<\/em> with tiered replication \u2014 a daily full clone for the bulk, hourly incrementals, and much tighter syncs for the handful of things I genuinely can&#8217;t lose. That&#8217;s disaster recovery, not high availability, and the distinction matters. I&#8217;m deliberately trading a little downtime and a human in the loop for a system I can fully understand and repair myself. Real HA \u2014 clustered databases, synchronously replicated storage, automatic failover \u2014 would be far more machinery and many more ways to fail silently, to buy me minutes I don&#8217;t need.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The interesting consequence is that, once the mechanics are hardened, the risk moves. It stops being about rsync or GRUB and becomes <em>operational<\/em>: a failover path that is exercised almost never will quietly rot. <code>coltrane<\/code> sleeps and drifts, a promote script references a path that has since moved, a certificate expires while the standby was asleep and couldn&#8217;t renew. A recovery plan you&#8217;ve never actually run end to end isn&#8217;t a plan \u2014 it&#8217;s a hypothesis. So the single most valuable habit here has nothing to do with code: every so often, actually cut over to <code>coltrane<\/code>, run on it for a while, and fail back \u2014 on a calm afternoon of my own choosing, not during a real outage. Everything that teaches me is something I don&#8217;t have to learn the hard way.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Cloning zappa onto coltrane sounds like it should be the easy part \u2014 copy the files across and you&#8217;re done. Copying the files is indeed easy. The hard part is <a class=\"more-link\" href=\"https:\/\/www.distributed-systems.net\/index.php\/cloning-a-server\/\">Continue Reading \u2192<\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"footnotes":""},"class_list":["post-1212","page","type-page","status-publish","hentry"],"_links":{"self":[{"href":"https:\/\/www.distributed-systems.net\/index.php\/wp-json\/wp\/v2\/pages\/1212","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.distributed-systems.net\/index.php\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/www.distributed-systems.net\/index.php\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/www.distributed-systems.net\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.distributed-systems.net\/index.php\/wp-json\/wp\/v2\/comments?post=1212"}],"version-history":[{"count":2,"href":"https:\/\/www.distributed-systems.net\/index.php\/wp-json\/wp\/v2\/pages\/1212\/revisions"}],"predecessor-version":[{"id":1251,"href":"https:\/\/www.distributed-systems.net\/index.php\/wp-json\/wp\/v2\/pages\/1212\/revisions\/1251"}],"wp:attachment":[{"href":"https:\/\/www.distributed-systems.net\/index.php\/wp-json\/wp\/v2\/media?parent=1212"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}