Docker Socket Updates attempts (Bare Safe)
CI / test (push) Successful in 1m31s
Release rolling / release (push) Successful in 7m10s

This commit is contained in:
Raven Scott
2026-07-19 13:38:48 -04:00
parent 5521f2952a
commit 926a89878b
7 changed files with 331 additions and 57 deletions
+2 -1
View File
@@ -290,10 +290,11 @@ let catalogRefreshTimer = null
function startCatalogRefresh() { function startCatalogRefresh() {
if (catalogRefreshTimer) return if (catalogRefreshTimer) return
// Refresh often enough that Docker/cgroup name enrichment appears without a reconnect
catalogRefreshTimer = setInterval(() => { catalogRefreshTimer = setInterval(() => {
if (!manager.active?.connected) return if (!manager.active?.connected) return
populateExploreCharts().catch(() => {}) populateExploreCharts().catch(() => {})
}, 30_000) }, 10_000)
} }
function stopCatalogRefresh() { function stopCatalogRefresh() {
+2 -2
View File
@@ -70,8 +70,8 @@ Have `pipeline.js` start each enabled collector; all emit `samples` batches into
1. Set `PEARDATA_DOCKER=1` on the agent (or re-run the installer when Docker is present). 1. Set `PEARDATA_DOCKER=1` on the agent (or re-run the installer when Docker is present).
2. Ensure `peardata` can read the socket (`usermod -aG docker peardata` + unit `SupplementaryGroups=… docker`). 2. Ensure `peardata` can read the socket (`usermod -aG docker peardata` + unit `SupplementaryGroups=… docker`).
3. Charts: `docker.containers`, `docker.cpu.<shortId>`, `docker.mem.<shortId>` (stable ids); **titles/families** use human names (`dozzle · CPU`). 3. Charts: `docker.containers`, `docker.cpu.<shortId>`, `docker.mem.<shortId>` (stable ids); **titles/families** use human names (`dozzle · CPU`).
4. Discovery: cgroup v2 `docker-*.scope` / `libpod-*.scope`; names from `fetchDockerNames` / Compose labels (`shared/container-names.js`). 4. Discovery: cgroup v2 `docker-*.scope` / `libpod-*.scope`; names via `loadContainerNameMap` (unix HTTP API → `docker ps` → filesystem) + Compose labels (`shared/container-names.js`).
5. Cgroups collector reuses the same socket map so `cgroup.*` cards are not bare hashes. 5. Cgroups collector reuses the same name map so `cgroup.*` cards are not bare hashes.
6. UI: [user-guide/containers.md](../user-guide/containers.md). 6. UI: [user-guide/containers.md](../user-guide/containers.md).
### Process top-N collector ### Process top-N collector
+10 -5
View File
@@ -19,7 +19,7 @@ import {
makeCgroupThrottleChart, makeCgroupThrottleChart,
} from '../../../shared/metrics.js' } from '../../../shared/metrics.js'
import { resolveContainerLabel } from '../../../shared/container-names.js' import { resolveContainerLabel } from '../../../shared/container-names.js'
import { fetchDockerNames, resolveDockerSocket } from './docker.js' import { loadContainerNameMap, resolveDockerSocket } from './docker.js'
import logger from '../../utils/logger.js' import logger from '../../utils/logger.js'
const log = logger.child('cgroups') const log = logger.child('cgroups')
@@ -217,13 +217,18 @@ export class CgroupsCollector extends EventEmitter {
async _refreshNames() { async _refreshNames() {
const now = Date.now() const now = Date.now()
if (now - this._nameRefreshAt < 30_000) return const interval = this._names.size ? 30_000 : 5_000
if (now - this._nameRefreshAt < interval) return
this._nameRefreshAt = now this._nameRefreshAt = now
try { try {
this.socketPath = resolveDockerSocket(this.socketPath) this.socketPath = resolveDockerSocket(this.socketPath)
if (fs.existsSync(this.socketPath)) { const { map, source } = await loadContainerNameMap(this.socketPath)
const map = await fetchDockerNames(this.socketPath) if (map.size) {
if (map.size) this._names = map const prev = this._names.size
this._names = map
if (prev !== map.size) {
log.info('Container names loaded for cgroups', { count: map.size, source })
}
} }
} catch { } catch {
// ignore — titles fall back to humanized hashes // ignore — titles fall back to humanized hashes
+238 -36
View File
@@ -12,9 +12,10 @@
*/ */
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import http from 'http' import net from 'net'
import os from 'os' import os from 'os'
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { execFile } from '../../utils/exec.js'
import { import {
SAMPLE_INTERVAL_MS, SAMPLE_INTERVAL_MS,
registerChart, registerChart,
@@ -22,7 +23,11 @@ import {
makeDockerCpuChart, makeDockerCpuChart,
makeDockerMemChart, makeDockerMemChart,
} from '../../../shared/metrics.js' } from '../../../shared/metrics.js'
import { pickContainerDisplayName, resolveContainerLabel } from '../../../shared/container-names.js' import {
pickContainerDisplayName,
resolveContainerLabel,
indexContainerName,
} from '../../../shared/container-names.js'
import logger from '../../utils/logger.js' import logger from '../../utils/logger.js'
const log = logger.child('docker') const log = logger.child('docker')
@@ -49,7 +54,8 @@ export function resolveDockerSocket(preferred) {
// ignore // ignore
} }
} }
return preferred || process.env.PEARDATA_DOCKER_SOCKET || '/var/run/docker.sock' // Prefer a real default path when nothing exists yet (docker not started)
return process.env.PEARDATA_DOCKER_SOCKET || '/var/run/docker.sock'
} }
function readFile(p) { function readFile(p) {
@@ -189,8 +195,7 @@ export function mapDockerContainerNames(list) {
const id = String(c.Id || '').toLowerCase() const id = String(c.Id || '').toLowerCase()
if (!id) continue if (!id) continue
const name = pickContainerDisplayName(c) const name = pickContainerDisplayName(c)
map.set(id, name) indexContainerName(map, id, name)
if (id.length >= 12) map.set(id.slice(0, 12), name)
for (const n of c.Names || []) { for (const n of c.Names || []) {
const bare = String(n || '') const bare = String(n || '')
.replace(/^\//, '') .replace(/^\//, '')
@@ -203,39 +208,231 @@ export function mapDockerContainerNames(list) {
} }
/** /**
* GET over a Unix domain socket (works under Node + Bare; bare-http1 has no socketPath).
* @param {string} socketPath * @param {string} socketPath
* @returns {Promise<Map<string, string>>} id → display name * @param {string} urlPath
* @param {number} [timeoutMs]
* @returns {Promise<string>} response body
*/ */
export function fetchDockerNames(socketPath) { export function httpGetUnix(socketPath, urlPath, timeoutMs = 3000) {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const req = http.request( // String form is reliable for bare-net → bare-pipe unix sockets
{ const socket = net.connect(socketPath)
socketPath, let buf = ''
path: '/containers/json?all=1', let settled = false
method: 'GET', const finish = (err, body) => {
timeout: 2000, if (settled) return
}, settled = true
(res) => { clearTimeout(timer)
let body = ''
res.on('data', (c) => {
body += c
})
res.on('end', () => {
try { try {
resolve(mapDockerContainerNames(JSON.parse(body))) socket.destroy()
} catch { } catch {
resolve(new Map()) // ignore
} }
}) if (err) reject(err)
else resolve(body)
} }
const timer = setTimeout(() => {
finish(new Error(`unix http timeout after ${timeoutMs}ms`))
}, timeoutMs)
socket.on('connect', () => {
socket.write(
`GET ${urlPath} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nAccept: application/json\r\n\r\n`
) )
req.on('error', () => resolve(new Map()))
req.on('timeout', () => {
req.destroy()
resolve(new Map())
}) })
req.end() socket.on('data', (chunk) => {
buf += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
}) })
socket.on('end', () => {
try {
finish(null, parseHttpBody(buf))
} catch (err) {
finish(err)
}
})
socket.on('error', (err) => finish(err))
})
}
/**
* @param {string} raw
*/
export function parseHttpBody(raw) {
let sep = raw.indexOf('\r\n\r\n')
let hdrEnd = 4
if (sep < 0) {
sep = raw.indexOf('\n\n')
hdrEnd = 2
}
if (sep < 0) throw new Error('invalid http response')
const header = raw.slice(0, sep)
let body = raw.slice(sep + hdrEnd)
const status = header.split(/\r?\n/)[0] || ''
if (!/\s200(\s|$)/.test(status)) {
throw new Error(`http ${status || 'error'}`)
}
if (/transfer-encoding:\s*chunked/i.test(header)) {
body = decodeChunked(body)
}
return body
}
function decodeChunked(body) {
let out = ''
let i = 0
while (i < body.length) {
const nl = body.indexOf('\r\n', i)
if (nl < 0) break
const size = parseInt(body.slice(i, nl), 16)
if (!Number.isFinite(size) || size <= 0) break
const start = nl + 2
out += body.slice(start, start + size)
i = start + size + 2
}
return out
}
/**
* Docker Engine API via unix socket.
* @param {string} socketPath
* @returns {Promise<Map<string, string>>}
*/
export async function fetchDockerNames(socketPath) {
const sock = resolveDockerSocket(socketPath)
const paths = [
'/containers/json?all=true',
'/v1.41/containers/json?all=true',
'/v1.44/containers/json?all=true',
]
let lastErr = null
for (const urlPath of paths) {
try {
const body = await httpGetUnix(sock, urlPath)
const map = mapDockerContainerNames(JSON.parse(body))
if (map.size) return map
} catch (err) {
lastErr = err
}
}
if (lastErr) throw lastErr
return new Map()
}
/**
* Parse `docker ps` TSV lines into a name map. Exported for tests.
* @param {string} stdout
* @returns {Map<string, string>}
*/
export function parseDockerPsNames(stdout) {
/** @type {Map<string, string>} */
const map = new Map()
for (const line of String(stdout || '').split('\n')) {
const tab = line.indexOf('\t')
if (tab < 0) continue
const id = line.slice(0, tab).trim().toLowerCase()
const names = line
.slice(tab + 1)
.trim()
.split(',')
.map((n) => n.trim())
.filter(Boolean)
if (!id || !names.length) continue
const name = pickContainerDisplayName({ Id: id, Names: names })
indexContainerName(map, id, name)
for (const n of names) map.set(n.replace(/^\//, '').toLowerCase(), name)
}
return map
}
/**
* `docker ps` CLI fallback (Bare-safe via server/utils/exec.js).
* @returns {Promise<Map<string, string>>}
*/
export async function fetchDockerNamesCli() {
try {
const { stdout } = await execFile(
'docker',
['ps', '-a', '--no-trunc', '--format', '{{.ID}}\t{{.Names}}'],
{ timeout: 5000, maxBuffer: 8 * 1024 * 1024 }
)
return parseDockerPsNames(stdout)
} catch {
return new Map()
}
}
/**
* Read Names from Docker's on-disk config.v2.json (no socket needed if readable).
* @returns {Map<string, string>}
*/
export function readDockerNamesFromFs() {
/** @type {Map<string, string>} */
const map = new Map()
const roots = [
process.env.PEARDATA_DOCKER_ROOT,
'/var/lib/docker/containers',
path.join(os.homedir(), '.local/share/docker/containers'),
].filter(Boolean)
for (const root of roots) {
let ents
try {
ents = fs.readdirSync(root)
} catch {
continue
}
for (const id of ents) {
if (!/^[0-9a-f]{64}$/i.test(id)) continue
const raw = readFile(path.join(root, id, 'config.v2.json'))
if (!raw) continue
try {
const cfg = JSON.parse(raw)
const labels = cfg.Config?.Labels || cfg.Labels || {}
const name = pickContainerDisplayName({
Id: id,
Names: cfg.Name ? [cfg.Name] : [],
Labels: labels,
Image: cfg.Config?.Image || cfg.Image || '',
})
indexContainerName(map, id, name)
if (cfg.Name) {
map.set(String(cfg.Name).replace(/^\//, '').toLowerCase(), name)
}
} catch {
// ignore bad configs
}
}
}
return map
}
/**
* Best-effort name map: Engine API → docker CLI → filesystem.
* @param {string} [socketPath]
* @returns {Promise<{ map: Map<string, string>, source: string }>}
*/
export async function loadContainerNameMap(socketPath) {
const sock = resolveDockerSocket(socketPath)
if (fs.existsSync(sock)) {
try {
const map = await fetchDockerNames(sock)
if (map.size) return { map, source: 'docker-api' }
} catch (err) {
log.warn('Docker API name fetch failed', { socket: sock, error: err.message })
}
}
try {
const map = await fetchDockerNamesCli()
if (map.size) return { map, source: 'docker-cli' }
} catch (err) {
log.warn('docker CLI name fetch failed', { error: err.message })
}
const fsMap = readDockerNamesFromFs()
if (fsMap.size) return { map: fsMap, source: 'docker-fs' }
return { map: new Map(), source: 'none' }
} }
export class DockerCollector extends EventEmitter { export class DockerCollector extends EventEmitter {
@@ -270,23 +467,28 @@ export class DockerCollector extends EventEmitter {
async _refreshNames() { async _refreshNames() {
const now = Date.now() const now = Date.now()
if (now - this._nameRefreshAt < 30_000) return // Retry sooner while empty so a late socket/group fix shows names quickly
const interval = this._names.size ? 30_000 : 5_000
if (now - this._nameRefreshAt < interval) return
this._nameRefreshAt = now this._nameRefreshAt = now
try { try {
this.socketPath = resolveDockerSocket(this.socketPath) this.socketPath = resolveDockerSocket(this.socketPath)
if (fs.existsSync(this.socketPath)) { const { map, source } = await loadContainerNameMap(this.socketPath)
const map = await fetchDockerNames(this.socketPath)
if (map.size) { if (map.size) {
const prev = this._names.size const prev = this._names.size
this._names = map this._names = map
if (prev !== map.size) { if (prev !== map.size) {
log.info('Docker container names loaded', { count: map.size, socket: this.socketPath }) log.info('Docker container names loaded', {
} count: map.size,
} else if (!this._names.size) { source,
log.warn('Docker socket reachable but no container names (check peardata ∈ docker group)', {
socket: this.socketPath, socket: this.socketPath,
}) })
} }
} else if (!this._names.size) {
log.warn(
'No Docker container names yet — check peardata ∈ docker group, PEARDATA_DOCKER=1, and socket path',
{ socket: this.socketPath, source }
)
} }
} catch (err) { } catch (err) {
log.warn('Docker name refresh failed', { error: err.message, socket: this.socketPath }) log.warn('Docker name refresh failed', { error: err.message, socket: this.socketPath })
+31 -2
View File
@@ -90,6 +90,21 @@ function stripTrailingReplica(name) {
return m[2] === '1' ? m[1] : `${m[1]} · ${m[2]}` return m[2] === '1' ? m[1] : `${m[1]} · ${m[2]}`
} }
/**
* Index a container id under full + short prefixes for lookup.
* @param {Map<string, string>} map
* @param {string} id
* @param {string} name
*/
export function indexContainerName(map, id, name) {
const full = String(id || '').toLowerCase()
const label = String(name || '').trim()
if (!full || !label || !map) return
map.set(full, label)
const max = Math.min(64, full.length)
for (let n = 12; n <= max; n++) map.set(full.slice(0, n), label)
}
/** /**
* Look up a display name from id/shortId/cgroup title via a name map. * Look up a display name from id/shortId/cgroup title via a name map.
* @param {string} idOrTitle * @param {string} idOrTitle
@@ -106,15 +121,23 @@ export function resolveContainerLabel(idOrTitle, nameMap) {
tryKeys.push(extracted, extracted.slice(0, 12)) tryKeys.push(extracted, extracted.slice(0, 12))
} }
if (isHexId(raw, 12)) { if (isHexId(raw, 12)) {
tryKeys.push(raw.toLowerCase().slice(0, 12)) tryKeys.push(raw.toLowerCase(), raw.toLowerCase().slice(0, 12))
} }
if (nameMap?.size) { if (nameMap?.size) {
for (const k of tryKeys) { for (const k of tryKeys) {
const hit = nameMap.get(k) const hit = nameMap.get(k)
if (hit && !isHexId(hit, 12)) return hit if (hit && !looksLikeHashLabel(hit)) return hit
if (hit) return hit if (hit) return hit
} }
// Prefix match: cgroup id vs API id length differences
const needle = (extracted || (isHexId(raw, 12) ? raw.toLowerCase() : '')).slice(0, 12)
if (needle.length >= 12) {
for (const [k, v] of nameMap) {
if (k.length < 12 || looksLikeHashLabel(v)) continue
if (k.startsWith(needle) || needle.startsWith(k.slice(0, 12))) return v
}
}
} }
if (extracted) return `container ${extracted.slice(0, 12)}` if (extracted) return `container ${extracted.slice(0, 12)}`
@@ -129,6 +152,12 @@ export function resolveContainerLabel(idOrTitle, nameMap) {
return stripped.slice(0, 64) return stripped.slice(0, 64)
} }
function looksLikeHashLabel(name) {
const s = String(name || '')
if (s.startsWith('container ')) return true
return isHexId(s.replace(/^container\s+/i, ''), 12)
}
/** /**
* Card / catalog title: "nginx · CPU" * Card / catalog title: "nginx · CPU"
* @param {string} metric e.g. CPU, memory, I/O * @param {string} metric e.g. CPU, memory, I/O
+28 -1
View File
@@ -7,7 +7,11 @@ import {
chartOptionLabel, chartOptionLabel,
isHexId, isHexId,
} from '../shared/container-names.js' } from '../shared/container-names.js'
import { mapDockerContainerNames } from '../server/services/collectors/docker.js' import {
mapDockerContainerNames,
parseHttpBody,
parseDockerPsNames,
} from '../server/services/collectors/docker.js'
import { makeDockerCpuChart, makeDockerMemChart } from '../shared/metrics.js' import { makeDockerCpuChart, makeDockerMemChart } from '../shared/metrics.js'
test('pickContainerDisplayName prefers compose service', (t) => { test('pickContainerDisplayName prefers compose service', (t) => {
@@ -68,6 +72,29 @@ test('mapDockerContainerNames indexes full and short ids', (t) => {
t.is(map.get(id.slice(0, 12)), 'nginx') t.is(map.get(id.slice(0, 12)), 'nginx')
}) })
test('parseHttpBody handles Content-Length and chunked Docker replies', (t) => {
const json = '[{"Id":"aa","Names":["/x"]}]'
const plain =
`HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${json.length}\r\n\r\n` +
json
t.is(parseHttpBody(plain), json)
const chunked =
'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n' +
Number(json.length).toString(16) +
'\r\n' +
json +
'\r\n0\r\n\r\n'
t.is(parseHttpBody(chunked), json)
})
test('parseDockerPsNames maps id to Names', (t) => {
const id = '45b3a7631c68aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const map = parseDockerPsNames(`${id}\tdozzle\n`)
t.is(map.get(id.slice(0, 12)), 'dozzle')
t.is(map.get('dozzle'), 'dozzle')
})
test('resolveContainerLabel humanizes cgroup docker scopes', (t) => { test('resolveContainerLabel humanizes cgroup docker scopes', (t) => {
const id = 'a1b2c3d4e5f6789012345678abcdef01' const id = 'a1b2c3d4e5f6789012345678abcdef01'
const map = new Map([ const map = new Map([
+12 -2
View File
@@ -49,12 +49,22 @@ You should see a line like `Docker container names loaded` with a count.
Chart **ids** remain `docker.cpu.<shortId>` / `docker.mem.<shortId>` so history stays stable when a container is recreated with the same id prefix. Chart **ids** remain `docker.cpu.<shortId>` / `docker.mem.<shortId>` so history stays stable when a container is recreated with the same id prefix.
## Name resolution order ## How names are loaded (agent)
PearData tries, in order:
1. **Docker Engine API** over the unix socket (raw HTTP — works under Bare)
2. **`docker ps`** CLI
3. On-disk `config.v2.json` under `/var/lib/docker/containers` (if readable)
Then each container label prefers:
1. Compose / Swarm / Kubernetes service labels (`web`, `web · 2`) 1. Compose / Swarm / Kubernetes service labels (`web`, `web · 2`)
2. Docker `Names` (e.g. `/dozzle``dozzle`) 2. Docker `Names` (e.g. `/dozzle``dozzle`)
3. Image short name 3. Image short name
4. Fallback `container <12-char id>` if the socket is unavailable 4. Fallback `container <12-char id>` if none of the sources worked
If the UI still shows `container <hash>`, the agent could not reach Docker. Check the journal for `Docker container names loaded` / `Container names loaded for cgroups`, then fix group/socket and restart.
## Related ## Related