Setting tag mom-2.3-5
[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
14 import syslog
15 import os
16 import sys
17 import getopt
18 import re
19 import pickle
20 import socket
21 import time
22
23 # bwlimit exports a few useful functions like run(), get_xid(), and get_slice()
24 import plnode.bwlimit as bwlimit
25
26 # Utility functions
27 from pl_mom import *
28
29 # Defaults
30 debug = False
31 verbose = 0
32 DATAFILE = "/var/lib/misc/swapmon.dat"
33 # xxx fixme - this is broken under git
34 VERSION = "$Id$"
35 # Seconds between process analysis
36 period = 30
37
38 # Minimum change in swap utilization over 30 seconds that will trigger
39 # early process analysis.
40 change_thresh = 5
41
42 # Swap utilization at which the largest consumer of physical memory is reset
43 reset_thresh = 80
44
45 # Swap utilization at which the machine is rebooted
46 reboot_thresh = 95
47
48 # Don't email the same message more than once in the same emailtimeout interval
49 email_timeout = 1800
50
51 # Physical size threshold to be considered a consumer.  Rationale is if there are no procs
52 # with a size at least as large as this, then there is a slow leaker;  better to just reboot.
53 rss_min = 150 * 1024 
54
55 # System slices that should not be reset (regexps)
56 system_slices = ['root', PLC_SLICE_PREFIX + '_']
57
58 # Message sent after a critical reboot
59 rebooted_subject = "pl_mom rebooted %(hostname)s"
60 rebooted_body = \
61 """
62 Sometime before %(date)s, swap space was
63 nearly exhausted on %(hostname)s, so pl_mom rebooted it.
64
65 Slices active prior to reboot are listed below. Memory usage
66 statistics are not entirely accurate due to threading.
67
68 %(table)s
69
70 %(date)s %(hostname)s reboot
71 """.lstrip()
72
73 # Message sent to system slices that should not be reset
74 alarm_subject = "pl_mom alarm slice %(slice)s on %(hostname)s"
75 alarm_body = \
76 """           
77 Sometime before %(date)s, swap space was
78 nearly exhausted on %(hostname)s.
79
80 System slice %(slice)s was the largest consumer of physical memory at
81 %(rss)s (%(percent)4.1f%%) (%(sz)s writable). It was not reset,
82 but please verify its behavior.
83
84 %(slice)s processes prior to alarm:
85
86 %(table)s
87
88 %(date)s %(hostname)s alarm %(slice)s
89 """.lstrip()
90
91 # Message sent after a slice has been killed
92 kill_subject = "pl_mom killed slice %(slice)s on %(hostname)s"
93 kill_body = \
94 """
95 Sometime before %(date)s, swap space was
96 nearly exhausted on %(hostname)s.
97
98 Slice %(slice)s was killed since it was the largest consumer of
99 physical memory at %(rss)s (%(percent)4.1f%%) (%(sz)s writable)
100 after repeated restarts.
101
102 Please reply to this message explaining the nature of your experiment,
103 and what you are doing to address the problem.
104
105 %(slice)s processes prior to reset:
106
107 %(table)s
108
109 %(date)s %(hostname)s reset %(slice)s
110 """.lstrip()
111
112 def killsliverprocs(xid):
113     bwlimit.run("/usr/sbin/vkill -s 9 -c %s 0" % xid)    
114
115
116 def usage():
117     print """
118 Usage: %s [OPTIONS]...
119
120 Options:
121         -d, --debug             Enable debugging (default: %s)
122         -v, --verbose           Increase verbosity level (default: %d)
123         -f, --file=FILE         Data file (default: %s)
124         -s, --slice=SLICE       Constrain monitoring to these slices (default: all)
125         -p, --period=SECONDS    Seconds between normal process analysis (default: %s)
126         --reset-thresh=PERCENT  Swap utilization at which slice reset is attempted
127         --reboot-thresh=PERCENT Swap utilization at which the machine is rebooted
128         --min-thresh=PERCENT    Minimum physical memory utilization to be considered a hog
129         --system-slice=SLICE    System slice that should not be reset
130         --status                Print memory usage statistics and exit
131         --memstatus             Print total memory, total swap, and swap used
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', 'vsname', '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     ps_fields.remove('vsname')
160
161     slices = {}
162
163     # Eat the header line. vps depends on the header to figure out
164     # which column is the PID column, so we can't just tell ps not to
165     # print it.
166     for line in bwlimit.run("/usr/sbin/vps -e -o " + ":16,".join(ps_fields))[1:]:
167         # Chomp newline
168         line = line.strip()
169
170         # Represent process as a dict of fields
171         values = line.split(None, len(fields) - 1)
172         if len(values) != len(fields):
173             if "ERR" in line:
174                 pass # ignore spurious error message from vps
175             else:
176                 print "slicestat: failed to parse line: " + line
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             if "ERR" in line:
196                 pass # ignore spurious error message from vps
197             else:
198                 print "slicestat: failed to parse line: " + line
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", "memstatus", "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         elif opt == "--memstatus":
425             (mem, swap) = memtotal()
426             swap_pct = swap_used()
427             print "memory total:", mem
428             print "swap total:", swap
429             print "swap used:", swap_pct
430             sys.exit(0)
431         else:
432             usage()
433             sys.exit(0)
434
435     # Check if we are already running
436     writepid("swapmon")
437
438     if not debug:
439         daemonize()
440         # Rewrite PID file
441         writepid("swapmon")
442         # Redirect stdout and stderr to syslog
443         syslog.openlog("swapmon")
444         sys.stdout = sys.stderr = Logger()
445
446     # Get total memory
447     (total_mem, total_swap) = memtotal()
448     slices = readdat()
449
450     # Query process table every 30 seconds, or when a large change in
451     # swap utilization is detected.
452
453     while True:
454         used = swap_used()
455         if last_used is None:  last_used = used
456    
457         if used >= reboot_thresh:
458             # Dump slice state before rebooting
459             writedat(slices)    
460             # Goodbye, cruel world
461             print "%d%% swap consumed, rebooting" % used
462             if not debug:  bwlimit.run("/bin/sync; /sbin/reboot -f")
463         elif used >= reset_thresh:
464             # Try and find a hog
465             slicelist = slices.values()
466             # Puts largest on top.
467             slicelist.sort(lambda a, b: b['rss'] - a['rss'])
468             for slice in slicelist:
469                 percent = 100. * slice['rss'] / total_mem
470                 if slice['rss'] < rss_min: continue
471                 print "%d%% swap consumed, slice %s is using %s (%d%%) of memory" % \
472                     (used,
473                     slice['name'],
474                     format_bytes(slice['rss'] * 1024, si = False),
475                     percent)
476                 slice['procs'].sort(lambda a, b: b['rss'] - a['rss'])
477                 # Make a pretty table.
478                 params = formtable(slice, percent)
479                 # Match slice name against system slice patterns
480                 is_system_slice = filter(None, 
481                     [re.match(pattern, slice['name']) for pattern in system_slices])
482     
483                 # Do not reset system slices, just warn once
484                 if is_system_slice: 
485                     if slice['name'] not in warned:
486                         warned.append(slice['name'])
487                         print "Warning slice " + slice['name']
488                         if debug:
489                             print alarm_subject % params
490                             print alarm_body % params
491                         else:
492                             slicemail(slice['name'], alarm_subject % params, 
493                               alarm_body % params)
494                 else:
495                     # Reset slice
496                     if not debug:
497                         if emailed.get(slice['name'], (time.time() + email_timeout + 1)) > (time.time() + email_timeout): 
498                             slicemail(slice['name'], kill_subject % params, kill_body % params)
499                             emailed[slice['name']] = time.time()
500                     else:
501                         print kill_subject % params
502                         print kill_body % params
503                     print "Killing procs in %s" % slice['name']
504                     killsliverprocs(slice['xid'])
505
506         # wait period before recalculating swap.  If in danger, recalc.
507         if timer <= 0 or used >= (last_used + change_thresh):
508             if used >= (last_used + change_thresh):
509                 print "%d%% swap consumed, %d%% in last %d seconds" % \
510                     (used, used - last_used, period - timer)
511             # Get slice state
512             slices = slicestat(names)
513             # Reset timer
514             timer = period
515             # Keep track of large changes in swap utilization
516             last_used = used
517         timer -= 1
518         time.sleep(1)
519
520     removepid("swapmon")
521
522 if __name__ == '__main__':
523     main()