Skip to content

runsc: update sentry CPU count on live cgroup CPU changes - #14277

Open
mayur-tolexo wants to merge 4 commits into
google:masterfrom
mayur-tolexo:cpu-live-resize
Open

runsc: update sentry CPU count on live cgroup CPU changes#14277
mayur-tolexo wants to merge 4 commits into
google:masterfrom
mayur-tolexo:cpu-live-resize

Conversation

@mayur-tolexo

@mayur-tolexo mayur-tolexo commented Aug 22, 2026

Copy link
Copy Markdown

When a sandbox's CPU quota changes without a restart (a Kubernetes in-place pod
resize, say), ApplicationCores stays at its boot-time value, so anything sizing
parallelism to nproc — GOMAXPROCS, make -j, the JVM — can't use the added CPU
without recreating the sandbox. runsc update already writes the host cgroup
(#12790); nothing told the sentry. Guest-visibility half of #14141.

Adds Kernel.SetApplicationCores, wired through the existing runsc update
path, so ApplicationCores and GOMAXPROCS follow the live quota. Grow-only:
shrinking would leave existing tasks' AllowedCPUMask pointing at CPUs that no
longer exist, and is rejected. Also rejected on platforms whose vCPU pool is
fixed at boot (HasCPUNumbers()); those need hot-add, which is separate work.

The count is taken from the update request rather than re-read from the cgroup.
Container.Update writes the cgroup only for the root container, so re-reading
it made runsc update --cpu-quota on a subcontainer a silent no-op. The cgroup
still supplies the cpuset ceiling.

This also exposed a sched_setaffinity crash: the syscall handler sizes its mask
from one ApplicationCores() read and SetCPUMask re-reads it, and CPUSetSize
rounds to 64-CPU boundaries, so a grow crossing one between the two reads could
panic the sentry from an unprivileged guest syscall. SetCPUMask now
zero-extends a stale, smaller mask.

kind, systrap, cgroup v2 — pod booted at cpu=1, resized in place to cpu=8:

$ kubectl exec gv-resize-test -- nproc
2
$ kubectl patch pod gv-resize-test --subresource resize --patch \
    '{"spec":{"containers":[{"name":"c","resources":{"limits":{"cpu":"8","memory":"128Mi"}}}]}}'
$ kubectl exec gv-resize-test -- nproc
8
$ kubectl get pod gv-resize-test -o jsonpath='{.status.containerStatuses[0].restartCount}'
0

I0822 03:09:37.398523 kernel.go:1962] ApplicationCores changed from 2 to 8
D0822 03:09:37.398545 gomaxprocs.go:76] Setting GOMAXPROCS to 8

An 8-thread busy loop then used all 8 cores (pod cgroup cpu.stat over 5s:
delta_usec=40010083, effective_cores=8.00) instead of staying capped at 2. A
shrink is refused without disrupting the sandbox. The affinity fix has two unit
tests; Kernel.StateFields() diffs clean against master, with applicationCores
still encoded as a uint via saveApplicationCores/loadApplicationCores — it has to
survive restore, so state:"nosave" was not an option.

Two notes for anyone reproducing. Kubernetes works with or without the
request-vs-cgroup change, because the kubelet writes the pod cgroup itself; the
last commit's message says otherwise and I can't amend it (fork ruleset).
/proc/cpuinfo and /sys/devices/system/cpu/online are generated once at boot
and never move — read the per-cpu lines in /proc/stat or sched_getaffinity.

When a sandbox's CPU cgroup quota changes without a restart (e.g. a
Kubernetes in-place pod resize), the sentry's ApplicationCores stayed
frozen at the boot-time value, so anything that sizes its parallelism
to nproc (GOMAXPROCS, make -j, the JVM, nginx worker_processes auto)
couldn't use the added CPU without recreating the sandbox.

This adds Kernel.SetApplicationCores, wired through the existing runsc
update path (already invoked by containerd on in-place resize), so
ApplicationCores and GOMAXPROCS track the live cgroup quota. It's
grow-only: shrinking is rejected, since it would leave already-created
tasks' AllowedCPUMask referencing now-invalid CPUs. It's also rejected
on platforms with a fixed vCPU pool sized at boot (HasCPUNumbers(),
e.g. KVM).

Tested on a kind cluster (systrap platform, cgroup v2): boot a pod at
cpu=1, resize in place to cpu=8, no restart.

    $ kubectl exec gv-resize-test -- nproc
    2
    $ kubectl patch pod gv-resize-test --subresource resize --patch \
        '{"spec":{"containers":[{"name":"c","resources":{"requests":{"cpu":"8","memory":"128Mi"},"limits":{"cpu":"8","memory":"128Mi"}}}]}}'
    $ kubectl exec gv-resize-test -- nproc
    8
    $ kubectl get pod gv-resize-test -o jsonpath='{.status.containerStatuses[0].restartCount}'
    0

Sentry log:
    I0822 03:09:37.398523 kernel.go:1962] ApplicationCores changed from 2 to 8
    D0822 03:09:37.398545 gomaxprocs.go:76] Setting GOMAXPROCS to 8

An 8-thread busy-loop workload, measured via the pod's cgroup cpu.stat
over a 5s window after the resize, actually used all 8 cores rather
than staying capped at the boot-time 2:
    delta_usec=40010083
    effective_cores=8.00

A shrink is rejected without disrupting the sandbox:
    W0822 03:10:20.658484 urpc.go:368] urpc: RPC call for method
    containerManager.SetCPUCount failed: SetApplicationCores(2) would
    shrink ApplicationCores from 8: not yet supported
    $ kubectl get pod gv-resize-test
    NAME             READY   STATUS    RESTARTS   AGE
    gv-resize-test   1/1     Running   0          54s
Making ApplicationCores mutable at runtime (previous commit) exposed an
existing sched_setaffinity/SetCPUMask race: the syscall handler sizes
its mask from one ApplicationCores() read, then SetCPUMask re-reads it
and panicked on any size mismatch. CPUSetSize rounds to 64-CPU
boundaries, so a live grow crossing one between those two reads could
panic the whole sentry from an unprivileged guest syscall. SetCPUMask
now zero-extends a stale, smaller mask instead of panicking; a mask
larger than currently allowed is still a caller bug and still panics.

Covered by TestSetCPUMaskToleratesGrowthAcrossSizeBoundary and
TestSetCPUMaskRejectsOversizedMask, which reproduce the exact 32-to-96
core crossing deterministically rather than relying on real timing.

    //pkg/sentry/kernel:kernel_test  PASSED in 0.0s
    Executed 1 out of 1 test: 1 test passes.
Making applicationCores an atomicbitops.Uint32 changed how it lands in
the statefile: atomicbitops.Uint32 is itself a savable struct, so the
field encoded as a nested struct where it used to be a plain uint.
That is a gratuitous statefile format break — a checkpoint written
before this series could no longer be read after it, for a field whose
saved value is just an integer.

Save it through saveApplicationCores/loadApplicationCores so the wire
type stays uint while the in-memory field remains atomic, matching how
Task's atomic.Pointer fields are already handled. Kernel.StateFields()
and the slot-5 encoding are then byte-identical to before the series.

Not state:"nosave" — the value must survive save/restore. Applications
size per-cpu structures from it, so a restore has to keep the
checkpointed count rather than adopt the new boot's --cpu-num.

    $ diff schema_before.txt schema_after.txt   # Kernel.StateFields()
    (no output)

    # before: Save(5, &k.applicationCores)      // field was uint
    # after:  SaveValue(5, applicationCoresValue)
    #         LoadValue(5, new(uint), func(y any) { ... })

    //pkg/sentry/kernel:kernel_test   PASSED in 0.0s
    //runsc/sandbox:sandbox_test      PASSED in 0.0s
    Executed 2 out of 2 tests: 2 tests pass.
SetCPUCount re-read the CPU quota from the sandbox cgroup. Under
Kubernetes pod-level resources the kubelet writes the quota to the pod
cgroup and leaves the sandbox's own at max, so the re-read found no
quota, fell back to the cpuset, and the count never moved -- the update
was silently dropped for exactly the case it exists to serve.

The quota is already in hand: containerd passes it in the update
request. Prefer it over the cgroup's, keeping the cgroup for the cpuset
ceiling, which a quota change does not move. When the request carries no
usable quota the cgroup decides as before, so a memory-only update is
not read as "quota removed".

Boot behaviour is unchanged. The quota-to-count arithmetic moves into
cpuNumFromQuota, shared by boot and update so the two cannot derive
different counts from the same quota.

Verified on a kind cluster whose cgroup layout matches the failing case
(pod cgroup 50000/100000, sandbox cgroup max). Against one running
sandbox, "runsc update --cpu-quota=400000":

  before the fix   nproc stays 2
  after the fix    nproc 2 -> 4, then 8 at --cpu-quota=800000

Note /proc/cpuinfo cannot show this: it is a static file generated once
at boot, so sched_getaffinity is the observable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant