Skip to content
Permalink
69d8f8ed98
Switch branches/tags

Name already in use

A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Go to file
 
 
Cannot retrieve contributors at this time
executable file 534 lines (434 sloc) 14.7 KB
#! /usr/bin/env python3
'''
show memory consumption per user
The script analyses actual or recorded memory usage on the
host where it is run.
The default is to read the information live from the /proc
directory. When called with the '-c' option, /var/log is
searched for the most recent forensics dump.
Setting the FORENSICS_STORE environment variable overrides the
default directory where the forensics logs are searched.
What to do if too much memory is burned on a given host?
#> ps -u $USER -o pid,state,lstart,vsz,cmd
This helps finding old processes, or one may just end _all_
own sessions on this host:
#> killall -u $USER
History:
13.10.2022, created, Kreitler
08.05.2024, default to reading the state 'live'
'''
import os
import re
import pwd
import sys
import time
import argparse
# # Debug helper
# try:
# from dumper import dump
# except ModuleNotFoundError:
# from pprint import pprint as dump
# ------------------------------------------ group/summarize data in a tree
class Classifier():
''' Use to group/summarize values into a tree structure. '''
def __init__(self):
self.cnt = 0
self.fld = dict()
self.val = 0
self._ik = None
def __iter__(self): # this is either expensive, stupid, or already present elsewhere in python
self._ik = iter(sorted (self.fld.keys()))
return self
def __next__(self):
n = next(self._ik)
return (n, self.fld[n])
def cfy(self, fields, val=0):
self.cnt += 1
self.val += val
if len(fields) > 0:
f = fields.pop(0)
if f not in self.fld: self.fld[f] = Classifier()
self.fld[f].cfy(fields, val)
return val
def get(self, key):
return self.fld[key]
def getval(self):
return self.val
def dump_simple(self, lbl='.', ind=0):
print('# %s %-8s %6d ,'%(' '*ind,lbl+':', self.cnt), self.val)
ks = sorted(self.fld.keys())
for k in ks:
self.fld[k].dump_simple(k, ind+2)
pass
# ------------------------------------ access to proc data (stored or live)
class ProcBuffer():
'''
Reads from /proc, provides an iterator. Keeps ProcStream simple,
allows handling /proc the same way as a 'forensics' log.
Example usage:
pb = ProcBuffer()
print(pb.read())
for line in pb:
print(line)
'''
def __init__(self, pdir='/proc'):
self.pdir = pdir
self.buf = []
self.bufit = iter(self.buf)
def __iter__(self):
return self
def __next__(self):
return next(self.bufit)
def readfile(self, fn):
lines = 0
try:
f = open(fn, errors='backslashreplace')
except (FileNotFoundError, PermissionError): # do nothing
return 0
for line in f:
self.buf.append(fn + ' ' + line.strip())
lines += 1
f.close()
return lines
def read(self):
# get machine info, return number of items
for pf in ('uptime', 'meminfo'):
self.readfile(self.pdir + '/' + pf)
# go for the PIDs
for pd in os.scandir(self.pdir):
if pd.name.isnumeric() and pd.is_dir(): # isdigit(), isdecimal() -- which one ?
for pf in ('stat', 'status'):
self.readfile(self.pdir + '/' + pd.name + '/' + pf)
return len(self.buf)
def get_iter(self):
self.bufit = iter(self.buf)
return self.bufit
class ProcInfoBase():
def __iter__(self):
return self
# today nobody closes files, but I like to ...
def close(self):
pass
class ProcInfo(ProcInfoBase):
''' Read from /proc .'''
def __init__(self, pdir='/proc'):
pb = ProcBuffer(pdir)
pb.read()
self.pbi = pb.get_iter()
self.source = pdir
def __next__(self):
return next(self.pbi)
class ProcInfoSaved(ProcInfoBase): # aka forensics
''' Read from forensics file. '''
def __init__(self, logfile):
self.file = open(logfile, errors='backslashreplace')
self.source = logfile
def __next__(self):
line = self.file.readline()
if not line:
self.close()
raise StopIteration
return line.strip()
def close(self):
if self.file:
self.file.close()
self.file = None
# ------------------------------------------------------- collect proc data
class ProcStreamParser():
'''
state: R is running, S is sleeping,
D is sleeping in an uninterruptible wait,
Z is zombie, T is traced or stopped,
I is idle, and not in the docs ?
'''
def __init__(self,procfshandler):
self.pfh = procfshandler
self.pidrex = re.compile(r'/proc/(\d+)/(\S+)\s+(.*)') # :xxx: hardcoded '/proc'
def parse(self, line):
m = self.pidrex.match(line)
if not m:
if line.startswith('/proc/uptime '):
wall_clock = float(line.split()[1])
self.pfh.set_uptime(wall_clock)
self.pfh.report_append( '# uptime: %.2f s, %.2f d' % (wall_clock, wall_clock/3600.0/24.0) )
elif line.startswith('/proc/meminfo MemTotal'):
memtotal = int(line.split()[2])
self.pfh.set_memtotal(memtotal)
self.pfh.report_append( '# memtotal: %d kB, %.2f GB' % (memtotal, memtotal/1024/1024) )
else:
pid = int(m.group(1))
pfile = m.group(2)
entry = m.group(3)
if pfile == 'stat':
fields = entry.split()
state = fields[2]
start_time = float(fields[21])/100.0 # seconds please ...
self.pfh.set_info(pid, 'start_time', start_time)
elif pfile == 'status':
if entry.startswith('VmData:'):
val = int(entry.split()[1])
self.pfh.set_info(pid, 'vmdata', val)
elif entry.startswith('State:'): # redundant
val = entry.split()[1]
self.pfh.set_info(pid, 'state', val)
elif entry.startswith('Uid:'):
val = int(entry.split()[1])
self.pfh.set_info(pid, 'uid', val)
# --------------------------------------------------------------- workhorse
class ProcFsHandler():
def __init__(self, classifier = Classifier(), age_thresh = 2):
self.Cfy = classifier
self.store = {}
self.usermap = {}
self.report = []
self.uptime = -1
self.memtotal = -1
self.age_threshold = age_thresh * 60*60*24 # days
self.supr_sys = False # used to suppress system accounts
def set_uptime(self, t):
self.uptime = t
def set_memtotal(self, m):
self.memtotal = m
def suppress_system_acc(self):
self.supr_sys = True
def report_append(self, s):
self.report.append(s)
# fill hash
def set_info(self, pid, key, val):
if pid not in self.store:
self.store[pid] = {}
self.store[pid][key] = val
def analyze(self):
for p in self.store:
# --------- resolve_user_names
name = '_unknown_'
vmdata = 0
state = ''
uid = self.store[p]['uid']
if self.supr_sys and (uid < 100 or uid >= 65533): continue
if not uid in self.usermap:
try:
name = pwd.getpwuid(uid).pw_name
except KeyError:
name = str(uid)
self.usermap[uid] = name
name = self.usermap[uid]
# ------------------- classify
state = self.store[p]['state']
if state in 'DZT': state = 'DZT'
if 'vmdata' in self.store[p]:
vmdata = int(self.store[p]['vmdata'])
if (self.uptime - self.store[p]['start_time']) < self.age_threshold:
# 'young' ones get a lower case letter
state = state.lower()
else:
continue # nothing to sum up
self.Cfy.cfy( [name, state], vmdata)
# -------------------------- memory classifier that 'reports' formated bars
class ProcMemClassifier(Classifier):
keys = ('DZT','S','R','dzt','s','r')
def maxMem(self):
m = max(k[1].val for k in self)
return m
def barStrings(self, scmax = 100, width = 80):
'''
returns a string array for display:
string has 3 components: 'user |X-----| memory' -- name bar amount
'''
# 10 for the user, 12 is default width for the number,
# 2 for fillers and such, 2 for the border
bar_width = width - 10 - 12 - 2 - 2
ret = list()
for k in self:
user = k[0]
mtot = k[1].val
bar = ' %-10s' % str(user)[:9] + '|'
nused = 0
for key in self.keys:
if key in k[1].fld:
ks = k[1].fld[key]
n = int(ks.val/scmax * bar_width)
if n > 0:
# todo: mind max clipping!
bar += key[0] + '-' * (n-1)
nused += n
remain = bar_width - nused
if remain <= 0:
bar += '|'
else:
bar += '|' + ' ' * (remain-1) + '.'
bar += sep_fmt12(k[1].val)
ret.append((user, mtot, bar))
return ret
def summaryStrings(self):
'''
returns a string array for condensed display:
string has 3 components: 'user memory states'
'''
ret = list()
for k in self:
states = ''
user = k[0]
mtot = k[1].val
line = ' %-10s' % str(user)[:9] + ':'
line += sep_fmt12(k[1].val)
for key in self.keys:
if key in k[1].fld:
states += key[0]
line += ' : [' + states + ']'
ret.append((user, mtot, line))
return ret
def collectusage(self, keys= '', memlimit=0):
ret = list()
for k in self:
memused = 0
for key in keys:
if key == 'D': key = 'DZT' # ugly
if key == 'd': key = 'dzt'
if key in k[1].fld:
if k[1].fld[key].val > memlimit:
memused += k[1].fld[key].val
if memused:
ret.append((k[0], memused))
return ret
# ------------------------------------------------------------------- tools
def register_logs(logdir):
''' Returns forensics-logs ordered by age, newest first. '''
tmp_logrec = []
for log in '00 10 20 30 40 50'.split():
logfile = os.path.join(logdir, 'forensics-%sth_min.log' % log)
if os.access(logfile, os.R_OK):
mt = os.stat(logfile).st_mtime
tmp_logrec.append((logfile,mt))
logs = sorted (tmp_logrec, key=lambda L: L[1], reverse=True)
return logs
def sep_fmt12(n = 0.0):
''' Gives readable positive numbers of width 12. '''
ret = '%12s' % '{0:,}'.format(n) # new in C: printf("%'.2f", 1234567.89); ???
ret = ret.replace(',','.')
if len(ret) > 12: ret = '%12.4g' % n
return ret
def get_term_size():
''' Say 'no' to shutil. '''
cols_lines = (80, 24)
try:
cols_lines = os.get_terminal_size()
except (ValueError, OSError):
pass
return cols_lines
def chk_term_size_usable():
''' Make sure terminal area is large enough, tell user. '''
bail = False
min_w = 40
min_h = 12
cols, lines = get_term_size()
if cols < min_w:
print('# Fatal: Terminal window is not wide enough. Columns needed:', min_w,
'( got', cols, ')', file=sys.stderr )
bail = True
if lines < min_h:
print('# Fatal: Terminal window is not high enough. Lines needed:', min_h,
'( got', lines, ')', file=sys.stderr )
bail = True
if bail:
print('# Will end now ...', file=sys.stderr)
return False
return True
# ---------------------------------------------------------- user interface
def handle_args():
''' Guess? '''
ap = argparse.ArgumentParser(allow_abbrev=True, description =
'Read conserved or live information from the /proc directory, ' +
'and shows memory consumption per user.' )
ap.add_argument('-H', dest='pydoc',
help="show documentation", action='store_true')
ap.add_argument("-a", dest='allentries',
help='print all entries, makes you scroll, helps when piping', action='store_true')
ap.add_argument('-c', dest='readconserved',
help='read data from a conserved forensics dump', action='store_true')
ap.add_argument("-d", dest='logdir', metavar='dir', default=None,
help='location of forensics logs (/var/log)')
ap.add_argument('-m', dest='memthresh', metavar='percent' ,
help='threshold for memory usage report (10%%)', default=10.0, type=float)
ap.add_argument('-o', dest='no_sysacc',
help='omit system accounts from being reported', action='store_true', default=False)
ap.add_argument('-q', dest='query', metavar='query' ,
help='report memory usage for given categories (eg. \'SD\')', default='')
ap.add_argument('-s', dest='summary',
help='print short summary', action='store_true')
ap.add_argument('-t', dest='durationthresh', metavar='days' ,
help='time in days when a job is considered as old', default=2)
ap.add_argument('-v', dest='verbose',
help='be a bit more verbose', action='store_true')
ap.add_argument('forensicsfile', metavar='file', nargs='?',
help='forensics file (defaults to most recent log found)')
return ap.parse_args()
# ------------------------------------------------------------- ab die post
if __name__ == '__main__':
args = handle_args()
if args.pydoc:
import subprocess
subprocess.run(('pydoc3', sys.argv[0]))
quit()
if not chk_term_size_usable(): quit()
proc = None
if args.forensicsfile:
if os.access(args.forensicsfile, os.R_OK):
proc = ProcInfoSaved(args.forensicsfile)
else:
print('# Error: can not read', args.forensicsfile, file=sys.stderr)
quit()
elif args.readconserved:
default_logdir = '/var/log'
logdir = None
if 'FORENSICS_STORE' in os.environ:
logdir = os.environ['FORENSICS_STORE']
if args.logdir:
logdir = args.logdir
if not logdir:
logdir = default_logdir
if not os.access(logdir, os.R_OK):
print('# Error: can not access', logdir, file=sys.stderr)
quit()
logs = register_logs(logdir)
if not len(logs):
print('# Fatal: no logs found in', logdir, file=sys.stderr)
quit()
proc = ProcInfoSaved(logs[0][0])
else:
proc = ProcInfo()
if args.verbose:
print(' Reading:', "'%s'" % proc.source, '...')
pmc = ProcMemClassifier()
whs = ProcFsHandler(pmc, float(args.durationthresh))
if args.no_sysacc: whs.suppress_system_acc()
psp = ProcStreamParser(whs)
for line in proc:
psp.parse(line)
proc.close()
whs.analyze()
if args.query:
memthresh = args.memthresh
keys = args.query
result = pmc.collectusage(keys, whs.memtotal*(memthresh/100))
for r in result:
print('%s %.1f' % (r[0], r[1]/1e6))
quit()
print(' Memory: %.1f Gb available, %.1f Gb in use (%.1f %%)\n' %
( whs.memtotal/1024**2,
pmc.getval()/1024**2,
100 * pmc.getval()/whs.memtotal )
)
if args.summary:
for i in sorted(pmc.summaryStrings(), key=lambda L: L[1], reverse=True):
print(i[2])
else:
cols, lines = get_term_size()
maxmem_used = pmc.maxMem()
print(' USER ', '*** OLD/young processes ***'.center(cols-23) , 'Amount [kb]')
mem_bars = pmc.barStrings(maxmem_used, cols)
limit = lines-11 if not args.allentries else len(mem_bars)
for i in sorted(mem_bars, key=lambda L: L[1], reverse=True)[:limit]:
print(i[2])
print('*** R = running, S = sleeping, D = deep sleep, zombie, or debugged ***'.center(cols))
print()