Guest User

Btrfs auto rebalance

a guest
May 14th, 2017
110
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
  1. #!/usr/bin/ruby
  2.  
  3. # Maximum free space wasted as percentage
  4. MAX_WASTED_RATIO = 0.3
  5. # Maximum unallocated space above which no check is needed
  6. UNALLOCATED_THRESHOLD_RATIO = 0.5
  7. # Target max waste when rebalancing (not much less than max_wasted)
  8. TARGET_WASTED_RATIO = 0.25
  9. MAX_FAILURES = 50
  10. MAX_REBALANCES = 100
  11. FLAPPING_LEVEL = 3
  12. # How fast can we move the -dusage/-musage targets
  13. MIN_TARGET_STEP = 2
  14. MAX_TARGET_STEP = 5
  15. # Maximum time allocated globally
  16. MAX_TIME = 14400 # 4 hours
  17. MAX_FS_TIME = 7200 # 2 hours
  18. SPREAD = (ARGV[0] || 68400).to_i # 19 hours
  19.  
  20. require 'open3'
  21.  
  22. def log(msg)
  23.   puts msg
  24.   IO.popen("logger -p user.notice --id=$$", "w+") { |io| io.puts(msg) }
  25. end
  26.  
  27. def filesystems
  28.   mounts = []
  29.   IO.popen("mount") do |io|
  30.     io.each_line do |line|
  31.       if match = line.match(/^(.*) on (.*) type btrfs/)
  32.         mounts << [ match[1], match[2] ] unless mounts.map(&:first).include?(match[1])
  33.       end
  34.     end
  35.   end
  36.   return mounts.map { |m| m[1] }
  37. end
  38.  
  39. class Btrfs
  40.   def initialize(mountpoint)
  41.     @mountpoint = mountpoint
  42.     @tried_targets = {}
  43.     @flapping_detected = false
  44.     refresh_usage
  45.   end
  46.  
  47.   def tried(target)
  48.     @tried_targets[target] ||= 0
  49.     @tried_targets[target] += 1
  50.     @flapping_detected = true if @tried_targets[target] == FLAPPING_LEVEL
  51.   end
  52.  
  53.   def refresh_usage
  54.     IO.popen("btrfs fi usage --raw '#{@mountpoint}'") do |io|
  55.       io.each_line do |line|
  56.         case line
  57.         when /Device size:\s*(\d*)$/
  58.           @device_size = Regexp.last_match[1].to_i
  59.         when /Device allocated:\s*(\d*)$/
  60.           @allocated = Regexp.last_match[1].to_i
  61.         when /Device unallocated:\s*(\d*)$/
  62.           @unallocated = Regexp.last_match[1].to_i
  63.         when /Free \(estimated\):\s*(\d*)\s*\(min: \d*\)$/
  64.           @free = Regexp.last_match[1].to_i
  65.         when /Data ratio:\s*(\d+\.\d+)$/
  66.           @ratio = Regexp.last_match[1].to_f
  67.         end
  68.       end
  69.     end
  70.   end
  71.  
  72.   def rebalance_if_needed(start_time)
  73.     @start_time = start_time
  74.     if rebalance_needed?
  75.       log_current_state
  76.       rebalance
  77.     end
  78.   rescue => ex
  79.     log "rebalance error: #{ex}\n#{ex.backtrace.join("\n")}"
  80.   end
  81.  
  82.   def log_current_state
  83.     log("#" * 80)
  84.     log "%s: #{@mountpoint} rebalance started" % Time.now.strftime("%Y/%m/%d %H:%M:%S")
  85.     log "waste %.2f%%" % (free_wasted * 100)
  86.     log "unallocated: #{@unallocated}"
  87.     log "ratio:       #{@ratio}"
  88.     log "free:        #{@free}"
  89.   end
  90.  
  91.   def rebalance
  92.     @fs_start_time = Time.now
  93.     cancel_reached = false
  94.     fork_balance_cancel
  95.     successive_failures = 0
  96.     count = 0
  97.     usage_target = start_target
  98.     previous_wasted = free_wasted
  99.     while (free_wasted > target_wasted) && (count < MAX_REBALANCES)
  100.       if Time.now > must_stop_at
  101.         log "Time allocated spent, aborting"
  102.         Process.wait @balance_cancel_pid
  103.         log "aborted"
  104.         cancel_reached = true
  105.         return
  106.       end
  107.       log "rebalance with usage: #{usage_target}"
  108.       count += 1
  109.       status = balance_usage(usage_target)
  110.       refresh_usage
  111.       if status != 0
  112.         successive_failures += 1
  113.         if usage_target == 0
  114.           fail "can't retry rebalancing, usage_target reached 0 already"
  115.         end
  116.         if successive_failures >= MAX_FAILURES
  117.           fail "too many rebalance failures: #{MAX_FAILURES}"
  118.         end
  119.         usage_target -= 1
  120.       else
  121.         successive_failures = 0
  122.         step = [ [ ((free_wasted - target_wasted) * 100) / 2, MIN_TARGET_STEP ].max,
  123.                  MAX_TARGET_STEP ].min.to_i
  124.         usage_target = [ usage_target + step, 100 ].min
  125.       end
  126.       log("waste %.2f%%" % (free_wasted * 100))
  127.       if @flapping_detected
  128.         fail "flapping detected: #{FLAPPING_LEVEL} times at this usage level"
  129.       end
  130.     end
  131.     if free_wasted > target_wasted
  132.       log "#{target_wasted} not reached in #{MAX_REBALANCES} balance calls"
  133.     end
  134.   ensure
  135.     kill_balance_cancel unless cancel_reached
  136.     log "%s: #{@mountpoint} rebalance stopped" % Time.now.strftime("%Y/%m/%d %H:%M:%S")
  137.   end
  138.  
  139.   def must_stop_at
  140.     [ @start_time + MAX_TIME, @fs_start_time + MAX_FS_TIME ].min
  141.   end
  142.  
  143.   def fork_balance_cancel
  144.     # sleep a minimum of 10 seconds to let the balance begin
  145.     delay = [ (must_stop_at - Time.now).to_i, 10 ].max
  146.     @balance_cancel_pid = spawn("sleep #{delay} && btrfs balance cancel #{@mountpoint} &>/dev/null")
  147.   end
  148.  
  149.   def kill_balance_cancel
  150.     Process.kill "TERM", @balance_cancel_pid
  151.   rescue
  152.     # do nothing
  153.   ensure
  154.     # Cleanup
  155.     Process.wait @balance_cancel_pid
  156.   end
  157.  
  158.   def balance_usage(target)
  159.     tried(target)
  160.     return balance_zero if target == 0
  161.     cmd =
  162.       "nice btrfs balance start -dusage=#{target} -musage=#{target} #{@mountpoint}"
  163.     output, status = Open3.capture2e(cmd)
  164.     log output
  165.     return status.exitstatus
  166.   end
  167.  
  168.   def balance_zero
  169.     cmd =
  170.       "nice btrfs balance start -musage=0 #{@mountpoint}"
  171.     output, status = Open3.capture2e(cmd)
  172.     log output
  173.     return status.exitstatus if Time.now > must_stop_at
  174.     cmd =
  175.       "nice btrfs balance start -dusage=0 #{@mountpoint}"
  176.     output, status2 = Open3.capture2e(cmd)
  177.     log output
  178.     return [ status.exitstatus, status2.exitstatus ].max
  179.   end
  180.  
  181.   def rebalance_needed?
  182.     (unallocated_ratio < UNALLOCATED_THRESHOLD_RATIO) && (free_wasted > max_wasted)
  183.   end
  184.  
  185.   def free_wasted
  186.     1 - (@unallocated.to_f / (@free * @ratio))
  187.   end
  188.   def unallocated_ratio
  189.     @unallocated.to_f / @device_size
  190.   end
  191.   def max_wasted
  192.     MAX_WASTED_RATIO
  193.   end
  194.  
  195.   def target_wasted
  196.     TARGET_WASTED_RATIO
  197.   end
  198.  
  199.   # Try to guess best usage_target starting_point
  200.   def start_target
  201.     ((1 - target_wasted) * 66).to_i
  202.   end
  203. end
  204.  
  205. def delay
  206.   sleep_amount = (SPREAD * Random.rand).to_i
  207.   return unless sleep_amount > 0
  208.   log "delaying for #{sleep_amount}"
  209.   sleep sleep_amount
  210. end
  211.  
  212. def rebalance_fs
  213.   todo = filesystems.map { |fs| Btrfs.new(fs) }.select(&:rebalance_needed?)
  214.   if todo.any?
  215.     delay
  216.     start = Time.now
  217.     # don't use the same order on each run (one filesystem could take too long
  218.     # and block others)
  219.     todo.shuffle.each { |btrfs| btrfs.rebalance_if_needed(start) }
  220.   end
  221. end
  222.  
  223. rebalance_fs
RAW Paste Data