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