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:
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
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.
- Ruby 2.7+ (tested on 3.0.2; uses only
optparse,json,
zlib,fileutils, andtimefrom 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.gzsiblings next to them. - A way to run it periodically:
cron/systemd timers on Linux, Task Scheduler on
Windows.
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.
{
"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
}
]
}
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
- “Not due” every time, even though the file looks huge —
double checkmax_bytesis 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.gzsiblings).
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 thepost_rotate
hook to signal a reopen in that case, or switch the app to open the file withO_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 It
- Glob-based rules — accept a glob pattern instead of a single path so one
rule can cover/var/log/myapp/worker-*.logacross 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_rotateinto a proper hook
that uploads the just-closed.gzto S3/blob storage before the next rotation cycle
would otherwise delete it.