mirror of
https://github.com/containerd/containerd.git
synced 2026-11-06 05:17:02 +00:00
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:
4 files changed
+303
No files matched your search
@@ -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) $@"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user