一个已提交的、部署步骤不会重新构建的构建产物
一个在 git 中被跟踪并嵌入到二进制文件中的生成文件,可以无需重新构建就进行发布。这不会报错,只是一个背后没有 CSS 规则的类。
有些构建产物会被特意提交到仓库中。一个编译后的样式表、一个生成的 API 客户端、一个打包后的翻译文件。这种模式一直都行之有效,直到你发现重新生成文件的命令在一个脚本里,而部署路径运行的却是另一个脚本。于是,新的输入内容未经重新构建就被交付了,而且没有任何东西会告诉你这一点。
我遇到的情况是:一个由 CSS 框架的命令行工具生成的样式表,被提交到 git,并嵌入到一个 Go 二进制文件中。部署脚本构建了一个容器镜像,但从未调用过那个 CSS 命令行工具。在最后一次本地构建之后编写的任何新工具类,在交付时其背后都没有对应的规则。
隐藏问题的配置
三个决定,单独来看都合情合理,但合在一起却很危险。
首先,提交生成的文件。对于构建缓慢或需要运行时镜像所不具备的工具链的资产来说,这很常见。这样可以获得可复现的服务,并减少容器中的一个依赖。
其次,将其嵌入到二进制文件中。在 Go 语言中,这通过 //go:embed 实现,也正因如此,该文件必须在编译时存在,而不是在启动时获取。
第三,将重新生成命令放在一个也执行其他操作的构建脚本中:例如签名、公证、上传桌面产物。该脚本需要凭据和 VPN,因此 CI 不会运行它。
现在来追踪一下这个文件。它由脚本 A 生成。它在脚本 B 执行期间被编译器使用。脚本 B 中没有任何机制会检查自上次输入变更以来,脚本 A 是否已运行。
为什么故障是静默的
严重程度完全取决于消费者如何对缺失的条目做出反应。
一个丢失了某个方法的生成式 API 客户端会明显地失败。调用点无法编译,CI 变红,你会在合并前修复它。生成的数据库查询代码和 protobuf 存根也是如此。类型系统正在为你进行检查。
样式表则恰恰相反。CSS 没有未定义类的概念。如果你写了 class="items-center" 却没有匹配的规则,浏览器会将其解析为空。元素会渲染,页面会加载,控制台不会出现警告。你最终得到一个在生产环境中存在细微错误、但在上次运行 CLI 的机器上却显示正确的布局。
当查找回退到键时,翻译包的行为方式相同。当未知标志被读取为 false 时,功能标志清单的行为方式也相同。在每种情况下,缺失的条目都有一个看似合理的默认值,而正是这个看似合理的默认值使得 bug 变得不易察觉。
所以经验法则是:如果生成的产物被没有 schema 的东西所使用,那么在检查之前,请假定这个缺口是存在的。
找出哪个路径重建了它
这大约需要一分钟,并且答案通常令人不适。
首先确认文件确实是被跟踪的,而不是被忽略的:
git ls-files --error-unmatch path/to/generated.css
git check-ignore -v path/to/generated.css # expect no output
然后,在每个参与部署的文件中 grep 生成器命令:
grep -rn "tailwindcss\|protoc\|sqlc\|openapi-generator" \
Dockerfile* .github/workflows/ script/ Makefile cloudbuild.yaml 2>/dev/null
在我的情况中,命中项都在同一个地方,即桌面构建脚本中,而 Dockerfile 和部署脚本中则没有任何命中项。这就是全部的诊断结果。容器构建会复制仓库并进行编译,因此 git 中的内容就是最终交付的内容。
还有一个检查可以捕获相反的错误:文件在部署期间生成但也被提交了,这会导致每次部署都产生虚假的差异:
git status --porcelain path/to/generated.css # after a local build
如果一次全新的构建在没有任何输入变化的情况下弄脏了文件,那么该生成器就不是确定性的,提交它将会永远与你作对。
两种弥补方法
两者都是有效的。根据运行时镜像是否能承载工具链来选择。
将生成过程移入部署路径。 这是一种直接的修复方法。将生成器添加到容器构建或生成镜像的 CI 工作流中,并停止提交其输出。代价是构建镜像会更臃肿,并且需要多固定一个工具链。如果生成器需要凭据或签名密钥,那么这个选项就不可行了,而这恰恰是最初出现这种差距的原因。
继续提交,并验证差异为空。 如果产物必须保留在 git 中,那么就让这个不变量显式化:在正常编辑后,生成的文件不得改变。通过一个检查来运行生成器,并在工作树变“脏”时失败,这样就能将一个隐蔽的差距转变为一个红色的构建:
make generate
git diff --exit-code path/to/generated.css \
|| { echo "generated asset is stale, run make generate and commit"; exit 1; }
把它放到 CI 中,而不是本地钩子中。本地钩子会被匆忙行事的人跳过,而这种人最有可能添加新的 class。
当这两种方法都不适用时,还有一个较弱的变体值得了解。保留一份硬编码的令牌列表,列出构件当前支持的令牌,并用它来检查新的源代码。我曾将其作为一个审查步骤:提取编译后样式表中存在的每个 class 名称,提取变更所添加的每个 class 属性,然后对这两个集合进行比对。它在代码交付前发现,一个候选 class 不在生成的集合中。这只是一个检查清单项目,而不是一道门禁,但总比相信记忆要好。
还有什么东西是这种模式
这种模式是“一个制品,它是部署的输入,但又是其他东西的输出”。一旦你这样命名它,它就会出现在很多地方。
当框架的 CLI 不在运行时镜像中时,编译后的样式表和 JS 包。从 OpenAPI 规范生成的 API 客户端,特别是当该规范位于不同的仓库中时。来自 SQL 生成器的数据库查询代码。Protobuf 和 gRPC 的存根。编译后的翻译目录。嵌入式迁移包。Vendored 的依赖树。Terraform 锁定文件。
那些“响亮”的,即缺失条目就会破坏编译的,大多能自行解决。那些“安静”的则值得特意审计:样式表、翻译目录、标志清单、图标精灵图,以及任何查找失败时有合理后备方案的东西。
还有第二个值得注意的信号。如果生成的文件也被嵌入到二进制文件中,消费者就无法在运行时获取新副本,因此这个缺口无法通过清除缓存或重新加载配置来掩盖。嵌入操作移除了你的应急方案。
常见问题解答
我应该停止提交生成的文件吗? 这样做可以消除这类错误,但会将工具链移入镜像中,导致构建速度变慢、镜像体积变大。对于那些使用广泛可用的工具能在几秒钟内构建的资产,是的,可以在部署期间生成。而对于任何需要凭据、原生编译或缓慢工具链的东西,提交文件并加上 CI 过期检查是一个合理的权衡。
使用 pre-commit 钩子就足够了吗? 不够。钩子是本地的、可跳过的,而且当有人全新克隆仓库时很容易丢失。如果你喜欢,可以使用钩子来提高速度,但权威性的检查必须在任何人都无法绕过的地方运行。
我如何知道一个类是确实缺失了,而不是拼写错误?
从生成的构建产物中提取标识符,并搜索确切的标记。这里要注意转义问题:一个名为 py-2.5 的工具类在编译后的 CSS 中可能会显示为 .py-2\.5,而简单地用 grep 搜索 .py-2.5 会报告它缺失。应该匹配未转义的形式,或者两者都检查。
如果 diff 检查在干净的代码树上持续失败怎么办? 那么说明生成器不是确定性的。常见原因包括嵌入的时间戳、版本横幅,或者依赖于文件系统的文件排序。在将此检查设为阻塞性之前,请固定版本、移除横幅或对输入进行排序。一个不稳定的门禁检查在一周内就会被禁用。
这适用于锁文件吗? 部分适用。锁文件也是提交的构建产物,但包管理器通常在安装过程中会验证它们,因此过时的锁文件会明确地导致失败。锁文件的风险恰恰相反:部署时静默地重新生成它们,并发布了未经任何人审查的依赖版本。