我家服务器的死亡与重生
摘要
作者讲述了其Raspberry Pi家庭服务器SD卡故障的经历,以及如何以NixOS、zram和外部备份为重点,通过最小化写入和提高冗余来重建的过程。
<p><a href="https://lobste.rs/s/k6ph7c/death_rebirth_my_home_server">评论</a></p>
查看缓存全文
缓存时间: 2026/07/20 09:33
# 家庭服务器的死亡与重生
来源:https://sgt.hootr.club/blog/home-server-rebirth/
几天前的晚上,我和伴侣心血来潮想安装某个特定的 Linux ISO。Red Letter Systems 的家伙们对它赞不绝口,在 boundingboxd 上也有好评,伴侣还喜欢它的安装预告片。我打开手机上的 Tremotesf(https://github.com/equeim/tremotesf-android)想检查 Pi 上是否已经存了这个,但种子列表加载不出来。SMB 挂载也挂不上。连 SSH 都连不进去。不想因为当场开始调试而毁掉这个夜晚,我把它归结为最近对 NixOS 配置所做的简单修改,打算第二天再瞧瞧。
## 第二天
我把 Pi 连上壁橱里专门调试用的显示器,活动了一下指关节,说了句“是时候调试了”之类的话,然后重新启动了这块砖头。当前的 NixOS 生成开始启动,加载了内核……然后卡住了。没有任何更多输出。上一个生成也如出一辙。我把 microSD 卡取出来做进一步检查。首先想到的是 `fsck` 一下;几天前整个大楼莫名其妙地断电了,也许断电正好发生在写入包含某些启动关键文件的数据块时?总之,值得一查。`fsck.ext4` 报告并修复了大量错误。再次运行报告文件系统已清洁,但弹出并重新插入卡后又出现了更多错误……长话短说,这张卡很可能已经坏了,断电只是给了它最后一击。按 F 致敬。
## 清点库存
这台 Raspberry Pi 4B 几乎 7×24 小时运行了好几年,所以这并不意外。据我所知,SD 卡在开始故障前能承受的写入次数相对较少。令人惊讶的是,考虑到我之前对它的粗暴对待,它竟然能这么长时间无故障运行。microSD 被挂载为根文件系统,而且我几乎没有采取任何措施来最小化写入。总的来说,我损失不大。重要的东西主要存储在外置硬盘上,配置几乎全部在我的 NixOS 配置里。唯一存放在 microSD 卡上且没有备份的是 `.torrent` 文件和 Navidrome、Jellyfin、slskd 的缓存,这些我都能轻松重新生成。Immich 的数据安全地备份在外置硬盘上,并且我定期做备份,所以那里没有损失。
我以前从未经历过“主”驱动器如此死掉;最严重的情况是坏过几个不重要的闪存盘。自从我十几岁时第一次尝试安装 Linux 时意外删除了 MacBook 上的主 OS X 分区以来,对硬盘损坏和数据丢失的恐惧就一直潜伏在脑海深处,但现在这种恐惧比以前更甚,所以我想采取强有力的预防措施。
## 配置新设置
我着手重建家庭服务器,心中有几个目标:
- 尽量减少对 microSD 的写入。
- 对外置硬盘上的数据实现一定冗余。
- 备份所有重要数据。
### 交换空间、/tmp 和其他易失性数据
即使 Pi 有 8GB 内存,交换空间也很有用,但我不认为把交换放在 microSD 上是个好主意,如果我希望它活得更久的话。所以我可以选择使用外置硬盘交换(非常慢),或者使用 zram(https://wiki.archlinux.org/title/Zram)。
> zram,原名 compcache,是一个 Linux 内核模块,用于在 RAM 中创建压缩块设备,即带有实时磁盘压缩的 RAM 磁盘。zram 的常见用途是交换空间或 `/tmp`。
我选择启用 zram 用于交换,但不用于 `/tmp`——我将 `/tmp` 保留为普通的 RAM 磁盘;我不完全确定这两个特性如何交互,但我认为让内核自己决定何时以及如何将 `/tmp` 中的冷页面换出到压缩交换中,而不是为它保留单独块,这样更好。
```nix
{
# 启用内存中压缩交换设备
zramSwap.enable = true;
# 使用 RAM 磁盘作为 /tmp
boot.tmp.useTmpfs = true;
}
```
我还选择了让 journald 日志记录到内存而不是磁盘。我需要调查是否可能先记录到内存,然后偶尔刷新到磁盘(那样的话我会将 `/var/log` 挂载到硬盘上的子卷),但这部分我还没搞清楚。
```nix
{
# 将日志存储到内存中的 /run/log/journal
services.journald.storage = "volatile";
}
```
microSD 卡仍然挂载到 `/`,但我禁用了 `atime`,因为它会在文件被访问时导致写入,而且我认为它没什么用。
```nix
{
fileSystems."/" = {
device = "/dev/disk/by-label/takodachi";
fsType = "ext4";
options = [ "noatime" ];
};
}
```
### 外置硬盘
如果你活在 2026 年,你可能知道现在不是购买新硬盘(或内存、显卡,以及任何可用于建造数据中心并填饱受 AI 狂热困扰的高管腰包的东西)的好时机,更明智的做法是利用手头的东西,发挥智慧和创造力。我有一大堆各种品牌、大小和年代的硬盘闲置着。我主要把它们当作冷存储,用来存放我“有点”想保留的旧数据;它们就像是数字版的杂物抽屉。我一直想把它们用于更高尚的目标,但直到现在都没找到机会。我从这堆硬盘中拿出了两个旧的 2.5 英寸硬盘。第一个是 500GB 的硬盘,里面是 Windows 文件系统。它曾用在伴侣的笔记本电脑里;那是一台来自另一个商业机器时代、重达 3KG 的庞然大物,与其说是真正的便携式机器,不如说是一台可以轻松搬到另一张桌子上的台式机,偶尔带回家。首先,我出于对垂死笔记本的怜悯,用 SSD 替换了 HDD;最终我给伴侣换上了一台运行 NixOS 的 Surface Pro 5,他们慷慨地把旧笔记本和硬盘给了我玩。第二个是 320GB 的硬盘,里面似乎是……一个 Home Assistant 安装?我不知道它怎么会出现在那里。总之,这是一块带苹果 Logo 的日立硬盘,所以它以前要么在我的 2006 年白色塑料 MacBook 里,要么在类似年代的 Mac Mini 里。我把这两件历史文物连接到扩展坞上,创建了一个带有 raid1 复制的 btrfs 池。我恰当地将它命名为“ポンコツ”(破烂货)。
```bash
sudo mkfs.btrfs --data raid1 --metadata raid1 --label ponkotsu /dev/sdX /dev/sdY
```
我选择 btrfs 是因为我熟悉它(我的台式机和笔记本都跑这个),我喜欢子卷(后面会详细说),也喜欢它池的灵活性。你不需要事先规划池的拓扑,随时都可以向池中添加硬盘,而且它们不需要有相同的大小。
### 子卷
我的计划是为每个服务创建一个 btrfs 子卷,为了实现声明式管理,我创建了一个 autosubvol 模块(https://kirarin.hootr.club/git/steinuil/flakes/src/commit/16ac9612b9103017adb0fdf43d547d08bbf04d3c/modules/nixos/autosubvol/default.nix)。配置如下:
```nix
services.autosubvol = {
enable = true;
disks.ponkotsu = {
device = "/dev/disk/by-uuid/";
subvolumes.immich = {
mountPoint = "/var/lib/immich";
mountOptions = [ "noatime" ];
requiredBy = [ "immich-server.service" ];
};
};
};
```
该模块创建了一些 systemd 单元:
- 每个磁盘一个 `.mount` 单元,挂载到 `/run/btrfs-roots/` 下。
- 每个子卷一个 `autosubvol-ensure-<disk>-<subvolume>.service` 一次性单元,其中包含一个脚本,检查子卷是否存在,如果不存在则创建它。它配置了 `RemainAfterExit=true`,以便子卷的 `.mount` 单元可以依赖它。
- 每个子卷一个 `.mount` 单元,挂载到指定的 `mountPoint`。它依赖 autosubvol-ensure `.service`,并在其 `requiredBy` 的单元上设置 `Before=` 和 `RequiredBy=`。
我认为这让每个服务模块看起来非常简洁。
```nix
{ config, ... }: {
services.immich = {
enable = true;
# ...
};
services.autosubvol.disks.ponkotsu.subvolumes.immich = {
mountPoint = config.services.immich.mediaLocation;
mountOptions = [ "noatime" ];
requiredBy = [ "immich-server.service" ];
};
}
```
我还为 `/var/cache` 创建了一个子卷,以备不时之需。
```nix
{
fileSystems."/var/cache" = {
device = "/dev/disk/by-uuid/...";
fsType = "btrfs";
options = [ "subvol=@cache" "noatime" "nofail" ];
};
}
```
### 备份
当初设置 Immich 时,我还创建了一个 Nix 模块,作为 sops-nix(https://github.com/Mic92/sops-nix)和 restic(https://restic.net/)nixpkgs 模块之间的桥接。实现(https://kirarin.hootr.club/git/steinuil/flakes/src/commit/16ac9612b9103017adb0fdf43d547d08bbf04d3c/modules/nixos/backups/default.nix)只有几行代码,在我的 Nix 配置中看起来如下:
```nix
{
internal.backups.restic.immich = {
paths = [ "/var/lib/immich" ];
exclude = [ "/var/lib/immich/encoded-video" "/var/lib/immich/thumbs" ];
pruneOpts = [ "--keep-daily 7" "--keep-weekly 4" "--keep-monthly 3" ];
};
}
```
由于每个服务都在自己的子卷上,我或许可以设置一个本地备份系统,使用快照和 `btrfs send`,搭配一块专门用于备份的硬盘(大部分时间保持断电)。但目前我没有那么多硬盘,而且获取新硬盘越来越困难,所以我把这个留给更好的时机吧。一个 S3 存储桶和复制功能暂时够用了。
### 组和权限
我希望多个服务能访问我的 Linux ISO:Transmission(https://transmissionbt.com/)、Jellyfin(https://jellyfin.org/)、Navidrome(https://www.navidrome.org/)以及其他一些服务。我通过创建一个 `media` 组,并将其作为附加组分配给所有需要读写这些文件的应用来解决这个问题。
```nix
{
users.groups.media = {};
users.users.steenuil.extraGroups = [ "media" ];
users.users.jellyfin.extraGroups = [ "media" ];
services.transmission = {
group = "media";
downloadDirPermissions = "2775";
settings.umask = "002";
};
}
```
使其生效的关键是 Transmission 的 `downloadDirPermissions` 设置为 `2775`。`2` 表示设置 setgid(https://www.gnu.org/software/coreutils/manual/html_node/Mode-Structure.html)位,这意味着如果 `/srv/linux-isos` 由 `media` 组拥有,那么在其中创建的每个文件和目录都将继承 `media` 组,而不是创建者的主组。
### 监控
这部分我还没完全搞定。我目前采取的唯一预防措施是定期在 btrfs 池上运行 scrub,以发现任何错误,并根据每个硬盘上的错误数量决定是否要仔细查看 S.M.A.R.T 统计数据。我想设置 smartd(https://github.com/smartmontools/smartmontools),或许再连接到像 Scrutiny(https://github.com/AnalogJ/scrutiny)这样漂亮的仪表盘上,但既然我还在运行许多其他服务,并且不介意拥有更多统计数据,或许设置 Prometheus(https://github.com/prometheus/prometheus)然后想办法在 Grafana 仪表盘上展示一切会更实用?我对 Grafana 几乎一无所知,所以这看起来有点吓人和过度,但可能是个不错的学习体验。
### 扩充池
让我们回到旋转的机械盘;池里目前有两个硬盘。我想向池中添加一个 1TB 的硬盘,同时将其内容也复制到池中。这块硬盘恰好被分为两半,大部分数据在后半部分,所以我将前半部分格式化为 btrfs,添加到池中,将后半部分复制到池中,然后扩展 btrfs 分区以填满整块硬盘。我不确定这在速度方面有多有效,但省去了我运行 `btrfs balance` 的步骤。为了减轻 Pi 的负担,我在台式机上设置了 Immich,并从那里的备份中恢复,由于在之前的步骤中我避开了移动缩略图,所以我用 webp 格式重新生成了所有缩略图。这就是池的当前状态:一块 1TB 硬盘、一块 500GB 硬盘和一块 320GB 硬盘;数据和元数据都使用 raid1。我计划再添加一块旧的 500GB 硬盘,并可能一直保持这种状态,直到硬盘价格下降或其中一块硬盘坏掉。我还有一块 10TB 的硬盘,仍格式化为 ext4,装满了 Linux ISO,无法合理地加入池中,所以我只会备份我认为重要的东西,然后一直用到它坏掉。#yolo
## 新的 microSD
由于 microSD 卡的压力大大减轻,我选择了一块 64GB 的卡,而不是之前用的 128GB。我寻找了一块“高耐久”卡,因为据说它们在报废之前能承受更多写入周期。不过我不完全确定最后拿到的是什么。这整件事发生在我写这篇帖子的假期前几天,所以我订购了一张能在周六——我离开的前一天——到的卡。周六早上来了,包裹还没发货,所以我只好取消订单,起身走到最近的家电卖场。其实还好。我愉快地散了步,拍了一张那些正在入侵南欧、留下一路半吃剩的叶子的亚洲甲虫的照片。被几只日本甲虫啃食的植物。店里开了空调。我看了一眼最新的噱头手机。我带着一张号称高耐久 64GB microSD 卡走出了店,甚至还有一台打折的 UPS。
## 部署
NixOS 在刚开始接触时是个令人畏惧的项目。互联网上散布着太多信息,难以完全掌握。不过你会慢慢习惯的。你的大脑会进行翻译。你可以通过下载官方的 NixOS 镜像,从一个空系统开始引导 Pi 上的 NixOS,或者你只需要在心底知道,你只需要这个模块:
```nix
{ modulesPath, ... }: {
imports = [ "${modulesPath}/installer/sd-card/sd-image-aarch64.nix" ];
sdImage.rootVolumeLabel = "takodachi";
}
```
然后就能施展这个咒语:
```bash
nix build .#nixosConfigurations.takodachi.config.system.build.sdImage
```
这将构建一个 `.img` 文件,你可以用 `dd` 命令将其写入 microSD 卡,无需任何中间步骤即可启动你的系统。
## 收尾
将镜像写入 microSD 卡后,我要做的就是将所有设备连接到新 UPS 并插上电源,几秒钟后我就能 SSH 到 Pi 并开始解决新配置带来的几个小问题。我出发去度假时有点晚,但 Navidrome 已经运行起来,可以在路上播放音乐了。
相似文章
我的新家庭服务器软件选择
一篇个人博客文章,详细描述了作者为新家庭服务器选择操作系统和服务管理软件的经历,比较了Synology、TrueNAS、Debian以及使用Runit的Void Linux。
将我的 NAS 从 CoreOS/Flatcar Linux 迁移到 NixOS
Michael Stapelberg 详细介绍了他将一台 NAS 从 CoreOS/Flatcar Linux 迁移到 NixOS 的过程,涵盖了从 Docker 容器逐步过渡到原生 NixOS 模块的步骤,并附有实际示例。
在NixOS下使用kexec实现无密码重启
本文详细介绍了一种在NixOS上使用kexec实现无密码服务器重启的方法,增强了加密系统的安全性并减少了重启时间。
从 Proxmox 迁移到 NixOS 和 Incus
作者描述了将其家庭实验室从 Proxmox 迁移到使用 Incus 的 NixOS 的过程,强调了声明式配置和可重现性相对于命令式系统的优势。
在树莓派 Zero 上完全运行于 RAM 中提供网站服务
本教程介绍如何在树莓派 Zero v1.3 上使用 Alpine Linux 搭建无磁盘网站,系统完全启动至其 512MB RAM 中。详细说明了所需硬件、操作系统配置、轻量级 Web 服务器以及将 TLS 终止卸载到外部 VPS 的方法。