the shed // ruby devops

A ~200-line, dependency-free reimplementation of logrotate‘s core mechanics — size/age triggers, gzip compression, retention, and a post-rotate hook — for the in-house services that never got one.

Step through the build below:

log_rotate.rb

The problem: a lot of the services running in a typical
fleet were never handed a real logging strategy. Someone wrote
File.open("app.log", "a") in 2021, it worked, and it shipped. Nobody wired up
logrotate for it because it wasn’t a package-managed daemon. Eighteen months
later that file is 40 GB, the disk is at 97%, and it’s paging someone at 3am.

Real logrotate is the standard fix on Linux, but it only
manages files it’s been explicitly configured for via /etc/logrotate.d, it needs
root to touch most system paths, and it doesn’t exist at all on the in-house Windows service
running the same app. log_rotate.rb is a ~200-line, dependency-free
reimplementation of the core mechanics: size/age-triggered rotation, gzip compression,
retention, and an optional hook to tell the writing process to reopen its file handle. Drop
it next to any app’s log directory and point cron (or Task Scheduler) at it.

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# log_rotate.rb -- A small, dependency-free logrotate reimplementation in Ruby.
#
# Problem it solves: lots of in-house services (a Sinatra app, a background
# worker, a custom daemon) just do `File.open("app.log", "a")` and never stop
# writing. Nobody wired up logrotate for them, so six months later a box
# fills its disk with a single 40GB log file. This script is a portable,
# no-gems tool you can drop next to any app and run from cron (or on a timer
# on Windows) to keep log files bounded: rotate by size or age, compress old
# generations, enforce a retention count, and optionally signal the owning
# process to reopen its log handle after rotation (the same "copytruncate"
# vs "signal" trade-off real logrotate makes).
#
# Usage:
#   ruby log_rotate.rb --config rotate.json
#   ruby log_rotate.rb --config rotate.json --dry-run
#   ruby log_rotate.rb --config rotate.json --json
#
# Exit codes: 0 = ok (rotated 0+ files, no errors), 1 = one or more rotation
# errors occurred (permissions, missing file, etc.) -- cron/monitoring
# friendly.
require 'optparse'
require 'json'
require 'zlib'
require 'fileutils'
require 'time'
module LogRotate
  # A single log file's rotation policy, loaded from the JSON config.
  RuleResult = Struct.new(:path, :action, :detail, :error, keyword_init: true)
  class Rule
    attr_reader :path, :max_bytes, :max_age_days, :keep, :compress, :post_rotate
    def initialize(cfg)
      @path         = cfg.fetch('path')
      @max_bytes    = cfg['max_bytes']                # nil means "no size trigger"
      @max_age_days = cfg['max_age_days']              # nil means "no age trigger"
      @keep         = cfg.fetch('keep', 7)
      @compress     = cfg.fetch('compress', true)
      @post_rotate  = cfg['post_rotate']                # optional shell command, e.g. signal to reopen
      raise ArgumentError, "rule for #{@path} needs max_bytes and/or max_age_days" if @max_bytes.nil? && @max_age_days.nil?
    end
    # Should this file rotate right now?
    def due?(stat)
      return true if max_bytes && stat.size >= max_bytes
      return true if max_age_days && (Time.now - stat.mtime) >= max_age_days * 86_400
      false
    end
  end
  class Rotator
    def initialize(rules, dry_run: false)
      @rules = rules
      @dry_run = dry_run
    end
    def run
      @rules.map { |rule| rotate_one(rule) }
    end
    private
    # Find the existing rotated generations for a path, e.g.
    # app.log.1, app.log.2.gz, app.log.3.gz -> [1, 2, 3]
    def existing_generations(path)
      dir  = File.dirname(path)
      base = File.basename(path)
      Dir.children(dir)
         .filter_map { |f| f[/\A#{Regexp.escape(base)}\.(\d+)(\.gz)?\z/, 1]&.to_i }
         .sort
    rescue Errno::ENOENT
      []
    end
    def rotate_one(rule)
      path = rule.path
      unless File.exist?(path)
        return RuleResult.new(path: path, action: :skipped, detail: 'file does not exist', error: false)
      end
      stat = File.stat(path)
      unless rule.due?(stat)
        return RuleResult.new(path: path, action: :skipped, detail: 'not due', error: false)
      end
      begin
        shift_generations(rule)
        rotated_to = perform_rotation(rule)
        enforce_retention(rule)
        run_post_rotate(rule)
        RuleResult.new(path: path, action: :rotated, detail: "-> #{rotated_to}", error: false)
      rescue StandardError => e
        RuleResult.new(path: path, action: :error, detail: e.message, error: true)
      end
    end
    # Shift app.log.2 -> app.log.3, app.log.1 -> app.log.2, etc. (highest
    # first so we never clobber a lower generation before it's moved).
    def shift_generations(rule)
      gens = existing_generations(rule.path)
      gens.sort.reverse_each do |n|
        src = generation_path(rule.path, n, rule.compress)
        dst = generation_path(rule.path, n + 1, rule.compress)
        next unless File.exist?(src)
        if @dry_run
          puts "[dry-run] would move #{src} -> #{dst}"
        else
          FileUtils.mv(src, dst, force: true)
        end
      end
    end
    def generation_path(path, n, compressed)
      compressed ? "#{path}.#{n}.gz" : "#{path}.#{n}"
    end
    # copytruncate strategy: copy current contents to .1 (compressing if
    # asked), then truncate the live file to zero length in place. This
    # matters because it never breaks a process's already-open file handle
    # -- unlike renaming the live file out from under it, which would leave
    # the writer appending to a now-unlinked inode forever.
    def perform_rotation(rule)
      target = generation_path(rule.path, 1, rule.compress)
      if @dry_run
        puts "[dry-run] would copy #{rule.path} -> #{target} and truncate #{rule.path}"
        return target
      end
      if rule.compress
        Zlib::GzipWriter.open(target) { |gz| gz.write(File.binread(rule.path)) }
      else
        FileUtils.cp(rule.path, target)
      end
      File.truncate(rule.path, 0)
      target
    end
    def enforce_retention(rule)
      gens = existing_generations(rule.path)
      overflow = gens.sort.select { |n| n > rule.keep }
      overflow.each do |n|
        victim = generation_path(rule.path, n, rule.compress)
        if @dry_run
          puts "[dry-run] would delete #{victim} (exceeds keep=#{rule.keep})"
        elsif File.exist?(victim)
          File.delete(victim)
        end
      end
    end
    def run_post_rotate(rule)
      return unless rule.post_rotate
      if @dry_run
        puts "[dry-run] would run post_rotate: #{rule.post_rotate}"
      else
        system(rule.post_rotate)
      end
    end
  end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
  options = { config: nil, dry_run: false, json: false }
  OptionParser.new do |opts|
    opts.banner = 'Usage: log_rotate.rb --config rotate.json [--dry-run] [--json]'
    opts.on('-c', '--config PATH', 'Path to JSON rotation config') { |v| options[:config] = v }
    opts.on('--dry-run', 'Show what would happen without touching files') { options[:dry_run] = true }
    opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true }
  end.parse!
  unless options[:config]
    warn 'Error: --config PATH is required'
    exit 1
  end
  raw = JSON.parse(File.read(options[:config]))
  rules = raw.fetch('rules').map { |cfg| LogRotate::Rule.new(cfg) }
  results = LogRotate::Rotator.new(rules, dry_run: options[:dry_run]).run
  had_error = results.any?(&:error)
  if options[:json]
    puts JSON.pretty_generate(results.map(&:to_h))
  else
    results.each do |r|
      marker = case r.action
               when :rotated then 'ROTATED'
               when :skipped then 'skip   '
               when :error   then 'ERROR  '
               end
      puts "#{marker} #{r.path}#{r.detail ? " (#{r.detail})" : ''}"
    end
    puts "\n#{results.count { |r| r.action == :rotated }} rotated, " \
         "#{results.count { |r| r.action == :skipped }} skipped, " \
         "#{results.count(&:error)} errors"
  end
  exit(had_error ? 1 : 0)
end

Why copytruncate, not rename: the tempting
implementation is File.rename("app.log", "app.log.1") then open a fresh
app.log. That breaks any process that already has app.log open —
it keeps writing happily to the renamed, soon-to-be-compressed inode, and the new
app.log stays empty forever. perform_rotation instead copies the
current bytes out (compressing as it goes) and then File.truncate(path, 0)s the
same file in place. The writer’s file descriptor is untouched — it just sees the
file suddenly get shorter, which is exactly what copytruncate mode in real
logrotate does, and why it’s the safer default for apps you don’t control the source of.

Why generations shift highest-first:
shift_generations walks existing generations in reverse (3, then 2, then 1)
before renaming any of them. If it went the other way, moving .1.gz to
.2.gz first would immediately get clobbered when .2.gz tried to
move to .3.gz a moment later. Reverse order means every move lands on an already-vacated
slot.

Why due? is a separate, tiny method: keeping
the size/age trigger logic in one predicate that takes a File::Stat means it’s
trivial to unit test in isolation (feed it a fake stat with a given size/mtime)
without touching the filesystem at all, and it’s the one place you’d extend to add a new
trigger condition later.

$ ruby log_rotate.rb --config rotate.json
  -> reopened app.log handle
ROTATED /tmp/logtest2/app.log (-> /tmp/logtest2/app.log.1.gz)
1 rotated, 0 skipped, 0 errors
$ ls -la
total 24
drwxr-xr-x  2 you  you  4096 Aug  5 11:44 .
drwxrwxrwt 11 root root 4096 Aug  5 11:44 ..
-rw-r--r--  1 you  you     0 Aug  5 11:44 app.log
-rw-r--r--  1 you  you  8246 Aug  5 11:44 app.log.1.gz
-rw-r--r--  1 you  you   163 Aug  5 11:44 rotate.json
$ ruby log_rotate.rb --config rotate.json   # run again immediately: nothing to do
skip    /tmp/logtest2/app.log (not due)
0 rotated, 1 skipped, 0 errors
exit: 0
Get the code

Full script + README on GitHub: ruby-devops-toolkit/log-rotate-manager

Rotation isn’t a nice-to-have. An unbounded log file eventually does one of three things:
it fills the disk (which takes down every other service on the box, not just the one
writing the log), it makes grepping across months of history unusably slow, or it silently
gets truncated by some other well-meaning script at the worst possible time.
log_rotate.rb gives you the same guarantees as system logrotate
— bounded size, bounded retention, compressed history — for any log file on any
host that can run Ruby, no root and no package manager required.

Prerequisites
  • Ruby 2.7+ (tested on 3.0.2; uses only optparse, json,
    zlib, fileutils, and time from the standard library —
    no gems to install).
  • Linux, macOS, or Windows. Everything here is pure Ruby I/O; there is nothing
    platform-specific.
  • Write access to the directory containing the log file(s) you want to rotate,
    and to create the .N.gz siblings next to them.
  • A way to run it periodically: cron/systemd timers on Linux, Task Scheduler on
    Windows.
log_rotate.rb rotation lifecycle diagram

The full rotation lifecycle: trigger check, generation shift, copytruncate, retention, post-rotate hook.
configuration

The Config Format

Every rule is one JSON object: the path to watch, and at least one trigger
(max_bytes and/or max_age_days), plus how many generations to
keep, whether to compress, and an optional post_rotate
shell command.

rotate.jsonjson
{
  "rules": [
    {
      "path": "/var/log/myapp/app.log",
      "max_bytes": 104857600,
      "keep": 7,
      "compress": true,
      "post_rotate": "systemctl kill -s HUP myapp"
    },
    {
      "path": "/var/log/myapp/access.log",
      "max_age_days": 1,
      "keep": 14,
      "compress": true
    }
  ]
}
walkthrough

Step-by-Step Walkthrough

The Rule class wraps one JSON rule and exposes a single
due?(stat) predicate. The Rotator class does the actual work in four
ordered steps per rule, each its own private method so the sequence reads top to bottom:

1. shift_generations

Finds existing path.N/path.N.gz files with a regex against the
directory listing, then walks the generation numbers highest first and renames each
one up by one slot. This ordering is the whole trick: renaming from the top down means every
move target is empty when you get to it.

2. perform_rotation

Copies (and optionally gzips via Zlib::GzipWriter) the live file’s current
bytes into the new .1/.1.gz, then File.truncates the
original file to zero length in place. Because it’s the same inode before and after,
any process with the file already open keeps writing to it without interruption or error
— it just observes the file becoming short.

3. enforce_retention

Re-lists the generations after the shift and deletes anything numbered higher than
keep. This runs after the shift specifically so a freshly-shifted-in generation
is what gets evaluated against the retention count, not the pre-shift state.

4. run_post_rotate

Shells out to an optional command — typically a signal to the owning process
(kill -HUP, a systemctl kill -s HUP, or a Windows service restart) so
it reopens its log file descriptor instead of continuing to write into the freshly truncated
file at some stale internal offset. Not every app needs this — anything using
File#syswrite in append mode naturally keeps writing from the truncated file’s new
end — but daemons that buffer or seek internally usually do.

troubleshooting

Troubleshooting

Common issues
  • “Not due” every time, even though the file looks huge
    double check max_bytes is in bytes, not KB/MB (a common off-by-1000 mistake).
    104857600 = 100 MiB.
  • Errno::EACCES / permission denied — the script needs write access
    to both the log file and its parent directory (to create/rename the .N.gz siblings).
    Run it as the same user that owns the log file, or via sudo/a scheduled task with the right
    identity.
  • Writer keeps appending to the truncated file but output looks corrupted
    — this only happens if the writer uses buffered I/O with an internal byte offset (rare in
    practice for simple loggers, common for some C-based daemons). Use the post_rotate
    hook to signal a reopen in that case, or switch the app to open the file with O_APPEND.
  • gzip file won’t decompress / looks truncated — make sure nothing else
    is rotating or truncating the same path concurrently; run this tool from a single cron entry per
    host, not overlapping timers.
extending

Extending It

Ideas
  • Glob-based rules — accept a glob pattern instead of a single path so one
    rule can cover /var/log/myapp/worker-*.log across a dynamic set of worker processes.
  • Parallel rotation — wrap the per-rule loop in a small thread pool for
    hosts with dozens of independently-rotating log files.
  • Size-based retention — instead of (or in addition to) a generation
    count, delete oldest generations until total compressed size is under a budget.
  • Remote shipping — extend post_rotate into a proper hook
    that uploads the just-closed .gz to S3/blob storage before the next rotation cycle
    would otherwise delete it.