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, andoptparsefrom the standard library, nothing togem 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
dockergroup - At least one container with a
HEALTHCHECKdefined in its image ordocker 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.

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.sockexists; on some setups the socket lives at a rootless path like$XDG_RUNTIME_DIR/docker.sockinstead, which is what--socketis for. - “Permission denied” opening the socket — your user isn’t in the
dockergroup (or you’re not root). Eithersudothe 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 aHEALTHCHECK. Add one in the Dockerfile (HEALTHCHECK CMD curl -f http://localhost/ || exit 1) or pass--health-cmdtodocker 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
UNIXServerthat returns canned JSON for the same paths (/containers/json?all=true,/containers/<id>/json,/containers/<id>/restart); pointing--socketat 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.projectto 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; extendDockerClientto pull CPU/memory and flag containers that are healthy but resource-starved. - Swarm/remote hosts —
UnixSocketHTTPcan be swapped for a TCP-based variant (Docker also supports exposing the API over TCP with TLS) to monitor a remote host with the sameDockerClient/HealthMonitorcode untouched. - Notification integration — pipe
Report.to_jsoninto 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.


