fix problem parsing output of vps; add --memstatus command
[mom.git] / swapmon.py
1 #!/usr/bin/python
2 #
3 # Swap monitoring daemon. Every 30 seconds, checks process memory
4 # usage. At 90% utilization, resets the slice that is consuming the
5 # most physical memory. At 95% utilization, reboots the machine to
6 # avoid a crash.
7 #
8 # Mark Huang <mlhuang@cs.princeton.edu>
9 # Andy Bavier <acb@cs.princeton.edu>
10 # Faiyaz Ahmed <faiyaza@cs.princeton.edu>
11 # Copyright (C) 2004-2006 The Trustees of Princeton University
12 #
13 # $Id$
14 #
15
16 import syslog
17 import os
18 import sys
19 import getopt
20 import re
21 import pickle
22 import socket
23 import time
24
25 # bwlimit exports a few useful functions like run(), get_xid(), and get_slice()
26 import bwlimit
27
28 # Utility functions
29 from pl_mom import *
30
31 # Defaults
32 debug = False
33 verbose = 0
34 DATAFILE = "/var/lib/misc/swapmon.dat"
35 VERSION = "$Id$"
36 # Seconds between process analysis
37 period = 30
38
39 # Minimum change in swap utilization over 30 seconds that will trigger
40 # early process analysis.
41 change_thresh = 5
42
43 # Swap utilization at which the largest consumer of physical memory is reset
44 reset_thresh = 80
45
46 # Swap utilization at which the machine is rebooted
47 reboot_thresh = 95
48
49 # Don't email the same message more than once in the same emailtimeout interval
50 email_timeout = 1800
51
52 # Physical size threshold to be considered a consumer.  Rationale is if there are no procs
53 # with a size at least as large as this, then there is a slow leaker;  better to just reboot.
54 rss_min = 150 * 1024 
55
56 # System slices that should not be reset (regexps)
57 system_slices = ['root', PLC_SLICE_PREFIX + '_']
58
59 # Message sent after a critical reboot
60 rebooted_subject = "pl_mom rebooted %(hostname)s"
61 rebooted_body = \
62 """
63 Sometime before %(date)s, swap space was
64 nearly exhausted on %(hostname)s, so pl_mom rebooted it.
65
66 Slices active prior to reboot are listed below. Memory usage
67 statistics are not entirely accurate due to threading.
68
69 %(table)s
70
71 %(date)s %(hostname)s reboot
72 """.lstrip()
73
74 # Message sent to system slices that should not be reset
75 alarm_subject = "pl_mom alarm slice %(slice)s on %(hostname)s"
76 alarm_body = \
77 """           
78 Sometime before %(date)s, swap space was
79 nearly exhausted on %(hostname)s.
80
81 System slice %(slice)s was the largest consumer of physical memory at
82 %(rss)s (%(percent)4.1f%%) (%(sz)s writable). It was not reset,
83 but please verify its behavior.
84
85 %(slice)s processes prior to alarm:
86
87 %(table)s
88
89 %(date)s %(hostname)s alarm %(slice)s
90 """.lstrip()
91
92 # Message sent after a slice has been killed
93 kill_subject = "pl_mom killed slice %(slice)s on %(hostname)s"
94 kill_body = \
95 """
96 Sometime before %(date)s, swap space was
97 nearly exhausted on %(hostname)s.
98
99 Slice %(slice)s was killed since it was the largest consumer of
100 physical memory at %(rss)s (%(percent)4.1f%%) (%(sz)s writable)
101 after repeated restarts.
102
103 Please reply to this message explaining the nature of your experiment,
104 and what you are doing to address the problem.
105
106 %(slice)s processes prior to reset:
107
108 %(table)s
109
110 %(date)s %(hostname)s reset %(slice)s
111 """.lstrip()
112
113 def killsliverprocs(xid):
114     bwlimit.run("/usr/sbin/vkill -s 9 -c %s 0" % xid)    
115
116
117 def usage():
118     print """
119 Usage: %s [OPTIONS]...
120
121 Options:
122         -d, --debug             Enable debugging (default: %s)
123         -v, --verbose           Increase verbosity level (default: %d)
124         -f, --file=FILE         Data file (default: %s)
125         -s, --slice=SLICE       Constrain monitoring to these slices (default: all)
126         -p, --period=SECONDS    Seconds between normal process analysis (default: %s)
127         --reset-thresh=PERCENT  Swap utilization at which slice reset is attempted
128         --reboot-thresh=PERCENT Swap utilization at which the machine is rebooted
129         --min-thresh=PERCENT    Minimum physical memory utilization to be considered a hog
130         --system-slice=SLICE    System slice that should not be reset
131         --status                Print memory usage statistics and exit
132         --memstatus             Print total memory, total swap, and swap used
133         -h, --help              This message
134 """.lstrip() % (sys.argv[0], debug, verbose, DATAFILE, format_period(period))
135
136 def slicestat(names = None):
137     """
138     Get status of specified slices (if names is None or empty, all
139     slices). vsize, sz, and rss are in KiB. Returns
140     PID CONTEXT             VSZ    SZ   RSS %MEM CMD
141     {xid: {'xid': slice_id,
142             'name': slice_name,
143             'procs': [{'pid': pid, 'xid': slice_id, 'cmd': command,
144                       'vsize': virtual_kib, 'sz': potential_kib, 'rss': physical_kib,
145                       'pcpu': cpu_percent, 'pmem': mem_percent}]
146             'vsize': total_virtual_kib,
147             'sz': total_potential_kib,
148             'rss': total_physical_kib}}
149     """
150     
151     # Mandatory fields. xid is a virtual field inserted by vps. Make
152     # sure cmd is last so that it does not get truncated
153     # automatically.
154     fields = ['pid', 'xid', 'vsname', 'vsize', 'sz', 'rss', 'pmem', 'cmd']
155
156     # vps inserts xid after pid in the output, but ps doesn't know
157     # what the field means.
158     ps_fields = list(fields)
159     ps_fields.remove('xid')
160     ps_fields.remove('vsname')
161
162     slices = {}
163
164     # Eat the header line. vps depends on the header to figure out
165     # which column is the PID column, so we can't just tell ps not to
166     # print it.
167     for line in bwlimit.run("/usr/sbin/vps -e -o " + ":16,".join(ps_fields))[1:]:
168         # Chomp newline
169         line = line.strip()
170
171         # Represent process as a dict of fields
172         values = line.split(None, len(fields) - 1)
173         if len(values) != len(fields):
174             print "slicestat: failed to parse line:", line
175             continue
176         proc = dict(zip(fields, values))
177
178         # Convert ints and floats
179         for field in proc:
180             try:
181                 proc[field] = int(proc[field])
182             except ValueError:
183                 try:
184                     proc[field] = float(proc[field])
185                 except ValueError:
186                     pass
187
188         # vps sometimes prints ERR or the name of the slice 
189             # instead of a context ID if it
190         # cannot identify the context of an orphaned (usually dying)
191         # process. Skip these processes.
192         if (type(proc['xid']) != int) or (type(proc['vsize']) !=int):
193             print "slicestat: failed to parse line:", line
194             continue
195
196         # Assign (pl_)sshd processes to slice instead of root
197         m = re.search(r"sshd: ([a-zA-Z_]+)", proc['cmd'])
198
199         if m is not None:
200             xid = bwlimit.get_xid(m.group(1))
201             if xid is not None:
202                 proc['xid'] = xid
203
204         name = bwlimit.get_slice(proc['xid'])
205         if name is None:
206             # Orphaned (not associated with a slice) class
207             name = "%d?" % proc['xid']
208
209         # Monitor only the specified slices
210         if names and name not in names:
211             continue
212
213         # Additional overhead calculations from slicestat
214
215         # Include 12 KiB of process overhead =
216         # 4 KiB top-level page table +
217         # 4 KiB kernel structure +
218         # 4 KiB basic page table
219         proc['rss'] += 12
220
221         # Include additional page table overhead
222         try:
223             if proc['vsize'] > 4096:
224                 proc['rss'] += 4 * ((proc['vsize'] - 1) / 4096)
225         except: pass
226
227         if slices.has_key(proc['xid']):
228             slice = slices[proc['xid']]
229         else:
230             slice = {'xid': proc['xid'], 'name': name, 'procs': [], 'vsize': 0, 'sz': 0, 'rss': 0}
231
232         slice['procs'].append(proc)
233         slice['vsize'] += proc['vsize']
234         slice['sz'] += proc['sz']
235         slice['rss'] += proc['rss']
236
237         slices[proc['xid']] = slice
238
239     return slices
240
241 def memtotal():
242     """
243     Returns total physical and swap memory on the system in KiB.
244     """
245     mem = 0
246     swap = 0
247     meminfo = open("/proc/meminfo", "r")
248     for line in meminfo.readlines():
249         try:
250             (name, value, kb) = line.split()
251         except:
252             continue
253         if name == "MemTotal:": 
254             mem = int(value)
255         elif name == "SwapTotal:":
256             swap = int(value)
257     meminfo.close()
258     return (mem, swap)
259
260 def swap_used():
261     """
262     Returns swap utilization on the system as a whole percentage (0-100).
263     """
264     total_swap = 0
265     total_used = 0
266     try:
267         swaps = open("/proc/swaps", "r")
268         # Eat header line
269         lines = swaps.readlines()[1:]
270         swaps.close()
271         for line in lines:
272             # /dev/mapper/planetlab-swap partition 1048568 3740 -1
273             (filename, type, size, used, priority) = line.strip().split()
274             try:
275                 total_swap += int(size)
276                 total_used += int(used)
277             except ValueEror, err:
278                 pass
279     except (IOError, KeyError), err:  pass
280
281     swapused = 100 * total_used / total_swap
282     if debug: print "%s percent swap used" % swapused
283     return swapused
284
285 def summary(slices = None, total_mem = None, total_swap = None):
286     """
287     Return a summary of memory usage by slice.
288     """
289     if not slices:  slices = slicestat()
290     slicelist = slices.values()
291     slicelist.sort(lambda a, b: b['sz'] - a['sz'])
292     if total_mem is None or total_swap is None:
293         (total_mem, total_swap) = memtotal()
294
295     table = "%-20s%10s%24s%24s\n\n" % ("Slice", "Processes", "Memory Usage", "Potential Usage")
296     for slice in slicelist:
297         table += "%-20s%10d%16s (%4.1f%%)%16s (%4.1f%%)\n" % \
298                  (slice['name'], len(slice['procs']),
299                   format_bytes(slice['rss'] * 1024, si = False),
300                   100. * slice['rss'] / total_mem,
301                   format_bytes(slice['sz'] * 1024, si = False),
302                   100. * slice['sz'] / (total_mem + total_swap))
303     return table
304
305 def formtable(slice, percent):
306     '''
307     Makes pretty message to email with human readable ps values.
308     '''
309     table = "%5s %10s %10s %10s %4s %4s %s\n\n" % \
310         ("PID", "VIRT", "SZ", "RES", '%CPU', '%MEM', 'COMMAND')
311     for proc in slice['procs']:
312         table += "%5s %10s %10s %10s %4.1f %s\n" % \
313             (proc['pid'],
314             format_bytes(proc['vsize'] * 1024, si = False),
315             format_bytes(proc['sz'] * 1024, si = False),
316             format_bytes(proc['rss'] * 1024, si = False),
317             proc['pmem'],
318             proc['cmd'])
319     
320     prettytable = {'hostname': socket.gethostname(),
321              'date': time.asctime(time.gmtime()) + " GMT",
322              'table': table,
323              'slice': slice['name'],
324              'rss': format_bytes(slice['rss'] * 1024, si = False),
325              'sz': format_bytes(slice['sz'] * 1024, si = False),
326              'percent': percent}
327     return prettytable
328
329 def readdat():
330     '''
331     Return dictionary of vps (slicestat) from datfile left behind by OOM
332     before rebooting.  If none file, just grab the latest dict (slicestat)
333     and return that.  If dat file found, means we rebooted, send an email to 
334     pl_mom@pl.
335     '''
336     try:
337         f = open(DATAFILE, "r+")
338         if verbose:
339             print "Loading %s" % DATAFILE
340         (v, slices) = pickle.load(f)
341         f.close()
342         # Check version of data file
343         if v != VERSION:
344             print "Not using old version '%s' data file %s" % (v, DATAFILE)
345             raise Exception
346
347         params = {'hostname': socket.gethostname(),
348                   'date': time.asctime(time.gmtime()) + " GMT",
349                   'table': summary(slices, total_mem, total_swap)}
350         if debug:
351             print rebooted_subject % params
352             print rebooted_body % params
353         else:
354             slicemail(None, rebooted_subject % params, rebooted_body % params)
355
356         # Delete data file
357         os.unlink(DATAFILE)
358     except Exception:
359         slices = slicestat()
360
361     return slices
362
363
364 def writedat(slices):
365     """
366     Write (slices) to pickled datfile.
367     """
368     if verbose:  print "Saving %s" % DATAFILE
369     f = open(DATAFILE, "w")
370     pickle.dump((VERSION, slices), f)
371     f.close()
372
373
374 def main():
375     # Defaults
376     global debug, verbose, DATAFILE, VERSION 
377     global period, change_thresh, reset_thresh, reboot_thresh, rss_min, system_slices
378     # All slices
379     names = []
380     timer = period
381     last_used = None
382     used = None
383     warned = []
384     emailed = {}
385
386     try:
387         longopts = ["debug", "verbose", "file=", "slice=", "status", "memstatus", "help"]
388         longopts += ["period=", "reset-thresh=", "reboot-thresh=", "min-thresh=", "system-slice="]
389         (opts, argv) = getopt.getopt(sys.argv[1:], "dvf:s:ph", longopts)
390     except getopt.GetoptError, err:
391         print "Error: " + err.msg
392         usage()
393         sys.exit(1)
394
395     for (opt, optval) in opts:
396         if opt == "-d" or opt == "--debug":
397             debug = True
398         elif opt == "-v" or opt == "--verbose":
399             verbose += 1
400         elif opt == "-f" or opt == "--file":
401             DATAFILE = optval
402         elif opt == "-s" or opt == "--slice":
403             names.append(optval)
404         elif opt == "-p" or opt == "--period":
405             period = int(optval)
406         elif opt == "--change-thresh":
407             change_thresh = int(optval)
408         elif opt == "--reset-thresh":
409             reset_thresh = int(optval)
410         elif opt == "--reboot-thresh":
411             reboot_thresh = int(optval)
412         elif opt == "--min-thresh":
413             rss_min = int(optval)
414         elif opt == "--system-slice":
415             system_slices.append(optval)
416         elif opt == "--status":
417             print summary(slicestat(names))
418             sys.exit(0)
419         elif opt == "--memstatus":
420             (mem, swap) = memtotal()
421             swap_pct = swap_used()
422             print "memory total:", mem
423             print "swap total:", swap
424             print "swap used:", swap_pct
425             sys.exit(0)
426         else:
427             usage()
428             sys.exit(0)
429
430     # Check if we are already running
431     writepid("swapmon")
432
433     if not debug:
434         daemonize()
435         # Rewrite PID file
436         writepid("swapmon")
437         # Redirect stdout and stderr to syslog
438         syslog.openlog("swapmon")
439         sys.stdout = sys.stderr = Logger()
440
441     # Get total memory
442     (total_mem, total_swap) = memtotal()
443     slices = readdat()
444
445     # Query process table every 30 seconds, or when a large change in
446     # swap utilization is detected.
447
448     while True:
449         used = swap_used()
450         if last_used is None:  last_used = used
451    
452         if used >= reboot_thresh:
453             # Dump slice state before rebooting
454             writedat(slices)    
455             # Goodbye, cruel world
456             print "%d%% swap consumed, rebooting" % used
457             if not debug:  bwlimit.run("/bin/sync; /sbin/reboot -f")
458         elif used >= reset_thresh:
459             # Try and find a hog
460             slicelist = slices.values()
461             # Puts largest on top.
462             slicelist.sort(lambda a, b: b['rss'] - a['rss'])
463             for slice in slicelist:
464                 percent = 100. * slice['rss'] / total_mem
465                 if slice['rss'] < rss_min: continue
466                 print "%d%% swap consumed, slice %s is using %s (%d%%) of memory" % \
467                     (used,
468                     slice['name'],
469                     format_bytes(slice['rss'] * 1024, si = False),
470                     percent)
471                 slice['procs'].sort(lambda a, b: b['rss'] - a['rss'])
472                 # Make a pretty table.
473                 params = formtable(slice, percent)
474                 # Match slice name against system slice patterns
475                 is_system_slice = filter(None, 
476                     [re.match(pattern, slice['name']) for pattern in system_slices])
477     
478                 # Do not reset system slices, just warn once
479                 if is_system_slice: 
480                     if slice['name'] not in warned:
481                         warned.append(slice['name'])
482                         print "Warning slice " + slice['name']
483                         if debug:
484                             print alarm_subject % params
485                             print alarm_body % params
486                         else:
487                             slicemail(slice['name'], alarm_subject % params, 
488                               alarm_body % params)
489                 else:
490                     # Reset slice
491                     if not debug:
492                         if emailed.get(slice['name'], (time.time() + email_timeout + 1)) > (time.time() + email_timeout): 
493                             slicemail(slice['name'], kill_subject % params, kill_body % params)
494                             emailed[slice['name']] = time.time()
495                     else:
496                         print kill_subject % params
497                         print kill_body % params
498                     print "Killing procs in %s" % slice['name']
499                     killsliverprocs(slice['xid'])
500
501         # wait period before recalculating swap.  If in danger, recalc.
502         if timer <= 0 or used >= (last_used + change_thresh):
503             if used >= (last_used + change_thresh):
504                 print "%d%% swap consumed, %d%% in last %d seconds" % \
505                     (used, used - last_used, period - timer)
506             # Get slice state
507             slices = slicestat(names)
508             # Reset timer
509             timer = period
510             # Keep track of large changes in swap utilization
511             last_used = used
512         timer -= 1
513         time.sleep(1)
514
515     removepid("swapmon")
516
517 if __name__ == '__main__':
518     main()