Nine tenths of this tutorial still runs exactly as printed, which is what makes the rest worth an hour: one sentence was already false on the day it published, one field stopped being printed, and the one modern switch that promises to ignore finalizer constraints refuses this object¶
Post — Using Finalizers to Control
Deletion, 2021-05-14,
by Aaron Alpar (Kasten) — one author, one vendor, and no release announcement attached: 269 lines,
12,396 bytes, no k8s version in the frontmatter and none in the body. The census row leaves the
version column empty because there is nothing to put in it. This is a tutorial about a mechanism
that was already old in 2021, and it is the only 2021 post in this year's shelf whose commands you
can run without translating them first.
As written
The post opens on a complaint rather than a feature: Deleting objects in Kubernetes can be
challenging. You may think you've deleted something, only to find it still persists. Four questions
are listed at :14-17 — what properties of a resource govern deletion, how finalizers and owner
references impact deletion, how the propagation policy changes the order of deletions, and how
deletion works with examples. Then the constraint that makes the whole thing reproducible, at :19:
all examples will use ConfigMaps and basic shell commands.
The basic delete comes first, as four separate fences at :27-46 — create a ConfigMap, get it,
delete it, get it again and read Error from server (NotFound). The prose at :48 calls that
last result an HTTP 404 error, and says Shell commands preceded by $ are followed by their
output, which is a convention the post then never uses: not one command in any fence carries a
prompt. A state diagram follows as an image, and the section closes by naming the two things that
complicate the picture — finalizers and owner references.
The definition at :61 is the one the exercise is built on. Finalizers are keys on resources that
signal pre-delete operations, they control the garbage collection on resources, and they are
designed to alert controllers what cleanup operations to perform prior to removing a resource. Then
the sentence that makes the rest of the tutorial possible: they don't necessarily name code that
should be executed; finalizers on resources are basically just lists of keys much like annotations.
Like annotations, they can be manipulated. Two real ones are named at :65-66,
kubernetes.io/pv-protection and kubernetes.io/pvc-protection, both described as preventing
accidental deletion of volumes.
The demonstration at :72-81 creates a ConfigMap carrying a single finalizer, the bare word
kubernetes. The post is explicit about why it chose that word: The configmap resource controller
doesn't understand what to do with the kubernetes finalizer key. I term these "dead" finalizers for
configmaps as it is normally used on namespaces. A delete is then issued in the background with
&, jobs shows it still running, and the object comes back from the API server with
deletionGracePeriodSeconds: 0, a deletionTimestamp, the finalizer list intact — and, at :107,
a selfLink. The reading at :111 is exact: the object was updated, not deleted, because
Kubernetes saw that the object contained finalizers and blocked removal of the object from etcd.
The way out, at :116-118, is a JSON patch that removes /metadata/finalizers. The backgrounded
delete then reports Done and the object is gone. A second state diagram follows, and :130 states
the rule: the object will remain in finalization until the controller has removed the finalizer
keys or the finalizers are removed using Kubectl.
Owner references are next, at :134-136: properties on resources that specify the relationship to
one another, so entire trees of resources can be deleted, where An owner reference consists of a
name and a UID, they link resources within the same namespace, and it also needs a UID for that
reference to work. Two experiments follow, both with ConfigMaps and both reproduced character for
character in step 8 below. Deleting the child leaves the parent. Deleting the parent takes both.
:193 names that cascade, says the default is true, offers --cascade=orphan as the way out,
and then carries an inline correction: *Update: starting with kubectl v1.20, the default for
cascade is background.*
The last mechanism is the propagation policy, reached at :212-231 through kubectl proxy and a
curl -X DELETE carrying a DeleteOptions body. The three values are listed at :235-237 —
Foreground: Children are deleted before the parent (post-order), Background: Parent is deleted
before the children (pre-order), Orphan: Owner references are ignored. The sentence introducing
them, at :231, is the one claim in this post that does not survive contact with the post itself:
Note that the propagation policy cannot be specified on the command line using kubectl. You have to
specify it using a custom API call.
Then a coda that is in a different register from everything before it. :241-263 shows how to force
a namespace out of Terminating by PUTing to the namespace's finalize subresource with
spec.finalizers set to null, and immediately warns that this may delete the namespace only and
leave orphan objects within the, now non-exiting, namespace - a confusing state for Kubernetes. The
Key Takeaways at :267 close on caution — there is a reason for adding a finalizer into the code,
so you should always investigate before manually deleting it — followed by an embedded conference
talk, which is the only part of the post this lab cannot reproduce.
As it runs now — almost all of it, and the exceptions are precise enough to be worth naming one at a time. This is the rare post that the documentation did not overtake, absorb or quietly contradict; it is the post the documentation points at.
The tree cites this post, and it is the only blog post the finalizers concept page cites.
concepts/overview/working-with-objects/finalizers.md:100 is the whole of that page's whatsnext: a
single bullet reading Read Using Finalizers to Control Deletion on the Kubernetes blog. Across the
pinned docs tree the post's slug occurs exactly once, and that is where. 102 files under
content/en/docs mention a finalizer — 105 if you count the three feature-gate files — so the
subject is everywhere, and the one place the tree hands a reader off to a blog post for it is the
concept page that defines the word.
Every sentence of the post's definition is now in the concept page, in the post's own order.
finalizers.md:15-18 carries Finalizers don't usually specify the code to execute. Instead, they
are typically lists of keys on a specific resource similar to annotations — the post's :61 with
the hedging removed. :22-30 turns the post's observed behaviour into three stated guarantees: the
API server adds metadata.deletionTimestamp, prevents removal until the finalizers field is
empty, and returns a 202 status code (HTTP "Accepted"). :32-38 is the controller loop the post
describes from the outside. And :40-46 promotes the post's first named example,
kubernetes.io/pv-protection, to the page's only worked example.
The one claim that broke was already broken when it was published, and the post breaks it
itself. :231 says the propagation policy cannot be given on the command line. :193, two
paragraphs earlier, says the default value of --cascade became background — a propagation policy
name, as a flag value — as of kubectl v1.20, which the post dates before its own publication. And
:204 runs kubectl delete --cascade=orphan. At the pin the flag takes all three:
tasks/administer-cluster/use-cascading-deletion.md:61, :119 and :168 give
--cascade=foreground, --cascade=background and --cascade=orphan as the first of two documented
routes, with the post's curl as the second.
The default the post corrected itself about belongs to the client, not to the API.
reference/kubectl/generated/kubectl_delete/_index.md:101 is where background actually lives:
--cascade string[="background"] with Default: "background". The API's own account is different.
reference/kubernetes-api/definitions/delete-options-v1-meta.md says of propagationPolicy that
The default policy is decided by the existing finalizer set in the metadata.finalizers and the
resource-specific default policy. Both sentences are true and they are about different programs.
One line of the post's printed output is no longer printed. The YAML at :95-109 includes
selfLink: /api/v1/namespaces/default/configmaps/mymap.
reference/kubernetes-api/definitions/object-meta-v1-meta.md:92 now reads Deprecated: selfLink is
a legacy read-only field that is no longer populated by the system, and says the same thing about
list metadata at list-meta-v1-meta.md:52. Everything else in that block — the timestamps, the
grace period, the finalizer list, the uid, the resourceVersion — comes back unchanged.
The field the post's curl was built to avoid is still deprecated toward a release that happened
nineteen releases ago. DeleteOptions still carries orphanDependents, and its description still
reads Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. That
sentence occurs 136 times across 69 files in the pinned API reference, in a tree whose newest
release is v1.37. The post never mentions the field, which is the right call, and the tree has not
been able to stop mentioning it.
The three options became two types and an override.
concepts/architecture/garbage-collection.md:58-67 says there are two types of cascading deletion
and lists Foreground and Background. Orphaning gets its own subsection at :110-114, as a behaviour
you override rather than a policy you choose. The API has not changed — propagationPolicy still
takes the post's three words — so this is a taxonomy edit, and it is the only place where reading
the pin first would leave you worse prepared for the post than reading the post first.
The tree names the foreground finalizer twice and spells it two different ways.
garbage-collection.md:80-81 says the API server sets metadata.finalizers to
foregroundDeletion, and use-cascading-deletion.md:80-94 shows it inside a captured API response,
so that spelling is attested by an actual server. owners-dependents.md:79 says it adds the
foreground finalizer. Step 9 reads the name off your own cluster, which is the only way to settle
it from inside the lab.
The glossary calls a finalizer a namespaced key; the tree shows three that are not.
reference/glossary/finalizer.md:6 and :13 both say namespaced keys, and finalizers.md:59-60
sharpens it into a rule: Custom finalizer names must be publicly qualified finalizer names, such as
example.com/finalizer-name, and the API server rejects writes to objects where the change does not
use qualified finalizer names for any custom finalizer. The word carrying that sentence is
custom. The post's kubernetes is not custom, and neither are the foregroundDeletion and
orphan that use-cascading-deletion.md:87 and :180 show the server writing by itself. Step 2
walks the boundary with three names.
Two rules the post demonstrates without stating are now stated. finalizers.md:49-55: once the
deletion timestamp is set the API server immediately starts to restrict changes to the
.metadata.finalizers field, so You can remove existing finalizers ... but you cannot add a new
finalizer, you cannot modify the deletionTimestamp, and After the deletion is requested, you can
not resurrect this object. The post's patch works because removal is the one edit still allowed.
Finalizer ordering is documented now, and the reason is the post's own failure mode.
reference/using-api/api-concepts.md:1126-1143 says external controllers may act at any time, in
any order, that order is not enforced between finalizers because it would introduce significant
risk of stuck .metadata.finalizers, and that the list is shared: any actor with permission can
reorder it. The deadlock it describes — one finalizer's owner waiting on a signal from another's —
is the multi-holder version of the single dead key the post invents by hand.
The owner reference grew a field, a guard and a failure mode.
concepts/overview/working-with-objects/owners-dependents.md:33-44 documents
ownerReferences.blockOwnerDeletion, set to true automatically when a controller writes the owner
reference, and says A Kubernetes admission controller controls user access to change this field for
dependent resources, based on the delete permissions of the owner. :46-62 — repeated word for
word at garbage-collection.md:40-56 — turns the post's link resources within the same namespace
into enforcement: cross-namespace owner references are disallowed by design, a namespaced owner
must exist in the same namespace as the dependent, and if it does not, the owner reference is
treated as absent, and the dependent is subject to deletion once all owners are verified absent.
Since v1.20 a warning Event with the reason OwnerRefInvalidNamespace is reported for the invalid
dependent.
The post's last technique is the only thing in it the tree does not document at all. The
finalize subresource occurs exactly once in content/en at the pin, and the occurrence is this
post. No task page, no concept page and no API reference page in the tree shows a reader how to
force a namespace out of Terminating, and finalizers.md:89-96 says the opposite in a note: In
cases where objects are stuck in a deleting state, avoid manually removing finalizers to allow
deletion to continue ... This should only be done when the purpose of the finalizer is understood
and is accomplished in another way. That is the post's own Key Takeaways, promoted into the
documentation and then pointed back at the post that carries the recipe it warns you about.
There is now a gated, permissioned, audited way to ignore finalizers, and it will refuse this
post's object. api-concepts.md:1145-1185 adds a Force deletion section: the delete option
ignoreStoreReadErrorWithClusterBreakingPotential, available since Kubernetes v1.37 by default,
which ignores finalizer constraints, and skips precondition checks. It needs both the delete
and unsafe-delete-ignore-read-errors verbs on the resource. It applies only to a resource that
can not be successfully retrieved from the storage — decryption failure or a decode failure — and
:1182-1185 closes the door on everything else: If the user issues a delete request with
ignoreStoreReadErrorWithClusterBreakingPotential set to true on an otherwise readable resource, the
API server aborts the request with an error. A ConfigMap held by a dead finalizer is perfectly
readable.
What this exercise does not cover. The conference talk embedded at :269 is outside the pin's
text. The two state diagrams are images, and this lab replaces them with the API responses they
draw. kubernetes.io/pvc-protection, the post's second named finalizer, is already walked in the
CSI beta exercise, and the finalizer that makes a
PersistentVolume's reclaim policy honour its claim is the subject of a later row in this same year —
that row owns it, and this exercise stays on ConfigMaps, exactly as the post does. Release facts and
the pressure behind each are in
research/blog-era-translation.md.
The diff, and why — four of the seven cases, and the dominant one is the one this archive records least often.
What is still right is almost everything, and it is right in the strongest available sense: the documentation now says it. The mechanism, the vocabulary, the worked example and the escape hatch all survived from 2021 to v1.37 without a rename, a group change or a gate. Every command in the post's finalizer sections runs verbatim, and the tree's own concept page ends by sending its reader here. In an archive where most five-year-old posts need translating before they can be executed, this is the control case — the one that shows how much of the churn elsewhere was churn rather than progress.
It was also, three times over, load-bearing. Three later posts in the blog archive — the
December 2021 PersistentVolume leak announcement, the 2024 beta announcement and the 2025 GA
announcement for the same fix — each end their finalizer paragraph with a character-identical
sentence: To learn more about finalizers, please refer to Using Finalizers to Control Deletion.
Four years apart, copied unchanged, because the thing being explained did not change either. That is
the test for the never absorbed case run in reverse, and this post fails it in the best way: it
was absorbed, it is cited inline, and the citation is load-bearing.
Wrong when it was published is the rarest case in this archive, and it applies to exactly one
sentence here. :231 tells the reader that the propagation policy cannot be specified on the
command line using kubectl, in a post whose own inline update names a propagation policy as the
default value of a kubectl flag, and which runs that flag with a policy name twenty lines earlier.
It is not a claim that aged: it contradicted the adjacent paragraph on the day of publication. What
makes it worth an hour is why it survived review — the API-first route was true, complete and
demonstrable, and nobody checks a sentence that introduces a working example.
The post broke in one line of output and nowhere else. selfLink was a real field in 2021 and
is a tombstone in the API reference at the pin. Nothing the post teaches depends on it; it is simply
there in the printed block, and a reader diffing their terminal against the page will find it
missing and have to go and discover why. That is the whole of the broke case for this post, which
is a remarkable sentence to be able to write about a 2021 tutorial.
Overtaken by stasis attaches to orphanDependents, the field the post's curl route exists to
replace. It has been deprecated in 1.7 for nineteen releases, the deprecation notice is still
copied into 69 files of generated API reference, and nothing has removed it. The post routed around
a field that was already legacy and is still legacy; the propagation policy it recommended instead
is now the documented default route in kubectl as well, which is the shape of a recommendation
that won without anything being deleted.
The ladder
Two gates, transcribed from their stages: lists parsed as YAML. Neither is the post's — the post
has no gate and never could, because finalizers are not a feature you enable. Both are gates the pin
added to the machinery the post describes, and both are readable only because the exercise provokes
them.
AllowUnsafeMalformedObjectDeletion alpha false 1.32 - 1.36
beta true 1.37 -
OrderedNamespaceDeletion beta false 1.30 - 1.32
beta true 1.33 - 1.33
stable true 1.34 - locked
The first gate is the one the Force deletion section depends on, and the pin catches it mid step:
five releases of alpha, then the beta row whose fromVersion is 1.37, the newest release the pin
knows. Read that as an instruction to check your own server before you predict step 6's result — on
v1.36 the option is not there at all, and on v1.37 it is there and refuses you. Its body text is the
plainest statement in the directory of what it buys: Enables the cluster operator to identify
corrupt resource(s) using the list operation, and introduces an option
ignoreStoreReadErrorWithClusterBreakingPotential that the operator can set to perform unsafe and
force delete operation of such corrupt resource(s) using the Kubernetes API.
The second gate has the shape worth learning to recognise: two consecutive beta rows whose only
difference is defaultValue, then stable with locked — the switch exists, then it is on, then
it is welded on. locked is the key the pin actually uses, on 51 stage entries across 50 of the 487
gate files, and the reason the distinction matters is walked in the PID-limiting
exercise. Its body is one sentence: While deleting namespace, the pods
resources is going to be deleted before the rest of resources. Typo and all, that is the only
account the tree gives of the ordering, and it is the ordering step 10 leans on when a namespace
refuses to finish.
Topology — solo: one node at 10.10.10.180, 4096MB, 4
CPUs, 25G. The post's own constraint at :19 is the lab's: all examples will use ConfigMaps and
basic shell commands, so nothing here schedules a Pod, pulls an image or sends a packet between
nodes. Every observation is a decision kube-apiserver or kube-controller-manager makes about an
object's metadata, which means one node with no workloads is the entire rig and the control-plane
taint can stay exactly where it is. If the guest from the previous exercise in this year is still
up, keep it; if you are starting here, bring it up with the five provision
steps, substituting topology=solo, then ssh
zain@10.10.10.180.
Do
- Establish that the post's opening still holds, command for command. These are the four fences at
:27-46, unchanged:
kubectl create configmap mymap
kubectl get configmap/mymap
kubectl delete configmap/mymap
kubectl get configmap/mymap
- Walk the boundary of the rule at
finalizers.md:59-60with three names. The first block is the post's:72-81, character for character; the other two change one word each:
cat <<EOF | kubectl create -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: mymap
finalizers:
- kubernetes
EOF
cat <<EOF | kubectl create -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: bare-custom
finalizers:
- mycompany
EOF
cat <<EOF | kubectl create -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: qualified-custom
finalizers:
- example.com/finalizer-name
EOF
- Start the proxy the post needs later, delete in the background exactly as
:86-89does, and then read the status code the concept page promises atfinalizers.md:30— once against an object that has a finalizer and once against one that does not:
kubectl proxy --port=8080 &
kubectl delete configmap/mymap &
jobs
kubectl create configmap plain
curl -s -o /dev/null -w 'plain: %{http_code}\n' -X DELETE \
localhost:8080/api/v1/namespaces/default/configmaps/plain
curl -s -o /dev/null -w 'qualified-custom: %{http_code}\n' -X DELETE \
localhost:8080/api/v1/namespaces/default/configmaps/qualified-custom
- Read the object the post says was updated rather than deleted, and diff the block you get against
the post's
:95-109line by line:
kubectl get configmap/mymap -o yaml
kubectl get configmap/mymap -o yaml | grep selfLink || echo "no selfLink in the output"
kubectl get configmap/mymap \
-o jsonpath='{.metadata.deletionTimestamp}{" grace="}{.metadata.deletionGracePeriodSeconds}{" finalizers="}{.metadata.finalizers}{"\n"}'
- Test the two restrictions the post never states and
finalizers.md:49-55does. Both of these are edits to an object that is already pending deletion:
kubectl patch configmap/mymap --type merge \
-p '{"metadata":{"finalizers":["kubernetes","example.com/second"]}}'
kubectl patch configmap/mymap --type merge \
-p '{"metadata":{"deletionTimestamp":null}}'
kubectl get configmap/mymap -o jsonpath='{.metadata.finalizers}{"\n"}'
- Reach for the only switch in the API that advertises ignoring finalizer constraints. Check your server's release against the ladder first, because the option's availability is the ladder's top row:
kubectl version -o yaml | grep -A5 serverVersion
kubectl auth can-i unsafe-delete-ignore-read-errors configmaps
curl -s -X DELETE localhost:8080/api/v1/namespaces/default/configmaps/mymap \
-H "Content-Type: application/json" \
-d '{"kind":"DeleteOptions","apiVersion":"v1","ignoreStoreReadErrorWithClusterBreakingPotential":true}'
- Finish the delete the post's way. This is
:116-118verbatim, andjobsis the point of it:
kubectl patch configmap/mymap \
--type json \
--patch='[ { "op": "remove", "path": "/metadata/finalizers" } ]'
kubectl get configmap/mymap -o yaml
jobs
- Run the post's two owner-reference experiments, then a third it sets up and never tries. The
first block is
:141-159and:164-175; the second re-creates the pair and deletes from the top; the third gives a child auidthat belongs to nothing:
cat <<EOF | kubectl create -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: mymap-parent
EOF
CM_UID=$(kubectl get configmap mymap-parent -o jsonpath="{.metadata.uid}")
cat <<EOF | kubectl create -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: mymap-child
ownerReferences:
- apiVersion: v1
kind: ConfigMap
name: mymap-parent
uid: $CM_UID
EOF
kubectl get configmap
kubectl delete configmap/mymap-child
kubectl get configmap
Then the same pair again, deleted from the parent, and finally the orphan case:
cat <<EOF | kubectl create -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: mymap-child
ownerReferences:
- apiVersion: v1
kind: ConfigMap
name: mymap-parent
uid: $CM_UID
EOF
kubectl delete configmap/mymap-parent
kubectl get configmap
cat <<'EOF' | kubectl create -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: no-such-owner
ownerReferences:
- apiVersion: v1
kind: ConfigMap
name: ghost
uid: 00000000-0000-0000-0000-000000000000
EOF
sleep 30
kubectl get configmap no-such-owner
kubectl get events -A --field-selector=reason=OwnerRefInvalidNamespace
- Put both halves of the post together and make a foreground delete hang, which is the only way to
read the finalizer the API server writes for itself. The child here carries both a dead finalizer
and
blockOwnerDeletion:
kubectl create configmap owner
UID=$(kubectl get configmap owner -o jsonpath="{.metadata.uid}")
cat <<EOF | kubectl create -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: blocker
finalizers:
- example.com/never-clears
ownerReferences:
- apiVersion: v1
kind: ConfigMap
name: owner
uid: $UID
blockOwnerDeletion: true
EOF
kubectl delete configmap owner --cascade=foreground --wait=false
kubectl get configmap owner -o jsonpath='{.metadata.finalizers}{"\n"}'
kubectl get configmap owner blocker
kubectl patch configmap/blocker --type json \
--patch='[ { "op": "remove", "path": "/metadata/finalizers" } ]'
kubectl get configmap
-
Run the coda. A namespace with one stuck object in it, and then the
PUTfrom:246-260, which is the one technique in this post that appears nowhere in the pinned documentation:kubectl create namespace test cat <<'EOF' | kubectl create -f - apiVersion: v1 kind: ConfigMap metadata: name: stuck namespace: test finalizers: - example.com/never-clears EOF kubectl delete namespace test --wait=false kubectl get namespace test \ -o jsonpath='{.status.phase}{" spec.finalizers="}{.spec.finalizers}{"\n"}' cat <<EOF | curl -X PUT \ localhost:8080/api/v1/namespaces/test/finalize \ -H "Content-Type: application/json" \ --data-binary @- { "kind": "Namespace", "apiVersion": "v1", "metadata": { "name": "test" }, "spec": { "finalizers": null } } EOF kubectl get namespace test kubectl get configmap -n test kubectl create namespace test kubectl get configmap -n test
Expect
Step 1 reproduces exactly, including the column headers. NAME DATA AGE with mymap 0 and an age
in seconds is what the post printed and what you get, and the last command prints Error from server
(NotFound): configmaps "mymap" not found — the same string, five years and sixteen releases later.
The post calls that an HTTP 404 error; kubectl does not print a code, and step 3 is where you
get to see one.
Step 2 is the rule at finalizers.md:59-60 held against three inputs, and the sentence's weight is
on the word custom. mymap with the bare word kubernetes is the post's own manifest and the
census row's claim that it still applies verbatim; qualified-custom carries the exact form the
page recommends. bare-custom is the one under test: an unqualified name that no part of Kubernetes
owns. Write down which of the three the API server takes and quote the error for any it refuses,
because that message is the only statement of the rule you will get from a running cluster — the
tree states it in prose, on one page, in a note.
Step 3: jobs shows two entries now, the proxy and the delete, and the delete stays Running
exactly as :89 shows. The two status codes are the finding. Deleting plain, which has no
finalizer, completes; deleting qualified-custom, which has one, returns 202 — the 202 status
code (HTTP "Accepted") that finalizers.md:30 promises and that no kubectl output in the post
could have shown you. That is the difference between deleted and accepted for deletion, printed
as a number.
Step 4 is the diff the exercise exists for. deletionGracePeriodSeconds: 0, a deletionTimestamp,
finalizers: [kubernetes], name, namespace, resourceVersion, uid — all present, all in the
post's own shape. The grep fires its fallback: selfLink is gone, because the API reference at
object-meta-v1-meta.md:92 marks it a legacy field no longer populated by the system. One line of
a fourteen-line block, and it is the only line of the post's output that a current cluster will not
give you.
Step 5: both patches are refused, and the reasons are different. Adding example.com/second to an
object that already has a deletionTimestamp runs into the restriction at finalizers.md:49-53 —
removal is allowed, addition is not — and clearing the timestamp runs into the next clause of the
same note. The third command confirms the list is untouched. Read that pair as the reason the post's
escape hatch is a remove operation and not an edit: there is exactly one legal move on a dying
object, and the post found it.
Step 6 is the modern hammer refusing to be one. If serverVersion reads v1.37 or later the gate is
on by default, can-i answers yes for a cluster-admin, the option is accepted by the decoder —
and the request still fails, because api-concepts.md:1182-1185 says the API server aborts the
request with an error when the target is readable. Your ConfigMap is readable; it is held by a key,
not by a corrupt row in etcd. On a server older than v1.37 you get a different refusal, from the
gate rather than from the check. Either way the sentence to carry out of this step is that the one
documented way to ignore finalizer constraints is scoped to objects the API server cannot decode,
and there is still no API for the post's problem.
Step 7 is the post's ending and it is unchanged. The JSON patch returns configmap/mymap patched,
the backgrounded delete from step 3 reports Done, and the get returns NotFound. Two commands,
one of which was already running, and the object leaves etcd — :130 in prose, on your terminal,
verbatim.
Step 8: the first block prints both ConfigMaps, deletes the child, and leaves mymap-parent alone,
exactly as :164-175 shows. The second deletes the parent and takes the child with it — No
resources found in default namespace. is the post's line and it is still the output. The third is
the experiment the post sets up with it also needs a UID for that reference to work and never
runs: a child whose owner does not exist is deleted by the garbage collector within a minute,
unasked, because owners-dependents.md:50-51 treats an unresolvable reference as an absent owner
and an object with no owners as garbage. The Event query returns nothing here — that reason is
reserved for cross-namespace and cluster-scoped mistakes, not for a uid that matches nothing — and
a get that returns NotFound on an object you just created is the point. The post's owner
references are not a link to a parent; they are a claim to be checked, and failing the check is
fatal.
Step 9 settles the spelling. owner goes into foreground deletion, and its own finalizers list
comes back holding one entry written by the API server, not by you. Two of the three places the tree
names it — garbage-collection.md:80-81 and the captured response at
use-cascading-deletion.md:80-94 — say foregroundDeletion; owners-dependents.md:79 says
foreground. Your cluster is the tiebreaker, and it is also the answer to the sentence at :231:
the flag you just used put a propagation policy on the command line, which the post says you cannot
do. Both objects stay listed until you remove the child's dead finalizer, and then both disappear in
the same second — post-order deletion, demonstrated by preventing it.
Step 10 ends somewhere the documentation will not follow you. The namespace goes to Terminating
with spec.finalizers still ["kubernetes"], and it stays there, because the namespace controller
cannot finish while one ConfigMap inside it is held by a key nothing will remove. The PUT returns
the namespace object with spec.finalizers cleared, and the namespace disappears from get
namespace — the whole of the post's recipe, working. Then read the next two lines carefully and
write down what they say, because that state is the post's own warning at :263 made real: an
object with a deletionTimestamp, a finalizer and a namespace that does not exist. Recreating the
namespace is the last command, and whether your ConfigMap comes back into view is the question the
post leaves open with sometimes. Either way you now own the mess that the namespaces
exercise declined to make, and its advice —
look for the finalizer before you look for a bigger hammer — reads differently from this side of it.
Read on
-
reference/using-api/api-concepts.md:1126-1143is the paragraph the post could not have written: why finalizer processing has no defined order, and why giving it one would create stuck objects rather than prevent them. Read it with step 5 in mind. The post's single dead key is the degenerate case of a deadlock between two live ones, and the reason the API server refuses to let you add a finalizer after the timestamp is set is the reason that deadlock has to be resolved by the holders rather than by the server. -
concepts/overview/working-with-objects/owners-dependents.md:33-44andconcepts/architecture/garbage-collection.md:90-94are the two reasons a foreground delete stalls that the post never mentions — a dependent withblockOwnerDeletion: true, and a dependent the garbage collection controller's cache does not hold because its typecannot be listed / watched successfully. Step 9 builds the first on purpose. The second is what a stuck delete looks like when nobody did anything wrong. -
The citation chain is worth following outside this exercise. Three release announcements in the blog archive — December 2021, 2024 and 2025, all three about making a PersistentVolume's reclaim policy survive an out-of-order delete — end their finalizer paragraph with the same sentence pointing here, byte for byte identical across four years. The 2021 one is a
walkin this very year and a later row owns it. Readfinalizers.md:89-96before you get there, because the note that tells you to avoid removing finalizers by hand is the post's Key Takeaways promoted to documentation, and the feature those three posts announce is a finalizer added specifically so that nobody has to. -
kubernetes.io/pvc-protection, the second finalizer the post names at:66, is walked where it belongs — in the CSI beta exercise, where a half-deleted PersistentVolume is a thing you have to clean up rather than a name in a list. The namespace half of step 10 is walked backwards in the namespaces exercise, which reads.spec.finalizersand.status.phaseon a namespace that is deleting properly. Between them and this exercise, every finalizer the post names has been seen doing its job. -
Unanswerable from the pin: which unqualified finalizer names the API server accepts. The tree shows three —
kubernetesin this post,orphanandforegroundDeletionin the captured responses onuse-cascading-deletion.md— and states the rule only for the custom case, so whether those three are a closed set, and what the error is for a fourth, is something step 2 measures and no page at the pin declares. Also unanswerable: whichkubectlrelease turned--cascadeinto a string taking policy names, the fact that would date the wrong sentence at:231precisely. The pin's own task page carries an unresolved authoring note asking exactly that, which the StatefulSets and DaemonSets exercise reads in place.
Teardown
One object here cannot be deleted the ordinary way, which is the exercise's own fault and the right place to finish: patch the finalizer off the orphan from step 10 first, then let the namespace go normally.
kubectl patch configmap/stuck -n test --type json \
--patch='[ { "op": "remove", "path": "/metadata/finalizers" } ]'
kubectl delete namespace test
kubectl delete configmap --all
jobs
kill %1
kubectl get configmap,namespaces
If the patch reports that the ConfigMap is not found, the re-created namespace never brought it
back into view and it left with the namespace; either result is a finding worth keeping. kill %1
stops the proxy. Nothing else on the node was touched, and no Pod ever ran — leave the guest up, the
next exercise in this year reuses it.