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