- getting a weird exception very occasionally while parsing vps output,
[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.3 2006/05/01 18:28:01 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         try:
191             name = bwlimit.get_slice(proc['xid'])
192             if name is None:
193                 # Orphaned (not associated with a slice) class
194                 name = "%d?" % proc['xid']
195         except Exception, err:
196             print "Warning: Exception received while parsing vps output", err
197             print proc
198
199         # Monitor only the specified slices
200         if names and name not in names:
201             continue
202
203         # Additional overhead calculations from slicestat
204
205         # Include 12 KiB of process overhead =
206         # 4 KiB top-level page table +
207         # 4 KiB kernel structure +
208         # 4 KiB basic page table
209         proc['rss'] += 12
210
211         # Include additional page table overhead
212         if proc['vsize'] > 4096:
213             proc['rss'] += 4 * ((proc['vsize'] - 1) / 4096)
214
215         if slices.has_key(proc['xid']):
216             slice = slices[proc['xid']]
217         else:
218             slice = {'xid': proc['xid'], 'name': name, 'procs': [], 'vsize': 0, 'rss': 0}
219
220         slice['procs'].append(proc)
221         slice['vsize'] += proc['vsize']
222         slice['rss'] += proc['rss']
223
224         slices[proc['xid']] = slice
225
226     return slices
227
228 def memtotal():
229     """
230     Returns total physical memory on the system in KiB.
231     """
232
233     meminfo = open("/proc/meminfo", "r")
234     line = meminfo.readline()
235     meminfo.close()
236     if line[0:8] == "MemTotal":
237         # MemTotal: 255396 kB
238         (name, value, kb) = line.split()
239         return int(value)
240
241     return 0
242
243 def swap_used():
244     """
245     Returns swap utilization on the system as a whole percentage (0-100).
246     """
247
248     total_swap = 0
249     total_used = 0
250
251     try:
252         swaps = open("/proc/swaps", "r")
253         # Eat header line
254         lines = swaps.readlines()[1:]
255         swaps.close()
256         for line in lines:
257             # /dev/mapper/planetlab-swap partition 1048568 3740 -1
258             (filename, type, size, used, priority) = line.strip().split()
259             try:
260                 total_swap += int(size)
261                 total_used += int(used)
262             except ValueEror, err:
263                 pass
264     except (IOError, KeyError), err:
265         pass
266
267     return 100 * total_used / total_swap
268
269 def summary(names = None, total_rss = memtotal()):
270     """
271     Return a summary of memory usage by slice.
272     """
273     slicelist = slicestat(names).values()
274     slicelist.sort(lambda a, b: b['rss'] - a['rss'])
275
276     table = "%-20s%10s%24s\n\n" % ("Slice", "Processes", "Memory Usage")
277     for slice in slicelist:
278         table += "%-20s%10d%16s (%4.1f%%)\n" % \
279                  (slice['name'], len(slice['procs']),
280                   format_bytes(slice['rss'] * 1024, si = False),
281                   100. * slice['rss'] / total_rss)
282
283     return table
284
285 def main():
286     # Defaults
287     global debug, verbose, datafile
288     global period, change_thresh, reset_thresh, reboot_thresh, min_thresh, system_slices
289     # All slices
290     names = []
291
292     try:
293         longopts = ["debug", "verbose", "file=", "slice=", "status", "help"]
294         longopts += ["period=", "reset-thresh=", "reboot-thresh=", "min-thresh=", "system-slice="]
295         (opts, argv) = getopt.getopt(sys.argv[1:], "dvf:s:ph", longopts)
296     except getopt.GetoptError, err:
297         print "Error: " + err.msg
298         usage()
299         sys.exit(1)
300
301     for (opt, optval) in opts:
302         if opt == "-d" or opt == "--debug":
303             debug = True
304         elif opt == "-v" or opt == "--verbose":
305             verbose += 1
306         elif opt == "-f" or opt == "--file":
307             datafile = optval
308         elif opt == "-s" or opt == "--slice":
309             names.append(optval)
310         elif opt == "-p" or opt == "--period":
311             period = int(optval)
312         elif opt == "--change-thresh":
313             change_thresh = int(optval)
314         elif opt == "--reset-thresh":
315             reset_thresh = int(optval)
316         elif opt == "--reboot-thresh":
317             reboot_thresh = int(optval)
318         elif opt == "--min-thresh":
319             min_thresh = int(optval)
320         elif opt == "--system-slice":
321             system_slices.append(optval)
322         elif opt == "--status":
323             print summary(names)
324             sys.exit(0)
325         else:
326             usage()
327             sys.exit(0)
328
329     # Check if we are already running
330     writepid("swapmon")
331
332     if not debug:
333         daemonize()
334         # Rewrite PID file
335         writepid("swapmon")
336         # Redirect stdout and stderr to syslog
337         syslog.openlog("swapmon")
338         sys.stdout = sys.stderr = Logger()
339
340     # Get total physical memory
341     total_rss = memtotal()
342
343     try:
344         f = open(datafile, "r+")
345         if verbose:
346             print "Loading %s" % datafile
347         (version, slices) = pickle.load(f)
348         f.close()
349         # Check version of data file
350         if version != "$Id: swapmon.py,v 1.3 2006/05/01 18:28:01 mlhuang Exp $":
351             print "Not using old version '%s' data file %s" % (version, datafile)
352             raise Exception
353
354         params = {'hostname': socket.gethostname(),
355                   'date': time.asctime(time.gmtime()) + " GMT",
356                   'table': summary(total_rss)}
357
358         if debug:
359             print rebooted_subject % params
360             print rebooted_body % params
361         else:
362             slicemail(None, rebooted_subject % params, rebooted_body % params)
363
364         # Delete data file
365         os.unlink(datafile)
366     except Exception:
367         version = "$Id: swapmon.py,v 1.3 2006/05/01 18:28:01 mlhuang Exp $"
368         slices = {}
369
370     # Query process table every 30 seconds, or when a large change in
371     # swap utilization is detected.
372     timer = period
373     last_used = None
374     used = None
375
376     # System slices that we have warned but could not reset
377     warned = []
378
379     while True:
380         used = swap_used()
381         if last_used is None:
382             last_used = used
383         if verbose:
384             print "%d%% swap consumed" % used
385
386         if used >= reboot_thresh:
387             # Dump slice state before rebooting
388             if verbose:
389                 print "Saving %s" % datafile
390             f = open(datafile, "w")
391             pickle.dump((version, slices), f)
392             f.close()
393
394             # Goodbye, cruel world
395             print "%d%% swap consumed, rebooting" % used
396             if not debug:
397                 bwlimit.run("/bin/sync; /sbin/reboot -f")
398
399         elif used >= reset_thresh:
400             # Try and find a hog
401             slicelist = slices.values()
402             slicelist.sort(lambda a, b: b['rss'] - a['rss'])
403             for slice in slicelist:
404                 percent = 100. * slice['rss'] / total_rss
405                 if percent < min_thresh:
406                     continue
407
408                 print "%d%% swap consumed, slice %s is using %s (%d%%) of memory" % \
409                       (used,
410                        slice['name'],
411                        format_bytes(slice['rss'] * 1024, si = False),
412                        percent)
413
414                 slice['procs'].sort(lambda a, b: b['rss'] - a['rss'])
415
416                 table = "%5s %10s %10s %4s %4s %s\n\n" % ("PID", "VIRT", "RES", '%CPU', '%MEM', 'COMMAND')
417                 for proc in slice['procs']:
418                     table += "%5s %10s %10s %4.1f %4.1f %s\n" % \
419                              (proc['pid'],
420                               format_bytes(proc['vsize'] * 1024, si = False),
421                               format_bytes(proc['rss'] * 1024, si = False),
422                               proc['pcpu'], proc['pmem'], proc['cmd'])
423
424                 params = {'hostname': socket.gethostname(),
425                           'date': time.asctime(time.gmtime()) + " GMT",
426                           'table': table,
427                           'slice': slice['name'],
428                           'rss': format_bytes(slice['rss'] * 1024, si = False),
429                           'percent': percent}
430
431                 # Match slice name against system slice patterns
432                 is_system_slice = filter(None, [re.match(pattern, slice['name']) for pattern in system_slices])
433
434                 if is_system_slice:
435                     # Do not reset system slices, just warn once
436                     if slice['name'] not in warned:
437                         warned.append(slice['name'])
438                         if debug:
439                             print alarm_subject % params
440                             print alarm_body % params
441                         else:
442                             print "Warning slice " + slice['name']
443                             slicemail(slice['name'], alarm_subject % params, alarm_body % params)
444                 else:
445                     # Otherwise, reset
446                     if debug:
447                         print reset_subject % params
448                         print reset_body % params
449                     else:
450                         try:
451                             pid = os.fork()
452                             if pid == 0:
453                                 print "Resetting slice " + slice['name']
454                                 vserver = VServer(slice['name'])
455                                 vserver.stop()
456                                 vserver.start(wait = False)
457                                 os._exit(0)
458                             else:
459                                 os.waitpid(pid, 0)
460                         except Exception, err:
461                             print "Warning: Exception received while resetting slice %s:" % slice['name'], err
462                         slicemail(slice['name'], reset_subject % params, reset_body % params)
463                     break
464
465         elif timer <= 0 or used >= (last_used + change_thresh):
466             if used >= (last_used + change_thresh):
467                 print "%d%% swap consumed, %d%% in last %d seconds" % \
468                       (used, used - last_used, period - timer)
469             # Get slice state
470             slices = slicestat(names)
471             # Reset timer
472             timer = period
473             # Keep track of large changes in swap utilization
474             last_used = used
475
476         timer -= 1
477         time.sleep(1)
478
479     removepid("swapmon")
480
481 if __name__ == '__main__':
482     main()