# SOW：论母猪的产后护理
> 原文发布于 [VONNG](https://vonng.com/db/sow/)。

今天老冯来和大家聊一聊《母猪的产后护理》。俺做的新开源项目 SOW，翻译成中文就是“老母猪”。

做一个 PostgreSQL 发行版，最折磨人的往往不是把软件编译出来，而是收拾编译出来的东西。

Pigsty 要为多个 Linux 发行版、多个 CPU 架构、多个 PostgreSQL 大版本维护成百上千个组件。不同组合一路展开，最终落到仓库里的制品超过十万个：RPM、DEB、索引、签名、校验和、快照，还有一堆为了兼容包管理器而存在的元数据。

用户看到的只是 `apt install` 或 `dnf install`。维护者看到的却是另一幅画面：你只更新了一个包，却必须保证另外九万九千九百九十九个对象没被误删；你只改了一份索引，却必须保证全球用户不会在切换瞬间读到一半新、一半旧的仓库。当仓库小的时候，这些事都像脚本题。仓库大到十万个制品之后，它突然变成了一道数据库题、分布式系统题，还是一道供应链安全题。

所以我写了 [SOW](https://sow.pgsty.com/zh/) —— 一个用 Go 编写的自包含 APT / YUM 软件仓库管理器。

![SOW 中文项目主页](sow-home.webp)

如果你只是想把一个目录里的 RPM / DEB 变成可用仓库，一条命令就够了：

```bash
sow create /www/pigsty
```

如果你要长期维护仓库，SOW 还提供 Managed 模式：一份包体投影成多个发行视图，记录期望状态与已构建状态，生成不可变快照，计算精确变更集，再增量发布到文件系统或对象存储。

一句话概括：**SOW 把“生成软件仓库索引”这件小事，和“治理一个长期运行的软件仓库”这件大事，装进了同一个单文件工具里。**

**这个工具纯粹是为了解决老冯自己的问题。但如果你也在维护大型、跨 Linux 发行版的软件仓库，它应该也能帮到你——虽然有这类需求的用户大概不会很多就是了。**

---

## 为什么叫 SOW？

这个名字值得单独讲讲。此前，我们做过另一个配套开源项目：[**Pig —— PostgreSQL Install Genius**](https://vonng.com/pg/pig/)；它是 PostgreSQL 生态的包管理器。既然有小猪负责装包，那么制作这些包、承载、组织与分发软件制品的仓库工具，自然就是“母猪” SOW 了。

![母猪造型的 USB 集线器](sow-usb-hub.webp)

它还可以展开为 **Software Object Warehouse** —— “软件对象仓库”。这个来自工业史的词，正好严丝合缝地落进软件供应链；更妙的是，它还有另一层双关：在传统铸铁场里，铁水先流入一条主槽，再分流到两侧的小槽里，冷却成一块块铁锭。那些铁锭叫 **Pig**，承载和分配铁水的主槽叫 **Sow**。从上方看，一条大槽带着一排小铁锭，正像一头母猪带着一窝小猪。

![Pig iron 与母猪、小猪命名的历史渊源](pig-iron.webp)

---

## 为什么要再造一个仓库工具？

最直接的诱因来自 Pigsty 的离线安装。Pigsty 会先把安装所需的 RPM / DEB 下载到本地，再生成一个离线软件仓库。过去，RPM 系统依赖 `createrepo_c`，Debian / Ubuntu 依赖 `dpkg-dev`。真正要生成的不过是几份 XML、Packages 与压缩索引，准备工具链却要装进几百 MB 的依赖。

这在 Linux 上已经够啰嗦；到了 macOS 上更难看。你得启动不同的 Linux 容器，挂载同一份目录，分别跑 RPM 与 DEB 工具，再把结果搬回来。为了生成几 MB 元数据，先请来几百 MB 工具链和一支容器车队，怎么看都不优雅。

![Pigsty 使用 SOW 替换 RPM 与 DEB 仓库工具链](pigsty-offline-diff.webp)

到了 [Pigsty 4.5](/zh/blog/article/v4.5/)，我把这套冗余清掉了。SOW 是一个几 MB 级的自包含二进制，在 Linux 和 macOS 上都能直接运行，同时理解 RPM / DEB 包格式与 APT / DNF 仓库规范。它没有守护进程，也不需要额外的语言运行时。

但“少装几个工具”只是表面问题。真正让我决定把 SOW 做下去的，是仓库规模扩大后暴露出的四个痛点。

### 第一，硬链接救不了对象存储

同一个 `noarch` RPM 可能同时出现在多个架构仓库里，同一份包也可能进入 beta、latest、stable 等多个视图。放在本地磁盘上，可以用硬链接让许多路径共用一份 inode；上传到 Cloudflare R2、OSS 或其他对象存储后，每个 `object key` 都会变成一份实打实的存储与上传成本。文件内容相同，不代表云端知道它们应该共享所有权。

### 第二，十万个文件让“比较一下”都变得昂贵

仓库更新通常只变动几十个包，但传统同步工具为了确认这一点，往往要把十几万个文件重新遍历、比较、校验一遍。全量检查一次花十几分钟并不稀奇；真正的数据传输可能只有几秒，时间全耗在“什么都没变”的证明上。

![传统仓库同步需要花费大量时间遍历和比较文件](rclone-sync.webp)

### 第三，在线仓库不应该露出半成品

一个 RPM 仓库不只是 `.rpm` 文件；客户端先读 `repomd.xml`，再沿着它找到 `primary`、`filelists` 和包体。APT 同理：`Release`、`InRelease`、`Packages` 与 `by-hash` 文件之间有严格引用关系。

如果更新顺序错了，客户端就可能先看到新指针，却找不到新指针引用的对象。对维护者来说只是几秒钟的上传窗口，对全球随机到访的用户来说，就是一次无法复现的 404、校验失败或安装中断。

![SOW 的自包含二进制与增量发布能力](sow-features.webp)

### 第四，目录没有版本，发行版需要版本

最核心的问题是：当老冯想进一步改进仓库、提供 Channel 能力时，之前的维护模型就会遇到这些问题：如何同时维护 beta、latest、stable 仓库？如何每月保存一个快照？如何回答“上周三到底加了哪些包”？如何安全回退？哪些旧对象已经没有任何快照引用，可以删除？

这些需求单独看都能用脚本拼出来；组合到一起，脚本就会长成一套没有事务、没有模式、没有审计的影子数据库。市面上当然有 `createrepo_c`、`dpkg-scanpackages`、`reprepro`、`aptly`，也有通用同步与对象存储工具。但我没有找到一个足够轻、同时把 RPM 与 DEB、单份包池、不可变快照、原子发布和增量交付放进同一套清晰模型里的开源工具。

幸运的是，自己做工具的成本从来没有像现在这样低过。

---

## 两种复杂度，两种模式

SOW 并不假定所有仓库都需要同样的治理强度。它把问题切成 Plain 与 Managed 两层：小问题保持小，大问题才使用完整状态机。

### Plain：包目录就是事实

Plain 模式只有一个核心命令：

```bash
sow create /srv/repo

# Pigsty 离线仓库兼容模式
sow create /srv/repo --pigsty
```

目录里的 RPM / DEB 是唯一权威事实，`repodata/`、`Packages` 与 `Packages.gz` 都是可以随时丢弃重建的投影。SOW 会并行扫描顶层软件包，每个包在默认路径上只打开一次，在同一遍里完成 SHA-256、解析和渲染所需事实的提取，再生成两种仓库元数据。

生成结果先进入同文件系统的私有 staging 区，经过 SOW 自己的解析器校验后再替换公开文件。最后，它只重新比较文件集合与 `stat` 快照，确认构建期间没有包被新增、删除或替换；不会为了“再放心一次”把所有大包重新哈希一遍。

![SOW Plain 平面仓库文档](plain-repo-docs.webp)

Plain 不保存操作日志，也不做沉重的事务恢复。进程中断了，就重新执行同一条 `sow create`：包目录还在，索引只是派生状态，重建比恢复更便宜。

`--pigsty` 模式还会最后写入 `repo_complete` 完成标记。只要这个标记不存在，消费方就知道这份仓库还没准备好。这是一个很小、但非常实用的提交协议。

这套模式解决了 Pigsty 最初的痛点：用一个小二进制替代两套工具链与多个容器，快速得到能被真实 APT / DNF / YUM 客户端消费的仓库。

### Managed：仓库不是目录，而是状态机

长期运行的仓库不能只看“目录里现在有什么”。它还必须知道你**想要什么**、上一次成功发布了什么，以及两者为什么不同。

SOW 的 Managed 模型分成四层：

![SOW Managed 模式的四层结构](managed-hierarchy.webp)

Workspace 是配置与发现边界；Repository 是所有权边界；Dist 是一个具名的 RPM 或 DEB 成员集合；Architecture View 只是渲染结果，不再拥有一份软件包。这里最重要的不变式是：**在一个 Repository 内，每个软件包只有一份正典包体，不留副本。**

```text
repo/pool/...                              唯一包体
repo/dists/el9/x86_64/repodata/...         RPM 元数据视图
repo/dists/trixie/main/binary-amd64/...    APT 元数据视图
```

`noarch` RPM 或 `all` DEB 可以被投影进多个架构索引，却不会复制包体。beta 与 stable 也可以引用同一个软件包对象，而不制造第二份云端 `object key`。而且更妙的是，APT 和 DNF 仓库可以在同一套目录体系下管理。

![SOW 的包池与 RPM、APT 元数据视图](package-pool.webp)

“只存一份”的边界必须说准确：它是一个 Repository 或一个发布前缀，不是整个 Workspace、`bucket` 或全世界。不同 Repository 之间不做隐式去重，因为去重不能以破坏所有权为代价。删掉一个仓库，绝不能顺手删掉另一个仓库依赖的共享对象。

---

## Desired、Built 与 Generation

Managed 模式把仓库状态拆成三个概念：

| 状态 | 含义 |
|:---|:---|
| Desired | 配置、加包、删包操作想要得到的成员集合 |
| Built | 上一次完整渲染、校验并提交成功的公共视图 |
| Generation | 对某个 Built 状态的不可变清单 |

这个区分看似学院派，实际上专门解决失败场景。

假设你一次加入五千个包，Desired 已经改变，但构建在中途被 `SIGKILL`。没有这层区分，系统只能面对一棵“不知道改到哪儿”的目录；有了它，SOW 可以诚实地说：意图已经更新，上一个 Built Generation 仍完整对外服务，新操作处于待恢复状态。

Generation 不是把整个仓库再复制一遍。它保存的是不可变 manifest、元数据与包体引用集合；多个快照可以引用同一份 Pool 对象。两代 Generation 之间的精确差异就是 Changeset：新增哪些包体、替换哪些元数据、切换哪些指针、哪些旧对象在保留期后可以删除，一目了然。

```bash
sow status  -r pigsty
sow changes -r pigsty
sow log     -r pigsty
```

所以增量同步不再从“重新扫描十万个文件”开始，而是从“比较两个已知 Generation”开始。

![SOW 的 Plain 与 Managed 能力矩阵](sow-capabilities.webp)

---

## 原子切换的秘密：最后才动指针

软件仓库没有一个跨文件、跨对象的全局事务。SOW 的做法不是假装它存在，而是把发布顺序设计成可证明的协议：

```text
payload  →  metadata  →  pointer  →  delete
 包体          元数据          指针          删除旧对象
```

先放入不可变包体；再写以 `checksum` 命名的元数据与 `by-hash` 索引；全部就位之后，最后才切换 `repomd.xml`、`Release` / `InRelease` 这些客户端入口。只有旧指针已经不再引用旧文件，并且保留期与证据门禁都满足，旧对象才允许删除。

因此，客户端沿着一个生效指针往下走时，它引用的内容一定已经存在。对单个协议视图来说，读者看到的要么是完整旧代，要么是完整新代，不会看到一棵被撕裂的树。

在本地 POSIX 文件系统上，这套过程依赖同盘 `staging`、`fsync`、原子 `rename`、稳定路径锁和持久操作日志。每条 Managed 写命令都会先检查并恢复上一次未完成操作，再开始自己的工作。恢复只根据已经落盘的证据判断该回滚还是前滚；证据矛盾时宁可中止并保持关闭状态，也不提供一个可能猜错的 `repair --force`。

对象存储不支持跨多个 Key 的原子提交，SOW 就先持久化 `commit intent`，再按确定顺序前滚协议指针，并为每个 `target` 单独保存 `Applied Checkpoint`。当前文件系统与 R2 发布各有自己的证据，前者成功绝不会被误认为后者也成功。R2 端如果缺少足够安全的条件删除证据，垃圾回收就只报告候选，不冒险远程删对象。

这也是 SOW 与一条 `rclone sync` 命令最本质的区别：传输文件不难，困难的是知道**哪些能传、何时算提交、失败后往哪边恢复，以及哪些真的可以删**。

---

## 十万个对象，性能不能靠信仰

SOW 0.3 的主要工作，不是继续堆功能，而是把已经成立的模型推到真实仓库规模。

Plain 路径现在每个包只做一遍内容读取、哈希与解析，并通过 `--jobs` 使用有界并发。输入相同时，生成的元数据逐字节一致；无须更新时返回 `no-op`，也不会为了“更新一下时间戳”替换公开 inode。

Managed 路径则把解析后的“软件包事实”按不可变 SHA-256 缓存在 SQLite 中。新包入库时完整认证并解析一次；后续构建批量载入事实，在内存里完成成员投影。暖构建仍会遍历公开命名空间，但对未变化的 Pool 文件只检查 `device`、`inode`、`size`、`mtime`、`ctime` 指纹，不再把所有包体重新读一遍。指纹漂移时才回退到一次权威 SHA-256，并自动修复缓存；需要全量密码学审计时，显式运行 `sow check`。

这类优化的价值，必须落在数字上。

在项目基准中，一个包含 5,000 个对象的 Dist，成员展开从约 **4.1 秒降到 33 毫秒**；50,000 个对象原先十分钟仍跑不完，现在约 **300 毫秒**完成。载荷提升也改成了有界单写入者组提交，每批最多 512 个对象或 1 GiB，既减少 `fsync` 风暴，也保证文件描述符和恢复状态不会随着仓库规模无限增长。

这些数字不是为了做一张跑分海报。它们只是说明：当仓库真的有十万个制品时，“状态模型正确”只是及格线，“日常小改动仍然足够便宜”才决定工具能不能被长期使用。

---

## 推倒第一版，再从最小闭环长回来

SOW 的开发时间不短，中间还经历过一次相当彻底的推倒重来。

最初那一版后来以 **v0.1.0** 留档。它野心很大：用 Git ref 管理仓库视图，用 SHA-256 CAS 保存制品，同时处理上游同步、多目标云发布、校验、修复、垃圾回收、Cloudflare Worker、CDN purge、边缘验证和生产迁移。

这些功能很多都已经做出来了，部分路径也通过了真实 APT / DNF 客户端与非生产 R2 环境的验收。但它的问题同样明显：仓库模型、云厂商、CDN、边缘运行时与迁移流程耦合得太紧。一项功能的正确性，要靠另外半套系统才能证明；任何小改动都会拖着一长串验收矩阵一起移动。

我最后决定把它封存。

推倒的不是目标，而是抵达目标的方式。一个基础设施工具最怕“什么都有一点，但没有哪一层能单独说清楚”。所以第二版先把最小闭环重新切出来：

- **P0 / Plain Create：** 一个目录进去，一个可用仓库出来；
- **P1 / Managed Control Plane：** Workspace、Repository、Dist、Membership、Build、Generation、Check、Changes 与 Operation Log；
- 更复杂的同步、远端发布、CDN 与供应商控制面，逐项回到独立验收队列。

**v0.2.0** 建立了今天的 Plain + Managed 主体：单份包池、元数据视图、确定性构建、锁、日志、崩溃恢复、Generation、文件系统与 R2 发布。

**v0.3.0** 没有再造一层概念，而是集中清理旧 V1 运行时，收敛云端传输边界，并解决 Plain 与 Managed 在大仓库上的重复读取、逐对象查询、载荷提交和可观察性问题。当前正式二进制只依赖新的 V2 核心，旧实现留在 Git 历史与 `v0.1.0` / `v0.2.0` 标签里，作为经验，而不是第二套事实来源。

这条路看上去比“一次憋个大的”慢，其实更快。每一层都有独立契约、失败语义和真实客户端验收，下一层建立在已经站稳的地基上，而不是建立在一份越来越难读的愿望清单上。

![SOW 命令索引](sow-commands.webp)

---

## 接下来的 Roadmap

SOW 0.3 已经能创建仓库、管理成员与快照、计算变更集，并发布到文件系统和 R2；但它离我脑子里完整的软件制品控制面还有距离。

接下来主要有四条线：

1. **上游仓库同步。** 直接消费 APT / YUM 上游索引，验证签名与摘要，只拉取缺失制品，并把镜像结果纳入同一套 Package Object、Membership 与 Generation 模型。
2. **更完整的增量交付。** 现在 `changes` 与 target checkpoint 已经能描述、复用并只发布差异；下一步是扩展对象存储与同步供应商覆盖，把大规模远端 inventory、断点恢复、条件写入和安全删除证据做成稳定闭环。
3. **CDN 与缓存控制。** CDN purge 不是“调一下 API”这么简单，还要绑定精确 Generation、缓存 TTL、回执与失败恢复。V1 已经证明这条路可行，但它会以独立、可验收的模块重新进入，而不是重新和仓库核心焊死。
4. **版本与保留策略。** beta、latest、stable、月度快照这些需求，现在已经可以用 Dist、Generation、retain 与 target 组合表达；后续还会补上更高层的策略编排，让常见发布节奏不必由外部脚本手工串联。

这些能力在第一版里大多已经有过实现。我不打算把旧代码整块搬回来，而会像 0.2、0.3 一样，一次只拿回一个边界清楚、能独立验收的能力。

不再憋大招，持续交付小而完整的闭环。

---

## 猪猪家族还在继续长大

SOW 是 Pigsty “猪圈宇宙”的一部分，而且这个命名体系已经越来越离谱，也越来越完整：

- [**Pigsty**](https://pigsty.cc)：猪圈，负责安装和管理 PostgreSQL 生态；
- **SOW**：母猪，负责组织、构建和发布软件仓库；
- **Boar**：公猪，开发中的 PIGSTY GUI 管控平台；
- [**Silo**](https://vonng.com/db/long-live-silo/)：筒仓，负责 S3 兼容对象存储；
- [**Oink**](https://vonng.com/db/oink-release/)：猪叫，负责文档与网站框架；
- **Snort**：猪拱，负责收集日志与监控指标。

这当然首先是一套命名梗，但它背后也慢慢长出了一条完整链路：SOW 整理制品，Silo 存放制品，Pigsty 把它们安装成系统，Snort 观察系统，Oink 把一切讲清楚。而 SOW 填上的，正是过去最容易被忽视的那一段。

软件仓库看起来只是一个能被 Nginx 托管的目录，但当它承载十万个对象、多个操作系统、多个架构与无数用户之后，它其实是一台没有界面的数据库：有对象、有关系、有版本、有事务、有日志、有垃圾回收，还有绝不能写错的提交指针。

SOW 做的事情，就是把这些隐含规则变成显式模型，把一堆“祖传脚本应该没问题”的侥幸，变成可以检查、恢复和审计的工程契约。如果你只想建一个离线仓库，可以从一条命令开始：

```bash
sow create /srv/repo
```

如果你也在维护一套长期运行的软件发行版，欢迎访问 [SOW 项目主页](https://sow.pgsty.com/zh/)，或直接阅读 [使用文档](https://sow.pgsty.com/zh/docs/)，看看它更深的一面。

SOW 采用 [Apache-2.0 许可证](https://github.com/pgsty/sow/blob/main/LICENSE)。当前 [v0.3.0](https://github.com/pgsty/sow/releases/tag/v0.3.0) 提供 Linux / macOS 的 amd64、arm64 归档，以及 Linux RPM / DEB 安装包；可以从 [下载页](https://sow.pgsty.com/zh/download/)获取，也可以直接查看 [源代码](https://github.com/pgsty/sow)。

十万个包并不可怕。可怕的是，它们还只是十万个文件。
