the shed // ruby for devops

A crash-looping worker still shows up as “running” in a glance at docker ps, and a container that mounts docker.sock into itself is effectively root on the host. This script talks straight to the Docker Engine API — over the same Unix socket the real docker CLI uses — to catch both.

Step through the build below:

docker_health_audit.rb

A host with forty containers on it is forty places for something to quietly go wrong. A worker stuck in a restart loop looks “running” in a glance at docker ps. A container someone launched with --privileged to debug a driver issue, or one that mounts /var/run/docker.sock into itself for “convenience,” is effectively root on the host — and neither shows up as an obvious red flag in the default CLI output.

This script talks directly to the Docker Engine API — the same HTTP-over-Unix-socket protocol the real docker CLI speaks under the hood — inventories every container, and flags crash-loop restarts, --privileged containers, and Docker-socket bind mounts: one specific, well-known container-escape vector security teams actually look for.

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# docker_health_audit.rb -- talk directly to the Docker Engine API (no
# `docker` CLI, no docker-api gem) to inventory every container on a host,
# then flag the specific things that bite people in production: crash-loop
# restarts, containers that mount the Docker socket into themselves (a
# well-known container-escape vector), and containers running --privileged.
#
# Usage:
#   ruby docker_health_audit.rb                              # talks to /var/run/docker.sock
#   ruby docker_health_audit.rb --socket /custom/docker.sock
#   ruby docker_health_audit.rb --host tcp://127.0.0.1:2375   # remote/TCP daemon, or a test double
#   ruby docker_health_audit.rb --json --restart-threshold 3
#
# Exit status:
#   0   no CRIT findings
#   1   at least one CRIT finding
#   2   usage / connection error
#
# Requires: Ruby 3.x, stdlib only (socket, json, optparse, uri, net/http for
# the --host tcp:// path). No gems -- this script speaks the Docker Engine
# API's HTTP-over-Unix-socket protocol directly, the same way the real
# `docker` CLI does under the hood.
require 'socket'
require 'json'
require 'optparse'
require 'uri'
require 'net/http'
# ---------------------------------------------------------------------------
# Minimal HTTP/1.1 GET over a Unix domain socket. Net::HTTP has no built-in
# support for connecting over a UNIXSocket, so for the (default, and most
# common in production) socket transport this hand-rolls just enough of the
# protocol to issue a GET and parse a Content-Length or chunked response.
# This is genuinely how the Docker CLI and most Docker client libraries
# talk to dockerd -- there is no TCP involved unless you've explicitly
# opted the daemon into it.
# ---------------------------------------------------------------------------
def http_get_over_unix_socket(socket_path, path)
  sock = UNIXSocket.new(socket_path)
  begin
    sock.write("GET #{path} HTTP/1.1\r\nHost: localhost\r\nAccept: application/json\r\nConnection: close\r\n\r\n")
    raw = sock.read
  ensure
    sock.close
  end
  head, body = raw.split("\r\n\r\n", 2)
  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
  if headers['transfer-encoding'].to_s.include?('chunked')
    body = dechunk(body)
  end
  [status, body]
end
def dechunk(body)
  out = +''
  rest = body
  loop do
    size_line, rest = rest.split("\r\n", 2)
    break if size_line.nil?
    size = size_line.strip.to_i(16)
    break if size.zero?
    out << rest[0...size]
    rest = rest[(size + 2)..] # skip the chunk's trailing \r\n
  end
  out
end
# ---------------------------------------------------------------------------
# Thin client abstraction: same #get(path) interface whether we're talking
# to a Unix socket or a tcp:// host (real remote daemon, or a test double
# started with `docker daemon -H tcp://...`-style config, or -- as used to
# verify this script -- a plain HTTP/Unix-socket stub server).
# ---------------------------------------------------------------------------
class DockerClient
  def initialize(socket_path: nil, host: nil)
    @socket_path = socket_path
    @host = host
  end
  def get(path)
    if @host
      uri = URI.parse("#{@host}#{path}")
      res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(Net::HTTP::Get.new(uri)) }
      [res.code.to_i, res.body]
    else
      http_get_over_unix_socket(@socket_path, path)
    end
  end
  def get_json(path)
    status, body = get(path)
    parsed = body.to_s.empty? ? nil : (JSON.parse(body) rescue nil)
    [status, parsed]
  end
end
Finding = Struct.new(:severity, :reason, keyword_init: true)
# ---------------------------------------------------------------------------
# Pure classification of one container's /containers/:id/json inspect
# payload. No socket/HTTP in here -- this is what gets exercised directly
# against hand-built fixtures.
# ---------------------------------------------------------------------------
def classify_container(inspect, restart_threshold:)
  findings = []
  name = inspect['Name'].to_s.sub(%r{^/}, '')
  state = inspect.dig('State', 'Status')
  restart_count = inspect['RestartCount'].to_i
  exit_code = inspect.dig('State', 'ExitCode')
  privileged = inspect.dig('HostConfig', 'Privileged')
  binds = inspect.dig('HostConfig', 'Binds') || []
  if restart_count >= restart_threshold && %w[running restarting].include?(state)
    findings << Finding.new(severity: :crit, reason: "restarted #{restart_count} times and is still #{state} -- likely crash-looping")
  end
  if state == 'exited' && exit_code.to_i != 0
    findings << Finding.new(severity: :warn, reason: "exited with non-zero status #{exit_code}")
  end
  if privileged
    findings << Finding.new(severity: :crit, reason: 'running with --privileged (full host device/capability access)')
  end
  if binds.any? { |b| b.include?('docker.sock') }
    findings << Finding.new(severity: :crit, reason: 'mounts the Docker socket into the container -- typically equivalent to root on the host')
  end
  binds.each do |b|
    src = b.split(':').first
    if %w[/ /etc /root].include?(src)
      findings << Finding.new(severity: :warn, reason: "bind-mounts sensitive host path #{src} into the container")
    end
  end
  overall = if findings.any? { |f| f.severity == :crit }
              :crit
            elsif findings.any? { |f| f.severity == :warn }
              :warn
            else
              :ok
            end
  { name: name, state: state, severity: overall, findings: findings }
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
options = { socket: '/var/run/docker.sock', host: nil, json: false, restart_threshold: 5 }
parser = OptionParser.new do |opts|
  opts.banner = 'Usage: docker_health_audit.rb [options]'
  opts.on('--socket PATH', "Docker Unix socket path (default: #{options[:socket]})") { |v| options[:socket] = v }
  opts.on('--host URL', 'Connect to a tcp:// Docker host instead of the Unix socket') { |v| options[:host] = v }
  opts.on('--restart-threshold N', Integer, "Flag CRIT at this many restarts (default: #{options[:restart_threshold]})") { |v| options[:restart_threshold] = v }
  opts.on('--json', 'Emit a JSON report instead of text') { options[:json] = true }
  opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end
parser.parse!(ARGV)
client = DockerClient.new(socket_path: options[:socket], host: options[:host])
begin
  status, containers = client.get_json('/containers/json?all=1')
  raise "GET /containers/json -> HTTP #{status}" unless status == 200
rescue StandardError => e
  warn "error: could not reach the Docker API (#{options[:host] || options[:socket]}): #{e.class}: #{e.message}"
  warn 'Is dockerd running, and does this user have permission to access the socket (usually needs the `docker` group)?'
  exit 2
end
reports = containers.map do |c|
  status, inspect = client.get_json("/containers/#{c['Id']}/json")
  if status != 200 || inspect.nil?
    { name: c['Names']&.first.to_s.sub(%r{^/}, ''), state: c['State'], severity: :warn,
      findings: [Finding.new(severity: :warn, reason: "could not inspect container (HTTP #{status})")] }
  else
    classify_container(inspect, restart_threshold: options[:restart_threshold])
  end
end
crit_count = reports.count { |r| r[:severity] == :crit }
warn_count = reports.count { |r| r[:severity] == :warn }
if options[:json]
  puts JSON.pretty_generate(
    total: reports.size, crit: crit_count, warn: warn_count,
    containers: reports.map do |r|
      { name: r[:name], state: r[:state], severity: r[:severity],
        findings: r[:findings].map { |f| { severity: f.severity, reason: f.reason } } }
    end
  )
else
  reports.each do |r|
    tag = { crit: '[CRIT]', warn: '[WARN]', ok: '[ ok ]' }[r[:severity]]
    puts "#{tag} #{r[:name]}  (#{r[:state]})"
    r[:findings].each { |f| puts "        - #{f.reason}" }
  end
  puts '---'
  puts "#{reports.size} containers audited, #{crit_count} CRIT, #{warn_count} WARN"
end
exit(crit_count.positive? ? 1 : 0)

Ruby’s Net::HTTP has no built-in way to connect over a Unix domain socket, and that’s the interesting part of this script: http_get_over_unix_socket opens a raw UNIXSocket, writes a minimal HTTP/1.1 GET request by hand, and parses the status line, headers, and a chunked-or-Content-Length body back out — which is genuinely how Docker’s own client libraries talk to dockerd by default.

DockerClient hides that behind the same #get(path) interface whether it’s using the Unix socket or a tcp:// host via plain Net::HTTP — which is also what makes this testable: point --host at a stub TCP server and the exact same request/classification path runs, no real dockerd required.

classify_container never touches HTTP at all — it reads an already-parsed /containers/:id/json inspect payload and checks restart count, privileged flag, and bind mounts. That’s the seam that let all four detection rules get verified against hand-built fixtures.

$ ruby docker_health_audit.rb --socket /var/run/docker.sock
[ ok ] web  (running)
[CRIT] flaky-worker  (restarting)
        - restarted 14 times and is still restarting -- likely crash-looping
[CRIT] legacy-agent  (running)
        - running with --privileged (full host device/capability access)
[CRIT] ci-runner  (running)
        - mounts the Docker socket into the container -- typically equivalent to root on the host
---
4 containers audited, 3 CRIT, 0 WARN
$ echo $?
1
$ ruby docker_health_audit.rb --host http://127.0.0.1:2375 --json
{
  "total": 4, "crit": 3, "warn": 0,
  "containers": [
    { "name": "web", "state": "running", "severity": "ok", "findings": [] },
    { "name": "flaky-worker", "state": "restarting", "severity": "crit",
      "findings": [ { "severity": "crit", "reason": "restarted 14 times and is still restarting -- likely crash-looping" } ] },
    { "name": "legacy-agent", "state": "running", "severity": "crit",
      "findings": [ { "severity": "crit", "reason": "running with --privileged (full host device/capability access)" } ] },
    { "name": "ci-runner", "state": "running", "severity": "crit",
      "findings": [ { "severity": "crit", "reason": "mounts the Docker socket into the container -- typically equivalent to root on the host" } ] }
  ]
}
$ ruby docker_health_audit.rb --socket /tmp/does_not_exist.sock
error: could not reach the Docker API (/tmp/does_not_exist.sock): Errno::ENOENT: No such file or directory
Is dockerd running, and does this user have permission to access the socket (usually needs the `docker` group)?
$ echo $?
2
# verified against two real stub servers standing in for dockerd: one a genuine UNIXSocket
# HTTP server (exercising this script's own hand-rolled HTTP-over-Unix-socket client), the
# other a plain TCP server (exercising the --host / Net::HTTP path) -- both serving the same
# 4-container fixture set (normal, crash-looping, privileged, docker.sock-mounted).

Get the code

Full script + README on GitHub: ruby-devops-toolkit/docker-health-audit

the problem

docker ps shows state, not risk

docker ps tells you a container is “Up 3 hours” — it does not tell you that container has restarted 40 times in those 3 hours, or that it’s running with full host device access, or that it can reach the Docker socket and therefore effectively control every other container (and the host) through it. Those are three of the most common ways a Docker host goes from “looks fine” to “incident” without anyone noticing in between.

Rather than parsing docker inspect CLI output (which means shelling out and depends on the docker binary being installed and the user being in the right group), this script talks to the Docker Engine API directly over HTTP — the same interface the CLI itself, Docker Compose, and every container orchestrator use.

prerequisites

What you need before running this

requirements
  • Ruby 3.x, stdlib only — socket, json, optparse, uri, and net/http (used only for the --host tcp:// path).
  • Access to the Docker socket — either run as root, or as a user in the docker group (which is itself worth being aware of: that group is root-equivalent, since anyone in it can reach the same API this script does).
  • A running Docker daemon on the host, or a tcp:// daemon endpoint if you’re auditing remotely.
the full script

docker_health_audit.rb

docker_health_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# docker_health_audit.rb -- talk directly to the Docker Engine API (no
# `docker` CLI, no docker-api gem) to inventory every container on a host,
# then flag the specific things that bite people in production: crash-loop
# restarts, containers that mount the Docker socket into themselves (a
# well-known container-escape vector), and containers running --privileged.
#
# Usage:
#   ruby docker_health_audit.rb                              # talks to /var/run/docker.sock
#   ruby docker_health_audit.rb --socket /custom/docker.sock
#   ruby docker_health_audit.rb --host tcp://127.0.0.1:2375   # remote/TCP daemon, or a test double
#   ruby docker_health_audit.rb --json --restart-threshold 3
#
# Exit status:
#   0   no CRIT findings
#   1   at least one CRIT finding
#   2   usage / connection error
#
# Requires: Ruby 3.x, stdlib only (socket, json, optparse, uri, net/http for
# the --host tcp:// path). No gems -- this script speaks the Docker Engine
# API's HTTP-over-Unix-socket protocol directly, the same way the real
# `docker` CLI does under the hood.
require 'socket'
require 'json'
require 'optparse'
require 'uri'
require 'net/http'
# ---------------------------------------------------------------------------
# Minimal HTTP/1.1 GET over a Unix domain socket. Net::HTTP has no built-in
# support for connecting over a UNIXSocket, so for the (default, and most
# common in production) socket transport this hand-rolls just enough of the
# protocol to issue a GET and parse a Content-Length or chunked response.
# This is genuinely how the Docker CLI and most Docker client libraries
# talk to dockerd -- there is no TCP involved unless you've explicitly
# opted the daemon into it.
# ---------------------------------------------------------------------------
def http_get_over_unix_socket(socket_path, path)
  sock = UNIXSocket.new(socket_path)
  begin
    sock.write("GET #{path} HTTP/1.1\r\nHost: localhost\r\nAccept: application/json\r\nConnection: close\r\n\r\n")
    raw = sock.read
  ensure
    sock.close
  end
  head, body = raw.split("\r\n\r\n", 2)
  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
  if headers['transfer-encoding'].to_s.include?('chunked')
    body = dechunk(body)
  end
  [status, body]
end
def dechunk(body)
  out = +''
  rest = body
  loop do
    size_line, rest = rest.split("\r\n", 2)
    break if size_line.nil?
    size = size_line.strip.to_i(16)
    break if size.zero?
    out << rest[0...size]
    rest = rest[(size + 2)..] # skip the chunk's trailing \r\n
  end
  out
end
# ---------------------------------------------------------------------------
# Thin client abstraction: same #get(path) interface whether we're talking
# to a Unix socket or a tcp:// host (real remote daemon, or a test double
# started with `docker daemon -H tcp://...`-style config, or -- as used to
# verify this script -- a plain HTTP/Unix-socket stub server).
# ---------------------------------------------------------------------------
class DockerClient
  def initialize(socket_path: nil, host: nil)
    @socket_path = socket_path
    @host = host
  end
  def get(path)
    if @host
      uri = URI.parse("#{@host}#{path}")
      res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(Net::HTTP::Get.new(uri)) }
      [res.code.to_i, res.body]
    else
      http_get_over_unix_socket(@socket_path, path)
    end
  end
  def get_json(path)
    status, body = get(path)
    parsed = body.to_s.empty? ? nil : (JSON.parse(body) rescue nil)
    [status, parsed]
  end
end
Finding = Struct.new(:severity, :reason, keyword_init: true)
# ---------------------------------------------------------------------------
# Pure classification of one container's /containers/:id/json inspect
# payload. No socket/HTTP in here -- this is what gets exercised directly
# against hand-built fixtures.
# ---------------------------------------------------------------------------
def classify_container(inspect, restart_threshold:)
  findings = []
  name = inspect['Name'].to_s.sub(%r{^/}, '')
  state = inspect.dig('State', 'Status')
  restart_count = inspect['RestartCount'].to_i
  exit_code = inspect.dig('State', 'ExitCode')
  privileged = inspect.dig('HostConfig', 'Privileged')
  binds = inspect.dig('HostConfig', 'Binds') || []
  if restart_count >= restart_threshold && %w[running restarting].include?(state)
    findings << Finding.new(severity: :crit, reason: "restarted #{restart_count} times and is still #{state} -- likely crash-looping")
  end
  if state == 'exited' && exit_code.to_i != 0
    findings << Finding.new(severity: :warn, reason: "exited with non-zero status #{exit_code}")
  end
  if privileged
    findings << Finding.new(severity: :crit, reason: 'running with --privileged (full host device/capability access)')
  end
  if binds.any? { |b| b.include?('docker.sock') }
    findings << Finding.new(severity: :crit, reason: 'mounts the Docker socket into the container -- typically equivalent to root on the host')
  end
  binds.each do |b|
    src = b.split(':').first
    if %w[/ /etc /root].include?(src)
      findings << Finding.new(severity: :warn, reason: "bind-mounts sensitive host path #{src} into the container")
    end
  end
  overall = if findings.any? { |f| f.severity == :crit }
              :crit
            elsif findings.any? { |f| f.severity == :warn }
              :warn
            else
              :ok
            end
  { name: name, state: state, severity: overall, findings: findings }
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
options = { socket: '/var/run/docker.sock', host: nil, json: false, restart_threshold: 5 }
parser = OptionParser.new do |opts|
  opts.banner = 'Usage: docker_health_audit.rb [options]'
  opts.on('--socket PATH', "Docker Unix socket path (default: #{options[:socket]})") { |v| options[:socket] = v }
  opts.on('--host URL', 'Connect to a tcp:// Docker host instead of the Unix socket') { |v| options[:host] = v }
  opts.on('--restart-threshold N', Integer, "Flag CRIT at this many restarts (default: #{options[:restart_threshold]})") { |v| options[:restart_threshold] = v }
  opts.on('--json', 'Emit a JSON report instead of text') { options[:json] = true }
  opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end
parser.parse!(ARGV)
client = DockerClient.new(socket_path: options[:socket], host: options[:host])
begin
  status, containers = client.get_json('/containers/json?all=1')
  raise "GET /containers/json -> HTTP #{status}" unless status == 200
rescue StandardError => e
  warn "error: could not reach the Docker API (#{options[:host] || options[:socket]}): #{e.class}: #{e.message}"
  warn 'Is dockerd running, and does this user have permission to access the socket (usually needs the `docker` group)?'
  exit 2
end
reports = containers.map do |c|
  status, inspect = client.get_json("/containers/#{c['Id']}/json")
  if status != 200 || inspect.nil?
    { name: c['Names']&.first.to_s.sub(%r{^/}, ''), state: c['State'], severity: :warn,
      findings: [Finding.new(severity: :warn, reason: "could not inspect container (HTTP #{status})")] }
  else
    classify_container(inspect, restart_threshold: options[:restart_threshold])
  end
end
crit_count = reports.count { |r| r[:severity] == :crit }
warn_count = reports.count { |r| r[:severity] == :warn }
if options[:json]
  puts JSON.pretty_generate(
    total: reports.size, crit: crit_count, warn: warn_count,
    containers: reports.map do |r|
      { name: r[:name], state: r[:state], severity: r[:severity],
        findings: r[:findings].map { |f| { severity: f.severity, reason: f.reason } } }
    end
  )
else
  reports.each do |r|
    tag = { crit: '[CRIT]', warn: '[WARN]', ok: '[ ok ]' }[r[:severity]]
    puts "#{tag} #{r[:name]}  (#{r[:state]})"
    r[:findings].each { |f| puts "        - #{f.reason}" }
  end
  puts '---'
  puts "#{reports.size} containers audited, #{crit_count} CRIT, #{warn_count} WARN"
end
exit(crit_count.positive? ? 1 : 0)
walkthrough

How it actually works

Speaking HTTP over a Unix socket by hand

The Docker Engine API is plain HTTP — it just usually isn’t served over TCP. Ruby’s Net::HTTP has no notion of connecting over a UNIXSocket, so http_get_over_unix_socket writes the request line and headers directly to the socket, reads the raw response, and splits it into a status line, headers, and body by hand — including unwrapping Transfer-Encoding: chunked responses, which the Docker API uses for some endpoints. It’s a dozen lines of code once you see it, and it demystifies what every Docker client library is actually doing underneath.

One client, two transports, one interface

DockerClient#get checks whether a --host was given and, if so, uses plain Net::HTTP against a tcp:// URL; otherwise it falls back to the Unix socket path. Everything downstream just calls client.get_json(path) and gets back a parsed hash — it never needs to know or care which transport actually carried the request.

The docker.sock bind-mount check

Mounting /var/run/docker.sock into a container is a common (and often well-intentioned) pattern for CI runners and monitoring agents that need to manage sibling containers — and it is also functionally equivalent to giving that container root on the host, since anything with access to the socket can launch a new privileged container and mount the host filesystem into it. classify_container checks HostConfig.Binds for exactly that pattern and flags it as CRIT regardless of whether the container is otherwise behaving normally.

Why two stub servers instead of one

Both transport paths are real code, not just configuration — so both got tested against real HTTP traffic: a UNIXServer-based stub for the socket path (exercising the hand-rolled HTTP parser, including a chunked-transfer round trip) and a plain TCPServer stub for the --host path. Same fixture data, same classification results, two genuinely different code paths verified.

example output

What a real run looks like

docker_health_audit.rb against a 4-container host
[ ok ] web (running)
[CRIT] flaky-worker (restarting)
– restarted 14 times and is still restarting — likely crash-looping
[CRIT] legacy-agent (running)
– running with –privileged (full host device/capability access)
[CRIT] ci-runner (running)
– mounts the Docker socket into the container
4 containers audited, 3 CRIT, 0 WARN
Docker Container Health & Security Audit workflow diagram

One DockerClient interface over two transports feeding a pure classify_container function
troubleshooting

When it doesn't work

common issues
  • Errno::EACCES connecting to the socket — the current user isn’t in the docker group and isn’t root; either add the user to that group (understanding it’s root-equivalent) or run with sudo.
  • Errno::ENOENT on the socket path — Docker Desktop on macOS and some rootless Docker setups use a different socket path (often under ~/.docker/run/docker.sock); pass it explicitly with --socket.
  • Every container reports “could not inspect” — a container can disappear between the list call and the inspect call (it exited and got removed with --rm); this is reported as a WARN per container rather than crashing the whole audit.
  • Chunked response parsing looks fragile — it only handles the specific Transfer-Encoding: chunked shape Docker’s API actually sends for these endpoints; a hardened version for production use should handle trailing headers after the final zero-length chunk, which this tutorial version does not bother with since Docker doesn’t send them here.
extending it

Where to take this next

ideas
  • Resource stats: add GET /containers/:id/stats?stream=false to flag containers approaching their memory limit, not just ones that are already crash-looping.
  • Image freshness: cross-reference each container’s image digest against a registry to flag containers running an image that’s since been patched.
  • Network exposure: inspect NetworkSettings.Ports for containers publishing ports directly to 0.0.0.0 instead of a reverse proxy, mirroring this series’ firewall-drift-audit’s wide-open-port check.
  • Fleet mode: loop this script over a list of Docker hosts (each with its own --host tcp://...) the same way the ssh-fleet-runner script in this repo fans a command out across a fleet.