perf(backend): replace docker system df shell-out with dockerode API (#818)

MonitorService.evaluate() forked the docker CLI every 30s and
walked the human-readable Reclaimable strings ("1.196GB", etc.)
with a regex to compute the janitor threshold check. The Docker
Engine API returns raw byte counts, and the existing
DockerController.getDiskUsage() already wraps it for images,
containers, and volumes. Extend that helper with reclaimable
build-cache bytes so MonitorService can sum the four categories
in one call.

Drops the child_process / promisify imports from MonitorService and
removes about 30 lines of stdout parsing. Also widens the explicit
return type of getDiskUsageClassified so the new fields aren't
silent runtime additions.
This commit is contained in:
Anso
2026-04-28 01:16:07 -04:00
committed by GitHub
parent 5cf4323511
commit 279ec62dff
3 changed files with 29 additions and 37 deletions
@@ -179,6 +179,10 @@ describe('DockerController - getDiskUsage', () => {
{ UsageData: { RefCount: 0, Size: 400 } }, // reclaimable (unused)
{ UsageData: { RefCount: 1, Size: 300 } }, // not reclaimable (in use)
],
BuildCache: [
{ ID: 'bc1', InUse: false, Size: 600 }, // reclaimable
{ ID: 'bc2', InUse: true, Size: 250 }, // not reclaimable
],
});
const dc = DockerController.getInstance(1);
@@ -187,6 +191,8 @@ describe('DockerController - getDiskUsage', () => {
expect(usage.reclaimableImages).toBe(500);
expect(usage.reclaimableContainers).toBe(200);
expect(usage.reclaimableVolumes).toBe(400);
expect(usage.reclaimableBuildCache).toBe(600);
expect(usage.reclaimableBuildCacheCount).toBe(1);
});
it('handles empty arrays gracefully', async () => {
+12
View File
@@ -163,17 +163,27 @@ class DockerController {
return { bytes, count: reclaimable.length };
};
const reclaimableBuildCache = (items: any[]) => {
if (!items || !Array.isArray(items)) return { bytes: 0, count: 0 };
const reclaimable = items.filter(i => i.InUse === false);
const bytes = reclaimable.reduce((acc, item) => acc + (item.Size || 0), 0);
return { bytes, count: reclaimable.length };
};
const images = df.Images ? reclaimableImages(df.Images) : { bytes: 0, count: 0 };
const containers = df.Containers ? reclaimableContainers(df.Containers) : { bytes: 0, count: 0 };
const volumes = df.Volumes ? reclaimableVolumes(df.Volumes) : { bytes: 0, count: 0 };
const buildCache = df.BuildCache ? reclaimableBuildCache(df.BuildCache) : { bytes: 0, count: 0 };
return {
reclaimableImages: images.bytes,
reclaimableContainers: containers.bytes,
reclaimableVolumes: volumes.bytes,
reclaimableBuildCache: buildCache.bytes,
reclaimableImageCount: images.count,
reclaimableContainerCount: containers.count,
reclaimableVolumeCount: volumes.count,
reclaimableBuildCacheCount: buildCache.count,
};
}
@@ -376,9 +386,11 @@ class DockerController {
reclaimableImages: number;
reclaimableContainers: number;
reclaimableVolumes: number;
reclaimableBuildCache: number;
reclaimableImageCount: number;
reclaimableContainerCount: number;
reclaimableVolumeCount: number;
reclaimableBuildCacheCount: number;
managedImageBytes: number;
unmanagedImageBytes: number;
managedVolumeBytes: number;
+11 -37
View File
@@ -1,6 +1,4 @@
import si from 'systeminformation';
import { exec } from 'child_process';
import { promisify } from 'util';
import semver from 'semver';
import DockerController from './DockerController';
import { DatabaseService } from './DatabaseService';
@@ -10,8 +8,6 @@ import { isValidVersion, getSenchoVersion } from './CapabilityRegistry';
import { getLatestVersion } from '../utils/version-check';
import { isDebugEnabled } from '../utils/debug';
const execAsync = promisify(exec);
const getMetricDetails = (metric: string): { name: string, unit: string } => {
switch (metric) {
case 'cpu_percent': return { name: 'CPU usage', unit: '%' };
@@ -168,39 +164,17 @@ export class MonitorService {
try {
const janitorLimitGb = parseFloat(settings['docker_janitor_gb']);
if (!isNaN(janitorLimitGb) && janitorLimitGb > 0) {
// Run docker system df to find reclamable space
const { stdout } = await execAsync('docker system df --format "{{json .}}"');
// Output might be multiple lines of JSON (Images, Containers, Local Volumes, Build Cache)
let totalReclaimableBytes = 0;
const lines = stdout.split('\n').filter(l => l.trim().length > 0);
for (const line of lines) {
try {
const parsed = JSON.parse(line);
// RECLAIMABLE might be something like "1.2GB" or "400MB" Let's parse it manually or just use raw sizes from docker api. Actually docker system df JSON format gives Reclaimable field as string e.g. "1.196GB" (or "0B").
const reclaimStr = parsed.Reclaimable;
if (reclaimStr) {
// Extract the number and the unit. e.g "1.196GB" (92%) -> 1.196
const match = reclaimStr.match(/^([0-9.]+)([a-zA-Z]+)/);
if (match) {
const val = parseFloat(match[1]);
// Docker emits both "kB" (lowercase k) on newer versions and "KB"
// historically. Normalise so neither is silently dropped, and
// include "TB" for hosts with terabytes of reclaimable build cache.
const unit = match[2].toUpperCase();
let bytes = 0;
if (unit === 'TB') bytes = val * 1024 * 1024 * 1024 * 1024;
else if (unit === 'GB') bytes = val * 1024 * 1024 * 1024;
else if (unit === 'MB') bytes = val * 1024 * 1024;
else if (unit === 'KB') bytes = val * 1024;
else if (unit === 'B') bytes = val;
totalReclaimableBytes += bytes;
}
}
} catch (e) {
console.warn('[MonitorService] Failed to parse Docker system df output:', e);
}
}
// Use the Docker Engine API directly via dockerode. The previous
// shell-out parsed `docker system df --format "{{json .}}"` and
// walked the human-readable "1.196GB" Reclaimable strings with
// a regex; the API returns raw byte counts and saves a fork
// every monitor tick (default 30 s).
const usage = await DockerController.getInstance().getDiskUsage();
const totalReclaimableBytes =
usage.reclaimableImages +
usage.reclaimableContainers +
usage.reclaimableVolumes +
usage.reclaimableBuildCache;
const reclaimGb = totalReclaimableBytes / (1024 * 1024 * 1024);
const JANITOR_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours