Summary
On a host that previously ran a large number of sandboxes and was then drained to 0 sandboxes, a lot of ns-* network namespaces can remain in /run/netns (leaked slots from failed/partial teardown). When we upgrade/restart the orchestrator on such a host, the gRPC service (GRPC_PORT) does not bind for 20–30 minutes. During that window the port is closed → Nomad health checks get connection-refused → the node looks dead / restart-flaps.
Environment
- Deployment: self-hosted, Nomad
type = "system" job, GRPC_PORT bound as a static port
- Orchestrator running the sandbox runtime (
ORCHESTRATOR_SERVICES includes orchestrator)
- Host state before upgrade: 0 running sandboxes, but a large number of residual
ns-* entries in /run/netns
Root cause (from reading the code)
The startup path is strictly serial, and the whole reclaim chain runs before the listener is bound:
acquireOrchestratorLock (flock) — packages/orchestrator/pkg/factories/run.go:398
startupreclaim.Run(...) — run.go:745
- →
network.ReclaimLeakedSlots(netnsDir, ...) — packages/orchestrator/pkg/sandbox/network/reclaim.go:15
network.NewStorageLocal(...) (must run after reclaim so leftover ns-* aren't snapshotted as foreign) — run.go:767, see note at run.go:736-738
- network pool
Populate(...)
- … only later:
cmux binds GRPC_PORT — run.go:974
The bottleneck is step 3. ReclaimLeakedSlots iterates leaked slots one by one and calls slot.RemoveNetwork() for each:
// packages/orchestrator/pkg/sandbox/network/reclaim.go
for _, idx := range slots {
slot, err := NewSlot(fmt.Sprintf("startup-reclaim-%d", idx), idx, config, egressProxy)
...
if err := slot.RemoveNetwork(); err != nil { ... } // netns + netlink + iptables/nftables teardown per slot
}
With thousands of residual namespaces, this serial per-namespace teardown takes tens of minutes, and because it sits ahead of cmux.Serve(), GRPC_PORT stays unbound the entire time.
Impact
- Node is unreachable on
GRPC_PORT for 20–30 min after every upgrade/restart on a "dirty" host.
- With Nomad
system + static port, health checks fail with connection-refused (not 503), so it reads as failed rather than starting → restart churn.
- The more sandboxes the host handled historically, the worse the leak, the longer the outage.
Proposed solutions (in rough priority order)
Solution A — Bind the port + serve /health early, run reclaim/populate in the background
Decouple "port is listening + health endpoint answering" from "sandbox runtime fully initialized":
- Keep
acquireOrchestratorLock first and blocking (single-instance guard; reclaim mutates host-level netns/iptables and must be exclusive).
- Bind
cmux and start the HTTP /health server immediately, reporting not healthy (503) while initializing.
- Run reclaim →
NewStorageLocal → pool populate → server.New(...) → gRPC RegisterService in a background goroutine, then call grpcServer.Serve(grpcListener) and flip status to Healthy.
Two required correctness points:
packages/orchestrator/pkg/service/info.go:94 currently initializes status to Healthy. It must start as a non-healthy state (e.g. Starting/Standby), otherwise the early /health returns 200 and Nomad routes gRPC traffic before services are registered.
- gRPC forbids
RegisterService after Serve(); the Serve(grpcListener) call must happen only after all Register* complete (early gRPC connections buffer in the cmux matcher until then).
Effect: Nomad sees an open port returning 503 → treats the node as starting, not failed → no restart flap; the long reclaim no longer blocks bind.
Solution B — Parallelize ReclaimLeakedSlots
The per-slot teardowns are independent. Bounding the loop with a worker pool (e.g. errgroup with a concurrency limit) would cut the reclaim wall-time roughly linearly. This helps even without Solution A. Note: the intra-chain ordering reclaim → NewStorageLocal must be preserved; only the loop inside reclaim is parallelized.
Solution C — Make startup reclaim bounded / deferrable
- A time or count budget for startup reclaim, deferring the remainder to a background reconciler after the node is already serving.
- Or an opt-in async-reclaim mode (
DisableStartupReclaim already exists as a related knob).
Additional questions for maintainers
- Is such a large
ns-* leak on drain expected, or does it point to a teardown path that should have cleaned these during normal operation? Fixing the leak source would reduce reclaim load in the first place.
- Would you accept Solution A (early-bind + background init) as the primary fix, with Solution B as a complementary speedup?
I'm happy to open a PR for Solution A and/or Solution B if the direction is agreeable.
Summary
On a host that previously ran a large number of sandboxes and was then drained to 0 sandboxes, a lot of
ns-*network namespaces can remain in/run/netns(leaked slots from failed/partial teardown). When we upgrade/restart the orchestrator on such a host, the gRPC service (GRPC_PORT) does not bind for 20–30 minutes. During that window the port is closed → Nomad health checks get connection-refused → the node looks dead / restart-flaps.Environment
type = "system"job,GRPC_PORTbound as a static portORCHESTRATOR_SERVICESincludesorchestrator)ns-*entries in/run/netnsRoot cause (from reading the code)
The startup path is strictly serial, and the whole reclaim chain runs before the listener is bound:
acquireOrchestratorLock(flock) —packages/orchestrator/pkg/factories/run.go:398startupreclaim.Run(...)—run.go:745network.ReclaimLeakedSlots(netnsDir, ...)—packages/orchestrator/pkg/sandbox/network/reclaim.go:15network.NewStorageLocal(...)(must run after reclaim so leftoverns-*aren't snapshotted asforeign) —run.go:767, see note atrun.go:736-738Populate(...)cmuxbindsGRPC_PORT—run.go:974The bottleneck is step 3.
ReclaimLeakedSlotsiterates leaked slots one by one and callsslot.RemoveNetwork()for each:With thousands of residual namespaces, this serial per-namespace teardown takes tens of minutes, and because it sits ahead of
cmux.Serve(),GRPC_PORTstays unbound the entire time.Impact
GRPC_PORTfor 20–30 min after every upgrade/restart on a "dirty" host.system+ static port, health checks fail with connection-refused (not 503), so it reads as failed rather than starting → restart churn.Proposed solutions (in rough priority order)
Solution A — Bind the port + serve
/healthearly, run reclaim/populate in the backgroundDecouple "port is listening + health endpoint answering" from "sandbox runtime fully initialized":
acquireOrchestratorLockfirst and blocking (single-instance guard; reclaim mutates host-level netns/iptables and must be exclusive).cmuxand start the HTTP/healthserver immediately, reporting not healthy (503) while initializing.NewStorageLocal→ pool populate →server.New(...)→ gRPCRegisterServicein a background goroutine, then callgrpcServer.Serve(grpcListener)and flip status toHealthy.Two required correctness points:
packages/orchestrator/pkg/service/info.go:94currently initializes status toHealthy. It must start as a non-healthy state (e.g.Starting/Standby), otherwise the early/healthreturns 200 and Nomad routes gRPC traffic before services are registered.RegisterServiceafterServe(); theServe(grpcListener)call must happen only after allRegister*complete (early gRPC connections buffer in the cmux matcher until then).Effect: Nomad sees an open port returning 503 → treats the node as starting, not failed → no restart flap; the long reclaim no longer blocks bind.
Solution B — Parallelize
ReclaimLeakedSlotsThe per-slot teardowns are independent. Bounding the loop with a worker pool (e.g.
errgroupwith a concurrency limit) would cut the reclaim wall-time roughly linearly. This helps even without Solution A. Note: the intra-chain orderingreclaim → NewStorageLocalmust be preserved; only the loop inside reclaim is parallelized.Solution C — Make startup reclaim bounded / deferrable
DisableStartupReclaimalready exists as a related knob).Additional questions for maintainers
ns-*leak on drain expected, or does it point to a teardown path that should have cleaned these during normal operation? Fixing the leak source would reduce reclaim load in the first place.I'm happy to open a PR for Solution A and/or Solution B if the direction is agreeable.