Зачем

Стандартные ps/top показывают RSS, который завышает реальное потребление памяти из-за shared-страниц (общие библиотеки, mmap и т.д.). Если процесс запущен в нескольких экземплярах (или использует много shared-памяти), сумма RSS по всем его PID сильно преувеличивает фактическое потребление.

Более честная метрика — Pss (Proportional Set Size) из /proc/<pid>/smaps_rollup: shared-страницы в ней учитываются пропорционально количеству процессов, которые их используют. Сумма Pss по всем процессам системы гораздо ближе к реальной занятой памяти.

Требование к сбору данных

Скрипт сборки статистики специально написан без единого внешнего вызова (awk, grep, cat, sort и т.д.) — только bash-билтины (read, [[ ]], арифметика (( ))). Причина: каждый вызов внешней команды — это fork()+exec(), и если таких вызовов много (сотни процессов × несколько вызовов на процесс), скрипт сам начинает заметно шуметь в системе, которую же и измеряет. На чистом bash в горячем пути остаётся только неизбежное чтение файлов /proc.

Обработка и форматирование вывода (сортировка, конвертация единиц, CSV/JSON/YAML) вынесены в отдельную стадию — там уже можно спокойно использовать awk/sort, так как на сам процесс замера это не влияет.

Скрипт

#!/bin/bash
set -euo pipefail

# ---------- defaults ----------
out_format="flat"     # flat|csv|json|yaml
unit="MB"             # B|KB|MB
sort_key="size"        # name|size        (только для flat)
sort_order="desc"      # asc|desc          (только для flat)
show_header=1           # только для flat
show_total=1            # только для flat

usage() {
    cat <<EOF
Usage: $0 [options]

  -f, --format FORMAT   flat|csv|json|yaml   (default: flat)
  -u, --unit UNIT       B|KB|MB              (default: MB)
  -s, --sort KEY        name|size           [flat only] (default: size)
  -o, --order ORDER     asc|desc            [flat only] (default: desc)
      --no-header                            [flat only]
      --no-total                             [flat only]
  -h, --help
EOF
}

while [[ $# -gt 0 ]]; do
    case "$1" in
        -f|--format) out_format=$2; shift 2 ;;
        -u|--unit)   unit=$2; shift 2 ;;
        -s|--sort)   sort_key=$2; shift 2 ;;
        -o|--order)  sort_order=$2; shift 2 ;;
        --no-header) show_header=0; shift ;;
        --no-total)  show_total=0; shift ;;
        -h|--help)   usage; exit 0 ;;
        *) echo "unknown option: $1" >&2; usage; exit 1 ;;
    esac
done

case "$out_format" in flat|csv|json|yaml) ;; *) echo "bad format: $out_format" >&2; exit 1 ;; esac
case "$unit" in B|KB|MB) ;; *) echo "bad unit: $unit" >&2; exit 1 ;; esac
case "$sort_key" in name|size) ;; *) echo "bad sort key: $sort_key" >&2; exit 1 ;; esac
case "$sort_order" in asc|desc) ;; *) echo "bad sort order: $sort_order" >&2; exit 1 ;; esac

# ---------- сбор: только bash, минимальное влияние на систему ----------
declare -A mem_kb

for pid_dir in /proc/[0-9]*; do
    comm=""
    read -r comm < "$pid_dir/comm" 2>/dev/null
    [[ -z "$comm" ]] && continue

    pss=0
    found=0
    while IFS=$' \t' read -r key value _; do
        if [[ "$key" == "Pss:" ]]; then
            pss=$value
            found=1
            break
        fi
    done < "$pid_dir/smaps_rollup" 2>/dev/null

    [[ $found -eq 1 && "$pss" =~ ^[0-9]+$ ]] || continue

    mem_kb["$comm"]=$(( ${mem_kb["$comm"]:-0} + pss ))
done

# ---------- обработка: обычный режим, внешние тулы ок ----------

convert() {
    # $1 = значение в KB -> строка в целевой единице
    local kb=$1
    case "$unit" in
        B)  echo $(( kb * 1024 )) ;;
        KB) echo "$kb" ;;
        MB) awk -v k="$kb" 'BEGIN{printf "%.2f", k/1024}' ;;
    esac
}

csv_escape() {
    # экранирование под RFC4180: удвоение кавычек, обёртка в кавычки
    local s=${1//\"/\"\"}
    printf '"%s"' "$s"
}

total_kb=0
raw_lines=()
for name in "${!mem_kb[@]}"; do
    kb=${mem_kb[$name]}
    total_kb=$(( total_kb + kb ))
    raw_lines+=("$name"$'\t'"$kb")
done
total_val=$(convert "$total_kb")

case "$out_format" in

flat)
    sort_flags=(-t $'\t')
    if [[ "$sort_key" == "size" ]]; then
        sort_flags+=(-k2,2 -n)
    else
        sort_flags+=(-k1,1)
    fi
    [[ "$sort_order" == "desc" ]] && sort_flags+=(-r)

    sorted=$(printf '%s\n' "${raw_lines[@]}" | sort "${sort_flags[@]}")

    [[ $show_header -eq 1 ]] && printf "%-25s %12s\n" "NAME" "MEM(${unit})"

    while IFS=$'\t' read -r name kb; do
        printf "%-25s %12s\n" "$name" "$(convert "$kb")"
    done <<< "$sorted"

    [[ $show_total -eq 1 ]] && printf "%-25s %12s\n" "TOTAL" "$total_val"
    ;;

csv)
    echo "name,mem_${unit,,}"
    for name in "${!mem_kb[@]}"; do
        printf '%s,%s\n' "$(csv_escape "$name")" "$(convert "${mem_kb[$name]}")"
    done
    ;;

json)
    echo "{"
    first=1
    for name in "${!mem_kb[@]}"; do
        [[ $first -eq 1 ]] || echo ","
        first=0
        printf '  "%s": %s' "$name" "$(convert "${mem_kb[$name]}")"
    done
    echo
    echo "}"
    ;;

yaml)
    for name in "${!mem_kb[@]}"; do
        printf '%s: %s\n' "$name" "$(convert "${mem_kb[$name]}")"
    done
    ;;

esac

Использование

./mem_report.sh                          # flat, MB, шапка+итог, сортировка по размеру убыв.
./mem_report.sh -f json -u KB
./mem_report.sh -f csv -u B
./mem_report.sh -s name -o asc --no-total
./mem_report.sh --no-header --no-total -s name

Опции

ФлагЗначенияПо умолчаниюДействует на
-f, --formatflat, csv, json, yamlflatвсе
-u, --unitB, KB, MBMBвсе
-s, --sortname, sizesizeтолько flat
-o, --orderasc, descdescтолько flat
--no-headerшапка показанатолько flat
--no-totalитог показантолько flat

Заметки на будущее

  • Группировка идёт по comm (имя процесса, обрезано ядром до 15 символов). Для точных длинных имён можно заменить источник имени на readlink -f /proc/$pid/exe или парсинг cmdline, но это дороже и не всегда доступно без прав.
  • Сортировка в flat делается по сырым KB до конвертации в целевую единицу — иначе строки вида "1234.56" сортировались бы как текст.
  • csv/json/yaml не сортируются и не имеют шапки/итога — эти опции относятся только к flat.