post: four posts — rclone/Drive quota, DSM space reclamation, mail domain/PTR, embedded fonts (EN+ZH) + og/banner
Deploy / build (push) Successful in 32s
Deploy / build (push) Successful in 32s
- my-backup-was-using-someone-elses-api-quota (notes, 2026-10-15, pinned by its own evidence) - space-reclamation-wasnt-stuck-it-was-waiting (devops, 2026-10-14, pinned by its own samples) - adding-a-domain-to-someone-elses-mail-server (notes, 2025-05-06, backdated: no date/version pins) - embedding-fonts-so-a-pdf-looks-the-same-everywhere (engineering, 2025-01-22, backdated: no date/version pins) All four updatedDate 2026-10-16 so every lastmod stays honest; the two backdated posts carry no prose links (a backdated post may only link backwards). og measured rows=1/overflow=0, banners 8 rows/delta=2/overflow=0.
This commit is contained in:
1 parent
636e6f9624
commit
08fa7a9412
18 files changed
+1036
No files matched your search
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: "给别人家的邮件服务器加一个域名"
|
||||
description: "收信只要四条 DNS 记录,一次就通了;而从它发信、以及转发它,撞上两堵任何 DNS 记录都修不了的墙。"
|
||||
pubDate: 2025-05-06
|
||||
updatedDate: 2026-10-16
|
||||
category: notes
|
||||
tags: ["email", "dns", "mailcow", "smtp", "deliverability"]
|
||||
ogImage: /og/adding-a-domain-to-someone-elses-mail-server.png
|
||||
banner: /banners/adding-a-domain-to-someone-elses-mail-server.png
|
||||
draft: false
|
||||
---
|
||||
|
||||
## 这件事看起来像个 DNS 活
|
||||
|
||||
有个域名要收信。邮件服务器是我自己那台跑 mailcow 的 VPS,所以这就是「加域名、加记录、建信箱」——二十分钟的活,而且我做过好几次。
|
||||
|
||||
这次花了更久,原因是我关于邮件服务器的两个假设并不成立,另外还因为互联网有一条任何服务器配置都改不了的性质。
|
||||
|
||||
## 收信:四条记录,加一次真测试
|
||||
|
||||
收信恰好只需要四样东西,而且都住在域名自己的 DNS 里(这里是 Cloudflare):
|
||||
|
||||
```text
|
||||
MX ninelore.com → mail.hoelee.com (优先级 10)
|
||||
TXT @ → v=spf1 mx -all
|
||||
TXT dkim._domainkey → (MTA 发布的那个选择器)
|
||||
TXT _dmarc → (策略 + 报告地址)
|
||||
```
|
||||
|
||||
配好之后,只用一种算数的方式测:**从这套环境外面**(走第三方中继)寄进新地址,然后去读服务器自己的日志行——`rspamd` 在收下这封信时记下 `RCPT_MAILCOW_DOMAIN(ninelore.com)`。收信一次就通。**收信确实是容易的那一半**,而它之所以容易,正因为它只依赖你自己能控制的记录。
|
||||
|
||||
## 出信才是地面移动的地方
|
||||
|
||||
两堵独立的墙,各有各的错误,而且都不是改一改区域文件就能修的配置失误。
|
||||
|
||||
**第一堵墙:那条反垃圾策略,本质上是缺一条 PTR。** 一次直投尝试被对方退回:
|
||||
|
||||
```text
|
||||
554 5.7.1 Mail rejected due to antispam policy
|
||||
```
|
||||
|
||||
在怪远端之前,我先排掉常见原因:四个主流黑名单全干净,域名自己的 SPF/DKIM/DMARC 也都正确。剩下的解释就是那条不在我控制里的记录——**发信 IP 没有反向 DNS**。一台收信服务器如果无法把连进来的 IP 映射回一个名字,就会把它当成一个它无法背书的来源;再正确的 SPF 也压不过这一点。
|
||||
|
||||
**第二堵墙:用中继绕过去也不行。** 下一个显然的动作是让所有信走那条已经能用的出信中继。结果是:
|
||||
|
||||
```text
|
||||
550-From header sender domain not verified (gmail.com)
|
||||
```
|
||||
|
||||
这行错误要仔细读,因为它一次打死了一整类设计。中继的「已验证寄件人」策略,检查的是 **From 头里的域名**——而**转发**出去的信保留的是原寄件人的 From。所以从你转发第一封来自外部寄件人的信开始,你就是在要求中继以 `gmail.com` 的身份发信,而它永远不会允许。mailcow 在这里还有个值得先知道的坑:它按**寄件人**域名选中继,所以一封 From 是外部域的信,根本不会走到你配的那条中继上。
|
||||
|
||||
## 那个并不存在的「信箱转发」功能
|
||||
|
||||
原计划是建好信箱,再转发一份到我的主邮箱。结果连着两个意外:
|
||||
|
||||
- mailcow **没有**「信箱转发」这个设置。把信箱地址写进别名的 `goto` 列表会被拒(`is_alias_or_mailbox`),因为每个信箱自带一条 self-alias 记录,代码在算目标时会跳过这个地址。
|
||||
- 想「保留信箱**并且**转发」只能走 **Sieve 过滤器**——那是另一套机制、另一种失败形态——而它最后仍然落在第一堵墙和第二堵墙上,因为这封信终究得离开这台机。
|
||||
|
||||
所以我**什么都没启用**。一条出信路径送不出去的转发,不会安静地失败:它会把退信发回给无辜的原始寄件人(backscatter),而每一封这样的退信都在花一个本来就没有余量的 IP 的信誉。对「这个新信箱要送到我的收件箱」这件事,稳定的答案是**收件侧的 POP 拉取**:我的邮件客户端从 `mail.hoelee.com:995` 拉信,VPS 什么都不用发。
|
||||
|
||||
## PTR 是一个 IP 一条,不是一个域一条
|
||||
|
||||
这是我一直需要解释、包括对我自己解释的部分:
|
||||
|
||||
- 反向记录是**IP 地址**的属性。不存在「每个域一条 PTR」,下次再加域也没有任何 PTR 要动。
|
||||
- 它正确的值就是邮件服务器自己对外报的名字——这里是 `mail.hoelee.com`,也就是 postfix 用作 HELO/myhostname 的那个。正向记录(`mail.hoelee.com → 41.216.187.102`)已经存在,**这一对**才是收信方要验证的东西:**forward-confirmed rDNS**。缺任何一半都一文不值。
|
||||
- 那个 IP 上所有域名都用同一条反向记录来评判。一百个域名,一条 PTR。
|
||||
- 谁来设:跑反向区的人。这里 `216.41.in-addr.arpa` 与 `187.216.41.in-addr.arpa` 两级的 SOA 都是 VPS 商自己的名字服务器,所以它是**服务商侧**的改动——先去 VM / 网络面板里找有没有 rDNS/PTR 输入框,没有就提工单,把上面那个确切的值给他们。
|
||||
|
||||
我没有假设,而是去查了当前状态:正向解析正常(`mail.hoelee.com → 41.216.187.102`),**那个 IP 的反向解析依旧什么都不返回**。所以直投仍然会被拒,唯一能用的出信路径是中继——而它那条「已验证寄件人」的要求,新域名一样得满足。
|
||||
|
||||
## 三件要提前知道的事,免得以后被吓到
|
||||
|
||||
1. **PTR 不是信誉。** 它拆掉的是一次硬拒;它不会让你被信任。新 IP 通常需要几周的日常通信,大厂才会停止把它塞进垃圾箱——而从全新 IP 起步,就是把这口钟从零开始拨。
|
||||
2. **PTR 修的是你自己的出信,对转发毫无帮助。** 转发信带的是别人的 From;如果那个域发布了 `p=reject`(银行和大厂都会),那么不管你这边多干净,它在远端都会被挡。
|
||||
3. **中继有一个按域的前置条件。** 一个域要走中继,就必须先在中继那边注册并验证它为寄件人。这是每个域永远多出来的一步,且与你自己的 DNS 里的一切无关。
|
||||
|
||||
## 如果重来一次
|
||||
|
||||
1. **在建域之前先定:要信箱还是要转发。** 转发是「必须成立的条件」最长的那条路;收件侧 POP 拉取几乎没有条件。
|
||||
2. **在向任何人承诺邮件之前,先查发信 IP 的反向记录。** 一次反向查询就能告诉我接下来会撞上哪堵墙。
|
||||
3. **把「收件侧拉取」当一等设计,而不是权宜之计。** 「这封信根本不用离开我的服务器」一次消掉三大类故障。
|
||||
4. **把那四条收信记录当成一张清单。** 那一半很无聊、可验证,而且是唯一一次就通的部分。
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: "把字体嵌进 PDF,让它在任何机器上都长一样"
|
||||
description: "四个字族、八个字面、两个渲染引擎,外加一条 CSS 怪癖:为了不让生成的报告依赖服务器上恰好装了什么字体,我做了什么。"
|
||||
pubDate: 2025-01-22
|
||||
updatedDate: 2026-10-16
|
||||
category: engineering
|
||||
tags: ["pdf", "fonts", "mpdf", "typography", "php"]
|
||||
ogImage: /og/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png
|
||||
banner: /banners/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png
|
||||
draft: false
|
||||
---
|
||||
|
||||
## 需求只有一句话
|
||||
|
||||
一个报告生成器必须产出在任何地方都长得一样的文档:中文标题用宋体、中文正文用黑体、英文标题与封面数字用一款展示衬线体、英文正文和表格用 Inter。字重也要对——强调用 medium、标题用 semibold——而且是三语、两个 PDF 渲染引擎都要对。
|
||||
|
||||
同时,**字体必须跟着文档走**。不是「服务器装过」,也不是「读者大概有」,而是**嵌进文件里**,这样另一个国家的印厂看到的页面是一样的。
|
||||
|
||||
老管线在最后这句上栽得很隐蔽:它依赖服务器上用包管理器装过的字体,而且**只嵌入了一款中文字体**。第二档字重根本不存在,于是「粗体」是渲染器的合成近似;而想发现这件事,唯一的办法是打开 PDF 去看里面到底装了什么。
|
||||
|
||||
## 规则一:从成品 PDF 里把字体读回来
|
||||
|
||||
「已嵌入」是一个关于**输出**的断言,所以我量的是输出。同一份文档,改前与改后:
|
||||
|
||||
| | 改前 | 改后 |
|
||||
|---|---|---|
|
||||
| zh 页数 | 23 | **23** |
|
||||
| en 页数 | 28 | **29** |
|
||||
| bm 页数 | 30 | **30** |
|
||||
| 真正嵌进去的字体 | 一款中文字体 + Roboto | 思源宋 Medium + SemiBold · 思源黑 Regular + Medium · Cormorant Garamond Medium + SemiBold · Inter Regular + SemiBold + Medium · 拉丁报告里的汉字走思源黑 |
|
||||
| 渲染时间 / 峰值内存 | 基线 | 同一量级(±10%,属噪声) |
|
||||
| 文件体积 | 基线 | 约 +3% |
|
||||
|
||||
每个字面的字形数是收据:中文报告里 15,988 个字形落在正文黑体、2,247 个落在它的 medium 档、634 个落在标题宋体的 medium、222 个落在它的 semibold。这就是「字体在服务器上」与「字体在文件里」的区别——另一个国家的读者看到的是同样四个字面,而代价是某一种语言正好多一页(拉丁字体的度量比它替换掉的那个更宽)。
|
||||
|
||||
## 规则二:只出静态实例,理由写在解析器里
|
||||
|
||||
现代做法看起来该是「每个字族发一个可变字体」,但在这里是错的,有两个实测理由:
|
||||
|
||||
1. **PDF 引擎的字体解析器只读 `glyf`/`loca`。** 喂它一个 PostScript/CFF 轮廓的 OpenType 文件,它直接拒绝:`Fonts with postscript outlines are not supported`。问题不是「可变」,是 CFF 轮廓。
|
||||
2. **它完全不读 `fvar` 表**,所以可变字体会**静默地**解析成它的**默认实例**——而中文字族的默认实例恰恰是最细的那一档(`name` 表里写得清清楚楚:Thin 100 与 ExtraLight 200)。整份报告会变成发丝体,而整条管线里不会报任何一个错。
|
||||
|
||||
所以构建步骤拿上游的可变字体**实例化成静态切面**:跑实例化器,然后把 `name`、`OS/2.usWeightClass` 和 `head.macStyle` 改写成它现在真实的字重(源文件的名字里还写着 Thin)。产物旁边有一份 manifest,逐文件记下 family、字重、sha256、字形数与码位数;许可证也随文件一起进仓库。
|
||||
|
||||
## 规则三:引擎只会说两种字重,那就映射到两个槽位
|
||||
|
||||
渲染引擎的 CSS 只认 `normal` 和 `bold`——它的源码自己就这么写("Does not support: bolder, lighter, 100..900")。所以数字字重对它毫无意义;而同一条管线里的无头 Chromium 又完全认它们。
|
||||
|
||||
可用的映射是:每个字族声明一个 **R 槽**和一个 **B 槽**(常规一档、更重一档),`font-weight: 500` 落到目标字族的 R 槽,`600` **只在 PDF 那一侧被改写成 `bold`**,落到 B 槽。Chromium 侧照写 `600`。两条测试钉住它:B 槽必须比 R 槽重(读 `usWeightClass`,不是假设),且 `font-weight: 600` 绝不允许出现在交给 PDF 引擎的样式表里。
|
||||
|
||||
有一处细节值得单写一段:在这个管线里,**medium 不是强调手段,而是一个字族成员**。中文标题用宋体的 medium 当常规、用 semibold 做强调;英文标题用展示衬线体做同样的事。这里搞错,屏幕上完全看不出来,而印出来一目了然。
|
||||
|
||||
## 规则四:不要按你控制不了输入的字集做子集化
|
||||
|
||||
第一版把中文字体按两个国家字集的并集做了子集,产物从约 50 MB 降到约 29 MB。然后我跑了姓名探针,发现这些字被裁掉了:
|
||||
|
||||
```text
|
||||
犇 骉 頔 祎 廸 玧 祾 赟 堃
|
||||
```
|
||||
|
||||
每一个都是真实的人名用字。报告会把客户姓名印在封面上,而姓名是自由输入——所以一套基于子集的构建,迟早会在产品最醒目的那一页上放一个豆腐块。全字形覆盖是约 31k 码位、约 50 MB 的嵌入字体;为了永远不给客户看一个空方块,这个价钱很便宜。
|
||||
|
||||
## 规则五:把「缺字」变成构建失败,而不是惊喜
|
||||
|
||||
闸门跑三项检查,任何一项不过就非零退出:
|
||||
|
||||
- manifest 里声明的每个字体文件都在,且 sha256 与磁盘上的文件一致;
|
||||
- 内容库(三语全部)+ 品牌字面 + 各类探针(简体、繁体、CJK 标点、全角、中英混排、数字、拉丁扩展、符号)+ 姓名用字,**每一个字在「它实际会被哪一档字体印出来」的那一档里都有字形**;
|
||||
- 查字形用的是 PDF 引擎自己的解析器,而不是第三方库,所以这条检查不可能和渲染器互相矛盾。
|
||||
|
||||
已知边界写在明处,不藏起来:✅ ✔ 这类符号在这几款正文字体里都没有字形,而引擎也画不了彩色 emoji,所以它们不出现在报告正文;拉丁标题那款展示衬线体只有 974 个字形,不画 ① ★ ℃,这也是为什么内容库的检查覆盖**所有**槽位而不只是正文;而拉丁报告里的汉字必须走专门的 CJK 槽位——两条测试防止两个渲染器在这点上产生分歧。
|
||||
|
||||
## 如果重来一次
|
||||
|
||||
1. **在动样式之前先定字族与字重。** 版式做完之后再去加第二档字重,意味着每一页页数都要手工复核一遍。
|
||||
2. **选字体格式之前先读渲染器的源码。** 可变字体看着是审美问题,实际上是解析器问题——两个文件、十分钟就定了。
|
||||
3. **把要嵌进文档的一切当作许可证产物。** 字体文件连同许可证一起进仓库,否则就得进别人的法务审查。
|
||||
4. **量「到底嵌进去了什么」。** 页数、每个字面的字形数、文件体积、渲染时间:四项都动了,其中三项的方向和我猜的相反。
|
||||
|
||||
## 结果
|
||||
|
||||
改前:一款中文字体加一款界面字体、靠合成粗体、还取决于服务器装过什么。改后:四个字族、八个静态字面真正嵌在文件里,字重是各个字族真实的成员,页数与上一版相差不超过一页,而且有一条 lint 闸门:某个字没有字形时直接让构建失败——于是失败形态从「客户看到一个方块」变成了「CI 拒绝发版」。这跟这条管线里其它地方的取舍是同一个:**让错误发不出去,而不是让错误容易被看见。**
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: "我的备份爬得比爬虫还慢,因为我在用别人的 API 配额"
|
||||
description: "1 TB 的 restic 种子传输卡在零字节。日志里点名的那个 Google 项目不是我的:那是 rclone 的共享 OAuth 客户端,配额和全世界每个 rclone 用户共池。"
|
||||
pubDate: 2026-10-15
|
||||
updatedDate: 2026-10-16
|
||||
category: notes
|
||||
tags: ["rclone", "backup", "google-drive", "oauth", "restic"]
|
||||
ogImage: /og/my-backup-was-using-someone-elses-api-quota.png
|
||||
banner: /banners/my-backup-was-using-someone-elses-api-quota.png
|
||||
draft: false
|
||||
---
|
||||
|
||||
## 订阅到期了,我得搬一个 TB
|
||||
|
||||
这件事以最不方便的方式找上门:承载我每晚备份的那个 OneDrive 账号已经没有订阅了,而手上还躺着一个 1.5 年没用的 Google 订阅。把备份搬过去、保住历史、别丢一晚。
|
||||
|
||||
第一件撞上的不是技术问题:**我记不清这套备份到底是谁在写**。rclone?Duplicati?还是群晖自带的什么?在一套 78 个 compose 栈、三台机器的环境里(就是[《一个人跑生产基础设施》](/posts/running-production-infrastructure-solo/)里那套),「我的备份是谁写的」本该是我张口就答的问题,而它当时不是。所以第一步是去读机器。
|
||||
|
||||
## 现场到底有什么
|
||||
|
||||
**两份 rclone 配置**,正是那种会咬人的细节:Backrest(restic 的前端)用的是 GUI 那份,而另外还有一份**陈旧的 CLI 副本**,被某个 cron 或某次人手 shell 用过。改了一份,等于只改了一半系统。
|
||||
|
||||
搬迁移本身在顺序对的时候很无聊,而我是先读后动的:
|
||||
|
||||
```text
|
||||
rclone.conf 追加 [gdrive-*] + [gdrive-*-crypt]
|
||||
rclone/rclone.conf(CLI 副本) 同样两个 remote
|
||||
Portainer stack 177 env → 新的 crypt remote;配置 :ro → :rw
|
||||
restic init 在 Google Drive 上建新仓库
|
||||
Backrest SetConfig dsm-repo 的 URI → 新位置
|
||||
```
|
||||
|
||||
在动计划 URI 之前,我先去源码里确认了一件一切依赖它的事:**restic 仓库的身份是 `guid = sha256(id)`** —— 一个由仓库 id 派生的稳定值,**不是**从 URL 推出来的。所以把计划指到新后端,不可能悄悄把历史分叉。六个计划事后逐字节与原版一致,仓库的 `id`/`guid` 未被动过。
|
||||
|
||||
## 然后种子停住了,而日志里点了一个陌生人的名字
|
||||
|
||||
新仓库灌到 1075 GB 里的大约 2.88 GB —— 0.27% —— 然后慢成爬行。实时日志里有答案:
|
||||
|
||||
```text
|
||||
'Read requests per minute' ... reason: RATE_LIMIT_EXCEEDED
|
||||
consumer: projects/202264815644 <- 不是我的项目
|
||||
quota_limit_value: 840000
|
||||
```
|
||||
|
||||
`202264815644` 是 **rclone 自己的共享 OAuth 项目**。我那个 TB 级种子吃的是每分钟读取配额,和地球上每一个 rclone 用户共池——这就是为什么它从约 750 KB/s 掉到零,而我的 NAS、我的磁盘、我的线路全都闲着。等我去量的时候,种子已经完全停摆:**90 秒内 0 字节**、159 个错误、依旧 `rateLimitExceeded`;而六个夜间计划也全撞同一堵墙:
|
||||
|
||||
```text
|
||||
Fatal: unable to save snapshot: unexpected HTTP response (500)
|
||||
googleapi: Error 403: Quota exceeded for quota metric 'Queries'
|
||||
and limit 'Requests per minute' ...
|
||||
```
|
||||
|
||||
在进度条里,「慢」和「被挡住」看起来一样,而它们是两个完全不同的问题。**采样 90 秒得到的零**,才是让我做出这个判断的东西。
|
||||
|
||||
## 你必须有自己的 OAuth 客户端,而控制台绕不过去
|
||||
|
||||
唯一的真修法只有一个:别再用共享客户端,用你自己的。也就是一个 Google Cloud 项目、一个 OAuth 同意屏、一个凭据。两个设置花掉了真正的时间:
|
||||
|
||||
- **Application type 必须选 Desktop app。** rclone 的回调是 `http://127.0.0.1:53682`,只有这个类型能不用额外配置就允许它。
|
||||
- **要点「Publish app」。** 留在 Testing 状态,Google 会**每 7 天**让刷新令牌过期一次——那就把一个「能跑」的系统变成了每周一次的静默故障。
|
||||
|
||||
我查过能不能用机器上已经认证好的 CLI 抄近路。不能,而原因值得记下来:这里的 `gcloud` 只以一个**别人的项目里的服务账号**登录,而服务账号**无法**创建同意屏或 OAuth 客户端 ID。凭据必须属于一个真人拥有的项目。这是一条死路——我是验证出来的,不是猜的。
|
||||
|
||||
## 授权流程里的三个坑
|
||||
|
||||
1. **未经验证就发布的应用,可能会硬性堵死受限 scope。** `.../auth/drive` 是受限 scope,一个**已发布但未完成 Google 审核**的应用有时会被直接拒绝且**没有旁路**。我这一次旁路是存在的——「Google hasn't verified this app」页面,然后 **Advanced → Go to rclone-backup (unsafe)**——但那取决于 scope 与审核状态,是一枚硬币,而不是保证;而「unsafe」只是个标签,意思是从没走过付费审核。
|
||||
2. **回调只接受一次请求。** 刷新页面,或者开第二个标签,都会在**没有 code** 的情况下打到 `127.0.0.1:53682`,把这次尝试弄死。我第一次就是这么死的(`no code returned`),而它看起来像凭据错了。**一个标签,别刷新。**
|
||||
3. **同意屏必须在跑 rclone 的那台机器的浏览器里完成。** 回调指向 `localhost`,所以手机或另一台电脑完不成——浏览器必须和等待那个端口的进程在同一台主机上。
|
||||
|
||||
之后就简单了:把 token 读出来,把 `client_id` + `client_secret` + `token` 写进**两份**配置(GUI 那份和陈旧的 CLI 副本),并恢复两个对长时无人值守上传很关键的开关:`use_trash = false` 与 `stop_on_upload_limit = true`。
|
||||
|
||||
## 旧的那一半:靠读,不靠问
|
||||
|
||||
清空旧 remote 时报了错——`itemNotFound`——而空间照样回来了:
|
||||
|
||||
```text
|
||||
Used: 1.012 TiB -> 3.800 GiB
|
||||
Free: ~0 -> 1.007 TiB
|
||||
DSM 文件夹: 在 -> 不在 回收站: 0 B
|
||||
```
|
||||
|
||||
那个错误是 rclone 在**干完活之后**发的抱怨。另外值得记一笔:工具的文档说个人账号无法 `permanentDelete`,按它你就得手动清回收站——实际是回收站已经是空的,约 1 TB 就这么回来了,不需要任何浏览器步骤。文档描述的是 API,而 API 不总是你拿到的东西。
|
||||
|
||||
还有一条量出来的事,因为它改变了我下一个决定:把那台机器上共享的同步容器暂停之后,负载均值直接减半——16.70 → 8.89。这就是「网络慢」和「机器忙」的区别。
|
||||
|
||||
## 如果重来一次
|
||||
|
||||
1. **从第一天起就用自己的 OAuth 客户端。** 共享客户端 ID 是一条你排在里面、却看不到进度、也找不到人负责的队伍。
|
||||
2. **「我的备份是谁写的」要写在文档里,不靠记忆。** 两份 rclone 配置,对一个一年没人画过图的系统来说是很正常的漂移量。
|
||||
3. **读目的地,别读退出码。** 那次清空「失败」了,却释放了一个 TB;而一次看起来成功的改名,可能什么都没落地。跟[《它报告成功,其实什么都没变》](/posts/it-reported-success-nothing-had-changed/)是同一课——这套环境一直在教这一课。
|
||||
4. **不要在注意力只剩最后百分之几的时候启动一个要跑几天的上传。** 在 0.27% 时发现 `RATE_LIMIT_EXCEEDED` 只花几分钟;跑了两天才发现,花掉的是整个窗口。
|
||||
|
||||
## 结果,诚实地说
|
||||
|
||||
管道是通的:计划现在住在 Google Drive 上、仓库身份没变、种子花的是**我的**配额而不是共享池、旧的那一侧退回了大约 1 TB。但它现在**还不是一个备份**:写这篇的时候,第一轮种子才上传了约 1075 GB 里的 2.88 GB,而且新仓库里**快照数为零**——在第一张快照落地之前,什么都还原不了。那是我盯着的数字,因为「传输在跑」和「你能还原」是两句不同的话。
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: "空间回收没卡住,它只是在等明天早上 09:29"
|
||||
description: "存储管理员那张卡整天写着 running,可用空间一动不动。其实 121 GiB 已经分两步回来了——然后下午又被写掉了 47 GiB。"
|
||||
pubDate: 2026-10-14
|
||||
updatedDate: 2026-10-16
|
||||
category: devops
|
||||
tags: ["synology", "btrfs", "snapshots", "storage", "nas"]
|
||||
ogImage: /og/space-reclamation-wasnt-stuck-it-was-waiting.png
|
||||
banner: /banners/space-reclamation-wasnt-stuck-it-was-waiting.png
|
||||
draft: false
|
||||
---
|
||||
|
||||
## 我问错了问题
|
||||
|
||||
我清掉几个文件夹,在 NAS 上点了空间回收,然后等。几个小时后,存储管理员那张卡还写着 **running**,可用空间看起来跟早上一样。我的问题是「它为什么什么都没回收」。
|
||||
|
||||
**它回收过了——两次**,而且其中一次比我删掉的东西大得多。测量回答的是另一个问题,而这才是这件事里最有意思的地方。
|
||||
|
||||
## 在相信任何东西之前,先给可用空间上定时采样
|
||||
|
||||
我没有去看那张进度卡,而是给 `volume1` 的可用空间做了当天每五分钟一次的采样:
|
||||
|
||||
| 时间 | 可用 | 事件 |
|
||||
|---|---|---|
|
||||
| 08:59 → 09:24 | 1134.1 GiB | 平——什么都没在回收 |
|
||||
| **09:29** | **+69.0 GiB** | Class 快照 `…10.14-09.29.02` 轮转 |
|
||||
| **09:34** | **+46.8 GiB** | Video 快照 `…10.14-09.30.35` 轮转 |
|
||||
| **15:28:45** | — | DSM 日志:`Start to empty all recycle bins.` |
|
||||
| **15:34** | **+5.0 GiB** | 清回收站结束——里面只有 5 GiB |
|
||||
| 16:35 → 17:39 | **−39 GiB** | 新数据写入(一阵爆发) |
|
||||
| 15:00 → 20:44 | **−47.2 GiB** | 净值:这个卷那天下午**丢**了空间 |
|
||||
|
||||
两次回收,合计约 121 GiB;然后 47 GiB 的新写入把下午吃掉了。「什么都没回来」是我这边的读法错误:我拿早上和晚上比,而晚上里面多了一整天的写入。
|
||||
|
||||
我特意手动清空的回收站是 **5 GiB**;我压根没想到的快照是 **115.8 GiB**。这个比例就是全部故事。
|
||||
|
||||
## 为什么那张卡写着 running,却什么都不代表
|
||||
|
||||
```text
|
||||
$ synostgreclaim --get-all-status --verbose
|
||||
{"state":"running","num_subvolume_remain":1,"now_runnable":true}
|
||||
schedule_table: 168 × 1
|
||||
run_time_option: run_all_time
|
||||
```
|
||||
|
||||
`state: running` 加上 168 个 1 的调度表和 `run_all_time`,意思是这个任务被配置成**按设计 24×7 都处于 running**。它是调度状态,不是进度指示器——所以在某天完全无事可排的日子,这张卡照样这么写。而当时那台机器上**没有** `synostgreclaim` 进程、没有 btrfs balance、md2/dm-3 的 I/O 基本为零。那一刻没有任何东西在被回收,也没有任何东西卡住。
|
||||
|
||||
然后是日志,而 DSM 上它们大多是红鲱鱼:
|
||||
|
||||
- 回收任务本身什么都不记;
|
||||
- `/var/log/space_operation.log` 是 LUKS 和卷列表的噪音;
|
||||
- `/var/log/synorecycle.log` 是 `synounlinkd` 的路径噪音;
|
||||
- 当天的 Log Center 里只有我自己那条清回收站的记录,外加几条 UPS 断电/来电的提示。
|
||||
|
||||
一个永远为真的 UI 状态字段,加上三个永远不会给出答案的日志文件:唯一剩下的仪器,就是**随时间变化的可用空间**。
|
||||
|
||||
## 没人告诉你的那个机制
|
||||
|
||||
空间回收释放的是**已删除的 btrfs 子卷**。它释放不了仍然被快照钉住的数据。那两个共享文件夹的保留策略是 **retention 1**(各留一代),所以**我删掉的任何东西,都要等到第二天的轮转、大约 09:29 / 09:30 才会变成可用空间**,一次跳几十 GB,然后安静 24 小时。
|
||||
|
||||
那两次跳变正是这个。它也就意味着,对于「我删了 50 GB 却什么都没发生」这个问题,在这台机器上的诚实答案是:「等明天早上再看」——而如果删掉的数据本来就在某个快照里面,答案是「等那个快照过期,之前不会释放」。
|
||||
|
||||
## 如果重来一次
|
||||
|
||||
1. **采样那个数字,不要采样状态。** 每五分钟量一次可用空间,比任何任务卡都更能回答「现在有没有事情在发生」,而且它同时让「回长」变得可见。
|
||||
2. **读调度字段,不读状态字段。** `run_all_time` 解释了那个永久 running,而且不需要任何 bug 存在。
|
||||
3. **搞清楚每个共享文件夹的保留策略。** 它是决定「删除何时变成可用空间」的那口钟;不知道它,每一次删除都会看起来坏掉一整天。
|
||||
4. **把两本账分开。** 回收站(5 GiB)和快照(115.8 GiB)行为完全不同,也是两套心理模型。
|
||||
5. **重新检查分母。** 这个卷平时就在以约 1.5 GiB/时 的速度被正常写入吃掉;回收回来 121 GiB、当天又花掉 47 GiB,只要你只比同一个数字的两个快照,这两件事看起来完全一样。
|
||||
|
||||
## 测量真正告诉我的,是这台机器的趋势
|
||||
|
||||
我本来是去修一个卡住的任务,结果带回来的是一条存储趋势:
|
||||
|
||||
```text
|
||||
volume1 42 TB 已用 98% 剩 1.2 TB 近来约 -1.5 GiB/时(8 天 -344 GiB)
|
||||
volume2 16 TB 已用 100% 剩 70 GB 约 -19 GiB / 2 天
|
||||
健康 两个卷 btrfs corruption_errs 均为 0
|
||||
所有 md 阵列 [UUUU] / [UU],没有在跑 balance
|
||||
read/write_io_errs 与九月基线一致
|
||||
```
|
||||
|
||||
所以有用的结论不是「回收是好的」。而是:**相对于写入速率,回收只是一个舍入误差**;在保留策略为 1 的前提下,删除并不是我以为的那根杠杆;而该拿来做告警的是趋势,不是那张任务卡。这跟[《你的磁盘快满告警在撒谎》](/posts/your-disk-full-alert-is-lying/)是同一课,只是从另一个方向来的:对**后果**告警(随时间变化的可用字节),因为机制自己的状态不是证据。
|
||||
Reference in new issue
Block a user