• how-to
  • Kubernetes

How to list every container image running in a Kubernetes cluster, and why the pod spec is the wrong place to look

The kubectl one-liners, and why they list the wrong thing: spec.image is a request, the kubelet’s imageID is what runs. A jq query lists it per pod and digest.

StackRadar Team

11 min read

image::tag:tag:tag:tagsha256:sha256:sha256:sha256:

The quick answer is one kubectl line from the Kubernetes docs. The right answer reads status.containerStatuses[].imageID instead of spec.containers[].image, because the spec is what you asked for and the status is what the kubelet pulled. On a two-node production cluster of ours, the two lists disagree on 30 of 76 running containers.

Below: the documented one-liners, what they are actually listing, a jq query that lists the digests running per pod and per image, three traps that catch most scripts, how to join the list to Deployments and Helm releases, and what to do with the list once you have it.

The kubectl one-liners

These are from the Kubernetes task page, verbatim. Only the first includes init containers, and none includes ephemeral debug containers.

bash
# Every image in every namespace, init containers included, with how many containers use it
kubectl get pods --all-namespaces -o jsonpath="{.items[*].spec['initContainers', 'containers'][*].image}" |\
tr -s '[[:space:]]' '\n' |\
sort |\
uniq -c

# One line per pod
kubectl get pods --all-namespaces -o jsonpath='{range .items[*]}{"\n"}{.metadata.name}{":\t"}{range .spec.containers[*]}{.image}{", "}{end}{end}' |\
sort

# One namespace, or one label
kubectl get pods --namespace kube-system -o jsonpath="{.items[*].spec.containers[*].image}"
kubectl get pods --all-namespaces -o jsonpath="{.items[*].spec.containers[*].image}" -l app=nginx

If someone asks "what images do we run" in a meeting, the first command answers it in a second. If a scanner, an auditor or a CVE is asking, keep reading.

What that list actually is

spec.containers[].image is a request. It is the string somebody wrote in a manifest, and the kubelet's job is to resolve it to an image and run that. On our cluster the one-liner returns 41 distinct references. 34 of them have no digest, so they name a tag, and a tag is a pointer that the publisher can move. One has no tag at all, which means :latest, which means "whatever it was when this pod started".

That is fine for humans. For anything that needs to know which bytes are running, it is the wrong field. Two pods that both say nginx:1.27 can be running two different images if the tag was re-pushed between their starts. A pod that says app:v2 may be running a sidecar no manifest mentions, because an admission webhook injected it. And a Dockerfile-level question, "does this image contain OpenSSL 3.3.2", cannot be answered from a tag at all.

What is actually running: the digests the kubelets report

When a container starts, the runtime records the image it pulled in status.containerStatuses[].imageID as a repository plus a sha256 digest. The digest identifies the exact manifest, so it is the key everything downstream should use. This lists it for every container, including init and ephemeral ones:

bash
kubectl get pods -A -o json | jq -r '
  .items[]
  | .metadata.namespace as $ns | .metadata.name as $pod
  | ((.status.initContainerStatuses // []) + (.status.containerStatuses // []) + (.status.ephemeralContainerStatuses // []))[]
  | select(.imageID != "")
  | [$ns, $pod, .name, (.imageID | sub("^docker-pullable://"; ""))] | @tsv' \
  | sort -u > running-containers.tsv

cut -f4 running-containers.tsv | sort | uniq -c | sort -rn   # one line per image, most containers first
cut -f4 running-containers.tsv | sort -u | wc -l             # how many distinct images are running
output (abridged)
  17 ghcr.io/lockdep/stackradar-server@sha256:2dde21d9…
  14 ghcr.io/digitalocean-packages/cilium@sha256:7a0f3c41…
   8 quay.io/argoproj/argocd@sha256:c9e2b7d0…
   …
40

On our cluster that is 96 containers in 63 pods (20 of them init containers) running 40 distinct images. Three details of the query:

  • select(.imageID != "") drops containers that have not started yet. They have no image until they do.
  • The docker-pullable:// prefix is what old Docker-based nodes emit; containerd and CRI-O write repo@sha256:… directly, which is what every one of our 96 entries looked like. The sub normalises both.
  • Three status arrays, not one. Init containers have their own, and so do ephemeral debug containers started with kubectl debug. An inventory that reads only containerStatuses misses both.

Three traps

1. status.image is not the tag you deployed

Next to imageID there is a status.containerStatuses[].image field, and it looks like the answer. It is not. The container runtime fills it with the first name it holds for the pulled digest, and that is only sometimes the name in your manifest. On our cluster the two differ for 30 of 76 running containers:

bash
kubectl get pods -A -o json | jq -r '
  .items[] | .metadata.namespace as $ns | .metadata.name as $pod
  | .spec.containers[] as $c
  | (.status.containerStatuses // [])[] | select(.name == $c.name)
  | [$ns, $pod, $c.image, .image, .imageID] | @tsv' | awk -F'\t' '$3 != $4'
  • alpine/socat came back as docker.io/alpine/socat:latest. Harmless normalisation.
  • quay.io/kiwigrid/k8s-sidecar:2.11.2 came back as docker.io/kiwigrid/k8s-sidecar:2.11.2. Same digest, different registry: the node had pulled the image under both names, and the runtime reported the other one.
  • Every digest-pinned reference (repo:tag@sha256:…) came back as a bare sha256:… with no repository.

The behaviour is known and long-standing. Read image for display if you like; key on imageID.

2. One tag, two digests

A mutable tag that gets re-pushed splits your fleet: pods started after the push run the new digest, pods started before it run the old one, and kubectl get pods shows the same tag for both. The spec-based list says one image. The digest-based list says two, with the pod names behind each. On our cluster this count was zero on the day we ran it, which is the point: it is zero until the day it is not, and only the digest list notices.

3. The node's image cache is not the running set

Each node reports the images in its kubelet cache under status.images, and some inventories read that instead. It is a superset: previous versions, images from pods that have since been deleted, and anything pulled but not yet garbage-collected.

bash
kubectl get nodes -o json | jq -r '
  .items[] | .metadata.name as $n
  | .status.images[] | [$n, (.sizeBytes / 1048576 | floor | tostring + " MiB"), (.names | join("  "))] | @tsv'

Our two nodes held 67 cached images carrying 84 names, against 40 images actually running. Scanning the node cache reports vulnerabilities in images nothing is running, which is noise, and it misses nothing that is running, which makes the noise hard to spot.

Pods in Completed or Error state still have an imageID, and finished Jobs keep their pods around until their TTL. Filter on .status.phase == "Running" if you want only containers with a live process, and keep the others if you want everything that executed.

Join it to the workload and the Helm release

A digest per pod is a start. The question that follows is "whose is it": which Deployment, DaemonSet or StatefulSet owns the pod, and which Helm release or ArgoCD application put it there. Pods carry the first in metadata.ownerReferences (a ReplicaSet, which in turn points at its Deployment) and most charts carry the second as the app.kubernetes.io/instance label.

bash
kubectl get pods -A -o json | jq -r '
  .items[]
  | .metadata as $m
  | ((.status.initContainerStatuses // []) + (.status.containerStatuses // []))[]
  | select(.imageID != "")
  | [$m.namespace,
     ((($m.ownerReferences // [])[0]) | if . then "\(.kind)/\(.name)" else "-" end),
     ($m.labels["app.kubernetes.io/instance"] // "-"),
     .imageID]
  | @tsv' | sort -u

On our cluster the owners come out as 30 ReplicaSets, 14 DaemonSets, 14 Jobs, 4 StatefulSets and one bare pod. To get from ReplicaSet to Deployment, follow the ReplicaSet's own ownerReferences, or read its deployment.kubernetes.io/revision annotation and the name prefix. The vulnerable-package post does the full join from a package name to a Helm release.

From a list of images to an inventory

The digest list is the first step of every question that matters more than the list itself. An SBOM per digest tells you what is inside each image (how to generate one per running image). A scan per digest tells you which running images a new CVE affects. A diff of today's list against yesterday's tells you what changed.

If you would rather not write the pipeline, the free, Apache-2.0 StackRadar CLI does the listing, the digest deduplication and the scan in one command from your laptop, through your kubeconfig, with no agent in the cluster:

bash
brew install lockdep/tap/stackradar      # or the install script / a release binary

stackradar scan                          # every running image, deduplicated by digest, matched against OSV and CISA KEV
stackradar scan -o json > report.json    # the inventory with digests, pods and findings, for jq
stackradar scan --serve                  # the same report in your browser

It is a point-in-time tool, like every command on this page. Run it tomorrow and yesterday's clean image may have a new CVE; it will not tell you until you run it. For the continuous version, one SBOM per running image re-matched as advisories land, with history across every cluster, that is what StackRadar is; the Kubernetes vulnerability scanning page describes how the agent lists images (the same imageID field, watched rather than polled) and the live demo shows the result.

Frequently asked questions

How do I list all container images in all namespaces with kubectl?

The documented one-liner: `kubectl get pods --all-namespaces -o jsonpath="{.items[*].spec['initContainers', 'containers'][*].image}" | tr -s '[[:space:]]' '\n' | sort | uniq -c`. It lists the image reference each pod asked for, with a count. To list what is actually running, read status.containerStatuses[].imageID instead; the jq command in this post does that per pod and per digest.

How do I get the digest of the image a pod is actually running?

From the pod status, not the spec: `kubectl get pod <name> -o jsonpath='{.status.containerStatuses[*].imageID}'`. The container runtime fills imageID with the repository and the sha256 digest it pulled. On old Docker-based nodes it carries a docker-pullable:// prefix; containerd and CRI-O write repo@sha256:… directly. An empty value means the container has not started yet.

Why does kubectl show a different image than the one in my Deployment?

status.containerStatuses[].image is filled by the container runtime with the first name it holds for the pulled digest, which is not necessarily the one you deployed: a Docker Hub reference comes back with docker.io/ and :latest added, an image that also exists under another tag or registry on that node can come back under that other name, and a digest-pinned reference often comes back as a bare sha256:…. The spec is your request, status.image is the runtime’s label, and status.imageID is the only field that identifies what runs.

Do these commands include init containers and sidecars?

The first documented one-liner includes init containers through spec['initContainers', 'containers']; the per-pod, per-namespace and per-label variants read only spec.containers. Sidecars injected by a mesh or an admission webhook are in the pod spec the API server stores, so every command sees them. Ephemeral debug containers appear only in status.ephemeralContainerStatuses, which the jq command in this post includes.

How do I list the images on a node?

Each node’s status.images lists every image in the kubelet’s image cache with its names and size: `kubectl get nodes -o json | jq '.items[].status.images[].names[]'`. That is a superset of what is running. On our two-node cluster it held 84 names while 40 distinct images were running; the rest were previous versions and images from pods that had gone, waiting for garbage collection.

How do I find which Deployment or Helm release runs an image?

Walk metadata.ownerReferences: a pod points at its ReplicaSet, DaemonSet, StatefulSet or Job, and a ReplicaSet points at its Deployment. Most Helm charts label their pods with app.kubernetes.io/instance set to the release name, and ArgoCD adds its own application label. The owners command in this post prints namespace, owner, release label and image digest per running container.

All posts