KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 构建与产物:让"这一份代码"变成"这一个东西" — keel 龙骨
这一章回答:从源码到可以部署的东西之间,中间到底发生了什么;以及为什么这段路径必须可重复、可追溯、可回滚。
这一章回答:从源码到可以部署的东西之间,中间到底发生了什么;以及为什么这段路径必须可重复、可追溯、可回滚。
许多"上线问题"其实在这一步就埋下了:本地生成的包和流水线生成的不一致、测试通过的代码和部署使用的代码不是同一份、产物里混进了本机的缓存文件。这一章把这段路径讲清楚。
一、构建的本质:确定性地做一次变换
源码 + 依赖清单 + 构建环境 ──确定性变换──► 产物(不可变)
(输入) (输出)
"确定性"这三个字是关键:同样的输入在任何机器上都应该得到同样的输出。 凡是做不到这一点的构建(比如依赖了"当前时间"、依赖了本地某个未声明的工具版本、依赖了网络上的最新 tag),都是在给未来存一张 rand() 的彩票。
| 破坏确定性的常见做法 | 后果 | 替代方案 |
|---|---|---|
依赖清单用 >= 范围 |
今天和明天装出来的依赖不同 | 锁文件 + 冻结安装(uv sync --frozen、npm ci、pip install --require-hashes) |
| 容器基础镜像用浮动 tag | 基础层随时可能换 | 按 digest 固定 |
| 构建时拉取网络最新资源 | 网络不同结果不同 | 资源也要版本化,必要时镜像进内网源 |
| 把构建放到个人机器上 | 环境不可复现 | 构建只在流水线里发生 |
铁律:产物只能由流水线生成。 哪怕你在本地为了调试构建过一模一样的 wheel,也不允许拿去部署——一旦破例,"生产上跑的到底是什么"这个问题就再也答不上来。
二、产物长什么样:不同生态的形态
| 生态 | 产物 | 关键元数据 |
|---|---|---|
| Python | *.whl / *.tar.gz(sdist) |
版本号、METADATA 里的依赖声明、wheel 平台标签 |
| Node | *.tgz + package.json |
version、files 白名单、exports |
| Java | *.jar / *.war |
manifest、依赖树 |
| Go / Rust | 单一静态二进制 | 无外部运行时依赖,交付最省事 |
| 任意语言 | 容器镜像 | digest、层、标签、config 里的 CMD/ENV/入口 |
两条通用纪律:
- 产物清单要有白名单(对应
files配置 /MANIFEST.in/.dockerignore)。常见的低级错误是把.env、tests/、.git/打进包里——既泄漏信息也增大体积。标准是:发布用的产物里,只应该有运行必需的东西。 - 给产物算校验值并随发布记录保存(sha256 / OCI digest)。没有校验值时,"生产上的这个包是不是我以为的那个"永远无法证明。
三、版本策略:让版本号承载信息
SemVer: MAJOR.MINOR.PATCH 例:1.4.2
破坏性 新增 修复
实践中的四个要点:
| 要点 | 说明 |
|---|---|
| 单一事实源 | 版本号应该只有一个地方"说了算"。推荐做法:Git tag 为最终事实源,构建时把 tag 写回清单文件,并在发布前做三方一致性校验(tag == 打包清单 == 源码里的 __version__) |
| 本地 bump 只为评审 | 开发者手动改版本号是为了让评审者看清"这是不是一次发版",但流水线必须重新以 tag 为准校准 |
| 每个产物版本唯一 | 不可重复发布同一版本号;这就是为什么 skip-existing 之类的机制能带来幂等性 |
| 预发布标签 | 1.4.2-rc.1、2.0.0-beta.3 用于灰度/内部版本,语义上低于同号正式版 |
版本号与 commit 的绑定关系要双向可查:知道版本能找到 commit(制品库/发布记录/attestation),知道 commit 能找到它产出过哪些版本(CI 运行记录)。
四、幂等发布:让每条支线都能安全重来
真实世界的发布很少一次跑通三个目标渠道。当发布链有几个阶段时,重跑就是常态:
发布 job 三段: PyPI ──► npm ──► Homebrew
失败发生在第 3 段(外部 API 抖动/网络超时)
↓ 直接重跑整条?如果前两段重发布会报错(版本号已存在)
↓ 所以每一段都要能回答:"这件事已经做过了吗?"
三种写法,本质都是以远端事实为输入而不是以本地状态或上一步的记忆为输入:
| 渠道 | 幂等手段 | 依赖的事实 |
|---|---|---|
| PyPI | --skip-existing |
注册表里该版本是否已存在 |
| npm | 发布前 npm view pkg@version 查重 |
同上 |
| Homebrew | 解析已发布的 sdist URL 与 sha256 来更新 formula | 上游 PyPI 的版本内容 |
还有两个不能忘的细节:
- 跨系统的最终一致性:A 发布了不等于 B 立刻查得到。要容忍一定的传播延迟(轮询 + 超时上限),而不是假设"写完就可见"。这类等待的边界条件,其实和消息队列的消费滞后是同一种问题形态。
- 部分失败的状态可见:每次发布都应该留下"哪一段成功了"的记录,否则只能靠人工猜测到底做完了哪几段。
五、产物冒烟:不要只测源码
源码测试通过 ≠ 产物能用。 以下错误只有"装一遍"才会暴露:
| 类别 | 典型问题 |
|---|---|
| 入口没被打包 | console_scripts 配错,pip install 后命令不存在 |
| 资源未包含 | 静态文件/模板/默认配置不在 wheel 里,运行时报文件不存在 |
| 依赖声明缺失 | 本机装了某个包所以能跑,干净环境就 ImportError |
| 平台差异 | Windows 路径、动态库、可执行位丢失 |
标准动作:
python -m venv /tmp/smoke && /tmp/smoke/bin/pip install dist/*.whl
/tmp/smoke/bin/<your-cli> --version
/tmp/smoke/bin/<your-cli> --help
这条检查位于流水线中的什么位置?在"构建成功之后、发布之前",且要在干净的虚拟环境/容器里执行。 这一段简单的检查,是你手上"发出去的东西是好的"唯一的直接证据。
动手:可观察结果
| 动作 | 产出物 | 判断标准 |
|---|---|---|
| 做一次冻结安装 | 锁文件 + CI 里 --frozen 的运行输出 |
故意改一行依赖清单但不更新锁文件,流水线应当失败 |
| 检查产物内容 | unzip -l dist/*.whl 或 tar tzf dist/*.tgz 的输出 |
没有 .git/、tests/、.env;只有必需文件 |
| 干净环境冒烟 | 一个新 venv / 容器里跑 --version |
能跑出正常输出;再故意漏掉一个依赖,看是否失败 |
| 三方版本一致性校验 | 一个校验脚本 + 失败样本 | 把清单版本改成与 tag 不一致,流水线应当红 |
| 算并记录 sha256 | 发布记录里的一列 | 任意两台机器上算出的值相同 |
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
| 把依赖清单改成范围语法再构建 | 两次构建产物 hash 是否一致 | 不一致 → 非确定性,赖炸不可自定义行为 |
在 files 白名单里漏掉一个运行时模板 |
干净环境是否报错 | 本机不报错是假象,正好说明为什么要干净环境冒烟 |
手工删除 .dockerignore |
镜像体积与内容 | .git/ 进了镜像=泄漏历史与体积膨胀 |
| 发布时断网一秒(模拟外部 API 抖动) | 重跑是否安全 | 能安全重跑才叫幂等;否则会产生半成品状态 |
| 重复发布同一版本号 | 是否被拒绝/跳过 | 拒绝或跳过都合理,产生两个同名不同内容的产物绝不可接受 |
自测题
- 为什么版本号要以 Git tag 为唯一事实源,而不是以打包清单文件为准?
- 冒烟测试要测的是产物而不是源码,举出两类只有这么测才能发现的错误。
- 幂等发布的三种手段之间有什么共同点?
- 跨系统发布链为什么需要处理"最终一致性"?
- "产物只能由流水线生成"这条纪律被打破后,会同时毁掉哪三件事?