2026年 07月 的归档
记一例 nginx 故障分析
早上起来习惯性瞄一眼 home lab 的日志,看到我那个 WebSocket 服务凌晨被重启过:
|
1 2 |
[2026-07-28 06:48:02] || Server: Loaded 7 pinned rooms [2026-07-28 06:48:02] || Server: Starting server at wss://0.0.0.0:8765 |
重启就重启吧,本来没当回事。结果顺手打开本博客——打不开。又试了下这台机器上跑的其他站,也全打不开。好家伙,整台机器的 nginx 全躺了,而且看时间已经躺了三个多小时。
先看现场
第一件事是确认机器有没有重启过。uptime 一看,没有,机器好好的。那就是 nginx 单独出事了。systemctl restart nginx 下去,唰一下全恢复了。
服务是回来了,但这更让人不安:能 restart 成功,说明磁盘上的配置本来就是好的,那它三个小时前到底为什么起不来?翻 unit 日志:
|
1 2 3 4 5 6 7 8 9 |
$ sudo journalctl -u nginx.service --since "6 hour ago" --no-pager Jul 28 06:48:02 s systemd[1]: Stopping nginx.service - A high performance web server and a reverse proxy server... Jul 28 06:48:02 s systemd[1]: nginx.service: Deactivated successfully. Jul 28 06:48:02 s systemd[1]: Stopped nginx.service - A high performance web server and a reverse proxy server. Jul 28 06:48:02 s systemd[1]: nginx.service: Consumed 19min 12.963s CPU time, 117.6M memory peak, 0B memory swap peak. Jul 28 06:48:02 s systemd[1]: Starting nginx.service - A high performance web server and a reverse proxy server... Jul 28 06:48:02 s nginx[2766192]: 2026/07/28 06:48:02 [emerg] 2766192#2766192: host not found in upstream "bones7456.github.io" in /etc/nginx/sites-enabled/luy:75 Jul 28 10:00:10 s systemd[1]: Starting nginx.service - A high performance web server and a reverse proxy server... Jul 28 10:00:10 s systemd[1]: Started nginx.service - A high performance web server and a reverse proxy server. |
host not found in upstream。我博客有几个路径(比如这个)是反代到 GitHub Pages 的,nginx 起来的时候要解析 bones7456.github.io,那一刻没解析出来,直接 emerg 拒绝启动。
再一看,06:48 这个时间点太像 Debian 系的 cron.daily 窗口了(/etc/crontab 里是 6:25,加上 anacron 的随机延迟,另外 cron.weekly 是 6:47,更像),我当时第一反应是 certbot 续期把 nginx 重启崩了。结果把那五分钟的全量日志拉出来一看,跟 certbot 半毛钱关系没有。所以还是那句话:别猜,看日志。
1 秒的竞速
把 journalctl --since "06:45" --until "06:50" 全量捞出来,真凶一目了然:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
06:47:50 Starting apt-daily-upgrade.service <== unattended-upgrades 开跑 06:47:57 systemd[1]: Reexecuting requested from client PID ... (unit apt-daily-upgrade.service) systemd 255.4-1ubuntu8.16 running in system mode <== systemd 自己被升级了 06:48:02 ★ 满机器的服务被批量重启(needrestart 干的) Stopping named... <== named 被停掉,不再监听 127.0.0.1#53 Stopping nginx... <== nginx 被停掉 Starting nginx... (PID 2766192) <== nginx 再次启动 └─ [emerg] host not found in upstream "bones7456.github.io" <== nginx 报错失败了 nginx.service: Control process exited, code=exited, status=1/FAILURE 06:48:03 Starting named... (PID 2766508) named: listening on IPv4 interface lo, 127.0.0.1#53 <== named 启动,DNS 回来了 |
看明白了:systemd 被 apt 自动升级,触发 needrestart 把机器上几乎所有服务全重启一遍。我这台机器上跑着 BIND 做内网 DNS,于是 named 和 nginx 一起被拖下水。
nginx 在 06:48:02 查 DNS,named 在 06:48:03 才恢复监听。就差 1 秒,nginx 输了这场竞速,代价是三个多小时的全站宕机。
最扎心的是同一批重启里的对比:php-fpm、redis、mariadb,还有我自己那几个 Flask 服务,全都 Started 成功了。唯独 nginx 死了。为什么?因为只有 nginx 在启动阶段需要解析外部域名,别的服务都不需要。
为什么 After=nss-lookup.target 没救下它
这才是这次最值得说的地方。翻开 Ubuntu 自带的 nginx unit:
|
1 2 3 4 |
[Unit] Description=A high performance web server and a reverse proxy server After=network-online.target remote-fs.target nss-lookup.target Wants=network-online.target |
nss-lookup.target 是 systemd 专门用来表示”域名解析已就绪”的标准锚点,DNS 服务一般会用 Before=nss-lookup.target 把自己挂上去。也就是说,nginx 早就声明过”我要等 DNS 就绪再启动”——然而它还是死在了 DNS 上。
原因在于After= 的语义被普遍误解了:
它只在同一个 systemd job transaction 内部编排启动先后,并不检查目标服务的实时健康状态。
needrestart 是逐个执行 systemctl restart <unit> 的,nginx 和 named 属于两个互相独立的 transaction,彼此之间没有任何排序约束。更要命的是,nss-lookup.target 是个 passive target,named 停止时它并不会跟着 deactivate,全程保持 active——于是 nginx 一查”依赖满足了吗”,满足,启动,然后一头撞死。
所以结论挺反直觉的:在 restart 场景下,After= 基本等于安慰剂。指望靠加一行 After=named.service 来防这类问题,是防不住的。
两道防线
既然顺序编排靠不住,那就换思路:一道事后自愈,一道事前免疫。
防线一:让它自己重试
Ubuntu 的 nginx.service 默认没有 Restart=,意味着启动失败一次就永久 failed,没有任何重试。而这次 named 只用了 1 秒就恢复——只要能重试一次,整件事根本不会发生。
|
1 2 3 4 5 6 7 8 9 10 11 |
sudo mkdir -p /etc/systemd/system/nginx.service.d sudo tee /etc/systemd/system/nginx.service.d/override.conf <<EOF [Unit] StartLimitIntervalSec=600 StartLimitBurst=20 [Service] Restart=on-failure RestartSec=10 EOF sudo systemctl daemon-reload |
StartLimit 那两行不是可选的:systemd 全局默认是 10 秒内最多 5 次,配上 RestartSec=10 会立刻撞上限流然后彻底放弃。改成 10 分钟内 20 次,才扛得住像样的 DNS 故障。
这里补一个 drop-in 的合并规则,很多人会搞混:
1. 标量指令(Restart=、RestartSec=、Type=、PIDFile=…)是覆盖。
2. 列表指令(After=、Wants=、Environment=、ExecStartPre=…)是追加,不是覆盖。想清空得先写一行空赋值 Update:After= 再写新值After=还不能被清空,详见下方评论。
3. ExecStart= 是重灾区:Type=forking 下只允许一条,直接在 drop-in 里写会报错,必须先 ExecStart= 清空再写。
想看合并后到底生效了什么,别看文件,看这个:
|
1 2 |
systemctl cat nginx # 看拼了哪些文件 systemctl show nginx -p After -p Restart -p RestartUSec # 看最终生效值,配置项是 RestartSec 对应的生效值是 RestartSec |
还有个细节值得确认:这次失败的其实是 ExecStartPre 里的 nginx -t -q(日志里那句 Control process exited, code=exited, status=1/FAILURE,result 是 exit-code)。Restart=on-failure 是覆盖这种情况的,不用担心它管不到。
防线二:别让 nginx 在启动期解析域名
自愈只是兜底,根上的毛病是:一个 location 的 upstream 解析不了,整个 nginx 拒绝启动,机器上所有站点陪葬。nginx 的配置校验是 all-or-nothing 的,一个小站点的临时故障被放大成了全局故障。
解法是给 proxy_pass 用变量——只要 proxy_pass 里含变量,nginx 就不再在启动期做一次性解析,改成运行时按需查 resolver。这样 DNS 挂了 nginx 照样能起来,最坏只是那一个 location 返回 502。
但这里有个大坑,下面单独说。
变量化 proxy_pass 的那个坑
我原来的配置是这样的,注意 proxy_pass 后面带了路径:
|
1 2 3 4 5 6 |
location ^~ /data/shi/ { proxy_pass https://bones7456.github.io/china-dynasty-timeline/; proxy_ssl_server_name on; proxy_set_header Host bones7456.github.io; ... } |
如果你天真地把域名换成变量,写成 proxy_pass https://$gh_pages/china-dynasty-timeline/;,就掉坑里了。nginx 的规则是:
1. proxy_pass 不含变量且带 URI → nginx 做前缀替换,把 location 匹配掉的 /data/shi/ 换成 /china-dynasty-timeline/。
2. proxy_pass 含变量 → 前缀替换机制彻底失效,URI 被固定成你写的那个。
后果是这样的:
|
1 2 3 4 |
请求 /data/shi/ 原配置 → /china-dynasty-timeline/ ✓ 变量版 → /china-dynasty-timeline/ ✓ 请求 /data/shi/assets/app.js 原配置 → /china-dynasty-timeline/assets/app.js ✓ 变量版 → /china-dynasty-timeline/ ✗ |
首页看着还挺正常,所有子资源全部错位——CSS、JS、图片全挂。这种”打开一看好像没事”的故障最恶心。
正解是用 rewrite ... break 手动接管路径映射,配一个不带 URI 的 proxy_pass。nginx 文档明确写了:proxy_pass 不带 URI 时,若 URI 已被 rewrite 改写,传递的就是改写后的 URI——正是我们要的。
先在 server 块里放解析器和变量:
|
1 2 3 |
resolver 127.0.0.1 1.1.1.1 8.8.8.8 valid=300s ipv6=off; resolver_timeout 5s; set $gh_pages "bones7456.github.io"; |
然后 location 改成:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
location ^~ /data/shi/ { rewrite ^/data/shi/(.*)$ /china-dynasty-timeline/$1 break; proxy_pass https://$gh_pages; proxy_ssl_server_name on; proxy_ssl_name bones7456.github.io; proxy_set_header Host bones7456.github.io; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 避免 GitHub Pages 返回的跳转暴露 github.io 域名 proxy_redirect https://bones7456.github.io/china-dynasty-timeline/ /data/shi/; proxy_redirect https://bones7456.github.io/ /data/shi/; } |
实际只动了三处:加 rewrite ... break、proxy_pass 去掉路径换成变量、显式加 proxy_ssl_name(它默认取 $proxy_host,变量化后写死更稳)。proxy_redirect 是字面量匹配,不受影响。查询串也会自动带过去,/data/shi/a?b=1 照常工作。
几个坑
1. resolver 里写多个地址是轮询,不是主备。我这里反代的是公网域名,两个 DNS 都能解析,正好互为冗余;但如果你要反代本机 named 里那些内网 zone,就绝不能这么写——轮询到 1.1.1.1 直接解析失败。那种 location 得单独配 resolver 127.0.0.1;。
2. ipv6=off 是刻意加的。变量化之后每次请求都要重新解析,家宽 IPv6 到 GitHub 的连通性又不一定稳,少一个变数是一个。确认 IPv6 走得通再打开。
3. 别指望 After= 能防住 restart 场景,前面说透了。它在正常开机时有用,成本为零可以留着,但不能当防线。
4. nginx -t 通过不代表这次改对了。它只校验语法,rewrite 的路径映射对不对,它一个字都不会告诉你。
怎么验证真的修好了
改完一定要实测,光看配置”觉得对”没用。先验路径映射——必须测子路径,别只测首页:
|
1 2 3 |
curl -sI https://luy.li/data/shi/ | head -3 # 从页面里抓个真实静态资源来测,期望 200 而不是 404 curl -s https://luy.li/data/shi/ | grep -oE '(src|href)="[^"]+\.(js|css)"' | head -3 |
说点本质的
复盘下来,这次事故里有三个独立的问题,任意修掉一个都不会出事:nginx 没有真正可靠的 DNS 就绪依赖、配置在启动期硬依赖 DNS、失败之后没有任何重试。三个凑齐了,一次 1 秒的 DNS 抖动才放大成 3 小时的全站宕机。
而我最想留下的一条经验是:别把系统的健壮性寄托在”顺序”上。After=、Before=、network-online.target 这些东西给人一种”我已经处理好依赖了”的错觉,但它们描述的是编排意图,不是运行时的真实状态。真正靠得住的只有两种东西——失败了能自己重试(自愈),和压根不依赖那个东西(免疫)。分布式系统里讲了很多年的道理,放在单机 systemd 上一样成立。
最后再补一句题外话:这次是我早上”顺手看了眼日志”才发现的,不然还得挂更久。所以监控该上还是得上,healthchecks.io、UptimeRobot 之类的免费额度足够个人站用了。再优雅的配置优化,也不如有人(或者有个机器人)在你睡觉的时候盯着。
全文完。
Notchy 1.3.7
上次写 Notchy 的时候(从接手到日用:我把 Notchy 改成了什么样),我自以为已经比较完善了,自己想要的功能都有了。但后面自己深度使用以后,包括也有网友反馈,发现还是有不少细节需要打磨。
于是一晃一个半月过去,翻了下 git log,从 1.2.7 到 1.3.7,中间又是 23 个 commit、小一千五百行 Swift,版本号跳了整整十个小版本。。。
趁着 1.3.7 刚发出去,把这段时间攒的东西整理一下:
标签页:从”能用”到”顺手”
上次提到的 Shadow Tab(右键一个 Xcode 或 Pinned 标签页,开一个 cd 到同目录但不启动 agent 的纯 shell)用顺手之后,发现每次都要right-click 选菜单有点烦,于是加了个 Cmd+Shift+T,直接对当前标签页开一个分身。Pin、unpin、开 Shadow Tab 的时候,标签页角上也会弹一个小图标提示一下,不然经常按完不确定到底生效没有。
关掉当前标签页这件事之前也有点反人类:永远跳到”下一个”,跟浏览器的习惯不一样。现在改成关掉后自动回到关之前那个活跃的标签页,一路往回找,符合直觉多了。
标签页多了之后,顺序调整和快速定位就成了刚需。这次加了两个东西:
一是拖拽排序——按住标签页左右拖,松手自动补位,Cmd+1…9 的跳转序号也跟着重新编号,不用再手动数第几个。
二是 Cmd+K 快速切换器:弹出一个按最近使用顺序排列、支持模糊搜索的会话列表,方向键选、回车跳、Esc 取消。做这个的时候踩了个 AppKit 的老坑——SwiftUI 的浮层(QuickSwitcherOverlay)从视图树里移除之后,并不会自动把键盘焦点还给原来的终端,得在 Esc 或选中之后手动调用 makeFirstResponder 把焦点抢回来,否则切换器关了,键盘输入却发不出去,你会以为是终端卡住了。
终端里能像正经终端一样用了
早些版本的终端体验偏”能跑就行”,这轮补了不少 iTerm2 用户会想念的东西。
右键菜单是最直观的一个:复制/粘贴/全选、查词典、拿选中内容或光标下的词去搜网页、打开光标下的 URL、在 Finder 里显示当前工作目录(或复制路径)、清屏。标签页右键菜单也顺带加了”创建检查点”。
深压(force click)一个单词会弹出系统词典释义,跟 Safari 里的 Look Up 一模一样——原理是接进了 trackpad 的 deep-click 二段位移,从 SwiftTerm 的缓冲区里解出光标下的词,再丢给 AppKit 的 showDefinition(for:at:)。
Cmd+点击打开文件/链接这块改动最细,也最容易在细节上翻车。之前直接点会遇到带空格的路径识别不出来、相对路径解析错目录之类的问题。现在带引号的路径(比如截图文件名里常见的空格)能整体识别成一个链接,相对路径按 shell 当前实际所在目录解析(用 proc_pidinfo 拿真实 cwd,不是猜的),像 agent 输出里常见的 Sources/File.swift:12 这种”文件:行号”引用,点一下直接在 Xcode 里跳到对应行。
另外一个不算起眼但天天受益的:流式输出的时候终端选区不再被冲掉了。原来 SwiftTerm 只要收到新数据就会重置鼠标上报状态,顺带清掉你刚选中的文本;现在只有 vim、htop 这类真正需要鼠标事件的 TUI 才会保留上报,普通 agent 输出流不会再打断你复制粘贴。
输入法这次是另一个坑
上次那篇讲的输入法问题,是 SwiftTerm 的 NSTextInputClient 吞掉预编辑文本(打拼音看不到候选字下面自己打了啥);这次修的是另一层——输入法来源(源)在标签页之间的记忆。
现在每个标签页会记住自己上次用的输入法:一个跑着 CLAUDE.md 项目、习惯打中文的标签页,切走切回来还是中文;旁边一个 Shadow Tab 默认停在英文,互不干扰。且只有 Notchy 面板真正拿到焦点时才会去改系统输入法,不会误动其他 App 正在用的输入法。
这个功能刚上的时候有个隐蔽 bug:macOS 的 panelDidResignKey 有时候一次失焦会触发两次,第二次触发时外部输入法已经被恢复了,结果把”外面那个 App 的中文输入法”错记成了这个标签页的输入法,污染了原本该是英文的 Shadow Tab。加了个 idempotent 保护(guard isPanelKey)才算收住。
同一批还加了 Quick Input:自己绑快捷键到预设命令,按一下自动敲进当前聚焦的终端(内置了 Cmd+G → git status),要不要自动回车可以按行配置,在 Settings → Quick Input 里随便增删,也可以整体关掉。
边边角角的细节
一些不太会单独写文章但用起来很舒服的小改动:
Xcode 工程在磁盘上被挪了地方之后,对应标签页现在会自动刷新到新路径——之前是按项目名字匹配的,工程一挪,标签页就一直 cd 到一个不存在的旧目录。
拖动窗口或者用 Cmd +/-/0 缩放字体的时候,HUD 提示会顺带把终端的字符行列数也标出来:
|
1 |
720 × 400 (120×40) |
外接显示器没接的时候,相关开关会自动置灰并给提示,接上拔下都会跟着实时更新,不用再对着一个点了没反应的开关发呆。
还有个不起眼但对 agent 体验有直接影响的:内嵌终端之前没有正确设置 TERM_PROGRAM,claude 这类会识别宿主终端类型的 CLI 只能退回去看原始的 $TERM 值,现在能正确认出自己是跑在 Notchy 里。
顺手修的一堆 bug
这段时间里还有几个纯粹的显示 bug,挑重点提一句:设置窗口以前是 floating 层级,会盖住其他 App 窗口,改成 normal 就好了;改字体大小之后终端字符被滚动条挡住的问题修了;终端往上滚看历史的时候,敲的字没有正确回显到屏幕上的问题也修了——这几个都是 SwiftTerm 底层渲染逻辑的坑,改动细节没什么好展开的,能用就完事了。
—
十个版本攒下来,Notchy 离”能用”更远了一步,离”顺手”近了一大截。想试试的话去 GitHub 下最新的 DMG 或 ZIP。
代码库放 iCloud 文件夹会怎样?
之前我有个习惯,会把代码(整个repo)放到 iCloud 管理的 ~/Documents 下。一直觉得挺方便的,因为即使没有 commit+push,我到另一台电脑上也能接着干活,登了同一个iCloud账号的目录会自动同步。
但最近遇到一个特别邪门的问题。一个用 uv 管理的 Python 项目,editable 方式安装,前一个小时还好好的,突然就:
|
1 2 3 4 5 |
$ .venv/bin/mycli --help Traceback (most recent call last): File ".venv/bin/mycli", line 4, in <module> from mycli.cli import main ModuleNotFoundError: No module named 'mycli.cli' |
邪门在哪呢?同一个 venv、同一个解释器,pytest 跑起来一千六百多个用例全绿。测试说包在,入口脚本说包没了。去 site-packages 里看,editable 安装落下的 .pth 文件好好躺在那,内容就是一行指向源码目录的路径,权限 644,路径也真实存在。文件在、内容对、权限对,但它就是不生效。

排查:谁把我的 .pth 吞了
先补一句背景:editable 安装的机制,是往 site-packages 里放一个 .pth 文件,Python 启动时 site 模块会读它,把里面的路径追加进 sys.path。这个文件失效,包就从解释器眼里消失。
做了三个对照实验,结果非常有意思:
1. 自己写一个探针 .pth(内容随便指个存在的目录)放进同一个目录——生效;
2. 把出问题的 .pth 原样 cp 成另一个文件名——生效;
3. 出问题的那个文件本身——死活不生效。
同目录、同内容、同权限,副本能用原件不能用,那唯一的区别只剩下文件的元数据了。ls 加个 -O 把 BSD 文件标志打出来:
|
1 2 3 |
$ ls -lO .venv/lib/python3.12/site-packages/*.pth -rw-r--r--@ 1 me staff hidden 56 Jul 16 17:42 _editable_impl_mycli.pth -rw-r--r--@ 1 me staff hidden 18 Jul 16 17:45 _virtualenv.pth |
flags 一栏赫然写着 hidden——macOS 的 UF_HIDDEN 文件标志。cp 不会复制 BSD flags,所以副本是干净的,这就解释了实验 2。而 CPython 从 3.11 起,site.py 处理 .pth 时加了一条安全加固(防止恶意软件用隐藏 .pth 做隐蔽注入):
|
1 2 3 4 |
if ((getattr(st, 'st_flags', 0) & stat.UF_HIDDEN) or (getattr(st, 'st_file_attributes', 0) & stat.FILE_ATTRIBUTE_HIDDEN)): _trace(f"Skipping hidden .pth file: {fullname!r}") return |
带隐藏标志的 .pth,静默跳过,一个字的错都不报。两个各自都算合理的行为叠在一起,效果就是:文件在,import 没了。
那谁给文件打的 hidden?chflags nohidden 清掉,几秒钟之后再看,又变回 hidden 了——有个进程在实时跟我对抗。答案到这基本明了:这个仓库在 ~/Documents 底下,而这台 Mac 开着 iCloud 的“桌面与文稿”同步。把仓库挪出 Documents、重建 venv,flags 干干净净,问题再没复发。
iCloud Drive 到底在做什么
开了”桌面与文稿”同步之后,~/Documents 就不再是普通目录,而是由 fileproviderd 守护进程托管的同步空间。它主要干三类事:
1. 监听并上传每一次文件变更——它假设文件是”偶尔被人编辑的文档”;
2. 冲突消解——当它认为同一个文件出现两个竞争版本,不丢弃任何一边,而是生成”xxx 2″”xxx 3″这样的冲突副本;
3. 元数据管理——给托管文件打 xattr 和文件标志(上面那个 hidden 就是它干的),开了”优化 Mac 存储”还会把冷文件驱逐成无数据的占位符,读取时才按需下载。
对文档来说这些设计都挺好。但代码库不是文档。
代码库为什么全中
第一,写入模式冲突。开发工具链的写入是高频、批量、依赖原子性的:包管理器一次重写上万个小文件,Python 用”临时文件 + rename”做原子写,git 靠锁文件和 rename 更新引用。同步进程和这些写入异步竞争,竞争输了就落冲突副本。我这次在 site-packages 里就看到了 “_editable_impl_mycli 2.pth”、”3.pth”、”4.pth” 一窝副本,主 .pth 的内容更是被拼接成了四份路径首尾相连的乱码:
|
1 |
/Users/me/Documents/dev/mycli/src/Users/me/Documents/dev/mycli/src/Users/me/... |
第二,元数据篡改。就是前面 UF_HIDDEN 那一段,而且它是主动维护的,你清掉它还会打回来。
第三,驱逐。.git/objects 里的 packfile、venv 里的二进制,都可能被”优化存储”驱逐成占位符——在线时表现为构建随机卡顿,离线时表现为仓库”损坏”。
第四,git 自己的风险。.git 里的 index、refs 一旦出现冲突副本,仓库状态可能真损坏。git 本身就是分布式同步工具,外面再套一层文件级同步,等于双重同步,语义必然打架。
这次踩到的坑
1. chflags nohidden 修不好——几秒内被 fileproviderd 顶回来,别在这条路上浪费时间;
2. 症状自相矛盾极具误导性:pytest 全绿(它靠 rootdir 机制自己把源码目录塞进了路径),入口脚本却挂了,两个工具对”包是否存在”给出相反答案,直觉上会先怀疑一万个别的东西,最后才怀疑文件系统在说谎;
3. 损坏是异步、随机发生的:同一个 venv 一小时内坏了两次,中间检查全都正常——因为坏不坏取决于同步进程什么时候追上来;
4. 诊断口诀:文件明明在但 import 不到,先 ls -lO 看 flags 列。
结论
iCloud(以及 Dropbox、OneDrive 这些文件级同步盘,机制细节不同但三板斧一样)适合放终态文档:文稿、图片、表格。任何带衍生状态的目录——代码库、venv、node_modules、.git、构建缓存——都不该放进去。代码的跨机同步交给 git,这本来就是它的职责,而且语义正确。仓库放个非托管路径(比如 ~/dev)就好;实在有目录必须留在 iCloud 里又想排除,macOS 没有官方排除项,只有给目录名加 .nosync 后缀这个 hack。
一个按文档假设设计的同步器,遇上一堆违反它全部假设的文件,双方都没有 bug,组合在一起就是灾难。就此,完毕。
每日一辨 DailyDiff
最近上架了一个新 App:DailyDiff(每日一辨)。做它的初衷很简单:一是我自己想把英语再往上提一提,二是儿子也在学英语,我想给他(也给自己)找一个每天花几分钟、细水长流的练法。
市面上背单词的软件很多,但它们解决的基本都是”认识”:看见 soldier 知道是战士,看见 warrior 也知道是战士。可这两个词的区别是什么、什么场合该用哪个——这种”掌握”层面的功夫,几乎没有软件管。而”认识一个单词”和”掌握一个单词”之间恰恰隔着一条鸿沟,它决定了你的英语是只能读,还是真的能用。所以我做了一个专门练这个的 App。
每天一道题,它长什么样
玩法抄了 Wordle 的作业:每天全球所有人拿到同一道题——一对容易混淆的英语近义词,各配一张黑白线稿插图。你用英语写出这两个词在含义、语气、用法上的区别,一两句话就行,然后 AI 给你批改。
批改是这个 App 的核心。不是给个分就完事,而是从五个维度(准确性、覆盖度、语言、清晰度、洞察)各打一个 0–100 的分,每个维度都附一句针对你这次答案的点评——指出你答到位的地方、漏掉的要点,像一对一外教改作业。五个分数汇总成总分和等级,从 D 到 SSS。SSS 很难拿:不光要总分 95 以上,还要求最低的那个维度也不低于 90,纯靠某一项拉平均分是蒙不到的。
批改完揭晓标准答案,对照着学。之后就是熟悉的套路:每日连击打卡、日历一格格点亮,还能导出一张成绩卡分享——卡上只有词对、插图、等级和雷达图,刻意不含答案,朋友看到照样能去玩。
题库目前 200 道,四百张插图全是 AI 生成的黑白线稿,风格统一得像一个插画师画的。
技术上几个有意思的决定
作为技术博客,还是得聊聊后端。整个服务端就是一个 Cloudflare Worker,配 D1(题库和用户数据)和 R2(插图),没有一台服务器要运维,账单基本可以忽略——独立开发选这套栈真的省心。
我之前的服务,都是部署在自己的home lab上的,这也是一次全套采用cloudflare的技术方案,发觉这个赛博菩萨果然名不虚传,免费额度够用不说,wrangler的开发体验还非常棒!
几个细节自认为做得还算讲究:
1. 评分标准(rubric)永远不下发到客户端。客户端只上传题目 ID 和你写的答案,评分要点由服务端按 ID 查表后喂给模型。否则抓个包就能看到得分点,这游戏就没法玩了。
2. 总分和等级不信任模型自己报的数。LLM 的算术是出了名的不可靠,让它算五个维度的加权和,隔三差五给你算错一个。所以模型只负责逐维度打分和写点评,加总、定级在代码里重算。
3. 匿名用户不用注册就能玩,每天免费批改一次。防滥用靠的不是强制登录,而是 Apple 的 App Attest——让苹果证明请求来自真机上的正版 App,机器人和脚本过不来。这套东西的原理和服务端验证的坑,我之前单独写过一篇《Apple App Attest 简介》,感兴趣可以看看。
4. 批改额度是”先预扣、失败退还”。最早的实现是先只读检查额度、批改成功了再扣,看起来很稳妥——但批改一次要跑上一两分钟,这个窗口里并发发请求,每个请求检查时都显示”还有额度”,就都放行了,白烧 token。改成预扣制之后,靠 D1 单写者的串行化保证并发下最多放行额度上限那么多个请求,批改失败再把额度退回去。
免费与收费
说说钱的事,明码标价:每天的题目永远免费,匿名一天能批改 1 次,登录后 2 次。Pro 订阅(月付或年付)解锁 200 道历史题库、研读模式(先看标准答案再作答)和每天 15 次批改。订阅收入拿去付 AI 推理的账单——每一次批改都是真金白银的 API 调用,所以免费额度给得抠门,请理解哈哈。
来玩
App Store 搜「DailyDiff」或「每日一辨」,或者直接点这个链接。中英文界面都有,今天的题不用注册就能做。
如果你试了之后觉得 AI 批改哪次明显不靠谱,或者有任何建议,欢迎邮件 support@dailydiff.vip 或者直接在下面留言——独立开发,每一条反馈我都会看。
顺便说一句,今天的题你打算拿几分?我至今没拿到过自己 App 里的 SSS。
就此,完毕。
fastlane——App Store Connect CLI(非官方)
我的打鼾监测 App NightSnore 支持 7 种语言(简中、英、日、韩、德、法、阿拉伯语)。这带来一个每次发版都要经历的痛苦环节:在 App Store Connect 后台,把 What’s New(新功能介绍)逐个语言粘贴进去——切语言、粘贴、保存,再切下一个,七遍。要是 Promotional Text(推广文本)也更新了,那就是十四遍。发布过APP的朋友,肯定对此就深有体会了。
这次发 2.3.0 的时候我终于忍不住了:这玩意儿就没有 CLI 能自动化吗?
还真有,而且就是 fastlane。有意思的是,fastlane 我其实早就装了——之前一直拿它给 App Store 截图加设备边框(frameit),我一直以为它就是个截图美化工具,哈哈。这次才发现,截图加框只是它十八般武艺里最不起眼的一样。
fastlane 到底是什么
fastlane 的定位是”把 iOS/Android 发布流程的每个环节都变成可脚本化的命令”。它其实是一整套工具的集合,每个工具管一段:
1. deliver:上传元数据(What’s New、描述、关键词、截图)到 App Store Connect,本文主角。
2. snapshot:跑 UI 测试自动截图,能覆盖每种语言 × 每种设备尺寸。
3. frameit:给截图加设备边框,我之前唯一用过的那个。
4. gym / pilot:打包上传、TestFlight 分发和测试员管理。
5. match / cert / sigh:证书和描述文件的团队共享管理。
6. precheck:上传前扫描文案里的审核高危词。
单人开发、Xcode 自动签名的话,match 这类团队工具基本用不上;但 deliver 对多语言 App 来说是刚需级的效率工具。
deliver:把 ASC 表单变成本地文件
deliver 的思路很直接:ASC 后台的每个表单字段,对应本地一个文本文件,目录按语言组织:
|
1 2 3 4 5 6 7 8 9 10 |
fastlane/metadata/ ├── zh-Hans/ │ ├── release_notes.txt ← What's New │ └── promotional_text.txt ← 推广文本 ├── en-US/ ├── ja/ ├── ko/ ├── de-DE/ ├── fr-FR/ └── ar-SA/ |
一个很贴心的设计是:目录里有什么文件,它就只上传什么。我只放了 release_notes.txt 和 promotional_text.txt,那么描述、关键词、截图这些都不会被碰。配置文件 Deliverfile 里再把二进制和截图明确跳过:
|
1 2 3 4 5 6 7 |
app_identifier "Senob.NightSnore" skip_binary_upload true skip_screenshots true force true # 跳过上传前的 HTML 预览确认 run_precheck_before_submit false submit_for_review false # 只填表单,提交审核仍手动 |
搭一条 What’s New 流水线
我的 App Store 文案一直维护在仓库的 AppStore/*.md 里(七个语言各一个文件),每次发版往里追加一段”## What’s New (X.Y.Z)”。这个 Markdown 就是唯一数据源,所以流水线只需要一个提取脚本:从 md 里抠出指定版本的段落,写到 deliver 要的 metadata 目录去。再包一个 fastlane lane 串起来:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
lane :whatsnew do |options| version = options[:version] || get_version_number( xcodeproj: "NightSnore.xcodeproj", target: "NightSnore" ) # 从 AppStore/*.md 生成 fastlane/metadata/<locale>/release_notes.txt sh("python3", "../utils/whatsnew_sync.py", version) deliver( api_key_path: "fastlane/api_key.json", app_version: version ) end |
提取脚本里顺手做了两层校验:七个语言缺任何一个对应版本的段落就直接报错(强制多语言同步,防漏),超过 ASC 的字符上限(What’s New 4000 字符、Promotional Text 170 字符)也直接拦下。以后发版就是一条命令:
|
1 2 3 4 5 6 7 8 9 |
$ fastlane whatsnew version:2.3.0 [18:02:20]: ▸ 同步版本 2.3.0 的 What's New + Promotional Text → fastlane/metadata/ [18:02:20]: ▸ zh-Hans notes 240 字符 / promo 57 字符 [18:02:20]: ▸ en-US notes 659 字符 / promo 158 字符 ... [18:02:29]: Uploading metadata to App Store Connect for localized version 'ja' [18:02:29]: Uploading metadata to App Store Connect for localized version 'ar-SA' [18:02:31]: ✅ 2.3.0 七语言 What's New 已同步到 ASC [18:02:31]: fastlane.tools finished successfully 🎉 |
从跑命令到 ASC 七个语言全部填好,9 秒。之前手动粘贴至少十分钟,还得祈祷别粘串了语言。
API Key,和一个专门坑你的格式问题
deliver 走的是 App Store Connect API,需要一个 API 密钥:ASC 后台”用户和访问 → 集成 → App Store Connect API”里创建一个团队密钥,角色选 App Manager,会得到一个 .p8 私钥文件(只能下载一次)加 Key ID 和 Issuer ID。
然后我就结结实实踩了个坑。deliver 支持用一个 JSON 文件传密钥,我很自然地写成了指向 .p8 文件路径的形式,结果:
|
1 2 |
Spaceship::ConnectAPI::Token.from_json_file': [!] App Store Connect API key JSON is missing field(s): key (RuntimeError) |
查了才知道:fastlane 的 Fastfile 里有个 app_store_connect_api_key 这个 action,它支持 key_filepath 参数指向 .p8 文件;但 deliver 的 api_key_path 参数指向的 JSON 文件,只认内联的 key 字段——你得把 .p8 的 PEM 内容整个塞进 JSON 字符串里(换行转成 \n)。同一个工具链里两种密钥写法长得几乎一样但互不兼容,这不纯纯挖坑嘛。正确格式长这样:
|
1 2 3 4 5 6 |
{ "key_id": "ABCD123456", "issuer_id": "12345678-abcd-....", "key": "-----BEGIN PRIVATE KEY-----\nMIGT...\n-----END PRIVATE KEY-----", "in_house": false } |
这个文件含私钥,务必进 .gitignore,待遇跟你的其他 secrets 一样。
踩坑与注意事项
1. deliver 只能写”可编辑状态”的版本(准备提交、被拒等),已在审核中或已上架的版本改不了。所以要在上传 build 之后、点提交审核之前跑它;版本还没建也没关系,deliver 会自动创建。
2. locale 代码不都带地区后缀:日语是 ja 不是 ja-JP,韩语是 ko,但英语是 en-US、德语是 de-DE。写映射表的时候留意。
3. metadata 目录里有什么就传什么,这既是特性也是风险:目录里残留一个过期的 description.txt,就会把线上描述覆盖掉。我的做法是 metadata 目录整个 gitignore,每次由脚本从 md 重新生成,保证它永远是纯派生产物。
用 skill 操作,vibe coding 更顺畅
还有一层我觉得比工具本身更有意思:这整条流水线,从功能开发、写七语言文案,到搭 fastlane、踩坑、修好,都是在 Claude Code 里完成的。搭好之后我顺手让它把整个发版流程沉淀成一个项目 skill——仓库里的一个 .claude/skills/release/SKILL.md 文件,把九个步骤写成清单:bump 版本号 → 编译验证 → 七语言 What’s New / Promotional Text → commit → xcodebuild 归档上传 → 打 tag → fastlane 同步 ASC 表单,连”deliver 的 JSON 只认内联 key”这种坑位说明都写在里面。
下次发版,我只需要说一句 /release 2.4.0,AI 就照着清单把整条链路跑完,唯一剩下的手动操作是去 ASC 点提交审核。skill 跟着仓库走,clone 下来就有,等于把发版的”部落知识”固化成了可执行的文档。对 vibe coding 来说这很关键:写代码交给 AI 大家都会了,但发布环节往往还是人肉在各个后台之间点来点去——把这段也纳入对话式工作流,从开发到上架才算真正闭环。
下一个目标
尝到甜头之后我看了一圈 fastlane 工具箱,对我这种多语言独立开发场景,下一个最值得上的是 snapshot:七个语言的商店截图现在还是手动截的,每次界面大改就是一下午。snapshot 跑 UI 测试自动出全语言截图,再接上我已经会用的 frameit 加框、deliver 上传,理论上截图这条线也能变成一条命令。挖个坑,做完再写。
发布流程自动化这件事,本质上是把”每次发版都要凭记忆和手感重复一遍的操作”变成”写一次、以后白嫖”的脚本。对独立开发者来说,省的那十几分钟是小事,真正值钱的是不再需要担心”这次是不是又漏了哪个语言”——机器不会漏。
就此,完毕。