一个跑了好几个月、平时安安静静的小应用,某天突然显示:重启 15 次。

看日志,整个输出只有两行:

1
2
3
4
Error: timed out

Caused by:
    timed out

更奇怪的是,同一个集群里其他 Pod 都只重启了 6 次。同一个节点、同一个时刻,为什么单单它多崩了 9 次?

这是一个"两个问题叠加"的故事:一个是节点级的——所有 Pod 一起被杀;一个是应用级的——只有它把"依赖抖动"放大成了崩溃循环。

一、先别盯着自己的 Pod

排查"重启 N 次",第一件事不是看自己的日志,而是看邻居:

1
kubectl -n my-app get pods -o wide

结果:一个服务重启 4 次,其余的服务——数据库、缓存、几个小的边车服务——全部是 6 次,只有我的应用是 15 次。

这一步直接给出了两个结论:

  • 每个 Pod 都有 6 次重启 → 有 6 次节点级事件,把所有容器一起杀了,这不是我的应用的问题;
  • 只有我的应用是 15 次 → 多出来的 9 次是它自己崩的。

再看容器退出时的状态:

1
2
3
kubectl -n my-app get pod app-6d9f7c8b5-x2k4p \
  -o jsonpath='{.status.containerStatuses[0].lastState}'
# {"terminated":{"exitCode":1,"reason":"Error","startedAt":"...","finishedAt":"..."}}

exitCode: 1,运行了 36 秒后退出,而且日志里没有启动成功的标记——它死在启动阶段。

二、谁在杀容器

节点上的容器运行时是 containerd(microk8s 内置)。翻系统日志:

1
journalctl --since "6 hours ago" | grep -E 'containerd|kubelite' | grep -i kill

看到的是:

1
2
3
4
systemd[1]: snap.microk8s.daemon-containerd.service:
  Sent signal SIGKILL to main process 2230966 (containerd) on client request.
systemd[1]: snap.microk8s.daemon-containerd.service:
  Main process exited, code=killed, status=9/KILL

containerd 和 kubelite 的进程号每隔一段时间就换一次,说明这两个守护进程被反复重启。而 on client request 说明不是内核杀的,是"有人"发起的——发起者是 microk8s 自己的 apiserver-kicker。

打开这个脚本(/snap/microk8s/<rev>/apiservice-kicker),核心逻辑一目了然:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
if snapctl services microk8s.daemon-kubelite | grep active &> /dev/null
then
  certs_modified="$(manage_certs)"
  if [[ "$certs_modified" -eq "1" ]] && ...
  then
    echo "cert change detected. Restarting the cluster-agent"
    snapctl restart microk8s.daemon-cluster-agent

    echo "cert change detected. Reconfiguring the kube-apiserver"
    rm -rf .srl
    snapctl stop microk8s.daemon-kubelite
    remove_all_containers          # ← 杀掉所有容器
    kill_all_container_shims
    snapctl restart microk8s.daemon-containerd
    snapctl start microk8s.daemon-kubelite
    start_all_containers
    ...

也就是说:只要 apiserver 的证书需要重新签发,microk8s 就会停掉 kubelite、杀掉全部容器、重启 containerd,再把一切拉起来。 这是一个非常"钝"的操作,代价是整个集群的容器集体重启一次。

所以现在的目标是:搞清楚证书为什么反复"变化"。

三、证书为什么会变

继续读脚本。manage_certs 最终调用 produce_certs,后者做了一件很朴素的事:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
render_csr_conf
if ! cmp -s certs/csr.conf.rendered certs/csr.conf; then
    force=true
    cp certs/csr.conf.rendered certs/csr.conf
fi

if $force; then
    gen_server_cert        # 重新签发 apiserver 证书
    gen_proxy_client_cert
    echo "1"               # ← 触发上面那套"杀全家"流程
fi

而 render_csr_conf 的内容是:

1
2
3
local IP_ADDRESSES="$(get_ips)"     # get_ips = hostname -I,再过滤掉 CNI 网卡
...
"$SNAP/bin/sed" -i "s/#MOREIPS/${ips}/g" certs/csr.conf.rendered

它把主机的 hostname -I 地址列表渲染进证书的 SAN 列表,再和上一次的版本做对比。 于是,任何让主机 IP 列表发生变化的操作——接口出现、接口消失、地址增删——都会让 microk8s 认为"节点 IP 变了,证书需要重签",然后杀掉所有容器。

那么,是谁在反复改变主机 IP 列表?把日志按时间对齐,答案非常整齐:

podman 事件2~4 秒后
03:06:49 一个容器 create03:06:52 cert change detected
03:33:34 容器 died03:33:36 cert change detected
04:33:54 网桥 Link UP04:33:59 cert change detected
04:36:26 容器 cleanup04:36:29 cert change detected
07:19:00 一个容器 start07:19:03 cert change detected
07:20:38 容器 died07:20:39 cert change detected

6 次事件,6 次命中。每次 podman 容器启动或停止,整个 Kubernetes 集群就被重启一次。

原因在 podman 的网络实现上:

1
2
$ podman network inspect podman
[{ "subnet": "10.88.0.0/16", "gateway": "10.88.0.1" }]  podman0

默认的 podman 网络,它的网桥(podman0,网关 10.88.0.1)是按需创建的:有容器挂在这个网络上时,网桥存在;最后一个容器停止后,网桥连同它的 IP 一起消失。而网桥是主机侧的接口,它的 IP 会出现在 hostname -I 里:

1
2
3
$ hostname -I
203.0.113.10 192.0.2.10 10.88.0.1 2001:db8:1::1 2001:db8:2::1
#              ↑ 公网 IP     ↑ podman 网桥(时有时无)

于是链条闭合了:

1
2
3
4
5
6
7
podman 容器启动/停止
  → 默认网络网桥创建/销毁
  → hostname -I 变化
  → csr.conf 渲染结果变化
  → microk8s 重新签发 apiserver 证书
  → remove_all_containers / kill_all_container_shims
  → 集群里所有容器被 SIGKILL,全部重启

一个侧面证据:证书 SAN 列表里确实躺着一个常驻网桥的网关地址(另一个长期存在的 podman 网络的网桥),而临时的那个不在里面——因为签发的那一刻它还没出现。

四、为什么只有它多崩 9 次

节点级的 6 次事件解释完了。现在解释 15 − 6 = 9。

应用启动时是这么写的:

1
2
let redis_pool = cache::init_redis().await?;   // ← 连不上就直接退出
println!("Redis connected");

而 init_redis 是急切连接:建立连接管理器时就会真的去连,连不上就返回 Err;? 一路向上,main 返回 Err,进程退出码 1。日志里那个只有两行的错误,就来自这里:

1
2
3
4
Error: timed out

Caused by:
    timed out

有意思的是这个错误为什么打印了两遍 timed out。翻 redis-rs 的源码,ConnectionManager 建立连接时给每次尝试套了 1 秒超时,超时后把 tokio 的 Elapsed 转换成一个 io::Error:

1
2
3
4
5
impl From<Elapsed> for RedisError {
    fn from(_: Elapsed) -> Self {
        io::Error::from(io::ErrorKind::TimedOut).into()
    }
}

io::ErrorKind::TimedOut 的 Display 就是 timed out;而 RedisError 包着它,自己的 Display 也输出这个 io::Error,同时 source() 还是它。于是 anyhow 打印错误链时,第一行和 “Caused by” 各出现一次——看起来像复读,其实是同一个错误的两个身份。

于是每次集群级事件,我的应用的实际经历是:

  1. 被 remove_all_containers 杀掉(1 次重启);
  2. 重新拉起时,Redis / Service / 网络还没就绪,急切连接超时,进程退出(重启);
  3. kubelet 退避 10 秒、20 秒后再拉,如果依赖还没就绪,再退出一次(重启)。

容器创建时间把它们排得清清楚楚,每次都成串出现:

1
2
3
4
5
6
03:07:16, 03:07:54, 03:08:29   ← 被杀 1 次 + 自崩 2 次
03:33:58, 03:34:36, 03:35:13   ← 被杀 1 次 + 自崩 2 次
04:34:24, 04:35:03             ← 被杀 1 次 + 自崩 1 次
04:36:49, 04:37:27, 04:38:01   ← 被杀 1 次 + 自崩 2 次
07:19:31, 07:20:09             ← 被杀 1 次 + 自崩 1 次
07:21:03, 07:21:40             ← 被杀 1 次 + 自崩 1 次(终于稳定)

6 次被杀 + 9 次自崩 = 15 次重启。真相就是这么朴素。

五、修复

1. 节点级:关掉"因 IP 变化而重签证书"

microk8s 预留了锁文件开关,脚本里检查的是 ${SNAP_DATA}/var/lock/no-cert-reissue:

1
touch /var/snap/microk8s/current/var/lock/no-cert-reissue

加上之后,kicker 会改走 ensure_server_ca 分支——只在"证书真的坏了"(比如签发者不匹配)时才重签,而不再因为 IP 列表变化就大动干戈。对单机、IP 相对固定的环境,这是最省事的解法;需要恢复时 rm 掉这个文件即可。

2. 应用级:别让启动依赖变成硬依赖

缓存本来就是 best-effort 的:读不到就回源,写不进去就算了。既然如此,启动时就不该为它去死。两种改法:

1
2
3
4
5
6
7
8
// 方案 A:惰性连接,启动不阻塞,后台自动重连
let client = redis::Client::open(url)?;
let pool = client.new_lazy_connection_manager()?;   // 不建立连接,首次命令时才连

// 方案 B:带退避的启动重试,重试窗口内不退出
for attempt in 0..N {
    match init_redis().await { Ok(p) => return Ok(p), Err(e) => { warn!(...); sleep(backoff).await } }
}

改完之后,同样的节点级事件里,它只会"被杀 1 次、重启 1 次",和其他 Pod 完全一样。

3. 备选:让网桥常驻

如果不想动证书开关,也可以让默认 podman 网络里始终挂一个常驻容器,网桥就不会消失,hostname -I 也就稳定了。但这只是绕开触发条件,换个接口(VPN、虚拟网卡)照样会踩,所以我更倾向 1 + 2。

六、经验

  • 排查"重启 N 次",先看邻居。 所有 Pod 都重启 = 平台问题;只有你重启 = 应用问题;两种都有 = 两个问题叠加,要分开修。
  • 主机上任何"接口出现/消失"的东西都可能改变 hostname -I:容器运行时网桥、VPN、虚拟网卡……如果某个组件会监听主机 IP 列表,就要小心。
  • microk8s 的证书轮换代价很大:检测到"节点 IP 变化"时,它的做法是杀掉所有容器再重建,而不是热更新。知道这一点,能省下很多"为什么全集群一起抖动"的困惑。
  • 启动时的硬依赖会把抖动放大:一次 30 秒的依赖不可用,如果进程选择退出,就会变成一串 CrashLoopBackOff 重启记录;如果进程选择降级运行,它就只是一次缓存未命中。

最后,回到那个只有两行的日志——它没有说谎,只是说的信息太少。把邻居的状态、系统的日志和几秒钟的时间相关性拼在一起,答案其实一直都在。