I am LAZY bones?
AN ancient AND boring SITE

2026年 07月 的归档

记一例 nginx 故障分析

早上起来习惯性瞄一眼 home lab 的日志,看到我那个 WebSocket 服务凌晨被重启过:

重启就重启吧,本来没当回事。结果顺手打开本博客——打不开。又试了下这台机器上跑的其他站,也全打不开。好家伙,整台机器的 nginx 全躺了,而且看时间已经躺了三个多小时。

先看现场

第一件事是确认机器有没有重启过。uptime 一看,没有,机器好好的。那就是 nginx 单独出事了。systemctl restart nginx 下去,唰一下全恢复了。

服务是回来了,但这更让人不安:能 restart 成功,说明磁盘上的配置本来就是好的,那它三个小时前到底为什么起不来?翻 unit 日志:

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" 全量捞出来,真凶一目了然:

看明白了: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:

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 秒就恢复——只要能重试一次,整件事根本不会发生。

StartLimit 那两行不是可选的:systemd 全局默认是 10 秒内最多 5 次,配上 RestartSec=10 会立刻撞上限流然后彻底放弃。改成 10 分钟内 20 次,才扛得住像样的 DNS 故障。

这里补一个 drop-in 的合并规则,很多人会搞混:

1. 标量指令(Restart=RestartSec=Type=PIDFile=…)是覆盖
2. 列表指令(After=Wants=Environment=ExecStartPre=…)是追加,不是覆盖。想清空得先写一行空赋值 After= 再写新值 Update:After=还不能被清空,详见下方评论。
3. ExecStart= 是重灾区:Type=forking 下只允许一条,直接在 drop-in 里写会报错,必须先 ExecStart= 清空再写。

想看合并后到底生效了什么,别看文件,看这个:

还有个细节值得确认:这次失败的其实是 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 后面带了路径

如果你天真地把域名换成变量,写成 proxy_pass https://$gh_pages/china-dynasty-timeline/;,就掉坑里了。nginx 的规则是:

1. proxy_pass 不含变量且带 URI → nginx 做前缀替换,把 location 匹配掉的 /data/shi/ 换成 /china-dynasty-timeline/
2. proxy_pass 含变量 → 前缀替换机制彻底失效,URI 被固定成你写的那个。

后果是这样的:

首页看着还挺正常,所有子资源全部错位——CSS、JS、图片全挂。这种”打开一看好像没事”的故障最恶心。

正解是用 rewrite ... break 手动接管路径映射,配一个不带 URIproxy_pass。nginx 文档明确写了:proxy_pass 不带 URI 时,若 URI 已被 rewrite 改写,传递的就是改写后的 URI——正是我们要的。

先在 server 块里放解析器和变量:

然后 location 改成:

实际只动了三处:加 rewrite ... breakproxy_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 的路径映射对不对,它一个字都不会告诉你。

怎么验证真的修好了

改完一定要实测,光看配置”觉得对”没用。先验路径映射——必须测子路径,别只测首页

说点本质的

复盘下来,这次事故里有三个独立的问题,任意修掉一个都不会出事: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 提示会顺带把终端的字符行列数也标出来:

外接显示器没接的时候,相关开关会自动置灰并给提示,接上拔下都会跟着实时更新,不用再对着一个点了没反应的开关发呆。

还有个不起眼但对 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 方式安装,前一个小时还好好的,突然就:

邪门在哪呢?同一个 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 文件标志打出来:

flags 一栏赫然写着 hidden——macOS 的 UF_HIDDEN 文件标志。cp 不会复制 BSD flags,所以副本是干净的,这就解释了实验 2。而 CPython 从 3.11 起,site.py 处理 .pth 时加了一条安全加固(防止恶意软件用隐藏 .pth 做隐蔽注入):

带隐藏标志的 .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 的内容更是被拼接成了四份路径首尾相连的乱码:

第二,元数据篡改。就是前面 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 后台的每个表单字段,对应本地一个文本文件,目录按语言组织:

一个很贴心的设计是:目录里有什么文件,它就只上传什么。我只放了 release_notes.txt 和 promotional_text.txt,那么描述、关键词、截图这些都不会被碰。配置文件 Deliverfile 里再把二进制和截图明确跳过:

搭一条 What’s New 流水线

我的 App Store 文案一直维护在仓库的 AppStore/*.md 里(七个语言各一个文件),每次发版往里追加一段”## What’s New (X.Y.Z)”。这个 Markdown 就是唯一数据源,所以流水线只需要一个提取脚本:从 md 里抠出指定版本的段落,写到 deliver 要的 metadata 目录去。再包一个 fastlane lane 串起来:

提取脚本里顺手做了两层校验:七个语言缺任何一个对应版本的段落就直接报错(强制多语言同步,防漏),超过 ASC 的字符上限(What’s New 4000 字符、Promotional Text 170 字符)也直接拦下。以后发版就是一条命令:

从跑命令到 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 文件路径的形式,结果:

查了才知道:fastlane 的 Fastfile 里有个 app_store_connect_api_key 这个 action,它支持 key_filepath 参数指向 .p8 文件;但 deliver 的 api_key_path 参数指向的 JSON 文件,只认内联的 key 字段——你得把 .p8 的 PEM 内容整个塞进 JSON 字符串里(换行转成 \n)。同一个工具链里两种密钥写法长得几乎一样但互不兼容,这不纯纯挖坑嘛。正确格式长这样:

这个文件含私钥,务必进 .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 上传,理论上截图这条线也能变成一条命令。挖个坑,做完再写。

发布流程自动化这件事,本质上是把”每次发版都要凭记忆和手感重复一遍的操作”变成”写一次、以后白嫖”的脚本。对独立开发者来说,省的那十几分钟是小事,真正值钱的是不再需要担心”这次是不是又漏了哪个语言”——机器不会漏。

就此,完毕。