213 lines
7.5 KiB
JavaScript
213 lines
7.5 KiB
JavaScript
/**
|
|
* Docker Maintenance Module
|
|
* Scheduled cleanup to prevent Docker disk bloat:
|
|
* - Prunes dangling images
|
|
* - Prunes stopped non-managed containers
|
|
* - Clears build cache
|
|
* - Monitors disk usage and warns when thresholds exceeded
|
|
*/
|
|
|
|
const Docker = require('dockerode');
|
|
const EventEmitter = require('events');
|
|
const { DOCKER } = require('./constants');
|
|
|
|
const docker = new Docker();
|
|
|
|
class DockerMaintenance extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.interval = null;
|
|
this.running = false;
|
|
this.lastRun = null;
|
|
this.lastResult = null;
|
|
}
|
|
|
|
start() {
|
|
if (this.running) return;
|
|
this.running = true;
|
|
|
|
// Run first maintenance 5 minutes after startup (let everything settle)
|
|
setTimeout(() => {
|
|
if (!this.running) return;
|
|
this.runMaintenance().catch(() => {});
|
|
}, 5 * 60 * 1000);
|
|
|
|
// Then run on the configured interval (default 24h)
|
|
this.interval = setInterval(() => {
|
|
this.runMaintenance().catch(() => {});
|
|
}, DOCKER.MAINTENANCE.INTERVAL);
|
|
}
|
|
|
|
stop() {
|
|
if (!this.running) return;
|
|
this.running = false;
|
|
if (this.interval) {
|
|
clearInterval(this.interval);
|
|
this.interval = null;
|
|
}
|
|
}
|
|
|
|
async runMaintenance() {
|
|
const startTime = Date.now();
|
|
const result = {
|
|
timestamp: new Date().toISOString(),
|
|
pruned: { images: 0, containers: 0, buildCache: 0 },
|
|
spaceReclaimed: { images: 0, containers: 0, buildCache: 0, total: 0 },
|
|
diskUsage: null,
|
|
warnings: [],
|
|
containersWithoutLogLimits: []
|
|
};
|
|
|
|
try {
|
|
// 1. Prune dangling images
|
|
try {
|
|
const imgResult = await docker.pruneImages({ filters: { dangling: { true: true } } });
|
|
result.pruned.images = (imgResult.ImagesDeleted || []).length;
|
|
result.spaceReclaimed.images = imgResult.SpaceReclaimed || 0;
|
|
} catch (e) {
|
|
result.warnings.push(`Image prune failed: ${e.message}`);
|
|
}
|
|
|
|
// 2. Prune stopped containers (only non-managed ones)
|
|
try {
|
|
const stopped = await docker.listContainers({
|
|
all: true,
|
|
filters: { status: ['exited', 'dead'] }
|
|
});
|
|
for (const c of stopped) {
|
|
// Skip DashCaddy-managed containers — user may want to restart them
|
|
if (c.Labels?.['sami.managed'] === 'true') continue;
|
|
// Skip containers stopped less than 24h ago
|
|
const stoppedAge = Date.now() / 1000 - c.Created;
|
|
if (stoppedAge < 86400) continue;
|
|
try {
|
|
const container = docker.getContainer(c.Id);
|
|
await container.remove({ force: true });
|
|
result.pruned.containers++;
|
|
} catch (e) {
|
|
// Container may have been removed between list and remove
|
|
}
|
|
}
|
|
} catch (e) {
|
|
result.warnings.push(`Container prune failed: ${e.message}`);
|
|
}
|
|
|
|
// 3. Prune build cache
|
|
try {
|
|
const cacheResult = await docker.pruneBuilder();
|
|
result.spaceReclaimed.buildCache = cacheResult.SpaceReclaimed || 0;
|
|
result.pruned.buildCache = (cacheResult.CachesDeleted || []).length;
|
|
} catch (e) {
|
|
// Build cache prune may not be available on all Docker versions
|
|
result.warnings.push(`Build cache prune failed: ${e.message}`);
|
|
}
|
|
|
|
// 4. Get disk usage
|
|
try {
|
|
const df = await docker.df();
|
|
result.diskUsage = {
|
|
images: {
|
|
count: (df.Images || []).length,
|
|
sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0)
|
|
},
|
|
containers: {
|
|
count: (df.Containers || []).length,
|
|
sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0)
|
|
},
|
|
volumes: {
|
|
count: (df.Volumes?.Volumes || []).length,
|
|
sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0)
|
|
},
|
|
buildCache: {
|
|
count: (df.BuildCache || []).length,
|
|
sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0)
|
|
}
|
|
};
|
|
result.diskUsage.totalBytes =
|
|
result.diskUsage.images.sizeBytes +
|
|
result.diskUsage.containers.sizeBytes +
|
|
result.diskUsage.volumes.sizeBytes +
|
|
result.diskUsage.buildCache.sizeBytes;
|
|
result.diskUsage.totalGB = +(result.diskUsage.totalBytes / (1024 ** 3)).toFixed(2);
|
|
|
|
if (result.diskUsage.totalGB > DOCKER.MAINTENANCE.DISK_WARN_GB) {
|
|
result.warnings.push(`Docker disk usage is ${result.diskUsage.totalGB}GB (threshold: ${DOCKER.MAINTENANCE.DISK_WARN_GB}GB)`);
|
|
}
|
|
} catch (e) {
|
|
result.warnings.push(`Disk usage check failed: ${e.message}`);
|
|
}
|
|
|
|
// 5. Check for containers without log rotation
|
|
try {
|
|
const running = await docker.listContainers({ all: false });
|
|
for (const c of running) {
|
|
if (c.Labels?.['sami.managed'] !== 'true') continue;
|
|
try {
|
|
const container = docker.getContainer(c.Id);
|
|
const info = await container.inspect();
|
|
const logConfig = info.HostConfig?.LogConfig;
|
|
if (!logConfig?.Config?.['max-size']) {
|
|
result.containersWithoutLogLimits.push({
|
|
name: c.Names[0]?.replace(/^\//, '') || c.Id.slice(0, 12),
|
|
id: c.Id.slice(0, 12)
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// Container may have stopped between list and inspect
|
|
}
|
|
}
|
|
if (result.containersWithoutLogLimits.length > 0) {
|
|
result.warnings.push(
|
|
`${result.containersWithoutLogLimits.length} container(s) have no log rotation — restart or update them to apply log limits: ${result.containersWithoutLogLimits.map(c => c.name).join(', ')}`
|
|
);
|
|
}
|
|
} catch (e) {
|
|
result.warnings.push(`Log config check failed: ${e.message}`);
|
|
}
|
|
|
|
result.spaceReclaimed.total =
|
|
result.spaceReclaimed.images +
|
|
result.spaceReclaimed.containers +
|
|
result.spaceReclaimed.buildCache;
|
|
|
|
result.duration = Date.now() - startTime;
|
|
this.lastRun = new Date().toISOString();
|
|
this.lastResult = result;
|
|
|
|
this.emit('maintenance-complete', result);
|
|
return result;
|
|
} catch (error) {
|
|
result.error = error.message;
|
|
result.duration = Date.now() - startTime;
|
|
this.lastResult = result;
|
|
this.emit('maintenance-failed', result);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Get Docker disk usage snapshot (callable on demand) */
|
|
async getDiskUsage() {
|
|
try {
|
|
const df = await docker.df();
|
|
const images = { count: (df.Images || []).length, sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0) };
|
|
const containers = { count: (df.Containers || []).length, sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0) };
|
|
const volumes = { count: (df.Volumes?.Volumes || []).length, sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0) };
|
|
const buildCache = { count: (df.BuildCache || []).length, sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0) };
|
|
const totalBytes = images.sizeBytes + containers.sizeBytes + volumes.sizeBytes + buildCache.sizeBytes;
|
|
return { images, containers, volumes, buildCache, totalBytes, totalGB: +(totalBytes / (1024 ** 3)).toFixed(2) };
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
getStatus() {
|
|
return {
|
|
running: this.running,
|
|
lastRun: this.lastRun,
|
|
lastResult: this.lastResult
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = new DockerMaintenance();
|