Skip to content

Commit 655054d

Browse files
Lutherwavesclaude
andcommitted
feat(daemon): cap concurrent sandboxes per profile
A profile bounds what ONE sandbox consumes — CPU, memory, tmpfs, PIDs, lifetime. Nothing bounded how many exist. N sandboxes each sitting at their ceiling is N times that ceiling of committed host memory, reached without any single request violating a policy: a retry storm, a leaked handle, or a reaper that stops reaping exhausts the host while the daemon does exactly what it was told to. Concurrent count is the one resource dimension the profile model did not cover. Adds `max_sandboxes` to a profile. Unset means unlimited, so existing deployments are unchanged, and a negative value is rejected at load rather than read as unlimited — "the cap silently did not apply" is the one outcome this setting exists to prevent. A refused create returns 429 with a new `at_capacity` error kind, distinct from `invalid`: the request is well formed and may well succeed later, once the reaper or an explicit Destroy frees a slot. A caller that cannot tell those apart either retries a malformed request forever or gives up on a temporary refusal. brokerclient maps it back through the existing ErrorFor path, so errors.Is against ErrAtCapacity works the same as against the library. Three things worth reviewing rather than skimming: The count comes from the backend, not from a number this process keeps. Sandboxes survive a daemon restart, and an in-process counter would not — it would drift into permitting more than the cap on exactly the reboot where that mattered. Reading the backend also means the reaper releases capacity for free: an idle sweep, a max_age expiry or a Destroy frees a slot with no extra bookkeeping. Checking a live count is not enough on its own. Concurrent requests all read the same pre-create count and all pass, which is precisely the retry storm the cap exists for, so the check has to be atomic with taking the slot: a reservation is held from the check until Create returns. The reservation is released only after Create returns, so a created sandbox is already visible to the count before it stops being counted as pending — the instant of overlap errs towards refusing one request too many rather than admitting one too many. An existing name does not consume a slot. Backend.Create returns the live sandbox when the name is taken, which is how a caller reuses its own warm sandbox; charging that against the cap would starve the callers already inside it, and only once the host was busy enough for the cap to bind. Stopped sandboxes do count. They hold no memory right now, but Create restarts one under the same name, so excluding them would let a caller walk past the cap by reviving what the reaper has not yet collected. Capacity is per profile rather than global. A per-profile number is easier to reason about and composes into a global one later; starting global makes the per-profile case harder to express. Profiles with different memory ceilings also want different counts. Not addressed here, and deliberately: per-caller quotas. The daemon has no caller identity — socket group membership is the entire access control list and every connection on it is anonymous and mutually indistinguishable — so a per-caller quota depends on first establishing identity, which is a larger and separable piece of work. A per-profile cap needs none of it, and still bounds total host exposure, which is the property actually at risk. Sizing guidance in the reference config and docs/security.md says to plan against memory_mb x max_sandboxes WITH headroom, because memory_mb is enforced approximately: roughly 1.4x the configured figure has been observed resident before the kill lands. Closes #28 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4718bfd commit 655054d

11 files changed

Lines changed: 332 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ Releases are cut automatically from [Conventional Commits](https://www.conventio
3030
server-side from configuration alone.
3131
- `pkg/brokerclient`: a drop-in `sandbox.Backend` that talks to `openbloxd`
3232
over its Unix socket, satisfying the same contract the Docker backend does.
33+
- `openbloxd`: `max_sandboxes` per profile, bounding how many sandboxes exist
34+
at once — the one resource dimension a profile did not otherwise cover.
35+
Exceeding it returns `429` with the new `at_capacity` error kind
36+
(`brokerapi.ErrAtCapacity`), distinct from a malformed request because the
37+
request is valid and may succeed once the reaper frees a slot. Unset means
38+
unlimited, so existing deployments are unchanged.
3339

3440
### Changed
3541

deploy/openbloxd.example.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ profiles:
2121
memory_mb: 2048
2222
disk_mb: 1024 # tmpfs, drawn from memory; must not exceed it.
2323
max_processes: 256 # Without this a fork bomb exhausts host PIDs.
24+
# How many sandboxes this profile may hold at once. Every other bound here
25+
# limits ONE sandbox; this is the only one that limits how many exist, and
26+
# without it a caller looping on create exhausts host memory without ever
27+
# violating a policy. Omit for unlimited.
28+
#
29+
# Size it as memory_mb x this number, with headroom: the runtime enforces
30+
# memory_mb approximately, and roughly 1.4x the configured figure has been
31+
# observed resident before the kill lands. A refused create returns 429 with
32+
# kind "at_capacity", so a caller can tell it from a malformed request.
33+
max_sandboxes: 32
2434
idle_timeout: 30m
2535
max_age: 4h
2636
default_timeout: 60s
@@ -36,5 +46,9 @@ profiles:
3646
memory_mb: 4096
3747
disk_mb: 2048
3848
max_processes: 256
49+
# Counted per profile, not globally: browser sandboxes cost more memory
50+
# each, so they get their own smaller ceiling rather than competing for
51+
# code-exec's.
52+
max_sandboxes: 8
3953
idle_timeout: 30m
4054
max_age: 4h

docs/security.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ Non-root by default (`1000:1000`), read-only root filesystem, and only `/tmp`,
5454
CPU, memory, scratch disk and PID count are capped per sandbox. Scratch is tmpfs and is
5555
drawn from the memory budget, so a sandbox cannot fill the host's disk by writing files.
5656

57+
Those bound one sandbox. `openbloxd` additionally bounds how many exist at once, per
58+
profile, through `max_sandboxes` — without it a caller looping on create exhausts host
59+
memory while every individual sandbox stays inside its policy. Size a host against
60+
`memory_mb` × `max_sandboxes` **with headroom**: the runtime enforces `memory_mb`
61+
approximately rather than exactly, and roughly 1.4× the configured figure has been
62+
observed resident before the kill lands.
63+
5764
### Bounded lifetime
5865

5966
Every sandbox has an idle timeout and a max age, enforced by a reaper.

internal/daemon/capacity.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package daemon
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"sync"
7+
8+
"github.com/blox-eng/openblox/pkg/brokerapi"
9+
)
10+
11+
// capacity bounds how many sandboxes a profile may hold at once.
12+
//
13+
// A profile bounds what ONE sandbox consumes. Nothing else bounds how many
14+
// exist, and N sandboxes each sitting at their ceiling is N times that ceiling
15+
// of committed host memory — reached without any single request violating a
16+
// policy. Concurrent count is the one resource dimension the profile model does
17+
// not otherwise cover.
18+
//
19+
// The count comes from the backend rather than from a number this process
20+
// keeps, because the backend is the authority: sandboxes survive a daemon
21+
// restart, and a counter that did not would drift into permitting more than the
22+
// cap on exactly the reboot where it mattered. It also means the reaper
23+
// releases capacity for free — an idle sweep, a max_age expiry or an explicit
24+
// Destroy frees a slot with no extra bookkeeping.
25+
type capacity struct {
26+
mu sync.Mutex
27+
// pending counts reservations held by requests that have passed the check
28+
// but whose Create has not returned yet. Without it, several concurrent
29+
// requests all read the same pre-create count and all pass — a retry storm
30+
// is precisely the case the cap exists for, so the check has to be atomic
31+
// with taking the slot.
32+
pending map[string]int
33+
}
34+
35+
// reserve takes a slot for profile, or reports ErrAtCapacity. The returned
36+
// release must be called once the create attempt has finished, whether or not
37+
// it succeeded.
38+
//
39+
// A limit of zero means unlimited, so a deployment that never configures one is
40+
// unchanged. In that case there is nothing to serialise and no backend call to
41+
// make.
42+
func (c *capacity) reserve(ctx context.Context, s *Server, profile string, limit int) (release func(), err error) {
43+
if limit <= 0 {
44+
return func() {}, nil
45+
}
46+
47+
c.mu.Lock()
48+
defer c.mu.Unlock()
49+
50+
live, err := s.countLive(ctx, profile)
51+
if err != nil {
52+
return nil, err
53+
}
54+
if live+c.pending[profile] >= limit {
55+
return nil, fmt.Errorf("%w: profile %q holds %d of %d sandboxes",
56+
brokerapi.ErrAtCapacity, profile, live+c.pending[profile], limit)
57+
}
58+
59+
if c.pending == nil {
60+
c.pending = map[string]int{}
61+
}
62+
c.pending[profile]++
63+
return func() {
64+
c.mu.Lock()
65+
defer c.mu.Unlock()
66+
// Released only after Create has returned, so a sandbox that was
67+
// created is already visible to countLive before it stops being
68+
// counted here. The overlap double-counts for an instant, which errs
69+
// towards refusing one request too many rather than admitting one too
70+
// many.
71+
if c.pending[profile]--; c.pending[profile] <= 0 {
72+
delete(c.pending, profile)
73+
}
74+
}, nil
75+
}
76+
77+
// countLive reports how many sandboxes currently exist under profile.
78+
//
79+
// Stopped sandboxes count. They hold no memory right now, but Backend.Create
80+
// restarts one under the same name, so excluding them would let a caller walk
81+
// past the cap simply by reviving what the reaper has not yet collected.
82+
func (s *Server) countLive(ctx context.Context, profile string) (int, error) {
83+
all, err := s.backend.List(ctx)
84+
if err != nil {
85+
return 0, err
86+
}
87+
n := 0
88+
for _, i := range all {
89+
if i.Labels[labelProfile] == profile {
90+
n++
91+
}
92+
}
93+
return n, nil
94+
}

internal/daemon/capacity_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package daemon
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
"github.com/blox-eng/openblox/pkg/brokerapi"
12+
)
13+
14+
// newCappedServer builds a server whose code-exec profile admits at most limit
15+
// concurrent sandboxes, with `existing` already live under the named profiles.
16+
func newCappedServer(t *testing.T, limit int, existing map[string]string) *Server {
17+
t.Helper()
18+
cfg := &Config{
19+
Socket: "/tmp/unused.sock",
20+
Profiles: map[string]Profile{
21+
"code-exec": {Image: "example.com/i@sha256:abc", Runtime: "runsc", MaxSandboxes: limit},
22+
"browser": {Image: "example.com/b@sha256:def"},
23+
},
24+
}
25+
return New(&fakeBackend{existing: existing}, cfg)
26+
}
27+
28+
func createSandbox(srv *Server, name, profile string) *httptest.ResponseRecorder {
29+
rec := httptest.NewRecorder()
30+
req := httptest.NewRequest(http.MethodPost, "/sandboxes",
31+
strings.NewReader(fmt.Sprintf(`{"name":%q,"profile":%q}`, name, profile)))
32+
srv.Handler().ServeHTTP(rec, req)
33+
return rec
34+
}
35+
36+
func TestLoadRejectsNegativeMaxSandboxes(t *testing.T) {
37+
path := writeConfig(t, `
38+
socket: /tmp/s.sock
39+
profiles:
40+
p:
41+
image: example.com/i@sha256:abc
42+
max_sandboxes: -1
43+
`)
44+
_, err := Load(path)
45+
if err == nil {
46+
t.Fatal("expected an error: a negative cap reads as 'unlimited' but is an operator typo")
47+
}
48+
if !strings.Contains(err.Error(), "max_sandboxes") {
49+
t.Errorf("error %q should name the offending field", err)
50+
}
51+
}
52+
53+
func TestCreateRefusesWhenProfileIsAtCapacity(t *testing.T) {
54+
srv := newCappedServer(t, 2, map[string]string{"a": "code-exec", "b": "code-exec"})
55+
56+
rec := createSandbox(srv, "c", "code-exec")
57+
58+
if rec.Code != http.StatusTooManyRequests {
59+
t.Fatalf("status = %d, want 429, body %s", rec.Code, rec.Body.String())
60+
}
61+
var body struct {
62+
Kind string `json:"kind"`
63+
}
64+
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
65+
t.Fatal(err)
66+
}
67+
if body.Kind != brokerapi.KindAtCapacity {
68+
t.Errorf("kind = %q, want %q — a caller must tell 'try later' from 'malformed'",
69+
body.Kind, brokerapi.KindAtCapacity)
70+
}
71+
}
72+
73+
func TestCreateAtCapacityStillServesAnExistingSandbox(t *testing.T) {
74+
srv := newCappedServer(t, 2, map[string]string{"a": "code-exec", "b": "code-exec"})
75+
76+
// "a" is already counted against the cap, so reusing it consumes no new
77+
// slot. Refusing here would break warm reuse exactly when the host is busy.
78+
rec := createSandbox(srv, "a", "code-exec")
79+
80+
if rec.Code != http.StatusCreated {
81+
t.Fatalf("status = %d, want 201, body %s", rec.Code, rec.Body.String())
82+
}
83+
}
84+
85+
func TestCreateCapacityCountsOnlyTheRequestedProfile(t *testing.T) {
86+
srv := newCappedServer(t, 2, map[string]string{"x": "browser", "y": "browser"})
87+
88+
rec := createSandbox(srv, "a", "code-exec")
89+
90+
if rec.Code != http.StatusCreated {
91+
t.Fatalf("status = %d, want 201 — browser sandboxes must not consume code-exec capacity, body %s",
92+
rec.Code, rec.Body.String())
93+
}
94+
}
95+
96+
func TestCreateIsUnlimitedWhenMaxSandboxesIsUnset(t *testing.T) {
97+
srv := newCappedServer(t, 0, map[string]string{"a": "code-exec", "b": "code-exec", "c": "code-exec"})
98+
99+
rec := createSandbox(srv, "d", "code-exec")
100+
101+
if rec.Code != http.StatusCreated {
102+
t.Fatalf("status = %d, want 201 — unset max_sandboxes means unlimited, body %s",
103+
rec.Code, rec.Body.String())
104+
}
105+
}
106+
107+
// TestConcurrentCreatesCannotExceedCapacity is the point of the feature: a
108+
// retry storm arrives in parallel, and counting live sandboxes without holding
109+
// a reservation would let every request pass the check before any of them had
110+
// created anything.
111+
func TestConcurrentCreatesCannotExceedCapacity(t *testing.T) {
112+
srv := newCappedServer(t, 1, nil)
113+
fake := srv.backend.(*fakeBackend)
114+
115+
start := make(chan struct{})
116+
fake.createBlock = start
117+
118+
const callers = 8
119+
codes := make(chan int, callers)
120+
for i := range callers {
121+
go func() { codes <- createSandbox(srv, fmt.Sprintf("s%d", i), "code-exec").Code }()
122+
}
123+
close(start)
124+
125+
var created, refused int
126+
for range callers {
127+
switch code := <-codes; code {
128+
case http.StatusCreated:
129+
created++
130+
case http.StatusTooManyRequests:
131+
refused++
132+
default:
133+
t.Errorf("unexpected status %d", code)
134+
}
135+
}
136+
if created != 1 {
137+
t.Errorf("created = %d, want 1 — the cap must hold under concurrent creates", created)
138+
}
139+
if refused != callers-1 {
140+
t.Errorf("refused = %d, want %d", refused, callers-1)
141+
}
142+
}

internal/daemon/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type Profile struct {
3333
MemoryMB int64 `yaml:"memory_mb"`
3434
DiskMB int64 `yaml:"disk_mb"`
3535
MaxProcesses int `yaml:"max_processes"`
36+
MaxSandboxes int `yaml:"max_sandboxes"`
3637
IdleTimeout time.Duration `yaml:"idle_timeout"`
3738
MaxAge time.Duration `yaml:"max_age"`
3839
DefaultTimeout time.Duration `yaml:"default_timeout"`
@@ -145,6 +146,12 @@ func (p Profile) validate(name string) error {
145146
if p.MaxProcesses < 0 {
146147
return fmt.Errorf("%w: profile %q has negative max_processes %d", sandbox.ErrInvalid, name, p.MaxProcesses)
147148
}
149+
// Zero means unlimited, which is what an unset cap has always meant. A
150+
// negative value would read the same way while being a typo, and "the cap
151+
// silently did not apply" is the one outcome this setting exists to prevent.
152+
if p.MaxSandboxes < 0 {
153+
return fmt.Errorf("%w: profile %q has negative max_sandboxes %d; omit it for unlimited", sandbox.ErrInvalid, name, p.MaxSandboxes)
154+
}
148155
if p.IdleTimeout < 0 {
149156
return fmt.Errorf("%w: profile %q has negative idle_timeout %s, which silently disables reaping", sandbox.ErrInvalid, name, p.IdleTimeout)
150157
}

internal/daemon/sandboxes.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,33 @@ func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
3333
// be handed back as though it satisfied this request. Backend.Create returns
3434
// the live sandbox when the name exists, so the check has to happen here,
3535
// ahead of that call.
36+
var exists bool
3637
if existing, err := s.backend.Open(r.Context(), req.Name); err == nil {
3738
if got := existing.Info().Labels[labelProfile]; got != req.Profile {
3839
fail(w, fmt.Errorf("%w: %q exists under profile %q, requested %q",
3940
brokerapi.ErrProfileConflict, req.Name, got, req.Profile))
4041
return
4142
}
43+
exists = true
4244
} else if !errors.Is(err, sandbox.ErrNotFound) {
4345
fail(w, err)
4446
return
4547
}
4648

49+
// Only a NEW name consumes a slot. A sandbox that already exists is already
50+
// counted, so re-creating it — the session-affinity path a caller takes to
51+
// reuse its own warm sandbox — must not be refused. Charging it again would
52+
// starve exactly the callers already inside the cap, and only once the host
53+
// was busy enough for the cap to bind.
54+
if !exists {
55+
release, err := s.cap.reserve(r.Context(), s, req.Profile, profile.MaxSandboxes)
56+
if err != nil {
57+
fail(w, err)
58+
return
59+
}
60+
defer release()
61+
}
62+
4763
// Profile options first, caller options second — and the caller's are only
4864
// ever env and labels. Nothing here can reach Runtime or Egress.
4965
opts := profile.Options()

0 commit comments

Comments
 (0)