Skip to content

Commit 14c1f2b

Browse files
committed
feat(shim): add per-pod user namespace injection via annotation
When the runtime opts in (`enable_user_namespace_annotation = true` in `runsc.toml`) and a pod sets `dev.gvisor.spec.user-namespace = "true"` in its `metadata.annotations`, the runsc containerd shim injects a Linux user namespace and a contiguous, non-overlapping uid/gid block into the sandbox container's OCI spec before invoking runsc. Application/exec containers in the same pod inherit the sandbox's user namespace from runsc; only the sandbox spec is modified. Caller-provided mappings (e.g. from kubelet pod.spec.hostUsers: false plumbing) take precedence. Two gates are required so a misconfigured pod cannot unilaterally enable a userns on a runtime that is not provisioned for one: 1. operator opt-in: `enable_user_namespace_annotation = true`. 2. pod opt-in: `dev.gvisor.spec.user-namespace: "true"` annotation. This exists to let runsc workloads run inside a user namespace on Kubernetes nodes whose kubelet+containerd stack does not yet plumb pod.spec.hostUsers (KEP-127) through to runsc. The shim never claims CRI RuntimeFeatures.UserNamespaces, so kubelet's KEP-127 admission is unaffected; this annotation is the per-pod opt-in until the upstream path lands. When that happens, drop the annotation and use `hostUsers: false` on the pod spec instead. Per-sandbox uniqueness is provided by a directory-based allocator under `user_namespace_state_dir` (default `/run/runsc/userns-pool`). os.Mkdir is the synchronization primitive: the kernel guarantees mkdir(2) is atomic, so two shim invocations racing on the same slot resolve correctly. Allocations survive shim restarts and clear on reboot (`/run` is tmpfs). Defaults: range_size=65536 UIDs per sandbox, pool_size=1000 concurrent sandboxes. host_uid_base / host_gid_base must be configured explicitly. Refs: #13303
1 parent d8751e5 commit 14c1f2b

7 files changed

Lines changed: 786 additions & 7 deletions

File tree

g3doc/user_guide/containerd/configuration.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,87 @@ log_level = "debug"
101101
EOF
102102
```
103103

104+
## User Namespace Injection
105+
106+
The shim can inject a Linux user namespace and uid/gid mappings into a
107+
sandbox container's OCI spec when the pod opts in via an annotation, so the
108+
workload runs in a user namespace without the caller having to set one up
109+
explicitly. This is useful on Kubernetes nodes whose runtime stack does not
110+
yet support `pod.spec.hostUsers: false` ([KEP-127][KEP-127]) for runsc; once
111+
that path is plumbed through, drop the annotation and use `hostUsers: false`
112+
instead. See [issue #13303](https://github.com/google/gvisor/issues/13303).
113+
114+
Two gates must both be true for injection to happen:
115+
116+
1. The operator enables the feature in `runsc.toml` with
117+
`enable_user_namespace_annotation = true`.
118+
2. The pod sets `metadata.annotations["dev.gvisor.spec.user-namespace"] =
119+
"true"`. Containerd propagates `dev.gvisor.*` pod annotations to the
120+
sandbox OCI spec via the `pod_annotations` match list.
121+
122+
The operator gate exists so a misconfigured pod cannot unilaterally request
123+
a userns on a runtime that is not provisioned for one.
124+
125+
Application/exec containers within the same pod inherit the sandbox's user
126+
namespace from runsc; only the sandbox container's spec is modified. If the
127+
caller already declared a user namespace or uid/gid mappings (e.g. via
128+
`hostUsers: false`), the shim leaves the spec untouched.
129+
130+
Each opted-in sandbox is assigned a contiguous, non-overlapping block of
131+
host UIDs from a per-node pool. Allocations are persisted under
132+
`user_namespace_state_dir` (default `/run/runsc/userns-pool`) so they
133+
survive shim restarts, and freed when the sandbox is deleted.
134+
135+
Enable it in `runsc.toml`:
136+
137+
```shell
138+
cat <<EOF | sudo tee /etc/containerd/runsc.toml
139+
enable_user_namespace_annotation = true
140+
user_namespace_host_uid_base = 100000
141+
user_namespace_host_gid_base = 100000
142+
# Optional, with defaults shown:
143+
user_namespace_range_size = 65536 # UIDs/GIDs per sandbox
144+
user_namespace_pool_size = 1000 # max concurrent sandboxes
145+
user_namespace_state_dir = "/run/runsc/userns-pool"
146+
EOF
147+
```
148+
149+
Ensure the runtime registration in containerd's config has
150+
`pod_annotations = ["dev.gvisor.*"]` so the opt-in annotation reaches the
151+
shim:
152+
153+
```shell
154+
cat <<EOF | sudo tee /etc/containerd/config.toml
155+
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runsc]
156+
runtime_type = "io.containerd.runsc.v1"
157+
pod_annotations = ["dev.gvisor.*"]
158+
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runsc.options]
159+
TypeUrl = "io.containerd.runsc.v1.options"
160+
ConfigPath = "/etc/containerd/runsc.toml"
161+
EOF
162+
```
163+
164+
A pod opts in by setting the annotation:
165+
166+
```yaml
167+
apiVersion: v1
168+
kind: Pod
169+
metadata:
170+
annotations:
171+
dev.gvisor.spec.user-namespace: "true"
172+
spec:
173+
runtimeClassName: gvisor
174+
containers:
175+
- name: app
176+
image: ...
177+
```
178+
179+
The host UID/GID range used by the pool must not overlap with system or
180+
kubelet-managed UIDs. With the defaults above the pool occupies
181+
`[100000, 100000 + 1000*65536)`; size accordingly for your node.
182+
183+
[KEP-127]: https://github.com/kubernetes/enhancements/issues/127
184+
104185
## NVIDIA Container Runtime
105186

106187
If you want to use

pkg/shim/v1/runsc/container.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import (
4242
"gvisor.dev/gvisor/pkg/shim/v1/proc"
4343
"gvisor.dev/gvisor/pkg/shim/v1/runsccmd"
4444
"gvisor.dev/gvisor/pkg/shim/v1/runtimeoptions"
45+
"gvisor.dev/gvisor/pkg/shim/v1/utils"
4546
)
4647

4748
// CgroupMode is the cgroups mode that is being used by the container.
@@ -69,6 +70,13 @@ type Container struct {
6970

7071
// cgroup is the cgroups mode that is being used by the container.
7172
cgroup CgroupMode
73+
74+
// userNS is the user-namespace allocator config that owns this
75+
// container's UID/GID slot, set when newInit injected a user namespace
76+
// into the spec via UserNamespaceConfig. Nil otherwise (sandbox without
77+
// shim-side userns, or non-sandbox container that inherits its
78+
// sandbox's userns).
79+
userNS *utils.UserNamespaceConfig
7280
}
7381

7482
// NewContainer returns a new runsc container
@@ -197,10 +205,20 @@ func NewContainer(ctx context.Context, platform stdio.Platform, r *task.CreateTa
197205
FSRestoreDirect: FSRestoreDirect,
198206
}
199207

200-
process, err := newInit(filepath.Join(r.Bundle, "work"), ns, platform, config, &opts, st.Rootfs)
208+
process, userNS, err := newInit(filepath.Join(r.Bundle, "work"), ns, platform, config, &opts, st.Rootfs)
201209
if err != nil {
202210
return nil, err
203211
}
212+
// Release the user namespace slot if anything from this point on fails.
213+
// On success cu.Release() below cancels the cleanup.
214+
if userNS != nil {
215+
sandboxID := r.ID
216+
cu.Add(func() {
217+
if err := utils.ReleaseUserNamespaceSlot(userNS, sandboxID); err != nil {
218+
log.L.Warningf("failed to release user namespace slot for %s: %v", sandboxID, err)
219+
}
220+
})
221+
}
204222
if err := process.Create(ctx, config); err != nil {
205223
return nil, err
206224
}
@@ -218,6 +236,7 @@ func NewContainer(ctx context.Context, platform stdio.Platform, r *task.CreateTa
218236
task: process,
219237
cgroup: cgroupMode,
220238
processes: make(map[string]extension.Process),
239+
userNS: userNS,
221240
}
222241
return &c, nil
223242
}
@@ -312,6 +331,14 @@ func (c *Container) Delete(ctx context.Context, r *task.DeleteRequest) (extensio
312331
// When ExecID is empty, it removes the init task in the container.
313332
if r.ExecID != "" {
314333
c.ProcessRemove(r.ExecID)
334+
} else if c.userNS != nil {
335+
// Sandbox init container is being deleted; release its user namespace
336+
// slot. Best-effort: a leaked slot is an operator nuisance, not a
337+
// correctness issue (cleared on reboot since /run is tmpfs), so log
338+
// and continue.
339+
if err := utils.ReleaseUserNamespaceSlot(c.userNS, c.ID); err != nil {
340+
log.L.Warningf("failed to release user namespace slot for %s: %v", c.ID, err)
341+
}
315342
}
316343
return p, nil
317344
}

pkg/shim/v1/runsc/options.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,54 @@ type Options struct {
5151
// EnableHibernateServer indicates if the hibernate server should be started.
5252
EnableHibernateServer bool `toml:"enable_hibernate_server" json:"enableHibernateServer"`
5353

54+
// EnableUserNamespaceAnnotation is the operator-side gate that allows
55+
// pods to opt into shim-side user namespace injection via the pod
56+
// annotation "dev.gvisor.spec.user-namespace": "true" (see
57+
// utils.UserNamespaceRequestAnnotation). When true, sandbox containers
58+
// whose pod annotations contain that key get a user namespace plus
59+
// contiguous, non-overlapping uid/gid mappings injected into their OCI
60+
// spec before runsc is invoked. Application/exec containers within the
61+
// same pod inherit the sandbox's user namespace.
62+
//
63+
// This exists to let runsc workloads run inside a user namespace on
64+
// nodes whose kubelet+containerd stack does not yet plumb pod.spec.
65+
// hostUsers (KEP-127) through to runsc. When that path lands upstream,
66+
// drop the annotation and use hostUsers: false on the pod spec instead.
67+
// See https://github.com/google/gvisor/issues/13303.
68+
//
69+
// The shim respects caller-supplied user namespaces and uid/gid
70+
// mappings: if the OCI spec already declares them (e.g. via
71+
// hostUsers: false), the shim leaves the spec untouched and does not
72+
// allocate a slot.
73+
//
74+
// Pods can only request the userns when this option is true, so a
75+
// misconfigured workload cannot unilaterally enable it.
76+
EnableUserNamespaceAnnotation bool `toml:"enable_user_namespace_annotation" json:"enableUserNamespaceAnnotation"`
77+
78+
// UserNamespaceHostUIDBase is the lowest host UID used by the
79+
// per-node UID pool. Each sandbox that opts in receives a contiguous
80+
// block of UserNamespaceRangeSize UIDs starting at
81+
// UserNamespaceHostUIDBase + slot*UserNamespaceRangeSize.
82+
UserNamespaceHostUIDBase uint32 `toml:"user_namespace_host_uid_base" json:"userNamespaceHostUidBase"`
83+
84+
// UserNamespaceHostGIDBase is the GID equivalent of
85+
// UserNamespaceHostUIDBase.
86+
UserNamespaceHostGIDBase uint32 `toml:"user_namespace_host_gid_base" json:"userNamespaceHostGidBase"`
87+
88+
// UserNamespaceRangeSize is the number of UIDs/GIDs each sandbox
89+
// receives. Defaults to 65536 when the annotation gate is enabled and
90+
// this field is unset.
91+
UserNamespaceRangeSize uint32 `toml:"user_namespace_range_size" json:"userNamespaceRangeSize"`
92+
93+
// UserNamespacePoolSize is the maximum number of concurrent sandboxes
94+
// that can hold non-overlapping UID/GID ranges on this node. Defaults
95+
// to 1000 when the annotation gate is enabled and this field is unset.
96+
UserNamespacePoolSize uint32 `toml:"user_namespace_pool_size" json:"userNamespacePoolSize"`
97+
98+
// UserNamespaceStateDir is the directory used to persist slot
99+
// allocations across shim restarts. Defaults to /run/runsc/userns-pool.
100+
UserNamespaceStateDir string `toml:"user_namespace_state_dir" json:"userNamespaceStateDir"`
101+
54102
// RunscConfig is a key/value map of all runsc flags.
55103
RunscConfig map[string]string `toml:"runsc_config" json:"runscConfig"`
56104
}

pkg/shim/v1/runsc/service.go

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -657,21 +657,65 @@ func getTopic(e any) string {
657657
return runtime.TaskUnknownTopic
658658
}
659659

660-
func newInit(workDir, namespace string, platform stdio.Platform, r *proc.CreateConfig, options *Options, rootfs string) (*proc.Init, error) {
660+
func newInit(workDir, namespace string, platform stdio.Platform, r *proc.CreateConfig, options *Options, rootfs string) (*proc.Init, *utils.UserNamespaceConfig, error) {
661661
spec, err := utils.ReadSpec(r.Bundle)
662662
if err != nil {
663-
return nil, fmt.Errorf("read oci spec: %w", err)
663+
return nil, nil, fmt.Errorf("read oci spec: %w", err)
664664
}
665665

666666
updated, err := utils.UpdateVolumeAnnotations(spec)
667667
if err != nil {
668-
return nil, fmt.Errorf("update volume annotations: %w", err)
668+
return nil, nil, fmt.Errorf("update volume annotations: %w", err)
669669
}
670670
updated = setPodCgroup(spec) || updated
671671

672+
// Shim-side user namespace injection.
673+
//
674+
// Two gates must both be true: the runtime operator opted in via
675+
// enable_user_namespace_annotation, AND the pod's metadata.annotations
676+
// requested it via "dev.gvisor.spec.user-namespace": "true". The
677+
// operator gate exists so a misconfigured workload cannot unilaterally
678+
// enable a userns when the runtime is not configured to support one.
679+
//
680+
// Only applied to sandbox containers; application/exec containers within
681+
// the pod inherit the sandbox's user namespace from runsc. The caller's
682+
// pre-existing user namespace or uid/gid mappings (e.g. from kubelet's
683+
// pod.spec.hostUsers: false plumbing) take precedence: InjectUserNamespace
684+
// returns updated=false in that case and we drop the slot we just claimed.
685+
var userNS *utils.UserNamespaceConfig
686+
if options.EnableUserNamespaceAnnotation && utils.IsSandbox(spec) && utils.HasUserNamespaceRequest(spec) {
687+
userNS = &utils.UserNamespaceConfig{
688+
HostUIDBase: options.UserNamespaceHostUIDBase,
689+
HostGIDBase: options.UserNamespaceHostGIDBase,
690+
RangeSize: options.UserNamespaceRangeSize,
691+
PoolSize: options.UserNamespacePoolSize,
692+
StateDir: options.UserNamespaceStateDir,
693+
}
694+
slot, err := utils.AllocateUserNamespaceSlot(userNS, r.ID)
695+
if err != nil {
696+
return nil, nil, fmt.Errorf("allocate user namespace slot: %w", err)
697+
}
698+
injected, err := utils.InjectUserNamespace(spec, userNS, slot)
699+
if err != nil {
700+
_ = utils.ReleaseUserNamespaceSlot(userNS, r.ID)
701+
return nil, nil, fmt.Errorf("inject user namespace: %w", err)
702+
}
703+
if injected {
704+
updated = true
705+
} else {
706+
// Caller already configured a user namespace; release the slot
707+
// we claimed and let the caller's spec stand.
708+
_ = utils.ReleaseUserNamespaceSlot(userNS, r.ID)
709+
userNS = nil
710+
}
711+
}
712+
672713
if updated {
673714
if err := utils.WriteSpec(r.Bundle, spec); err != nil {
674-
return nil, err
715+
if userNS != nil {
716+
_ = utils.ReleaseUserNamespaceSlot(userNS, r.ID)
717+
}
718+
return nil, nil, err
675719
}
676720
}
677721

@@ -691,7 +735,7 @@ func newInit(workDir, namespace string, platform stdio.Platform, r *proc.CreateC
691735
p.Sandbox = specutils.SpecContainerType(spec) == specutils.ContainerTypeSandbox
692736
p.UserLog = utils.UserLogPath(spec)
693737
p.Monitor = reaper.Default
694-
return p, nil
738+
return p, userNS, nil
695739
}
696740

697741
// setPodCgroup searches for the pod cgroup path inside the container's cgroup

pkg/shim/v1/utils/BUILD

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ go_library(
99
name = "utils",
1010
srcs = [
1111
"annotations.go",
12+
"userns.go",
1213
"utils.go",
1314
"volumes.go",
1415
],
@@ -25,7 +26,10 @@ go_library(
2526
go_test(
2627
name = "utils_test",
2728
size = "small",
28-
srcs = ["volumes_test.go"],
29+
srcs = [
30+
"userns_test.go",
31+
"volumes_test.go",
32+
],
2933
library = ":utils",
3034
deps = [
3135
"@com_github_mohae_deepcopy//:go_default_library",

0 commit comments

Comments
 (0)