You run ps aux on your VPS and see a wall of processes marked <defunct>. Or worse, you try to SSH in, start a new script, or restart a service, and the shell just says fork: retry: Resource temporarily unavailable. Nothing you kill seems to make a dent. This is almost always a zombie process problem, and it's one of the more confusing things to debug because the processes themselves are, technically, already dead.
What "Zombie" and "Defunct" Actually Mean
A zombie process isn't a process that's stuck or hung — it's one that already finished running. When a Linux process exits, the kernel keeps a small record of it (exit code, resource usage stats) in the process table until its parent process calls wait() to collect that information. Until the parent does that, the dead process sticks around as an entry in the table, shown by ps as <defunct> with a state of Z.
One or two zombies sitting around for a few seconds is completely normal — every process briefly passes through this state. The problem is when the parent never calls wait() at all. Then the zombie never gets cleaned up, and if the parent keeps spawning new short-lived children in a loop, you accumulate hundreds or thousands of them.
| ps STAT code | Meaning |
|---|---|
| Z | Zombie — process has exited, parent hasn't reaped it yet |
| D | Uninterruptible sleep — usually waiting on disk I/O, not a zombie |
| S | Sleeping — normal idle state |
| R | Running or runnable |
Symptom: Process Table Fills Up, New Processes Won't Start
A handful of zombies use almost no memory or CPU — they're just a table entry. The real damage shows up at scale. Every process, dead or alive, occupies a PID (process ID), and PIDs are a finite resource on the system as a whole and per-user via ulimit. If a buggy script or service leaks zombies fast enough, you eventually hit one of these limits and new processes simply can't be created:
bash: fork: retry: Resource temporarily unavailable- Cron jobs silently failing to start
- SSH sessions refusing new logins even though the server "looks" idle in
top - nginx-primary-script-unknown-file-not-found-on-a-vps" class="auto-link">PHP-FPM or Node workers failing to spawn under load
Check where you stand with:
ps aux | grep 'Z' | grep -v grep
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/'
ps -eLf | wc -l # total process/thread count
ulimit -u # max processes allowed for your user
cat /proc/sys/kernel/pid_max # system-wide PID ceiling
If the zombie count is climbing steadily every time you check, you're not just looking at leftover mess — something is actively creating them faster than anything reaps them.
Cause: A Parent That Never Calls wait()
You can't kill a zombie directly — kill -9 on a zombie's PID does nothing, because there's no running process left to signal. The fix has to target the parent. In our support queue, the parent is almost always one of these:
- A custom script that forks and forgets. Shell scripts, PHP, Python, or Node code that spawns a subprocess (
exec(),popen(),child_process.exec()) without ever reading its exit status or output leaves a zombie behind every single run. If this runs on a cron schedule, you get a steady drip of zombies forever. - A Docker container running as PID 1. Inside a container, whatever process you launch becomes PID 1, and PID 1 has a special job in Linux: reaping orphaned zombies. Most application binaries (Node, a Python script, a Java app) were never written to do that job, so zombies pile up inside the container namespace even if the host looks fine.
- A crashed or misbehaving daemon. If a long-running service (a custom monitoring agent, a worker manager, an old version of supervisord) has a bug in its child-handling code, every task it dispatches can leave a corpse behind.
- SSH or FTP daemons under heavy short-lived connection load where the parent daemon is slow to reap closed sessions.
Fix: Find the Parent, Then Deal With It
Step 1 — identify the parent PID (PPID) of the zombies, not just the zombies themselves:
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/ {print $2}' | sort | uniq -c | sort -rn
This groups zombies by parent and shows you which PID is responsible. Then look up what that process actually is:
ps -p <PPID> -o pid,cmd
From there you have three options, depending on what you find:
- If the parent is a systemd-managed service (a web app, a queue worker, a custom daemon), restart it cleanly:
systemctl restart yourservice. On restart, systemd re-parents and the zombies under the old process get reaped when that process fully exits. - If the parent is a one-off script or shell session gone wrong, sending it a signal to make it exit lets
init/systemd(PID 1) inherit and reap its zombies:kill -TERM <PPID>, then check again a few seconds later. If it doesn't respond, escalate tokill -9only as a last resort — that process itself may then leave a zombie of its own that PID 1 will clean up almost immediately. - If the parent is a Docker container's entrypoint process, the fix belongs in the image, not a one-time kill. Add a proper init system to the container so something is always reaping children.
For Docker specifically, the simplest fix is running the container with an init process attached:
docker run --init your-image
or, in Compose:
services:
app:
image: your-image
init: true
That inserts a minimal init (Docker bundles tini) as PID 1 inside the container, whose entire job is reaping zombies and forwarding signals correctly. If you're building your own base image and don't want to rely on --init, installing tini or dumb-init directly as the container's entrypoint does the same thing.
As an absolute last resort, if the box is so starved for PIDs that you can't even run diagnostic commands, a reboot clears every zombie instantly because the entire process table resets. It's a valid emergency move, but it doesn't fix the underlying script or container that caused it — the zombies will simply start piling up again on the same schedule.
Prevention: Stop It From Coming Back
- In your own scripts, always collect the exit status of anything you fork. In shell, that means not backgrounding a command with
&and walking away — usewaitafterward, or better, avoid unnecessary subshells entirely. In Node, usechild_process.exec()or.on('exit', ...)rather than firing and forgetting. In Python, prefersubprocess.run()over manually callingos.fork(). - In Docker, make
--init(orinit: truein Compose) part of your standard run configuration, the same way you'd always set a restart policy. - Monitor process counts, not just CPU and memory. A tool like Netdata or even a simple cron check comparing
ps -eo stat | grep -c Zagainst a threshold, alerting if it climbs past a handful, catches this early — before it turns into a fork-bomb-style outage. - Set sane
ulimit -uvalues per user so a single runaway script hits its own ceiling and fails loudly, instead of quietly starving the whole VPS of PIDs.
Zombie processes are one of those Linux quirks that sound alarming the first time you see a screen full of <defunct> entries, but they're rarely dangerous by themselves. The actual risk is always the parent that's failing to clean up after itself — find that, fix it once, and the zombies stop appearing on their own.
Frequently Asked Questions
Can zombie processes crash my VPS?
Not directly — a zombie holds almost no memory or CPU, just a slot in the process table. The real risk is indirect: enough of them can exhaust available PIDs (bound by ulimit -u and kernel.pid_max), which then blocks new processes, cron jobs, and even SSH logins from starting at all.
Why doesn't kill -9 remove a zombie process?
Because there's nothing left to kill. A zombie is already terminated; all that remains is its exit-status entry waiting for the parent to read it with wait(). Signals target running processes, so they have no effect on a zombie's PID. You have to act on the parent instead.
How many zombie processes is "too many"?
A handful appearing and disappearing within seconds during normal operation is fine and not worth chasing. It becomes a real problem when the count keeps growing over minutes or hours without dropping back down — that pattern means something is leaking them faster than anything is reaping them.
Will restarting my VPS fix zombie processes?
Yes, a reboot clears the entire process table and removes every zombie immediately. It's a fine emergency fix if you're locked out of PIDs, but it's a reset, not a repair — whatever script, cron job, or container was leaking them will start doing it again unless you fix the parent process.
Why do I see zombies inside a Docker container but not on the host?
Inside a container's own PID namespace, whatever process you launch runs as PID 1, and PID 1 has the special kernel responsibility of reaping orphaned children. Most application binaries were never written to do that job, so zombies accumulate inside the container even though the host's real PID 1 (systemd or init) is reaping everything correctly at the host level. Running the container with --init (or an equivalent like tini) fixes this.
