Skip to content

Commit 71f7426

Browse files
agentHitsclaude
andauthored
fix(monitoring): name untagged images in Docker Disk Usage (#115)
Docker Disk Usage listed many images as "<none>:<none>" with the subtitle "Dangling local image", including images that running containers use. Docker keeps no tag for an image in two common cases: - the tag moved to a newer pull or build (a redeploy of an application or of the panel itself), while an exited swarm task from the previous deploy still holds the old image; - the image was pulled by digest, as compose does for "name:tag@sha256:" references, so no tag is stored locally at all. The name is now taken from the reference the image's containers were created from (running containers first), and from RepoDigests when no container uses the image. A digest-only name keeps a short digest, so "repo@sha256:<12 chars>". The subtitle says which case it is: "Previous version: the tag now points to another image", "Pulled by digest, no local tag", or "Dangling local image" when nothing uses it. Source lists the references. Containers are now matched to images by image id from docker container inspect. docker ps gives no image id and shows a bare id once the tag has moved, so digest-pinned containers were reported as "no containers reported" before. Used by also marks containers that are not running, e.g. "panel.1.abc (exited)", which shows why an old image is kept. Checked against a real host's data: all 28 untagged images got their names, and Used by matched the CONTAINERS column of docker system df -v. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 19105a3 commit 71f7426

2 files changed

Lines changed: 213 additions & 31 deletions

File tree

‎apps/dokploy/__test__/monitoring/docker-disk-usage.test.ts‎

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ describe("parseDockerDiskUsageVerbose", () => {
124124
{
125125
id: "274333ea2993",
126126
image: "2e7598a736d0",
127+
imageId: "sha256:2e7598a736d0full",
127128
name: "dokploy.1.test",
128129
},
129130
],
@@ -152,7 +153,7 @@ describe("parseDockerDiskUsageVerbose", () => {
152153
expect(enriched.images[0]).toEqual(
153154
expect.objectContaining({
154155
name: "<none>:<none>",
155-
subtitle: "Dangling local image",
156+
subtitle: "Untagged local image",
156157
}),
157158
);
158159
expect(enriched.images[0]?.meta).toEqual(
@@ -184,6 +185,109 @@ describe("parseDockerDiskUsageVerbose", () => {
184185
]),
185186
);
186187
});
188+
189+
test("names untagged images after the references that created them", () => {
190+
const details = parseDockerDiskUsageVerbose(
191+
[
192+
"Images space usage:",
193+
"",
194+
"REPOSITORY TAG IMAGE ID CREATED SIZE SHARED SIZE UNIQUE SIZE CONTAINERS",
195+
"<none> <none> d0ef856968db 2 hours ago 2.45GB 74.87MB 2.373GB 1",
196+
"<none> <none> 3a02d38405dc 5 months ago 114MB 0B 114MB 2",
197+
"<none> <none> b1c5c0aec2ab 9 hours ago 1.42GB 1.416GB 0B 0",
198+
].join("\n"),
199+
);
200+
const redisDigest =
201+
"sha256:09160599abd229764c0fb44cb6be640294e1d360a54b19985ab4843dcf2d90f1";
202+
203+
const enriched = enrichDockerDiskUsageDetails(details, {
204+
containers: [
205+
{
206+
id: "old-panel-task",
207+
image: "ghcr.io/example/panel:dev",
208+
imageId: "sha256:d0ef856968dbfull",
209+
name: "panel.1.old",
210+
state: "exited",
211+
},
212+
{
213+
id: "redis-a",
214+
image: `redis:8-alpine@${redisDigest}`,
215+
imageId: "sha256:3a02d38405dcfull",
216+
name: "stack-redis-1",
217+
state: "running",
218+
},
219+
{
220+
id: "redis-b",
221+
image: `redis:8-alpine@${redisDigest}`,
222+
imageId: "sha256:3a02d38405dcfull",
223+
name: "stack-redis-rate-limit-1",
224+
state: "running",
225+
},
226+
],
227+
imageInspects: new Map([
228+
[
229+
"d0ef856968db",
230+
{
231+
Id: "sha256:d0ef856968dbfull",
232+
RepoDigests: ["ghcr.io/example/panel@sha256:08556bfbd114b366"],
233+
RepoTags: [],
234+
},
235+
],
236+
[
237+
"3a02d38405dc",
238+
{
239+
Id: "sha256:3a02d38405dcfull",
240+
RepoDigests: [`redis@${redisDigest}`],
241+
RepoTags: [],
242+
},
243+
],
244+
[
245+
"b1c5c0aec2ab",
246+
{
247+
Id: "sha256:b1c5c0aec2abfull",
248+
RepoDigests: [
249+
"registry.example.com:5000/team/grafana@sha256:b01e2b61edddaf1f8ed9",
250+
],
251+
RepoTags: [],
252+
},
253+
],
254+
]),
255+
});
256+
const byId = new Map(enriched.images.map((image) => [image.id, image]));
257+
258+
expect(byId.get("d0ef856968db")).toEqual(
259+
expect.objectContaining({
260+
name: "ghcr.io/example/panel:dev",
261+
subtitle: "Previous version: the tag now points to another image",
262+
}),
263+
);
264+
expect(byId.get("d0ef856968db")?.meta).toEqual(
265+
expect.arrayContaining([
266+
{ label: "Source", value: "ghcr.io/example/panel:dev" },
267+
{ label: "Used by", value: "panel.1.old (exited)" },
268+
]),
269+
);
270+
expect(byId.get("3a02d38405dc")).toEqual(
271+
expect.objectContaining({
272+
name: "redis:8-alpine",
273+
subtitle: "Pulled by digest, no local tag",
274+
}),
275+
);
276+
expect(byId.get("3a02d38405dc")?.meta).toEqual(
277+
expect.arrayContaining([
278+
{
279+
label: "Used by",
280+
value: "stack-redis-1, stack-redis-rate-limit-1",
281+
},
282+
]),
283+
);
284+
expect(byId.get("b1c5c0aec2ab")).toEqual(
285+
expect.objectContaining({
286+
name: "registry.example.com:5000/team/grafana@sha256:b01e2b61eddd",
287+
subtitle: "Dangling local image",
288+
}),
289+
);
290+
});
187291
});
188292

189293
describe("limitDockerDiskUsageDetails", () => {

‎packages/server/src/utils/docker/utils.ts‎

Lines changed: 108 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,9 @@ const sortDiskUsageDetails = (items: DockerDiskUsageDetailItem[]) =>
362362
interface DockerDiskUsageContainerReference {
363363
id: string;
364364
image: string;
365+
imageId: string;
365366
name: string;
367+
state?: string;
366368
}
367369

368370
interface DockerImageInspect {
@@ -410,23 +412,59 @@ const parseJsonArray = <T>(stdout: string): T[] => {
410412

411413
const isDanglingImageName = (name: string) => name === "<none>:<none>";
412414

413-
const isSameImageReference = (
414-
containerImage: string,
415-
detail: DockerDiskUsageDetailItem,
416-
inspect?: DockerImageInspect,
417-
) => {
418-
const fullId = inspect?.Id ?? "";
419-
const shortId = detail.id;
415+
const isImageIdReference = (reference: string) =>
416+
/^(sha256:)?[a-f0-9]{12,64}$/.test(reference);
420417

421-
return (
422-
containerImage === detail.name ||
423-
containerImage === shortId ||
424-
containerImage.startsWith(shortId) ||
425-
(Boolean(fullId) && containerImage === fullId) ||
426-
(Boolean(fullId) && fullId.startsWith(`sha256:${containerImage}`))
418+
const splitImageReference = (reference: string) => {
419+
const [repositoryAndTag = reference, digest] = reference.split("@");
420+
const lastPathSegment = repositoryAndTag.slice(
421+
repositoryAndTag.lastIndexOf("/") + 1,
427422
);
423+
return {
424+
digest,
425+
hasTag: lastPathSegment.includes(":"),
426+
repositoryAndTag,
427+
};
428428
};
429429

430+
const getImageReferenceName = (reference: string) => {
431+
const { digest, hasTag, repositoryAndTag } = splitImageReference(reference);
432+
if (hasTag || !digest) {
433+
return repositoryAndTag;
434+
}
435+
return `${repositoryAndTag}@${digest.slice(0, "sha256:".length + 12)}`;
436+
};
437+
438+
// Docker drops the tag from an image once the tag moves to a newer pull or
439+
// build, and never stores one for an image pulled by digest; the reference a
440+
// container was created from still says what the image is.
441+
const describeUntaggedImage = (references: string[], usedBy: string[]) => {
442+
const hasMovedTag = references.some((reference) => {
443+
const { digest, hasTag } = splitImageReference(reference);
444+
return hasTag && !digest;
445+
});
446+
if (hasMovedTag) {
447+
return "Previous version: the tag now points to another image";
448+
}
449+
if (references.length > 0) {
450+
return "Pulled by digest, no local tag";
451+
}
452+
return usedBy.length > 0 ? "Untagged local image" : "Dangling local image";
453+
};
454+
455+
const isContainerOfImage = (
456+
container: DockerDiskUsageContainerReference,
457+
detail: DockerDiskUsageDetailItem,
458+
inspect?: DockerImageInspect,
459+
) =>
460+
container.imageId === inspect?.Id ||
461+
container.imageId.startsWith(`sha256:${detail.id}`);
462+
463+
const formatContainerName = (container: DockerDiskUsageContainerReference) =>
464+
container.state && container.state !== "running"
465+
? `${container.name} (${container.state})`
466+
: container.name;
467+
430468
const formatListValue = (items: string[], emptyValue = "--") =>
431469
items.length > 0 ? items.join(", ") : emptyValue;
432470

@@ -452,18 +490,29 @@ export const enrichDockerDiskUsageDetails = (
452490
inspect?.RepoTags?.filter((tag: string) => tag !== "<none>:<none>") ??
453491
[];
454492
const repoDigests = inspect?.RepoDigests ?? [];
455-
const usedBy = containers
456-
.filter((container) =>
457-
isSameImageReference(container.image, detail, inspect),
458-
)
459-
.map((container) => container.name);
493+
const imageContainers = containers
494+
.filter((container) => isContainerOfImage(container, detail, inspect))
495+
.sort(
496+
(a, b) =>
497+
Number(b.state === "running") - Number(a.state === "running"),
498+
);
499+
const usedBy = imageContainers.map(formatContainerName);
500+
const isUntagged = isDanglingImageName(detail.name);
501+
const references = [
502+
...new Set(
503+
imageContainers
504+
.map((container) => container.image)
505+
.filter((reference) => !isImageIdReference(reference)),
506+
),
507+
];
508+
const nameReference = references[0] ?? repoDigests[0];
460509
const imagePath = getImagePathFromInspect(inspect);
461510
const extraMeta = [
462511
{ label: "Full image id", value: fullId },
463512
{
464513
label: "Source",
465-
value: isDanglingImageName(detail.name)
466-
? "dangling local image"
514+
value: isUntagged
515+
? formatListValue(references, "dangling local image")
467516
: detail.name,
468517
},
469518
...(repoTags.length > 0
@@ -487,8 +536,12 @@ export const enrichDockerDiskUsageDetails = (
487536
(meta) => meta.label !== "Containers" || usedBy.length === 0,
488537
),
489538
],
490-
subtitle: isDanglingImageName(detail.name)
491-
? "Dangling local image"
539+
name:
540+
isUntagged && nameReference
541+
? getImageReferenceName(nameReference)
542+
: detail.name,
543+
subtitle: isUntagged
544+
? describeUntaggedImage(references, usedBy)
492545
: detail.subtitle,
493546
};
494547
}),
@@ -688,26 +741,51 @@ export const limitDockerDiskUsageDetails = (
688741
volumes: limitDiskUsageDetailItems(details.volumes, detailLimit),
689742
});
690743

744+
// docker ps has no image id and prints a bare id instead of the reference once
745+
// the tag has moved on, so only inspect ties a container to an untagged image.
746+
const dockerContainerReferenceFormat =
747+
'{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},"imageId":{{json .Image}},"state":{{json .State.Status}}}';
748+
691749
const getDockerDiskUsageContainerReferences = async (): Promise<
692750
DockerDiskUsageContainerReference[]
693751
> => {
752+
const { stdout: idsStdout } = await execAsync("docker ps -aq --no-trunc");
753+
const containerIds = idsStdout
754+
.split("\n")
755+
.map((containerId) => containerId.trim())
756+
.filter(Boolean);
757+
if (containerIds.length === 0) {
758+
return [];
759+
}
760+
694761
const { stdout } = await execAsync(
695-
"docker ps -a --no-trunc --format '{{json .}}'",
762+
`docker container inspect --format ${quoteShellArg(
763+
dockerContainerReferenceFormat,
764+
)} ${containerIds.map((containerId) => quoteShellArg(containerId)).join(" ")}`,
696765
);
697766

698767
return parseJsonLines<{
699-
ID?: string;
700-
Image?: string;
701-
Names?: string;
768+
id?: string;
769+
image?: string;
770+
imageId?: string;
771+
name?: string;
772+
state?: string;
702773
}>(stdout).flatMap((container) => {
703-
if (!container.ID || !container.Image || !container.Names) {
774+
if (
775+
!container.id ||
776+
!container.image ||
777+
!container.imageId ||
778+
!container.name
779+
) {
704780
return [];
705781
}
706782

707783
return {
708-
id: container.ID,
709-
image: container.Image,
710-
name: container.Names,
784+
id: container.id,
785+
image: container.image,
786+
imageId: container.imageId,
787+
name: container.name.replace(/^\//, ""),
788+
state: container.state,
711789
};
712790
});
713791
};

0 commit comments

Comments
 (0)