review: apply consultant batch-05 (devops re-review) fixes to 11 posts
- 11/telegram: rebuild the b"\r\n".join() explanation around the two real failure modes (line-splitting binary + doubled CRLF before Content-Disposition); fix the CR-eaten escapes that broke the inline code and the parts[] sample; close the Round 3 PID 1 vs docker exec gap; drop the leftover EN/ZH family framing; add the host anchor - 04/09: the zero-block CRC32C belongs on the found side, not wanted; note the little-endian print order (0x98f94189 -> 0x8941f998); close the 09-22 scrub window and link the TRIM sequel from 09's result - 06: losetup -p is --pass-fd, not a priority; explain the -2 in /proc/swaps - 03: UDMA_CRC only counts ICRC frame faults; add the destructive-write warning to the raw fio recipe - 02: CONFIG_CFS_BANDWIDTH, not 'no CFS scheduler'; SME wording in the ZH CTA - 01: three reclaims (115.8 + 5.0 GiB), 344 GiB/8d = 1.8 GiB/h not 1.5 - 05/07/10: blueprint fields; 10 filters blank snapshot lines + names the empty-snapshot overwrite case; 07 comments that a container name survives recreation
This commit is contained in:
1 parent
a7d1cbaf3a
commit
19de315201
20 files changed
+155
-120
No files matched your search
@@ -1,9 +1,11 @@
|
||||
---
|
||||
title: "空间回收没卡住,它只是在等明天早上 09:29"
|
||||
description: "存储管理员那张卡整天写着 running,可用空间一动不动。其实 121 GiB 已经分两步回来了——然后下午又被写掉了 47 GiB。"
|
||||
description: "存储管理员那张卡整天写着 running,可用空间一动不动。其实 121 GiB 已经分三笔回来了——然后下午又被写掉了 47 GiB。"
|
||||
pubDate: 2026-10-14
|
||||
updatedDate: 2026-10-16
|
||||
category: devops
|
||||
blueprint: self-hosted-infrastructure
|
||||
blueprintLayer: L1-compute-storage-network
|
||||
tags: ["synology", "btrfs", "snapshots", "storage", "nas"]
|
||||
ogImage: /og/zh-space-reclamation-wasnt-stuck-it-was-waiting.jpg
|
||||
banner: /banners/space-reclamation-wasnt-stuck-it-was-waiting.jpg
|
||||
@@ -67,14 +69,14 @@ $ synostgreclaim --get-all-status --verbose
|
||||
2. **读调度字段,不读状态字段。** `run_all_time` 解释了那个永久 running,而且不需要任何 bug 存在。
|
||||
3. **搞清楚每个共享文件夹的保留策略。** 它是决定「删除何时变成可用空间」的那口钟;不知道它,每一次删除都会看起来坏掉一整天。
|
||||
4. **把两本账分开。** 回收站(5 GiB)和快照(115.8 GiB)行为完全不同,也是两套心理模型。
|
||||
5. **重新检查分母。** 这个卷平时就在以约 1.5 GiB/时 的速度被正常写入吃掉;回收回来 121 GiB、当天又花掉 47 GiB,只要你只比同一个数字的两个快照,这两件事看起来完全一样。
|
||||
5. **重新检查分母。** 这个卷平时就在以约 1.8 GiB/时 的速度被正常写入吃掉;回收回来 121 GiB、当天又花掉 47 GiB,只要你只比同一个数字的两个快照,这两件事看起来完全一样。
|
||||
|
||||
## 测量真正告诉我的,是这台机器的趋势
|
||||
|
||||
我本来是去修一个卡住的任务,结果带回来的是一条存储趋势:
|
||||
|
||||
```text
|
||||
volume1 42 TB 已用 98% 剩 1.2 TB 近来约 -1.5 GiB/时(8 天 -344 GiB)
|
||||
volume1 42 TB 已用 98% 剩 1.2 TB 近来约 -1.8 GiB/时(8 天 -344 GiB)
|
||||
volume2 16 TB 已用 100% 剩 70 GB 约 -19 GiB / 2 天
|
||||
健康 两个卷 btrfs corruption_errs 均为 0
|
||||
所有 md 阵列 [UUUU] / [UU],没有在跑 balance
|
||||
|
||||
@@ -222,7 +222,7 @@ services:
|
||||
mem_limit: 2g
|
||||
```
|
||||
|
||||
一条只属于 DSM 的注意事项:DSM 上任何 compose 文件都别写 `cpus:`。它的内核没有 CPU CFS 调度器,这个键会被静默忽略——你得到的是有 CPU 限制的错觉,其实没有任何限制。
|
||||
一条只属于 DSM 的注意事项:DSM 上任何 compose 文件都别写 `cpus:`。它的内核没有编译开启 cgroup 的 CPU 带宽控制(`CONFIG_CFS_BANDWIDTH`),这个键会被静默忽略——默认的 CFS 调度器照常跑你的进程,只是没有配额可执行,你得到的是有 CPU 限制的错觉,其实没有任何限制。
|
||||
|
||||
然后 API 就能用了,而且形态很讨喜。登录,创建或指定一个电子表格,读写范围:
|
||||
|
||||
@@ -277,4 +277,4 @@ Synology Office → spreadsheet-api → .xlsx export → chart renderer
|
||||
|
||||
---
|
||||
|
||||
*我在马来西亚为小企业做这类自托管集成——电子表格和 NAS 自动化、仪表盘,以及把你的数据留在你自己硬件上的内部工具。如果你需要这类东西,[WhatsApp 联系我](https://wa.me/60127972969) 或 [给我发邮件](mailto:[email protected]?subject=Self-hosted%20spreadsheet%20automation)——也可以看看我在 [hoelee.com](https://hoelee.com) 还做些什么。*
|
||||
*我在马来西亚为中小企业(SME)做这类自托管集成——电子表格和 NAS 自动化、仪表盘,以及把你的数据留在你自己硬件上的内部工具。如果你需要这类东西,[WhatsApp 联系我](https://wa.me/60127972969) 或 [给我发邮件](mailto:[email protected]?subject=Self-hosted%20spreadsheet%20automation)——也可以看看我在 [hoelee.com](https://hoelee.com) 还做些什么。*
|
||||
@@ -63,6 +63,8 @@ banner: /banners/that-dying-ssd-was-just-a-bad-sata-cable.jpg
|
||||
```
|
||||
# 判别性测试:写入带校验和,回读,不一致就失败
|
||||
# 要么跑全表面,要么跑 dmesg 标记的精确 LBA 区间
|
||||
# 注意:直接写裸盘会覆写该偏移的扇区。仅在未挂载、可弃的裸盘上执行;
|
||||
# 已挂载的生产盘请把 --filename 指向一个临时测试文件。
|
||||
fio --name=verify --filename=/dev/sdd \
|
||||
--offset=111G --size=2G \
|
||||
--rw=write --verify=crc32c --do_verify=1 \
|
||||
@@ -76,7 +78,7 @@ fio --name=verify --filename=/dev/sdd \
|
||||
- **先换线。** 在看到超时或写损坏时,在给盘判死刑之前,重新插拔/更换线材,或换到另一个口,重跑*相同*的失败命令。问题跟着**端口**走就是链路故障;跟着**盘**走(换了好口还坏)才是盘的问题。这是最便宜、最能一锤定音的测试,而我花了好几小时才做。
|
||||
- **别信一个干净的 `randrw`。** 它看不见静默损坏。凡是盘出现过任何写症状,都用 `verify=crc32c` 和 `do_verify=1`。
|
||||
- **对准日志里的 LBA 区间。** 内核或文件系统已经标记了具体扇区的话,就在*那里*测,而不是从偏移 0 开始。
|
||||
- **SMART 全绿 ≠ 链路健康。** UDMA_CRC 为零只说明*当前*链路干净,它看不到过去那段不稳定的链路;而传输超时的页面在算出 CRC 之前就已经被丢了。
|
||||
- **SMART 全绿 ≠ 链路健康。** `UDMA_CRC`(属性 199)只统计「到达时接口校验和出错(ICRC)」的数据帧。劣质线材通常是另一种坏法——`WRITE FPDMA QUEUED` 命令超时与链路重置——这类 I/O 在触发帧级 CRC 计数之前就已经被丢弃,所以一条真正有问题的链路可以让这个计数器从头到尾都是 0。
|
||||
|
||||
## 结果
|
||||
|
||||
|
||||
@@ -71,10 +71,12 @@ Seagate 盘上,raw 值是障眼法:IronWolf 110 的属性 1 raw 字段是厂
|
||||
`dmesg` 里是上一个 NVMe 事故里我盯了一周的同一个签名:
|
||||
|
||||
```
|
||||
btrfs: checksum verify failed on logical 1505629372416 mirror 1 wanted 0x8941f998
|
||||
btrfs: checksum verify failed on logical 1505629372416 mirror 1 wanted <expected> found 0x8941f998
|
||||
```
|
||||
|
||||
**`0x8941f998` 是 4 KiB 全零块的 CRC32C。** 池在读出空白块——而且关键的,是
|
||||
**`0x8941f998` 是 4 KiB 全零块的 CRC32C**——而且它出现在每一次失败的 `found`
|
||||
一侧,这才是关键:`wanted` 是 csum tree 里存着的校验和(文件*本应*是的内容),
|
||||
`found` 是内核按盘上实际返回的字节算出来的校验和。池在读出空白块——而且关键的,是
|
||||
在**两块盘的相同逻辑偏移上同时读出**。我翻了 PM9A3 阵列那次的笔记:值相同,
|
||||
「确定性全零」行为相同,让 RAID1 失效的关联方式也相同。
|
||||
|
||||
@@ -178,11 +180,13 @@ read_errors: 0 verify_errors: 0 super_errors: 0
|
||||
运行(此前计数器是每天在涨的)。
|
||||
- dmesg 里没有新增 `csum failed` 行。
|
||||
|
||||
确认窗口还有几天:Win11 虚拟机重新上线(它产生的是能复现问题的写入模式)、
|
||||
对照基线文件每日检查、2026-09-22 再做一次复核 scrub。如果关了 TRIM 损坏还在,
|
||||
下一个嫌疑就不是硬盘,而是内存(这台机器没有 ECC)——用 memtest 测。如果
|
||||
一直平静,那 IronWolf 110 队列化 TRIM 固件 bug 就算坐实了——我敢说论坛里
|
||||
「健康硬盘毁掉我的 RAID」的帖子,相当一部分也是它干的。
|
||||
确认窗口接着跑了几个星期:Win11 虚拟机重新上线(它产生的是能复现问题的写入
|
||||
模式)、对照基线文件每日检查、2026-09-22 再做一次复核 scrub。那次复核的结果和第
|
||||
一次一样干净——`corrected_errors: 0`、dmesg 没有新增 `csum failed` 行、
|
||||
`corruption_errs` 仍然停在 sdd1=27 / sdb1=31。如果关了 TRIM 损坏还在,下一个嫌疑
|
||||
就不是硬盘,而是内存(这台机器没有 ECC)——用 memtest 测。它一直安静,于是
|
||||
IronWolf 110 队列化 TRIM 固件 bug 就成了这起事故——我敢说,也包括论坛里
|
||||
「健康硬盘毁掉我的 RAID」那些帖子中的相当一部分——的根因。
|
||||
|
||||
一个「正在故障」的 SMART 告警、一块零错误的硬盘、一个全新阵列上双镜像同时
|
||||
损坏——每个迹象都指向别处,而真正的凶手是一个我们大多数人不假思索就开启的
|
||||
|
||||
@@ -3,6 +3,8 @@ title: "上游被删了,然后带着一个重写版回来"
|
||||
description: "我线上跑的阅读服务,依赖的镜像官方仓库已经 404。几个月后上游回来了——233 个提交、换了语言、零 release。怎么分辨「项目死了」和「项目在重写」。"
|
||||
pubDate: 2026-10-06
|
||||
category: devops
|
||||
blueprint: sme-digital-suite
|
||||
blueprintLayer: L4-managed-ops-handover
|
||||
tags: ["docker", "self-hosting", "upstream", "maintenance", "registry"]
|
||||
ogImage: "/og/zh-the-upstream-was-deleted-then-came-back-rewritten.jpg"
|
||||
banner: "/banners/the-upstream-was-deleted-then-came-back-rewritten.jpg"
|
||||
@@ -10,7 +12,7 @@ draft: false
|
||||
dynamicCta: true
|
||||
---
|
||||
|
||||
`docker pull hectorqin/reader` 返回 **404**。而这正是我线上阅读服务所依赖的项目:一个只发邀请码的小书库,给我家人和几位付费读者用,每天在跑,上面还搭了一条我自己做的语音朗读管线,让「朗读」按钮念出来的是正经的神经网络音色。
|
||||
`docker pull hectorqin/reader` 返回 **404**。而这正是我线上阅读服务所依赖的项目:一个只发邀请码的小书库,供一批付费订阅读者与内部用户每天使用,上面还搭了一条我自己做的语音朗读管线,让「朗读」按钮念出来的是正经的神经网络音色。
|
||||
|
||||
404 还不是最有意思的部分。**2026-09-16**,同一个仓库被从零重新初始化——提交信息是 `chore: 初始化仓库`——十六天后,它已经有了 **233 个提交**、另一门语言、另一个端口、另一种数据库、另一个镜像仓库。我依赖的项目没有死,它是被**一个重写版替换了**,名字没变、地址没变、那 11,036 颗星也没变。
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ done
|
||||
# btrfs RAID1 拒绝直接 swapfile:loop 设备 + NOCOW
|
||||
chattr +C "${SWAPFILE}"
|
||||
LOOPDEV=$(losetup -f)
|
||||
losetup -p 100 "${LOOPDEV}" "${SWAPFILE}"
|
||||
losetup "${LOOPDEV}" "${SWAPFILE}"
|
||||
swapon -p 1 "${LOOPDEV}"
|
||||
```
|
||||
|
||||
@@ -110,6 +110,11 @@ NAME TYPE SIZE USED PRIO
|
||||
/dev/loop0 partition 8G 0B -2
|
||||
```
|
||||
|
||||
(这个 `-2` 是内核给「创建时未指定优先级」的 swap 设备的默认值——那段输出
|
||||
来自加上 `swapon -p 1` 之前的那次运行。顺带说明:`losetup` 自己的 `-p` 是
|
||||
`--pass-fd`,是 loop 加密时代的遗留参数,跟优先级毫无关系;在那里写
|
||||
`-p 100` 轻则无效,重则直接报错。)
|
||||
|
||||
**文件名是 `/dev/loop0`,不是 `/mnt/cache/swapfile`。** 所以这样的清理脚
|
||||
本:
|
||||
|
||||
@@ -158,7 +163,7 @@ done
|
||||
[ -f /mnt/cache/swapfile ] || exit 0
|
||||
losetup -j /mnt/cache/swapfile | grep -q . && exit 0
|
||||
LOOP=$(losetup -f)
|
||||
losetup -p 100 "$LOOP" /mnt/cache/swapfile && swapon -p 1 "$LOOP"
|
||||
losetup "$LOOP" /mnt/cache/swapfile && swapon -p 1 "$LOOP"
|
||||
```
|
||||
|
||||
为什么选「At Startup of Array」而不是「At First Array Start Only」?因为「First Array Start Only」只在开机后的*第一次*阵列启动时跑一次,一旦你在 GUI 里点了停止再启动,它就会跳过。把原本写在 `/boot/config/go` 里的那段 swap 启动代码彻底删掉,改用「At Startup of Array」配合上面那行 `losetup -j` 幂等检查,就能让这个 User Script 成为唯一事实来源——不管是冷开机还是纯 GUI 停止 → 启动循环,一处配置,两条路径都覆盖。
|
||||
|
||||
@@ -4,6 +4,8 @@ description: "了解如何使用 Docker Archive API 将大型视频直接送入
|
||||
pubDate: 2025-08-22
|
||||
updatedDate: 2026-10-22
|
||||
category: devops
|
||||
blueprint: ai-agent-automation
|
||||
blueprintLayer: L1-deterministic-orchestration
|
||||
tags: ["docker", "cdp", "playwright", "youtube", "portainer", "automation"]
|
||||
ogImage: "/og/zh-uploading-large-videos-to-youtube-via-cdp.jpg"
|
||||
banner: "/banners/uploading-large-videos-to-youtube-via-cdp.jpg"
|
||||
@@ -98,7 +100,7 @@ import urllib.request
|
||||
|
||||
PORTAINER_KEY = os.environ.get("PORTAINER_API_KEY", "")
|
||||
PORTAINER_BASE = "http://192.168.1.1:9000/api/endpoints/2/docker"
|
||||
CONTAINER_ID = "2dc0fb6a11c7" # 目标 Chrome 容器 ID
|
||||
CONTAINER_ID = "2dc0fb6a11c7" # 容器短 ID 或容器名(如 "chrome")——建议用名字:容器重建后名字仍在
|
||||
|
||||
def stage_file_to_container(local_path: str, dest_filename: str) -> None:
|
||||
if not PORTAINER_KEY:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "SMART 显示健康,RAID 却在悄悄损坏数据"
|
||||
description: "两块看起来完全健康的 NVMe 组成的 btrfs RAID1 阵列,静默地把 Windows 虚拟机喂成了全零字节。我靠校验和取证抓住它——但根因(M.2 转接卡链路故障 vs 批次缺陷)至今仍未定论,现在两块盘测试都干净。"
|
||||
description: "两块看起来完全健康的 NVMe 组成的 btrfs RAID1 阵列,静默地把 Windows 虚拟机喂成了全零字节。靠的是一个校验和取证——每次失败都落在 found 一侧的全零块 CRC32C——以及两周后终于坐实的根因:异步 TRIM,既不是硬盘,也不是转接卡。"
|
||||
pubDate: 2026-09-16
|
||||
updatedDate: 2026-09-18
|
||||
category: devops
|
||||
@@ -63,12 +63,12 @@ banner: /banners/when-smart-says-healthy-but-your-raid-is-corrupting-data.jpg
|
||||
btrfs 在读校验失败时会往内核日志记一条校验和不匹配。同一个值反复出现:
|
||||
|
||||
```text
|
||||
btrfs: checksum verify failed on <device> wanted 0x8941f998 found <other>
|
||||
btrfs: checksum verify failed on <device> wanted <expected> found 0x8941f998
|
||||
```
|
||||
|
||||
这个特定值是最重要的线索。**`0x8941f998` 就是一整块零字节的 CRC32C。** 它每次都出现、跨越互不相关的文件和 inode,绝不是巧合。正在发生的不是随机比特腐烂,而是盘**稳定地返回全零块**,而这些零填充的读取正是 btrfs 校验失败的对象:
|
||||
这个特定值是最重要的线索。**`0x8941f998` 就是一整块零字节的 CRC32C**——注意它在日志里落在哪一侧:`wanted` 是 csum tree 里记录的期望值(文件本应是的内容),`found` 是内核按盘上实际返回的字节算出来的值。全零块的校验和出现在 `found` 一侧,等于直接证明「读回来的数据本身是空的」,而不是元数据写错了。它每次都出现、跨越互不相关的文件和 inode,绝不是巧合。正在发生的不是随机比特腐烂,而是盘**稳定地返回全零块**,而这些零填充的读取正是 btrfs 校验失败的对象:
|
||||
|
||||
- 验证一下:零填充块的 `crc32c` 就等于 `0x8941f998`。
|
||||
- 验证一下:零填充的 4 KiB 块,CRC32C 是 `0x98f94189`,内核按磁盘上的小端序把它打印成 `0x8941f998`。
|
||||
- 同一个值反复出现 → 读取是**确定性**地吐出空数据,不是偶尔翻几个比特。
|
||||
|
||||
两块虚拟磁盘都中招(`vdisk1.img` inode 2358574、`vdisk2.img` inode 2362817),正好对上「存储层在把零当成被请求的数据返回」的图景。
|
||||
@@ -117,6 +117,8 @@ btrfs RAID1 本应自愈:一个成员坏块、从镜像重建、写回好数
|
||||
|
||||
一台 Windows 虚拟机从备份恢复、数据安全迁出那个坏池、Docker 数据根目录也搬了家——**外加一条事后再推翻的教训。** 我当时换掉了两块盘,但现在我没法确定它们就是元凶:证据同样支持一块共用的 **M.2 转接卡**链路故障,而且现在两块盘测试都干净。(完整排查见 **["我差点把这颗 SSD 送修——真正坏的是 SATA 数据线"](/posts/zh/that-dying-ssd-was-just-a-bad-sata-cable/)**。)
|
||||
|
||||
**最终更新:**转接卡最后也洗清了。两周后同一个全零校验和出现在**完全不同的堆栈**上——主板原生 SATA 口,路径里没有任何转接卡——真正的机制这才露出来:异步 TRIM 同时在两块 RAID1 成员上擦除同一个逻辑区间。详见[《换了不同的硬盘,同样的损坏又来了——真凶是 TRIM,不是 SSD》](/posts/zh/the-cause-was-trim-not-the-ssds/)。
|
||||
|
||||
事故被控制住,是因为损坏被校验和抓住了、而不是在里面泡了几个星期——这一点无论根因是什么都成立。但更诚实的教训,比「把盘换掉」这句要更广、也更不戏剧化:
|
||||
|
||||
1. **一块「正在损坏」的盘,并不等于盘本身坏了。** 在给硬件判死刑前,先排除链路:重新插拔/更换线材、换到别的口/转接卡/插槽,重跑*相同*的失败操作。如果故障跟着**口/转接卡**走,那就是连接的问题。
|
||||
|
||||
@@ -3,6 +3,8 @@ title: "为什么容器里的 Chrome 会忘记标签页 —— 以及我是怎
|
||||
description: "Chrome 的「继续上次浏览」在 Docker 容器里根本不会生效:三次失败的尝试、真正的原因,以及最后管用的 70 行守护脚本。"
|
||||
pubDate: 2026-09-29
|
||||
category: devops
|
||||
blueprint: ai-agent-automation
|
||||
blueprintLayer: L1-deterministic-orchestration
|
||||
tags: [docker, chrome, cdp, persistence, synology, self-hosting]
|
||||
ogImage: /og/zh-why-chrome-forgets-its-tabs-in-a-container.jpg
|
||||
banner: /banners/why-chrome-forgets-its-tabs-in-a-container.jpg
|
||||
@@ -141,7 +143,7 @@ def open_tabs():
|
||||
return [t.get("url", "") for t in tabs
|
||||
if t.get("type") == "page" and t.get("url", "").startswith("http")]
|
||||
|
||||
saved = [u.strip() for u in open(STORE)] if os.path.exists(STORE) else []
|
||||
saved = [u.strip() for u in open(STORE) if u.strip()] if os.path.exists(STORE) else []
|
||||
|
||||
# 1) restore — wait up to 3 minutes for the browser to come up
|
||||
if saved:
|
||||
@@ -232,6 +234,7 @@ docker restart chrome && docker restart chrome-relay # order matters
|
||||
- **确认谁有权限写那条策略。** `root:root`、`644`,放在 `/etc/opt/chrome/policies/managed` 下才是对的形状;用户可写的策略文件会被静默忽略。
|
||||
- **把「浏览器在跑」和「容器在跑」当成两个不同的问题。** 残留 singleton 那次故障,从外面看就是一个健康的容器。
|
||||
- **与其让软件记住状态,不如自己存快照。** 任何关闭路径上带强杀的软件 —— 容器、kiosk、桌面会话 —— 都不是依赖「干净退出」记账的好地方。一个 60 秒一次、原子写入的快照很无聊,但它管用。
|
||||
- **小心「空快照」这条边界。** 因为第 2 步连空列表也会写(这是「我把标签页全关了」得以被尊重的方式),如果浏览器在会话中途挂掉、被桌面会话以空白状态重新拉起,60 秒后它就会把空标签页快照写进去,把上一份好文件覆盖掉。最省事的护栏是:从 `/json/version` 里记住浏览器的 `webSocketDebuggerUrl`,只有当实例还是你启动的那一个时,才允许覆盖快照。
|
||||
|
||||
## 结果
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ banner: /banners/why-telegram-bot-notifications-die.jpg
|
||||
---
|
||||
|
||||
我运行着一套自动化商品监控服务,专门监测市场上新上架的商品,一旦有新品就通过
|
||||
Telegram 实时推送。有一天它突然不再通知了。容器本身是健康的,爬虫也照常往
|
||||
Telegram 实时推送。整套东西就是我 Synology NAS 上的一个容器,用 Portainer
|
||||
管着,Docker bridge 网络里跑着一个 `PID 1` 就是 Python 进程的服务。有一天它
|
||||
突然不再通知了。容器本身是健康的,爬虫也照常往
|
||||
NocoDB 里归档商品——但每一条通知都以日志里那三个字符宣告失败:
|
||||
|
||||
```
|
||||
@@ -115,50 +117,34 @@ nginx 会自己回一张 400 页面——没有 JSON,也没有友好的错误
|
||||
|
||||
## 修复
|
||||
|
||||
我最初的代码在拼装 multipart 请求体时,为了图省事对分片缓冲做了按行切分再用 `b"
|
||||
\n".join(body_lines)` 重新拼接——正是那种"看着显然没问题"、实则埋下微妙 bug 的写法:
|
||||
我最初的代码在拼装 multipart 请求体时,把头部字段和二进制图片都当成「行」,再用 `b"\r\n".join(body_lines)` 拼接——正是那种「看着显然没问题」、实则埋下边界帧错误的写法:
|
||||
|
||||
```python
|
||||
body = b"
|
||||
\n".join(body_lines)
|
||||
# 错误示范:片段自带 \r\n,或二进制数据先被按行切分,再用 b"\r\n" 拼接
|
||||
body = b"\r\n".join(body_lines)
|
||||
```
|
||||
|
||||
问题在于 JPEG 是**二进制数据**。它的压缩字节流中间天然会出现 `0x0A`(`\n`)或 `0x0D`(`
|
||||
`)字节。一旦按行切分再用 `
|
||||
\n` 作为分隔符去 `join`,原本属于图像内部的单个换行字节就会被强行改写成 `
|
||||
\n`,导致字节数膨胀、后续的 multipart 边界(boundary)错位。这也一举解释了第三轮里的诡异矛盾:我在 `docker exec` 手工测试时用的是一个极简的测试 payload(甚至直接传了图片 URL),而常驻进程 `PID 1` 抓到的那张真实商品 JPEG 恰好包含触发边界错位的字节序列!Telegram 收到一个边界损坏的请求体,它的边缘 nginx 在进入 bot API 之前就直接回了 400。
|
||||
它错在两处,而且都不是 `bytes.join()` 去改写了 JPEG——`b"\r\n".join()` 只在列表元素**之间**插入分隔符,永远不会碰元素内部的字节。真正的破坏发生在更上游。第一,如果二进制数据在之前经过了按行处理(例如 `.splitlines()`),JPEG 压缩流内部的单个 `0x0D` 或 `0x0A` 字节就会被当成换行改写成 `\r\n`,既破坏了图片字节,也让按原文件长度算出来的 `Content-Length` 对不上。第二,如果 `body_lines` 里某个片段本身就自带末尾 `\r\n`,外层再 join 一次就变成 `--boundary\r\n\r\nContent-Disposition: ...`——在 MIME 规范里,一个空行就意味着该 part 的头部结束了,于是这个 part 成了一个没有头部的匿名块,`Content-Disposition` 反倒变成了正文的第一行。Telegram 前置的 `nginx/1.30.1` 在请求体校验阶段就把这种畸形请求当成错误,直接返回 HTML 400,根本走不到 Bot API。
|
||||
|
||||
正确的做法是按二进制块(parts)逐段追加,用空字节串 `b"".join(parts)` 原样拼接,绝不按文本行处理:
|
||||
这也解开了第三轮那个矛盾的谜底:我手工 `docker exec` 打的那条命令,用的是刚在宿主机改过的脚本、发的是只有两个字段的最小化 payload;而常驻的 `PID 1` 进程跑的仍是内存里的旧拼接路径,喂进去的是真实商品 JPEG,字节长到正好命中「双 `\r\n`」那种情况——同一个函数,不同的输入,一个 200,一个 400。
|
||||
|
||||
正确的做法是显式控制每一段的分帧,再用空字节串 `b"".join(parts)` 原样拼接各段字节块:
|
||||
|
||||
```python
|
||||
boundary = "----tg" + token_hex(8)
|
||||
parts = [
|
||||
f"--{boundary}
|
||||
\n".encode(),
|
||||
b'Content-Disposition: form-data; name="chat_id"
|
||||
\n
|
||||
\n',
|
||||
f"{chat_id}
|
||||
\n".encode(),
|
||||
f"--{boundary}
|
||||
\n".encode(),
|
||||
b'Content-Disposition: form-data; name="caption"
|
||||
\n
|
||||
\n',
|
||||
f"{caption}
|
||||
\n".encode(),
|
||||
f"--{boundary}
|
||||
\n".encode(),
|
||||
f"--{boundary}\r\n".encode(),
|
||||
b'Content-Disposition: form-data; name="chat_id"\r\n\r\n',
|
||||
f"{chat_id}\r\n".encode(),
|
||||
f"--{boundary}\r\n".encode(),
|
||||
b'Content-Disposition: form-data; name="caption"\r\n\r\n',
|
||||
f"{caption}\r\n".encode(),
|
||||
f"--{boundary}\r\n".encode(),
|
||||
b'Content-Disposition: form-data; name="photo"; '
|
||||
b'filename="image.jpg"
|
||||
\nContent-Type: image/jpeg
|
||||
\n
|
||||
\n',
|
||||
b'filename="image.jpg"\r\nContent-Type: image/jpeg\r\n\r\n',
|
||||
image_bytes, # ← 二进制保持原样,绝不按行切分
|
||||
b"
|
||||
\n",
|
||||
f"--{boundary}--
|
||||
\n".encode(),
|
||||
b"\r\n",
|
||||
f"--{boundary}--\r\n".encode(),
|
||||
]
|
||||
body = b"".join(parts) # 直接拼接字节块,不插入额外分隔符
|
||||
```
|
||||
@@ -188,8 +174,10 @@ tg("sendPhoto", {"photo": image_url, "caption": caption})
|
||||
api.telegram.org` 只要三秒钟,就能省掉我整整一轮。
|
||||
3. **把"输入相同、输出相反"当成谎言。** 输入从来就不相同——两个进程在某
|
||||
个我没看到的地方有差异。诚实的做法是去 diff 字节,而不是 diff 代码。
|
||||
4. **永远不要用文本分隔符去手工拼接二进制数据。** 把文本和 JPEG 混在一起
|
||||
做 `b"\r\n".join()`,迟早会出破坏 bug。
|
||||
4. **永远不要用文本分隔符去手工拼接二进制数据。** `b"\r\n".join()` 只在元
|
||||
素之间插分隔符——所以任何自带末尾 `\r\n` 的片段会变成两个,而经过
|
||||
`.splitlines()` 的二进制流早就被改写了。自己控制每一段的分帧,再用
|
||||
`b"".join()` 拼接。
|
||||
|
||||
## 结果
|
||||
|
||||
|
||||
Reference in new issue
Block a user