The Special Case of PID 1
I was testing some things on our kubernetes cluster and wanted to
quickly restart the k8s container without restarting the pod and having to
wait for the graceful termination period. So I exec'd into the container and
sent a signal to PID 1 ( kill -15 1 ) and expected the process to
terminate while kicking me out of the container. Much to my surprise, it did
absolutely nothing. I thought, hey, maybe for some reason it's hanging, let's
give it a few seconds... A few seconds passed and still nothing happened.
Okay, perhaps something is causing it to not properly process the signals, so
I sent a SIGKILL (-9) as those
cannot be intercepted or processed in any way. Even that didn't kill the process. I was flabbergasted. Did we find a bug
in the kernel, or did we find a way to make an unkillable process?
I decided to put a strace on the process from the host machine to see if there's any insight. At first I saw it was receiving the signals but chose to basically ignore them:
root@gke-prod-pool-4bde38d8-zg5o:~# strace -p 81502
strace: Process 81502 attached
restart_syscall(<... resuming interrupted read ...>
) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
--- SIGILL {si_signo=SIGILL, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGTRAP {si_signo=SIGTRAP, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGBUS {si_signo=SIGBUS, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGFPE {si_signo=SIGFPE, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGSEGV {si_signo=SIGSEGV, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGHUP {si_signo=SIGHUP, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGINT {si_signo=SIGINT, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGQUIT {si_signo=SIGQUIT, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGABRT {si_signo=SIGABRT, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGUSR1 {si_signo=SIGUSR1, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGUSR2 {si_signo=SIGUSR2, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGPIPE {si_signo=SIGPIPE, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGALRM {si_signo=SIGALRM, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGSTKFLT {si_signo=SIGSTKFLT, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGCHLD {si_signo=SIGCHLD, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- SIGSTOP {si_signo=SIGSTOP, si_code=SI_USER, si_pid=14, si_uid=0} ---
--- stopped by SIGSTOP ---
I ran a for loop that sent signals 1-20 at the process — none of them had any effect, but strace confirmed it was receiving every one. With that in mind, I wondered if it was because I didn't set a handler to handle the signals, so I quickly created a bash script to run as the entrypoint for the pod.
#!/usr/bin/env bash
# Playground signal-trapping entrypoint.
# NOTE: no `set -e` on purpose — `wait` returns >128 when interrupted by a
# signal, which would make `set -e` kill the script the moment a trap fires.
set -uo pipefail
log() { echo "[playground $(date -u +%FT%TZ)] $*"; }
running=true
handle() {
local sig="$1"
log "caught SIG${sig}"
case "$sig" in
TERM)
log "SIGTERM -> graceful cleanup, then exit"
# (put real cleanup here: flush, drain, close fds, etc.)
running=false
;;
INT|QUIT)
log "SIG${sig} -> logged, staying alive (send SIGTERM or SIGKILL to stop)"
;;
HUP)
log "SIGHUP -> pretend-reloading config"
;;
USR1)
log "SIGUSR1 -> custom action A"
;;
USR2)
log "SIGUSR2 -> custom action B"
;;
KILL)
log "KILL, this should not be reached"
;;
esac
}
# Install one trap per signal, passing the signal name into the handler.
for sig in TERM INT QUIT HUP USR1 USR2 KILL; do
# shellcheck disable=SC2064 # expand $sig now, on purpose
trap "handle ${sig}" "${sig}"
done
log "playground up as PID $$ — trapping: TERM INT QUIT HUP USR1 USR2"
log "reminder: SIGKILL(9) and SIGSTOP(19) can never be trapped"
# Keep-alive loop. sleep runs in the background and we `wait` on it so a
# trap interrupts the wait instantly; then we reap the (possibly stale)
# sleep so we don't leak one process per signal received.
while ${running}; do
sleep infinity &
sleep_pid=$!
wait "${sleep_pid}"
kill "${sleep_pid}" 2>/dev/null || true
done
log "exiting cleanly"
exit 0
I ran this as the pod entrypoint, ran a kubectl exec to send signals to PID 1, and we were in business. The logs I was expecting were printing, and the strace was showing movement.
0x7ffe1ca15e54, 0, NULL) = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGUSR1 {si_signo=SIGUSR1, si_code=SI_USER, si_pid=10, si_uid=0} --- # Signal sent and caught
rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
[snipped]
read(3, "2026-07-07T12:19:23Z\n", 4096) = 21 # read the date to print to the log
read(3, "", 4096) = 0
[snipped]
write(1, "[playground 2026-07-07T12:19:23Z"..., 49) = 49 #print the log
This time it wasn't just the signal being printed. When I sent a SIGUSR1 to PID 1 from inside the container, it was able to respond by printing a log — the exact log I wanted. Unfortunately, even when I trapped a SIGKILL, it was still a no-op; we'll discuss below how kubernetes gets around this. This makes sense, as SIGKILL along with SIGSTOP cannot be intercepted. So the closest I could get was a handler for SIGTERM to shut down, since this is what kubernetes sends the process when you issue a kubectl delete pod. Now let's pop the hood and see why the kernel behaves this way — starting with PID 1 itself, before we get to how containers and kubernetes fit in.
The Genius of the Kernel
anthony@anthony-QEMU-Virtual-Machine:~$ ps aux | head
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.5 27272 17764 ? Ss Jul06 0:03 /usr/lib/systemd/systemd --switched-root --system --deserialize=52 splash
root 2 0.0 0.0 0 0 ? S Jul06 0:00 [kthreadd]
root 3 0.0 0.0 0 0 ? S Jul06 0:00 [pool_workqueue_release]
root 4 0.0 0.0 0 0 ? I< Jul06 0:00 [kworker/R-rcu_gp]
root 5 0.0 0.0 0 0 ? I< Jul06 0:00 [kworker/R-sync_wq]
root 6 0.0 0.0 0 0 ? I< Jul06 0:00 [kworker/R-kvfree_rcu_reclaim]
root 7 0.0 0.0 0 0 ? I< Jul06 0:00 [kworker/R-slub_flushwq]
root 8 0.0 0.0 0 0 ? I< Jul06 0:00 [kworker/R-netns]
root 10 0.0 0.0 0 0 ? I< Jul06 0:00 [kworker/0:0H-kblockd]
This process is special since, if you were to kill it, there would be no parent process left to take over, ultimately crashing the system. Let's look at some kernel code to see what's happening under the hood.
if (unlikely(signal->flags & SIGNAL_UNKILLABLE) &&
!sig_kernel_only(signr))
continue;
These two lines are the answer — case closed, pack your bags, time to go home.
Although, it's not that simple. Let's break it down: the first part, unlikely,
is a compiler optimization telling the CPU that this branch is not likely to
be taken, so it can optimize branch prediction. The next part looks at the
signal struct: signal->flags & SIGNAL_UNKILLABLE. This is
a bitwise AND operation, checking if the unkillable flag is set for the
process (PID 1). If that's false, it checks whether the signal is one that
userspace can't catch (SIGKILL, SIGSTOP); otherwise, it continues
down the path.
static bool sig_task_ignored(struct task_struct *t, int sig, bool force)
{
void __user *handler;
handler = sig_handler(t, sig);
/* SIGKILL and SIGSTOP may not be sent to the global init */
if (unlikely(is_global_init(t) && sig_kernel_only(sig)))
return true; // (A)
if (unlikely(t->signal->flags & SIGNAL_UNKILLABLE) &&
handler == SIG_DFL && !(force && sig_kernel_only(sig)))
return true; // (B)
/* Only allow kernel generated signals to this kthread */
if (unlikely((t->flags & PF_KTHREAD) &&
(handler == SIG_KTHREAD_KERNEL) && !force))
return true;
return sig_handler_ignored(handler, sig);
}
So that explains PID 1's general immunity. But it still doesn't explain why my
container ignored every signal until I added handlers — this function is where
that gets resolved. It checks if it's a SIGKILL or SIGSTOP sent to the global
init process, and skips any handling if so. Next, it checks if the signal
is unkillable (PID 1) and if it's using the default handler
(SIG_DFL); if so, it ignores the signal, since we want any signal
handling on the root process to be intentional. This explains why we must
explicitly set a handler to handle any signal.
Reading a Process's Signal Handlers
Side note: you can see if a process has a handler by running
cat /proc/<pid>/status | grep Sig. This will show a hex
representation of the blocked, pending, ignored, and caught signals. Here's an
example:
root@anthony-QEMU-Virtual-Machine:/# cat /proc/1/status | grep Sig
SigQ: 1/7506
SigPnd: 0000000000000000
SigBlk: 0000000000010000
SigIgn: 0000000000000000
SigCgt: 0000000000014a07
To see which signals have handlers attached, we can break it down in binary:
0000000000014a07 = 0001 0100 1010 0000 0111. A quick way to do
this is by counting the position of the set bits, so we're catching
1, 2, 3, 10, 12, 15, 17 — this lines up with the signals we're
catching in the script above (17 is SIGCHLD, which bash traps implicitly
because of the background wait). You can run
kill -l to see a list of signals.
PID Namespaces and Containers
Okay, we talked about the Linux kernel, but how does this relate to kubernetes, and how does kubernetes kill a process if you can't send a SIGKILL to PID 1? To answer that, we need to look at how containers are managed and created in Linux.
Containers mainly lean on two Linux features: cgroups and namespaces. (Cgroups handle the resource side — CPU, memory, IO limits, basically what shows up as "requests/limits" in a kubernetes manifest, but they're not the reason PID 1 behaves the way it does, so we'll leave them there.) Namespaces are the ones that matter for us, and the PID namespace specifically is the whole answer to "how do you even have a PID of 1"
# start the namespace with unshare in the background
anthony@anthony-QEMU-Virtual-Machine:~$ sudo unshare -pmUrf --mount-proc bash -c "exec /tmp/entrypoint.sh" &
[1] 9217
# Enter into the namespace
anthony@anthony-QEMU-Virtual-Machine:~$ sudo nsenter -t 9225 -a bash -c "mount -t proc proc /proc && exec bash"
root@anthony-QEMU-Virtual-Machine:/# ps aux | head
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 20460 5508 pts/1 S+ 19:57 0:00 bash -c exec /tmp/entrypoint.sh
root 4 0.0 0.1 15720 6108 pts/1 S+ 19:57 0:00 sleep infinity
root 5 0.0 0.1 20752 6272 pts/2 S 19:59 0:00 bash
root 12 0.0 0.1 20792 3740 pts/2 R+ 19:59 0:00 ps aux
root 13 0.0 0.1 15892 6188 pts/2 S+ 19:59 0:00 head
# Or you can just enter a namespace running just bash
anthony@anthony-QEMU-Virtual-Machine:~$ sudo unshare -pmUrf --mount-proc bash
root@anthony-QEMU-Virtual-Machine:/home/anthony# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 20128 4260 pts/1 S 20:01 0:00 bash
root 7 0.0 0.1 21720 5084 pts/1 R+ 20:01 0:00 ps aux
root@anthony-QEMU-Virtual-Machine:/home/anthony#
How Kubernetes Kills PID 1 Anyway
So how does kubernetes handle shutting down processes if you can't SIGKILL PID 1? This is where Linux earns its keep: it only allows parent-namespace processes to force-deliver a signal to a process with a PID of 1. It does this by checking the namespace level of the sending process. If the signal comes from a parent namespace, it can "accept" the SIGKILL signal. If it comes from a peer process, a process in the same namespace, it ignores the signal.
Resolving PIDs Across Namespaces
So how does the kernel even know a signal came from a parent namespace instead of a sibling process in the same one? It comes down to how PIDs are represented internally — a process doesn't actually have "a PID," it has one per namespace level it's nested in:
// A task doesn't have "a PID" — it has one `struct pid` carrying a number *per namespace
// level* (`include/linux/pid.h:58`):
struct pid {
unsigned int level;
...
struct upid numbers[]; // one entry per ns level
};
struct upid {
int nr; // the number *as seen in this ns*
struct pid_namespace *ns;
};
When a signal is sent, the kernel resolves the sender's PID
in the target's namespace. No PID there means the sender is in an
ancestor namespace, so it sets force
(kernel/signal.c):
// Sender has no pid in the target's ns => it's an ancestor.
if (!task_pid_nr_ns(current, task_active_pid_ns(t))) {
info->si_pid = 0;
force = true; // ancestor => bypass PID 1's shield
}
The Shield, Bypassed
A namespace's init carries SIGNAL_UNKILLABLE and drops
SIGKILL from its own children — unless force is set:
// Namespace PID 1 ignores SIGKILL/SIGSTOP unless force is set,
// i.e. unless it came from an ancestor namespace.
if (unlikely(t->signal->flags & SIGNAL_UNKILLABLE) &&
handler == SIG_DFL && !(force && sig_kernel_only(sig)))
return true; // a peer's SIGKILL is dropped here
Conclusion
PID 1 gets special treatment from the kernel. SIGKILL and SIGSTOP get
dropped when they arrive from inside the same PID namespace, and only go
through when they come from a parent namespace with force
set. That's why kubectl delete pod can kill a container even
though sending SIGKILL from inside that same container does nothing.
The part that matters day to day: if your entrypoint doesn't trap SIGTERM,
kubernetes isn't stuck when a pod takes the full
terminationGracePeriodSeconds to go away. It's waiting on a
process that was never set up to listen, then killing it from kubelet once
the timer runs out. A signal handler in your entrypoint, even a small one
like the script above, gets you back to a normal shutdown.
I still don't have a good answer for why I expected kill -15 1
to work in the first place, beyond years of running that command against
regular processes where it just does. PID 1 plays by different rules, and
now I know why.
Comments
Post a Comment