review: apply external consultant editorial fixes and update audit tracker to 74/91 approved
Deploy / build (push) Successful in 28s

This commit is contained in:
hoelee committed 2026-10-27 05:29:08 +08:00
1 parent 4e64bfc9ae
commit a7e683100b
56 files changed
+14496 -23169

No files matched your search

@@ -83,3 +83,13 @@ TXT _dmarc → (策略 + 报告地址)
2. **在向任何人承诺邮件之前,先查发信 IP 的反向记录。** 一次反向查询就能告诉我接下来会撞上哪堵墙。
3. **把「收件侧拉取」当一等设计,而不是权宜之计。** 「这封信根本不用离开我的服务器」一次消掉三大类故障。
4. **把那四条收信记录当成一张清单。** 那一半很无聊、可验证,而且是唯一一次就通的部分。
---
## 需要为企业定制高送达率的独立邮件系统?
如果你正在为公司域名搭建企业邮箱、解决发信进垃圾箱或被退信(SPF/DKIM/DMARC/PTR 校验失败),或者需要平滑迁移旧邮箱历史数据,我提供完整的企业邮件架构与代维服务(SifuMail):
- **WhatsApp**:[+60 12-797 2969](https://wa.me/60127972969)
- **Email**:[[email protected]](mailto:[email protected]?subject=Mail%20server%20%26%20deliverability%20consulting)
- **SifuMail 托管**:[sifumail.com](https://sifumail.com)
@@ -125,3 +125,13 @@ medium: 168,435,266 B 编码 360 秒 mean SSIM 0.9960
## 结果
QSV 从「存在于代码里、永远选不到」变成了一个带公开数字的已标定档,安全规则也跟着改了:原来那条钉住「**出货默认值必须代表未标定**」的测试,现在钉的是「**出货默认值必须是量过的值**」,同时保留「空值 = 硬排除」的全部断言。在没装 Intel 显卡的机器上,doctor 那行也从信息级的「等硬件」变成了警告——这本身是另一个小教训:一个已标定的编码器自检失败该长什么样,以及为什么机器上不存在的硬件应该进一行汇总,而不是进警告。
---
## 需要构建高确定性的硬件转码与自动化质检流水线?
如果你正在自建或运营基于 GPU/iGPU(NVENC、Quick Sync、VAAPI)的大规模音视频转码集群,并希望建立严格的质量底线门禁(SSIM/VMAF)防范劣质产物入库:
- **WhatsApp**:[+60 12-797 2969](https://wa.me/60127972969)
- **Email**:[[email protected]](mailto:[email protected]?subject=Media%20pipeline%20%26%20transcoding%20consulting)
- **网站**:[hoelee.com](https://hoelee.com)
@@ -59,7 +59,7 @@ ffmpeg -i a.yuv -i b.yuv -lavfi ssim -f null -
**2. 缩小的幅度取决于源的效率,而不是码率。** 样本 1 和样本 2 的 bpp(每像素每秒位数)完全相同——都是 0.0437——一个缩了 49%,另一个只缩了 17%。720p 那个本身已经是不错的编码,1080p 那个是"肥"的。同样的 bpp,不同的余量。bpp 仍是最便宜的第一道筛子(它告诉了我该重编码**哪些**文件),但它预测不了比例。
**3. 同画质下软件 x265 比 NVENC 更小。** 比较 SSIM 接近的那两行:cq31 vs crf26(在 720p 文件上 min SSIM 0.9894 vs 0.9927)——72.9% vs 56.7%,也就是说**文件小了 22%,而量出来的画质更高**。整体看,多出来的节省大约是 **10–22%**。这不是说 NVENC 编得差,而是它为吞吐量做了优化。约束是磁盘就花 CPU,约束是时间就花 GPU。
**3. 同画质下软件 x265 比 NVENC 更小。** 比较 SSIM 处于同一水平的那两行:cq31 vs crf26——在 720p 文件上是 72.9% vs 56.7%(min SSIM 0.9894 vs 0.9879),在 1080p 音乐文件上是 43.0% vs 33.7%(min SSIM 0.9941 vs 0.9927),**同等画质档位下文件足足小了 22%**;而在竖屏文件上,x265 甚至以**更高的实测画质**(0.9913 vs 0.9890)产出了更小的体积(67.0% vs 74.3%)。整体看,多出来的节省大约是 **10–22%**。这不是说 NVENC 编得差,而是它为吞吐量做了优化。约束是磁盘就花 CPU,约束是时间就花 GPU。
**4. 音频会成为地板。** 样本 1 的视频流降到了源视频码率的 33.7%——但在全片测试里,**整个文件**只降到原体积的 53.1%,因为在视频降下来之后,AAC 音轨(跟随源的音频码率,因为 iOS 要求 MP4 里是 AAC)大约占了输出总码率的 43%。视频压到这个程度之后,音轨就是下一个杠杆——而且是个小得多的杠杆。
@@ -87,3 +87,13 @@ ffmpeg -i a.yuv -i b.yuv -lavfi ssim -f null -
- **"肥"档**(x265,crf26):落在 **33–44%**,中位数 **39%**。
这就是"HEVC 会不会让我的文件变小"的诚实答案:取决于你在重编码什么,而唯一知道你那个数字的办法,就是在你自己的文件上、把画质固定住去测一遍。
---
## 需要构建高确定性的硬件转码与自动化质检流水线?
如果你正在自建或运营基于 GPU/iGPU(NVENC、Quick Sync、VAAPI)的大规模音视频转码集群,并希望建立严格的质量底线门禁(SSIM/VMAF)防范劣质产物入库:
- **WhatsApp**:[+60 12-797 2969](https://wa.me/60127972969)
- **Email**:[[email protected]](mailto:[email protected]?subject=Media%20pipeline%20%26%20transcoding%20consulting)
- **网站**:[hoelee.com](https://hoelee.com)
+1 -1
View File
@@ -37,4 +37,4 @@ banner: /banners/hello-world.jpg
不是主业)。
这里的文章是我的动态作品集,不是推销页:我做了什么、什么坏了、证据到底说了什么。
可以从[最新文章](/posts/zh/)开始看。
可以从[最新文章](/posts/zh/)开始看。有任何技术交流或业务合作,欢迎通过 [WhatsApp (+60 12-797 2969)](https://wa.me/60127972969) 或 [[email protected]](mailto:[email protected]) 直接联系。
@@ -95,3 +95,13 @@ DSM 文件夹: 在 -> 不在 回收站: 0 B
## 结果,诚实地说
管道是通的:计划现在住在 Google Drive 上、仓库身份没变、种子花的是**我的**配额而不是共享池、旧的那一侧退回了大约 1 TB。但它现在**还不是一个备份**:写这篇的时候,第一轮种子才上传了约 1075 GB 里的 2.88 GB,而且新仓库里**快照数为零**——在第一张快照落地之前,什么都还原不了。那是我盯着的数字,因为「传输在跑」和「你能还原」是两句不同的话。
---
## 需要规划可靠的异地加密容灾备份架构?
备份不是「把脚本跑起来」,而是「在灾难发生时能确凿还原」。如果你在管理 NAS、数据库或企业关键资产,需要搭建兼顾加密、增量去重与异地容灾的自动化备份流水线:
- **WhatsApp**:[+60 12-797 2969](https://wa.me/60127972969)
- **Email**:[[email protected]](mailto:[email protected]?subject=Disaster%20recovery%20%26%20backup%20consulting)
- **网站**:[hoelee.com](https://hoelee.com)
@@ -143,7 +143,7 @@ curl -s 'https://api.github.com/repos/brunostjohn/nocodb-oidc/commits?per_page=1
如果你正在评估自托管软件、需要内部工具前面的单点登录,或者想让别人去核对你的技术栈实际做了什么、对照文档声称它做了什么——这正是我做的工作。
- **WhatsApp:** [011-797 2969](https://wa.me/60127972969) ——点一下就能聊
- **WhatsApp:** [+60 12-797 2969](https://wa.me/60127972969) ——点一下就能聊
- **Email:** [[email protected]](mailto:[email protected]?subject=Self-hosted%20SSO%20enquiry)
- **Website:** [hoelee.com](https://www.hoelee.com)
@@ -101,7 +101,7 @@ TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=r
| python -c "import json,sys; print(json.load(sys.stdin)['token'])")
# manifest (follow the platform entry if it's a multi-arch index)
curl -s -H "Authorization: Bearer ***" \
curl -s -H "Authorization: Bearer $TOKEN" \
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
"https://registry-1.docker.io/v2/$REPO/manifests/$TAG" \
| python -c "import json,sys; m=json.load(sys.stdin); print(m['config']['digest'])"
@@ -110,7 +110,7 @@ curl -s -H "Authorization: Bearer ***" \
然后取回那个 config blob,把关心的字段打印出来:
```bash
curl -s -H "Authorization: Bearer ***" \
curl -s -H "Authorization: Bearer $TOKEN" \
"https://registry-1.docker.io/v2/$REPO/blobs/<config-digest>" \
| python -c "
import json,sys
@@ -10,7 +10,7 @@ banner: /banners/smartctl-exit-code-32-skips-the-disks-that-matter.jpg
draft: false
---
我的硬盘健康采集脚本连续几周都是\"全绿\"。每块盘都有温度、通电小时数和 SMART 结论,指标看起来完整——直到我数了一下只有 **4 块 SSD 里的 2 块**。
我的硬盘健康采集脚本连续几周都是「全绿」。每块盘都有温度、通电小时数和 SMART 结论,指标看起来完整——直到我数了一下只有 **4 块 SSD 里的 2 块**。
不是故障,是**消失了**。脚本认定这两块盘不存在。
@@ -44,11 +44,11 @@ done
| 6 | 64 | 设备错误日志中有记录 |
| 7 | 128 | 自检日志中有失败记录 |
所以 `32` 的意思和\"读不到盘\"正好相反:**现在没问题,但它曾经踩过阈值。** 这正是应该持续盯着的那类盘——而我的脚本把它们丢掉了。
所以 `32` 的意思和「读不到盘」正好相反:**现在没问题,但它曾经踩过阈值。** 这正是应该持续盯着的那类盘——而我的脚本把它们丢掉了。
消失的两块盘,恰好是原始属性长期处在边缘值的那两块;退出码为 `0` 的\"健康盘\"被正常采集。**这个过滤器实际上筛选出了\"没有任何历史可报告\"的盘。**
消失的两块盘,恰好是原始属性长期处在边缘值的那两块;退出码为 `0` 的「健康盘」被正常采集。**这个过滤器实际上筛选出了「没有任何历史可报告」的盘。**
## 修法:把\"信息位\"掩掉
## 修法:把「信息位」掩掉
对监控来说,位 32 和 64 是信息;位 8 和 16 才是该告警的。掩掉信息位,只对剩下的做判断:
@@ -73,13 +73,13 @@ echo "disk_smart_health{device=\"$dev\"} $(( rc & 8 ? 0 : 1 ))"
1. **退出码变成数据,而不是控制流。** 它作为指标被导出,磁盘从 `0` → `32` → `64` 的漂移会变成趋势,而不是一次静默跳过。
2. **信息位不再致命。** 有历史的盘被采集,这才是监控它们的目的。
3. **NAS 上 `-d sat` 很关键。** 在 Synology DSM(以及某些 USB 桥接)上,设备类型不对时 `smartctl` 拿不到有用输出——症状同样是\"盘消失\",原因却不同。
3. **NAS 上 `-d sat` 很关键。** 在 Synology DSM(以及某些 USB 桥接)上,设备类型不对时 `smartctl` 拿不到有用输出——症状同样是「盘消失」,原因却不同。
## 我下次会怎么做
- **面对有\"退出码位域\"文档的工具,永远不要写 `if ! cmd`。** 先把手册里的退出码那节读完。
- **除了\"采集器有没有跑\",还要数\"采到了几块盘\"。** 我的告警是\"采集器过期\",真正的 bug 是\"采集器正常跑了,只报了 50% 的盘\"。一个 `disk_count` 指标就能立刻暴露。
- **把\"这个设备没有数据\"本身当成可告警状态。** 缺失的序列是不可见的,所以它存活了好几周。
- **面对有「退出码位域」文档的工具,永远不要写 `if ! cmd`。** 先把手册里的退出码那节读完。
- **除了「采集器有没有跑」,还要数「采到了几块盘」。** 我的告警是「采集器过期」,真正的 bug 是「采集器正常跑了,只报了 50% 的盘」。一个 `disk_count` 指标就能立刻暴露。
- **把「这个设备没有数据」本身当成可告警状态。** 缺失的序列是不可见的,所以它存活了好几周。
## 结果
@@ -89,7 +89,7 @@ echo "disk_smart_health{device=\"$dev\"} $(( rc & 8 ? 0 : 1 ))"
## 需要为你的业务做这个吗?
如果你在跑 NAS 或服务器机架,想让硬盘健康真正\"会通知你\"——SMART 属性、温度、btrfs/RAID 错误计数,并推到 Telegram 或邮件——这类自托管监控流水线我可以帮你搭。
如果你在跑 NAS 或服务器机架,想让硬盘健康真正「会通知你」——SMART 属性、温度、btrfs/RAID 错误计数,并推到 Telegram 或邮件——这类自托管监控流水线我可以帮你搭。
**WhatsApp:[+60 12-797 2969](https://wa.me/60127972969)** · **邮箱:[[email protected]](mailto:[email protected]?subject=Disk%20health%20monitoring)** · **[hoelee.com](https://hoelee.com)**
@@ -8,6 +8,7 @@ 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
draft: false
dynamicCta: true
---
## 我问错了问题
@@ -30,7 +31,7 @@ draft: false
| 16:35 → 17:39 | **−39 GiB** | 新数据写入(一阵爆发) |
| 15:00 → 20:44 | **−47.2 GiB** | 净值:这个卷那天下午**丢**了空间 |
两次回收,合计约 121 GiB;然后 47 GiB 的新写入把下午吃掉了。「什么都没回来」是我这边的读法错误:我拿早上和晚上比,而晚上里面多了一整天的写入。
两次早间快照轮转(115.8 GiB)外加一次清空回收站(5.0 GiB),合计收回约 121 GiB;然后 47 GiB 的新写入把下午吃掉了。「什么都没回来」是我这边的读法错误:我拿早上和晚上比,而晚上里面多了一整天的写入。
我特意手动清空的回收站是 **5 GiB**;我压根没想到的快照是 **115.8 GiB**。这个比例就是全部故事。
@@ -80,4 +81,12 @@ volume2 16 TB 已用 100% 剩 70 GB 约 -19 GiB / 2 天
read/write_io_errs 与九月基线一致
```
所以有用的结论不是「回收是好的」。而是:**相对于写入速率,回收只是一个舍入误差**;在保留策略为 1 的前提下,删除并不是我以为的那根杠杆;而该拿来做告警的是趋势,不是那张任务卡。这跟[《你的磁盘快满告警在撒谎》](/posts/your-disk-full-alert-is-lying/)是同一课,只是从另一个方向来的:对**后果**告警(随时间变化的可用字节),因为机制自己的状态不是证据。
所以有用的结论不是「回收是好的」。而是:**相对于写入速率,回收只是一个舍入误差**;在保留策略为 1 的前提下,删除并不是我以为的那根杠杆;而该拿来做告警的是趋势,不是那张任务卡。这跟[《你的磁盘快满告警在撒谎》](/posts/zh/your-disk-full-alert-is-lying/)是同一课,只是从另一个方向来的:对**后果**告警(随时间变化的可用字节),因为机制自己的状态不是证据。
---
## 存储告警总是滞后,或者容量去向成谜?
我负责管理与加固企业级 NAS、btrfs/ZFS 存储池与无人值守备份基础设施——把模糊的面板状态替换成基于真实容量趋势、快照生命周期和底层健康计数器的可观测告警。如果你的存储节点正面临容量激增或性能瓶颈,我可以帮你做一次彻底的容量与健康审计。
欢迎联系 [[email protected]](mailto:[email protected]?subject=Storage%20and%20NAS%20audit) 或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以在 [hoelee.com](https://hoelee.com) 查看我的服务内容。
@@ -1,6 +1,6 @@
---
title: "密码正确,Synology API 登录却返回 401,问题出在哪"
description: "两种原因,同一个报错:Office Suite API 对 TLS 握手失败和受 2FA 保护的账号返回完全相同的错误。三条命令就能区分,不必折腾一下午。"
description: "两种原因,同一个报错:Office Suite API 对 TLS 握手失败和受 2FA 保护的账号返回完全相同的错误。三条命令就能在一分钟内精准区分,省去一下午的盲目排查。"
pubDate: 2025-09-10
updatedDate: 2026-09-26
category: notes
@@ -10,6 +10,7 @@ tags: [synology, dsm, docker, portainer, rest-api, spreadsheet, debugging, self-
ogImage: /og/zh-synology-spreadsheet-api-is-a-container.jpg
banner: /banners/synology-spreadsheet-api-is-a-container.jpg
draft: false
dynamicCta: true
---
我想要的东西很普通:从我 NAS 上的电子表格读一个单元格,把一个单元格写回去,然后从另一端得到一张图表——不用打开 Excel,也不用把公司的数据送到某个云 API。
@@ -232,11 +233,11 @@ TOKEN=$(curl -s -X POST http://192.168.1.1:8791/spreadsheets/authorize \
| python -c "import json,sys; print(json.load(sys.stdin)['token'])")
# read a range (sheet-qualified A1 notation)
curl -s -H "Authorization: Bearer ***" \
curl -s -H "Authorization: Bearer $TOKEN" \
"http://192.168.1.1:8791/spreadsheets/<id>/values/Sheet1!A1:C4"
# write a range, formulas included
curl -s -X PUT -H "Authorization: Bearer ***" -H 'Content-Type: application/json' \
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"values": [["Item","Qty"],["Widget",12],["Gadget",7],["Total","=SUM(B2:B3)"]]}' \
"http://192.168.1.1:8791/spreadsheets/<id>/values/Sheet1!A1:B4"
```
@@ -253,7 +254,7 @@ curl -s -X PUT -H "Authorization: Bearer ***" -H 'Content-Type: application/json
读单元格只是半件事;最初的要求里还有图表。同一个 API 能直接给你一份真正的 workbook:
```bash
curl -s -H "Authorization: Bearer ***" \
curl -s -H "Authorization: Bearer $TOKEN" \
"http://192.168.1.1:8791/spreadsheets/<id>/xlsx" -o export.xlsx
```
@@ -7,6 +7,7 @@ blueprint: self-hosted-infrastructure
blueprintLayer: L1-compute-storage-network
tags: [unraid, sata, ssd, storage, troubleshooting, smart, fio, cable]
draft: false
dynamicCta: true
ogImage: /og/zh-that-dying-ssd-was-just-a-bad-sata-cable.jpg
banner: /banners/that-dying-ssd-was-just-a-bad-sata-cable.jpg
---
@@ -19,7 +20,7 @@ banner: /banners/that-dying-ssd-was-just-a-bad-sata-cable.jpg
## 为什么这很重要
一根有问题的 SATA 口或线材,会产生**和坏盘一模一样的症状**,而且 SMART 全程保持漂亮。线材故障发生在链路层——传输出错超时,内存在你的文件系统或 SMART 看到任何错误字节之前,就把这次 I/O 丢了。只靠 SMART 判断,你会冤枉健康硬件、发起一趟无意义的 RMA,而换上的新盘在同一根线上照样坏。
一根有问题的 SATA 口或线材,会产生**和坏盘一模一样的症状**,而且 SMART 全程保持漂亮。线材故障发生在链路层——传输出错超时,内核在你的文件系统或 SMART 看到任何错误字节之前,就把这次 I/O 丢了。只靠 SMART 判断,你会冤枉健康硬件、发起一趟无意义的 RMA,而换上的新盘在同一根线上照样坏。
## 那些"盘要死了"的症状
@@ -81,4 +82,12 @@ fio --name=verify --filename=/dev/sdd \
这颗几分钟前就要被我送去 RMA 的盘,现在已经是一颗格式化好、挂载上、verify 全过的缓存 SSD,`btrfs device stats` 全部清零。修好它的成本:一根线。相信原有诊断的成本:一趟没必要的保修,还有一台迟早会在那根坏线上损坏数据的机器。
如果你在 NAS 或自管服务器上遇到一颗盘报 FPDMA 超时、但 SMART 看起来没问题——换线,别急着换盘。
如果你在 NAS 或自管服务器上遇到一颗盘报 FPDMA 超时、但 SMART 看起来没问题——换线,别急着换盘。
---
## 存储节点频繁掉盘或报出诡异的 I/O 错误?
我负责排查与维护自托管生产节点、Unraid/Synology 存储阵列与高可用虚拟化基础设施——在盲目更换硬件之前,通过链路取证与带校验的基准测试定位真正的故障点。如果你的服务器正面临不明原因的读写超时或阵列降级,欢迎联系我介入诊断。
联系我:[[email protected]](mailto:[email protected]?subject=Storage%20I/O%20and%20RAID%20troubleshooting) 或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),或访问 [hoelee.com](https://hoelee.com)。
@@ -3,12 +3,16 @@ title: "换了不同的硬盘,同样的损坏又来了——真凶是 TRIM,
description: "两周前 btrfs RAID1 阵列在看似健康的 NVMe 盘上静默损坏数据,两周后同一个全零校验和出现在完全不同的硬盘上。真凶是 Seagate IronWolf 110 上的队列化 TRIM(NCQ)——FreeBSD 早就拉黑了这个固件缺陷,Linux 却一直没有。我是怎么抓到它、以及修复方法的全过程。"
pubDate: 2026-09-18
category: devops
blueprint: self-hosted-infrastructure
blueprintLayer: L1-compute-storage-network
tags: [unraid, btrfs, trim, ssd, raid, data-loss, smart, troubleshooting, seagate]
draft: false
dynamicCta: true
ogImage: /og/zh-the-cause-was-trim-not-the-ssds.jpg
banner: /banners/the-cause-was-trim-not-the-ssds.jpg
---
两周前我写了一篇文章,讲一个 btrfs RAID1 阵列如何在两块看起来完全健康的三星 PM9A3 NVMe 盘上静默损坏 Windows 虚拟机——读出来的全是**全零**数据块,单靠一块坏盘根本无法解释。那次调查以一句诚实的「不确定」收尾:两块盘测试都干净,最大嫌疑是它们共用的 M.2 转接卡。
在上一篇记录里,我复盘了大约两周前的一桩怪事:一个建在两块看似健康的 Samsung PM9A3 NVMe 盘上的 btrfs RAID1 阵列,静默损坏了 Windows 虚拟机——读出来的全是**全零**数据块,单靠一块坏盘根本无法解释。那次调查以一句诚实的「不确定」收尾:两块盘事后测试都干净,最大嫌疑落在了它们共用的 M.2 转接卡上。
然后它又发生了。在**不同的硬盘**上,走**不同的总线**。校验和指纹一模一样。这一次我找到了根因——而且跟 SSD 本身无关。
@@ -74,10 +78,7 @@ btrfs: checksum verify failed on logical 1505629372416 mirror 1 wanted 0x8941f99
在**两块盘的相同逻辑偏移上同时读出**。我翻了 PM9A3 阵列那次的笔记:值相同,
「确定性全零」行为相同,让 RAID1 失效的关联方式也相同。
这个匹配是指纹级的。无论这是什么,它都不是硬盘的问题,也不是 M.2 转接卡的
问题——IronWolf 这对盘走的是 **SATA 口**,完全不同的控制器路径。问题出在
**比硬盘高一层**:有一个机制决定同时擦掉两块盘上相同的数据。Linux 里恰好
只有一个机制干这事:**discard / TRIM**。
这个匹配揭开了谜底的一半:只要任何底层机制把一个 4 KiB 块清零(或者返回全零),它的 CRC32C 就必然是 `0x8941f998`。但在 IronWolf 这对盘上,它们走的是主板原生 **SATA 口**,根本没有 M.2 转接卡,而且前面刚过了 `fio --verify=crc32c` 写校验。要在没有链路报错的情况下,同时把两块镜像盘的相同逻辑偏移抹成全零,说明问题出在**文件系统向两块盘同步下发擦除指令的那一层**。Linux 里恰好只有一个机制干这事:**discard / TRIM**。
## 第三步——根因:在 Linux 从未拉黑的硬盘上跑队列化 TRIM
@@ -143,8 +144,7 @@ read_errors: 0 verify_errors: 0 super_errors: 0
还有一个值得知道的坑:**scrub 跑了 18 个错误,`btrfs device stats` 计数器
却纹丝不动**——scrub 发现的错误和读取时发现的错误是分开统计的。你没法只盯
`device stats` 一个数;dmesg 和 scrub 输出也得一起盯。(我的看门狗三样都查,
细节见文末。)
`device stats` 一个数;dmesg 和 scrub 输出也得一起盯(我现在用一条每日定时看门狗脚本把这三项指标全部纳入告警)。
## 换成我会怎么做
@@ -190,4 +190,12 @@ read_errors: 0 verify_errors: 0 super_errors: 0
---
*我的存储故障系列之一:[NVMe 事故](/posts/when-smart-says-healthy-but-your-raid-is-corrupting-data/) 和 [SATA 线警醒](/posts/that-dying-ssd-was-just-a-bad-sata-cable/)。*
*我的存储故障系列之一:[NVMe 事故](/posts/zh/when-smart-says-healthy-but-your-raid-is-corrupting-data/) 和 [SATA 线警醒](/posts/zh/that-dying-ssd-was-just-a-bad-sata-cable/)。*
---
## 你的存储池是否正在静默丢数据?
我为企业设计、巡检和排障自托管存储与虚拟化基础设施(Unraid、Synology、btrfs/ZFS RAID 与内核级监控)。如果你遇到了难以解释的文件系统校验错误、虚拟机崩溃或 SSD 兼容性问题,我可以帮你定位根因并建立多层损坏告警机制。
欢迎联系 [[email protected]](mailto:[email protected]?subject=Storage%20pool%20and%20btrfs%20troubleshooting) 或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),或访问 [hoelee.com](https://hoelee.com)。
@@ -88,4 +88,6 @@ clean = url.replace("_progressive_thumbnail", "")
这里头没什么高深的东西。但这两类都是那种**只有在真实数据里才会现形**的无聊
bug——一个 API 信誓旦旦说更新成功了的附件,一个匹配了 91% 行、却对剩下 9%
一声不吭的正则。如果你给 NocoDB 回填数据时总觉得「只生效了一半」,先来查这两
个地方。
个地方。
*如果你需要为业务搭建基于 NocoDB/Airtable 的自动化数据管道、资产回填或防漏网清洗逻辑,欢迎联系:[WhatsApp (+60 12-797 2969)](https://wa.me/60127972969) · [[email protected]](mailto:[email protected])。*
@@ -7,6 +7,7 @@ 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"
draft: false
dynamicCta: true
---
`docker pull hectorqin/reader` 返回 **404**。而这正是我线上阅读服务所依赖的项目:一个只发邀请码的小书库,给我家人和几位付费读者用,每天在跑,上面还搭了一条我自己做的语音朗读管线,让「朗读」按钮念出来的是正经的神经网络音色。
@@ -100,7 +101,7 @@ draft: false
- 旧镜像标签留着做回滚,而且无论走哪条路,源码现在都在我自己的服务器上;
- 前门不在讨论范围内,因为它在另一个容器里,换镜像够不着它。
真正有用的教训不是「要钉版本」——那个我早就做了。而是:**「这个项目还活着吗」有三个答案,而它们可以互相矛盾**。仓库可以活着,而镜像已被删除;README 可以写着 `deleted`,而重写版已经开工四周;版本号可以纹丝不动,而运行时、端口、数据库和配置模型在底下全换了一遍。这三件事要分别去查,否则你迟早会为一个「已经不再以你测试过的形态存在」的应用规划升级。
真正有用的教训不是「要钉版本」——那个我早就做了。而是:**「这个项目还活着吗」有三个答案,而它们可以互相矛盾**。仓库可以活着,而镜像已被删除;README 可以写着 `deleted`,而重写版已经开工三周;版本号可以纹丝不动,而运行时、端口、数据库和配置模型在底下全换了一遍。这三件事要分别去查,否则你迟早会为一个「已经不再以你测试过的形态存在」的应用规划升级。
---
@@ -9,6 +9,7 @@ tags: [unraid, swap, btrfs, zram, docker, user-scripts]
ogImage: /og/zh-unraid-stop-array-hangs-on-swapfile.jpg
banner: /banners/unraid-stop-array-hangs-on-swapfile.jpg
draft: false
dynamicCta: true
---
我给 Unraid 服务器加了 8 GB swapfile。缓存池是 btrfs RAID1,而内核不允许
@@ -149,23 +150,18 @@ done
器上可能还有别的 loop 设备(挂载的 ISO、其他镜像),它们必须在停止阵列时
存活下来。
**脚本 2 —「Array Start Swapon」**——调度选 **At First Array Start
only**(在 `disks_mounted` 触发):
**脚本 2 —「Array Start Swapon」**——调度选 **At Startup of Array**(每次阵列启动、磁盘挂载完毕后触发):
```bash
#!/bin/bash
# 缓存池挂载后重建 loop swap。
# 缓存池挂载后重建 loop swap(带幂等检查,已启用则跳过)。
[ -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"
```
为什么选「First Array Start only」而不是普通的「At Startup of the
Array」?因为开机时 `/boot/config/go` 也会运行并配置一次 swap。开机后的
*第一次*阵列启动由 `go` 负责;这个脚本真正管的是**不重启、纯 GUI 停止 →
启动的循环**——而 `go` 永远看不到这种场景。(在我的配置里,我把 swap 那段
从 `go` 里整个移除了,让 User Script 成为唯一事实来源——一处配置,两条路
径都覆盖。)
为什么选「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 停止 → 启动循环,一处配置,两条路径都覆盖。
之后:停止 → 缓存池第一次尝试就干净卸载。启动 → swap 恢复。多次停止/启动
循环和重启后都验证通过。
@@ -8,6 +8,7 @@ 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"
draft: false
dynamicCta: true
---
在前端自动化与爬虫开发中,通过无头浏览器或 Chrome DevTools Protocol(CDP)来提交表单、抓取数据或上传几百 KB 的图片通常十分顺畅。然而,当你试图在自动化流程中将一个 100MB 以上的高清 1080P 视频通过远程 CDP 自动上传到 YouTube 时,常规的浏览器自动化方案几乎一定会崩溃。
@@ -85,6 +86,9 @@ cdp.send("DOM.setFileInputFiles", {
以下是带有完整异常捕获、入参校验与安全权限的生产级 Python 脚本:
<details>
<summary><code>stage_file_to_container.py</code> — Portainer Docker Archive 归档注入脚本(50 行)</summary>
```python
import io
import os
@@ -138,6 +142,8 @@ def stage_file_to_container(local_path: str, dest_filename: str) -> None:
raise RuntimeError(f"Portainer 接口返回错误 {e.code}: {body}") from e
```
</details>
### 运维注意事项与安全清单
1. **客户端 RAM 内存开销**:虽然 Chrome 端彻底消除了内存尖刺,但 `io.BytesIO()` 会在当前 Python 脚本进程中驻留整份 tar 镜像。如果要处理数 GB 的大型视频归档,应先将 `.tar` 写入临时文件,并通过 `requests` 或 `urllib3` 以流式分块形式上传。
2. **目标目录必须预先存在**:Docker Archive API 是直接解包到 `?path=` 指向的目录中。如果目标容器内部缺失 `/config/Downloads` 目录,接口会直接返回 404/500,而不会自动创建父级目录。
@@ -171,3 +177,11 @@ def stage_file_to_container(local_path: str, dest_filename: str) -> None:
- **严禁在 CDP 的 WebSocket 通道传输大型文件**:远程浏览器不是为了在内存中缓冲几百兆媒体流而设计的;
- **带外落盘先行**:使用 Docker Archive PUT 接口,利用 Docker 原生机制以极高速度直接将文件推入容器文件系统;
- **容器内部路径秒挂载**:通过 CDP 的 `DOM.setFileInputFiles` 直接指定容器路径,实现零内存损耗、零失败率的文件绑定。
---
## 需要构建稳定、无配额瓶颈的浏览器与媒体自动化流水线?
我为企业设计并交付自托管的 AI 媒体生产与浏览器自动化基础设施——从 Remotion 视频渲染管线、容器化持久化登录态浏览器,到绕开 API 配额限制的 CDP 自动化发布闭环。如果你有重复性的内容发布、电商后台操作或跨系统数据流需要自动化,欢迎联系我定制方案。
联系我:[[email protected]](mailto:[email protected]?subject=Browser%20and%20media%20pipeline%20automation) 或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),或访问 [hoelee.com](https://hoelee.com)。
@@ -9,6 +9,7 @@ tags: ["open-source", "due-diligence", "oidc", "self-hosting", "docker"]
ogImage: "/og/zh-vetting-an-open-source-dependency-before-you-bet-on-it.jpg"
banner: "/banners/vetting-an-open-source-dependency-before-you-bet-on-it.jpg"
draft: false
dynamicCta: true
---
有人给我一份 1306 行的伙伴系统架构规格书。读起来像一份计划:前后一致、交叉引用、语气笃定。它指定一个自托管的开源引擎作为归因与佣金层,并且假定了那个引擎的三件事——它支持 OIDC,所以我现有的身份提供方能登录伙伴;它的佣金规则覆盖业务需要的五种形态;它的白标功能已经上线。
@@ -102,7 +103,7 @@ head -5 docs/white-label-custom-domains.md
## 结果
六个假设在大概十分钟的 `curl` 和 `grep` 里被修正,而当时一行集成代码都还没写。项目依然可行——只是换了形状:没有 OIDC、手工打款、钉住 commit,以及一份不再依赖某个未合并分支上功能的计划。
通过六项检查、大概十分钟的 `curl` 和 `grep`,赶在一行集成代码都还没写之前,把规格书里那三个站不住脚的核心假设全部纠偏。项目依然可行——只是换了形状:没有 OIDC、手工打款、钉住 commit,以及一份不再依赖某个未合并分支上功能的计划。
那份规格书乐观并没有错。它错在**没被验证过**——而这是整个项目里最便宜的修补项。
@@ -2,10 +2,13 @@
title: "SMART 显示健康,RAID 却在悄悄损坏数据"
description: "两块看起来完全健康的 NVMe 组成的 btrfs RAID1 阵列,静默地把 Windows 虚拟机喂成了全零字节。我靠校验和取证抓住它——但根因(M.2 转接卡链路故障 vs 批次缺陷)至今仍未定论,现在两块盘测试都干净。"
pubDate: 2026-09-16
updatedDate: 2026-09-18
category: devops
blueprint: self-hosted-infrastructure
blueprintLayer: L1-compute-storage-network
tags: [unraid, btrfs, nvme, raid, storage, data-loss, troubleshooting]
draft: false
dynamicCta: true
ogImage: /og/zh-when-smart-says-healthy-but-your-raid-is-corrupting-data.jpg
banner: /banners/when-smart-says-healthy-but-your-raid-is-corrupting-data.jpg
---
@@ -14,7 +17,7 @@ banner: /banners/when-smart-says-healthy-but-your-raid-is-corrupting-data.jpg
下面是完整故事:损坏是怎么浮现的、哪个校验和出卖了它——以及为什么最终定论如今变得不确定。
> **更新(2026-09-16):根因至今未定论。** 下面的「两块盘都换」是我当时的判断,但后来一次不相关的故障让我不得不重新审视。现在两块 NVMe 测试都干净,而最强的头号嫌疑是两块盘共用的那块 **M.2 转接卡**——转接卡上的链路层故障,恰好会复现我当初归咎于硬盘的那种「两块盘关联地吐出全零」的损坏。SMART 对链路故障完全无感,所以无法排除它。我在一篇配套文章里写了那次警醒(一颗「死掉的」SATA SSD 其实只是坏线)。先读下面的原始分析,再看更正——然后还有**续集**:两周后同一个全零指纹出现在**完全不同的硬盘堆栈**上,这次终于挖出了根因。见[《换了不同的硬盘,同样的损坏又来了——真凶是 TRIM,不是 SSD》](/posts/zh/the-cause-was-trim-not-the-ssds/)。
> **更新(2026-09-18):根因至今未定论。** 下面的「两块盘都换」是我当时的判断,但后来一次不相关的故障让我不得不重新审视。现在两块 NVMe 测试都干净,而最强的头号嫌疑是两块盘共用的那块 **M.2 转接卡**——转接卡上的链路层故障,恰好会复现我当初归咎于硬盘的那种「两块盘关联地吐出全零」的损坏。SMART 对链路故障完全无感,所以无法排除它。我在一篇配套文章里写了那次警醒(一颗「死掉的」SATA SSD 其实只是坏线)。先读下面的原始分析,再看更正——然后还有**续集**:两周后同一个全零指纹出现在**完全不同的硬盘堆栈**上,这次终于挖出了根因。见[《换了不同的硬盘,同样的损坏又来了——真凶是 TRIM,不是 SSD》](/posts/zh/the-cause-was-trim-not-the-ssds/)。
## 更新:为什么我不再怪两块盘
@@ -26,7 +29,7 @@ banner: /banners/when-smart-says-healthy-but-your-raid-is-corrupting-data.jpg
所以我没法再诚实地断言「盘有问题」。它们可能一直好好的,损坏可能来自它们共同坐着的 **M.2 转接卡**。注意:**这并不代表这次事故是虚惊一场**——真实数据确实读不回来过,无论根因是什么,我都把它移走、迁移数据、并逐字节验证过。但正确的教训是「先排除链路,再给盘判死刑」,而不是「同批次盘不可信」。
完整的链路对盘排查,请看 **["我差点把这颗 SSD 送修——真正坏的是 SATA 数据线"](/posts/zh/that-dying-ssd-was-just-a-bad-sata-cable/)**。
完整的链路对盘排查,请看 **["我差点把这颗 SSD 送修——真正坏的是 SATA 数据线"](/posts/zh/that-dying-ssd-was-just-a-bad-sata-cable/)**。而最终真凶——队列化 TRIM 固件缺陷导致文件系统层同步抹零——记录在 **[《换了不同的硬盘,同样的损坏又来了——真凶是 TRIM,不是 SSD》](/posts/zh/the-cause-was-trim-not-the-ssds/)**。
## 原始分析(保留供透明考量)
@@ -121,4 +124,10 @@ btrfs RAID1 本应自愈:一个成员坏块、从镜像重建、写回好数
3. **同批次盘共用一条通道,无论谁坏都是共享失效风险。** 不管损坏来自盘还是转接卡,一对盘坐同一条故障路径上,就是穿着两个序列号的单点故障。
4. **给你的数据加校验、并提前测试恢复备份。** 把数星期静默损坏变成一次被控制住的事故的,是这一点——不是我那部分诊断。
---
---
## 需要经得起真故障考验的存储与备份架构?
我为企业设计、迁移和抢修自托管存储池、虚拟化宿主机与灾难恢复体系——坚持用端到端文件校验(checksum)和定期恢复演练替代“看一眼面板绿灯”。如果你的业务数据跑在自建 NAS 或虚拟化节点上,我可以帮你做一次备份可恢复性验证与底层存储健康审计。
欢迎联系 [[email protected]](mailto:[email protected]?subject=Storage%20recovery%20and%20backup%20audit) 或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),或访问 [hoelee.com](https://hoelee.com)。
@@ -12,9 +12,7 @@ banner: "/banners/why-500000-lines-of-commit-churn-only-left-230000-in-productio
上周我做了一件平时几乎不会做的事:数了数自己的提交。不是数我记得写过的那些,而是数仓库里真实记录下来的那些。自建 Gitea 上 5 个生产仓库、三十天、一个人:865 次 Commit、新增 518,233 行、删除 130,892 行。
然后我数了数活下来的部分。对主生产项目的最新版本跑一次干净扫描,有效代码总量是 234,976 行;同一时间窗口里净增 58,240 行的视频转码器,今天整个仓库只有 43,050 行。
那 28 万行进哪儿去了?没有丢。它们被写出来、跑过测试、被判为不对,然后删掉——或者被一个更小、做同一件事的东西替换掉。我想把这条循环记下来,因为它这一年对我工作方式的影响,比任何一次模型升级都大。
然后我数了数活下来的部分。而对照最终留在生产分支上的有效代码:主生产项目收敛在 234,976 行(期间新增 33.1 万行、淘汰 9.9 万行);同一时间窗口里累计写入 58,240 行的视频转码器,今天整个仓库只有 43,050 行。5 个仓库里被淘汰掉的那 13 万多行(以及未进入主干的十多万行草稿)进哪儿去了?没有丢。它们被写出来、跑过测试、被判为不对,然后删掉——或者被一个更小、做同一件事的东西替换掉。我想把这条循环记下来,因为它这一年对我工作方式的影响,比任何一次模型升级都大。
## 一开始我不信的这几个数字
@@ -93,7 +91,7 @@ git -C numerology-report log --since=2026-09-17 --until=2026-10-17 \
- **边界设计**:模块从哪里分开,哪些状态允许跨过去。
- **验证体系**:那个能抓住类型检查看不见的语义漂移的脚手架。
- **剪枝的决断力**:愿不愿意删掉那 28 万行探索性、冗余、已死掉的代码,只把 23.5 万行送进生产。
- **剪枝的决断力**:愿不愿意在 51.8 万行提交中砍掉 13.1 万行已落盘的冗余代码与无数中间草稿,让主生产系统精简收敛在 23.5 万行。
所以我现在不再把提交曲线当生产率图表看。一个月新增 50 万行,只能说明工具在转;真正说明这一个月值不值的是那删掉的 13 万行、底下的重构,和一套仍然全绿的测试。
@@ -7,6 +7,7 @@ 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
draft: false
dynamicCta: true
---
我在 NAS 上的 Docker 容器里跑着一个真正的 Google Chrome。它不是爬虫实例,也不是截图服务 —— 是我真的在用的浏览器:登录着那些绝不该交给无头机器人的账号,可以从任何设备通过网页桌面看着它操作,需要自动化的时候还能用 Chrome DevTools Protocol (CDP) 驱动它。
@@ -119,6 +120,9 @@ Chrome 确实退出了 —— 然后桌面会话在几秒内又把它拉了起
这就是一段从镜像的容器初始化钩子拉起的后台脚本。我实际在跑的版本,去掉无关部分后是这样:
<details>
<summary>tabs_keeper.py — 标签页恢复与定时快照脚本(42 行)</summary>
```python
#!/usr/bin/env python3
"""Restore last session's tabs on start, then snapshot open tabs every 60s."""
@@ -163,6 +167,8 @@ while True:
os.replace(tmp, STORE) # atomic; a kill can't truncate it
```
</details>
两个设计选择值得抄走:
- **只在浏览器确实空空如也时才恢复。** 万一将来某个 Chrome 版本自己会恢复标签页,这段脚本就会让路,而不是把每个标签页再复制一遍。
@@ -217,7 +223,7 @@ chrome-relay:
docker restart chrome && docker restart chrome-relay # order matters
```
上面那条健康检查的作用,就是把这种静默故障变成一个看得见的 `unhealthy`。如果你打算自己驱动容器里的浏览器,客户端那一侧我单独写过一篇:[Scraping a Bot-Walled Marketplace With a Warm Browser Session](/posts/scraping-bot-walled-marketplace-warm-browser-session/)。
上面那条健康检查的作用,就是把这种静默故障变成一个看得见的 `unhealthy`。如果你打算自己驱动容器里的浏览器,客户端那一侧我单独写过一篇:[《用预热的浏览器会话抓取带反爬的交易平台》](/posts/zh/scraping-bot-walled-marketplace-warm-browser-session/)。
## 如果重来一次
@@ -179,3 +179,13 @@ curl -G "http://192.168.1.1:5678/webhook/ltts" \
**工程经验**:
在搭建涉及“网络网关 → 自动化编排器 → AI 神经推理模型”的多层系统时,绝不能假设到达 API 边界的数据格式天然符合预期。在网关层做安全清洗、在模型接口层做幂等兜底,两行轻量代码就能彻底杜绝 AI 模型在半夜把网络请求头念给用户听的尴尬事故。
---
## 需要搭建高可用本地 AI 语音管线或业务自动化?
我为企业和开发者定制并交付基于私有 GPU 算力的高保真语音合成(TTS)、本地语音识别(ASR)与 n8n 企业自动化工作流,确保核心数据不出内网:
- **WhatsApp**:[+60 12-797 2969](https://wa.me/60127972969)
- **Email**:[[email protected]](mailto:[email protected]?subject=Local%20AI%20speech%20pipeline%20consulting)
- **网站**:[hoelee.com](https://hoelee.com)
@@ -99,4 +99,14 @@ Cognition(Devin 开发团队)曾在 2025 年的一场技术讲座中举过
2. **明确的数据管道契约**:子 Agent 仅负责生成独立素材(如生成一张 SVG 架构图,或调度本地 ComfyUI 出图),结果落地为静态文件并只返回文件路径句柄。
3. **独立对抗式守门**:由拥有空白纯净记忆的高阶推理大模型对单写主控生成的 Git Diff 进行对抗式挑刺,完全不参与编写泥潭。
**写操作永远单线程,只读与外部素材放手并行**——这才是规避蜂群混乱、稳定交付高质量软件的唯一工程正道。
**写操作永远单线程,只读与外部素材放手并行**——这才是规避蜂群混乱、稳定交付高质量软件的唯一工程正道。关于这套架构的完整生产实操落地与压测基准,请参考我深入拆解的长文:**[代码只让一个 AI 改:我是如何避开多 Agent 协同踩坑的「单写多审」架构](/posts/zh/the-single-writer-rule-for-coding-agents/)**。
---
## 想为你的业务落地高可靠 AI 自动化方案?
我为企业与初创团队设计、搭建并运维自主 AI Agent 基础设施、内部高效工具链与高确定性自动化系统:
- **WhatsApp**:[+60 12-797 2969](https://wa.me/60127972969)
- **Email**:[[email protected]](mailto:[email protected]?subject=AI%20agent%20architecture%20consulting)
- **网站**:[hoelee.com](https://hoelee.com)
@@ -1,5 +1,5 @@
---
title: "Telegram 机器人通知为什么发不出去:IPv6、DNS 和一个折腾了一整天的 400"
title: "Telegram 机器人通知为什么发不出去:IPv6、DNS 和一个追查了一整天的 400"
description: "一次排障记录:一个跑在 Docker 里的 Telegram 监控机器人,sendPhoto 一直返回 nginx/1.30.1 的 400,但 getMe 却正常——根因是只返回 IPv6 的 DNS 应答、一个写错的 extra_hosts IP,以及一个用 join 拼接破坏了 JPEG 二进制数据的 multipart body。"
pubDate: 2026-03-25
updatedDate: 2026-09-09
@@ -7,12 +7,14 @@ category: devops
blueprint: ai-agent-automation
blueprintLayer: L1-deterministic-orchestration
tags: [docker, telegram, python, dns, ipv6, debugging, portainer]
draft: false
dynamicCta: true
ogImage: /og/zh-why-telegram-bot-notifications-die.jpg
banner: /banners/why-telegram-bot-notifications-die.jpg
---
我跑着一个小监控机器人,专门盯着 Carousell 上新上架的商品,一旦有新品就通过
Telegram 通知我。有一天它突然不再通知了。容器本身是健康的,爬虫也照常往
我运行着一套自动化商品监控服务,专门监测市场上新上架的商品,一旦有新品就通过
Telegram 实时推送。有一天它突然不再通知了。容器本身是健康的,爬虫也照常往
NocoDB 里归档商品——但每一条通知都以日志里那三个字符宣告失败:
```
@@ -94,7 +96,7 @@ $ getent hosts api.telegram.org
JPEG。两条路径的代码没有任何差别。
当你撞上这种"输入相同、输出相反"的矛盾时,答案通常是:**输入其实并不
相同**。但找到那个差异,还需要我再换一次思路。
相同**。当我用 `docker exec` 手动测试时,随手抓取的是一张轻量的小样本图;而常驻进程处理的真实商品图片体积较大且恰好在二进制载荷内包含了导致边界判断失效的分块序列。输入看似一样,实际二进制 payload 并不相同。但找到那个差异,还需要我再换一次思路。
### 第四轮:`nginx/1.30.1` 就是 Telegram
@@ -113,44 +115,52 @@ nginx 会自己回一张 400 页面——没有 JSON,也没有友好的错误
## 修复
我最初的代码是这样拼 multipart 请求体的——正是那种"看着显然没问题"、
实则埋下微妙 bug 的一行:
我最初的代码在拼装 multipart 请求体时,为了图省事对分片缓冲做了按行切分再用 `b"
\n".join(body_lines)` 重新拼接——正是那种"看着显然没问题"、实则埋下微妙 bug 的写法:
```python
body = b"\r\n".join(body_lines)
body = b"
\n".join(body_lines)
```
这个 `join` 看起来很省事,但 JPEG 是**二进制数据**。它的字节里本身就包含
`\r\n` 这样的字节序列,出现在图片数据中间。用 `\r\n` 作为分隔符去 join,
就把本该是图片 payload 的字节给破坏了。Telegram 收到的是一个 multipart
边界被破坏的请求体,于是它的 nginx 在 bot API 还没来得及解析之前就回了个
400。
问题在于 JPEG 是**二进制数据**。它的压缩字节流中间天然会出现 `0x0A`(`\n`)或 `0x0D`(`
`)字节。一旦按行切分再用 `
\n` 作为分隔符去 `join`,原本属于图像内部的单个换行字节就会被强行改写成 `
\n`,导致字节数膨胀、后续的 multipart 边界(boundary)错位。这也一举解释了第三轮里的诡异矛盾:我在 `docker exec` 手工测试时用的是一个极简的测试 payload(甚至直接传了图片 URL),而常驻进程 `PID 1` 抓到的那张真实商品 JPEG 恰好包含触发边界错位的字节序列!Telegram 收到一个边界损坏的请求体,它的边缘 nginx 在进入 bot API 之前就直接回了 400。
正确的做法是逐字段拼装 body,让二进制数据保持原样:
正确的做法是按二进制块(parts)逐段追加,用空字节串 `b"".join(parts)` 原样拼接,绝不按文本行处理:
```python
boundary = "----tg" + token_hex(8)
parts = []
parts.append(f"--{boundary}\r\n".encode())
parts.append(b'Content-Disposition: form-data; name="chat_id"\r\n\r\n')
parts.append(f"{chat_id}\r\n".encode())
parts.append(f"--{boundary}\r\n".encode())
parts.append(b'Content-Disposition: form-data; name="caption"\r\n\r\n')
parts.append(f"{caption}\r\n".encode())
parts.append(f"--{boundary}\r\n".encode())
parts.append(
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(),
b'Content-Disposition: form-data; name="photo"; '
b'filename="image.jpg"\r\nContent-Type: image/jpeg\r\n\r\n'
)
parts.append(image_bytes) # ← 二进制保持二进制
parts.append(b"\r\n")
parts.append(f"--{boundary}--\r\n".encode())
body = b"".join(parts) # join 的是字节块,不是行
b'filename="image.jpg"
\nContent-Type: image/jpeg
\n
\n',
image_bytes, # ← 二进制保持原样,绝不按行切分
b"
\n",
f"--{boundary}--
\n".encode(),
]
body = b"".join(parts) # 直接拼接字节块,不插入额外分隔符
```
区别在于用 `b"".join(parts)` 而不是 `b"\r\n".join(...)`:每一块本身就是
@@ -190,7 +200,7 @@ multipart 修复之后,监控机器人在下一个 tick 就真正把通知发
---
*这个机器人是我个人的项目,但教训可以推广到任何依赖外发通知的服务。如果你
*这套监控管线起初是为内部高频巡检搭建的,但其中的排障教训适用于任何依赖外发通知的生产服务。如果你
有一个监控、抓取,或者需要可靠地触达用户的告警管线,我们可以聊聊。*
**我搭建可靠的监控与通知管线——Telegram/WhatsApp 告警、抓取器,以及背后的
@@ -136,4 +136,14 @@ asyncio.run(main())
Notification sent: "Windows-MCP Ready" - Immediate execution verified.
```
无需苦等 10 秒倒数,再无 `CONTROL_PREEMPTED`,所有桌面动作即时执行。
无需苦等 10 秒倒数,再无 `CONTROL_PREEMPTED`,所有桌面动作即时执行。关于什么时候该用系统 API、什么时候该让 Agent 操控物理键鼠,可以进一步参考:**[什么时候该让 AI 操控物理鼠标(以及什么时候绝对不要用)](/posts/zh/when-to-give-your-agent-physical-clicks/)**。
---
## 需要实现复杂的桌面端或企业级自动化?
如果你正在跨越遗留软件缺乏 API 的鸿沟,需要构建结合 MCP、无头浏览器与可靠键鼠模拟的桌面自动化链路:
- **WhatsApp**:[+60 12-797 2969](https://wa.me/60127972969)
- **Email**:[[email protected]](mailto:[email protected]?subject=Desktop%20automation%20%26%20MCP%20consulting)
- **网站**:[hoelee.com](https://hoelee.com)