cmd: add containerd-sandbox-init

A minimal PID 1 for the PID namespace of a pod that shares it, run by
containerd-shim-sandboxed-runc-v2 in place of a pause container. It
stays alive so that the namespace does, ignores SIGCHLD so that the
kernel reaps the orphans reparented to it, and removes every signal
handler so that nothing sent from inside the pod can end it or run code
in it: the kernel drops a signal sent to the init of a PID namespace
when init has no handler for it. The Go runtime installs handlers for
almost every signal, including the synchronous faults, SIGPROF and the
signals 32 and 33 that os/signal cannot reset, so those go back to the
default disposition, or are ignored for the others, through
rt_sigaction. Only SIGKILL from the
parent namespace ends it. It makes itself non-dumpable, so that the
other processes of the pod can neither ptrace it nor open its /proc
entries without CAP_SYS_PTRACE. It runs with one P, no garbage
collector and parks in pause(2).

It is built static like the shims and installed with them.

Signed-off-by: Maksym Pavlenko <pavlenko.maksym@gmail.com>
This commit is contained in:
Maksym Pavlenko committed 2026-11-04 19:04:42 -08:00
1 parent d113e117d8
commit 16d7ff4f1a
4 files changed
+303

No files matched your search

+4
View File
@@ -276,6 +276,10 @@ bin/containerd-shim-sandboxed-runc-v2: cmd/containerd-shim-sandboxed-runc-v2 FOR
@echo "$(WHALE) $@"
CGO_ENABLED=${SHIM_CGO_ENABLED} $(GO) build ${GO_BUILD_FLAGS} -o $@ ${SHIM_GO_LDFLAGS} ${SHIM_GO_TAGS} ./cmd/containerd-shim-sandboxed-runc-v2
bin/containerd-sandbox-init: cmd/containerd-sandbox-init FORCE # PID 1 of a shared pod PID namespace for the sandboxed shim, static like the shims
@echo "$(WHALE) $@"
CGO_ENABLED=${SHIM_CGO_ENABLED} $(GO) build ${GO_BUILD_FLAGS} -o $@ ${SHIM_GO_LDFLAGS} ${SHIM_GO_TAGS} ./cmd/containerd-sandbox-init
binaries: $(BINARIES) ## build binaries
@echo "$(WHALE) $@"
+1
View File
@@ -18,6 +18,7 @@ WHALE="+"
ONI="-"
COMMANDS += containerd-shim-runc-v2
COMMANDS += containerd-shim-sandboxed-runc-v2
COMMANDS += containerd-sandbox-init
# check GOOS for cross compile builds
ifeq ($(GOOS),linux)
+134
View File
@@ -0,0 +1,134 @@
//go:build linux
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// containerd-sandbox-init is PID 1 of the PID namespace of a pod that shares
// it (shareProcessNamespace). containerd-shim-sandboxed-runc-v2, the
// pause-less sandbox shim, starts it through runc for those pods only, and it
// runs as a child of the shim. An ordinary pod, one that does not share its
// PID namespace, has no sandbox process at all.
//
// It does what PID 1 of a pod has to do and nothing else: it stays alive so
// that the namespace does, it lets the kernel reap the orphans that get
// reparented to it, and it cannot be ended from inside the pod.
//
// The kernel delivers a signal to the init of a PID namespace, from inside
// that namespace, only when init has a handler installed for it, and discards
// it otherwise. SIGKILL and SIGSTOP reach it from the parent namespace only,
// and SIGKILL from the shim is how it ends. The Go runtime installs handlers
// for almost every signal and ends the process on most of them. A Go init
// that kept those handlers could be killed from a member container with
// kill -HUP 1 or kill -SEGV 1, so disableSignals removes every one of them.
//
// It is also not dumpable, so that the other processes of the pod cannot
// inspect or control it: without CAP_SYS_PTRACE they can neither ptrace it
// nor open /proc/1/root, /proc/1/mem and the like.
package main
import (
"os"
"os/signal"
"runtime"
"runtime/debug"
"syscall"
"unsafe"
"golang.org/x/sys/unix"
)
func main() {
run()
}
// run sets the process up and parks it; it never returns.
func run() {
// runc makes the process non-dumpable, but execve made it dumpable
// again, since it changed no credentials.
if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil {
os.Exit(1)
}
// A single scheduler thread (GOMAXPROCS) and no garbage collector:
// there is nothing to run and nothing to collect. Like pause, the
// process then sleeps until it is killed.
runtime.GOMAXPROCS(1)
debug.SetGCPercent(-1)
if err := disableSignals(); err != nil {
os.Exit(1)
}
debug.FreeOSMemory()
for {
// A blocking system call rather than select {}: with no other
// goroutine the runtime would report a deadlock.
unix.Pause()
}
}
// disableSignals leaves no signal handler in place, so that no signal sent
// from inside the pod runs any code in the process. SIGCHLD is ignored rather
// than defaulted so that the kernel reaps the children reparented to PID 1 on
// its own, with no wait loop. Ignoring SIGURG gives up asynchronous
// preemption, which a process whose only goroutine waits in pause(2) does not
// need. os/signal cannot touch the synchronous faults, for which the runtime
// installs handlers that exit the process when the signal came from kill(2),
// nor SIGPROF, whose handler it keeps for profiling, nor signals 32 and 33,
// which the C library reserves and the runtime keeps for itself (33 for
// system calls that must run on every thread, which this process never
// makes): the faults go back to the kernel default and the others are
// ignored, through rt_sigaction.
func disableSignals() error {
// Signals 32 and 33, reserved by the C library and kept by the Go runtime.
const (
sigRuntime1 = syscall.Signal(32)
sigRuntime2 = syscall.Signal(33)
)
for n := 1; n <= 64; n++ {
sig := syscall.Signal(n)
switch sig {
case unix.SIGKILL, unix.SIGSTOP:
// Uncatchable, and how the shim ends the process.
case unix.SIGILL, unix.SIGTRAP, unix.SIGBUS, unix.SIGFPE, unix.SIGSEGV, unix.SIGSTKFLT, unix.SIGSYS:
if err := setAction(sig, sigDFL); err != nil {
return err
}
case unix.SIGPROF, sigRuntime1, sigRuntime2:
if err := setAction(sig, sigIGN); err != nil {
return err
}
default:
signal.Ignore(sig)
}
}
return nil
}
// The dispositions setAction sets.
const (
sigDFL = 0
sigIGN = 1
)
// setAction sets the disposition of sig, with no flags and an empty mask. The
// struct sigaction passed is sized for every architecture and starts with the
// handler, as it does on every supported one (MIPS puts the flags first and
// has a larger sigset); the last argument is the size of the kernel sigset.
func setAction(sig syscall.Signal, handler uintptr) error {
act := [4]uint64{uint64(handler)}
if _, _, errno := unix.RawSyscall6(unix.SYS_RT_SIGACTION, uintptr(sig), uintptr(unsafe.Pointer(&act)), 0, 8, 0, 0); errno != 0 {
return errno
}
return nil
}
+164
View File
@@ -0,0 +1,164 @@
//go:build linux
/*
Copyright The containerd Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
"github.com/containerd/containerd/v2/pkg/testutil"
)
// childEnv makes the test binary run as the init instead of the tests.
const childEnv = "CONTAINERD_SANDBOX_INIT_TEST_CHILD"
func TestMain(m *testing.M) {
if os.Getenv(childEnv) == "1" {
run()
}
os.Exit(m.Run())
}
// initUser is the user the shim runs the init as.
const initUser = 65535
// TestSignals runs the init as PID 1 of a new PID namespace, as the user the
// shim runs it as, and sends it every signal but SIGKILL and SIGSTOP, from
// the parent namespace: a signal that ended it here would end it from inside
// the pod as well (see the package doc).
func TestSignals(t *testing.T) {
testutil.RequiresRoot(t)
cmd := exec.Command(initCopy(t))
cmd.Env = append(os.Environ(), childEnv+"=1")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: unix.CLONE_NEWPID,
Credential: &syscall.Credential{Uid: initUser, Gid: initUser},
}
require.NoError(t, cmd.Start())
// waitErr is valid once exited is closed.
var waitErr error
exited := make(chan struct{})
go func() {
waitErr = cmd.Wait()
close(exited)
}()
t.Cleanup(func() {
_ = cmd.Process.Kill()
<-exited
})
pid := cmd.Process.Pid
// The setup is done once the last signal of the loop, 64, is ignored.
require.Eventually(t, func() bool {
ignored, _ := signalMasks(t, pid)
return ignored&(1<<63) != 0
}, 10*time.Second, 10*time.Millisecond, "the init did not finish its signal setup")
ignored, caught := signalMasks(t, pid)
for _, sig := range []syscall.Signal{unix.SIGCHLD, unix.SIGURG, unix.SIGPROF} {
assert.NotZero(t, ignored&(1<<(sig-1)), "%s must be ignored", unix.SignalName(sig))
}
assert.Zero(t, caught, "no signal may have a handler (SigCgt %#x)", caught)
// The /proc/<pid> entries of a process that is not dumpable belong to
// root (the directory itself keeps the owner of the process).
var st unix.Stat_t
require.NoError(t, unix.Stat(fmt.Sprintf("/proc/%d/status", pid), &st))
assert.EqualValues(t, 0, st.Uid, "the init must not be dumpable")
for n := 1; n <= 64; n++ {
sig := syscall.Signal(n)
if sig == unix.SIGKILL || sig == unix.SIGSTOP {
continue
}
require.NoError(t, unix.Kill(pid, sig), "signal %d", n)
select {
case <-exited:
t.Fatalf("signal %d (%s) ended the init: %v", n, unix.SignalName(sig), waitErr)
case <-time.After(20 * time.Millisecond):
}
}
require.NoError(t, unix.Kill(pid, unix.SIGKILL))
select {
case <-exited:
var exitErr *exec.ExitError
require.ErrorAs(t, waitErr, &exitErr)
assert.Equal(t, unix.SIGKILL, exitErr.Sys().(syscall.WaitStatus).Signal())
case <-time.After(5 * time.Second):
t.Fatal("SIGKILL must end the init")
}
}
// initCopy copies the test binary where the init user can run it: the
// directories go test builds it in are not traversable for other users.
func initCopy(t *testing.T) string {
self, err := os.Executable()
require.NoError(t, err)
dir := t.TempDir()
for d := dir; d != os.TempDir(); d = filepath.Dir(d) {
require.NoError(t, os.Chmod(d, 0o755))
}
src, err := os.Open(self)
require.NoError(t, err)
defer src.Close()
path := filepath.Join(dir, "init")
dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o755)
require.NoError(t, err)
_, err = io.Copy(dst, src)
require.NoError(t, errors.Join(err, dst.Close()))
return path
}
// signalMasks reads the ignored and caught signal sets of pid from /proc;
// bit n-1 stands for signal n.
func signalMasks(t *testing.T, pid int) (ignored, caught uint64) {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
require.NoError(t, err)
for _, line := range strings.Split(string(data), "\n") {
field, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
value = strings.TrimSpace(value)
switch field {
case "SigIgn":
ignored, err = strconv.ParseUint(value, 16, 64)
require.NoError(t, err)
case "SigCgt":
caught, err = strconv.ParseUint(value, 16, 64)
require.NoError(t, err)
}
}
return ignored, caught
}