今年早些时候,一个不起眼的依赖版本更新合并请求出现在我的一个开源项目里。其中一处改动轻微地调整了持续集成配置,我差点只扫一眼差异就把它合并了。
后来我认真读了内容:每次构建都会多出一个额外步骤。而我的自托管运行器——一台为了节省持续集成时长而租用的便宜虚拟专用服务器——会执行来自分支的代码。它上面还留着云服务凭证,就放在配置文件里,是过去某个版本的自己“为了方便”留下的。
一个恶意合并请求根本不需要逃逸任何东西。它完全可以把我的环境变量发送到某个文本粘贴网站,而运行器会乖乖照做。
真正的教训并不在于那一个合并请求。关键在于,自托管运行器是一台执行陌生人代码的机器,而且执行完之后还会继续存活。我最便宜的那台机器,恰恰是我最危险的一台。
容器是标准答案,而且并非毫无用处。但在运行器上,容器与宿主机共享内核;挂载的缓存在任务结束后依然存在,被污染的缓存会直接交给下一次构建;通常还会暴露一个容器套接字;文件系统在构建之间持续保留。
我不想要“礼貌性”的隔离。我想要一条真正的边界。所以现在每个持续集成任务都会获得一个全新的 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
没有公网地址意味着这台机器不能主动向外发起连接,也不能被外部直接访问。它只能通过受控通道与编排器通信。凭证在运行时以短时效、限定范围的令牌注入,既不会出现在镜像里,也不会在销毁后留在磁盘上。
热门跟贴