* NM rate values are in bits/s. Fixed Byte limits.
[mom.git] / bwmon.py
1 #!/usr/bin/python
2 #
3 # Average bandwidth monitoring script. Run periodically via cron(8) to
4 # enforce a soft limit on daily bandwidth usage for each slice. If a
5 # slice is found to have exceeded its daily bandwidth usage when the
6 # script is run, its instantaneous rate will be capped at the desired
7 # average rate. Thus, in the worst case, a slice will only be able to
8 # send a little more than twice its average daily limit.
9 #
10 # Two separate limits are enforced, one for destinations exempt from
11 # the node bandwidth cap, and the other for all other destinations.
12 #
13 # Mark Huang <mlhuang@cs.princeton.edu>
14 # Andy Bavier <acb@cs.princeton.edu>
15 # Faiyaz Ahmed <faiyaza@cs.princeton.edu>
16 # Copyright (C) 2004-2006 The Trustees of Princeton University
17 #
18 # $Id: bwmon.py,v 1.17 2007/01/03 20:15:06 faiyaza Exp $
19 #
20
21 import syslog
22 import os
23 import sys
24 import getopt
25 import time
26 import pickle
27
28 import socket
29 import xmlrpclib
30 import bwlimit
31
32 from sets import Set
33
34 # Utility functions
35 from pl_mom import *
36
37 # Constants
38 seconds_per_day = 24 * 60 * 60
39 bits_per_byte = 8
40
41 # Defaults
42 debug = False
43 verbose = 0
44 datafile = "/var/lib/misc/bwmon.dat"
45 nm = None
46
47 # Burst to line rate (or node cap).  Set by NM.
48 default_maxrate = bwlimit.get_bwcap()
49 default_maxexemptrate = bwlimit.bwmax
50
51 # What we cap to when slices break the rules.
52 # 500 Kbit or 5.4 GB per day
53 #default_avgrate = 500000
54 # 1.5 Mbit or 16.4 GB per day
55 #default_avgexemptrate = 1500000
56
57 # 5.4 Gbyte per day. 5.4 * 1024 k * 1024M * 1024G 
58 # 5.4 Gbyte per day max allowed transfered per recording period
59 default_ByteMax = 5798205850
60 default_ByteThresh = int(.8 * default_ByteMax) 
61 # 16.4 Gbyte per day max allowed transfered per recording period to I2
62 default_ExemptByteMax = 17609365914 
63 default_ExemptByteThresh = int(.8 * default_ExemptByteMax) 
64
65 default_MinRate = 8
66
67 # Average over 1 day
68 period = 1 * seconds_per_day
69
70 # Message template
71 template = \
72 """
73 The slice %(slice)s has transmitted more than %(bytes)s from
74 %(hostname)s to %(class)s destinations
75 since %(since)s.
76
77 Its maximum %(class)s burst rate will be capped at %(new_maxrate)s/s
78 until %(until)s.
79
80 Please reduce the average %(class)s transmission rate
81 of the slice to %(limit)s per %(period)s.
82
83 """.lstrip()
84
85 footer = \
86 """
87 %(date)s %(hostname)s bwcap %(slice)s
88 """.lstrip()
89
90 class Slice:
91     """
92     Stores the last recorded bandwidth parameters of a slice.
93
94     xid - slice context/VServer ID
95     name - slice name
96     time - beginning of recording period in UNIX seconds
97     bytes - low bandwidth bytes transmitted at the beginning of the recording period
98     exemptbytes - high bandwidth bytes transmitted at the beginning of the recording period (for I2 -F)
99     ByteMax - total volume of data allowed
100     ByteThresh - After thresh, cap node to (maxbyte - bytes)/(time left in period)
101     ExemptByteMax - Same as above, but for i2.
102     ExemptByteThresh - i2 ByteThresh
103     maxrate - max_rate slice attribute. 
104     maxexemptrate - max_exempt_rate slice attribute.
105     self.emailed = did we email during this recording period
106
107     """
108
109     def __init__(self, xid, name, maxrate, maxexemptrate, bytes, exemptbytes):
110         self.xid = xid
111         self.name = name
112         self.time = 0
113         self.bytes = 0
114         self.exemptbytes = 0
115         self.ByteMax = default_ByteMax
116         self.ByteThresh = default_ByteThresh
117         self.ExemptByteMax = default_ExemptByteMax
118         self.ExemptByteThresh = default_ExemptByteThresh
119         self.maxrate = default_maxrate
120         self.maxexemptrate = default_maxexemptrate
121         self.emailed = False
122
123         # Get real values where applicable
124         self.reset(maxrate, maxexemptrate, bytes, exemptbytes)
125
126     def __repr__(self):
127         return self.name
128
129     def updateSliceAttributes(self):
130         # Query Node Manager for max rate overrides
131         try:
132             vals = nm.query(self.name, 
133                 [('nm_net_max_rate', self.maxrate),
134                 ('nm_net_max_exempt_rate', self.maxexemptrate),
135                 ("nm_net_max_byte", int(self.ByteMax / 1024)),
136                 ("nm_net_max_exempt_byte", int(self.ExemptByteMax / 1024)),
137                 ("nm_net_max_thresh_byte", int( .8 * self.ByteMax / 1024)),
138                 ("nm_net_max_thresh_exempt_byte", int(.8 * self.ExemptByteMax / 1024)),
139                 ("nm_net_avg_rate", 0),
140                 ("nm_net_avg_exempt_rate", 0)])
141
142             (self.maxrate,
143                 self.maxexemptrate,
144                 ByteMax,
145                 ExemptByteMax,
146                 ByteThresh,
147                 ExemptByteThresh,
148                 avgrate,
149                 avgexemptrate) = vals
150             
151             # The shitty bit.  Gotta bias the limits so as not to overflow xmlrpc
152             self.ByteMax = ByteMax * 1024 
153             self.ByteThresh = ByteThresh * 1024 
154             self.ExemptByteMax = ExemptByteMax * 1024 
155             self.ExemptByteThresh = ExemptByteThresh * 1024 
156
157             # The hack here is that when i pass 0 to the xmlrpc request to NM, 
158             # for rate limits and it comes back non zero, then screw the byte limits.
159             # Mult by the period and recompute the byte limits.  The thought is
160             # If/when PLC switches to byte limits, the avgrates wont be used as
161             # slice attributes and will return as 0
162             if (avgrate != 0):
163                 self.ByteMax = int(avgrate * period / 8) 
164                 self.ByteThresh = int(self.ByteMax * .8)
165
166             if (avgexemptrate != 0):
167                 self.ExemptByteMax = int(avgexemptrate * period / 8)
168                 self.ExemptByteThresh = int(self.ExemptByteMax * .8)
169
170             if debug and verbose:
171                 print "%s - \n" \
172                     "  self.maxrate %s \n" \
173                     "  self.maxexemptrate %s \n" \
174                     "  ByteMax %s \n" \
175                     "  ExemptByteMax %s \n" \
176                     "  ByteThresh %s \n" \
177                     "  ExemptByteThresh %s \n" \
178                     "  avgrate - %s \n" \
179                     "  avgexemptrate - %s" % (self.name, self.maxrate, self.maxexemptrate, ByteMax, ExemptByteMax, ByteThresh, ExemptByteThresh, avgrate, avgexemptrate)
180
181         except Exception, err:
182             print "Warning: Exception received while querying NM:", err
183
184     def reset(self, maxrate, maxexemptrate, bytes, exemptbytes):
185         """
186         Begin a new recording period. Remove caps by restoring limits
187         to their default values.
188         """
189         
190         # Query Node Manager for max rate overrides
191         self.updateSliceAttributes()    
192
193         # Reset baseline time
194         self.time = time.time()
195
196         # Reset baseline byte coutns
197         self.bytes = bytes
198         self.exemptbytes = exemptbytes
199
200         # Reset email 
201         self.emailed = False
202
203         if (self.maxrate != maxrate) or (self.maxexemptrate != maxexemptrate):
204             print "%s reset to %s/%s" % \
205                   (self.name,
206                    bwlimit.format_tc_rate(self.maxrate),
207                    bwlimit.format_tc_rate(self.maxexemptrate))
208             bwlimit.set(xid = self.xid, maxrate = self.maxrate, maxexemptrate = self.maxexemptrate)
209
210     def update(self, maxrate, maxexemptrate, bytes, exemptbytes):
211         """
212         Update byte counts and check if byte limits have been
213         exceeded. 
214         """
215     
216         # Query Node Manager for max rate overrides
217         self.updateSliceAttributes()    
218      
219         # Prepare message parameters from the template
220         message = ""
221         params = {'slice': self.name, 'hostname': socket.gethostname(),
222                   'since': time.asctime(time.gmtime(self.time)) + " GMT",
223                   'until': time.asctime(time.gmtime(self.time + period)) + " GMT",
224                   'date': time.asctime(time.gmtime()) + " GMT",
225                   'period': format_period(period)} 
226
227         if bytes >= (self.bytes + self.ByteThresh):
228             new_maxrate = \
229             int((self.ByteMax - (bytes - self.bytes))/(period - int(time.time() - self.time)))
230             if new_maxrate < default_MinRate:
231                 new_maxrate = default_MinRate
232         else:
233             new_maxrate = maxrate
234
235         # Format template parameters for low bandwidth message
236         params['class'] = "low bandwidth"
237         params['bytes'] = format_bytes(bytes - self.bytes)
238         params['maxrate'] = bwlimit.format_tc_rate(maxrate)
239         params['limit'] = format_bytes(self.ByteMax)
240         params['new_maxrate'] = bwlimit.format_tc_rate(new_maxrate)
241
242         if verbose:
243             print "%(slice)s %(class)s " \
244                   "%(bytes)s of %(limit)s (%(new_maxrate)s/s maxrate)" % \
245                   params
246
247         # Cap low bandwidth burst rate
248         if new_maxrate != maxrate:
249             message += template % params
250             print "%(slice)s %(class)s capped at %(new_maxrate)s/s " % params
251     
252         if exemptbytes >= (self.exemptbytes + self.ExemptByteThresh):
253             new_maxexemptrate = \
254             int((self.ExemptByteMax - (self.bytes - bytes))/(period - int(time.time() - self.time)))
255             if new_maxexemptrate < default_MinRate:
256                 new_maxexemptrate = default_MinRate
257         else:
258             new_maxexemptrate = maxexemptrate
259
260         # Format template parameters for high bandwidth message
261         params['class'] = "high bandwidth"
262         params['bytes'] = format_bytes(exemptbytes - self.exemptbytes)
263         params['maxrate'] = bwlimit.format_tc_rate(maxexemptrate)
264         params['limit'] = format_bytes(self.ExemptByteMax)
265         params['new_maxexemptrate'] = bwlimit.format_tc_rate(new_maxexemptrate)
266
267         if verbose:
268             print "%(slice)s %(class)s " \
269                   "%(bytes)s of %(limit)s (%(new_maxrate)s/s maxrate)" % params
270
271         # Cap high bandwidth burst rate
272         if new_maxexemptrate != maxexemptrate:
273             message += template % params
274             print "%(slice)s %(class)s capped at %(new_maxexemptrate)s/s" % params
275
276         # Apply parameters
277         if new_maxrate != maxrate or new_maxexemptrate != maxexemptrate:
278             bwlimit.set(xid = self.xid, maxrate = new_maxrate, maxexemptrate = new_maxexemptrate)
279
280         # Notify slice
281         if message and self.emailed == False:
282             subject = "pl_mom capped bandwidth of slice %(slice)s on %(hostname)s" % params
283             if debug:
284                 print subject
285                 print message + (footer % params)
286             else:
287                 self.emailed = True
288                 slicemail(self.name, subject, message + (footer % params))
289
290
291
292 def usage():
293     print """
294 Usage: %s [OPTIONS]...
295
296 Options:
297         -d, --debug            Enable debugging (default: %s)
298         -v, --verbose            Increase verbosity level (default: %d)
299         -f, --file=FILE            Data file (default: %s)
300         -s, --slice=SLICE        Constrain monitoring to these slices (default: all)
301         -p, --period=SECONDS    Interval in seconds over which to enforce average byte limits (default: %s)
302         -h, --help                This message
303 """.lstrip() % (sys.argv[0], debug, verbose, datafile, format_period(period))
304
305 def main():
306     # Defaults
307     global debug, verbose, datafile, period, nm
308     # All slices
309     names = []
310
311     try:
312         longopts = ["debug", "verbose", "file=", "slice=", "period=", "help"]
313         (opts, argv) = getopt.getopt(sys.argv[1:], "dvf:s:p:h", longopts)
314     except getopt.GetoptError, err:
315         print "Error: " + err.msg
316         usage()
317         sys.exit(1)
318
319     for (opt, optval) in opts:
320         if opt == "-d" or opt == "--debug":
321             debug = True
322         elif opt == "-v" or opt == "--verbose":
323             verbose += 1
324             bwlimit.verbose = verbose - 1
325         elif opt == "-f" or opt == "--file":
326             datafile = optval
327         elif opt == "-s" or opt == "--slice":
328             names.append(optval)
329         elif opt == "-p" or opt == "--period":
330             period = int(optval)
331         else:
332             usage()
333             sys.exit(0)
334
335     # Check if we are already running
336     writepid("bwmon")
337
338     if not debug:
339         # Redirect stdout and stderr to syslog
340         syslog.openlog("bwmon")
341         sys.stdout = sys.stderr = Logger()
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: bwmon.py,v 1.17 2007/01/03 20:15:06 faiyaza Exp $":
351             print "Not using old version '%s' data file %s" % (version, datafile)
352             raise Exception
353     except Exception:
354         version = "$Id: bwmon.py,v 1.17 2007/01/03 20:15:06 faiyaza Exp $"
355         slices = {}
356
357     # Get special slice IDs
358     root_xid = bwlimit.get_xid("root")
359     default_xid = bwlimit.get_xid("default")
360
361     #Open connection to Node Manager. Global.
362     nm = NM()
363
364     live = []
365     # Get actuall running values from tc.
366     for params in bwlimit.get():
367         (xid, share,
368          minrate, maxrate,
369          minexemptrate, maxexemptrate,
370          bytes, exemptbytes) = params
371         live.append(xid)
372
373         # Ignore root and default buckets
374         if xid == root_xid or xid == default_xid:
375             continue
376
377         name = bwlimit.get_slice(xid)
378         if name is None:
379             # Orphaned (not associated with a slice) class
380             name = "%d?" % xid
381
382         # Monitor only the specified slices
383         if names and name not in names:
384             continue
385         #slices is populated from the pickle file
386         #xid is populated from bwlimit (read from /etc/passwd) 
387         if slices.has_key(xid):
388             slice = slices[xid]
389             if time.time() >= (slice.time + period) or \
390                bytes < slice.bytes or exemptbytes < slice.exemptbytes:
391                 # Reset to defaults every 24 hours or if it appears
392                 # that the byte counters have overflowed (or, more
393                 # likely, the node was restarted or the HTB buckets
394                 # were re-initialized).
395                 slice.reset(maxrate, maxexemptrate, bytes, exemptbytes)
396             else:
397                 # Update byte counts
398                 slice.update(maxrate, maxexemptrate, bytes, exemptbytes)
399         else:
400             # New slice, initialize state
401             slice = slices[xid] = Slice(xid, name, maxrate, maxexemptrate, bytes, exemptbytes)
402
403     # Delete dead slices
404     dead = Set(slices.keys()) - Set(live)
405     for xid in dead:
406         del slices[xid]
407
408     if verbose:
409         print "Saving %s" % datafile
410     f = open(datafile, "w")
411     pickle.dump((version, slices), f)
412     f.close()
413
414     removepid("bwmon")
415
416 if __name__ == '__main__':
417     main()