post: four posts — rclone/Drive quota, DSM space reclamation, mail domain/PTR, embedded fonts (EN+ZH) + og/banner
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:
hoelee committed 2026-10-16 13:47:10 +08:00
1 parent 636e6f9624
commit 08fa7a9412
18 files changed
+1036

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+68
View File
@@ -1288,6 +1288,74 @@ BANNERS['two-ways-a-javascript-file-stops-working-and-looks-fine'] = {
],
};
BANNERS['my-backup-was-using-someone-elses-api-quota'] = {
titlebar: "root@dsm - 1 TB seed, 0.27%",
lines: [
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "rclone about gdrive-restic" },
{ t: 'dim', text: "2.88 GB of ~1075 GB uploaded - 0 snapshots" }, { t: 'err', text: "403 Quota exceeded - consumer projects/202264815644" },
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "own OAuth client - Desktop app - Publish app" },
{ t: 'ok', text: "the seed spends my quota now" }, { t: 'hl', text: "the callback accepts ONE request: no reload, no second tab" },
{ t: 'dim', text: "old side: 1.012 TiB -> 3.800 GiB reclaimed" }, { t: 'dim', text: "guid = sha256(id): repointing a plan cannot fork history" },
],
flow: [
{ n: '1', label: "find what writes it" },
{ n: '2', label: "repoint the repo" },
{ n: '3', label: "own the client" },
{ n: '4', label: "seed" },
],
};
BANNERS['space-reclamation-wasnt-stuck-it-was-waiting'] = {
titlebar: "root@nas - volume1, 09:29",
lines: [
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "df -h /volume1 (every 5 min)" },
{ t: 'dim', text: "08:59-09:24 flat at 1134.1 GiB" }, { t: 'ok', text: "09:29 +69.0 GiB - Class snapshot rotated" },
{ t: 'ok', text: "09:34 +46.8 GiB - Video snapshot rotated" }, { t: 'err', text: "15:34 +5.0 GiB - the recycle bin held almost nothing" },
{ t: 'hl', text: "retention 1: a delete frees at tomorrow's rotation" }, { t: 'dim', text: "the afternoon wrote -47.2 GiB: reclaim was a rounding error" },
{ t: 'dim', text: "volume1 98% used - volume2 100%" },
],
flow: [
{ n: '1', label: "sample the number" },
{ n: '2', label: "read the schedule" },
{ n: '3', label: "know retention" },
{ n: '4', label: "alert on the trend" },
],
};
BANNERS['adding-a-domain-to-someone-elses-mail-server'] = {
titlebar: "mail.hoelee.com - one more domain",
lines: [
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "add domain + MX/SPF/DKIM/DMARC" },
{ t: 'ok', text: "inbound works first try - RCPT_MAILCOW_DOMAIN" }, { t: 'err', text: "outbound: 554 antispam policy (4 RBLs clean, no rDNS)" },
{ t: 'err', text: "via relay: 550 From header sender domain not verified" }, { t: 'hl', text: "PTR is per IP - every domain shares one reverse record" },
{ t: 'dim', text: "forwarding needs Sieve, and still cannot leave the box" }, { t: 'dim', text: "a relay needs a verified sender per domain, forever" },
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "POP pull on the receiving side" },
],
flow: [
{ n: '1', label: "four records" },
{ n: '2', label: "test from outside" },
{ n: '3', label: "check the reverse" },
{ n: '4', label: "pull, don't forward" },
],
};
BANNERS['embedding-fonts-so-a-pdf-looks-the-same-everywhere'] = {
titlebar: "report.pdf - what is really inside",
lines: [
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "pdffonts report.pdf" },
{ t: 'err', text: "before: one CJK face + Roboto, bold was synthetic" }, { t: 'dim', text: "variable font: parser reads glyf only, ignores fvar -> Thin 100" },
{ t: 'ok', text: "after: 8 static faces, 4 families, embedded" }, { t: 'hl', text: "font-weight 600 -> rewritten to bold for the PDF engine" },
{ t: 'dim', text: "GB2312 subsetting cut real name characters: 犇 骉 頔 祎 堃" }, { t: 'dim', text: "glyph lint fails the build instead of printing a box" },
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "page counts: zh 23/23, en 28/29, bm 30/30" },
],
flow: [
{ n: '1', label: "files in the repo" },
{ n: '2', label: "static cuts only" },
{ n: '3', label: "map two weights" },
{ n: '4', label: "lint every glyph" },
],
};
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
let category = 'devops';
+20
View File
@@ -392,6 +392,26 @@ TERMINALS['two-ways-a-javascript-file-stops-working-and-looks-fine'] = `
<div class="line"><span class="prompt">&nbsp;</span><span class="err">TypeError: t is not a function (a parameter named t)</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">typeof t === 'function' ?lang=pseudo clean</span><span class="fix">-> script reaches the end</span></div>`;
TERMINALS['my-backup-was-using-someone-elses-api-quota'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">restic backup --repo gdrive:restic</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">403 RATE_LIMIT_EXCEEDED - consumer: projects/202264815644</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">own OAuth client + Publish app</span><span class="fix">-> the quota is mine</span></div>`;
TERMINALS['space-reclamation-wasnt-stuck-it-was-waiting'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">synostgreclaim --get-all-status</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">state running, schedule 168x1, run_all_time: always true</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">sample free space every 5 min</span><span class="fix">-> +121 GiB, then -47 GiB written</span></div>`;
TERMINALS['adding-a-domain-to-someone-elses-mail-server'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">dig +short MX newdomain.com -> mail.hoelee.com</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">554 5.7.1 Mail rejected due to antispam policy</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">reverse lookup 41.216.187.102</span><span class="fix">-> nothing: no PTR</span></div>`;
TERMINALS['embedding-fonts-so-a-pdf-looks-the-same-everywhere'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">pdffonts report.pdf</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">before: one CJK face, bold was synthetic</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">8 static faces embedded</span><span class="fix">-> same look everywhere</span></div>`;
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
@@ -0,0 +1,154 @@
---
title: "Adding a Domain to Someone Else's Mail Server"
description: "Receiving mail took four DNS records and worked first try. Sending from it, and forwarding it, hit two walls that no DNS record can fix."
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
---
## The task looked like a DNS job
A domain needed mail. The mail server is my own VPS running mailcow, so this
is "add the domain, add the records, create the mailbox" — the kind of task
that takes twenty minutes and that I have done several times.
It took longer, because two of the things I assumed about mail servers are
not true, and because of one property of the internet that no amount of
server configuration can change.
## Inbound: four records and a real test
Inbound mail needs exactly four things, and all four live in the domain's
own DNS (Cloudflare here):
```text
MX ninelore.com → mail.hoelee.com (priority 10)
TXT @ → v=spf1 mx -all
TXT dkim._domainkey → (the selector the MTA publishes)
TXT _dmarc → (policy + reporting address)
```
Configured and then tested the only way that counts: mail sent **from
outside** the estate (through a third-party relay) into the new address, and
then read the server's own log line — `rspamd` recording
`RCPT_MAILCOW_DOMAIN(ninelore.com)` as the message was accepted into the
mailbox. Receiving worked on the first try. Inbound is genuinely the easy
half, and it is easy precisely because it depends only on records you
control.
## Outbound is where the ground moves
Two separate walls, each with its own error, and neither is a config mistake
you can fix by editing a zone file.
**Wall one: the anti-spam policy that is really a missing PTR.** A direct
delivery attempt to the destination provider came back:
```text
554 5.7.1 Mail rejected due to antispam policy
```
Before blaming the remote, I checked the usual causes: four major
blocklists, all clean, and the domain's own SPF/DKIM/DMARC all correct. The
remaining explanation was the one with no DNS record in my control — the
sending IP had no reverse DNS. A receiving server that cannot map the
connecting IP back to a name treats it as a source it cannot vouch for, and
no amount of correct SPF outweighs that.
**Wall two: relaying your way around it does not work either.** The obvious
next move is to send everything through the outbound relay that already
works. That attempt produced:
```text
550-From header sender domain not verified (gmail.com)
```
Read that error carefully, because it kills a whole family of designs. The
relay enforces its verified-sender policy against the **From header domain**
— and a *forwarded* message keeps the original sender's From. So the moment
you forward mail from an outside sender, you are asking your relay to send
as `gmail.com`, which it will never allow. mailcow makes this worse in a way
that is worth knowing before you try: it selects the relay by **sender**
domain, so a message with an outside From does not even reach the relay you
configured.
## The forwarding feature that does not exist
The plan was to create the mailbox and forward a copy to my main address.
That is two surprises in a row:
- mailcow has no "mailbox forwarding" setting. Writing the mailbox address
into an alias' `goto` list is rejected (`is_alias_or_mailbox`) because
every mailbox already carries a self-alias record and the code skips the
address when computing destinations.
- Keeping the mailbox *and* forwarding requires a **Sieve filter**, which is
a different mechanism with a different failure mode — and it lands on wall
one and wall two anyway, because the message still has to leave the box.
So I enabled **nothing**. A forward whose outbound path cannot deliver does
not fail quietly: it sends the rejection back to the innocent original
sender — backscatter — and every one of those bounces spends the reputation
of an IP that has none to spare. The stable answer for "this new mailbox
should reach my inbox" turned out to be a **POP pull on the receiving
side**: my mail client fetches from `mail.hoelee.com:995` and nothing has to
be sent from the VPS at all.
## PTR is one per IP, not one per domain
This is the part I keep having to explain, including to myself:
- The reverse record is a property of the **IP address**. There is no
per-domain PTR, and there is nothing to add the next time a domain is
added.
- Its correct value is the name the mail server itself announces — in this
case `mail.hoelee.com`, which is what postfix sends as its
HELO/myhostname. The matching forward record
(`mail.hoelee.com → 41.216.187.102`) already exists, and the pair is what
receivers verify: **forward-confirmed rDNS**. One half without the other
is worth nothing.
- Every domain on that IP is judged by that same reverse record. A hundred
domains, one PTR.
- Who sets it: whoever runs the reverse zone. Here the SOA for both
`216.41.in-addr.arpa` and `187.216.41.in-addr.arpa` is the VPS host's own
nameserver, so the record is a provider-side change — look for an rDNS/PTR
field in the VM or network panel first, and otherwise open a ticket with
the exact value above.
I checked the current state rather than assuming: the forward lookup
resolves (`mail.hoelee.com → 41.216.187.102`) and the reverse lookup for
that IP still returns nothing. So direct delivery continues to be rejected,
and the only outbound path that works is the relay — with its
verified-sender requirement, which new domains have to satisfy there too.
## Three things to expect, so you are not surprised later
1. **PTR is not a reputation.** It removes a hard rejection; it does not
make you trusted. A new IP typically needs weeks of ordinary mail before
the big providers stop folding it into spam, and starting from a fresh IP
means starting that clock from zero.
2. **PTR fixes your own outbound and does nothing for forwarding.** A
forwarded message carries someone else's From, and if that domain
publishes `p=reject` — banks and large providers do — it is blocked at
the far end no matter how clean your setup is.
3. **Relaying has a per-domain precondition.** If a domain sends through a
relay, that domain must be registered and verified as a sender with the
relay. It is an extra step per domain forever, and it is independent of
everything in your own DNS.
## What I would do differently
1. **Decide mailbox-or-forward before creating the domain.** Forwarding is
the option with the longest list of things that must be true; a POP pull
on the receiving side has almost none.
2. **Check the sending IP's reverse record before promising anyone mail.**
One reverse lookup would have told me which of the two walls I was about
to hit.
3. **Treat a receiving-side pull as a first-class design, not a
workaround.** "The message never has to leave my server" removes three
whole categories of failure at once.
4. **Keep the four inbound records as a checklist.** That half is boring,
verifiable, and was the part that worked first try.
@@ -0,0 +1,158 @@
---
title: "Embedding Fonts So a PDF Looks the Same on Every Machine"
description: "Four families, eight faces, two renderers and one CSS quirk: what it took to stop a generated report depending on whatever fonts the server happened to have."
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
---
## The requirement was one sentence
A report generator has to produce a document that looks the same everywhere:
Chinese headings in a serif, Chinese body text in a sans, Latin headings and
cover numerals in a display serif, Latin body text and tables in Inter.
Weights matter — medium for emphasis, semibold for headings — across three
languages and two PDF renderers.
And the fonts must travel with the document. Not "the server has them
installed", not "the reader probably has them" — **embedded**, so a print
shop in another country sees the same pages.
The old pipeline failed on the last clause in a way that was invisible: it
depended on fonts installed on the server by a package manager, and it only
ever embedded a single CJK face. The second weight did not exist, so "bold"
was the renderer's synthetic approximation, and the only way to notice was
to open the PDF and look at what was actually inside it.
## Rule one: read the fonts back out of the finished PDF
"Embedded" is a claim about the output, so I measured the output. The same
document, rendered before and after:
| | before | after |
|---|---|---|
| zh page count | 23 | **23** |
| en page count | 28 | **29** |
| bm page count | 30 | **30** |
| fonts actually embedded | one CJK face + Roboto | Source Han Serif Medium + SemiBold · Source Han Sans Regular + Medium · Cormorant Garamond Medium + SemiBold · Inter Regular + SemiBold + Medium · Source Han Sans for the CJK runs inside Latin reports |
| render time / peak memory | baseline | same order (±10%, noise) |
| file size | baseline | roughly +3% |
The glyph counts per face are the receipt: in the Chinese report, 15,988
glyphs land in the body sans, 2,247 in its medium weight, 634 in the heading
serif medium, 222 in its semibold. That is the difference between "the font
is on the server" and "the font is in the file" — a reader in another
country sees the same four faces, and a page count that moved by exactly one
page in one language (the Latin metrics are wider than what they replaced)
is the honest cost.
## Rule two: static instances only, and the reason is in the parser
The obvious modern choice — ship one variable font per family — is wrong
here, for two measured reasons:
1. **The PDF engine's font parser reads `glyf`/`loca` only.** Feed it an
OpenType file with PostScript/CFF outlines and it refuses:
`Fonts with postscript outlines are not supported`. A variable font is
not the problem; CFF outlines are.
2. **It does not read the `fvar` table at all**, so a variable font silently
resolves to its *default instance* — and the default instances of the CJK
families are the thinnest ones (Thin 100 and ExtraLight 200, visible in
the `name` table). Every page would render in a hairline weight, with no
error anywhere in the pipeline.
So the build step takes the upstream variable fonts and **instantiates
static cuts**: it runs the instancer, then rewrites `name`,
`OS/2.usWeightClass` and `head.macStyle` so the file identifies itself as
the weight it now is (the source's own name still says "Thin"). A manifest
records family, weight class, sha256, glyph count and code-point count for
every file, and the licence files ship next to them.
## Rule three: the engine speaks two weights, so map onto two slots
The renderer's CSS handling honours exactly `normal` and `bold` — its source
says so in as many words ("Does not support: bolder, lighter, 100..900").
Numeric weights are therefore meaningless to it, while the headless-Chromium
renderer in the same pipeline honours them properly.
The mapping that works: each family declares an **R slot** and a **B slot**
(regular-ish and heavier), `font-weight: 500` lands on the R slot of the
intended family, and `600` is **rewritten to `bold`** on the PDF side only,
which lands on the B slot. Chromium keeps `600` as written. Two tests pin
it: the B slot must be heavier than the R slot (read from `usWeightClass`,
not assumed), and `font-weight: 600` must never appear in the stylesheet
handed to the PDF engine.
One subtlety worth the paragraph: a "medium" weight is not an emphasis
mechanism in this pipeline, it is a *family member*. Chinese headings use
the serif's medium as their regular and its semibold for emphasis; Latin
headings do the same with the display serif. Getting that wrong is invisible
on a screen and obvious in print.
## Rule four: do not subset against a character set you do not control
The first attempt subset the CJK fonts to the union of two national
character sets and produced a ~29 MB bundle instead of ~50 MB. Then I ran
the name probe and found these were cut:
```text
犇 骉 頔 祎 廸 玧 祾 赟 堃
```
Every one of them is a real given name character. The report prints a
customer's name on the cover, and the name is free input — so a subset-based
build will eventually put a tofu box on the most prominent page of the
product. Full coverage is ~31k code points and roughly 50 MB of embedded
fonts; that is a fine price for never showing a customer an empty rectangle.
## Rule five: make missing glyphs a build failure, not a surprise
The gate runs three checks and exits non-zero on any of them:
- every font file declared in the manifest is present, and its sha256
matches the file on disk;
- every character in the content library (all three languages) plus the
brand strings, plus probe classes — simplified, traditional, CJK
punctuation, full-width forms, mixed-script, digits, Latin extended,
symbols — plus the name characters, has a glyph **in the face that will
actually print it**;
- the lookup uses the PDF engine's own font parser rather than a third-party
library, so the check cannot disagree with the renderer.
The known edges are written down instead of hidden. Dingbats and emoji (✅ ✔)
exist in none of these text fonts and the engine cannot draw colour emoji,
so they are kept out of the report body. The display serif has only 974
glyphs and does not draw symbols like ① ★ ℃, which is why the
content-library check covers *all* slots rather than just the body. And CJK
characters inside a Latin report must go through the dedicated CJK slot —
two tests keep the renderers from disagreeing about that.
## What I would do differently
1. **Choose faces and weights before styling anything.** Retrofitting a
second weight after the layout exists means re-checking every page count
by hand.
2. **Read the renderer's source before choosing a font format.** The
variable-font decision looked like a taste question and was actually a
parser question — two files settled it in ten minutes.
3. **Treat everything you ship inside a document as a licence artifact.**
Font files enter the repo with their licences, or they enter someone
else's legal review.
4. **Measure what got embedded.** Page count, glyph-per-face counts, file
size, render time: all four moved, and three of them moved in ways I
would have guessed wrong.
## The result
Before: one CJK face and a UI font, with synthetic bold, depending on what
the server had installed. After: four families and eight static faces
embedded in the file, weights that are real members of their families, page
counts within one page of the previous layout, and a lint gate that fails
the build when a character has no glyph — so the failure mode moved from "a
customer sees a box" to "CI refuses the release". Which is the same trade as
everywhere else in this pipeline: make the mistake impossible to ship rather
than easy to notice.
@@ -0,0 +1,169 @@
---
title: "My Backup Was Crawling Because I Was Using Someone Else's API Quota"
description: "A 1 TB restic seed stalled at zero bytes. The log named a Google project I did not own: rclone's shared OAuth client, quota pooled with every rclone user alive."
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
---
## The subscription ended, and I had to move a terabyte
The call came in the least convenient way: the OneDrive account holding my
nightly backups had no subscription any more, and I had a 1.5-year Google
subscription sitting unused. Move the backup, keep the history, don't lose a
night.
The first thing I hit was not a technical problem: I could not remember
*how* the backup was wired. rclone? Duplicati? Something Synology shipped?
On a three-host estate with 78 compose stacks — the one in
[Running Production Infrastructure Solo](/posts/running-production-infrastructure-solo/)
— "which tool writes my backups" is a question I should have been able to
answer from memory and could not. So step one was reading the machine.
## What was actually there
Two rclone configs, which is the kind of detail that bites: the GUI config
that Backrest (the restic front end) uses, plus a **separate stale CLI
copy** that a cron job or a human shell had been using at some point. Change
one and you have changed half the system.
The migration itself is boring when done in the right order, and I did it
read-first:
```text
rclone.conf append [gdrive-*] + [gdrive-*-crypt]
rclone/rclone.conf (CLI copy) same two remotes
Portainer stack 177 env → the new crypt remote; config :ro → :rw
restic init new repo on Google Drive
Backrest SetConfig dsm-repo URI → the new location
```
Before touching the plan URIs I checked one property in the source, because
everything depended on it: a restic repository's identity is
`guid = sha256(id)` — a stable value derived from the repo id, **not** from
its URL. So repointing a plan at a new backend cannot silently fork the
history. All six plans came out byte-identical to the originals, and the
repo `id`/`guid` were untouched.
## Then the seed stopped moving, and the log named a stranger
The new repo seeded to 2.88 GB of roughly 1075 GB — 0.27% — and then slowed
to a crawl. The live log had the answer:
```text
'Read requests per minute' ... reason: RATE_LIMIT_EXCEEDED
consumer: projects/202264815644 <- not my project
quota_limit_value: 840000
```
`202264815644` is **rclone's own shared OAuth project**. My terabyte seed
was drawing on a per-minute read quota pooled with every rclone user on
earth, which is why it degraded from ~750 KB/s to nothing while my NAS, my
disk and my link were all idle. By the time I measured, the seed was fully
stalled — **0 bytes in 90 seconds**, 159 errors, still `rateLimitExceeded` —
and the six nightly plans had all failed with the same wall:
```text
Fatal: unable to save snapshot: unexpected HTTP response (500)
googleapi: Error 403: Quota exceeded for quota metric 'Queries'
and limit 'Requests per minute' ...
```
"Slow" and "blocked" look similar in a progress bar and are completely
different problems. Ninety seconds of sampled zeros is what made the call.
## You need your own OAuth client — and the console is unavoidable
There is exactly one real fix: stop using the shared client and bring your
own. That means a Google Cloud project, an OAuth consent screen, and a
credential. Two settings cost real time:
- **Application type = Desktop app.** rclone's redirect is
`http://127.0.0.1:53682`, and that client type permits it without extra
configuration.
- **Publish the app.** Leave it in "Testing" and Google expires your refresh
token **every 7 days** — which turns "working" into a silent weekly
outage.
I checked whether I could shortcut the console with the CLI that was already
authenticated here. I could not, and the reason is worth knowing: `gcloud`
was signed in only as a **service account** in an unrelated project, and
service accounts cannot create consent screens or OAuth client IDs. The
credential has to belong to a project a human owns. It was a dead end, and I
verified that instead of asserting it.
## Three traps in the authorization flow
1. **Publishing an unverified app can hard-block a restricted scope.**
`.../auth/drive` is restricted, and an app that is published *without*
completing Google's verification review is sometimes blocked outright
with no bypass. In my case the bypass existed — the "Google hasn't
verified this app" screen, then **Advanced → Go to rclone-backup
(unsafe)** — but that is a coin flip on scope and review state, not a
guarantee, and "unsafe" is only a label meaning the app never went
through a paid audit.
2. **The callback accepts exactly one request.** A reload, or a second tab,
hits `127.0.0.1:53682` with no code and kills the attempt. My first try
died exactly that way (`no code returned`), and it looks like a
credentials problem. One tab. Don't reload it.
3. **Finish the consent screen on the machine that runs rclone.** The
redirect goes to `localhost`, so a phone or another PC cannot complete it
— the browser has to be the one on the same host as the process waiting
on that port.
Then: read the token, write `client_id` + `client_secret` + `token` into
**both** configs (the GUI one and the stale CLI copy), and restore the two
flags that matter for a long unattended upload: `use_trash = false` and
`stop_on_upload_limit = true`.
## The old side: verify by reading, not by asking
Purging the old remote reported an error — `itemNotFound` — and the space
came back anyway:
```text
Used: 1.012 TiB -> 3.800 GiB
Free: ~0 -> 1.007 TiB
DSM folder: present -> gone Trashed: 0 B
```
The error was rclone complaining *after* the work finished. Also worth
recording: the tool's documentation says personal accounts cannot
`permanentDelete`, which would have meant emptying the recycle bin by hand —
in practice the trash was already empty and roughly 1 TB was reclaimed with
no browser step. Documentation describes the API; the API is not always what
you get.
One more measured thing, because it changed my next decision: pausing the
file-sync container that shared that machine halved the load average — 16.70
→ 8.89 — which is the difference between "the network is slow" and "the box
is busy".
## What I would do differently
1. **Own the OAuth client from day one.** A shared client id is a queue you
are standing in, with no status page and no one to call.
2. **Answer "what writes my backups" from a document, not from memory.** Two
rclone configs is a normal amount of drift for a system nobody has drawn
in a year.
3. **Read the destination, not the exit code.** The purge "failed" and freed
a terabyte; a successful-looking rename can land nothing. Same lesson as
[It Reported Success. Nothing Had Changed.](/posts/it-reported-success-nothing-had-changed/)
— this estate keeps teaching it.
4. **Never start a multi-day upload while the last few percent of your
attention is elsewhere.** Checking for `RATE_LIMIT_EXCEEDED` at 0.27%
costs minutes; discovering it after two days costs the window.
## The result, honestly stated
The plumbing works: the plans live on Google Drive, the repo identity is
unchanged, the seed is now spending **my** quota instead of a shared one,
and the old side gave back about a terabyte. What it is not, yet, is a
backup: at the time I wrote this, the first seed had uploaded 2.88 GB of
~1075 GB and the new repo had **zero snapshots** — nothing is restorable
until the first snapshot lands. That is the number I watch, because "the
transfer is running" and "you can restore" are different claims.
@@ -0,0 +1,124 @@
---
title: "Space Reclamation Wasn't Stuck. It Was Waiting for Tomorrow at 09:29."
description: "The Storage Manager card said 'running' all day and free space hadn't moved. 121 GiB had come back in two steps — and the afternoon ate 47 GiB of it."
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
---
## The question I asked was the wrong question
I emptied a few folders, kicked off Space Reclamation on the NAS, and
waited. Hours later the Storage Manager card still read **running**, and
free space looked the way it had looked in the morning. My question was "why
didn't it reclaim anything".
It had reclaimed — twice — and one of those reclaims was larger than
anything I had deleted. The measurement answered a different question than
the one I asked, which is the interesting part.
## Sample free space on a timer before believing anything
I sampled `volume1` free space every five minutes for the day instead of
looking at a progress card:
| time | free | event |
|---|---|---|
| 08:59 → 09:24 | 1134.1 GiB | flat — nothing reclaiming |
| **09:29** | **+69.0 GiB** | Class snapshot `…10.14-09.29.02` rotated |
| **09:34** | **+46.8 GiB** | Video snapshot `…10.14-09.30.35` rotated |
| **15:28:45** | — | DSM log: `Start to empty all recycle bins.` |
| **15:34** | **+5.0 GiB** | recycle-bin empty finished — only 5 GiB was in there |
| 16:35 → 17:39 | **−39 GiB** | new data written (a burst) |
| 15:00 → 20:44 | **−47.2 GiB** | net: the volume *lost* space that afternoon |
Two reclaims, ~121 GiB in total, and then 47 GiB of new writes swallowed the
afternoon. "Nothing came back" was a reading error on my side: I compared
the morning to the evening, and the evening had a day of writing in it.
The recycle bin — the thing I had bothered to empty by hand — was **5 GiB**.
The snapshots, which I had not thought about at all, were **115.8 GiB**.
That ratio is the whole story.
## Why the card said "running" and meant nothing
```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` with a schedule table of 168 ones and `run_all_time` means
the task is configured to be "running" **24×7, by design**. It is a
schedule, not a progress indicator — so the card will read the same thing on
a day when nothing is scheduled to happen at all. Meanwhile the machine had
no `synostgreclaim` process, no btrfs balance, and md2/dm-3 I/O of roughly
zero. Nothing was being reclaimed at that moment, and nothing was stuck
either.
Then the logs, which are mostly red herrings on DSM:
- the reclaim task itself logs nothing;
- `/var/log/space_operation.log` is LUKS and volume-list spam;
- `/var/log/synorecycle.log` is `synounlinkd` path noise;
- Log Center for the day contained only my own recycle-bin line and a few
UPS on-battery/back-online blips.
A UI state field that is always true and three log files that never contain
the answer: the only instrument left is free space over time.
## The mechanism nobody tells you about
Space Reclamation frees **deleted btrfs subvolumes**. It cannot free data
that a snapshot is still pinning. Those two shares keep **retention 1** —
one generation each — so anything I delete becomes free space only at the
**next day's rotation, around 09:29 / 09:30**, in a step of tens of
gigabytes, and then it is silent for 24 hours.
That is exactly what the two jumps were. It also means the honest answer to
"I deleted 50 GB and nothing happened" on this box is: "wait for tomorrow
morning, then check again" — and if the deleted data lived inside a
snapshot, the answer is "it frees when that snapshot expires, and not
before".
## What I would do differently
1. **Sample the number, not the status.** A five-minute timer on free space
answers "is anything happening" in a way no task card can, and it made
the regrowth visible at the same time.
2. **Read the schedule field, not the state field.** `run_all_time` explains
a permanent "running" without any bug existing.
3. **Know each share's retention.** It is the clock that decides when a
delete becomes free space; without it, every deletion looks broken for a
day.
4. **Separate the two ledgers.** Recycle bin (5 GiB) and snapshots (115.8
GiB) behave nothing alike and are two different mental models.
5. **Re-check the denominator.** The volume was losing ~1.5 GiB/h to normal
writes; a reclaim that returns 121 GiB and a day that consumes 47 GiB
look identical if you only compare two snapshots of the same number.
## What the measurement actually says about the machine
I went in expecting to fix a stuck job and came out with a storage trend
instead:
```text
volume1 42 TB 98% used 1.2 TB free ~-1.5 GiB/h lately (-344 GiB over 8 days)
volume2 16 TB 100% used 70 GB free ~-19 GiB / 2 days
health btrfs corruption_errs 0 on both volumes
all md arrays [UUUU] / [UU], no balance running
read/write_io_errs identical to the September baseline
```
So the useful conclusion is not "the reclaim works". It is that reclaim is a
rounding error next to the write rate, deletion is not the lever I thought
it was while retention is 1, and the trend — not the task card — is what I
should be alerting on. That is the same lesson as
[Your Disk-Full Alert Is Lying](/posts/your-disk-full-alert-is-lying/),
arriving from the other direction: alert on the consequence (bytes free over
time), because the mechanism's own status is not evidence.
@@ -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/)是同一课,只是从另一个方向来的:对**后果**告警(随时间变化的可用字节),因为机制自己的状态不是证据。