find bwlimit at plnode-utils
[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 plnode.bwlimit as 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             if "ERR" in line:
175                 pass # ignore spurious error message from vps
176             else:
177                 print "slicestat: failed to parse line: " + line
178             continue
179         proc = dict(zip(fields, values))
180
181         # Convert ints and floats
182         for field in proc:
183             try:
184                 proc[field] = int(proc[field])
185             except ValueError:
186                 try:
187                     proc[field] = float(proc[field])
188                 except ValueError:
189                     pass
190
191         # vps sometimes prints ERR or the name of the slice
192             # instead of a context ID if it
193         # cannot identify the context of an orphaned (usually dying)
194         # process. Skip these processes.
195         if (type(proc['xid']) != int) or (type(proc['vsize']) !=int):
196             if "ERR" in line:
197                 pass # ignore spurious error message from vps
198             else:
199                 print "slicestat: failed to parse line: " + line
200             continue
201
202         # Assign (pl_)sshd processes to slice instead of root
203         m = re.search(r"sshd: ([a-zA-Z_]+)", proc['cmd'])
204
205         if m is not None:
206             xid = bwlimit.get_xid(m.group(1))
207             if xid is not None:
208                 proc['xid'] = xid
209
210         name = bwlimit.get_slice(proc['xid'])
211         if name is None:
212             # Orphaned (not associated with a slice) class
213             name = "%d?" % proc['xid']
214
215         # Monitor only the specified slices
216         if names and name not in names:
217             continue
218
219         # Additional overhead calculations from slicestat
220
221         # Include 12 KiB of process overhead =
222         # 4 KiB top-level page table +
223         # 4 KiB kernel structure +
224         # 4 KiB basic page table
225         proc['rss'] += 12
226
227         # Include additional page table overhead
228         try:
229             if proc['vsize'] > 4096:
230                 proc['rss'] += 4 * ((proc['vsize'] - 1) / 4096)
231         except: pass
232
233         if slices.has_key(proc['xid']):
234             slice = slices[proc['xid']]
235         else:
236             slice = {'xid': proc['xid'], 'name': name, 'procs': [], 'vsize': 0, 'sz': 0, 'rss': 0}
237
238         slice['procs'].append(proc)
239         slice['vsize'] += proc['vsize']
240         slice['sz'] += proc['sz']
241         slice['rss'] += proc['rss']
242
243         slices[proc['xid']] = slice
244
245     return slices
246
247 def memtotal():
248     """
249     Returns total physical and swap memory on the system in KiB.
250     """
251     mem = 0
252     swap = 0
253     meminfo = open("/proc/meminfo", "r")
254     for line in meminfo.readlines():
255         try:
256             (name, value, kb) = line.split()
257         except:
258             continue
259         if name == "MemTotal:": 
260             mem = int(value)
261         elif name == "SwapTotal:":
262             swap = int(value)
263     meminfo.close()
264     return (mem, swap)
265
266 def swap_used():
267     """
268     Returns swap utilization on the system as a whole percentage (0-100).
269     """
270     total_swap = 0
271     total_used = 0
272     try:
273         swaps = open("/proc/swaps", "r")
274         # Eat header line
275         lines = swaps.readlines()[1:]
276         swaps.close()
277         for line in lines:
278             # /dev/mapper/planetlab-swap partition 1048568 3740 -1
279             (filename, type, size, used, priority) = line.strip().split()
280             try:
281                 total_swap += int(size)
282                 total_used += int(used)
283             except ValueEror, err:
284                 pass
285     except (IOError, KeyError), err:  pass
286
287     swapused = 100 * total_used / total_swap
288     if debug: print "%s percent swap used" % swapused
289     return swapused
290
291 def summary(slices = None, total_mem = None, total_swap = None):
292     """
293     Return a summary of memory usage by slice.
294     """
295     if not slices:  slices = slicestat()
296     slicelist = slices.values()
297     slicelist.sort(lambda a, b: b['sz'] - a['sz'])
298     if total_mem is None or total_swap is None:
299         (total_mem, total_swap) = memtotal()
300
301     table = "%-20s%10s%24s%24s\n\n" % ("Slice", "Processes", "Memory Usage", "Potential Usage")
302     for slice in slicelist:
303         table += "%-20s%10d%16s (%4.1f%%)%16s (%4.1f%%)\n" % \
304                  (slice['name'], len(slice['procs']),
305                   format_bytes(slice['rss'] * 1024, si = False),
306                   100. * slice['rss'] / total_mem,
307                   format_bytes(slice['sz'] * 1024, si = False),
308                   100. * slice['sz'] / (total_mem + total_swap))
309     return table
310
311 def formtable(slice, percent):
312     '''
313     Makes pretty message to email with human readable ps values.
314     '''
315     table = "%5s %10s %10s %10s %4s %4s %s\n\n" % \
316         ("PID", "VIRT", "SZ", "RES", '%CPU', '%MEM', 'COMMAND')
317     for proc in slice['procs']:
318         table += "%5s %10s %10s %10s %4.1f %s\n" % \
319             (proc['pid'],
320             format_bytes(proc['vsize'] * 1024, si = False),
321             format_bytes(proc['sz'] * 1024, si = False),
322             format_bytes(proc['rss'] * 1024, si = False),
323             proc['pmem'],
324             proc['cmd'])
325     
326     prettytable = {'hostname': socket.gethostname(),
327              'date': time.asctime(time.gmtime()) + " GMT",
328              'table': table,
329              'slice': slice['name'],
330              'rss': format_bytes(slice['rss'] * 1024, si = False),
331              'sz': format_bytes(slice['sz'] * 1024, si = False),
332              'percent': percent}
333     return prettytable
334
335 def readdat():
336     '''
337     Return dictionary of vps (slicestat) from datfile left behind by OOM
338     before rebooting.  If none file, just grab the latest dict (slicestat)
339     and return that.  If dat file found, means we rebooted, send an email to 
340     pl_mom@pl.
341     '''
342     try:
343         f = open(DATAFILE, "r+")
344         if verbose:
345             print "Loading %s" % DATAFILE
346         (v, slices) = pickle.load(f)
347         f.close()
348         # Check version of data file
349         if v != VERSION:
350             print "Not using old version '%s' data file %s" % (v, DATAFILE)
351             raise Exception
352
353         params = {'hostname': socket.gethostname(),
354                   'date': time.asctime(time.gmtime()) + " GMT",
355                   'table': summary(slices, total_mem, total_swap)}
356         if debug:
357             print rebooted_subject % params
358             print rebooted_body % params
359         else:
360             slicemail(None, rebooted_subject % params, rebooted_body % params)
361
362         # Delete data file
363         os.unlink(DATAFILE)
364     except Exception:
365         slices = slicestat()
366
367     return slices
368
369
370 def writedat(slices):
371     """
372     Write (slices) to pickled datfile.
373     """
374     if verbose:  print "Saving %s" % DATAFILE
375     f = open(DATAFILE, "w")
376     pickle.dump((VERSION, slices), f)
377     f.close()
378
379
380 def main():
381     # Defaults
382     global debug, verbose, DATAFILE, VERSION 
383     global period, change_thresh, reset_thresh, reboot_thresh, rss_min, system_slices
384     # All slices
385     names = []
386     timer = period
387     last_used = None
388     used = None
389     warned = []
390     emailed = {}
391
392     try:
393         longopts = ["debug", "verbose", "file=", "slice=", "status", "memstatus", "help"]
394         longopts += ["period=", "reset-thresh=", "reboot-thresh=", "min-thresh=", "system-slice="]
395         (opts, argv) = getopt.getopt(sys.argv[1:], "dvf:s:ph", longopts)
396     except getopt.GetoptError, err:
397         print "Error: " + err.msg
398         usage()
399         sys.exit(1)
400
401     for (opt, optval) in opts:
402         if opt == "-d" or opt == "--debug":
403             debug = True
404         elif opt == "-v" or opt == "--verbose":
405             verbose += 1
406         elif opt == "-f" or opt == "--file":
407             DATAFILE = optval
408         elif opt == "-s" or opt == "--slice":
409             names.append(optval)
410         elif opt == "-p" or opt == "--period":
411             period = int(optval)
412         elif opt == "--change-thresh":
413             change_thresh = int(optval)
414         elif opt == "--reset-thresh":
415             reset_thresh = int(optval)
416         elif opt == "--reboot-thresh":
417             reboot_thresh = int(optval)
418         elif opt == "--min-thresh":
419             rss_min = int(optval)
420         elif opt == "--system-slice":
421             system_slices.append(optval)
422         elif opt == "--status":
423             print summary(slicestat(names))
424             sys.exit(0)
425         elif opt == "--memstatus":
426             (mem, swap) = memtotal()
427             swap_pct = swap_used()
428             print "memory total:", mem
429             print "swap total:", swap
430             print "swap used:", swap_pct
431             sys.exit(0)
432         else:
433             usage()
434             sys.exit(0)
435
436     # Check if we are already running
437     writepid("swapmon")
438
439     if not debug:
440         daemonize()
441         # Rewrite PID file
442         writepid("swapmon")
443         # Redirect stdout and stderr to syslog
444         syslog.openlog("swapmon")
445         sys.stdout = sys.stderr = Logger()
446
447     # Get total memory
448     (total_mem, total_swap) = memtotal()
449     slices = readdat()
450
451     # Query process table every 30 seconds, or when a large change in
452     # swap utilization is detected.
453
454     while True:
455         used = swap_used()
456         if last_used is None:  last_used = used
457    
458         if used >= reboot_thresh:
459             # Dump slice state before rebooting
460             writedat(slices)    
461             # Goodbye, cruel world
462             print "%d%% swap consumed, rebooting" % used
463             if not debug:  bwlimit.run("/bin/sync; /sbin/reboot -f")
464         elif used >= reset_thresh:
465             # Try and find a hog
466             slicelist = slices.values()
467             # Puts largest on top.
468             slicelist.sort(lambda a, b: b['rss'] - a['rss'])
469             for slice in slicelist:
470                 percent = 100. * slice['rss'] / total_mem
471                 if slice['rss'] < rss_min: continue
472                 print "%d%% swap consumed, slice %s is using %s (%d%%) of memory" % \
473                     (used,
474                     slice['name'],
475                     format_bytes(slice['rss'] * 1024, si = False),
476                     percent)
477                 slice['procs'].sort(lambda a, b: b['rss'] - a['rss'])
478                 # Make a pretty table.
479                 params = formtable(slice, percent)
480                 # Match slice name against system slice patterns
481                 is_system_slice = filter(None, 
482                     [re.match(pattern, slice['name']) for pattern in system_slices])
483     
484                 # Do not reset system slices, just warn once
485                 if is_system_slice: 
486                     if slice['name'] not in warned:
487                         warned.append(slice['name'])
488                         print "Warning slice " + slice['name']
489                         if debug:
490                             print alarm_subject % params
491                             print alarm_body % params
492                         else:
493                             slicemail(slice['name'], alarm_subject % params, 
494                               alarm_body % params)
495                 else:
496                     # Reset slice
497                     if not debug:
498                         if emailed.get(slice['name'], (time.time() + email_timeout + 1)) > (time.time() + email_timeout): 
499                             slicemail(slice['name'], kill_subject % params, kill_body % params)
500                             emailed[slice['name']] = time.time()
501                     else:
502                         print kill_subject % params
503                         print kill_body % params
504                     print "Killing procs in %s" % slice['name']
505                     killsliverprocs(slice['xid'])
506
507         # wait period before recalculating swap.  If in danger, recalc.
508         if timer <= 0 or used >= (last_used + change_thresh):
509             if used >= (last_used + change_thresh):
510                 print "%d%% swap consumed, %d%% in last %d seconds" % \
511                     (used, used - last_used, period - timer)
512             # Get slice state
513             slices = slicestat(names)
514             # Reset timer
515             timer = period
516             # Keep track of large changes in swap utilization
517             last_used = used
518         timer -= 1
519         time.sleep(1)
520
521     removepid("swapmon")
522
523 if __name__ == '__main__':
524     main()