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:
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).
Full script + README on GitHub: ruby-devops-toolkit/docker-health-audit
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.
What you need before running this
- Ruby 3.x, stdlib only —
socket,json,optparse,uri, andnet/http(used only for the--host tcp://path). - Access to the Docker socket — either run as root, or as a user in the
dockergroup (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.
docker_health_audit.rb
#!/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)
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.
What a real run looks like
When it doesn't work
- Errno::EACCES connecting to the socket — the current user isn’t in the
dockergroup 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: chunkedshape 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.
Where to take this next
- Resource stats: add
GET /containers/:id/stats?stream=falseto 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.Portsfor containers publishing ports directly to0.0.0.0instead 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.