一个跑了好几个月、平时安安静静的小应用,某天突然显示:重启 15 次。
看日志,整个输出只有两行:
更奇怪的是,同一个集群里其他 Pod 都只重启了 6 次。同一个节点、同一个时刻,为什么单单它多崩了 9 次?
这是一个"两个问题叠加"的故事:一个是节点级的——所有 Pod 一起被杀;一个是应用级的——只有它把"依赖抖动"放大成了崩溃循环。
一、先别盯着自己的 Pod
排查"重启 N 次",第一件事不是看自己的日志,而是看邻居:
| |
结果:一个服务重启 4 次,其余的服务——数据库、缓存、几个小的边车服务——全部是 6 次,只有我的应用是 15 次。
这一步直接给出了两个结论:
- 每个 Pod 都有 6 次重启 → 有 6 次节点级事件,把所有容器一起杀了,这不是我的应用的问题;
- 只有我的应用是 15 次 → 多出来的 9 次是它自己崩的。
再看容器退出时的状态:
exitCode: 1,运行了 36 秒后退出,而且日志里没有启动成功的标记——它死在启动阶段。
二、谁在杀容器
节点上的容器运行时是 containerd(microk8s 内置)。翻系统日志:
| |
看到的是:
containerd 和 kubelite 的进程号每隔一段时间就换一次,说明这两个守护进程被反复重启。而 on client request 说明不是内核杀的,是"有人"发起的——发起者是 microk8s 自己的 apiserver-kicker。
打开这个脚本(/snap/microk8s/<rev>/apiservice-kicker),核心逻辑一目了然:
| |
也就是说:只要 apiserver 的证书需要重新签发,microk8s 就会停掉 kubelite、杀掉全部容器、重启 containerd,再把一切拉起来。 这是一个非常"钝"的操作,代价是整个集群的容器集体重启一次。
所以现在的目标是:搞清楚证书为什么反复"变化"。
三、证书为什么会变
继续读脚本。manage_certs 最终调用 produce_certs,后者做了一件很朴素的事:
而 render_csr_conf 的内容是:
它把主机的 hostname -I 地址列表渲染进证书的 SAN 列表,再和上一次的版本做对比。 于是,任何让主机 IP 列表发生变化的操作——接口出现、接口消失、地址增删——都会让 microk8s 认为"节点 IP 变了,证书需要重签",然后杀掉所有容器。
那么,是谁在反复改变主机 IP 列表?把日志按时间对齐,答案非常整齐:
| podman 事件 | 2~4 秒后 |
|---|---|
| 03:06:49 一个容器 create | 03:06:52 cert change detected |
| 03:33:34 容器 died | 03:33:36 cert change detected |
| 04:33:54 网桥 Link UP | 04:33:59 cert change detected |
| 04:36:26 容器 cleanup | 04:36:29 cert change detected |
| 07:19:00 一个容器 start | 07:19:03 cert change detected |
| 07:20:38 容器 died | 07:20:39 cert change detected |
6 次事件,6 次命中。每次 podman 容器启动或停止,整个 Kubernetes 集群就被重启一次。
原因在 podman 的网络实现上:
默认的 podman 网络,它的网桥(podman0,网关 10.88.0.1)是按需创建的:有容器挂在这个网络上时,网桥存在;最后一个容器停止后,网桥连同它的 IP 一起消失。而网桥是主机侧的接口,它的 IP 会出现在 hostname -I 里:
于是链条闭合了:
一个侧面证据:证书 SAN 列表里确实躺着一个常驻网桥的网关地址(另一个长期存在的 podman 网络的网桥),而临时的那个不在里面——因为签发的那一刻它还没出现。
四、为什么只有它多崩 9 次
节点级的 6 次事件解释完了。现在解释 15 − 6 = 9。
应用启动时是这么写的:
而 init_redis 是急切连接:建立连接管理器时就会真的去连,连不上就返回 Err;? 一路向上,main 返回 Err,进程退出码 1。日志里那个只有两行的错误,就来自这里:
有意思的是这个错误为什么打印了两遍 timed out。翻 redis-rs 的源码,ConnectionManager 建立连接时给每次尝试套了 1 秒超时,超时后把 tokio 的 Elapsed 转换成一个 io::Error:
io::ErrorKind::TimedOut 的 Display 就是 timed out;而 RedisError 包着它,自己的 Display 也输出这个 io::Error,同时 source() 还是它。于是 anyhow 打印错误链时,第一行和 “Caused by” 各出现一次——看起来像复读,其实是同一个错误的两个身份。
于是每次集群级事件,我的应用的实际经历是:
- 被
remove_all_containers杀掉(1 次重启); - 重新拉起时,Redis / Service / 网络还没就绪,急切连接超时,进程退出(重启);
- kubelet 退避 10 秒、20 秒后再拉,如果依赖还没就绪,再退出一次(重启)。
容器创建时间把它们排得清清楚楚,每次都成串出现:
6 次被杀 + 9 次自崩 = 15 次重启。真相就是这么朴素。
五、修复
1. 节点级:关掉"因 IP 变化而重签证书"
microk8s 预留了锁文件开关,脚本里检查的是 ${SNAP_DATA}/var/lock/no-cert-reissue:
| |
加上之后,kicker 会改走 ensure_server_ca 分支——只在"证书真的坏了"(比如签发者不匹配)时才重签,而不再因为 IP 列表变化就大动干戈。对单机、IP 相对固定的环境,这是最省事的解法;需要恢复时 rm 掉这个文件即可。
2. 应用级:别让启动依赖变成硬依赖
缓存本来就是 best-effort 的:读不到就回源,写不进去就算了。既然如此,启动时就不该为它去死。两种改法:
改完之后,同样的节点级事件里,它只会"被杀 1 次、重启 1 次",和其他 Pod 完全一样。
3. 备选:让网桥常驻
如果不想动证书开关,也可以让默认 podman 网络里始终挂一个常驻容器,网桥就不会消失,hostname -I 也就稳定了。但这只是绕开触发条件,换个接口(VPN、虚拟网卡)照样会踩,所以我更倾向 1 + 2。
六、经验
- 排查"重启 N 次",先看邻居。 所有 Pod 都重启 = 平台问题;只有你重启 = 应用问题;两种都有 = 两个问题叠加,要分开修。
- 主机上任何"接口出现/消失"的东西都可能改变
hostname -I:容器运行时网桥、VPN、虚拟网卡……如果某个组件会监听主机 IP 列表,就要小心。 - microk8s 的证书轮换代价很大:检测到"节点 IP 变化"时,它的做法是杀掉所有容器再重建,而不是热更新。知道这一点,能省下很多"为什么全集群一起抖动"的困惑。
- 启动时的硬依赖会把抖动放大:一次 30 秒的依赖不可用,如果进程选择退出,就会变成一串 CrashLoopBackOff 重启记录;如果进程选择降级运行,它就只是一次缓存未命中。
最后,回到那个只有两行的日志——它没有说谎,只是说的信息太少。把邻居的状态、系统的日志和几秒钟的时间相关性拼在一起,答案其实一直都在。