remote-build
GitHub将重型 Rust 构建、Clippy 检查及测试任务卸载至 GCP Spot VM,避免本地 Mac 资源争用。适用于冷启动构建或主机负载过高场景,需配合 detach/attach 模式使用以防止超时导致 VM 泄漏。
触发场景
安装
npx skills add nubjs/nub --skill remote-build -g -y
SKILL.md
Frontmatter
{
"name": "remote-build",
"metadata": {
"internal": true
},
"description": "Run a nub Rust build, clippy gate, or test suite on an ephemeral Google Cloud spot VM instead of the dev Mac — and, for a macOS artifact, cross-compile aarch64-apple-darwin on Linux and pull the signed binary back. Invoke (via the Skill tool) whenever you are about to start a COLD build, `cargo clippy --all-targets --all-features`, a full `cargo test`, or a `release` build, and whenever the host is contended (load high, many agent worktrees building, a benchmark needs a quiet box). THE RULE THIS SKILL EXISTS TO CARRY: the heavy, cold-anyway jobs belong on a remote builder; the ~5s warm incremental loop stays local, because remote loses that one. Also the go-to when someone asks to \"build this without hammering my machine\" or to reclaim CPU from builds. Pairs with `dev-loop` (the local loop), `rust-build` (target-dir sharing), `rust-build-hygiene` (not orphaning builds), `cpu-reduction` (clearing residue that already accumulated), and `gcloud-vm` (the underlying VM mechanics)."
}
Remote builds — get the heavy Rust jobs off the Mac
scripts/remote-build.ts dispatches a build/gate to a throwaway GCE spot VM and reports the result. For a macOS binary use the mac-build skill instead — it builds natively on a real macOS runner, with no stub TBDs, no pinned zig, and a correct deployment target. Measurements and decision record: wiki/research/remote-build-offload.md.
nub scripts/remote-build.ts --job clippy --detach # start it, print the VM name, exit
nub scripts/remote-build.ts --attach <vm-name> # stream + collect; deletes the VM
nub scripts/remote-build.ts --job clippy # foreground; only if you can wait
nub scripts/remote-build.ts --job test # the whole-workspace test suite
nub scripts/remote-build.ts --fanout 10 --job clippy # 10 builders at once
nub scripts/remote-build.ts --reap # delete stray builder VMs
nub scripts/remote-build.ts --build-image # re-bake the golden image (rare)
Driving this from an agent harness? Use --detach, then --attach. A foreground run is
SIGKILLed at the harness timeout — two minutes by default, ten at most — and SIGKILL cannot be
caught, so layer 1 never runs and the VM leaks until its server-side TTL. Measured twice: a cold
clippy killed at 2m13s, then again at 10m, each orphaning a builder. --attach polls in bounded
windows and exits 75 for "still running, call again"; re-run it until it returns the job's own
exit code.
What goes remote, and what must NOT
Measured, n2-standard-16 vs the Mac:
| Job | Remote | Mac | Verdict |
|---|---|---|---|
| warm incremental | 8.1s | ~5s | stays local — remote loses |
clippy --all-targets --all-features |
35.3s | — | remote |
cargo test (whole workspace) |
39.4s warm | — | remote |
The inner loop is deliberately not a job type. Do not route cargo build --profile fast through this while iterating; you will make your loop slower.
Why remote helps is disk, not cores. Under load the Mac sits at ~30% idle CPU with a load average of 155, sys ~25%, disk at 3000–4000 tps at 5–6 KB/transfer — cargo fingerprint/stat churn across a dozen multi-GB target dirs on one APFS volume. Each remote builder brings its own disk; more local cores would not have helped.
Gotchas
- macOS ships openrsync ("2.6.9 compatible"), not rsync 3.x. Any 3.x-only flag fails the whole sync. Sync uses a
--files-fromallowlist built fromgit ls-files; an--excludeblocklist makes rsync walk ~99 GB of gitignored tree and time out at 120s. - A builder can silently degrade the binary three ways —
aube-resolver/build.rsships an empty primer (falling back to network packument fetches, exit 0) ifnodeis missing, ifgenerate-primer.mjsfails to spawn, or if it exits non-zero. The job script’scommand -v nodecheck catches only the first, and that is deliberate: it does not setAUBE_REQUIRE_PRIMER=1. That guard protects a shipped binary, which is whyrelease.ymlsets it andci.ymldoes not — a lint or test gate ships nothing. Setting it here made every remote job die inbuild.rs, because the primer JSON is gitignored (so thegit ls-files-driven sync cannot carry it) and regenerating it needs the networked registry crawl only the release pipeline runs. - Under
--all-features,crates/nub-core/build.rspanics unlessruntime/addons/nub-native.nodeis staged. The job script stages a placeholder, as CI does. cmakeis mandatory on the builder —libz-ng-sysfails ~35s in without it.
Orphaned builders cannot outlive their TTL (three layers)
A local finally is defeated by SIGKILL, so it is not trusted alone:
finally+ SIGINT/SIGTERM handlers delete the VM on normal and interrupted paths.- Every builder carries
--max-run-duration=45m --instance-termination-action=DELETE, so GCE deletes it server-side even if the launching process dies outright. This is the layer that holds. - Every VM is labelled
nub-builder=1, sonub scripts/remote-build.ts --reapsweeps strays with no local state.
Layer 2 covers every instance, including the image bake. --instance-termination-action applies to --max-run-duration, not only spot preemption, and accepts STOP as well as DELETE — so the bake gets --max-run-duration=90m with STOP (what it does to itself anyway before imaging the disk) and a builder gets 45m with DELETE (a merely-stopped VM still bills its disk). The create window is not covered by layer 1 — GCE can have the VM up before gcloud returns — but layer 2 is set at create time.
--reap will not touch a VM younger than 90 minutes. With many agents sharing one GCP project, an unfiltered sweep would destroy a sibling's in-flight build. Layer 2 guarantees a healthy instance is gone by its TTL, so age is the definition of stray. --reap-all forces the unfiltered sweep. --reap exits non-zero if any delete fails, so "no output, exit 0" genuinely means clean.
A build is never detached locally — a detached local build reparents to PID 1, outlives its launcher, holds locks, and is not reaped (see rust-build-hygiene). --detach is not that: it detaches on the disposable remote VM, which is single-purpose and carries a hard --max-run-duration, so a forgotten job cannot outlive its TTL or contend with anything on the dev host.
The golden image
--build-image bakes a nub-builder image family with apt deps, rustup + the darwin target + clippy, pinned zig, cargo-zigbuild, Node, a warmed crate registry, and pre-compiled dependency artifacts in $HOME/.cargo-shared-target. That path is load-bearing and must match the one every job exports — the bake deletes ~/src when it finishes, so a target dir inside it would be destroyed while the image advertised warm artifacts. Re-bake when the toolchain or dependency graph moves substantially, or when the warm block below changes — a warm-up that no longer matches jobScript is exactly as cold as no warm-up.
The bake covers both jobs — but a given image is only as warm as its bake. The warm block runs every cargo invocation jobScript emits, verbatim: the root clippy, the nub-native clippy, cargo test --workspace --no-run, and (cd crates/nub-native && cargo build). --build-image is manual-only, though — no workflow or cron invokes it — so the live nub-builder family stays exactly as it was last baked. Check the image date before sizing a run (gcloud compute images list --project pullfrog --no-standard-images --format='table(name,family,creationTimestamp)' --sort-by=~creationTimestamp — the default columns carry no timestamp): an image baked before the warm block covered clippy leaves builders cold-compiling at ~250s rather than ~35s, and that is the state of the family until someone re-bakes. Cargo fingerprints on the command shape, so a warm-up differing by driver, profile, package scope, or feature set produces artifacts the job cannot use and the image goes silently cold.
What this does NOT give you
- No provenance binding. The artifact is checked for being a runnable arm64 Mach-O with a valid ad-hoc signature — which attests runnability, not origin. Nothing cryptographically ties the binary to the source that was sent.
- The golden image is a trust concentration. Baked once and reused for every build, so anyone with write access to the
pullfrogproject (or its service-account key) could bake something into every dev binary you later run. An accepted property of the design, not an oversight. StrictHostKeyChecking=no. Unavoidable with ephemeral VMs on recycled IPs. Closing it properly means--no-address+ IAP tunnelling.
Cost
Spot c3-standard-8 is a few cents per build; a 7-minute release build is about $0.03, and a stray cannot outlive 45 minutes. Cost is not a reason to hesitate — contention is what you are spending money to avoid.
版本历史
-
c10a266
当前 2026-08-04 22:52
修复 cgroup-v1 环境下 PID 限制检测路径错误导致的容器内线程池耗尽问题
-
46280a5
2026-08-03 01:43
更新远程构建性能对比数据,移除 Linux 测试通过数说明;补充 macOS 交叉编译指引及与 dev-loop 等技能的配合关系;完善 orphaned builders 的 TTL 机制描述。
- f966b97 2026-07-31 00:05


