← All posts

Same workload. Both risks.

Removed APIs and image vulnerabilities get tracked in two different backlogs, by two different people, on two different weeks. They land on the same workloads far more often than that split implies. Here's how to pull both lists yourself and join them.

3 August 2026· 9 min read· Runtimez
kubernetes devops security sre

Every platform team I've worked with runs two parallel programs against the same cluster. One is the upgrade project: a version deadline, a list of removed APIs, a migration checklist. The other is vulnerability management: a scanner, a ticket queue, a monthly severity report.

They almost never talk to each other. The upgrade list is keyed by API group and version. The vulnerability list is keyed by image digest. Neither is keyed by the thing you actually operate — the workload — so nobody notices when the same Deployment shows up on both.

That overlap is the most useful list in your cluster, and it takes about twenty minutes of shell to produce. It's worth producing because the two problems have the same remedy: you were going to touch that workload anyway. Bumping the base image and migrating the manifest in one PR costs one review, one rollout, and one round of soak time. Doing them six weeks apart costs three of each.

Step 1 — find what's actually blocking the upgrade

There are two different questions here, and conflating them is where most upgrade audits go wrong.

Question A: what objects are stored using an API that's going away?

This is the one that maps to workloads. Cluster scanners like kube-no-trouble (kubent) and Pluto read live objects and tell you the kind, namespace, and name of everything using a deprecated or removed API version.

Scan live cluster objects
kubent --target-version 1.32 --output json > kubent.json

jq -r '.[] | [.Kind + "/" + .Namespace + "/" + .Name, .ApiVersion, .ReplaceWith] | @tsv' \
  kubent.json | sort > blockers.tsv
blockers.tsv — example
Deployment/checkout/api-gateway     apps/v1        apps/v1
Ingress/edge/storefront             networking.k8s.io/v1beta1   networking.k8s.io/v1
PodDisruptionBudget/payments/ledger policy/v1beta1 policy/v1

Note the caveat that trips people up: kubent reports what is stored in the cluster right now. If your manifests still contain the old API version in Git but the object was applied through a chart that already normalizes it, the cluster looks clean and your next helm upgrade still fails. Scan both — the live cluster and your rendered manifests.

Question B: what is still calling a removed API?

This one catches controllers, operators, CI jobs, and that one cron script nobody owns. The API server has exposed a purpose-built metric for it since Kubernetes 1.19:

Deprecated APIs still receiving requests
kubectl get --raw /metrics \
  | grep '^apiserver_requested_deprecated_apis'
Example output
apiserver_requested_deprecated_apis{group="flowcontrol.apiserver.k8s.io",
  removed_release="1.32",resource="flowschemas",subresource="",version="v1beta3"} 1

The removed_release label is the important one — it tells you the exact version where this stops working, so you can sort by deadline rather than by anxiety. The metric only reports APIs that are deprecated and have a scheduled removal, so anything in that output is a real, dated problem.

The gauge alone doesn't say who's responsible. If you're scraping the API server with Prometheus, join it against request traffic to get the user agent:

PromQL — who is calling the deprecated API
apiserver_requested_deprecated_apis
  * on (group, version, resource, subresource)
  group_right() sum by (group, version, resource, subresource, removed_release, user_agent)
    (rate(apiserver_request_total[7d]))

Use the longest range your retention allows. A weekly reconcile loop is invisible in a 1-hour window, and those are exactly the callers that surprise you at 2 a.m. on upgrade night.

Question A gives you workloads. Question B gives you user agents. You need both, but only A joins cleanly against your vulnerability data — so that's the list we carry into step 3.

Step 2 — inventory images by workload

Scanner output is keyed by image. To join it against anything operational, you first need a map from workload to image.

Map every workload to its images
kubectl get deploy,sts,ds -A -o jsonpath='{range .items[*]}{.kind}{"/"}{.metadata.namespace}{"/"}{.metadata.name}{"\t"}{range .spec.template.spec.containers[*]}{.image}{" "}{end}{"\n"}{end}' \
  | sort > workload-images.tsv

Two things worth knowing about that command. Requesting multiple resource types is what keeps .kind populated on each item — ask for a single type and kubectl strips it from the list entries, and your keys come out as /checkout/api-gateway. And it reads .spec, not .status, so mid-rollout you'll get the intended image rather than what's serving traffic. That's usually what you want for planning, but say so out loud if you hand the list to someone else.

Then scan. Trivy will do the whole cluster and key its output the same way:

Scan the cluster, keep only what's actionable
trivy k8s --report all --severity HIGH,CRITICAL --format json -o vulns.json cluster

jq -r '
  .Resources[]
  | select(.Results != null)
  | [ .Kind + "/" + .Namespace + "/" + .Name,
      ([.Results[].Vulnerabilities // [] | length] | add // 0)
    ] | @tsv
' vulns.json | awk -F'\t' '$2 > 0' | sort > vulns.tsv

Filtering to HIGH and CRITICAL isn't laziness, it's the only way the output stays legible. A full-severity scan of a normal cluster returns thousands of rows and gets ignored, which is worse than not scanning.

Step 3 — join them

Both files are now keyed by Kind/namespace/name. That's the whole trick.

The intersection
join -t $'\t' \
  <(cut -f1 blockers.tsv | sort -u) \
  <(sort -u vulns.tsv)
Example output
Deployment/checkout/api-gateway    23
Deployment/payments/ledger-api     11
Ingress/edge/storefront            4

On the clusters I've run this against, the intersection is typically 5–15% of workloads. It is never zero, and it is never random. The same services show up on both lists for a structural reason: a workload that's been running long enough to accumulate a deprecated API version has also been running long enough for its base image to drift, because both are symptoms of the same thing — nobody has had a reason to open that repo in a year.

That's also why this list is tractable. You are not being asked to fix everything. You're being asked to fix the dozen services that have been quietly aging, and you get two problems closed per PR.

Step 4 — order by blast radius, not by count

Don't sort the intersection by CVE count. A workload with 40 findings behind an internal-only Service is less urgent than one with 6 that terminates public traffic. Pull four signals and rank on those:

Blast-radius signals
# replicas — is there any redundancy at all?
kubectl get deploy -A -o custom-columns=\
'W:.kind,NS:.metadata.namespace,NAME:.metadata.name,REPLICAS:.spec.replicas'

# disruption budgets — will a node drain stall on this?
kubectl get pdb -A

# ingress-exposed — which Services are reachable from outside?
kubectl get ingress -A -o jsonpath='{range .items[*]}{.metadata.namespace}{"/"}{range .spec.rules[*].http.paths[*]}{.backend.service.name}{" "}{end}{"\n"}{end}'
SignalWhy it moves the workload up
Internet-exposedA CVE that's reachable from outside is a different class of problem than one that isn't. This dominates the ranking.
replicas: 1No redundancy. Both the API migration and the image bump are single-shot changes with no safe rollback window.
No PDBThe upgrade's node drains will take it down anyway, so you're touching it during the upgrade whether you planned to or not.
Removal deadlineSort by the removed_release value from step 1. A 1.32 removal on a 1.31 cluster is this sprint; a 1.35 removal is next quarter.

What this turns into

The output of a morning's work is a short, ordered list where every row justifies itself: this workload blocks the upgrade, carries N high-severity CVEs, serves public traffic, and runs one replica. That sentence gets budget in a way that "we have 4,000 vulnerabilities" never has.

Practically, the sprint plan writes itself:

Where this breaks down

Being honest about the limits, because they matter if you try to run this as a standing process:

None of that makes the exercise less worthwhile. Run it once before your next upgrade and you'll have a better-ordered backlog than either of your two existing ones.

Disclosure: we build Runtimez, which does this continuously instead of as a Saturday shell session — a read-only agent scores every workload on both axes, joins them, and ranks the fleet by blast radius. If you'd rather have someone run the audit for you once, that's our Upgrade Readiness Audit. But the commands above are the whole method, and you should be able to reproduce it without us.

See the intersection on your own clusters

One read-only Helm install · first report in under an hour · secrets and images never leave your cluster.

Questions? hello@runtimez.io