the shed // ruby + systemd

A crash-looping service is bad. A crash-looping service that also crash-loops your restarts is worse. Here’s a pure-Ruby systemd watchdog with a rate-limited auto-restart that refuses to do that.

Step through the build below:




systemd_watchdog.rb

A crashed service that never gets flagged is one of the most common causes of a 3am page. systemctl status is fine for a human staring at one box, but it gives you nothing machine-readable for cron or a monitoring pipeline, and it does nothing to stop a crash-looping service from being restarted into the ground.

systemd_watchdog.rb checks a list of units, classifies each as OK / WARN / CRIT with real exit codes, and — if you opt in with --restart — auto-restarts failed units through a rate limiter that refuses to restart the same unit more than N times in a trailing window.

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# systemd_watchdog.rb — watch a list of systemd units, classify their health,
# and (optionally) auto-restart failed units with a rate-limited backoff so a
# crash-looping service can't be restarted into oblivion by cron.
#
# Why this exists: `systemctl status` is fine for a human staring at one box,
# but it doesn't give you a machine-readable health check you can drop into
# cron/Nagios/Prometheus textfile collectors, and it doesn't protect you from
# a service that fails, gets restarted, fails again, forever. This script is
# that missing piece: a few hundred lines of pure Ruby stdlib, no gems.
#
# Usage:
#   ruby systemd_watchdog.rb --units nginx,sshd,cron
#   ruby systemd_watchdog.rb --units nginx,sshd --restart --max-restarts 3 --window 600
#   ruby systemd_watchdog.rb --units nginx --json
#
# Exit codes (cron/Nagios-friendly):
#   0 = all units OK
#   1 = at least one unit WARN (activating/reloading/unknown)
#   2 = at least one unit CRIT (failed, or inactive when it should be running)
require 'optparse'
require 'open3'
require 'json'
require 'time'
require 'fileutils'
# ---------------------------------------------------------------------------
# UnitStatus: the result of inspecting a single systemd unit.
# ---------------------------------------------------------------------------
UnitStatus = Struct.new(:name, :active_state, :sub_state, :load_state, :result,
                         :level, :restarted, :message, keyword_init: true) do
  def to_h
    super.reject { |k, _| k == :message } .merge(message: message)
  end
end
# ---------------------------------------------------------------------------
# SystemdWatchdog: queries systemctl, classifies units, and drives restarts.
# ---------------------------------------------------------------------------
class SystemdWatchdog
  # Properties we pull from `systemctl show`. Keeping this list short keeps
  # each subprocess call fast — we only ask for what we actually use.
  PROPERTIES = %w[ActiveState SubState LoadState Result].freeze
  def initialize(units:, restart: false, max_restarts: 3, window: 600,
                 state_file: nil, dry_run: false, logger: $stderr)
    @units = units
    @restart = restart
    @max_restarts = max_restarts
    @window = window # seconds
    @state_file = state_file || default_state_file
    @dry_run = dry_run
    @logger = logger
    @state = load_state
  end
  # Runs the check (and restarts, if enabled) for every configured unit.
  # Returns an array of UnitStatus.
  def run
    @units.map { |unit| check_unit(unit) }
  ensure
    save_state
  end
  private
  # --- inspection ----------------------------------------------------------
  def check_unit(unit)
    props = show_properties(unit)
    if props.empty?
      return UnitStatus.new(name: unit, active_state: 'unknown', sub_state: 'unknown',
                             load_state: 'unknown', result: 'unknown', level: :warn,
                             restarted: false, message: 'systemctl returned no data (unit may not exist)')
    end
    status = classify(unit, props)
    if status.level == :crit && @restart
      status.restarted = attempt_restart(unit)
    end
    status
  end
  # Runs `systemctl show  -p Prop1 -p Prop2 ...` and parses the
  # `Key=Value` lines it prints (one per requested property, in order).
  def show_properties(unit)
    args = ['systemctl', 'show', unit]
    PROPERTIES.each { |p| args += ['-p', p] }
    stdout, stderr, status = Open3.capture3(*args)
    unless status.success?
      log("systemctl show #{unit} failed: #{stderr.strip}")
      return {}
    end
    stdout.each_line.each_with_object({}) do |line, h|
      key, _, value = line.strip.partition('=')
      h[key] = value unless key.empty?
    end
  end
  # Turns the raw ActiveState/SubState/Result into an OK/WARN/CRIT verdict.
  # This is deliberately conservative: anything we don't recognize is WARN,
  # never silently OK, so unexpected systemd output can't hide a problem.
  def classify(unit, props)
    active = props['ActiveState'] || 'unknown'
    sub    = props['SubState'] || 'unknown'
    load_s = props['LoadState'] || 'unknown'
    result = props['Result'] || 'unknown'
    level, message =
      case active
      when 'active'
        [:ok, "#{unit} is active (#{sub})"]
      when 'activating', 'reloading', 'deactivating'
        [:warn, "#{unit} is transitioning (#{active}/#{sub})"]
      when 'failed'
        [:crit, "#{unit} has FAILED (result=#{result})"]
      when 'inactive'
        # `inactive` isn't automatically bad — plenty of oneshot/timer units
        # are supposed to be inactive between runs. We only flag it CRIT if
        # systemd itself recorded a non-success Result for the last run.
        if %w[success start-limit-hit exec-condition].include?(result) && result != 'success'
          [:crit, "#{unit} is inactive with result=#{result}"]
        elsif result == 'success' || result == 'unknown'
          [:ok, "#{unit} is inactive (result=#{result})"]
        else
          [:crit, "#{unit} is inactive with result=#{result}"]
        end
      else
        [:warn, "#{unit} reported unrecognized ActiveState=#{active}"]
      end
    level = :crit if load_s == 'not-found'
    message = "#{unit} unit file not found" if load_s == 'not-found'
    UnitStatus.new(name: unit, active_state: active, sub_state: sub, load_state: load_s,
                    result: result, level: level, restarted: false, message: message)
  end
  # --- restart / rate limiting ---------------------------------------------
  # Restarts a failed unit unless it has already been restarted
  # @max_restarts times within the trailing @window seconds — that guard is
  # what stops this script from turning a crash-looping service into a
  # restart-looping cron job that hammers the box every minute forever.
  def attempt_restart(unit)
    history = (@state[unit] ||= [])
    now = Time.now
    history.reject! { |t| now - Time.parse(t) > @window }
    if history.size >= @max_restarts
      log("#{unit}: hit #{@max_restarts} restarts within #{@window}s, refusing to restart again " \
          '(manual intervention needed)')
      return false
    end
    if @dry_run
      log("[dry-run] would run: systemctl restart #{unit}")
      return false
    end
    log("#{unit}: attempting restart (#{history.size + 1}/#{@max_restarts} in window)")
    _out, err, status = Open3.capture3('systemctl', 'restart', unit)
    if status.success?
      history << now.iso8601
      log("#{unit}: restart succeeded")
      true
    else
      log("#{unit}: restart command failed: #{err.strip}")
      false
    end
  end
  # --- state persistence -----------------------------------------------------
  def default_state_file
    File.join((ENV['TMPDIR'] || '/tmp'), 'systemd_watchdog_state.json')
  end
  def load_state
    return {} unless File.exist?(@state_file)
    JSON.parse(File.read(@state_file))
  rescue JSON::ParserError
    {}
  end
  def save_state
    FileUtils.mkdir_p(File.dirname(@state_file))
    File.write(@state_file, JSON.pretty_generate(@state))
  rescue StandardError => e
    log("could not persist state file #{@state_file}: #{e.message}")
  end
  def log(msg)
    @logger.puts("[systemd_watchdog] #{msg}")
  end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if $PROGRAM_NAME == __FILE__
  options = {
    units: [],
    restart: false,
    max_restarts: 3,
    window: 600,
    json: false,
    dry_run: false,
    state_file: nil
  }
  OptionParser.new do |opts|
    opts.banner = 'Usage: systemd_watchdog.rb --units UNIT1,UNIT2 [options]'
    opts.on('-u', '--units UNITS', 'Comma-separated list of unit names to check') do |v|
      options[:units] = v.split(',').map(&:strip)
    end
    opts.on('-r', '--restart', 'Auto-restart units found in CRIT state') { options[:restart] = true }
    opts.on('--max-restarts N', Integer, 'Max restarts per unit within --window (default 3)') do |v|
      options[:max_restarts] = v
    end
    opts.on('--window SECONDS', Integer, 'Rate-limit window in seconds (default 600)') do |v|
      options[:window] = v
    end
    opts.on('--state-file PATH', 'Where to persist restart history (default /tmp)') do |v|
      options[:state_file] = v
    end
    opts.on('--dry-run', 'Log what would be restarted without doing it') { options[:dry_run] = true }
    opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true }
    opts.on('-h', '--help', 'Show this help') do
      puts opts
      exit 0
    end
  end.parse!
  if options[:units].empty?
    warn 'error: --units is required, e.g. --units nginx,sshd,cron'
    exit 3
  end
  watchdog = SystemdWatchdog.new(
    units: options[:units],
    restart: options[:restart],
    max_restarts: options[:max_restarts],
    window: options[:window],
    state_file: options[:state_file],
    dry_run: options[:dry_run]
  )
  results = watchdog.run
  worst = results.map(&:level).max_by { |l| { ok: 0, warn: 1, crit: 2 }[l] }
  if options[:json]
    puts JSON.pretty_generate(
      generated_at: Time.now.iso8601,
      overall: worst.to_s,
      units: results.map(&:to_h)
    )
  else
    results.each do |r|
      tag = { ok: 'OK  ', warn: 'WARN', crit: 'CRIT' }[r.level]
      restarted = r.restarted ? ' [restarted]' : ''
      puts "#{tag} #{r.name.ljust(20)} #{r.message}#{restarted}"
    end
    puts "\noverall: #{worst}"
  end
  exit({ ok: 0, warn: 1, crit: 2 }[worst])
end

classify() is intentionally conservative — anything it doesn’t recognize is WARN, never a silent OK.

attempt_restart() reads a small JSON state file of past restart timestamps and refuses to restart a unit again once it hits --max-restarts within --window seconds — that’s what stops a crash loop from becoming a restart loop.

Exit codes (0/1/2 for OK/WARN/CRIT) match the convention cron, Nagios, and CI health gates already expect.

$ ruby systemd_watchdog.rb --units cron,ssh,apparmor,this-unit-does-not-exist
OK   cron                 cron is active (running)
OK   ssh                  ssh is active (running)
OK   apparmor             apparmor is active (exited)
CRIT this-unit-does-not-exist this-unit-does-not-exist unit file not found
overall: crit
$ echo $?
2
$ ruby systemd_watchdog_test.rb
[systemd_watchdog] flaky-app.service: attempting restart (1/3 in window)
[systemd_watchdog] flaky-app.service: restart succeeded
[systemd_watchdog] flaky-app.service: attempting restart (2/3 in window)
[systemd_watchdog] flaky-app.service: restart succeeded
[systemd_watchdog] flaky-app.service: attempting restart (3/3 in window)
[systemd_watchdog] flaky-app.service: restart succeeded
[systemd_watchdog] flaky-app.service: hit 3 restarts within 600s, refusing to restart again (manual intervention needed)
[systemd_watchdog] [dry-run] would run: systemctl restart flaky-app.service
PASS  healthy unit classifies as :ok
PASS  failed unit classifies as :crit and gets restarted
PASS  rate limiter refuses restart #4 within the window
PASS  dry-run mode logs but does not restart
PASS  unit that does not exist (not-found) classifies as :crit
ALL TESTS PASSED

Get the code

Full script + README on GitHub: ruby-devops-toolkit/systemd-watchdog

Prerequisites

Prerequisites

You’ll need
  • Ruby 2.7+ (tested on 3.0.2) — stdlib only: optparse, open3, json, fileutils. No gems.
  • A Linux host running systemd (systemctl on your PATH).
  • Permission to run systemctl show (unprivileged is fine) and, if you enable --restart, permission to run systemctl restart <unit> for the units you’re watching (typically via sudo/polkit).
systemd watchdog architecture
The full script

The Complete Script

systemd_watchdog.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# systemd_watchdog.rb — watch a list of systemd units, classify their health,
# and (optionally) auto-restart failed units with a rate-limited backoff so a
# crash-looping service can't be restarted into oblivion by cron.
#
# Why this exists: `systemctl status` is fine for a human staring at one box,
# but it doesn't give you a machine-readable health check you can drop into
# cron/Nagios/Prometheus textfile collectors, and it doesn't protect you from
# a service that fails, gets restarted, fails again, forever. This script is
# that missing piece: a few hundred lines of pure Ruby stdlib, no gems.
#
# Usage:
#   ruby systemd_watchdog.rb --units nginx,sshd,cron
#   ruby systemd_watchdog.rb --units nginx,sshd --restart --max-restarts 3 --window 600
#   ruby systemd_watchdog.rb --units nginx --json
#
# Exit codes (cron/Nagios-friendly):
#   0 = all units OK
#   1 = at least one unit WARN (activating/reloading/unknown)
#   2 = at least one unit CRIT (failed, or inactive when it should be running)
require 'optparse'
require 'open3'
require 'json'
require 'time'
require 'fileutils'
# ---------------------------------------------------------------------------
# UnitStatus: the result of inspecting a single systemd unit.
# ---------------------------------------------------------------------------
UnitStatus = Struct.new(:name, :active_state, :sub_state, :load_state, :result,
                         :level, :restarted, :message, keyword_init: true) do
  def to_h
    super.reject { |k, _| k == :message } .merge(message: message)
  end
end
# ---------------------------------------------------------------------------
# SystemdWatchdog: queries systemctl, classifies units, and drives restarts.
# ---------------------------------------------------------------------------
class SystemdWatchdog
  # Properties we pull from `systemctl show`. Keeping this list short keeps
  # each subprocess call fast — we only ask for what we actually use.
  PROPERTIES = %w[ActiveState SubState LoadState Result].freeze
  def initialize(units:, restart: false, max_restarts: 3, window: 600,
                 state_file: nil, dry_run: false, logger: $stderr)
    @units = units
    @restart = restart
    @max_restarts = max_restarts
    @window = window # seconds
    @state_file = state_file || default_state_file
    @dry_run = dry_run
    @logger = logger
    @state = load_state
  end
  # Runs the check (and restarts, if enabled) for every configured unit.
  # Returns an array of UnitStatus.
  def run
    @units.map { |unit| check_unit(unit) }
  ensure
    save_state
  end
  private
  # --- inspection ----------------------------------------------------------
  def check_unit(unit)
    props = show_properties(unit)
    if props.empty?
      return UnitStatus.new(name: unit, active_state: 'unknown', sub_state: 'unknown',
                             load_state: 'unknown', result: 'unknown', level: :warn,
                             restarted: false, message: 'systemctl returned no data (unit may not exist)')
    end
    status = classify(unit, props)
    if status.level == :crit && @restart
      status.restarted = attempt_restart(unit)
    end
    status
  end
  # Runs `systemctl show <unit> -p Prop1 -p Prop2 ...` and parses the
  # `Key=Value` lines it prints (one per requested property, in order).
  def show_properties(unit)
    args = ['systemctl', 'show', unit]
    PROPERTIES.each { |p| args += ['-p', p] }
    stdout, stderr, status = Open3.capture3(*args)
    unless status.success?
      log("systemctl show #{unit} failed: #{stderr.strip}")
      return {}
    end
    stdout.each_line.each_with_object({}) do |line, h|
      key, _, value = line.strip.partition('=')
      h[key] = value unless key.empty?
    end
  end
  # Turns the raw ActiveState/SubState/Result into an OK/WARN/CRIT verdict.
  # This is deliberately conservative: anything we don't recognize is WARN,
  # never silently OK, so unexpected systemd output can't hide a problem.
  def classify(unit, props)
    active = props['ActiveState'] || 'unknown'
    sub    = props['SubState'] || 'unknown'
    load_s = props['LoadState'] || 'unknown'
    result = props['Result'] || 'unknown'
    level, message =
      case active
      when 'active'
        [:ok, "#{unit} is active (#{sub})"]
      when 'activating', 'reloading', 'deactivating'
        [:warn, "#{unit} is transitioning (#{active}/#{sub})"]
      when 'failed'
        [:crit, "#{unit} has FAILED (result=#{result})"]
      when 'inactive'
        # `inactive` isn't automatically bad — plenty of oneshot/timer units
        # are supposed to be inactive between runs. We only flag it CRIT if
        # systemd itself recorded a non-success Result for the last run.
        if %w[success start-limit-hit exec-condition].include?(result) && result != 'success'
          [:crit, "#{unit} is inactive with result=#{result}"]
        elsif result == 'success' || result == 'unknown'
          [:ok, "#{unit} is inactive (result=#{result})"]
        else
          [:crit, "#{unit} is inactive with result=#{result}"]
        end
      else
        [:warn, "#{unit} reported unrecognized ActiveState=#{active}"]
      end
    level = :crit if load_s == 'not-found'
    message = "#{unit} unit file not found" if load_s == 'not-found'
    UnitStatus.new(name: unit, active_state: active, sub_state: sub, load_state: load_s,
                    result: result, level: level, restarted: false, message: message)
  end
  # --- restart / rate limiting ---------------------------------------------
  # Restarts a failed unit unless it has already been restarted
  # @max_restarts times within the trailing @window seconds — that guard is
  # what stops this script from turning a crash-looping service into a
  # restart-looping cron job that hammers the box every minute forever.
  def attempt_restart(unit)
    history = (@state[unit] ||= [])
    now = Time.now
    history.reject! { |t| now - Time.parse(t) > @window }
    if history.size >= @max_restarts
      log("#{unit}: hit #{@max_restarts} restarts within #{@window}s, refusing to restart again " \
          '(manual intervention needed)')
      return false
    end
    if @dry_run
      log("[dry-run] would run: systemctl restart #{unit}")
      return false
    end
    log("#{unit}: attempting restart (#{history.size + 1}/#{@max_restarts} in window)")
    _out, err, status = Open3.capture3('systemctl', 'restart', unit)
    if status.success?
      history << now.iso8601
      log("#{unit}: restart succeeded")
      true
    else
      log("#{unit}: restart command failed: #{err.strip}")
      false
    end
  end
  # --- state persistence -----------------------------------------------------
  def default_state_file
    File.join((ENV['TMPDIR'] || '/tmp'), 'systemd_watchdog_state.json')
  end
  def load_state
    return {} unless File.exist?(@state_file)
    JSON.parse(File.read(@state_file))
  rescue JSON::ParserError
    {}
  end
  def save_state
    FileUtils.mkdir_p(File.dirname(@state_file))
    File.write(@state_file, JSON.pretty_generate(@state))
  rescue StandardError => e
    log("could not persist state file #{@state_file}: #{e.message}")
  end
  def log(msg)
    @logger.puts("[systemd_watchdog] #{msg}")
  end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if $PROGRAM_NAME == __FILE__
  options = {
    units: [],
    restart: false,
    max_restarts: 3,
    window: 600,
    json: false,
    dry_run: false,
    state_file: nil
  }
  OptionParser.new do |opts|
    opts.banner = 'Usage: systemd_watchdog.rb --units UNIT1,UNIT2 [options]'
    opts.on('-u', '--units UNITS', 'Comma-separated list of unit names to check') do |v|
      options[:units] = v.split(',').map(&:strip)
    end
    opts.on('-r', '--restart', 'Auto-restart units found in CRIT state') { options[:restart] = true }
    opts.on('--max-restarts N', Integer, 'Max restarts per unit within --window (default 3)') do |v|
      options[:max_restarts] = v
    end
    opts.on('--window SECONDS', Integer, 'Rate-limit window in seconds (default 600)') do |v|
      options[:window] = v
    end
    opts.on('--state-file PATH', 'Where to persist restart history (default /tmp)') do |v|
      options[:state_file] = v
    end
    opts.on('--dry-run', 'Log what would be restarted without doing it') { options[:dry_run] = true }
    opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true }
    opts.on('-h', '--help', 'Show this help') do
      puts opts
      exit 0
    end
  end.parse!
  if options[:units].empty?
    warn 'error: --units is required, e.g. --units nginx,sshd,cron'
    exit 3
  end
  watchdog = SystemdWatchdog.new(
    units: options[:units],
    restart: options[:restart],
    max_restarts: options[:max_restarts],
    window: options[:window],
    state_file: options[:state_file],
    dry_run: options[:dry_run]
  )
  results = watchdog.run
  worst = results.map(&:level).max_by { |l| { ok: 0, warn: 1, crit: 2 }[l] }
  if options[:json]
    puts JSON.pretty_generate(
      generated_at: Time.now.iso8601,
      overall: worst.to_s,
      units: results.map(&:to_h)
    )
  else
    results.each do |r|
      tag = { ok: 'OK  ', warn: 'WARN', crit: 'CRIT' }[r.level]
      restarted = r.restarted ? ' [restarted]' : ''
      puts "#{tag} #{r.name.ljust(20)} #{r.message}#{restarted}"
    end
    puts "\noverall: #{worst}"
  end
  exit({ ok: 0, warn: 1, crit: 2 }[worst])
end
Walkthrough

How It Works

The script is three layers stacked on top of each other, and each one is independently testable — which is exactly how the test suite below exercises it.

1. show_properties — one subprocess per unit

For every unit you pass with --units, the script shells out to systemctl show <unit> -p ActiveState -p SubState -p LoadState -p Result via Open3.capture3. Asking for exactly those four properties (instead of the full property dump systemctl show normally prints) keeps each call fast — that matters once you’re watching a few dozen units on a 1-minute cron cadence. The output comes back as plain Key=Value lines, which show_properties parses into a hash.

2. classify — turning raw state into OK / WARN / CRIT

This is the part worth reading carefully, because it’s deliberately conservative: anything the method doesn’t explicitly recognize falls into :warn, never :ok. ActiveState=active is OK. failed is CRIT. activating/reloading/deactivating are WARN (the unit is mid-transition, give it another cycle). inactive is the interesting case — a lot of oneshot and timer units are supposed to be inactive between runs, so the script only flags an inactive unit as CRIT if systemd itself recorded a non-success Result for the last run. And if LoadState=not-found — you typo’d the unit name, or it was uninstalled — that’s always CRIT, regardless of what ActiveState says.

3. attempt_restart — restart with a rate limiter, not a retry loop

This is the piece that turns a health check into something safe to point --restart at in production. Before restarting anything, it loads a small JSON state file (<unit> => [timestamp, timestamp, ...]), throws away entries older than --window seconds, and only restarts if there are fewer than --max-restarts entries left. A service that’s crash-looping gets three restart attempts and then the script backs off and logs “manual intervention needed” instead of hammering systemctl restart every minute forever. --dry-run walks the exact same rate-limit logic without ever calling systemctl restart, which is how you’d first roll this out against a fleet you don’t fully trust yet.

The CLI wires all three together and exits 0/1/2 for OK/WARN/CRIT — the same convention Nagios-style checks and CI health gates expect, so you can drop this straight into a monitoring pipeline without a wrapper script.

Verified output

Example Output

$ ruby systemd_watchdog.rb
$ ruby systemd_watchdog.rb –units cron,ssh,apparmor,this-unit-does-not-exist
OK cron cron is active (running)
OK ssh ssh is active (running)
OK apparmor apparmor is active (exited)
CRIT this-unit-does-not-exist this-unit-does-not-exist unit file not found
overall: crit
$ echo $?
2
$ ruby systemd_watchdog_test.rb
[systemd_watchdog] flaky-app.service: attempting restart (1/3 in window)
[systemd_watchdog] flaky-app.service: restart succeeded
[systemd_watchdog] flaky-app.service: attempting restart (2/3 in window)
[systemd_watchdog] flaky-app.service: restart succeeded
[systemd_watchdog] flaky-app.service: attempting restart (3/3 in window)
[systemd_watchdog] flaky-app.service: restart succeeded
[systemd_watchdog] flaky-app.service: hit 3 restarts within 600s, refusing to restart again (manual intervention needed)
[systemd_watchdog] [dry-run] would run: systemctl restart flaky-app.service
PASS healthy unit classifies as :ok
PASS failed unit classifies as :crit and gets restarted
PASS rate limiter refuses restart #4 within the window
PASS dry-run mode logs but does not restart
PASS unit that does not exist (not-found) classifies as :crit
ALL TESTS PASSED
When it doesn't behave

Troubleshooting

Common issues
  • “systemctl show <unit> failed” / empty properties. Almost always a typo’d unit name, or the unit is a template unit that needs an instance suffix ([email protected], not [email protected]). The script reports this unit as :warn rather than crashing the whole run.
  • Restarts never happen even with --restart. Check the state file (default: $TMPDIR/systemd_watchdog_state.json, override with --state-file) — if the unit already has --max-restarts timestamps inside the --window, that’s the rate limiter doing its job. Also confirm the user running the script actually has permission to restart that unit; a permission failure is logged but doesn’t raise.
  • Permission denied on systemctl restart. This script deliberately does not attempt to sudo/escalate itself — wire that up at the cron/systemd-timer level (e.g. a narrow sudoers rule scoped to systemctl restart nginx, not a blanket NOPASSWD) rather than inside the Ruby.
  • Exit code 3. That’s an argument error (missing --units), not a health verdict — keep it out of your alerting thresholds for “unit is unhealthy.”
Make it yours

Extending This Script

Ideas
  • Add a --webhook URL flag and POST a JSON summary on any CRIT, instead of relying on the exit code alone.
  • Read the unit list from a YAML/JSON config file instead of --units, so the fleet you watch is version-controlled alongside the script.
  • Emit Prometheus textfile-collector output (node_systemd_unit_state{unit="nginx"} 1) instead of/alongside JSON — pairs well with this repo’s prometheus-exporter script.
  • Track restart reasons (the Result value at restart time) in the state file so a post-incident review can see exactly what kept failing.