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