Skip to main content
Bash · lesson 17 of 22
Bash intermediate Lesson 17 of 22

Also part of: Linux

Bash System Information

Inspect a Linux system from Bash with uname, df, du, free, ps, pgrep, ss, lsof and systemctl, and parse their output reliably in scripts.

By SkillByExample · Updated

The outputs below were captured in an Ubuntu 24.04 container running on a WSL2 kernel, so the sizes, PIDs and kernel version will differ on your machine. The shape of the output, and the parsing traps it shows, are the same on any Linux host.

uname and os-release: What Am I Running On?

Scripts that download binaries need the OS and CPU architecture in the vendor’s naming scheme:

echo "kernel: $(uname -s) $(uname -r)"
echo "arch:   $(uname -m)"
case "$(uname -m)" in
  x86_64)        arch=amd64 ;;
  aarch64|arm64) arch=arm64 ;;
  *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;;
esac
os=$(uname -s | tr '[:upper:]' '[:lower:]')
echo "download: https://releases.example.com/app-${os}-${arch}.tar.gz"
. /etc/os-release && echo "distro: $PRETTY_NAME"
$ bash uname.sh
kernel: Linux 5.15.153.1-microsoft-standard-WSL2
arch:   x86_64
download: https://releases.example.com/app-linux-amd64.tar.gz
distro: Ubuntu 24.04.5 LTS

uname -m says x86_64, but most release pages say amd64, so the case translates between the two. Linux reports aarch64 and macOS reports arm64 for the same kind of chip. /etc/os-release is a file of shell variable assignments, so sourcing it gives you the distribution name and version.

df and du: Disk Space

df shows free space per filesystem. du shows what is using it.

df -h / | cut -c1-60
echo "---"
pct=$(df --output=pcent / | tail -1 | tr -dc '0-9')
echo "root filesystem is ${pct}% full"
(( pct >= 80 )) && echo "ALERT" || echo "OK (threshold 80%)"
echo "---"
mkdir -p logs && head -c 3M /dev/zero > logs/big.log && head -c 200K /dev/zero > logs/small.log && echo hi > logs/tiny.log
du -sh logs/* | sort -rh
$ bash disk.sh
Filesystem      Size  Used Avail Use% Mounted on
overlay         251G   14G  225G   6% /
---
root filesystem is 6% full
OK (threshold 80%)
---
3.0M	logs/big.log
200K	logs/small.log
4.0K	logs/tiny.log

df --output=pcent asks for just the one column, which is sturdier than picking field 5 out of the full output. On some systems a long device name makes df put the numbers on a second line, and field 5 is no longer the percentage. tr -dc '0-9' strips the % and the padding. du reports blocks in use, so a 3-byte file still takes 4.0K. sort -rh understands K/M/G suffixes. When the disk is full but du finds nothing large, check inodes with df -i.

free: Memory

free -m
avail=$(free -m | awk '/^Mem:/ {print $7}')
echo "available: ${avail} MB"
(( avail < 500 )) && echo "WARN: low memory" || echo "OK"
$ bash memory.sh
               total        used        free      shared  buff/cache   available
Mem:            7795        1339        5393          49        1405        6455
Swap:           2048           0        2048
available: 6454 MB
OK

Alert on available, not free. Linux uses idle RAM as cache (buff/cache) and gives it back when programs need it, so a low free figure is normal. The two numbers differ by 1 MB because free ran twice, and memory changed between the calls. Matching the row by /^Mem:/ is safer than using a line number.

ps and pgrep: Processes

sleep 30 & sleep 31 &
echo "-- ps -C: chosen columns for one command name:"
ps -o pid,ppid,user,stat,etime,cmd -C sleep
echo "-- pgrep -a sleep:"
pgrep -a sleep
kill %1 %2
$ bash ps.sh
-- ps -C: chosen columns for one command name:
  PID  PPID USER     STAT     ELAPSED CMD
    9     8 alice    S          00:00 sleep 30
   10     8 alice    S          00:00 sleep 31
-- pgrep -a sleep:
9 sleep 30
10 sleep 31

-o picks the columns and -C filters by command name, so there’s no ps aux | grep pipeline. That pipeline has a well-known flaw: grep sometimes matches its own command line, depending on timing. pgrep avoids that, and pgrep -f matches against the full command line. For interactive use, ps aux (BSD style, with CPU and memory columns) and ps -ef (with the parent PID) are the usual full listings, and --sort=-%cpu puts the busiest processes first.

Don’t Parse top by Position

top -bn1 prints one snapshot in batch mode. Many scripts read the CPU idle figure out of field 8:

top -bn1 | grep '^%Cpu' > cpu.txt
echo '%Cpu(s):  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st' >> cpu.txt
cat cpu.txt
echo "-- field 8 of each line:"
awk '{print "[" $8 "]"}' cpu.txt
echo "-- idle found by its label:"
awk -F',' '{ for (i = 1; i <= NF; i++) if ($i ~ / id$/) { gsub(/[^0-9.]/, "", $i); print $i "%" } }' cpu.txt
$ bash top-cpu.sh
%Cpu(s): 13.6 us,  5.3 sy,  0.0 ni, 77.3 id,  0.0 wa,  0.0 hi,  3.8 si,  0.0 st 
%Cpu(s):  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
-- field 8 of each line:
[77.3]
[id,]
-- idle found by its label:
77.3%
100.0%

The first line is a real snapshot. The second is the same format on a fully idle machine, exactly as top prints it. top pads each number to five characters, and 100.0 fills the whole space, so it joins onto the comma before it. Every field then shifts by one, and “field 8” becomes the text id,, which breaks any arithmetic done on it. So the script breaks exactly when the machine is at its quietest. Finding the value by its id label works for both lines.

ss and lsof: Who Owns That Port?

nc -l 8080 >/dev/null &
sleep 0.5
echo "-- ss -tlnp:"
ss -tlnp | awk 'NR==1 || /:8080/'
echo "-- lsof -i :8080:"
lsof -nP -i :8080
echo "-- PID only:"
lsof -ti :8080
is_listening() { ss -tln | awk -v p=":$1" '$4 ~ p"$" {found=1} END {exit !found}'; }
is_listening 8080 && echo "8080: listening"
is_listening 80   || echo "80: nothing listening"
kill %1
$ bash ports.sh
-- ss -tlnp:
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0      1            0.0.0.0:8080      0.0.0.0:*    users:(("nc",pid=9,fd=3))
-- lsof -i :8080:
COMMAND PID  USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
nc        9 alice    3u  IPv4 274181      0t0  TCP *:8080 (LISTEN)
-- PID only:
9
8080: listening
80: nothing listening

-t means TCP, -l listening sockets, -n numeric ports, and -p the owning process. is_listening matches the port at the end of the local-address column. A plain grep :80 would also match :8080. Processes owned by other users only appear with sudo.

systemd: systemctl and journalctl

systemd doesn’t run inside this lesson’s container, so these commands are shown for reference only, with no captured output. On a real server they look like this:

systemctl is-active --quiet nginx && echo "nginx up"   # exit status only — ideal in scripts
systemctl is-enabled nginx                             # enabled / disabled
sudo systemctl restart nginx
systemctl list-units --type=service --state=failed
journalctl -u nginx --since "1 hour ago" -p err        # this unit's errors, last hour
journalctl -u nginx -f                                 # follow, like tail -f

For scripts, prefer the --quiet and is-* forms. They report through their exit status, so there’s no text to parse.

Practice

  1. Print a one-line health summary with the 1-minute load, the memory in use as a percentage (total minus available), and the root disk usage. Set status=ALERT if memory is over 90% or disk over 85%.

    Solution
    read -r load1 _ < /proc/loadavg
    mem_pct=$(free -m | awk '/^Mem:/ { printf "%d", ($2 - $7) * 100 / $2 }')
    disk_pct=$(df --output=pcent / | tail -1 | tr -dc '0-9')
    printf 'host=%s load1=%s mem=%s%% disk=%s%%\n' "$(hostname)" "$load1" "$mem_pct" "$disk_pct"
    status=OK
    (( mem_pct > 90 || disk_pct > 85 )) && status=ALERT
    echo "status=$status"
    $ bash p1.sh
    host=devbox load1=1.35 mem=18% disk=6%
    status=OK

    /proc/loadavg is designed to be parsed, which makes it more reliable than scraping the output of uptime.

  2. Free port 9090 by killing whatever is listening on it, then confirm the port is free.

    Solution
    nc -l 9090 >/dev/null &
    sleep 0.5
    pids=$(lsof -ti :9090)
    echo "port 9090 held by PID(s): $pids"
    kill $pids
    sleep 0.2
    lsof -ti :9090 || echo "port 9090 is free"
    $ bash p2.sh
    port 9090 held by PID(s): 9
    port 9090 is free

    $pids is deliberately unquoted so that several PIDs become separate arguments to kill.

  3. Wrap the OS and architecture detection in a platform function that returns strings like linux-amd64, and use it to build a release-download URL.

    Solution
    platform() {
      local os arch
      os=$(uname -s | tr '[:upper:]' '[:lower:]')
      case "$(uname -m)" in
        x86_64) arch=amd64 ;;
        aarch64|arm64) arch=arm64 ;;
        *) return 1 ;;
      esac
      echo "${os}-${arch}"
    }
    echo "https://github.com/example/tool/releases/download/v1.2.0/tool-$(platform).tar.gz"
    $ bash p3.sh
    https://github.com/example/tool/releases/download/v1.2.0/tool-linux-amd64.tar.gz

Frequently Asked Questions

What is the difference between ps aux and ps -ef?
Both list all processes. ps aux uses BSD syntax and shows CPU/memory usage columns. ps -ef uses POSIX syntax and shows the full command with parent PID (PPID). They overlap heavily — ps aux is more common on Linux.
netstat is deprecated — what should I use instead?
Use ss (socket statistics) from the iproute2 package. It is faster, more featureful, and is the modern replacement. Most examples that use netstat translate directly to ss with similar flags.
How do I find what process is using a port?
Use ss -tlnp | grep :PORT or lsof -i :PORT. Both show the PID and process name holding the socket.
Why does my script break when parsing top output?
top pads its numbers to a fixed width, so a value like 100.0 fills the space and joins onto the previous comma, and every awk field after it shifts. Find values by their label instead of by position, or read /proc/stat, /proc/loadavg and free directly, which are designed to be parsed.