Skip to main content
Linux · lesson 3 of 7
Linux intermediate Lesson 3 of 7

Also part of: Bash

A Zombie Process That Never Gets Reaped — and the PID 1 That Fixes It

A child exits, its parent never calls wait(), and the zombie sits forever under a naive PID 1 — orphaned to PID 1 doesn't help unless that PID 1 actually reaps. Docker's --init does; a bare sleep infinity doesn't.

By SkillByExample · Updated

linux processes zombie proc signals

Killing an orphaned zombie’s new parent should clean it up — except when that new parent never does the one thing that would.

Creating a real zombie

// zombie_maker.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        printf("child (pid %d) exiting now\n", getpid());
        exit(0);
    } else {
        printf("parent (pid %d) sleeping without wait()\n", getpid());
        sleep(30);
    }
    return 0;
}
docker run -d --name linux-lesson --privileged ubuntu:22.04 sleep infinity
gcc -o zombie_maker zombie_maker.c
docker exec -d linux-lesson /root/zombie_maker
docker exec linux-lesson ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0   2792  1008 ?        Ss   05:55   0:00 sleep infinity
root      1883  0.0  0.0   2776   956 ?        Ss   06:07   0:00 /root/zombie_maker
root      1889  0.0  0.0      0     0 ?        Z    06:07   0:00 [zombie_maker] <defunct>
root      1890  0.0  0.0   7072  1560 ?        Rs   06:07   0:00 ps aux

STAT Z and <defunct> — a real zombie. The child (1889) already exited; the parent (1883) is 30 seconds into a sleep() and hasn’t called wait() yet, so the kernel keeps the child’s exit status parked in the process table.

docker exec linux-lesson cat /proc/1889/status
Name:	zombie_maker
State:	Z (zombie)
Tgid:	1889
Pid:	1889
PPid:	1883
...

/proc/<pid>/status confirms it directly: State: Z (zombie), and PPid: 1883 names the exact parent responsible for reaping it.

The bug: orphaning doesn’t guarantee reaping

docker exec linux-lesson kill -9 1883
docker exec linux-lesson ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0   2792  1008 ?        Ss   05:55   0:00 sleep infinity
root      1889  0.0  0.0      0     0 ?        Z    06:07   0:00 [zombie_maker] <defunct>
root      1908  0.0  0.0   7072  1612 ?        Rs   06:08   0:00 ps aux

The parent is gone — but the zombie is still there.

docker exec linux-lesson cat /proc/1889/status
Name:	zombie_maker
State:	Z (zombie)
Pid:	1889
PPid:	1
...

PPid: 1 confirms the zombie was correctly re-parented (“orphaned”) to this container’s PID 1 — but that PID 1 is a plain sleep infinity, whose entire implementation is “sleep.” It never calls wait() on anything, adopted children included, because nothing in it does that. Orphaning moves responsibility for reaping to PID 1; it does not guarantee PID 1 will actually do it.

The fix: a real init as PID 1

docker run -d --name zombie-test --init ubuntu:22.04 sleep infinity
docker exec zombie-test ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  4.0  0.0   1008     4 ?        Ss   06:08   0:00 /sbin/docker-init -- sleep infinity
root         7  0.0  0.0   2792  1020 ?        S    06:08   0:00 sleep infinity
root         8  0.0  0.0   7072  1592 ?        Rs   06:08   0:00 ps aux

Docker’s --init flag inserts docker-init (a build of tini) as the real PID 1, with the specified command (sleep infinity) running as its child instead. Repeat the exact same zombie-creation steps against this container:

docker exec -d zombie-test /root/zombie_maker
docker exec zombie-test kill -9 878    # kill the zombie_maker parent
docker exec zombie-test ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0   1008     4 ?        Ss   06:08   0:00 /sbin/docker-init -- sleep infinity
root         7  0.0  0.0   2792  1020 ?        S    06:08   0:00 sleep infinity
root       897  2.0  0.0   7072  1608 ?        Rs   06:10   0:00 ps aux

No zombie anywhere in the list. tini’s entire purpose is implementing the two responsibilities a real PID 1 needs — reaping orphaned children and forwarding signals — which is exactly the gap between “something is running as PID 1” and “PID 1 actually does PID 1’s job.”

Practice

1. Confirm the zombie under the naive sleep infinity PID 1 never goes away on its own.
docker exec linux-lesson ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0   2792  1008 ?        Ss   05:55   0:00 sleep infinity
root      1889  0.0  0.0      0     0 ?        Z    06:07   0:00 [zombie_maker] <defunct>

Still there, unchanged, well after the original 30-second sleep would have elapsed — this zombie is permanent until the container itself is removed, since nothing in this container will ever call wait() on it.

2. Check whether the reaped zombie's PID gets reused.
docker exec zombie-test ps aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0   1008     4 ?        Ss   06:08   0:00 /sbin/docker-init -- sleep infinity
root         7  0.0  0.0   2792  1020 ?        S    06:08   0:00 sleep infinity

Once reaped, the zombie’s process table entry is fully freed, and the kernel is free to reissue that PID number to a future process — this is exactly why an unreaped zombie is a genuine resource leak at scale: every zombie holds a PID that can’t be reused until it’s collected.

3. List every process in the D (uninterruptible sleep) or Z (zombie) state on a system, the two states worth actually worrying about.
docker exec zombie-test ps -eo pid,stat,comm --sort=stat
  PID STAT COMMAND
  902 Rs   ps
    1 Ss   docker-init
    7 S    sleep

No D or Z entries here — this is the actual command worth running on a real system when investigating “is something stuck”: D means a process is blocked on uninterruptible I/O (often a hung disk or NFS mount), Z means an unreaped zombie. Both are visible at a glance by sorting on the STAT column instead of scanning a full ps aux by eye.

Next: systemd and service management — how a real init system supervises long-running services, restarts them on crash, and orders startup dependencies.

Frequently Asked Questions

What exactly is a zombie process?
A process that has finished running (called exit()) but still has an entry in the process table, because its parent hasn't yet called wait() to collect its exit status. The kernel keeps that entry around specifically so the parent can retrieve the exit code later — a zombie uses essentially no resources (no memory, no CPU), it's just a table row waiting to be acknowledged.
Why didn't killing the zombie's parent make the zombie disappear?
Killing the parent doesn't retroactively call wait() for it — the zombie just gets re-parented (orphaned) to whatever process is PID 1 in that process's namespace. Whether the zombie then actually gets cleaned up depends entirely on whether that PID 1 calls wait() on its adopted children; a zombie can be orphaned to PID 1 and still never get reaped if PID 1 itself doesn't do that job.
Why does a plain sleep infinity as PID 1 fail to reap zombies?
sleep infinity's entire job is to sleep — it never calls wait() on anything, adopted children included, because nothing in its code does that. A real init system (systemd, or a purpose-built minimal init like tini) explicitly implements the PID 1 responsibility of reaping every orphaned child; a command that happens to be running as PID 1 without implementing that logic simply won't do it.
What does Docker's --init flag actually add?
It inserts tini (bundled as docker-init) as the container's real PID 1, with your specified command running as a child of it instead of as PID 1 directly. tini's whole purpose is correctly forwarding signals and reaping zombies — exactly the two responsibilities a container's actual command usually isn't written to handle, since most programs assume something else is doing PID 1's job.
Does a zombie process actually cause problems if it's never reaped?
A single zombie is essentially harmless — it holds a process table slot but no other resources. The real danger is a long-running service that spawns many short-lived children without ever reaping them: over time, the accumulating zombie entries can exhaust the system's process ID space, at which point new processes fail to start at all. It's rarely one zombie that causes an incident; it's thousands, slowly.