carlos / platform Public

activator: instance, sidecar and seed spawn into the host's exec cgroup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 38f6c0aa2857bb283fc71c21ae044fcc79ef98fc parent 764ed2b
2 files changed, +86 −41
  • internal/activator/backend_exec.go +78 −41
  • internal/activator/backend_unit.go +8 −0
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 87d27c613..3673b1e02 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -20,6 +20,7 @@ import (
"syscall"
"time"
+ "github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/registry"
@@ -56,6 +57,11 @@ type ExecBackend struct {
// instance so it can invalidate its own cached responses right after a
// write. Nil disables this — no purge listener, no error.
Cache *edge.Cache
+ // Cgroups, when non-nil, places every spawned child — instance, sidecar,
+ // seed — in a per-host cgroup under the agent's delegated subtree so the
+ // metrics sampler can account it (exec-child accounting spec). Nil — no
+ // delegation: old unit file, tests, darwin — spawns exactly as before.
+ Cgroups *cgroup.Manager
}
var _ Backend = (*ExecBackend)(nil)
@@ -213,30 +219,40 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error {
ctx, cancel := context.WithTimeout(ctx, seedTimeout)
defer cancel()
argv := append(seedArgs, "--db", dbPath)
- cmd := exec.CommandContext(ctx, b.liveLink(host), argv...)
- // The purge-channel vars are agent-owned in BOTH directions here too
- // (Start's discipline, see withoutVars): fileEnv is tenant-supplied, so
- // without this a tenant's own env file could hand its seed a
- // CARLOS_CONTROL_SOCKET the agent never opened and a CARLOS_PURGE for a
- // channel that does not exist — and the seed has no serving cache to
- // purge in any case. This is what makes the contract above true rather
- // than merely intended.
- cmd.Env = childEnv(withoutVars(fileEnv, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE"))
- // Like the instance child, the seed logs through the agent's own streams
- // (journald under systemd) — and its stderr is ALSO teed into a bounded
- // buffer, because the wake error this returns is the whole diagnosis an
- // operator gets for a host that refuses to come up.
var stderr capBuffer
- cmd.Stdout = os.Stdout
- cmd.Stderr = io.MultiWriter(os.Stderr, &stderr)
- // WaitDelay is what makes seedTimeout real. Because Stderr is a plain
- // io.Writer (the tee), os/exec pipes it and Run waits for that pipe to
- // close — and a seed that spawned its own grandchild holds the write end
- // open long after the timeout killed the seed itself. Without this, a
- // wedged seed would hold the lease until the wake's own 5m budget expired.
- cmd.WaitDelay = seedDrainGrace
log.Printf("activator: seeding %s: %s %s", host, b.liveLink(host), strings.Join(argv, " "))
- if err := cmd.Run(); err != nil {
+ // The seed shares the instance's carlos-exec@<host> cgroup: first-boot
+ // cost is that instance's work. No CleanupHost here — the instance spawn
+ // reuses the dir moments later, and its exit path (or the next agent
+ // start's sweep) removes it.
+ cmd, err := b.Cgroups.Start(host, func() *exec.Cmd {
+ cmd := exec.CommandContext(ctx, b.liveLink(host), argv...)
+ // The purge-channel vars are agent-owned in BOTH directions here too
+ // (Start's discipline, see withoutVars): fileEnv is tenant-supplied, so
+ // without this a tenant's own env file could hand its seed a
+ // CARLOS_CONTROL_SOCKET the agent never opened and a CARLOS_PURGE for a
+ // channel that does not exist — and the seed has no serving cache to
+ // purge in any case. This is what makes the contract above true rather
+ // than merely intended.
+ cmd.Env = childEnv(withoutVars(fileEnv, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE"))
+ // Like the instance child, the seed logs through the agent's own streams
+ // (journald under systemd) — and its stderr is ALSO teed into a bounded
+ // buffer, because the wake error this returns is the whole diagnosis an
+ // operator gets for a host that refuses to come up.
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = io.MultiWriter(os.Stderr, &stderr)
+ // WaitDelay is what makes seedTimeout real. Because Stderr is a plain
+ // io.Writer (the tee), os/exec pipes it and Wait waits for that pipe to
+ // close — and a seed that spawned its own grandchild holds the write end
+ // open long after the timeout killed the seed itself. Without this, a
+ // wedged seed would hold the lease until the wake's own 5m budget expired.
+ cmd.WaitDelay = seedDrainGrace
+ return cmd
+ })
+ if err == nil {
+ err = cmd.Wait()
+ }
+ if err != nil {
// WaitDelay reports on the SUCCESS path too: a seed that exits 0 after
// backgrounding something (a helper, a stray `&`) leaves the inherited
// stderr pipe open, and Run then returns ErrWaitDelay INSTEAD of nil
@@ -245,7 +261,7 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error {
// built — leaving the host fresh again next wake, i.e. permanently
// un-wakeable. The exit status is the verdict; the leaked pipe is a
// log line.
- if errors.Is(err, exec.ErrWaitDelay) && cmd.ProcessState != nil && cmd.ProcessState.Success() {
+ if cmd != nil && errors.Is(err, exec.ErrWaitDelay) && cmd.ProcessState != nil && cmd.ProcessState.Success() {
log.Printf("activator: seeded %s (it exited 0 but left a child holding its output pipe open for more than %s)", host, seedDrainGrace)
return nil
}
@@ -437,23 +453,33 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin
} else {
childVars = withoutVars(childVars, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE")
}
- cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath)
- cmd.Env = childEnv(childVars)
- // The child logs through the agent's own stderr/stdout (journald under
- // systemd); there's no per-instance unit to own a log stream anymore.
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
// Record the build we're about to exec — read the symlink immediately before
- // Start() so it names the version the kernel will resolve. Reading it here,
+ // the start so it names the version the kernel will resolve. Reading it here,
// rather than back in wake() after the health wait, closes a real window: a
// concurrent ship's repoint during the up-to-30s wait would otherwise make
// us record the NEW target while this child runs the OLD binary, and the
// sweep would then think it's already converged and never cycle it. The
- // residual sub-exec race (a repoint between this read and Start) is in the
- // safe direction — we'd record the older version, so the sweep cycles once
- // too often rather than never. Empty on a read error: the sweep won't cycle.
+ // residual sub-exec race (a repoint between this read and the start) is in
+ // the safe direction — we'd record the older version, so the sweep cycles
+ // once too often rather than never. Empty on a read error: the sweep won't
+ // cycle.
spawnVer, _ := b.LiveVersion(host)
- if err := cmd.Start(); err != nil {
+ // Spawned through the cgroup manager (nil-tolerant: no delegation means
+ // a plain start, exactly as before) so the child lands in
+ // carlos-exec@<host> and the sampler can account it. build may run
+ // twice — the manager's clone3-refusal fallback — so everything about
+ // the Cmd is configured inside it.
+ cmd, err := b.Cgroups.Start(host, func() *exec.Cmd {
+ cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath)
+ cmd.Env = childEnv(childVars)
+ // The child logs through the agent's own stderr/stdout (journald
+ // under systemd); there's no per-instance unit to own a log stream
+ // anymore.
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd
+ })
+ if err != nil {
return nil, "", err
}
@@ -479,6 +505,9 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin
}
go func() {
p.err = cmd.Wait()
+ // Last exiter wins: rmdir refuses while the sidecar (same host
+ // cgroup) is still alive, and that failure is tolerated.
+ b.Cgroups.CleanupHost(host)
close(p.done)
}()
@@ -604,18 +633,26 @@ func (b *ExecBackend) StartSidecar(_ context.Context, rt registry.Route) (Proc,
if err != nil {
return nil, fmt.Errorf("read sidecar env: %w", err)
}
- cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run")
- cmd.Env = childEnv(fileEnv)
- // Like the instance child: logs through the agent's own streams (journald
- // under systemd) — no per-host unit owns a log stream anymore.
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- if err := cmd.Start(); err != nil {
+ // Same host cgroup as the instance: a sidecar's load is that instance's
+ // work, and attributing it elsewhere would be a lie in the other
+ // direction.
+ cmd, err := b.Cgroups.Start(rt.Host, func() *exec.Cmd {
+ cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run")
+ cmd.Env = childEnv(fileEnv)
+ // Like the instance child: logs through the agent's own streams
+ // (journald under systemd) — no per-host unit owns a log stream
+ // anymore.
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd
+ })
+ if err != nil {
return nil, err
}
p := &childProc{cmd: cmd, done: make(chan struct{})}
go func() {
p.err = cmd.Wait()
+ b.Cgroups.CleanupHost(rt.Host)
close(p.done)
}()
return p, nil
diff --git a/internal/activator/backend_unit.go b/internal/activator/backend_unit.go
index 55caeda87..992e5423f 100644
--- a/internal/activator/backend_unit.go
+++ b/internal/activator/backend_unit.go
@@ -56,6 +56,7 @@ import (
"sync"
"time"
+ "github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -65,6 +66,13 @@ import (
// try-restarts for non-hibernating tenants.
func UnitName(host string) string { return "carlos-app@" + host + ".service" }
+// ExecName is the metrics sample key for an exec-backed tenant's children —
+// the delegated cgroup directory the activator spawns them into. Defined by
+// internal/cgroup (which creates the directories); surfaced here next to
+// UnitName so the console learns both of a host's legal metric keys from
+// the same place.
+func ExecName(host string) string { return cgroup.ExecName(host) }
+
// UnitBackend is the Backend for routes with Backing == registry.BackingUnit.
type UnitBackend struct {
// Systemctl runs one systemctl verb against a unit — the Proc-style seam