This file contains:
- Part 1: the blog post in English
- Part 2: the blog post in Japanese
- Part 3: a reproduction runbook, with placeholders, that you can follow together with an AI assistant
Replace every <PLACEHOLDER> with your own values. Never paste secrets (passwords, API keys, tokens) into a chat.
Part 1 (English)
From a hard disk to an SSD: a bootable spare and a weekly clone on a dual-GPU home server
Background
This home server has two NVIDIA GPUs in one machine. An RTX 3060 runs local LLM inference: a 12B quantized model served by llama.cpp as a systemd service. An RTX 3070 mines while the machine is idle. The system originally ran from a single 500 GB SATA hard disk, which held the OS, the models, and all service data.
The project had two goals:
- Performance: shorten boot time, service start-up time, and model load time, which I expected to be limited by random I/O on the hard disk.
- Availability: stop depending on a single disk, and keep a second disk that can boot the system on its own.
This post covers the migration, the update that exposed the lack of a fallback, and the weekly clone that now keeps the spare current.
Step 1: Moving the root filesystem from the hard disk to NVMe
Why the hard disk was expected to be the bottleneck. The services on this machine issue many small reads: Python imports, shared libraries, systemd units, and the model file. On a mechanical disk, those reads are bound by seek latency. NVMe removes most of that latency.
Target layout.
| Disk | Role after migration |
|---|---|
| NVMe SSD (primary) | EFI system partition and root filesystem; boots by default |
| SATA hard disk (spare) | Previous system kept as a cold fallback; mounted read-only with nofail |
What had to change. Copying files is not enough to make a system boot. Four things decide it:
- Every entry in
/etc/fstabmust use a UUID, not a device name such as/dev/sda2. Device names can change when disks are added or removed. - The EFI partition must live on the new disk, and its boot entry must point at the new loader.
- GRUB's configuration must reference the new root UUID. A copied configuration still points at the old one.
- The old disk must not be mounted writable at boot, so that no boot writes to it by accident.
Verify before rebooting.
findmnt -no SOURCE / # which device is the running root lsblk -f # filesystems, labels, and UUIDs sudo blkid # confirm each UUID cat /etc/fstab # every entry should be UUID-based, or intentionally absent Keep the old disk attached but unmounted until the first successful boot from the new disk.
Results. I did not record benchmarks before or after the migration, so this post makes no numeric claims about speed. Subjectively, the improvement was noticeable for only a few days before it became the new normal. The lesson: if performance is the goal, record a baseline before the change, not after it.
Gotcha. The old disk still held a complete, valid system. If its old UUID stayed in fstab as a writable mount, the next boot would try to mount it. Remove the entry, or mark it nofail and read-only.
Why the spare mattered
A routine system update went wrong partway through, and the primary disk could no longer be trusted to keep running as before. The spare was not bootable, so I had no working fallback. Closing that gap is the point of this post.
Making the spare disk bootable
I wanted a second system that could boot on its own, so I formatted the spare disk's root partition and copied the running system to it:
- Back up the important configuration files.
- Format the spare root partition with
mkfs.ext4. Formatting avoids old files mixing into the new copy. - Copy the root filesystem with
rsync -aHAXx --numeric-ids. The-xflag keeps the EFI partition out of the root copy. - Rewrite the copy's
/etc/fstabwith the spare disk's own UUIDs, and remove any entry for a mount that no longer exists. - Copy the EFI contents, then reinstall GRUB inside a chroot with
--no-nvram.
Two gotchas came up:
- Copied EFI loader stubs hard-code the root UUID. The copied
grub.cfgstill pointed at the original disk, so selecting the spare disk booted the original system. Every stub the firmware can load must point at the spare disk's root. -
grub-installwrites to NVRAM by default. When two disks share a boot label, that can repoint the original disk's firmware entry.--no-nvramleaves firmware entries alone, and boot order is set in the firmware setup instead.
Booting the spare disk from the firmware boot menu brought up the cloned system. Check every loader path, not just the one you expect.
A weekly one-way clone with interlocks
A one-time clone protects against nothing that happens after it. So I added a weekly job that mirrors the running primary root to the spare root:
- Schedule: a systemd timer every Friday at 02:00, with
Persistent=trueso a missed run happens after the next boot. - Direction: one way only, primary to spare.
- Interlock 1: the job runs only when the machine is booted from the primary disk. If it is booted from the spare, it logs a skip and writes nothing.
- Interlock 2: the destination must match the expected UUID, must not be the running root, and must not already be mounted. Otherwise it refuses.
- Sync:
rsync -aHAXx --numeric-ids --delete, excluding virtual filesystems and the EFI partition. - Fixups: after the sync, the spare's fstab is rewritten from a template, and the primary's UUID is replaced in the spare's
grub.cfg. The job fails if any primary UUID remains. - Alerts: failures send an email through
msmtpwith an app password stored in a root-only config file.
The clone is a single generation. A bad change on the primary reaches the spare on the next run. This is deliberate: the clone guarantees a bootable copy of the latest state, not a history.
Lessons
- A fallback only counts once you have booted it.
- Cloned boot loaders are not portable. Rewrite every UUID the firmware might load, and avoid touching firmware boot entries when installing a bootloader for a clone.
- Put interlocks in the script. A job that writes to a disk should check which disk it is running from, and refuse if the answer is wrong.
- Keep the old kernel as a rollback path until the new one has run for a while.
- A mirror is not a backup. If you need history, plan it separately.
Part 2 (日本語)
ハードディスクからSSDへの換装、起動できる予備ディスク、そして毎週のクローン
背景
このマシンには NVIDIA GPU を 2 枚載せている。RTX 3060 はローカル LLM の推論に使う。12B の量子化モデルを llama.cpp で動かし、systemd のサービスとして提供している。RTX 3070 は、アイドル時にマイニングへ回す。当初は 500GB の SATA ハードディスク 1 台で動かしていて、OS もモデルもサービスのデータもすべてそこに載っていた。
目的は二つだった。
- 性能:起動時間、サービスの立ち上がり時間、モデルの読み込み時間を短くすること。ハードディスクのランダム I/O がボトルネックになると想定していた。
- 可用性:ディスク 1 台に依存する状態をやめること。単独で起動できる予備ディスクを持つこと。
この記事では、換装、その後に起きた更新中の障害とフォールバックの欠如、そして予備を最新に保つための毎週のクローンについて書く。
手順 1:ハードディスクから NVMe SSD への root の移行
ボトルネックと想定した理由。 このマシンのサービスは、Python の import、共有ライブラリ、systemd のユニット、モデルファイルなど、小さな読み込みを大量に行う。機械式ディスクでは、これらがシーク時間に左右される。NVMe にすれば、そのレイテンシは大きく減るはずだった。
換装後の構成。
| ディスク | 換装後の役割 |
|---|---|
| NVMe SSD(主) | EFI パーティションと root。既定で起動する |
| SATA ハードディスク(予備) | 旧システムを冷たい予備として保持。読み取り専用・nofail でマウント |
変更が必要だったこと。 ファイルをコピーしただけでは、起動できるかどうかは決まらない。起動を左右するのは次の四点だ。
-
/etc/fstabのすべてのエントリを、/dev/sda2のようなデバイス名ではなく UUID で指定する。デバイス名は、ディスクを追加・取り外しすると変わることがある。 - EFI パーティションを新しいディスクに置き、起動エントリが新しいローダーを指すようにする。
- GRUB の設定が新しい root の UUID を参照していること。コピーした設定は、古い UUID を指したままになる。
- 古いディスクが起動時に書き込み可能な状態でマウントされないようにする。起動のたびに、誤って書き込まれるのを防ぐためだ。
再起動前の確認。
findmnt -no SOURCE / # 実行中の root のデバイス lsblk -f # ファイルシステム、ラベル、UUID sudo blkid # 各 UUID の確認 cat /etc/fstab # すべて UUID 指定、または意図的に無いこと 新しいディスクから最初に起動が成功するまで、古いディスクは接続したままにして、マウントはしない。
結果。 換装前後のベンチマークは記録していない。したがって、この記事では速度について数値による主張はしない。体感の改善は数日で薄れ、以降は当たり前の状態になった。性能が目的なら、変更の前にベースラインを記録しておくこと。後から測っても、比べられない。
注意点。 古いディスクには、動く完全なシステムが残っている。古い UUID のまま書き込み可能なマウントとして fstab に残すと、次の起動でそれをマウントしようとする。エントリを削除するか、nofail と読み取り専用にすること。
予備ディスクが必要になった理由
更新の途中で、主ディスクをこれまでどおりには信用できなくなった。予備ディスクは起動できない状態だったから、フォールバックがなかった。このギャップを埋めるのが、この記事の目的だ。
予備ディスクを起動できるようにする
単独で起動できる第二のシステムが必要だったので、予備ディスクの root パーティションをフォーマットし、動いているシステムを丸ごとコピーした。
- 重要な設定ファイルを退避する。
- 予備の root パーティションを
mkfs.ext4でフォーマットする。古いファイルが混ざらないように、新規に作る。 -
rsync -aHAXx --numeric-idsで root をコピーする。-xによって、EFI パーティションはコピーから外れる。 - コピー先の
/etc/fstabを、予備ディスク自身の UUID で書き直す。存在しないマウントの行は削除する。 - EFI の中身をコピーし、chroot 内で
--no-nvramを付けて GRUB を入れ直す。
ここで二つの落とし穴があった。
- コピーした EFI の stub は、root の UUID を埋め込んでいる。 コピーした
grub.cfgが元のディスクを指したままだったため、予備ディスクを選んでも元のシステムが起動した。ファームウェアが読み込みうる stub は、すべて予備ディスクの root を指す必要がある。 -
grub-installは、デフォルトで NVRAM に書き込む。 二つのディスクが同じラベルを持つと、元のディスクのファームウェアの起動エントリを書き換えてしまうことがある。--no-nvramを付ければ、ファームウェアの起動エントリには触らずに済む。起動順はファームウェアの設定画面で決める。
ファームウェアの起動メニューから予備ディスクを選ぶと、クローンしたシステムが立ち上がった。期待している経路だけでなく、すべての読み込み経路を確認すること。ここを省くと、あとで必ず痛い目を見る。
インターロック付きの毎週の一方向クローン
一度きりのクローンは、そのあとの変化を守ってくれない。そこで、動いている主ディスクの root を予備に毎週ミラーするジョブを追加した。
- 実行タイミング: systemd タイマーで毎週金曜 02:00。
Persistent=trueにしてあるので、実行を逃したときは次の起動後に実行される。 - 方向: 主ディスクから予備への一方通行。
- インターロック 1: 主ディスクで起動しているときだけ実行する。予備ディスクで起動しているときは、skip と記録するだけで、何も書き込まない。
- インターロック 2: 同期先は、期待する UUID と一致し、実行中の root ではなく、マウントされていないことを確認する。一つでも違えば拒否する。
- 同期:
rsync -aHAXx --numeric-ids --delete。仮想ファイルシステムと EFI パーティションは除外する。 - 後処理: 予備側の fstab をテンプレートから書き直し、予備側の
grub.cfgの主ディスクの UUID を置き換える。主ディスクの UUID が残っていたら、失敗として止まる。 - 通知: 失敗したら
msmtpでメールを送る。アプリパスワードは root だけが読める設定ファイルに置く。
クローンは世代を持たないので、主ディスクで壊れた変更は次の同期で予備にも反映される。これは意図的なトレードオフだ。クローンが保証するのは「最新の状態を起動できるコピー」であって、履歴ではない。
学んだこと
- 実際に起動したことがあるものだけが、フォールバックになる。
- クローンした起動ローダーは、そのままでは移せない。ファームウェアが読み込みうる UUID をすべて書き換えること。
- インターロックはスクリプトの中に入れる。ディスクに書き込むジョブは、いま自分がどのディスクから動いているかを確認し、おかしければ拒否するべきだ。
- 性能は、変更の前に測る。
- 古いカーネルは、新しいものが十分に動くまで残す。
- ミラーはバックアップではない。履歴が必要なら、別に計画する。
Part 3: Reproduction runbook
This runbook is written so that you can follow it with an AI assistant. Give the assistant the whole file, then tell it what your disks are.
0. How to work with the AI assistant
Paste this at the start of the session, filling in the placeholders:
We are setting up a weekly one-way clone from my primary root disk (<PRIMARY_ROOT_DEVICE>, UUID <PRIMARY_ROOT_UUID>) to a spare disk (<SPARE_DEVICE>, root partition UUID <SPARE_ROOT_UUID>, EFI UUID <SPARE_EFI_UUID>). Rules: - Use read-only commands first and show me the output. - Before any command that writes to a disk (mkfs, mount with write, rsync --delete, grub-install), show me the exact command and wait for my "go". - Back up every file before editing it. - Never print secrets. I will type passwords myself. - Verify the target device by UUID and size before every destructive step. 1. Prerequisites
- A primary disk that holds the running root (NVMe or SATA).
- A spare disk with at least one partition large enough for the root filesystem, and a separate EFI partition.
- Packages:
rsync,msmtp,grub-efi-amd64(or the distro equivalent). - A working email account with an app password (never put the password in a shell history or a chat).
2. Identify the disks (read-only)
findmnt -no SOURCE / lsblk -f sudo blkid Write down:
- the primary root UUID (
<PRIMARY_ROOT_UUID>) - the spare root partition and its UUID after formatting (
<SPARE_ROOT_UUID>) - the spare EFI UUID (
<SPARE_EFI_UUID>)
3. Back up before changing anything
mkdir -p ~/backup-$(date +%Y%m%d) sudo cp /etc/fstab ~/backup-$(date +%Y%m%d)/fstab.bak 4. Format the spare root partition (destructive)
Confirm the device first. The UUID must be the spare's old root, not the primary's.
sudo umount /path/to/spare/root 2>/dev/null sudo mkfs.ext4 -L root /dev/<SPARE_ROOT_PARTITION> sudo blkid /dev/<SPARE_ROOT_PARTITION> 5. Copy the running root
sudo mkdir -p /mnt/clone sudo mount /dev/<SPARE_ROOT_PARTITION> /mnt/clone sudo rsync -aHAXx --numeric-ids --stats \ --exclude=/proc/* --exclude=/sys/* --exclude=/dev/* --exclude=/run/* \ --exclude=/tmp/* --exclude=/var/tmp/* --exclude=/mnt/* --exclude=/media/* \ --exclude=/lost+found --exclude=/boot/efi/* \ / /mnt/clone/ rsync exit code 24 (files vanished during transfer) is normal on a live system. Any other non-zero code is a failure.
6. Rewrite the copy's fstab and EFI loader
Write a fstab for the spare disk, using its own UUIDs:
sudo tee /mnt/clone/etc/fstab >/dev/null <<'EOF' UUID=<SPARE_ROOT_UUID> / ext4 defaults 0 1 UUID=<SPARE_EFI_UUID> /boot/efi vfat defaults 0 1 EOF Mount the spare EFI, then reinstall GRUB inside a chroot. --no-nvram is important.
sudo mount /dev/<SPARE_EFI_PARTITION> /mnt/clone/boot/efi for d in dev proc sys run; do sudo mount --bind /$d /mnt/clone/$d; done sudo chroot /mnt/clone grub-install --target=x86_64-efi \ --efi-directory=/boot/efi --bootloader-id=spare --no-nvram --recheck sudo chroot /mnt/clone update-grub Verify that every loader stub points at the spare root:
sudo grep -rH "search.fs_uuid\|UUID=" /mnt/clone/boot/efi/EFI/ /mnt/clone/boot/grub/grub.cfg If any stub still shows the primary UUID, fix it with sed (after backing it up) and check again.
Clean up:
for d in run sys proc dev; do sudo umount /mnt/clone/$d; done sudo umount /mnt/clone/boot/efi /mnt/clone 7. Boot test
Shut down. Use the firmware boot menu to choose the spare disk's entry. Do not change the firmware boot order yet. After boot:
findmnt -no SOURCE / # should show the spare root partition uname -r 8. Install the weekly job
Install msmtp and write /etc/msmtprc with the app password (edit it yourself):
sudo tee /etc/msmtprc >/dev/null <<'EOF' defaults auth on tls on tls_starttls on tls_trust_file /etc/ssl/certs/ca-certificates.crt logfile /var/log/msmtp.log account gmail host smtp.gmail.com port 587 from <YOUR_ADDRESS> user <YOUR_ADDRESS> password "PASTE_APP_PASSWORD_HERE" account default : gmail EOF sudo chown root:root /etc/msmtprc && sudo chmod 600 /etc/msmtprc Write the fstab template for the spare disk:
sudo install -d -m 755 /etc/clone-job sudo tee /etc/clone-job/fstab.spare >/dev/null <<'EOF' UUID=<SPARE_ROOT_UUID> / ext4 defaults 0 1 UUID=<SPARE_EFI_UUID> /boot/efi vfat defaults 0 1 EOF Write the sync script. It runs only from the primary disk and refuses to write anywhere else.
sudo tee /usr/local/sbin/clone-job.sh >/dev/null <<'EOF' #!/bin/bash # One-way weekly clone: primary root -> spare root. Does nothing when booted from the spare. set -uo pipefail SRC_UUID=<PRIMARY_ROOT_UUID> DST_UUID=<SPARE_ROOT_UUID> FSTAB_TEMPLATE=/etc/clone-job/fstab.spare MAIL_TO=<YOUR_ADDRESS> MNT=/mnt/clone-job-dst LOCK=/run/clone-job.lock DRYRUN=${DRYRUN:-0} log() { logger -t clone-job -- "$*"; echo "$(date '+%F %T') $*"; } cleanup() { mountpoint -q "$MNT" && umount "$MNT"; } trap cleanup EXIT fail() { log "FAILED: $*" printf 'To: %s\nSubject: [clone-job] FAILED on %s\n\n%s\n%s\n' \ "$MAIL_TO" "$(hostname)" "$*" "$(date)" | msmtp "$MAIL_TO" \ || log "could not send failure mail" exit 1 } exec 9>"$LOCK" flock -n 9 || { log "skip: another run is in progress"; exit 0; } # Interlock 1: only run when booted from the primary disk ROOT_SRC=$(findmnt -no SOURCE /) ROOT_UUID=$(blkid -s UUID -o value "$ROOT_SRC") if [ "$ROOT_UUID" != "$SRC_UUID" ]; then log "skip: booted from $ROOT_SRC, not the primary disk. Nothing done." exit 0 fi # Interlock 2: destination must be the spare root, not the running root, and not mounted DST_PART=$(readlink -f "/dev/disk/by-uuid/$DST_UUID" 2>/dev/null || true) [ -n "$DST_PART" ] && [ -b "$DST_PART" ] || fail "spare root ($DST_UUID) not found" [ "$DST_PART" != "$ROOT_SRC" ] || fail "destination equals the running root; refusing" [ "$(blkid -s UUID -o value "$DST_PART")" = "$DST_UUID" ] || fail "destination UUID mismatch" findmnt -S "$DST_PART" >/dev/null && fail "destination is already mounted; refusing" [ -f "$FSTAB_TEMPLATE" ] || fail "fstab template missing" mkdir -p "$MNT" mount -o noatime "$DST_PART" "$MNT" || fail "mount failed" RSYNC_OPTS=(-aHAXx --numeric-ids --delete --stats) [ "$DRYRUN" = 1 ] && RSYNC_OPTS+=(-n) EXCLUDES=( --exclude=/proc/* --exclude=/sys/* --exclude=/dev/* --exclude=/run/* --exclude=/tmp/* --exclude=/var/tmp/* --exclude=/mnt/* --exclude=/media/* --exclude=/lost+found --exclude=/srv/backup-hdd/* --exclude=/boot/efi/* ) rsync "${RSYNC_OPTS[@]}" "${EXCLUDES[@]}" / "$MNT"/ rc=$? if [ "$rc" -ne 0 ] && [ "$rc" -ne 24 ]; then fail "rsync exited with code $rc"; fi if [ "$DRYRUN" = 1 ]; then log "dry run finished (nothing written)"; exit 0; fi # Spare-specific fixups install -m 644 "$FSTAB_TEMPLATE" "$MNT/etc/fstab" || fail "could not write fstab" sed -i "s/$SRC_UUID/$DST_UUID/g" "$MNT/boot/grub/grub.cfg" || fail "could not edit grub.cfg" grep -q "$SRC_UUID" "$MNT/boot/grub/grub.cfg" "$MNT/etc/fstab" && fail "primary UUID still present on spare" grep -q "UUID=$DST_UUID" "$MNT/etc/fstab" || fail "fstab root check failed" sync log "OK: primary -> spare clone finished (rsync rc=$rc)" exit 0 EOF sudo chown root:root /usr/local/sbin/clone-job.sh && sudo chmod 755 /usr/local/sbin/clone-job.sh Install the systemd service and timer:
sudo tee /etc/systemd/system/clone-job.service >/dev/null <<'EOF' [Unit] Description=Weekly one-way clone primary root -> spare root ConditionPathExists=/usr/local/sbin/clone-job.sh [Service] Type=oneshot ExecStart=/usr/local/sbin/clone-job.sh Nice=10 TimeoutStartSec=2h EOF sudo tee /etc/systemd/system/clone-job.timer >/dev/null <<'EOF' [Unit] Description=Run clone-job every Friday 02:00 [Timer] OnCalendar=Fri *-*-* 02:00:00 Persistent=true [Install] WantedBy=timers.target EOF sudo systemctl daemon-reload && sudo systemctl enable --now clone-job.timer systemctl list-timers clone-job.timer --no-pager 9. Test in order
# (a) mail printf 'To: <YOUR_ADDRESS>\nSubject: [test] mail\n\nhello\n' | sudo msmtp <YOUR_ADDRESS> && echo sent # (b) dry run, writes nothing sudo env DRYRUN=1 /usr/local/sbin/clone-job.sh 2>&1 | tail -15 # (c) real run, from the primary disk sudo systemctl start clone-job.service journalctl -u clone-job.service -n 40 --no-pager Then boot from the spare disk and run the job once more. The expected output is a skip line, and nothing should be written:
sudo systemctl start clone-job.service journalctl -u clone-job.service -n 10 --no-pager findmnt | grep clone-job || echo "no clone mount (good)" 10. Rollback
- To stop the weekly job:
sudo systemctl disable --now clone-job.timer - To remove everything: delete the files listed above, then
sudo systemctl daemon-reload - To return to the original disk: select it in the firmware boot menu. Do not run
grub-installon the primary unless you have a backup of its EFI partition.
Prepared as a reproduction guide. Test every step on a non-production machine first if you can.