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

Whenever you start up your OS, there's a global init process that takes PID 1 — this process is the parent of all other processes. This is often systemd, but it can be another init process.

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

Popular posts from this blog

Beyond Static Logging: Introducing Dynamic Log Levels in Go

My website Services stack.

How we sped up a Postgresql procedure 50X