Reading Old Kubernetes Manifests: dockershim, PodSecurityPolicy and Removed APIs (1.16–1.25)
Converting 2018–2021-era manifests takes more than changing apiVersion: apps/v1 requires a selector, kubectl convert writes the old defaults (infinite progressDeadlineSeconds, maxUnavailable: 1) into the manifest, and Ingress backends changed shape. With real output we see that the dockershim removal didn't affect Docker images, replace PodSecurityPolicy with Pod Security Admission and scan with pluto.
Version note: The examples in this post were run with kind 0.33 and Kubernetes 1.37, kubectl 1.37, kubectl-convert 1.37.2 and pluto 5.24.5.
Guess First
You find a Deployment manifest in a blog post from 2019; apiVersion: extensions/v1beta1. Today's cluster rejects it, so you convert it to apps/v1 with the official tool, kubectl convert. The output is clean, the cluster accepts it, the pods come up. The question: does the converted Deployment behave the same as a modern Deployment written by hand with the same fields? A hint: kubectl convert doesn't only change the apiVersion, it also writes the old API's (Application Programming Interface) default values into the manifest. Which values those are, we'll see below with real output.
Why Old Manifests Keep Showing Up
The years with the most Kubernetes tutorials were 2018–2021. Stack Overflow answers, first editions of books, internal wikis and Helm charts nobody has touched for years use the APIs of that era. Kubernetes' deprecation policy keeps beta APIs working for at least 9 months or 3 releases after the deprecation announcement; then it removes them. For a removed API version, the cluster knows nothing anymore:
$ kubectl apply -f old/api-deployment.yaml
error: resource mapping not found for name: "api" namespace: "bookstore" from "old/api-deployment.yaml": no matches for kind "Deployment" in version "extensions/v1beta1"
ensure CRDs are installed first
The second line of the error is misleading: you don't need to install a CRD (CustomResourceDefinition); the point is that Deployment now lives in apps/v1. In this post we look at which lines to watch for when reading an old manifest, which changes don't end with changing the apiVersion, and how to scan for them automatically. You don't have to memorize when each API was removed; the table below and the official deprecation guide are enough.
| Old | New | Removed in | Only apiVersion? |
|---|---|---|---|
extensions/v1beta1, apps/v1beta1, apps/v1beta2 Deployment, DaemonSet, ReplicaSet | apps/v1 | 1.16 | No: selector is required and immutable, defaults differ |
extensions/v1beta1, networking.k8s.io/v1beta1 Ingress | networking.k8s.io/v1 | 1.22 | No: backend structure and pathType changed |
rbac.authorization.k8s.io/v1beta1, apiextensions.k8s.io/v1beta1 (CRD) | v1 | 1.22 | Yes for RBAC (Role-Based Access Control); no for CRDs, a schema is required |
batch/v1beta1 CronJob | batch/v1 | 1.25 | Yes; new fields like timeZone came later |
policy/v1beta1 PodDisruptionBudget | policy/v1 | 1.25 | Almost: the meaning of an empty selector changed |
policy/v1beta1 PodSecurityPolicy | No counterpart; Pod Security Admission | 1.25 | No: an entirely different mechanism |
autoscaling/v2beta1, autoscaling/v2beta2 HPA (Horizontal Pod Autoscaler) | autoscaling/v2 | 1.25 and 1.26 | Yes from v2beta2; from v2beta1 the metric structure differs |
Deployment: From extensions/v1beta1 to apps/v1
A typical manifest from 2019:
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: api
namespace: bookstore
spec:
replicas: 2
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: bookstore-api:1.0.0
ports:
- containerPort: 8080
Notice: there's no selector. The old API derived it from the template's labels. The first reflex is to change the apiVersion to apps/v1 by hand:
The Deployment "api" is invalid:
* spec.selector: Required value
* spec.template.metadata.labels: Invalid value: {"app":"api"}: `selector` does not match template `labels`
In apps/v1 the selector is required and can't be changed once created (that's why the commonLabels trap in the fourteenth post was so expensive). kubectl convert fills in the missing field; it no longer ships inside kubectl but is installed as a separate plugin (kubectl-convert), and with --local it works without going to the cluster:
$ kubectl convert -f old/api-deployment.yaml --local -o yaml
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: api
name: api
namespace: bookstore
spec:
progressDeadlineSeconds: 2147483647
replicas: 2
revisionHistoryLimit: 2147483647
selector:
matchLabels:
app: api
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
type: RollingUpdate
...
Here is the answer to Guess First. The selector came, but with it came the old API's defaults. We compared them with the values the API server assigns when the same Deployment is created from scratch with apps/v1 today:
# after applying the kubectl convert output
2147483647 2147483647 {"maxSurge":1,"maxUnavailable":1}
# a Deployment created from scratch with apps/v1
600 10 {"maxSurge":"25%","maxUnavailable":"25%"}
All three differences change behavior. progressDeadlineSeconds: 2147483647 means a stuck rollout never becomes ProgressDeadlineExceeded; in the fourth post of the series we tied a rollout counting as failed to this field, and the pipeline's kubectl rollout status waits forever too. revisionHistoryLimit: 2147483647 piles up a ReplicaSet for every deploy. And maxUnavailable: 1 can take down half the capacity at once during a rolling update of a two-replica Deployment. Don't commit the converter's output as is; delete its defaults and keep only the fields you really want.
Ingress: networking.k8s.io/v1 and ingress.class
An old Ingress:
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: api
namespace: bookstore
annotations:
kubernetes.io/ingress.class: nginx
spec:
rules:
- host: bookstore.local
http:
paths:
- path: /api
backend:
serviceName: api
servicePort: 80
kubectl convert translated the backend to the new structure and added the required pathType:
spec:
rules:
- host: bookstore.local
http:
paths:
- backend:
service:
name: api
port:
number: 80
path: /api
pathType: ImplementationSpecific
ImplementationSpecific leaves path matching up to the controller; the same manifest can behave differently on two different controllers. For portability choose Prefix or Exact. On apply, the cluster gave one more warning:
Warning: annotation "kubernetes.io/ingress.class" is deprecated, please use 'spec.ingressClassName' instead
ingress.networking.k8s.io/api created
NAME CLASS HOSTS ADDRESS PORTS AGE
api <none> bookstore.local 80 0s
The CLASS column is empty: some controllers still read the annotation, but newly written controllers only look at spec.ingressClassName. The bigger picture is in the sixth post of the series: the Ingress API is frozen, ingress-nginx was retired in March 2026, and the path for new setups is Gateway API. There's an ingress2gateway tool to convert an old Ingress to Gateway API; we look at its current state in the next post.
The dockershim Removal: Your Docker Images Keep Working
With Kubernetes 1.24 (May 2022), dockershim, the layer the kubelet used to talk to Docker Engine, was removed. Back then "Kubernetes is dropping Docker" headlines were widely discussed and widely misunderstood. What changed is the container runtime on the node: the kubelet now talks to a CRI (Container Runtime Interface) compatible runtime, mostly containerd:
NAME VERSION RUNTIME
k8swork2-1-control-plane v1.37.0 containerd://2.3.4
k8swork2-1-worker v1.37.0 containerd://2.3.4
k8swork2-1-worker2 v1.37.0 containerd://2.3.4
We built every image in this series with Docker and all of them ran fine on containerd: images produced by Docker follow the OCI (Open Container Initiative) standard. What really breaks in old manifests is something else: CI (Continuous Integration) pods that mount the node's /var/run/docker.sock to build images "Docker-in-Docker" style, monitoring agents that look at the node's containers with docker ps, and old log collectors that read Docker's log file path. If you see such a hostPath, move the image build out of the cluster (to a CI runner) or to a tool that doesn't need a daemon. containerd 1.x support has ended too: Kubernetes 1.35 was the last release to support it.
From PodSecurityPolicy to Pod Security Standards
PSP (PodSecurityPolicy) was deprecated in 1.21 and removed in 1.25. An old PSP manifest:
apiVersion: policy/v1beta1
kind: PodSecurityPolicy
metadata:
name: restricted
spec:
privileged: false
runAsUser:
rule: MustRunAsNonRoot
seLinux:
rule: RunAsAny
supplementalGroups:
rule: RunAsAny
fsGroup:
rule: RunAsAny
volumes: ["configMap", "secret", "emptyDir", "persistentVolumeClaim"]
There's no "convert" counterpart for this, and the tool says so plainly:
error: unable to decode "old/restricted-psp.yaml": no kind "PodSecurityPolicy" is registered for version "policy/v1beta1" in scheme "pkg/api/legacyscheme/scheme.go:30"
PSP was a hard-to-predict mechanism: it could mutate pods, and which policy applied depended on RBAC. Its replacement, PSA (Pod Security Admission), works with the namespace labels we used in the fifteenth post and doesn't change the pod; it only admits or rejects. When translating, look at what the PSP allows and pick the closest PSS (Pod Security Standards) level: the PSP above is close to restricted with runAsNonRoot and its volume restriction, but restricted also requires a capability drop, seccomp and allowPrivilegeEscalation: false. PSP's job of assigning defaults (e.g. filling in runAsUser automatically) must now be written explicitly in the pod spec; for custom rules PSS doesn't cover, use a policy engine like Kyverno or Gatekeeper, or the built-in ValidatingAdmissionPolicy.
CronJob, PDB and HPA: Small but Sneaky Differences
Converting a batch/v1beta1 CronJob to batch/v1 really is just an apiVersion change; the converted manifest applied to the cluster without errors (cronjob.batch/cleanup-carts created). But old tutorials don't know timeZone; write the time zone explicitly as in the thirteenth post. In a policy/v1 PodDisruptionBudget (PDB) an empty selector ({}) now selects every pod in the namespace; in v1beta1 it selected none. On the HPA side, the targetAverageUtilization field of autoscaling/v2beta1 became the target.type: Utilization and target.averageUtilization structure in v2; the HPA in the eleventh post uses this new form. And kubectl itself: the kubectl run --replicas=3 command from old tutorials hasn't created a Deployment since 1.18, only a single pod; for a Deployment there's kubectl create deployment.
Automating the Scan: pluto
Instead of reading a repo full of manifests one by one, we scan them with pluto. For the five old files above:
$ pluto detect-files -d old -o wide
NAME NAMESPACE KIND VERSION REPLACEMENT DEPRECATED DEPRECATED IN REMOVED REMOVED IN REPL AVAIL REPL AVAIL IN
api bookstore Deployment extensions/v1beta1 apps/v1 true v1.9.0 true v1.16.0 true v1.9.0
api bookstore Ingress extensions/v1beta1 networking.k8s.io/v1 true v1.14.0 true v1.22.0 true v1.19.0
api bookstore PodDisruptionBudget policy/v1beta1 policy/v1 true v1.21.0 true v1.25.0 true v1.21.0
cleanup-carts bookstore CronJob batch/v1beta1 batch/v1 true v1.21.0 true v1.25.0 true v1.21.0
restricted <UNKNOWN> PodSecurityPolicy policy/v1beta1 true v1.21.0 true v1.25.0 false
The REPL AVAIL IN column says since which version the new API has existed; it matters if you support old and new clusters together. When pluto finds something it returns a non-zero exit code (4 for us), so putting it in CI as a step is enough. There's pluto detect-helm for Helm charts and pluto detect-api-resources for objects in the cluster; but as we saw in the previous post, only the API server metric shows clients that talk to the cluster with old APIs. The official source is the Kubernetes Deprecated API Migration Guide page, which lists which API was removed in which version.
Production Notes
When you convert an old manifest, review it like a new one: delete the defaults the converter wrote, bring selectors and labels to the series' standard (app.kubernetes.io/name), add probes, resources and a securityContext. Don't worry separately about objects with old APIs in a running cluster: the API server keeps them in its storage version and serves them through the new API; what breaks are the files in the repo and the pipelines that apply them. Before an upgrade, check a pluto scan across all repos, the deprecated API metric in the cluster and the provider's upgrade insights together. When a selector has to change, you can't fix the Deployment in place; you have to create it under a new name and move traffic, or plan a short outage and delete and recreate it.
Common Pitfalls
Believing the "ensure CRDs are installed first" suggestion in the error message and hunting for CRDs. Changing the apiVersion by hand and forgetting the selector. Committing the kubectl convert output as is and living with an infinite progressDeadlineSeconds and unlimited revision history. Leaving pathType: ImplementationSpecific on an Ingress and getting different matching behavior when the controller changes. Rushing to change Dockerfiles because "Kubernetes dropped Docker"; what really needs scanning are docker.sock mounts. Trying to translate a PSP one-to-one into PSS. Converting an old PDB with an empty selector to policy/v1 and creating a budget that covers the whole namespace. And being surprised that kubectl run --replicas from an old tutorial doesn't create a Deployment.
A Small Experiment
★☆☆ Run pluto detect-files -d . on an old Kubernetes repo you have (or find on GitHub). How many files break, in which versions?
★★☆ Convert this post's old Deployment with kubectl convert, clean out the defaults, and bring it to the series' label standard and the rollout settings from the fourth post. Show the difference between the convert output and the cleaned-up version with kubectl diff.
★★★ Create a policy/v1 PodDisruptionBudget with an empty selector, run two different Deployments in the namespace and interpret the ALLOWED DISRUPTIONS value in the kubectl get pdb output. Then drain a node and see which pods get caught by this budget.
We've come from old APIs to today. In the last post of the series we look from today forward: what's new for application developers in Kubernetes 1.33–1.37, the options after ingress-nginx, and the current state of Gateway API.