A small app of mine had been running quietly for months. Then one day, it showed: 15 restarts.
The only log it produced was two lines:
The strange part: every other pod in the same cluster had restarted exactly 6 times. Same node, same moment — why did only this one crash 9 extra times?
The answer turned out to be two problems stacked on top of each other. One was cluster-wide: all pods were being killed together. The other was app-level: only my app turned a brief dependency hiccup into a crash loop.
1. First, don’t stare at your own pod
When a pod restarts N times, the first step is not reading its logs. It is looking at its neighbours:
| |
The result: one service had restarted 4 times, and the rest — the database, the cache, the small side services — had all restarted exactly 6 times. Only my app was at 15.
That single command split the problem in two:
- Every pod restarted 6 times → there were 6 node-level events that killed all containers. That is not my app’s fault.
- Only my app reached 15 → the extra 9 restarts were self-inflicted.
Then I checked how the container had exited:
Exit code 1, after about 36 seconds, and the log never showed a successful startup. It was dying during boot.
2. Who is killing the containers?
The node runs containerd (bundled with microk8s). I searched the system journal:
| |
And found this:
The process IDs of containerd and kubelite kept changing, so those daemons were being restarted again and again. And on client request told me the killer was not the kernel — it was something on the host, asking systemd to do it. That something was microk8s’s own apiserver-kicker.
The kicker is a small shell script (/snap/microk8s/<rev>/apiservice-kicker), and its core logic is easy to read:
| |
In other words: whenever the apiserver certificate needs to be re-issued, microk8s stops kubelite, kills every container on the node, restarts containerd, and brings everything back up. That is a very blunt instrument, and the price is a cluster-wide restart of all containers.
So the real question became: why did the certificate keep “changing”?
3. Why did the certificate keep changing?
The kicker calls manage_certs, which ends up in produce_certs. That function does something simple:
And render_csr_conf does this:
It renders the host’s hostname -I address list into the certificate’s SAN list, then compares the result with the previous version. So any change to the host’s IP list — an interface appearing, an interface disappearing, an address being added or removed — makes microk8s believe the node’s IPs have changed and the certificate must be re-issued. And re-issuing means killing every container.
So who kept changing the host’s IP list? I lined up the logs by time, and the answer was remarkably tidy:
| podman event | 2–4 seconds later |
|---|---|
| 03:06:49 a container was created | 03:06:52 cert change detected |
| 03:33:34 a container died | 03:33:36 cert change detected |
| 04:33:54 a bridge came up | 04:33:59 cert change detected |
| 04:36:26 a container was cleaned up | 04:36:29 cert change detected |
| 07:19:00 a container started | 07:19:03 cert change detected |
| 07:20:38 a container died | 07:20:39 cert change detected |
Six events, six hits. Every time a podman container started or stopped, the whole Kubernetes cluster was restarted.
The reason is in how podman creates networks:
The bridge of the default podman network (podman0, gateway 10.88.0.1) is created on demand: while a container is attached to that network, the bridge exists; when the last container stops, the bridge and its IP disappear. And a bridge is a host-side interface, so its IP shows up in hostname -I:
Now the chain closes:
| |
One small piece of supporting evidence: the certificate’s SAN list did contain the gateway of a permanent podman bridge (from another network that always has a container attached), but not the temporary one — because at the moment the certificate was issued, the temporary bridge did not exist.
4. Why did only my app crash 9 extra times?
That explains the 6 cluster-wide events. Now for 15 − 6 = 9.
My app starts like this:
And init_redis connects eagerly: creating the connection manager actually opens the connection. If that fails, it returns Err, the ? propagates it, main returns Err, and the process exits with code 1. That is where the two-line log comes from:
Interestingly, the same message appears twice. In the redis-rs source, each connection attempt is wrapped in a 1-second timeout, and a timeout converts tokio’s Elapsed into an io::Error:
The Display of io::ErrorKind::TimedOut is literally timed out. RedisError wraps it, prints it as its own Display, and also returns it from source(). So when anyhow prints the error chain, the same string appears on the first line and again under “Caused by” — it looks like an echo, but it is one error wearing two hats.
So this is what each cluster-wide event looked like from my app’s point of view:
- It was killed by
remove_all_containers(restart #1); - When it came back, Redis, the Service, and the network were not ready yet, so the eager connection timed out and the process exited (restart);
- kubelet backed off for 10 seconds, then 20, and tried again — if the dependency was still not ready, it exited once more (restart).
The container creation times show the pattern perfectly — they always come in bursts:
| |
6 kills + 9 self-crashes = 15 restarts. The truth was that simple.
5. The fix
5.1 Node level: stop re-issuing certificates on IP changes
microk8s provides a lock file for exactly this. The script checks for ${SNAP_DATA}/var/lock/no-cert-reissue:
| |
With the lock in place, the kicker takes the ensure_server_ca branch instead — it only re-issues when the certificate is genuinely broken (for example, a wrong issuer), not when the IP list changes. On a single-node machine with fairly stable IPs, this is the easiest fix. To revert, just rm the file.
5.2 App level: don’t make a startup dependency a hard dependency
A cache is best-effort by nature: on a miss you fetch the data again, and a failed write is not a disaster. So the app should not die for it at startup. Two ways to fix it:
| |
After the change, the next node-level event would cost my app exactly one kill and one restart — the same as every other pod.
5.3 Alternative: keep the bridge alive
If you would rather not touch the certificate lock, you can keep one long-running container attached to the default podman network, so the bridge never disappears and hostname -I stays stable. But that only removes one trigger; a VPN or a virtual interface could do the same thing tomorrow. That is why I prefer 5.1 + 5.2.
6. What I learned
- When a pod restarts N times, look at its neighbours first. All pods restarting means a platform problem; only yours restarting means an app problem; both means two problems — fix them separately.
- Anything on the host that appears and disappears can change
hostname -I: container runtime bridges, VPNs, virtual interfaces. If some component watches the host’s IP list, watch out. - microk8s certificate rotation is expensive. When it detects an IP change, it kills every container and rebuilds, instead of hot-reloading. Knowing this saves a lot of confusion when the whole cluster shudders at once.
- A hard dependency at startup amplifies hiccups. Thirty seconds of dependency downtime becomes a string of CrashLoopBackOff restarts if the process chooses to exit; it stays a single cache miss if the process chooses to degrade.
And finally, back to that two-line log. It was not lying — it just said too little. The neighbours’ restart counts, the system journal, and a few seconds of timing correlation told the rest of the story.