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