从 57P03 死循环到 0.42 秒极速自愈:外置盘环境下 Immich 容器启动故障、VirtioFS 刷盘瓶颈与损坏页无损抢救记

从 57P03 死循环到 0.42 秒极速自愈:外置盘环境下 Immich 容器启动故障、VirtioFS 刷盘瓶颈与损坏页无损抢救记

作为个人私有云相册的核心中枢,Immich 承载了我近百 GB 的珍贵历史照片与视频元数据。为了保障宿主机内置硬盘的空间充裕,我一直将 Immich 的全量媒体库与 PostgreSQL 数据库目录持久化在外置高速固态硬盘(/Volumes/X1/immich)上,并通过 macOS 平台下轻量高效的 OrbStack 虚拟机环境进行容器化驱动。

然而,在近期的日常维护与版本平滑演进中,这套外置存储架构却接连遭遇了两场惊心动魄的险情:先是在例行执行版本有保护升级(v3.2.2 至 v3.3.3)的全量冷备时,PostgreSQL 抛出了致命的数据页校验和损坏错误(page verification failed);紧接着在宿主机休眠唤醒与重启后,immich-server 容器突发陷入了无休止的 Restarting (1) 崩溃循环,Web 前端彻底瘫痪。在这篇文章中,我将完整复盘这两起事故的底层技术根因,深入拆解 VirtioFS 跨层挂载下的文件同步陷阱、PostgreSQL 崩溃恢复机制与事务级坏块无损手术实操。


一、故障现象:从版本升级受阻到容器启动无限死循环

本次针对 Immich 的维护工作主要分为两个关键战役:升级前的全面健康核查与升级后的异常启动死循环排查。

1. 升级前险情:pg_dumpall 遭遇数据页校验和损坏(Checksum Mismatch)

在将 Immich 升级至 v3.3.3 之前,我遵循最严苛的数据安全准则,准备先对运行在 PostgreSQL 14.19 上的整个数据库做一次全量冷备份。然而,在执行备份脚本时,终端却突然弹出了红色告警并强行中断:

pg_dump: WARNING: page verification failed, calculated checksum 45601 but expected 33863
pg_dump: error: Error message from server: ERROR: invalid page in block 262 of relation base/16384/17154

我的 PostgreSQL 实例在初始化时显式启用了 --data-checksums(数据页块级校验和保护)。当备份进程扫描到 OID 为 17154 的表(对应 public.asset_job_status)第 262 号数据块时,计算出的校验和与页头记录的值出现严重偏差。这表明该物理页在底层存储写入时遭遇了不完整写入或静默数据损坏。

与此同时,配套的组件也暴露出了隐性隐患:

  • Valkey(Redis)快照受损:immich_redis 容器在重启时读取 /data/dump.rdb 报错 Internal error in RDB reading offset ... -> Invalid LZF compressed string,容器在拉起后 3 秒内发生段错误退出;
  • 虚拟磁盘 BTRFS 报错:在拉取新版本镜像层时,OrbStack 虚拟内核抛出 BTRFS error (device vdb1): bad tree block start,触发了虚拟文件系统的强制只读(forced readonly)。

很显然,外置移动硬盘在历史运行中遭遇过宿主机异常断电或未完全卸载即拔出的情况,留下了多处块级伤痕。

2. 升级后核心故障:immich-server 陷入 Restarting (1) 循环

在完成数据坏块修复与版本升级后,本以为一切万事大吉,但在宿主机经历一次休眠唤醒并重启容器组后,新的严重故障爆发了:

执行 docker compose ps 观测,immich_server 持续处于 Restarting (1) 状态,外部访问 http://localhost:2283 提示连接被拒绝(Connection Refused)。

查看 immich_server 容器实时日志,捕获到了高频抛出的致命崩溃堆栈:

PostgresError: the database system is starting up
    at ErrorResponse (.../postgres/src/connection.js:817:22) {
  severity_local: 'FATAL',
  severity: 'FATAL',
  code: '57P03',
  file: 'postmaster.c',
  line: '2337',
  routine: 'ProcessStartupPacket'
}

转而查看底层数据库容器 immich_postgres,其健康状态长时间卡在 Up (health: starting),日志持续反复输出:

LOG:  database system was interrupted; last known up at 2026-10-16 01:23:45 UTC
FATAL:  the database system is starting up

这个所谓的“启动恢复过程”竟然整整持续了 8~10 分钟以上!在这漫长的 10 分钟内,数据库拒绝任何外部客户端的读写握手。


二、根因深度逆向:VirtioFS 性能泥潭与容器启动编排缺陷

为什么原本只需回放几兆字节预写式日志(WAL)的 PostgreSQL,在外置固态硬盘上竟然需要卡顿 10 分钟?为什么 immich-server 会因为数据库的延迟恢复而直接崩溃致死?

我将整个故障的层层传导链路梳理如下:

flowchart TD
    A["宿主机休眠 / 重启 / 外置盘突然中断"] --> B["PostgreSQL 未完成优雅停机 (Unclean Shutdown)"]
    B --> C["下次拉起触发 StartupXLOG 崩溃恢复流程"]
    C --> D["PG 默认 recovery_init_sync_method = fsync"]
    D --> E["遍历数据目录下数千个物理文件逐一执行 fsync()"]
    E --> F["VirtioFS 跨层调用 + USB/外置磁盘 I/O 延迟累加(耗时长达 8~10 分钟)"]
    F --> G["PostgreSQL 锁死在启动态,返回 57P03 错误"]
    H["docker-compose.yml 采用简写 depends_on: [database, redis]"] --> I["未等待 healthy 门控,抢跑拉起 immich-server"]
    G & I --> J["immich-server 握手失败,触发致命未捕获异常退出"]
    J --> K["Docker 触发指数退避重启,全站服务彻底瘫痪"]

    style A fill:#475569,stroke:#94a3b8,color:#fff
    style E fill:#991b1b,stroke:#ef4444,color:#fff
    style G fill:#9a3412,stroke:#f97316,color:#fff
    style J fill:#7f1d1d,stroke:#f87171,color:#fff

1. 根因剖析一:PostgreSQL 默认 fsync 恢复机制在外置盘 + VirtioFS 下的几何级放大

深入研读 PostgreSQL 内核的崩溃恢复(Crash Recovery)机制可以发现,当数据库进程被 SIGKILL、断电或宿主机休眠强行终止时,控制文件 global/pg_control 中记录的状态不是正常的 DB_SHUTDOWNED,而是 DB_IN_CRASH_RECOVERY 或 DB_SHUTDOWNED_IN_RECOVERY。

在下一次启动时,PostgreSQL 的启动进程(startup process)会进入 StartupXLOG()。在真正读取并回放 WAL 记录(redo)之前,内核出于严谨的数据持久性保障,必须先调用 SyncDataDirectory() 将整个数据目录里的脏数据进行一次全局强制刷盘,以防基础数据文件在后续追加写入时因文件系统缓存不一致导致更严重的覆写损坏。

而在 PostgreSQL 14 中,控制预同步方式的参数 recovery_init_sync_method 默认值为:

recovery_init_sync_method = fsync

这个默认机制的工作流程非常原始:递归遍历整个 $PGDATA 目录下的所有文件(包括所有表的物理数据段、索引文件、临时文件与 WAL 段),逐一调用 open() 获取文件描述符,然后对每个文件单独执行一次 fsync() 系统调用,最后再 close()。

在一个运行了较长时间的 Immich 库中,数据目录下的各类小文件轻松突破数千甚至上万个。

如果是在原生 Linux 宿主机的本地 NVMe 硬盘上,操作系统的 VFS 页缓存合并能力极强,数千次 fsync() 通常在数秒内即可结束。但在我的运行环境中:

  1. 宿主系统与容器边界:macOS 宿主机通过 OrbStack 运行 Linux 容器,两者之间的目录挂载依托于 VirtioFS(一种跨虚拟化边界的高性能共享文件系统协议);
  2. 硬件总线延迟:数据目录实际驻留在 USB 3.2 / Thunderbolt 外置移动固态硬盘上;
  3. 调用穿透损耗:每一次由 Linux Guest 内核发起的单文件 fsync(),都需要通过 Virtio 队列向 macOS Host 发起 IPC 调用,由 macOS 内核驱动转换为外置总线的刷盘指令(如 SCSI SYNCHRONIZE CACHE 或 NVMe Flush 命令)。

根据实测,在 VirtioFS + 外置存储链路上,单次 fsync() 的端到端往返耗时高达 150~300 毫秒。数千个文件依次串行执行 fsync(),总体耗时被硬生生拖长到了 8~10 分钟!

在此期间,WAL 回放根本尚未开始(实测后续的 WAL 日志重放本身仅耗时 < 0.8 秒),整个数据库进程完全对外失联。

2. 根因剖析二:docker-compose.yml 依赖编排缺少健康检查门控

另一个导致容器直接崩溃的罪魁祸首,出在 Immich 默认的 Docker Compose 编排写法上。

此前 docker-compose.yml 中的 immich-server 服务依赖项配置如下:

depends_on:
  - redis
  - database

在 Docker Compose 规范中,以数组形式简写的 depends_on 仅约束容器的“启动先后顺序”(相当于 condition: service_started),而绝不保证被依赖服务的内部“健康可用状态”。

这意味着:

  1. Docker daemon 在收到 docker compose up 指令后,先后发起了启动 database 容器与 immich-server 容器的系统调用;
  2. 此时 immich_postgres 的主进程虽然已经存在,但正深陷在前面提到的 10 分钟 fsync() 泥潭中;
  3. 紧接着,Node.js 编写的 immich-server 启动并初始化 TypeORM / Postgres 客户端连接池,向 tcp://database:5432 发送握手报文;
  4. PostgreSQL 内核的 ProcessStartupPacket 发现系统处于启动恢复态,立即根据代码规范返回了 FATAL: code 57P03 (the database system is starting up);
  5. immich-server 在关键引导阶段收到致命断连异常,未能优雅容忍重试,直接抛出 Uncaught Exception 退出,退出码为 1;
  6. Docker 根据 restart: always 策略重新拉起 immich-server,再次连接、再次崩溃……从而陷入了持续的 Restarting (1) 死循环,并触发 Docker 的指数退避惩罚。

三、实战排障与修复方案:从事务坏块微创手术到 0.42 秒极速自愈

明确了底层两重瓶颈后,我制定了针对性的修复管线:首先解决物理坏块并实施安全升级,随后彻底根除启动同步延迟与编排缺陷。

1. 数据库损坏页的无损抢救(zero_damaged_pages 事务内手术)

面对 public.asset_job_status 表 Block 262 抛出的校验和失败问题,很多网管的第一反应是直接清空表或通过物理删除文件来暴力重建,这对于强外键约束的复杂系统往往会带来灾难性的级联数据破坏。

我采取了更为严密的手术方案:

步骤一:全库表级强制顺序扫描校验

为了确认坏块是否蔓延至相册核心业务表,我通过连接数据库,对 public 下的所有表执行全量顺序扫描(强制 SET enable_indexscan = off; SET enable_bitmapscan = off;)。扫描证实:核心的 asset(资产表)、exif(照片元数据表)、person(人物面孔表)、album(相册表)与 users(用户表)全部 100% 完好无损,唯独 asset_job_status 表存在 4 个物理块(Block 262~265)校验和破损。

asset_job_status 表在 Immich 中主要用于记录后台异步任务(如人脸识别、转码、EXIF 提取等)的处理时间戳,不包含原始照片的物理哈希与存储路径。

步骤二:开启局部损坏页规避并导出可用记录

PostgreSQL 提供了开发者级参数 zero_damaged_pages。当开启该参数时,如果系统检测到损坏的数据页头,不会抛出致命错误,而是自动将该损坏页全部视为空白页(填充零字节),从而允许 SELECT 继续向后读取。

为了防止该危险参数污染全局实例,我将其严格限制在只读显式事务内:

BEGIN;
SET LOCAL zero_damaged_pages = on;

-- 将损坏表中的所有完好记录提取并重建为临时暂存表
CREATE TABLE public.asset_job_status_salvaged AS 
SELECT * FROM public.asset_job_status;

COMMIT;

实测从该表中成功抢救出 44,178 条完好记录。

步骤三:资产对比与缺失记录补齐

通过查询主资产表 public.asset,当时全库实际共有 44,191 条资产记录。也就是说,损坏的 4 个物理页上恰好对应了缺失的 13 个资产 ID(44,191 - 44,178 = 13)。

我通过 EXCEPT 差集运算快速锁定了这 13 个资产的主键 ID,并将它们作为初始空任务状态补回新表,最后原子替换原表并重建主外键约束:

BEGIN;

-- 补齐因坏块丢失的 13 条占位记录(时间戳填 NULL,促使 Immich 后续按需重跑状态同步)
INSERT INTO public.asset_job_status_salvaged (assetId)
SELECT a.id FROM public.asset a
WHERE a.id NOT IN (SELECT assetId FROM public.asset_job_status_salvaged);

-- 替换原损坏表并恢复外键级联
DROP TABLE public.asset_job_status CASCADE;
ALTER TABLE public.asset_job_status_salvaged RENAME TO asset_job_status;

ALTER TABLE public.asset_job_status 
  ADD CONSTRAINT asset_job_status_pkey PRIMARY KEY ("assetId");

ALTER TABLE public.asset_job_status 
  ADD CONSTRAINT asset_job_status_assetid_fkey 
  FOREIGN KEY ("assetId") REFERENCES public.asset(id) 
  ON UPDATE CASCADE ON DELETE CASCADE;

COMMIT;

-- 立即对修复后的表进行物理重排与统计信息更新
VACUUM FULL ANALYZE public.asset_job_status;

手术完成后,我再次执行 pg_dumpall | gzip -c 进行全量导出,备份过程流畅通过,没有出现任何校验和警告,成功产出了 199MB 的完备冷备份包。

2. 根除 10 分钟挂起:引入 recovery_init_sync_method = syncfs

物理数据修复后,必须从底层彻底解决外置盘在 VirtioFS 下漫长遍历 fsync 的痛点。

PostgreSQL 在 13 及 14 版本中引入了更为高效的预同步机制:syncfs。
在标准的 Linux 5.8+ 现代内核上,系统调用 syncfs(fd) 允许用户进程仅传入某个目录下打开的一个文件描述符,内核就会针对该文件描述符所属的整个底层文件系统超级块发起统一的脏数据刷盘操作。

通过这一特性,PostgreSQL 不再需要去遍历成千上万个文件一个个执行 fsync(),而是仅仅需要:

  1. 打开数据根目录获取一个文件句柄;
  2. 执行一次 syncfs();
  3. 关闭句柄,直接进入 WAL 重放环节!

实施配置

Immich 官方使用的定制化镜像(ghcr.io/tensorchord/pgvecto-rs)在容器内的 /etc/postgresql/postgresql.conf 文件末尾包含了一行关键引用:

include_if_exists '/var/lib/postgresql/data/postgresql.override.conf'

这为我们在不重构镜像的前提下提供了极佳的挂载扩展点。我在宿主机外置磁盘的数据目录(/Volumes/X1/immich/postgres/)下创建了 postgresql.override.conf 文件,写入如下声明:

# Use syncfs instead of file-by-file fsync during crash recovery initialization.
# On external drives mounted via VirtioFS on macOS, fsync-ing thousands of files
# one-by-one takes ~8-10 minutes, causing immich-server to fail on startup.
recovery_init_sync_method = syncfs

惊人的调优效果对比

在配置生效后,我模拟了容器非正常关机并重新拉起。通过 docker logs -f immich_postgres 查看启动日志,奇迹出现了:

LOG:  database system was interrupted; last known up at 2026-10-16 02:11:05 UTC
LOG:  database system was not properly shut down; automatic recovery in progress
LOG:  redo starts at 2/3B84F000
LOG:  invalid record length at 2/3B851230: wanted 48, got 0
LOG:  redo done at 2/3B8511F8 system usage: CPU: user: 0.12 s, system: 0.30 s, elapsed: 0.42 s
LOG:  database system is ready to accept connections

原本耗时高达 8~10 分钟(500~600 秒) 的启动卡顿,瞬间被压缩到了 0.42 秒!整整提速了 1400 多倍!

xychart-beta
    title "PostgreSQL 崩溃恢复数据目录预同步耗时对比 (单位: 秒)"
    x-axis ["逐文件fsync", "单次syncfs"]
    y-axis "耗时 (秒)" 0 --> 600
    bar [540, 0.42]

3. 编排固化:升级 docker-compose.yml 健康依赖门控

为了彻底消除 immich-server 在启动时的抢跑报错隐患,必须将 Docker Compose 的依赖模式由松散的进程拉起约束升级为严密的健康门控检查。

我打开 docker-compose.yml,对 immich-server 的声明块进行了重构:

services:
  immich-server:
    container_name: immich_server
    image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
    # ... 其他环境与存储配置保持不变 ...
    depends_on:
      redis:
        condition: service_healthy
      database:
        condition: service_healthy
    restart: always

同时,将 redis 组件(已升级为 Valkey 9 独立分支)更新为 v3.3.3 官方推荐的哈希指纹镜像:

  redis:
    container_name: immich_redis
    image: docker.io/valkey/valkey:9@sha256:c123e3715db63d06d4ad6964884037aa0d5d4d703939b9929954112889708e1d
    healthcheck:
      test: ["CMD", "valkey-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: always

通过这一改动,Docker 只有在确认:

  1. immich_postgres 的 pg_isready 探测返回代码 0;
  2. immich_redis 成功响应 PONG 后;

才会正式启动 immich-server 主服务,彻底杜绝了客户端因 57P03 抢跑而陷入崩溃退避的恶性循环。

4. 彻底重置 OrbStack 损坏镜像层并完成平滑升级

在阶段一排查中发现的 OrbStack 虚拟磁盘 BTRFS bad tree block start 错误,属于虚拟机镜像层元数据受损。为防止未来拉取其他容器镜像时发生只读熔断,我将其他独立容器卷备份导出后,直接执行了虚拟环境的原子重置:

# 干净重建无坏块的 OrbStack 内部虚拟磁盘
orbctl reset -y

随后重新拉取了 Immich v3.3.3 的官方镜像。在健全的依赖门控护航下,容器组一次性点火成功,并平稳顺畅地执行完毕了新版本的 4 项数据库 Schema 演进迁移:

  • 1789419229196-ConvertUserOAuthIdEmptyStringToNull
  • 1790587508209-RenameGeoNamesCountries
  • 1790616293884-PersonSharing
  • 1790693088454-AddPersonUserTableSharedBySharedWithConstraint

四、最终战果与多维对比

经过这套组合拳的系统级治理,整套外置存储架构的稳定度得到了质的飞跃。

关键性能与可用性指标对照表

评估维度 修复与治理前 修复与治理后 改善幅度与工程收益
PostgreSQL 恢复预同步耗时 8~10 分钟以上(串行 fsync) 0.42 秒(全局 syncfs) 提速 >1400 倍,启动即用
immich-server 启动行为 进程启动即冲刺,遭遇 57P03 崩溃退避 严格等待依赖容器 healthy 后拉起 一次性秒级点火,零错误退出
数据库块级校验和完整性 asset_job_status 表存在 4 个破损页 全库全表 0 坏块,冷备份 100% 验证通过 数据零丢失,补齐 13 条占位记录
Valkey 缓存与虚机底层存储 RDB 损坏导致启动即崩,BTRFS 只读熔断 升级为 Valkey 9 稳定哈希,重置干净 BTRFS 消除宿主机虚拟化层隐形故障
服务端版本 v3.2.2 v3.3.3(顺利跑通 4 项迁移) 紧跟上游最新特性与安全补丁

现网实机运行健康状态验证

目前,位于外置固态硬盘上的 Immich 全套容器组已持续稳定运行:

$ docker compose ps
NAME                IMAGE                                  STATUS                    PORTS
immich_postgres     ghcr.io/tensorchord/pgvecto-rs:...    Up 2 hours (healthy)      5432/tcp
immich_redis        docker.io/valkey/valkey:9@sha256:...   Up 2 hours (healthy)      6379/tcp
immich_server       ghcr.io/immich-app/immich-server:...   Up 2 hours (healthy)      0.0.0.0:2283->2283/tcp

通过 HTTP 客户端验证 Web 端口与版本 API:

$ curl -s http://localhost:2283/api/server/version
{"major":3,"minor":3,"patch":3,"prerelease":null}

$ curl -I -s http://localhost:2283/ | head -n 1
HTTP/1.1 200 OK

服务毫秒级响应,相册浏览、智能搜索、人脸识别聚合以及手机端后台自动同步全链路恢复如初。


五、外置存储部署容器化数据库的实操避坑心得

通过这次惊心动魄的实战攻坚,我总结了 3 条对于所有将容器或数据库挂载在外置存储介质(如 USB SSD / DAS 盘阵列)上的高价值避坑准则:

  1. 永远警惕跨文件系统协议(VirtioFS / 9P / NFS)下的元数据刷盘开销:
    在虚拟化或多层文件系统架构下,逐文件调用 fsync() 是极其高昂的致命陷阱。只要使用的是 Linux 5.8 及以上内核,务必为 PostgreSQL 开启 recovery_init_sync_method = syncfs,让内核底层以文件系统为单位一次性刷写脏页,避免单文件穿透带来的 I/O 乘法效应。
  2. 永远不要信任默认数组写法的 depends_on:
    在生产与自建家庭服务器环境中,只要后端业务进程(Node.js / Python / Go)强依赖关系型数据库或分布式缓存,就必须显式声明 condition: service_healthy。让应用层等待数据层通过自检探针后再点火,能够防御 90% 以上由重启冷启动时延引发的服务雪崩。
  3. 外置移动磁盘的断开一定要有“仪式感”:
    绝不要因为固态硬盘速度快而在开机状态下随手拔掉雷电或 USB 连接线。在物理断开外置存储前,必须先在终端执行 docker compose stop 保证数据库完成 DB_SHUTDOWNED 干净落盘,方能彻底杜绝由块级断电部分写入(Partial Write)导致的静默校验和损坏。

原文链接与参考资料