Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.

The Problem

A container can be “running” and still be broken — a process that’s alive but wedged, a web server accepting connections but returning 500s for everything, a worker that stopped consuming its queue. Docker’s built-in HEALTHCHECK mechanism exists exactly for this, and once configured, Docker tracks a healthy/unhealthy/starting status per container. The catch is that Docker won’t act on that status by itself — nothing restarts an unhealthy container just because its healthcheck is failing, unless you build that loop yourself.

The usual answer is to install the official docker-api gem, but you don’t actually need it: the Docker Engine API is just HTTP over a Unix domain socket (/var/run/docker.sock by default), and Ruby’s standard library has everything required to speak raw HTTP/1.1 over a UNIXSocket. This script does exactly that in about 200 lines, with no third-party dependencies.

Prerequisites

  • Ruby 3.x — only socket, json, and optparse from the standard library, nothing to gem install
  • Docker Engine running with its Unix socket available (the Linux default: /var/run/docker.sock)
  • Read access to the socket for listing/inspecting; typically means running as root or a member of the docker group
  • At least one container with a HEALTHCHECK defined in its image or docker run --health-cmd ... to see non-trivial output

How It Works

Four layers, each with one job: a minimal HTTP client that speaks to the Unix socket, a thin Docker API wrapper on top of it, a health classifier that decides what needs attention, and a report formatter.

Docker health monitoring workflow: UnixSocketHTTP talks raw HTTP/1.1 to the Docker Engine socket, DockerClient wraps the relevant endpoints, HealthMonitor classifies containers and chooses dry-run or restart, and a test harness swaps in a fake UNIXServer standing in for dockerd

The full script

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# docker_health_monitor.rb — Poll the Docker Engine API over its Unix
# socket for container health status and restart anything unhealthy.
# No 'docker' gem, no shelling out to the `docker` CLI — just a raw
# HTTP/1.1 request written straight to /var/run/docker.sock.
#
# Usage:
#   ruby docker_health_monitor.rb list
#   ruby docker_health_monitor.rb check                  # dry-run by default
#   ruby docker_health_monitor.rb check --restart-unhealthy
#   ruby docker_health_monitor.rb check --json report.json
#
# Requires: Ruby 3.x, Docker Engine with its Unix socket exposed
# (the default on Linux at /var/run/docker.sock). Nothing to `gem install`.

require 'socket'
require 'json'
require 'optparse'

# ---------------------------------------------------------------------------
# UnixSocketHTTP — the smallest HTTP/1.1 client that can talk to a Unix
# domain socket. Ruby's Net::HTTP is built around TCPSocket and doesn't
# support Unix sockets directly, so for a tool this small it's simpler to
# write the request line and headers by hand than to monkey-patch Net::HTTP.
# ---------------------------------------------------------------------------
class UnixSocketHTTP
  Response = Struct.new(:status, :headers, :body) do
    def json
      JSON.parse(body)
    end
  end

  def initialize(socket_path)
    @socket_path = socket_path
  end

  def get(path)
    request('GET', path)
  end

  def post(path, body = nil)
    request('POST', path, body)
  end

  private

  def request(method, path, body = nil)
    socket = UNIXSocket.new(@socket_path)
    payload = body ? JSON.generate(body) : nil

    lines = ["#{method} #{path} HTTP/1.1", 'Host: localhost', 'Connection: close']
    lines << 'Content-Type: application/json' if payload
    lines << "Content-Length: #{payload.bytesize}" if payload
    socket.write(lines.join("\r\n") + "\r\n\r\n")
    socket.write(payload) if payload

    raw = socket.read
    socket.close
    parse_response(raw)
  end

  def parse_response(raw)
    head, _, rest = raw.partition("\r\n\r\n")
    status_line, *header_lines = head.split("\r\n")
    status = status_line.split(' ')[1].to_i
    headers = header_lines.each_with_object({}) do |line, h|
      k, v = line.split(':', 2)
      h[k.strip.downcase] = v.strip if k && v
    end

    body = if headers['transfer-encoding'] == 'chunked'
             dechunk(rest)
           else
             rest
           end

    Response.new(status, headers, body)
  end

  # Docker's API responds with chunked transfer-encoding for most GET
  # requests. Each chunk is prefixed with its length in hex on its own
  # line, terminated by a zero-length chunk.
  def dechunk(data)
    out = +''
    until data.empty?
      size_line, _, data = data.partition("\r\n")
      size = size_line.to_i(16)
      break if size.zero?

      out << data[0, size]
      data = data[(size + 2)..] || '' # skip the chunk's trailing \r\n
    end
    out
  end
end

# ---------------------------------------------------------------------------
# DockerClient — the handful of Engine API calls this tool needs, expressed
# as plain methods instead of a full API wrapper.
# ---------------------------------------------------------------------------
class DockerClient
  def initialize(socket_path: '/var/run/docker.sock', http: nil)
    @http = http || UnixSocketHTTP.new(socket_path)
  end

  # GET /containers/json?all=true -> Array of container summaries
  def containers
    @http.get('/containers/json?all=true').json
  end

  # GET /containers/:id/json -> detailed inspect, including Health
  def inspect_container(id)
    @http.get("/containers/#{id}/json").json
  end

  def restart_container(id)
    @http.post("/containers/#{id}/restart")
  end
end

# ---------------------------------------------------------------------------
# HealthMonitor — decides which containers need attention. Depends only on
# a client object that responds to #containers, #inspect_container, and
# #restart_container, so tests can substitute a FakeDockerClient backed by
# an in-memory Hash instead of a real Engine API.
# ---------------------------------------------------------------------------
class HealthMonitor
  Result = Struct.new(:name, :id, :status, :action)

  def initialize(client, restart_unhealthy: false)
    @client = client
    @restart_unhealthy = restart_unhealthy
  end

  def run
    @client.containers.map do |summary|
      id = summary['Id']
      name = (summary['Names'] || ['?']).first.to_s.sub(%r{\A/}, '')
      detail = @client.inspect_container(id)
      health = detail.dig('State', 'Health', 'Status') # "healthy" / "unhealthy" / "starting" / nil

      action = 'none'
      if health == 'unhealthy'
        if @restart_unhealthy
          @client.restart_container(id)
          action = 'restarted'
        else
          action = 'would_restart (dry-run)'
        end
      end

      Result.new(name, id[0, 12], health || 'no-healthcheck', action)
    end
  end
end

# ---------------------------------------------------------------------------
# Report — pure formatting over an Array.
# ---------------------------------------------------------------------------
module Report
  def self.print_table(results)
    name_width = [results.map { |r| r.name.length }.max || 4, 4].max
    puts format("%-#{name_width}s  %-12s  %-16s  %s", 'NAME', 'ID', 'HEALTH', 'ACTION')
    results.each do |r|
      puts format("%-#{name_width}s  %-12s  %-16s  %s", r.name, r.id, r.status, r.action)
    end
    unhealthy = results.count { |r| r.status == 'unhealthy' }
    puts "\n#{results.size} container(s), #{unhealthy} unhealthy."
  end

  def self.to_json(results)
    JSON.pretty_generate(results.map(&:to_h))
  end
end

# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
if $PROGRAM_NAME == __FILE__
  options = { socket: '/var/run/docker.sock', restart: false, json: nil }
  command = ARGV.shift
  OptionParser.new do |opts|
    opts.on('--socket PATH', 'Docker socket path (default /var/run/docker.sock)') { |v| options[:socket] = v }
    opts.on('--restart-unhealthy', 'Actually restart unhealthy containers (default is dry-run)') { options[:restart] = true }
    opts.on('--json PATH', 'Write JSON report to PATH') { |v| options[:json] = v }
  end.parse!(ARGV)

  client = DockerClient.new(socket_path: options[:socket])

  case command
  when 'list'
    client.containers.each do |c|
      name = (c['Names'] || ['?']).first.to_s.sub(%r{\A/}, '')
      puts format('%-12s %-20s %s', c['Id'][0, 12], name, c['Status'])
    end
  when 'check'
    results = HealthMonitor.new(client, restart_unhealthy: options[:restart]).run
    Report.print_table(results)
    File.write(options[:json], Report.to_json(results)) if options[:json]
    exit(results.any? { |r| r.status == 'unhealthy' && r.action == 'would_restart (dry-run)' } ? 1 : 0)
  else
    warn 'Usage: docker_health_monitor.rb [list|check] [--restart-unhealthy] [--socket PATH] [--json PATH]'
    exit 1
  end
end

Walkthrough

UnixSocketHTTP is the piece worth slowing down on, since it’s the least common pattern in typical Ruby code. Opening a UNIXSocket instead of a TCPSocket gets you a byte stream to the Docker daemon, but nothing speaks HTTP for you — the request line, headers, and blank-line terminator are written by hand, exactly as they’d appear on the wire. The response side is a little more involved: Docker responds to most GET requests with Transfer-Encoding: chunked rather than a fixed Content-Length, so dechunk walks the hex-prefixed chunk format (length in hex, CRLF, that many bytes, CRLF, repeat until a zero-length chunk) to reassemble the real body.

DockerClient deliberately exposes only the three calls this tool needs — list containers, inspect one, restart one — rather than trying to be a general-purpose API wrapper. That restraint is what keeps the whole script under 200 lines and easy to audit.

HealthMonitor#run is the decision logic, and it depends only on an object that responds to #containers, #inspect_container, and #restart_container — not on DockerClient specifically. That’s what makes it possible to substitute a fake client backed by an in-memory Hash in tests, the same dependency-injection pattern used elsewhere in this series. A container with no healthcheck configured at all reports nil for State.Health.Status, which the script surfaces as no-healthcheck rather than lumping it in with genuine failures — an unmonitored container isn’t the same problem as a failing one.

The dry-run default is deliberate: check alone never restarts anything, only --restart-unhealthy does. That mirrors the account-provisioning script elsewhere in this series — anything that mutates running infrastructure should require an explicit, hard-to-typo flag, not be the default behavior of the read-focused command.

Example Output

Verified against a real Unix-socket HTTP server — a small fake “dockerd” written for testing that serves the same chunked and fixed-length response shapes the real Engine API uses, so the HTTP client code above ran unmodified against real socket I/O:

$ ruby docker_health_monitor.rb list --socket /tmp/fake_docker.sock
a1a1a1a1a1a1 web1                 Up 2 hours (healthy)
b2b2b2b2b2b2 worker1              Up 10 minutes (unhealthy)
c3c3c3c3c3c3 cache1               Up 3 days

$ ruby docker_health_monitor.rb check --socket /tmp/fake_docker.sock
NAME     ID            HEALTH            ACTION
web1     a1a1a1a1a1a1  healthy           none
worker1  b2b2b2b2b2b2  unhealthy         would_restart (dry-run)
cache1   c3c3c3c3c3c3  no-healthcheck    none

3 container(s), 1 unhealthy.

$ ruby docker_health_monitor.rb check --socket /tmp/fake_docker.sock --restart-unhealthy
NAME     ID            HEALTH            ACTION
web1     a1a1a1a1a1a1  healthy           none
worker1  b2b2b2b2b2b2  unhealthy         restarted
cache1   c3c3c3c3c3c3  no-healthcheck    none

3 container(s), 1 unhealthy.

The restart call was independently confirmed by having the fake daemon log every POST /containers/<id>/restart it received — it logged exactly one call, for worker1‘s container ID, only in the --restart-unhealthy run.

Troubleshooting

  • “No such file or directory” opening the socket — confirm Docker is running (systemctl status docker) and that /var/run/docker.sock exists; on some setups the socket lives at a rootless path like $XDG_RUNTIME_DIR/docker.sock instead, which is what --socket is for.
  • “Permission denied” opening the socket — your user isn’t in the docker group (or you’re not root). Either sudo the script or add your user to the group and re-login.
  • Every container shows no-healthcheck — that’s expected if the images don’t define a HEALTHCHECK. Add one in the Dockerfile (HEALTHCHECK CMD curl -f http://localhost/ || exit 1) or pass --health-cmd to docker run.
  • Response parsing breaks on a Docker API version you’re using — the chunked-decoding logic assumes well-formed HTTP/1.1 chunking, which Docker has used consistently across API versions; if you see truncated JSON, check whether something in front of the socket (a proxy) is altering the transfer encoding.
  • Testing without a real Docker daemon — stand up a tiny UNIXServer that returns canned JSON for the same paths (/containers/json?all=true, /containers/<id>/json, /containers/<id>/restart); pointing --socket at it exercises the real HTTP client code with no Docker installation required, which is exactly how the output above was produced.

Extending This Script

  • Restart backoff / circuit breaker — track restart counts per container and stop auto-restarting (and instead just alert) after N restarts within a time window, so a permanently broken container doesn’t restart-loop forever.
  • Compose project filtering — Docker attaches labels like com.docker.compose.project to containers; filter on those to scope monitoring to one Compose stack at a time.
  • Resource stats alongside health — the Engine API also exposes /containers/:id/stats; extend DockerClient to pull CPU/memory and flag containers that are healthy but resource-starved.
  • Swarm/remote hostsUnixSocketHTTP can be swapped for a TCP-based variant (Docker also supports exposing the API over TCP with TLS) to monitor a remote host with the same DockerClient/HealthMonitor code untouched.
  • Notification integration — pipe Report.to_json into the same alerting pattern used in this series’ Windows event-log tutorial (email, or swap in a webhook) so unhealthy containers show up somewhere a human will see them.

Leave a Reply

Your email address will not be published. Required fields are marked *

Click to access the login or register cheese