-
Notifications
You must be signed in to change notification settings - Fork 155
ateom gvisor: drop privileged: true
#496
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f8476da
520dccb
72ce559
c820281
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ import ( | |
| "os" | ||
| "runtime" | ||
| "sort" | ||
| "strings" | ||
| "sync" | ||
|
|
||
| "cloud.google.com/go/compute/metadata" | ||
|
|
@@ -109,6 +110,12 @@ func do(ctx context.Context) error { | |
| return fmt.Errorf("in os.MkdirAll(%q): %w", ateomDir, err) | ||
| } | ||
|
|
||
| // Prepare the pod cgroup so runsc can create per-actor-container leaves under | ||
| // it with real accounting, instead of ignoring cgroups entirely. | ||
| if err := setupCgroupDelegation(ctx); err != nil { | ||
| return fmt.Errorf("while setting up cgroup delegation: %w", err) | ||
| } | ||
|
|
||
| // TODO: Consider whether we want to fork, so that we have an "init" process | ||
| // as PID 1 that does nothing but reap processes that get reparented to it. | ||
| // Then we won't have to mess about with locking the reaper while we do our | ||
|
|
@@ -663,12 +670,150 @@ func enableIPv4Forwarding() error { | |
| // the host-side veth and then leave through the pod's eth0. Without this, the | ||
| // kernel would not route traffic between those interfaces even though both | ||
| // live in the worker pod network namespace. | ||
| if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1\n"), 0o644); err != nil { | ||
| // | ||
| // Without privileged, the container runtime bind-mounts /proc/sys read-only. | ||
| // The worker holds CAP_SYS_ADMIN and uses no user namespace, so the ro flag | ||
| // is not locked: clear it, write the sysctl, restore ro. | ||
| const path = "/proc/sys/net/ipv4/ip_forward" | ||
| if b, err := os.ReadFile(path); err == nil && len(b) > 0 && b[0] == '1' { | ||
| return nil | ||
| } | ||
| if err := os.WriteFile(path, []byte("1\n"), 0o644); err == nil { | ||
| return nil | ||
| } | ||
| if err := unix.Mount("none", "/proc/sys", "", unix.MS_BIND|unix.MS_REMOUNT, ""); err != nil { | ||
| return fmt.Errorf("while remounting /proc/sys read-write to enable IPv4 forwarding: %w", err) | ||
| } | ||
| defer func() { | ||
| _ = unix.Mount("none", "/proc/sys", "", unix.MS_BIND|unix.MS_REMOUNT|unix.MS_RDONLY, "") | ||
| }() | ||
| if err := os.WriteFile(path, []byte("1\n"), 0o644); err != nil { | ||
| return fmt.Errorf("while enabling IPv4 forwarding in worker pod netns: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // setupCgroupDelegation prepares the worker pod's cgroup so runsc can create a | ||
| // per-actor-container leaf under it with real cpu/memory/pids accounting. | ||
| // | ||
| // The unprivileged worker runs in a private cgroup namespace, so /sys/fs/cgroup | ||
| // is the pod's own cgroup scope rather than the host root. Two things must be | ||
| // arranged before runsc can nest container cgroups here: | ||
| // | ||
| // - The cgroup v2 "no internal processes" rule forbids a cgroup from holding | ||
| // processes directly while also delegating controllers to children. The pod | ||
| // scope is not the true cgroup root, so the exemption does not apply: we move | ||
| // the worker's own processes into a dedicated "ateom" leaf. | ||
| // - Controllers are only available to children if enabled in the scope's | ||
| // cgroup.subtree_control. We enable everything the parent delegated to us. | ||
| // | ||
| // The runtime bind-mounts /sys/fs/cgroup read-only for unprivileged pods. The | ||
| // worker holds CAP_SYS_ADMIN with no user namespace, so the ro flag is not | ||
| // locked: clear it and leave it writable (runsc writes here on every | ||
| // create/restore). | ||
| func setupCgroupDelegation(ctx context.Context) error { | ||
| const root = "/sys/fs/cgroup" | ||
| const leaf = root + "/ateom" | ||
|
|
||
| // Delegation only makes sense inside a private cgroup namespace, where | ||
| // /sys/fs/cgroup is the pod's own scope. A privileged worker instead inherits | ||
| // the host cgroup namespace, so /sys/fs/cgroup is the true host root: it holds | ||
| // unmovable kernel threads (cgroup.procs would never drain) and must not be | ||
| // carved up. Detect the namespace via /proc/self/cgroup, which reads "0::/" | ||
| // only at a cgroup-namespace root, and skip delegation otherwise (runsc then | ||
| // falls back to its own cgroup handling). | ||
| if private, err := inPrivateCgroupNamespace(); err != nil { | ||
| return fmt.Errorf("while detecting cgroup namespace: %w", err) | ||
| } else if !private { | ||
| slog.InfoContext(ctx, "not in a private cgroup namespace; skipping cgroup delegation (worker is likely privileged)") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When do we expect worker to be privileged when using gvisor sandbox class?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a skew guard. Though technically we could also hit this on say, a cgroup v1 host. Also very technically: cgroupv2 unprivileged container == private cgroupns is more of a convention across docker/podman/containerd/cri-o. You could also explicitly opt into into it in cgroupv1, but there was some sort of agreement to make it default with the migration to v2. But they're not actually linked from the kernel perspective, only major container runtimes. |
||
| return nil | ||
| } | ||
|
|
||
| if err := os.Mkdir(leaf, 0o755); err != nil && !os.IsExist(err) { | ||
| // The runtime bind-mounts /sys/fs/cgroup read-only; clear the flag with a | ||
| // bind-remount. This needs CAP_SYS_ADMIN (held) and an AppArmor profile | ||
| // that permits mount. The gVisor worker runs AppArmor-unconfined, which | ||
| // runsc's own mounts require anyway; on nodes that do enforce the default | ||
| // profile (GKE COS) this mount is otherwise denied with EPERM. | ||
| if err := unix.Mount("none", root, "", unix.MS_BIND|unix.MS_REMOUNT, ""); err != nil { | ||
| return fmt.Errorf("while remounting %q read-write: %w", root, err) | ||
| } | ||
| if err := os.Mkdir(leaf, 0o755); err != nil && !os.IsExist(err) { | ||
| return fmt.Errorf("while creating cgroup leaf %q: %w", leaf, err) | ||
| } | ||
| } | ||
|
|
||
| if err := moveProcs(ctx, root+"/cgroup.procs", leaf+"/cgroup.procs"); err != nil { | ||
| return fmt.Errorf("while moving worker processes into %q: %w", leaf, err) | ||
| } | ||
|
|
||
| avail, err := os.ReadFile(root + "/cgroup.controllers") | ||
| if err != nil { | ||
| return fmt.Errorf("while reading available cgroup controllers: %w", err) | ||
| } | ||
| // Enable controllers one at a time so a single controller the node cannot | ||
| // delegate (for example cpuset without an assigned cpu set) does not prevent | ||
| // the others from being enabled. | ||
| var enabled []string | ||
| for _, c := range strings.Fields(string(avail)) { | ||
| if err := os.WriteFile(root+"/cgroup.subtree_control", []byte("+"+c), 0o644); err != nil { | ||
| slog.WarnContext(ctx, "could not enable cgroup controller for delegation", slog.String("controller", c), slog.Any("err", err)) | ||
| continue | ||
| } | ||
| enabled = append(enabled, c) | ||
| } | ||
| slog.InfoContext(ctx, "cgroup delegation ready", slog.Any("controllers", enabled)) | ||
| return nil | ||
| } | ||
|
|
||
| // inPrivateCgroupNamespace reports whether the process sits at the root of its | ||
| // own cgroup namespace. The cgroup v2 line of /proc/self/cgroup ("0::<path>") | ||
| // reports the path relative to the namespace root, so it reads exactly "/" only | ||
| // when /sys/fs/cgroup is the namespace's own (pod-scoped) cgroup. A privileged | ||
| // worker inheriting the host cgroup namespace instead sees its full host path | ||
| // (for example "/kubepods.slice/.../cri-containerd-<id>.scope"). | ||
| func inPrivateCgroupNamespace() (bool, error) { | ||
| b, err := os.ReadFile("/proc/self/cgroup") | ||
| if err != nil { | ||
| return false, fmt.Errorf("while reading /proc/self/cgroup: %w", err) | ||
| } | ||
| for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { | ||
| if path, ok := strings.CutPrefix(line, "0::"); ok { | ||
| return path == "/", nil | ||
| } | ||
| } | ||
| return false, fmt.Errorf("no cgroup v2 (0::) entry in /proc/self/cgroup") | ||
| } | ||
|
|
||
| // moveProcs relocates every process listed in srcProcs into dstProcs. cgroup.procs | ||
| // only ever lists processes that are not already in a child cgroup, and the list | ||
| // shrinks as we drain it, so loop until the source is empty. | ||
| func moveProcs(ctx context.Context, srcProcs, dstProcs string) error { | ||
| // One pass moves everything it saw, but a process can fork between the read | ||
| // and the writes, so re-read until the source reads empty. 100 is an | ||
| // arbitrary generous bound (one or two passes suffice in practice) so a | ||
| // process that can never be moved fails startup with a clear error instead | ||
| // of looping forever. | ||
| for range 100 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: could you leave a comment to explain where the number "100" comes from?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a somewhat arbitrary upper bound on attempts. Added a code comment. |
||
| b, err := os.ReadFile(srcProcs) | ||
| if err != nil { | ||
| return fmt.Errorf("while reading %q: %w", srcProcs, err) | ||
| } | ||
| pids := strings.Fields(string(b)) | ||
| if len(pids) == 0 { | ||
| return nil | ||
| } | ||
| for _, pid := range pids { | ||
| // Writing a TGID moves the whole thread group. A process can exit | ||
| // between the read and the write, so a failure here is not fatal. | ||
| if err := os.WriteFile(dstProcs, []byte(pid), 0o644); err != nil { | ||
| slog.WarnContext(ctx, "could not move process into cgroup leaf", slog.String("pid", pid), slog.Any("err", err)) | ||
| } | ||
| } | ||
| } | ||
| return fmt.Errorf("%q did not drain after 100 iterations", srcProcs) | ||
| } | ||
|
|
||
| func installActorNftablesRules(podIP net.IP) error { | ||
| // Install a dedicated nftables table for the active actor. Keeping all | ||
| // rules in an ateom-owned table makes cleanup simple and avoids mutating | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this mean the "shape" of cgroup hierarchy will look different depending on microvm vs. givsor?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, though that's only mildly relevant, since the actual actor cgroups are inside the microVM guest environment anyhow. It's a different kernel etc.
I'm also looking at patching uVM for this, for overlapping reasons (the device passthrough angle), but it's tricky.