Build modules: a per-node processor cap, and each step's threads within it (issue 1806)

The operator's desktop slows while it builds (shanks at 92%, the Go build
runtime at 1650% of 24 processors). The build modules declare cpu-cap
(default shanks=50,g14=50,ace=75), which the composer turns into the runtime's
quota at idle priority; each step's go, node and buildah commands get
GOMAXPROCS and UV_THREADPOOL_SIZE for their part of it, and the status tools
show the limits and what the kernel holds the runtime to.
This commit is contained in:
2026-10-26 13:41:05 +01:00
parent 879600202a
commit 64b9ded2db
26 changed files with 195 additions and 26 deletions
@@ -110,6 +110,9 @@ func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
if !strings.Contains(env, "XDG_RUNTIME_DIR="+m.Store+"/xdg") {
t.Errorf("the environment of %q lacks the module's own runtime directory: %s", c.line, env)
}
if !strings.Contains(env, "GOMAXPROCS=") {
t.Errorf("the environment of %q gives buildah no thread limit (issue 1806): %s", c.line, env)
}
}
// The image is removed from the store once the digest is read, so the store does not grow with every build.
if len(f.called("rmi")) != 1 {
@@ -0,0 +1,39 @@
package main
import (
"git.novox.be/novox/mesh-sdk/go/buildcpu"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// The processors a step may take (novox/hq issue 1806). The kernel holds the module's runtime to idle priority and to
// its node's share of the processors (the setting cpu-cap, written into its unit by the node-engine); inside that
// bound a step starts no more threads than its part of it, so the steps running at once do not wait in the kernel's
// throttle.
// cpuLimits is what one step is held to now: the cap from the settings file, the machine's processors and the node's
// capacity, read at every call like the capacity, so a change takes effect at the next step.
func (m *Module) cpuLimits(say func(string, ...any)) buildcpu.Limits {
slots := 1
switch {
case m.Capacity != nil:
slots = m.Capacity()
case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
}
if slots < 1 {
slots = 1
}
return buildstep.NodeLimits(m.Settings, m.Node, slots, say)
}
// threadEnv is the environment buildah is given for its threads (it is a Go program). What a build's RUN lines start
// runs in the container's own environment, inside the runtime's cgroup: the quota and the nice level bind it.
func (m *Module) threadEnv() []string {
return buildcpu.Env(m.cpuLimits(nil).Threads)
}
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
// to, read from its cgroup rather than assumed.
func (m *Module) cpuStatus() map[string]any {
return map[string]any{"limits": m.cpuLimits(m.Say), "in-force": buildcpu.ReadInForce()}
}
@@ -128,7 +128,8 @@ func (m *Module) Health(ctx context.Context) health.Answer {
// Status is what the module reports of itself.
func (m *Module) Status(ctx context.Context) map[string]any {
l, err := m.look(ctx)
out := map[string]any{"node": m.Node, "account": m.Account, "buildah": l.Buildah, "subuids": l.UIDs, "subgids": l.GIDs, "refusing": err != nil}
out := map[string]any{"node": m.Node, "account": m.Account, "buildah": l.Buildah, "subuids": l.UIDs, "subgids": l.GIDs, "refusing": err != nil,
"cpu": m.cpuStatus()}
if err != nil {
out["why"] = err.Error()
}
@@ -117,7 +117,7 @@ func (m *Module) storeFlags() []string {
// error, and the next look refuses with it.
func (m *Module) env() []string {
_ = m.ensureRuntimeDir()
return []string{"XDG_RUNTIME_DIR=" + m.runtimeDir()}
return append([]string{"XDG_RUNTIME_DIR=" + m.runtimeDir()}, m.threadEnv()...)
}
// runtimeDir is buildah's runtime directory, inside the store.