← back to latest

Find the process that is quietly eating your memory

ps -eo pid,ppid,rss,etimes,pcpu,comm --sort=-rss | head -11

anatomy

ps
Process status. On Linux it is a reader for `/proc`, which means the output is a snapshot taken the instant you pressed enter, not a meter. Every number here is either a counter accumulated since the process started or a value that was true a millisecond ago.
-e
Every process on the machine. Without it `ps` shows only the processes in your own session, which is the one place the leak is never hiding. The BSD spelling of the same idea is `ax`, which is why `ps aux` and `ps -ef` return similar lists through completely different option grammars.
-o
Replace the default column set with exactly the ones you name, in the order you name them. This is the difference between reading `ps` output and squinting at it: the default format spends two columns on TTY and TIME, neither of which tells you anything about memory.
rss
Resident set size in kilobytes: the physical memory the process currently holds that is not swapped out. The alternative, `vsz`, counts address space the process has reserved but may never touch, which is why every JVM and Go binary looks like it is using 12 GB in tools that show virtual size.
etimes
Elapsed seconds since the process started. The sibling field `etime` prints the friendlier `[[DD-]hh:]mm:ss` form, but it sorts and diffs badly, and both fields print under the same `ELAPSED` header. Seconds are what you want when you are about to compare a process against the machine's uptime.
pcpu
An alias for `%cpu`, and not what most people assume it is. It is the cputime/realtime ratio over the entire life of the process, so a daemon that pegged a core for an hour last Tuesday still reports a polite low number today.
comm
The executable name, taken from the kernel's task name field, which is 16 bytes wide. That gives you 15 usable characters, which is why `systemd-journald` shows up as `systemd-journal` and why two different binaries can collapse into the same string. Swap in `args` when you need the full command line.
--sort=-rss
Sort by resident memory, with the leading minus for descending. The sorting happens inside `ps` on its internal values before any formatting, so it is not equivalent to piping into `sort`, which only ever sees the printed strings. You can also sort on a field you are not displaying: `ps -eo pid,comm --sort=-rss` is valid.
head -11
Ten processes plus the header line. `ps` writes its header as an ordinary line of stdout, so `head -10` quietly costs you the tenth process.

Sample output

    PID    PPID   RSS ELAPSED %CPU COMMAND
   1847       1 812344 604812  1.4 node
   2291    1847 198220 604811  0.3 node
    914       1 166052 986388  0.2 postgres
   3120     914  88376  84219  0.1 postgres
   1102       1  61288 986402  0.0 redis-server
    742       1  42116 986411  0.0 containerd
   4881    4879  33940    127 12.6 rsync
    688       1  18644 986414  0.0 systemd-journal
    612       1  12208 986415  0.0 systemd-udevd
      1       0   9884 986417  0.0 systemd

PID 1847 is holding 793 MB, and the last column of the bottom row is the context that makes it a finding. PID 1 has been up 986417 seconds, so the machine booted 11 days ago, but the node process has only been running for 604812 of them. It restarted 7 days ago and has been climbing ever since, which is the shape of a leak rather than a process that is merely large. The second row is its child by PPID, so stopping 1847 takes 2291 with it.

When you would reach for it

The box is swapping, the out-of-memory killer took something at 3am, or a container keeps getting restarted by its orchestrator and you have a few seconds to find out what was fat before it dies again. top answers the same question interactively, but it needs a terminal you can sit in front of, and it cannot be piped, logged, or dropped into a cron job. This form fits on one line of a runbook and works over ssh host '...' with no TTY at all.

Gotchas

  • Add up the RSS column and you will get more memory than the machine has. RSS counts every resident page in full for every process that maps it, so a shared library loaded by forty processes is charged forty times, and copy-on-write pages a child has never written are charged to both parent and child. The honest per-process number is PSS, where shared pages are divided among their users: awk '/^Rss:|^Pss:/{print}' /proc/1847/smaps_rollup. The gap is not subtle: check it against any idle login shell and you will see roughly 2500 kB of RSS reported against a PSS closer to 1200 kB.
  • %CPU is an average over the process’s entire lifetime, not a current reading. In the output above, rsync shows 12.6% after 127 seconds of life while node shows 1.4% after a week, and that does not mean node is idle. It means a week of arithmetic has flattened whatever it did this morning. For an instantaneous number, take two samples of /proc/PID/stat a second apart, or use top -b -n 2 and read the second frame.
  • Neither --sort nor etimes exists in the BSD ps on macOS. Use -m to sort by memory and etime for elapsed time, as in the first variant below. BSD’s comm also behaves differently: it prints the full executable path, and ucomm is the short accounting name. On Alpine, BusyBox provides a ps that accepts a narrow -o keyword list and has no --sort at all, so apk add procps before running this.
  • Swap comm for args and the output gets truncated to your terminal width, because ps clips lines to the screen when stdout is a TTY. A pipe is not a TTY, so the same command through less or into a file shows the full command line and you get two different answers from one command. Add -ww to disable clipping in both cases.

Variants

$ ps -Ao pid,ppid,rss,etime,pcpu,comm -m | head -11

The macOS and BSD form. -A is the BSD spelling of -e, and -m sorts by memory usage instead of by terminal and PID

$ ps -eo rss,comm --no-headers | awk '{mem[$2]+=$1} END {for (c in mem) printf "%8d %s\n", mem[c], c}' | sort -rn | head -5

Total memory per program rather than per process. Chrome and Node fragment themselves across dozens of PIDs, so the real consumer can be invisible in a per-process ranking

$ ps -o pid,rss,etimes,pcpu,comm -p "$(pgrep -d, -f 'node server.js')"

Watch one application over time. pgrep -d, emits a comma-separated PID list, which is exactly the format -p wants, so the command substitution does the joining for you

lineage

`ps` is one of the oldest commands still in daily use: it appears in the First Edition Unix manual from November 1971, alongside `cat` and `ls`. For most of its life it was a privileged program in an uncomfortable sense. There was no kernel interface for asking about processes, so `ps` opened `/dev/kmem`, walked the kernel's process table directly, and decoded structures whose layout it had to know in advance. That is why it shipped setgid to the `kmem` group for decades, and why upgrading a kernel could leave you with a `ps` that printed convincing nonsense. Linux ended that arrangement by exposing the process table as files under `/proc`, turning `ps` into a program that reads text. The option grammar never got the same cleanup. Berkeley's `ps` took options without a dash (`ps aux`), AT&T's System V took them with one (`ps -ef`), and the Linux `ps` accepts both dialects plus GNU long options, which is the whole explanation for a manual that warns you that `ps -aux` is a different request than `ps aux`: under POSIX rules the first one asks for processes owned by a user named x. The implementation most Linux systems run today is procps-ng, a fork taken up by Craig Small, Jim Warner and others around 2011 after the original procps project went quiet.