构建我梦寐以求的部署工具
摘要
作者详细介绍了一款名为'Deptool'的自定义Python部署与配置管理工具的开发过程。该工具旨在比Ansible等现有方案更快、更可预测,源于对数字主权和更优工具的追求。
<p><a href="https://lobste.rs/s/ze6pao/building_deployment_tool_i_wish_i_had">评论</a></p>
查看缓存全文
缓存时间: 2026/05/08 09:37
# 构建我想要的部署工具 来源:https://ruuda.nl/2026/deptool,作者 ruuda,发表于 2026 年 5 月 6 日
凌晨 00:43。我看着计划,按下 y。"s4.ruuda.nl: connecting ..." 我屏住呼吸。"Applying" 在我终端窗口中一闪而过,随后定格在 "done"。成功了。现在真正的工作可以开始了。
*弹出栈帧。*
我刚才在做什么来着?
和一起工作过一段时间的人可能会指责我患有"非我发明"综合征。我更愿意称之为"有更高的标准"。为什么要忍受平庸工具带来的无尽挫败感,当你可以构建*自己的*工具,而且它们真的很好用?话说回来,我刚才在做什么?对了,我想写一篇关于欧洲数字主权的博客文章。如果把它发布在托管于美国、由美国控制的超大规模云服务商上的博客,那就太虚伪了。所以把它搬到欧洲去,应该不难。
*压入栈帧。*
开一台新 VM,把 DNS 记录指向 ... 哦,对了,DNS。我用 Cloudflare 做 DNS。这是另一个特朗普可以在情况恶化时下令停止提供服务的实体。也许我应该自己托管 DNS 服务器。
*压入栈帧。*
我的 web 服务器是一台运行 Nginx 的小 VM,加上 Lego 来自动续期证书。Nginx 配置这些年变得有些复杂,但我用 Nix 生成它,所以没问题,就两个配置文件。我写了一个小型 Python 脚本把文件复制到服务器并重启 Nginx。这个脚本过去几年一直很好用,但现在我想开始运行 DNS 服务器了。我现在至少需要*两台*服务器。还有更多的 systemd 单元、配置文件、zone 文件 ... 这个脚本不够用了,我需要严肃的集群配置管理。
*压入栈帧。*
"NixOS" —— 我听到 Arian (https://functional.cafe/@arianvp) 的声音在我脑海中低语。"就用 NixOS (https://social.hackerspace.pl/@q3k/110598898979332486)。配置只要一行,`services.nsd.enable = true`。" 他说得当然对。我已经在用 Nix 来构建 (https://codeberg.org/ruuda/miniserver) 最小的 EROFS 镜像,用于 Nginx 和 Lego。我就是这样在 Flatcar 上运行它们的。但我喜欢最小化基础 OS 的理念,从只读 chroot 运行服务,只保留必要的二进制文件。"现在不要把范围蔓延到切换发行版," 我告诉自己。"来构建一个新的部署工具吧。"
## 实际效果
现在是一个月后,Deptool (https://docs.ruuda.nl/deptool/) 已经存在。这是我更新 DNS 记录的过程:
```
$ deptool deploy
s4.ruuda.nl
update nsd ~ zones/ruuda.nl.zone
restart unit nsd.service
s5.ruuda.nl
update nsd ~ zones/ruuda.nl.zone
restart unit nsd.service
Auto-rollback if deploy fails.
Apply to 2 hosts in cluster 'prod'? [y/N/d] y
s4.ruuda.nl: done
s5.ruuda.nl: done
Changes deployed successfully to 2 hosts in 0.99s.
```
在这篇文章中,我们会逐步介绍它的工作原理,但别急。我是怎么走到这一步的?
## 需求清单
如果我要构建自己的工具 ... 一个真正好用的配置管理工具应该是什么样的?这里我们可以向 Ansible 学习:它犯了所有可能的错误,让后来者可以吸取教训。
我希望我的工具是:
**快速的。** 配置更新应该在亚秒级完成。没有*根本性*理由让它更慢,即使是跨大西洋的 ping 也只有 100ms。
**可预测的。** 工具应该告诉我它将要做什么,然后只做这些。像 OpenTofu 那样,有独立的 *plan* 和 *apply* 阶段。不像 Ansible,它的检查模式毫无用处,因为每个命令式步骤都可能触发只有在执行后才能知道的级联变更。而且没有什么能防止主机在检查和实际运行之间发生变化,使得检查更像是一种"氛围检查"而非可靠依赖的东西。
**安全的。** 如果我搞坏了 Nginx 配置,我不希望 web 服务器宕机好几分钟,而我 frantic 地试图修复。(我:"啊,只是个小拼写错误,直接修复比尝试恢复上一个版本更快。" 旁白:"要是那个拼写错误是*唯一*的问题就好了 ...")不,我希望工具能自动帮我回滚。在毫秒级内完成。
**简单的。** 我只需要把配置文件从我的笔记本复制到服务器,然后重启几个 systemd 单元。我不需要为所有人解决每一个部署问题;我不需要控制流或任意代码执行。我确实需要能够模板化 —— 抱歉,是*生成* (https://ruuda.nl/2023/the-yaml-document-from-hell/#templating-yaml-is-a-terrible-terrible-idea) —— 配置文件,但可以用*单独的工具* (https://docs.ruuda.nl/rcl/generating_files/) 来做这件事。
**声明式的。** 如果我从配置中移除一个文件或应用,它也应该从服务器上移除。我不想添加显式的清理步骤,最终因为不可避免地忘记而导致漂移和残留文件。
**零配置的。** 我想在服务器刚配置好后就用这个工具来管理它们。我不想手动安装 agent、daemon 或依赖,也不想在任何地方注册或登记主机,因为那样我就有了新的问题:如何自动化*那个*过程。
## 解耦分发
核心思想事后看来显而易见,也许这就是为什么它到处都在出现。在工作中,David (https://davidv.dev/) 最近构建了 Unsible (https://github.com/ChorusOne/unsible),一个接受 Ansible playbook 的工具,但不是逐步执行它们,而是在本地构建一个 tarball 并发送到主机,在那里大部分工作只是把文件放到正确位置。
我喜欢这种将配置生成与分发解耦的想法。我的简陋部署脚本也是这样工作的:在外部构建配置,脚本主要就是一个简单的文件复制器。
从某种意义上说,NixOS 就是这个理念在本地系统上的应用。我们可以从 Nix 学到更多:将生成的产物存放在不同版本可以共存的地方,把系统管理的命令式部分限制在一个小的激活步骤,只是交换几个符号链接。这种设计对包管理和系统配置都适用。
把范围缩小到分发小文件和运行简单的激活步骤,部署就变成了一个可解的问题。我把我的实现称为 *Deptool* (https://docs.ruuda.nl/deptool/)。它是这样工作的。
**为整个集群预渲染配置文件。** 将它们存储在磁盘上的一个目录中。这个目录树有两层:顶层是按目标主机分的目录,下面一层是按应用分的目录。
**把它放进 Git 仓库。** 现在我们可以将一个版本与另一个版本做 diff,看整个集群发生了什么变化。从 diffstat 中可以看到哪些主机受影响、哪些应用发生了变化,对每个配置文件都有精确的 diff。
**在主机上的隔离目录中物化文件。** 我们把所有东西放在 `/var/lib/deptool`,不会干扰任何其他东西。在里面创建一个以要部署的 commit 命名的目录。现在多个版本可以在磁盘上共存。然后我们让 `current` 符号链接指向部署的版本。现在我们可以原子性地切换版本,而且不会有残留文件。删除的文件简单地在下一个版本中不被物化。对于要求文件在特定位置的应用,我们可以在文件系统的任何地方创建指向 `/var/lib/deptool` 的符号链接。这一步不是原子性的,但它只发生在添加或删除符号链接时,而不是在我们编辑它们指向的文件时。而且如果我们后来部署一个不再包含该符号链接的版本,我们从 diff 中就知道需要删除它。不会有东西残留。
**用 remote-tracking refs 追踪已部署的内容。** 回到操作员的笔记本上,我们追踪部署到每个主机的 commit。这是按主机而非按集群的属性,因为如果某个变更不影响某台主机,我们不需要在那里部署新的 commit。现在我们可以离线计算集群 diff。这个 diff 就是部署计划,我们可以在*毫秒级*内呈现它。
**要部署时,首先尝试在目标主机上获取锁。** 我们通过 SSH 连接并发送锁请求。在请求中,我们包含我们认为已部署在该主机上的 commit。如果我们获得了锁,那么我们的计划是有效的,而且在我们释放锁之前,其他任何东西都不能在该主机上部署,所以计划*保持*有效。部署只有在持有受影响所有主机的锁时才会继续。如果任何 ref 已过时,主机上部署了其他东西,这意味着计划是*过时的*,我们中止。我们更新本地 refs,下一次运行会显示最新的计划。
**重启 systemd 单元。** 我所有的服务都作为 systemd 单元运行,它们启动很快,所以我可以在有疑问时倾向于重启它们。在应用配置变更后,我们重启受影响的 systemd 单元。如果单元启动失败,我们可以把符号链接指回上一个已知良好的版本,然后再次重启。毫秒级自动回滚。
这就是 Deptool 背后的核心思想。集群配置只是 Git 仓库中追踪的一个目录树,在每个主机上我们 checkout 相关文件到隔离目录,改变几个符号链接,然后重启 systemd 单元。就这些!
## 乐观并发
Deptool 的部署具有乐观并发的特性。我们假设自己知道当前的集群状态,并基于此做计划。如果假设被证明是错误的,我们就得重试。这意味着在没有竞争时非常快(当我是唯一部署的人,总是从同一台笔记本部署),代价是在竞争下表现极差(一个团队的人都在不断尝试部署,只有一个人能成功,其他人都得重试)。这和 `git push` 是同一个模型。它不会扩展到数百人或数千台服务器,但没关系。我是唯一管理我个人基础设施的人,我也没有数千台服务器。构建自己的工具的好处就是,你可以针对自己的确切用例进行优化!
## 构建 agent
我们有了设计,从高层次知道要做什么。如何在被管理的主机上实现它?
我的 web 服务器运行 Flatcar Linux (https://www.flatcar.org/),一个基于镜像的 OS,用户空间非常精简。它有 coreutils 和 Bash,但没有包管理器,也没有 Python。这对减少攻击面很有好处。对安装东西来说就不太好了。
我想用我的工具来管理服务器,所以它需要开箱即用地兼容 Flatcar。如果我需要为工具安装什么东西,那我就有了新的问题:如何自动化*那个*安装过程!
所以我们需要从外部管理这台新主机。我可以 SSH 进去,在那里使用无密码 sudo。也许我们可以直接通过 SSH 执行命令?如果你曾经尝试用这种方式自动化任何稍微复杂的事情,你会很快从惨痛教训中学到,握手不仅慢 —— argv 也无法在 SSH 边界上毫发无损地穿越。我想要一个快速的工具,不想处理通过 SSH 执行 shell 时的词分割和转义的微妙之处。所以让我们绕过这个雷区。
如果我们只运行*一个*简单的命令呢?一个不带参数的程序,在可预测的位置。这就是 *agent*。Agent 从 stdin 读取消息,在 stdout 上响应。我们只把 SSH 当作传输层,当作一个 socket。现在我们有了主机上的代码执行能力,我们可以发送任何我们想要的输入,再也不用为转义操心了!
现在只需要启动 agent ... 但 agent 是怎么到那儿的?这是一台新主机!而且我们如何构建这个 agent?
这里我们也可以从 Ansible 的错误中学习。在你缓解 (https://mitogen.networkgenomics.com/ansible_detailed.html) 了它最糟糕的缺陷之后,Ansible 每次连接到主机时仍然会发送数兆字节的 Python 模块。不用说,这很慢。极其慢。
这种设计唯一的好处是你确切知道远程运行的是什么,所以不会有版本混淆。理论上至少。实际上,我花了无数小时盯着晦涩的 Python 错误,因为 Ansible 并不是*完全*自包含的。这造成了兼容性噩梦。
此外,我们在 Flatcar 上甚至*没有* Python。
那试试这个怎么样?
**我们构建一个静态二进制文件。** 除了内核不做任何假设,没有解释器需要解析数兆字节代码才能开始做有用的事。
**把它放在以构建它的 commit 命名的位置。** 这确保连接两端运行的是同一个版本,所以永远不会有兼容性问题。如果我们成功启动了这个二进制文件,它就说的是正确的协议。我们把它放在 `/var/lib/deptool/bin/deptool--<commit>`。
**乐观地假设二进制文件已经在那里了。** SSH 握手相当昂贵。不要浪费在任何探测或幂等安装步骤上。二进制文件大约 1.6MB。发送起来不算慢得无法接受,但也不是免费的,而且集群配置变更的频率远高于 Deptool 更新;常见情况是二进制已经存在。
**如果启动二进制文件失败,那我们就需要安装它。** 我们通过 SSH 第二次连接,这次用这个命令:
```
uname -sm && sudo mkdir -p /var/lib/deptool/{bin,apps,store} && sudo dd status=none of=<path> && sudo chmod +x <path> && sudo sha256sum <path>
```
我们是这样驱动它的。首先,从 stdout 读取一行获取 `uname` 输出。这告诉我们机器的 OS 和 CPU 架构,这样我们就可以发送对应平台的 agent 二进制文件。我们把二进制文件写到 stdin;`dd` 在远程把它写到磁盘。最后,从 stdout 再读取一行:远程计算的 shasum。这确认传输成功。
整个过程只依赖标准化的 coreutils 程序。当我们现在重试启动 agent 时,它应该会成功,而且 agent 会清理旧版本以避免填满磁盘。
我们获得了什么?
- 一种在远程主机上执行 agent 并与它通信的方式。
- 自动安装,远程主机上只需要 coreutils。
- 两端运行相同版本,协议在构造上兼容。
- 所有用户控制的输入都通过我们 SSH 支撑的 socket 传输,永远不会进入 SSH 命令或 shell 命令,所以我们绕过了任何转义问题和长度限制。
- 常见情况下只需要一次 SSH 握手,所以延迟最小。
- 不常见情况下(对新机器部署,或更新工具后)需要额外两次连接加上一次 1.6MB 的传输。有了 `ControlMaster` (https://man.openbsd.org/ssh_config#ControlMaster),后续连接可以跳过大部分开销,所以总共只需几秒钟。这种情况下不是亚秒级部署,但仍然比 Ansible 好。
这种方法在我构建 Deptool 的过程中就已经表现得很好,而且需要复制二进制文件的情况相对更常见。特别是对于我部署配置、微调、再部署的流程,SSH 可以保持底层连接存活,所以部署感觉真的是瞬时的。
## 结论
过去一个月我一直在用 Deptool 管理我的个人基础设施,我对它非常满意。能在连接前立即看到准确的计划,能有自动回滚,都很棒,但亚秒级部署才是真正改变游戏规则的。
相似文章
纵深防御:Python供应链安全实用指南
一份关于通过分层防御保护Python供应链的实用指南,包括使用Ruff进行代码检查、使用哈希锁定依赖项、使用pip-audit进行漏洞扫描、生成SBOM以及使用OIDC证明的可信发布。
@googledevs:减少样板代码编写时间,加速部署。
Google Developers 重点介绍了一款工具,可减少样板代码并加快部署速度。
@freeCodeCamp:内部开发者平台能帮助团队加快交付速度,无需手动管理基础设施。在本指南中,Ayobami…
一份关于使用Backstage、ArgoCD和Crossplane构建内部开发者平台的综合指南,让团队能够自助获取基础设施和部署服务,无需手动填写工单流程。
Z4nzu/hackingtool
hackingtool v2.0.0 是一款基于 Python 3.10+ 的 CLI,将 185+ 安全工具按 20 个分类打包,支持搜索、标签、批量安装与 Docker,为渗透测试人员与研究者设计。
我如何过度工程化我的书
一位开发者描述了使用 Git、Markdown、CI 管道和代码检查工具来过度工程化他们的书籍写作和发布过程,自动化检查和多种格式构建。