今年早些时候,一个不起眼的依赖版本更新合并请求出现在我的一个开源项目里。其中一处改动轻微地调整了持续集成配置,我差点只扫一眼差异就把它合并了。

后来我认真读了内容:每次构建都会多出一个额外步骤。而我的自托管运行器——一台为了节省持续集成时长而租用的便宜虚拟专用服务器——会执行来自分支的代码。它上面还留着云服务凭证,就放在配置文件里,是过去某个版本的自己“为了方便”留下的。

打开网易新闻 查看精彩图片

一个恶意合并请求根本不需要逃逸任何东西。它完全可以把我的环境变量发送到某个文本粘贴网站,而运行器会乖乖照做。

真正的教训并不在于那一个合并请求。关键在于,自托管运行器是一台执行陌生人代码的机器,而且执行完之后还会继续存活。我最便宜的那台机器,恰恰是我最危险的一台。

容器是标准答案,而且并非毫无用处。但在运行器上,容器与宿主机共享内核;挂载的缓存在任务结束后依然存在,被污染的缓存会直接交给下一次构建;通常还会暴露一个容器套接字;文件系统在构建之间持续保留。

我不想要“礼貌性”的隔离。我想要一条真正的边界。所以现在每个持续集成任务都会获得一个全新的 Firecracker 微型虚拟机——独立内核、独立根文件系统——任务一结束,这台机器就销毁。

每个任务的生命周期

在 Krova Cloud 上,每个微型虚拟机就是一个 Cube。配置只需一条命令,不到一秒就能启动:

npm i -g @krovacloud/clikrova cubes create ci-job-812 --cpu 2 --ram 4 --disk 40 --image ubuntu-24.04# ✓ Cube provisioned · booted in 0.9s

我的流水线为每个任务调用的包装脚本如下:

#!/bin/bashset -euo pipefailCUBE="ci-job-$BUILD_ID"# fresh box per job — nothing carries over, everkrova cubes create "$CUBE" --cpu 2 --ram 4 --disk 40 --image ubuntu-24.04# secrets injected at runtime as short-lived, scoped tokens.# never in the image, never on disk after teardownkrova ssh "$CUBE" "DEPLOY_TOKEN=$DEPLOY_TOKEN ./run-build.sh"# teardown is the security featurekrova cubes delete "$CUBE"

当编排器直接调用接口时,重试逻辑也包含在内——注意幂等键,这样重试请求就不会重复配置资源:

curl -X POST https://krova.cloud/api/v1/spaces/$SPACE/cubes \-H "X-API-KEY: $KROVA_KEY" \-H "Idempotency-Key: $(uuidgen)" \"name": "ci-job-812","image": "ubuntu-24.04","resources": { "vcpu": 2, "ramGb": 4, "diskGb": 40 }}'

从内部看运行器长什么样

这是真正改变我威胁模型的部分。任务所在的机器没有公网地址,它处在一个私有网络地址转换后的网络里:

root@ci-job-812:~# ip -brief addrlo

没有公网地址意味着这台机器不能主动向外发起连接,也不能被外部直接访问。它只能通过受控通道与编排器通信。凭证在运行时以短时效、限定范围的令牌注入,既不会出现在镜像里,也不会在销毁后留在磁盘上。