banshee is under active development: collectors will keep changing, and a version you publish will likely need adjustments. For stable use, wait for version 1.0.0.

Containers

banshee/containers-collection/containers/docker/ssh/

compose

Whether the services of each Docker Compose project of a host run and are healthy, by SSH, through a restricted sudo or the docker group.

  • containers
  • docker
  • compose
  • docker-compose
  • service
  • healthcheck
  • sudo
  • ssh
  • linux

by banshee1 installation1 version

v1Latest

Published on October 27, 2026. First version.

Keen 1.0.0 · 92 lines
// The services of the Docker Compose projects of a Docker host, by SSH: for each service, whether all its
// containers run, their health, and how many of them run. A service is named <project>/<service>, so that
// the selection of the probe can keep one project (myapp/*) or leave out a service that runs once and stops,
// such as a migration (myapp/migrate).
// The projects and services are read from the labels Compose puts on each container
// (com.docker.compose.project, com.docker.compose.service), the same for Compose v1 and v2, through the
// very docker ps of the collector of the containers and its sudoers line: the docker compose plugin is not
// needed. The containers of docker compose run (com.docker.compose.oneoff=True) are left out.
need('protocol:ssh');
$sudo = variable('docker:sudo', 'boolean', array(
    'label'   => 'Through sudo',
    'help'    => 'Runs docker through sudo, with the sudoers line of the documentation. Off when the account is in the docker group instead.',
    'default' => true,
));

per('service', 'Services');
output('state', 'text', array('label' => 'State', 'per' => 'service'));
output('health', 'text', array('label' => 'Health', 'per' => 'service'));
output('containers', 'number', array('label' => 'Containers', 'per' => 'service'));
output('running', 'number', array('label' => 'Containers running', 'per' => 'service'));

watch.expect('not-running', 'Service not running', 'state', array('critical' => array('running')));
watch.state('unhealthy', 'Service unhealthy', 'health', array('critical' => array('unhealthy')));
watch.missing('missing', 'Service missing', 'state', array('enabled' => false));

// Runs a docker command, through sudo -n when asked, and returns what it wrote. A daemon that does not
// answer is a host whose containers are all down: critical. Anything else that keeps docker from answering
// (sudo refusing, docker missing, the account outside the docker group) is a setting to fix.
function docker_run($sudo, $arguments) {
    $command = '/usr/bin/docker ' . $arguments;
    if ($sudo) {
        $command = 'sudo -n ' . $command;
    }
    $read = ssh.run($command);
    if ($read['exit'] != 0) {
        if (text.contains($read['errors'], 'Cannot connect to the Docker daemon')) {
            fail('critical', 'The Docker daemon is not running', $read['errors']);
        }
        if (text.contains($read['errors'], 'sudo')) {
            fail('misconfigured', 'sudo refused to run docker: see the sudoers line of the documentation', $read['errors']);
        }
        fail('misconfigured', 'docker did not answer', $read['errors']);
    }
    return $read['output'];
}

// One JSON object per line: {"Names": "myapp-web-1", "State": "running", "Status": "Up 2 hours (healthy)",
// "Labels": "com.docker.compose.project=myapp,com.docker.compose.service=web,...", ...}. The labels are
// written key=value, separated by commas.
$output = docker_run($sudo, "ps --all --format '{{json .}}'");
$services = array();
foreach (text.lines($output) as $line) {
    $container = json.read($line);
    $labels = $container['Labels'];
    $project = text.match($labels, '(?:^|,)com\.docker\.compose\.project=([^,]*)');
    $service = text.match($labels, '(?:^|,)com\.docker\.compose\.service=([^,]*)');
    if (list.count($project) == 0 || list.count($service) == 0) {
        continue;
    }
    if (text.contains($labels, 'com.docker.compose.oneoff=True')) {
        continue;
    }
    $name = $project[1] . '/' . $service[1];
    if (!list.has($services, $name)) {
        $services[$name] = array('state' => 'running', 'health' => 'none', 'containers' => 0, 'running' => 0);
    }
    $services[$name]['containers'] = $services[$name]['containers'] + 1;

    // The service runs when all its containers run; otherwise its state is that of the first that does not.
    if ($container['State'] == 'running') {
        $services[$name]['running'] = $services[$name]['running'] + 1;
    } elseif ($services[$name]['state'] == 'running') {
        $services[$name]['state'] = $container['State'];
    }

    // The worst health of its containers: unhealthy, then starting, then healthy; none without a health check.
    $status = $container['Status'];
    if (text.contains($status, '(unhealthy)')) {
        $services[$name]['health'] = 'unhealthy';
    } elseif (text.contains($status, '(health: starting)') && $services[$name]['health'] != 'unhealthy') {
        $services[$name]['health'] = 'starting';
    } elseif (text.contains($status, '(healthy)') && $services[$name]['health'] == 'none') {
        $services[$name]['health'] = 'healthy';
    }
}

foreach ($services as $name => $service) {
    value('state', $service['state'], $name);
    value('health', $service['health'], $name);
    value('containers', $service['containers'], $name);
    value('running', $service['running'], $name);
}