Files
peardata/server/services/collectors/docker.js
T
Raven Scott 9bc7a67020
CI / test (push) Successful in 51s
Release rolling / release (push) Has been cancelled
updates
2026-07-18 19:04:39 -04:00

318 lines
8.6 KiB
JavaScript

/**
* Opt-in Docker / container collector (Phase 3 spike).
*
* Enable: PEARDATA_DOCKER=1
* Discovers containers via cgroup v2 scopes (docker-*.scope / libpod-*.scope)
* and optionally Docker Engine API over a unix socket for names.
*
* Emits:
* docker.containers — running / total counts
* docker.cpu.<shortId> — % of one host CPU
* docker.mem.<shortId> — usage / limit MiB
*/
import fs from 'fs'
import path from 'path'
import http from 'http'
import os from 'os'
import { EventEmitter } from 'events'
import {
SAMPLE_INTERVAL_MS,
registerChart,
DOCKER_CONTAINERS_CHART,
makeDockerCpuChart,
makeDockerMemChart,
} from '../../../shared/metrics.js'
import logger from '../../utils/logger.js'
const log = logger.child('docker')
export function isDockerCollectorEnabled() {
const v = process.env.PEARDATA_DOCKER
return v === '1' || v === 'on' || v === 'true'
}
function readFile(p) {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
}
function bytesToMiB(n) {
return n / (1024 * 1024)
}
function hostCpus() {
try {
return os.cpus().length || 1
} catch {
return 1
}
}
/**
* @returns {Array<{ id: string, shortId: string, name: string, cgroupPath: string }>}
*/
export function discoverCgroupContainers() {
const roots = [
'/sys/fs/cgroup/system.slice',
'/sys/fs/cgroup/docker',
'/sys/fs/cgroup',
]
/** @type {Map<string, { id: string, shortId: string, name: string, cgroupPath: string }>} */
const found = new Map()
for (const root of roots) {
let entries
try {
entries = fs.readdirSync(root, { withFileTypes: true })
} catch {
continue
}
for (const ent of entries) {
if (!ent.isDirectory()) continue
const name = ent.name
let id = null
let label = name
const dockerScope = name.match(/^docker-([0-9a-f]{12,64})\.scope$/i)
const podmanScope = name.match(/^libpod-([0-9a-f]{12,64})\.scope$/i)
if (dockerScope) {
id = dockerScope[1].toLowerCase()
label = id.slice(0, 12)
} else if (podmanScope) {
id = podmanScope[1].toLowerCase()
label = id.slice(0, 12)
} else if (/^[0-9a-f]{64}$/i.test(name) && root.endsWith('/docker')) {
id = name.toLowerCase()
label = id.slice(0, 12)
}
if (!id || found.has(id)) continue
const cgroupPath = path.join(root, name)
if (
!readFile(path.join(cgroupPath, 'cpu.stat')) &&
!readFile(path.join(cgroupPath, 'cpuacct.usage'))
) {
continue
}
found.set(id, {
id,
shortId: id.slice(0, 12),
name: label,
cgroupPath,
})
}
}
return [...found.values()]
}
/**
* @param {string} cgroupPath
* @returns {{ usageNs: number }|null}
*/
export function readCgroupCpu(cgroupPath) {
const raw = readFile(path.join(cgroupPath, 'cpu.stat'))
if (raw) {
/** @type {Record<string, number>} */
const m = {}
for (const line of raw.split('\n')) {
const [k, v] = line.trim().split(/\s+/)
if (k && v != null) m[k] = Number(v)
}
if (m.usage_usec != null) {
return { usageNs: m.usage_usec * 1000 }
}
}
const acct = readFile(path.join(cgroupPath, 'cpuacct.usage'))
if (acct) {
const usageNs = Number(acct.trim())
if (Number.isFinite(usageNs)) return { usageNs }
}
return null
}
/**
* @param {string} cgroupPath
* @returns {{ usage: number, limit: number }|null} bytes
*/
export function readCgroupMemory(cgroupPath) {
const current = readFile(path.join(cgroupPath, 'memory.current'))
if (current) {
const usage = Number(current.trim())
let limit = Number(readFile(path.join(cgroupPath, 'memory.max'))?.trim())
if (!Number.isFinite(limit) || limit <= 0 || limit > 1e15) limit = 0
if (Number.isFinite(usage)) return { usage, limit }
}
const usageFile = readFile(path.join(cgroupPath, 'memory.usage_in_bytes'))
if (usageFile) {
const usage = Number(usageFile.trim())
const limitRaw = readFile(path.join(cgroupPath, 'memory.limit_in_bytes'))
let limit = limitRaw ? Number(limitRaw.trim()) : 0
if (limit > 1e15) limit = 0
if (Number.isFinite(usage)) return { usage, limit }
}
return null
}
/**
* @param {string} socketPath
* @returns {Promise<Map<string, string>>} id → name
*/
export function fetchDockerNames(socketPath) {
return new Promise((resolve) => {
const req = http.request(
{
socketPath,
path: '/containers/json?all=1',
method: 'GET',
timeout: 2000,
},
(res) => {
let body = ''
res.on('data', (c) => {
body += c
})
res.on('end', () => {
try {
const list = JSON.parse(body)
/** @type {Map<string, string>} */
const map = new Map()
for (const c of list) {
const id = String(c.Id || '').toLowerCase()
const name = String((c.Names && c.Names[0]) || id)
.replace(/^\//, '')
.slice(0, 64)
if (id) map.set(id, name)
if (id.length >= 12) map.set(id.slice(0, 12), name)
}
resolve(map)
} catch {
resolve(new Map())
}
})
}
)
req.on('error', () => resolve(new Map()))
req.on('timeout', () => {
req.destroy()
resolve(new Map())
})
req.end()
})
}
export class DockerCollector extends EventEmitter {
constructor(opts = {}) {
super()
this.intervalMs = opts.intervalMs || Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS
this.socketPath = opts.socketPath || process.env.PEARDATA_DOCKER_SOCKET || '/var/run/docker.sock'
this._timer = null
/** @type {Map<string, { usageNs: number, wallMs: number }>} */
this._prevCpu = new Map()
/** @type {Map<string, string>} */
this._names = new Map()
this._nameRefreshAt = 0
}
start() {
if (this._timer) return
registerChart(DOCKER_CONTAINERS_CHART)
log.info('Docker collector started', { socket: this.socketPath })
this._tick()
this._timer = setInterval(() => this._tick(), this.intervalMs)
if (typeof this._timer.unref === 'function') this._timer.unref()
}
stop() {
if (this._timer) {
clearInterval(this._timer)
this._timer = null
}
}
async _refreshNames() {
const now = Date.now()
if (now - this._nameRefreshAt < 30_000) return
this._nameRefreshAt = now
try {
if (fs.existsSync(this.socketPath)) {
this._names = await fetchDockerNames(this.socketPath)
}
} catch {
// ignore
}
}
async _tick() {
try {
await this._refreshNames()
const containers = discoverCgroupContainers()
const ts = Date.now()
const wallMs = ts
const ncpu = hostCpus()
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
const batch = [
{
chart: 'docker.containers',
context: 'docker.containers',
ts,
values: { running: containers.length, total: containers.length },
},
]
for (const c of containers) {
const display =
this._names.get(c.id) || this._names.get(c.shortId) || c.name || c.shortId
const cpuDef = makeDockerCpuChart(c.shortId, display)
const memDef = makeDockerMemChart(c.shortId, display)
registerChart(cpuDef)
registerChart(memDef)
const cpu = readCgroupCpu(c.cgroupPath)
let usagePct = 0
if (cpu) {
const prev = this._prevCpu.get(c.id)
if (prev && wallMs > prev.wallMs) {
const dNs = cpu.usageNs - prev.usageNs
const dWallNs = (wallMs - prev.wallMs) * 1e6
if (dNs >= 0 && dWallNs > 0) {
usagePct = Math.min(100 * ncpu, (dNs / dWallNs) * 100)
}
}
this._prevCpu.set(c.id, { usageNs: cpu.usageNs, wallMs })
}
batch.push({
chart: cpuDef.id,
context: 'docker.cpu',
ts,
values: { usage: usagePct },
})
const mem = readCgroupMemory(c.cgroupPath)
batch.push({
chart: memDef.id,
context: 'docker.mem',
ts,
values: {
usage: mem ? bytesToMiB(mem.usage) : 0,
limit: mem && mem.limit ? bytesToMiB(mem.limit) : null,
},
})
}
this.emit('samples', batch)
} catch (err) {
log.warn('Docker tick failed', { error: err.message })
}
}
}
/** @type {DockerCollector|null} */
let singleton = null
export function getDockerCollector() {
if (!singleton) singleton = new DockerCollector()
return singleton
}