Skip to main content
RunBook Academy

← All break/fix scenarios in Kubernetes

advancedkubernetes-cert~50 min

Certificate expired

Reported symptoms

  • At 10:00 UTC every kubectl call from every client begins failing with "tls: failed to verify certificate: x509: certificate has expired or is not yet valid"
  • An engineer who retries with --insecure-skip-tls-verify gets "error: You must be logged in to the server (Unauthorized)" instead, and the channel spends twenty minutes debating whether somebody changed RBAC overnight
  • Every application keeps serving. No Pod has restarted, no external check has flapped, and no customer has reported anything
  • crictl ps on a control-plane node shows kube-apiserver Running with 0 restarts and 47 days of uptime, so nothing has crashed
  • The kubelet journal on all fifteen nodes fills with x509 errors against the API server, while those same nodes keep running every Pod they already had
  • The Grafana cluster dashboards went blank at 10:02 and the first hypothesis in the channel is a Prometheus outage
  • kubeadm certs renew all was run on cp-1 at 10:35 and kubeadm certs check-expiration on that node now reports 364 days on every row, yet every symptom above is unchanged

Evidence

  • · kubeadm certs check-expiration at 10:05 shows every leaf certificate with an expiry timestamp earlier the same morning, and every certificate authority still years away
  • · openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -dates shows notBefore one year and one day ago and notAfter at 10:00 today
  • · date -u and timedatectl on all three control-plane nodes agree with each other and with the reference clock, so this is expiry rather than skew
  • · curl -sk https://127.0.0.1:6443/livez returns ok, while the same request with --cacert /etc/kubernetes/pki/ca.crt fails with certificate has expired
  • · After the renewal on cp-1, openssl s_client -connect 127.0.0.1:6443 still presents a certificate whose notAfter is 10:00 this morning
  • · crictl ps on cp-1 shows the kube-apiserver container start time unchanged by the renewal, still 47 days ago
  • · openssl x509 -in /var/lib/kubelet/pki/kubelet-client-current.pem -noout -dates on a worker shows notAfter about three months away, not today
  • · kubeadm certs check-expiration on cp-2 and cp-3 still shows this morning as the expiry, because the renewal was run on cp-1 only
  • · The cluster was initialised on this date one year ago and kubeadm upgrade has never been run against it
Diagnosis and resolutionclick to reveal

Root cause

kubeadm issues every certificate in the cluster PKI at kubeadm init time with one year of validity, so a cluster that is never upgraded reaches a single minute at which the API server serving certificate, the API server client certificates to etcd and to the kubelets, and the client certificates embedded in admin.conf, controller-manager.conf and scheduler.conf all expire together. Both ends of every control-plane connection failed at once, which is why the reported error depends only on which side noticed first: a client verifying the server certificate reports x509 expired, and the same client told to skip verification gets as far as presenting its own expired certificate and is told it is unauthorised. The second and larger part of the incident is that the obvious remediation appeared to work. kubeadm certs renew rewrites the files under /etc/kubernetes/pki and the kubeconfigs beside them, but the control-plane components read their key material once at startup and never reload it, and each of them is a static Pod that has been running for 47 days. So the files on disk became valid while the process serving port 6443 went on presenting the certificate it loaded in June, and kubeadm certs check-expiration, which reads the files, reported success for a cluster that was still entirely down. The workers are the one part that looked after itself: the kubelet client certificate rotates automatically at 75 percent of its validity, so it has a different and later expiry than the PKI that was issued at init.

Remediation

Confirm the clock before renewing anything. Skew produces the identical symptom from a perfectly good certificate, and if the clock is the fault then renewing certificates burns the incident window and fixes nothing. With the clock confirmed, run kubeadm certs renew all on one control-plane node and then force the static Pods to be recreated, because that is the step the renewal does not perform: move the four manifests out of /etc/kubernetes/manifests, wait for the kubelet file check interval, and move them back. That is what makes each component start a new process that reads the renewed material. Do this one node at a time. Moving all four manifests takes etcd down on that node as well, so on a three-member control plane the sequencing is what keeps a quorum alive while you work, and doing two nodes at once can turn a credential outage into a quorum outage that is far more expensive to unwind. Once one node serves a valid certificate the cluster is usable again, and the remaining nodes can be repaired without time pressure. Finally, remember that every operator kubeconfig is a copy: renewing admin.conf on the node does not touch the file in anyone home directory, and kubectl will keep failing until those copies are replaced. Do not restore etcd and do not run kubeadm reset. The datastore is intact and current; this is a credential outage, and a restore would discard real writes to fix a problem it does not touch.

Verification

Verify the certificate the process is serving, not the certificate on disk, because the difference between those two is the entire incident. On each repaired node, openssl s_client -connect 127.0.0.1:6443 must present a certificate whose notAfter is about a year away, and crictl ps must show a kube-apiserver container whose start time is minutes old rather than weeks. Then confirm the TLS chain end to end rather than with -k: curl with --cacert /etc/kubernetes/pki/ca.crt against the health endpoint must succeed with verification enabled. From a client, kubectl get nodes with a refreshed kubeconfig must return every node Ready, and the kubelet journals must stop producing x509 errors, which is the signal that the nodes accept the new serving certificate. Run kubeadm certs check-expiration on every control-plane node rather than one, since a node missed during the repair reports its own expired dates and is the thing that will fail next. Confirm the controllers are actually leading again by reading the kube-system leases, because a control plane that answers reads is not necessarily one that is reconciling.

Prevention

Alert on residual days per certificate, and alert early enough that the response is a change ticket rather than an incident. The actionable metric is the remaining time, not the expiry timestamp, and a warning at 30 days with a page at 7 turns the anniversary into a maintenance window. Run kubeadm certs check-expiration on every control-plane node rather than one and compare the results, because diverging dates mean a host was re-bootstrapped or repaired out of band and will expire on its own schedule. Understand why most clusters never see this: kubeadm upgrade renews the PKI as a side effect, so a cluster on a regular upgrade cadence keeps resetting the clock without anybody deciding to, and a cluster that has skipped upgrades for a year has two problems rather than one. Write the renewal runbook so that recreating the static Pods is a numbered step and not a footnote, and so that its verification reads the served certificate. A verification that reads the same file the remediation just wrote can only tell you the write succeeded. Keep the recovery path for an expired admin.conf written down, because the moment you need it is the moment you have no working kubectl to look it up with. Keep NTP healthy and monitored on the control plane for the same reason a clock check comes first in the diagnosis.

Reported symptoms

The cluster is three control-plane nodes and twelve workers, built with kubeadm and never upgraded. It has been in production for a year and has been, by every measure anyone tracks, boring.

At 10:00 UTC it stopped answering. The incident channel filled up faster than the facts did:

  • Every kubectl command, from every workstation and from the CI runner, fails immediately: Unable to connect to the server: tls: failed to verify certificate: x509: certificate has expired or is not yet valid.
  • An engineer adds --insecure-skip-tls-verify out of habit and gets a completely different error: error: You must be logged in to the server (Unauthorized). The channel spends twenty minutes on whether an RBAC change went out overnight. It did not.
  • The product is fine. Every application Pod is serving, no container has restarted, and not one external check has flapped.
  • crictl ps on cp-1 shows kube-apiserver Running, 0 restarts, up 47 days. Whatever is broken, the control plane has not crashed.
  • The kubelet journal on all fifteen nodes is filling with x509 errors against the API server — and those same nodes keep every Pod they already had running perfectly.
  • The Grafana cluster dashboards went blank at 10:02. The first hypothesis in the channel is a Prometheus outage, and two people go and look at Prometheus.

At 10:35 somebody finds the answer, or half of it. kubeadm certs check-expiration shows an expiry of 10:00 this morning on every row. They run kubeadm certs renew all on cp-1, re-run check-expiration, and get 364 days on every line.

Nothing changes. Every symptom above is still true at 10:40, and now the team has a working remediation, a passing verification, and a cluster that is still down.

Evidence provided

Read-only / Safecp-1 at 10:05 — one date, every leaf certificate
$ sudo kubeadm certs check-expiration
CERTIFICATE                EXPIRES                  RESIDUAL TIME   EXTERNALLY MANAGED
admin.conf                 Aug 18, 2026 10:00 UTC   <invalid>        no
apiserver                  Aug 18, 2026 10:00 UTC   <invalid>        no
apiserver-etcd-client      Aug 18, 2026 10:00 UTC   <invalid>        no
apiserver-kubelet-client   Aug 18, 2026 10:00 UTC   <invalid>        no
controller-manager.conf    Aug 18, 2026 10:00 UTC   <invalid>        no
etcd-healthcheck-client    Aug 18, 2026 10:00 UTC   <invalid>        no
etcd-peer                  Aug 18, 2026 10:00 UTC   <invalid>        no
etcd-server                Aug 18, 2026 10:00 UTC   <invalid>        no
front-proxy-client         Aug 18, 2026 10:00 UTC   <invalid>        no
scheduler.conf             Aug 18, 2026 10:00 UTC   <invalid>        no

Illustrative output

Read-only / Safecp-1, cp-2, cp-3
$ timedatectl show -p NTPSynchronized -p TimeUSec --value
Read-only / Safecp-1 — with verification on
$ curl -s --cacert /etc/kubernetes/pki/ca.crt https://127.0.0.1:6443/livez
curl: (60) SSL certificate problem: certificate has expired

Illustrative output

Read-only / Safecp-1 — the same endpoint, with verification off
$ curl -sk https://127.0.0.1:6443/livez
ok

Illustrative output

Read-only / Safecp-1 at 10:40, AFTER kubeadm certs renew all reported 364 days
$ openssl s_client -connect 127.0.0.1:6443 -showcerts < /dev/null 2>/dev/null | openssl x509 -noout -subject -dates
subject=CN = kube-apiserver
notBefore=Aug 18 09:55:00 2025 GMT
notAfter=Aug 18 10:00:00 2026 GMT

Illustrative output

Read-only / Safeworker-04 — a different expiry entirely
$ sudo openssl x509 -in /var/lib/kubelet/pki/kubelet-client-current.pem -noout -dates
notBefore=May 21 08:14:00 2026 GMT
notAfter=Nov 21 08:14:00 2026 GMT

Illustrative output

Read-only / Safecp-1 at 10:40 — the renewal did not restart anything
$ sudo crictl ps | grep kube-apiserver
3b7a91ce2f04d  registry.k8s.io/kube-apiserver:v1.34.1  47 days ago  Running  0  kube-apiserver-cp-1

Illustrative output

Work the evidence before reading on

The expiry is not the interesting part. The interesting part is that the correct remediation was applied and verified, and the cluster stayed down.

  1. curl succeeds with -k and fails with --cacert. What does that pair tell you about which component is broken, and which is fine?
  2. Two clients, two completely different errors, one cluster. Trace a TLS handshake and work out which certificate each error is about, and why skipping verification changes the answer.
  3. kubeadm certs check-expiration says 364 days. openssl s_client says the certificate expires at 10:00 this morning. Both commands ran on the same host, seconds apart, and both are correct. What is each one actually reading?
  4. The workers are the only part of the cluster with a valid identity, and their certificate has an expiry three months from now rather than today. What is different about how it was issued?

Before continuing: name the step that stands between a renewed file and a renewed process, and say why no amount of re-running check-expiration can tell you whether it happened.

Root cause

1. One issue date, one expiry

kubeadm init generates the cluster PKI in a single pass and gives every certificate the same one-year validity. That is deliberate: it makes renewal one planned event a year instead of a continuous background process. The cost is that the cluster has exactly one hard deadline, and everything crosses it in the same minute — the API server serving certificate, its client certificates to etcd and to the kubelets, and the client certificates embedded in admin.conf, controller-manager.conf and scheduler.conf.

The certificate authorities did not expire; they are issued for years, and check-expiration lists them separately. Only the leaves went.

2. Why two clients report two different problems

A TLS handshake has an order, and the error you get is from whichever check fails first.

The client connects, the API server presents its serving certificate, and the client verifies it against the cluster CA. That certificate expired at 10:00, so verification fails and kubectl reports x509: certificate has expired. The client never gets far enough to present its own credentials.

Add --insecure-skip-tls-verify and the client stops checking the server. Now the handshake proceeds to client authentication, the client presents the expired certificate from admin.conf, the API server rejects it, and the request arrives with no identity attached. The API server answers Unauthorized.

Both messages are accurate. Neither one says “the PKI expired”, and the second one looks exactly like an RBAC regression, which is why it cost twenty minutes.

3. Why the workers were fine

The kubelet client certificate is not part of the init-time PKI. The kubelet requests it through a CSR that the kube-controller-manager signs, and it renews it automatically at 75 percent of its validity — which is why kubelet-client-current.pem on worker-04 expires in three months rather than this morning.

So the nodes still had a valid identity and were only failing because they could not verify the API server. Their Pods kept running throughout, because the kubelet does not need the API server to keep containers that already exist alive.

4. Why the renewal did not fix anything

This is the part worth carrying away.

kubeadm certs renew rewrites files: the certificates under /etc/kubernetes/pki/ and the kubeconfigs beside them. It does not reach into a running process. The control-plane components read their key material once, at startup, and the kube-apiserver process on cp-1 started 47 days ago. It went on serving the certificate it loaded then.

And kubeadm certs check-expiration reads the same files the renewal just wrote. It confirmed that the write succeeded. It cannot confirm, and does not claim, that any process picked the new material up.

Resolution

  1. Confirm the clock on all three control-plane nodes with timedatectl status before touching the PKI. Agreement between the nodes and with the reference clock is what separates expiry from skew, and only one of those two is fixed by renewing.
  2. Pick one control-plane node and work only on that one. With the cluster already down there is no availability left to protect, but etcd quorum still is: parking all four manifests stops that node etcd member too, and taking a second node down at the same time turns a credential outage into a quorum outage.
  3. Renew on that node: sudo kubeadm certs renew all. This rewrites the certificates under /etc/kubernetes/pki/ and the kubeconfigs in /etc/kubernetes/, including admin.conf.
  4. Recreate the static Pods, which is the step the renewal does not perform. Move the four manifests out of /etc/kubernetes/manifests/, wait past the kubelet fileCheckFrequency of 20 seconds, confirm with sudo crictl ps that they are gone, then move them back.
  5. Verify on the node by reading the served certificate, not the file: openssl s_client -connect 127.0.0.1:6443 must present a notAfter about a year out, and sudo crictl ps must show a kube-apiserver container that is minutes old rather than 47 days old.
  6. Prove the chain now verifies: curl -s --cacert /etc/kubernetes/pki/ca.crt https://127.0.0.1:6443/livez returns ok with verification enabled. This is the check that was failing, and -k deliberately cannot answer it.
  7. Use the renewed admin.conf on the node itself to confirm the cluster is back: sudo kubectl --kubeconfig /etc/kubernetes/admin.conf get nodes. One healthy API server restores the whole control plane.
  8. Repeat the renewal and the manifest recreation on the second node, verify it, then the third. One at a time. A node missed here reports its own expired dates and becomes the next incident.
  9. Redistribute operator kubeconfigs. Every engineer copy of admin.conf is a copy, and renewing on the node does not touch it. Where a fresh credential is needed rather than a copy of the admin one, kubeadm kubeconfig user generates it on the control-plane node.
  10. Do not restore etcd and do not run kubeadm reset. The datastore is intact and current; a restore would discard every write since the snapshot in exchange for fixing a problem it does not touch.

Verification

  1. The served certificate is new on every control-plane node: openssl s_client -connect 127.0.0.1:6443 -showcerts < /dev/null 2>/dev/null | openssl x509 -noout -dates shows a notAfter roughly a year away. This is the check that would have caught the failed remediation at 10:40.
  2. The processes actually restarted: sudo crictl ps shows kube-apiserver, kube-controller-manager, kube-scheduler and etcd with start times from the maintenance rather than from 47 days ago.
  3. The chain verifies without -k: curl -s --cacert /etc/kubernetes/pki/ca.crt https://127.0.0.1:6443/livez returns ok on each node.
  4. sudo kubeadm certs check-expiration reports roughly 364 days on every control-plane node, not just the first one. Run it in a loop over all three and compare, because divergence means a host was missed or was repaired out of band.
  5. kubectl get nodes from a refreshed client kubeconfig returns every node Ready, and no client needs --insecure-skip-tls-verify to get an answer.
  6. The kubelet journals stop producing x509 errors: sudo journalctl -u kubelet --since "10 minutes ago" | grep -c x509 returns 0 on a sample of workers. That is the nodes confirming they accept the new serving certificate.
  7. The control plane is reconciling and not merely answering reads: kubectl get lease -n kube-system shows current holders for the controller-manager and scheduler leases, and a throwaway Deployment can be created, scaled and deleted.
  8. A certificate-expiry alert exists and has been tested by evaluating it against the now-known expiry date, so the next anniversary arrives as a ticket rather than as a page.

Prevention

  • Alert on residual days, not on the expiry timestamp. The remaining time is the actionable number; a warning at 30 days and a page at 7 turns the anniversary into a maintenance window. An alert that fires on the day is not an alert, it is a postmortem.
  • Inventory every control-plane node, not one. Run kubeadm certs check-expiration across all of them and compare. Diverging dates mean a host was re-bootstrapped or restored out of band, and it will expire on its own private schedule.
  • Know why most clusters never hit this. kubeadm upgrade renews the PKI as a side effect, so a cluster on a regular upgrade cadence keeps resetting the clock without anyone deciding to. A cluster that has skipped upgrades for a year has two problems, and the certificates are the one that shows up first.
  • Write the static Pod recreation into the renewal runbook as a numbered step. It is the step this incident turned on, and it is the step most naturally written as a footnote.
  • Verify credentials by asking the process, not the filesystem. A verification that reads the file the remediation just wrote is a closed loop. openssl s_client reads what is actually being served.
  • Write down the recovery path for an expired admin.conf somewhere outside the cluster. The moment you need it is the moment you have no working kubectl to look it up with, and the wiki page may well be behind an ingress on the cluster that is down.
  • Keep NTP healthy and monitored on the control plane, for the same reason the clock check comes first in the diagnosis.