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