Reported symptoms
The payments-api workload is moving from a cluster built three years
ago onto a freshly installed one. The manifests were copied across
unchanged, which is the whole point of the exercise. The application
authenticates to the estate secret manager using its Kubernetes
workload identity and reads its database configuration from there.
The migration window opened at 09:00 UTC. What follows is one incident that presented as three.
- 09:02. The Deployment is applied. Every Pod sits in
ContainerCreatingand never starts a container. The manifest has run unchanged for three years, so the manifest is assumed innocent and the new cluster is assumed guilty. - 09:11. Somebody points the workload at the token that every Pod already has, at the standard projected path. The Pods start within seconds. The channel records the issue as resolved.
- 09:14. The application is running and its readiness endpoint says it cannot reach its configuration store. The secret manager is logging a rejected login for this workload identity, once per retry.
- 09:40. A second change declares a proper projected token with the audience the secret manager requires. Pods start at 09:44, authenticate, and serve customer traffic. The window closes.
- 10:44. Every Pod in the deployment fails to authenticate, at the same second, with no deployment, no restart and no change in the intervening hour.
The last one is the interesting one. Nothing happened at 10:44. The thing that happened was at 09:44, exactly sixty minutes earlier.
Evidence provided
$ kubectl -n payments describe pod -l app=payments-api$ kubectl -n payments get secret$ kubectl -n payments get serviceaccount payments-api -o yamlThe manifest that has run unchanged for three years is this:
volumes:
- name: sa-token
secret:
secretName: payments-api-token
containers:
- name: api
volumeMounts:
- name: sa-token
mountPath: /var/run/secrets/payments
readOnly: true
That manifest never created anything. It consumed a Secret that older clusters produced automatically for every ServiceAccount. On this cluster nothing produces it, so the mount has nothing to mount.
$ TOKEN=$(kubectl -n payments exec deploy/payments-api -- cat /var/run/secrets/kubernetes.io/serviceaccount/token)
printf '%s' "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '.aud, .exp'$ kubectl -n payments create token payments-api --audience openbao --duration 10mThe corrected volume, applied at 09:40:
volumes:
- name: bao-token
projected:
sources:
- serviceAccountToken:
path: bao-token
audience: openbao
expirationSeconds: 3600
$ kubectl -n payments exec deploy/payments-api -- ls -l --time-style=full-iso /var/run/secrets/bao/bao-tokenWork the evidence before reading on
Two of the three failures were introduced by the repair for the previous one. That is worth sitting with before reading the answer.
- The namespace contains no Secret of the service account token type and none was deleted. What changed between the two cluster generations, and why did nothing in the migration plan catch it?
- A Pod that starts successfully and a Pod that can authenticate are different claims. Which of the two did the 09:11 change actually deliver, and what would have exposed the difference in one command?
- A token minted by hand with a different audience works. Given that, which components can you exonerate, and what single field is the entire second failure?
- Nothing happened at 10:44. The token file changed at 10:32 and the container started at 09:44. Reconstruct the arithmetic, and say whose responsibility the missing step is.
Before continuing: name the check that would have failed at 09:45, one minute after everyone agreed the migration was finished.
Root cause
The credential the manifest expects has not been generated for several releases
Older clusters created a long-lived token Secret for every ServiceAccount automatically. That stopped in v1.24, and the feature gate that controlled it was removed in v1.27, so on this 1.37 cluster the behaviour is not merely off, it is gone. The workload identity mechanism is now the TokenRequest API: the admission controller adds a projected volume, the kubelet fetches a time-bound token for the specific Pod, and refreshes it before it expires.
A manifest written against the old mechanism does not fail loudly at
apply time. It fails at mount time, which is why the Pod is stuck in
ContainerCreating rather than crash-looping with a useful message.
The projected token is minted for one audience, and the secret manager is a different one
Every Pod already carries a token at the standard path. Its audience defaults to the API server, because that is what it is for. The secret manager is a separate relying party which validates the audience claim and refuses a token that was not minted for it.
That refusal is the system working. An audience-bound token that any relying party would accept is a bearer token for the entire estate: present the API server token to the secret manager, to the message broker, to anything else federated to the same issuer. The audience is what stops one identity from becoming all of them.
The application caches a credential the platform is actively replacing
The corrected volume asks for a one hour token. The kubelet requests a replacement once the token is past eighty per cent of its lifetime, or older than twenty four hours, and rewrites the file in place. Reloading it is explicitly the application’s job.
The application reads the file once, in its start-up path, and keeps the string. So the timeline is exact: container start at 09:44, file replaced at 10:32, cached token expires at 10:44, every Pod fails together because every Pod started together.
Resolution
- Rule out the two shortcuts explicitly before anyone proposes them. Do not create a token Secret by hand: it is a static credential with no expiry and no revocation path. Do not relax the audience requirement at the secret manager: that check is what stops a token minted for the API server being replayed against every other relying party trusting the same issuer.
- Declare a projected
serviceAccountTokenvolume on the Pod with the audience the secret manager expects and an explicitexpirationSeconds, mounted at a path clearly distinct from the API server credential. Inheriting the default expiry is what makes a workload behave differently on two clusters for reasons nobody can see in the manifest. - Delete the legacy Secret volume, its volume mount, and any comment in the manifest that explains the old pattern. A commented-out block is an invitation to restore it during the next incident.
- Change the application to read the token file at each authentication attempt rather than caching the string at start-up. The file lives on a tmpfs mount, so re-reading it costs nothing measurable, and this is the step that actually ends the incident.
- If the application cannot be changed in this window, reload the file on a schedule instead. Every five minutes is comfortably ahead of a rotation that happens at eighty per cent of an hour, and the upstream guidance says plainly that a fixed schedule is usually good enough.
- Roll the deployment so every Pod picks up the new volume, and stagger the roll rather than replacing every Pod in the same minute. Pods that start together rotate together and therefore fail together, which is why 10:44 looked like an event.
- Record which credential mechanism this workload used on the source cluster and which it uses now, in the migration checklist, and repeat that question for every remaining workload before its window opens.
- Leave the incident open until the ninety minute soak in the verification step has passed. Closing it at 09:45 is exactly what happened the first time.
Verification
- Read the secret manager audit records and confirm a successful login attributed to this workload identity, then confirm there are no rejected logins for it after the roll. The relying party is the independent witness; the cluster only knows that it handed out a file.
- Decode the payload of the token the Pod is actually mounting and confirm the audience array contains the secret manager and the expiry matches the value you declared. This distinguishes a token you configured from a token you inherited.
- Confirm the Pods are running and their own readiness endpoint reports the configuration store reachable. At 09:14 the Pods were running and could not authenticate, and only the second signal knew.
- Wait at least ninety minutes with the workload serving. Nothing shorter can observe an hourly failure, and a thirty minute smoke test passed cleanly on the broken build.
- List the token file inside a running container and confirm its modification time is later than the container start time while the application is still authenticating. A rotated file plus an unbroken service is the only evidence that the reload path works.
- Delete one Pod deliberately and confirm its replacement authenticates without help. A workload that only works while somebody is watching a rollout is not fixed.
- Confirm no Secret of the service account token type has appeared in the namespace during the incident. Under pressure, somebody usually creates one, and it will still be there next year.
Prevention
- Declare the expiry, never inherit it. Set
expirationSecondsexplicitly on every projected token. The API server extends admission-injected tokens by default, up to a year, to keep legacy workloads alive during migrations, so a workload can run for months without ever observing a rotation. - Treat it worked before as a question, not as evidence. On the source cluster this workload held a token that never expired. The sentence describes a credential mechanism that no longer exists, and it is the single most misleading input to this incident.
- Pin the audience at every relying party. No third party should accept the API server audience. The check costs nothing and it is the only boundary between one workload identity and all of them.
- Lint the manifests. Fail the pipeline on any reference to a Secret of the service account token type. It is a one-line rule and it would have caught this before the migration window opened.
- Alert on the two signals this incident produced. Any Pod in
ContainerCreatingfor more than five minutes, and rejected logins at the secret manager grouped by workload identity, warning at three failures in five minutes. - Soak for two hours, not thirty minutes. A migrated workload must cross at least two token rotations in staging before promotion. Nothing shorter can see this class of failure, and every workload in the estate is about to be migrated the same way.