第 4 章
etcd 备份与集群升级
集群跑起来之后,迟早要升级。升级前先做一件事:备份 etcd。这两件事在 CKA 里几乎必考。
实验在自己的 VMware 集群上完成:K8s v1.31.14,etcd 3.5.24,最后升到 v1.32.13。新版 etcd 3.6 有几处命令变了,不一样的地方会单独标出来。
etcd 存着什么
K8s 的所有资源——Deployment、Service、ConfigMap、Secret、RBAC、节点信息——都存在 etcd 里。其他控制平面组件基本不存数据,挂了重启就行;etcd 丢了,集群就"失忆"了。所以备份集群状态就是备份 etcd。
每个资源在 etcd 里是一个 key,路径格式是 /registry/<资源类型>/<namespace>/<名字>(节选):
/registry/apiextensions.k8s.io/customresourcedefinitions/ciliumcidrgroups.cilium.io
/registry/deployments/default/nginx
/registry/services/specs/default/nginx
为什么不能直接复制数据目录
etcd 一直在写(心跳、租约续期……),cp -r /var/lib/etcd 复制到一半数据就可能变了,得到一份前后不一致的坏文件——和不能在 MySQL 运行时复制数据目录一个道理。
etcdctl snapshot save 是让 etcd 自己导出某一时刻的完整数据:前后一致、单个文件、带校验值。
快照里有所有 Secret
K8s 默认不加密 Secret,只做 base64 编码。拿到快照就能还原出集群里所有密码和密钥,备份文件要按敏感数据保管。
找到 etcd 的配置
etcd.yaml 是 Static Pod 清单
etcd 的配置在 /etc/kubernetes/manifests/etcd.yaml。这个目录由 kubelet 直接监视:放了什么 YAML,就在本节点跑什么 Pod,不经过 API Server。
$ sudo ls /etc/kubernetes/manifests/
etcd.yaml kube-apiserver.yaml kube-controller-manager.yaml kube-scheduler.yaml
控制平面组件没法通过 API Server 创建自己——API Server 还没起来时,谁来创建 API Server?所以 kubeadm 把它们写成静态文件交给 kubelet。改了这里的文件,kubelet 会自动重建 Pod,以后改 API Server 参数、恢复 etcd 都在这里操作。
文件只有 root 可读,普通 cat 会报 Permission denied,要加 sudo。
用 grep 查参数
sudo grep -E 'data-dir|crt|key' /etc/kubernetes/manifests/etcd.yaml
- --cert-file=/etc/kubernetes/pki/etcd/server.crt
- --data-dir=/var/lib/etcd
- --key-file=/etc/kubernetes/pki/etcd/server.key
- --peer-cert-file=/etc/kubernetes/pki/etcd/peer.crt
- --peer-key-file=/etc/kubernetes/pki/etcd/peer.key
- --peer-trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt
- --trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt
7 行分三组:
| 用途 | 参数 | 用不用 |
|---|---|---|
| 数据目录 | --data-dir | 快照存这里 |
| 对客户端(2379 端口) | --trusted-ca-file / --cert-file / --key-file | ✅ 填进 etcdctl |
| 成员之间(2380 端口) | --peer- 开头的三个 | ❌ |
ca.crt 两组共用:同一个 CA 签发了 server 和 peer 两套证书。
踩坑:一开始写的
grep 'data-dir|trusted-ca'没有任何输出。grep 默认是基础正则(BRE),|只是普通字符,要用grep -E 'A|B'(扩展正则)或者grep 'A\|B'。
调用 etcdctl
etcdctl 在容器里
etcd 镜像(registry.k8s.io/etcd)打包了三个程序:
| 程序 | 作用 |
|---|---|
etcd | 数据库本身 |
etcdctl | 客户端:查询、备份 |
etcdutl | 离线工具:直接操作数据文件,比如恢复快照 |
kubeadm 不会在节点上装 etcdctl,所以要借 kubectl exec 进容器执行:
kubectl -n kube-system exec etcd-ubuntu -- etcdctl -h
- Pod 名
etcd-ubuntu:etcd.yaml 里写的是name: etcd,Static Pod 会自动加上节点名 --之后是在容器里执行的命令
exec 不会新建容器,只是在正在运行 etcd 的那个容器里临时起一个 etcdctl 进程,跑完就退出。两个进程在同一个容器里,所以 etcdctl 连 127.0.0.1:2379 就能找到 etcd。
etcd 镜像是 distroless 精简镜像,连 shell 都没有:
$ kubectl -n kube-system exec -it etcd-ubuntu -- sh
error: Internal error occurred: ... OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH
所以只能每次 exec 一条具体命令进去。这段报错从左到右正好是 exec 请求经过的各层:API Server → kubelet → containerd → runc,排障时看最后一句。
三个证书:双向 TLS
etcd.yaml 里有 --client-cert-auth=true,意思是 etcd 要求客户端也出示证书(mTLS),连接时双方互验身份:
| etcdctl 参数 | 对应 etcd.yaml | 作用 |
|---|---|---|
--cacert | --trusted-ca-file → ca.crt | 验证对方确实是 etcd |
--cert | --cert-file → server.crt | 出示我的证书 |
--key | --key-file → server.key | 证明这张证书是我的 |
路径不用背,看 etcd.yaml 填 etcdctl 就行。
每条命令都带这三个参数太长了,做实验时设一个 alias:
alias e='kubectl -n kube-system exec etcd-ubuntu -- etcdctl --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key'
之后 e endpoint health、e member list 就够了。
hostPath:容器和节点共享目录
etcd.yaml 里的挂载声明:
volumeMounts:
- mountPath: /var/lib/etcd
name: etcd-data
- mountPath: /etc/kubernetes/pki/etcd
name: etcd-certs
volumes:
- hostPath:
path: /etc/kubernetes/pki/etcd
name: etcd-certs
- hostPath:
path: /var/lib/etcd
name: etcd-data
容器里的 /var/lib/etcd 和节点上的 /var/lib/etcd 是同一个目录,证书目录也一样。所以容器里的 etcdctl 能用节点上的证书路径,容器里存的快照在节点上也能看到。
挂载不是手动做的:kubeadm init 生成 etcd.yaml 时写好了声明,之后每次容器启动,由 kubelet 通知 containerd 执行挂载。目录的创建时间就是 init 那天:
$ sudo stat /var/lib/etcd
...
Birth: 2026-08-29 06:56:11.547867945 +0000
用 crictl 从容器运行时这一层看实际的挂载:
sudo crictl inspect $(sudo crictl ps --name etcd -q) | grep -A2 '"containerPath"'
| 容器路径 | 节点路径 | 谁声明的 |
|---|---|---|
/var/lib/etcd | /var/lib/etcd | etcd.yaml |
/etc/kubernetes/pki/etcd | /etc/kubernetes/pki/etcd | etcd.yaml |
/etc/hosts | /var/lib/kubelet/pods/<Pod UID>/etc-hosts | kubelet 自动加 |
/dev/termination-log | /var/lib/kubelet/pods/<Pod UID>/containers/… | kubelet 自动加 |
为什么非要挂载:容器的文件系统是临时的,删了重建就没了。crictl ps 显示 etcd 容器的 ATTEMPT 是 2,重启过,但数据一点没丢,因为数据一直在节点磁盘上。
crictl 直接问 containerd 本节点跑着哪些容器,不依赖 API Server。API Server 挂了 kubectl 就什么都查不到,这时靠
crictl ps -a和crictl logs排障。
查看 etcd 状态
endpoint 是什么
endpoint 就是一个访问地址(协议 + IP + 端口)。etcdctl 不去任何地方"发现"地址,给它什么就连什么,不给就用默认的 127.0.0.1:2379。
etcd 在哪些地址上监听,etcd.yaml 里写着:
- --listen-client-urls=https://127.0.0.1:2379,https://10.177.16.45:2379
- --listen-metrics-urls=http://127.0.0.1:2381
- --listen-peer-urls=https://10.177.16.45:2380
| 端口 | 给谁用 | 说明 |
|---|---|---|
| 2379 | 客户端(API Server、etcdctl) | 本机和其他机器都能连,一个 member 开了两个 endpoint |
| 2380 | 其他 etcd 成员 | 成员都在别的机器上,所以不监听 127.0.0.1 |
| 2381 | 健康检查、监控指标 | 只对本机开放,所以用 HTTP 不加密;livenessProbe 和 readinessProbe 打的就是这个端口 |
验证"给什么连什么":
$ e --endpoints=https://10.177.16.45:2379 endpoint health
https://10.177.16.45:2379 is healthy: successfully committed proposal: took = 8.833634ms
$ e --endpoints=https://10.177.16.45:9999 endpoint health
... dial tcp 10.177.16.45:9999: connect: connection refused ...
https://10.177.16.45:9999 is unhealthy: failed to commit proposal: context deadline exceeded
Error: unhealthy cluster
context deadline exceeded 只是"重试到超时",真正的原因要往上找:
| 底层报错 | 含义 | 常见原因 |
|---|---|---|
connection refused | 机器通,端口没人监听 | 端口写错、etcd 没起来 |
i/o timeout | 机器都连不上 | IP 写错、防火墙 |
certificate / tls 相关 | 连上了,身份验证失败 | 证书路径错、用错证书 |
和 K8s 的 Endpoints 不是一回事
kubectl get ep 里找不到 etcd。K8s 的 Endpoints 是 Service 背后的 Pod 地址列表,只有 Service 才有。etcd 没有 Service:Service 本身要靠 API Server 和 etcd 才能运转,etcd 不能反过来依赖它。
API Server 是直接连 etcd 地址的,写在 kube-apiserver.yaml 里(kubeadm 默认配置):
--etcd-servers=https://127.0.0.1:2379
--etcd-cafile=/etc/kubernetes/pki/etcd/ca.crt
--etcd-certfile=/etc/kubernetes/pki/apiserver-etcd-client.crt
--etcd-keyfile=/etc/kubernetes/pki/apiserver-etcd-client.key
API Server 连 etcd 也要三个证书,用的是它专用的客户端证书 apiserver-etcd-client。
| etcd 的 endpoint | K8s 的 Endpoints | |
|---|---|---|
| 是什么 | 一个访问地址 | 一种资源对象 |
| 在哪看 | etcdctl 的 --endpoints、API Server 的 --etcd-servers | kubectl get ep |
| 谁有 | etcd 成员 | Service |
endpoint health
$ e endpoint health
127.0.0.1:2379 is healthy: successfully committed proposal: took = 15.833875ms
健康检查不是 ping:它会真的提交一次测试写入,确认写入成功才算 healthy,15.8ms 是这次写入的耗时。
member list
$ e member list -w table
+------------------+---------+--------+---------------------------+---------------------------+------------+
| ID | STATUS | NAME | PEER ADDRS | CLIENT ADDRS | IS LEARNER |
+------------------+---------+--------+---------------------------+---------------------------+------------+
| c3210e4aa6db4a6e | started | ubuntu | https://10.177.16.45:2380 | https://10.177.16.45:2379 | false |
+------------------+---------+--------+---------------------------+---------------------------+------------+
- CLIENT ADDRS 只有一个:member list 显示的是
--advertise-client-urls(告诉别人用哪个地址连我),不是--listen-client-urls(实际监听哪些地址)。127.0.0.1 告诉别的机器也没用 - IS LEARNER:新成员先当 learner,只同步数据不投票,追上进度后用
member promote转正,避免拖慢集群写入 - STATUS 是
started只说明它加入过集群,不代表现在还活着。成员宕机了 member list 照样显示 started,判断健康要用endpoint health --cluster
member 和 endpoint 的区别:
member | endpoint | |
|---|---|---|
| 查什么 | 集群的成员名单 | 指定地址上的 etcd 状态 |
| 常用子命令 | list / add / remove / promote | health / status / hashkv |
| 范围 | 连任意一个成员都返回完整名单 | 只查 --endpoints 给的地址,加 --cluster 查全部成员 |
quorum:为什么是 3 或 5 个成员
etcd 用 Raft 共识:每个成员存一份完整数据,写入要超过半数成员确认才算成功。
| 成员数 | 需要确认 | 最多能坏 |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 | 2 | 1 |
| 4 | 3 | 1 |
| 5 | 3 | 2 |
2 个和 1 个一样一个都不能坏,4 个和 3 个一样只能坏 1 个,所以成员数取奇数。实验集群只有 1 个成员,没有任何冗余,更要备份;HA 集群坏了一个成员直接替换就行,不需要从快照恢复。
endpoint status
$ e endpoint status -w table
+----------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| ENDPOINT | ID | VERSION | DB SIZE | IS LEADER | IS LEARNER | RAFT TERM | RAFT INDEX | RAFT APPLIED INDEX | ERRORS |
+----------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| 127.0.0.1:2379 | c3210e4aa6db4a6e | 3.5.24 | 3.4 MB | true | false | 4 | 661112 | 661112 | |
+----------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| 列 | 值 | 解读 |
|---|---|---|
| ID | c3210e4aa6db4a6e | 和 member list 一致,这个 endpoint 连的就是这个 member |
| DB SIZE | 3.4 MB | 默认上限 2GB,写满会触发 NOSPACE 告警,etcd 变成只读 |
| IS LEADER | true | 只有一个成员,它自己就是 leader |
| RAFT TERM | 4 | 选过 4 次 leader,单成员 etcd 每次重启都会重新选举 |
| RAFT INDEX / APPLIED INDEX | 661112 / 661112 | 确认的写入次数 / 已落进数据库的次数,相等说明没有积压 |
66 万次写入大部分是心跳:kubelet 定期续节点租约,controller-manager 和 scheduler 续自己的 leader 租约。集群什么都不干,也一直在写 etcd。
存快照
snapshot save
$ e snapshot save /var/lib/etcd/snapshot.db
{"level":"info",...,"msg":"created temporary db file","path":"/var/lib/etcd/snapshot.db.part"}
...
{"level":"info",...,"msg":"fetched snapshot","endpoint":"127.0.0.1:2379","size":"3.4 MB","took":"now"}
{"level":"info",...,"msg":"saved","path":"/var/lib/etcd/snapshot.db"}
Snapshot saved at /var/lib/etcd/snapshot.db
路径必须在挂载目录下。命令在容器里执行,路径是容器里的路径;存到 /tmp 之类的地方,节点上看不到,容器一重建就丢了。etcd 容器只挂了 /var/lib/etcd 和证书目录,所以存 /var/lib/etcd。
Tab 补全的陷阱:输入
e snapshot save /var/按 Tab,补全出来的是节点上的目录。补全由本机的 shell 完成,它不知道这个参数最后会传进容器。
直接在节点上运行 etcdctl 时(考试一般是这样),路径就是节点上的路径,按题目要求存。
在节点上确认:
$ sudo ls -lh /var/lib/etcd/
total 3.3M
drwx------ 4 root root 4.0K Sep 26 05:25 member
-rw------- 1 root root 3.3M Sep 26 06:55 snapshot.db
日志说 3.4 MB、ls -h 说 3.3M,其实是同一个大小:etcd 按 1000 进位,ls -h 按 1024 进位。
snapshot status:验证快照
$ e snapshot status /var/lib/etcd/snapshot.db -w table
Deprecated: Use `etcdutl snapshot status` instead.
+----------+----------+------------+------------+
| HASH | REVISION | TOTAL KEYS | TOTAL SIZE |
+----------+----------+------------+------------+
| d587d57d | 582270 | 783 | 3.4 MB |
+----------+----------+------------+------------+
| 列 | 含义 |
|---|---|
| HASH | 校验值,复制到别处后可以对比,确认文件没坏 |
| REVISION | etcd 的数据版本号,每修改一次 key 加 1 |
| TOTAL KEYS | 数据库里 key 的总数,包括还没压缩掉的历史版本和 etcd 内部数据 |
| TOTAL SIZE | 和 save 时一致 |
REVISION(582270)比 RAFT INDEX(661112)小,因为 Raft 日志里还有租约、压缩这类不直接改 key 的操作。
版本差异:etcd 3.6 起,snapshot status 和 snapshot restore 从 etcdctl 移到了 etcdutl。3.5 里还能用,但会提示 Deprecated;到了 3.6.5,etcdctl -h 里只剩 snapshot save。
复制到备份目录
mkdir -p ~/backup
sudo cp /var/lib/etcd/snapshot.db ~/backup/snapshot.db-$(date +%F)
sudo cp -r /etc/kubernetes/pki/etcd ~/backup/
- 快照和 etcd 数据在同一块磁盘上,盘坏了会一起丢,所以要复制出来;生产环境还要再复制到别的机器
- 证书也要备份:节点坏了要在新机器上重建 etcd 时,需要同一套 CA 证书,其他组件才能继续和它通信
$(date +%F)生成2026-09-26这样的日期,按文件名排序就是时间顺序sudo cp出来的文件属于 root、权限是600,保持这样就好,快照里有所有 Secret
小结:etcd 备份做了什么
| # | 步骤 | 说明 |
|---|---|---|
| 1 | 查 etcd.yaml | 找数据目录和三个客户端证书的路径 |
| 2 | endpoint health | 确认 etcd 能正常写入 |
| 3 | member list / endpoint status | 确认成员数、leader、数据库大小 |
| 4 | snapshot save | 存到挂载目录,节点上也能看到 |
| 5 | snapshot status | 验证快照完整 |
| 6 | 复制快照和证书 | 离开 etcd 的数据盘,按敏感数据保管 |
恢复(snapshot restore)一旦出错,集群就不可用了,所以放到最后再单独练。
考试里怎么做
- 考试环境一般在节点上直接装好了 etcdctl,不走
kubectl exec;证书只有 root 能读,要加sudo - 题目通常会给出证书路径;没给就
sudo grepetcd.yaml - 命令模板在官方文档 Operating etcd clusters for Kubernetes,考试时可以查
- 同一台机器上要跑多条 etcdctl,可以用环境变量代替参数:
sudo -i
export ETCDCTL_CACERT=/etc/kubernetes/pki/etcd/ca.crt
export ETCDCTL_CERT=/etc/kubernetes/pki/etcd/server.crt
export ETCDCTL_KEY=/etc/kubernetes/pki/etcd/server.key
etcdctl snapshot save /opt/etcd-backup.db
sudo 默认不会带上普通用户的环境变量,所以先 sudo -i 切到 root 再 export。老教程里的 ETCDCTL_API=3 从 etcd 3.4 起就是默认值,不用再加。
集群升级
把集群从 v1.31.14 升到 v1.32.13,一共三台:控制平面 ubuntu,worker ubuntu1、ubuntu2。
全程照官方文档做,用到三页:
| 文档 | 讲什么 |
|---|---|
| Upgrading kubeadm clusters | 主流程、控制平面怎么升、失败了怎么办 |
| Changing the Kubernetes package repository | 换 apt 源 |
| Upgrading Linux nodes | worker 怎么升 |
路径是 Docs → Tasks → Administer a Cluster → Administration with kubeadm,和 etcd 那页在同一个大目录下。文档写的是最新版本(1.36 → 1.37),老版本的流程基本一样,把命令里的 1.37.x 换成自己的版本就行。
两条规则
一次只升一个小版本:1.31 → 1.32 可以,1.31 → 1.33 不行,要分两次。
先升控制平面,再升 worker:Version Skew Policy 规定 kubelet 可以比 API Server 旧,但不能比它新。先升 worker 的话,就成了 kubelet 比 API Server 新,不允许。
一个节点上的两层
升级要换两样东西,分两步换:
ubuntu(控制平面节点)
├─ kubelet ← apt 装的系统服务,自己 apt install 换
│ ├─ Static Pod:etcd / apiserver / controller-manager / scheduler ← kubeadm upgrade 换
│ ├─ DaemonSet Pod:calico-node / kube-proxy
│ └─ 普通 Pod:CoreDNS、应用……
kubeadm upgrade改的是/etc/kubernetes/manifests/里的清单,kubelet 看到清单变了,重建 Static Pod- kubelet 本身是个普通程序,kubeadm 不管,要用 apt 换包、重启服务
kubectl get nodes的 VERSION 列显示的是每个节点 kubelet 的版本,控制平面的版本要看kubectl version的 Server Version
升级前:备份
文档只说要备份重要的东西,没列清单。判断标准是丢了能不能重新生成:
| 东西 | 丢了会怎样 | 备不备 |
|---|---|---|
| etcd 数据 | 集群里所有对象都没了 | 必须 |
/etc/kubernetes/pki/ 证书 | 整台机器重建时,没有原来的 CA,所有组件和节点都要重新签证书 | 必须 |
| kubeadm 配置 | 重建时要用同样的参数(Pod 网段、API Server 地址等) | 建议 |
| Static Pod 清单、kubeconfig | kubeadm 用配置和证书能重新生成 | 不用 |
快照按上面「存快照」的方法存好,放进 ~/backup,再备份证书和配置:
sudo cp -r /etc/kubernetes/pki ~/backup/
kubectl -n kube-system get cm kubeadm-config -o yaml > ~/backup/kubeadm-config.yaml
kubeadm 配置不是一个现成的文件。我 init 时直接在命令行传参数,/root 下没有 kubeadm-config.yaml。kubeadm 把配置存进了集群:kube-system 里的 ConfigMap kubeadm-config,升级时读的也是它:
[upgrade/config] Reading configuration from the "kubeadm-config" ConfigMap in namespace "kube-system"...
导出来能看到 init 时定下的参数:
controlPlaneEndpoint: 10.177.16.45:6443
kubernetesVersion: v1.31.14
networking:
podSubnet: 192.168.0.0/16
serviceSubnet: 10.96.0.0/12
kubernetesVersion 就是 kubeadm 认定的集群版本,升级成功后会变成 v1.32.13。
换软件源
pkgs.k8s.io 每个小版本一个仓库,源地址里写死了版本号:
$ cat /etc/apt/sources.list.d/kubernetes.list
deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.31/deb/ /
不换源的话,apt-cache madison kubeadm 只能列出 1.31.0 到 1.31.14,根本没有 1.32。文档的主页面只用一句话提了这件事,然后链到另一页,很容易漏看;漏了也有兜底,madison 那一节写着:看不到想要的版本,就回去检查仓库。
sudo sed -i 's/v1.31/v1.32/' /etc/apt/sources.list.d/kubernetes.list
sudo apt update
sudo apt-cache madison kubeadm
kubeadm | 1.32.13-1.1 | https://pkgs.k8s.io/core:/stable:/v1.32/deb Packages
kubeadm | 1.32.12-1.1 | https://pkgs.k8s.io/core:/stable:/v1.32/deb Packages
...
kubeadm | 1.32.0-1.1 | https://pkgs.k8s.io/core:/stable:/v1.32/deb Packages
文档建议升到最新的补丁版本,选 1.32.13。
sed 's/找什么/换成什么/',三个/一个都不能少,少了最后一个会报unterminated 's' command- 先不加
-i跑一遍,屏幕上看结果对了再加-i改文件 - 每台节点的 apt 源是各自的,三台都要换
控制平面:ubuntu
第一步:升级 kubeadm
sudo apt-mark unhold kubeadm && \
sudo apt-get update && sudo apt-get install -y kubeadm='1.32.13-1.1' && \
sudo apt-mark hold kubeadm
$ kubeadm version
kubeadm version: &version.Info{Major:"1", Minor:"32", GitVersion:"v1.32.13", ...}
hold 是锁住版本,平时 apt upgrade 不会顺手把 K8s 的包升上去。K8s 升级要按顺序来,不能让 apt 自己决定。所以每次都是先 unhold、装好、再 hold 回去。
踩坑:第一次写成
kubeadm='1.32.13',报Version '1.32.13' for 'kubeadm' was not found。apt 的版本号要和 madison 第二列一字不差,后面的-1.1是打包修订号。文档写成'1.37.x-*',那个-*就是通配这一段的。失败后再跑一次,提示
kubeadm was already not on hold:第一次的&&链在 install 失败后就停了,最后的 hold 没执行。
第二步:upgrade plan
$ sudo kubeadm upgrade plan
[upgrade/versions] Cluster version: 1.31.14
[upgrade/versions] kubeadm version: v1.32.13
W1009 ... could not fetch a Kubernetes version from the internet: unable to get URL "https://dl.k8s.io/release/stable.txt": ... tls: handshake failure
W1009 ... falling back to the local client version: v1.32.13
Components that must be upgraded manually after you have upgraded the control plane with 'kubeadm upgrade apply':
COMPONENT NODE CURRENT TARGET
kubelet ubuntu v1.31.14 v1.32.13
kubelet ubuntu1 v1.31.14 v1.32.13
kubelet ubuntu2 v1.31.14 v1.32.13
COMPONENT NODE CURRENT TARGET
kube-apiserver ubuntu v1.31.14 v1.32.13
kube-controller-manager ubuntu v1.31.14 v1.32.13
kube-scheduler ubuntu v1.31.14 v1.32.13
kube-proxy 1.31.14 v1.32.13
CoreDNS v1.11.3 v1.11.3
etcd ubuntu 3.5.24-0 3.5.24-0
You can now apply the upgrade by executing the following command:
kubeadm upgrade apply v1.32.13
plan 只检查、不改动,两张表分别是:
| 表 | 内容 | 说明 |
|---|---|---|
| 第一张 | 三台的 kubelet | kubeadm 不管,apply 之后要自己 apt 换 |
| 第二张 | 控制平面组件 + addon | apply 自动换 |
- kube-proxy 的 NODE 列是空的:它是 DaemonSet,更新一次所有节点都生效
- CoreDNS 和 etcd 这次版本不变,所以文档里升级 etcd 前的注意事项(先停 apiserver 再升级)用不上
- TLS 警告:plan 会去 dl.k8s.io 查最新版本,我的网络走代理,没连上,就退回用 kubeadm 自己的版本当目标。不影响升级,apply 时明确写出版本号就行
第三步:upgrade apply
sudo kubeadm upgrade apply v1.32.13
问一次 [y/N],输入 y,几分钟后:
[upgrade] SUCCESS! A control plane node of your cluster was upgraded to "v1.32.13".
日志就是 apply 的工作流程:
| 顺序 | 日志 | 做了什么 |
|---|---|---|
| 1 | Pulling images | 先拉新镜像 |
| 2 | etcd → kube-apiserver → kube-controller-manager → kube-scheduler | 一个一个换,每个都等 kubelet 重启好才换下一个 |
| 3 | Renewing ... certificate | 顺手续签证书 |
| 4 | backing up old manifest to "/etc/kubernetes/tmp/kubeadm-backup-manifests-..." | 旧清单自动备份 |
| 5 | Storing the configuration used in ConfigMap "kubeadm-config" | 更新集群里记录的版本 |
| 6 | Writing kubelet configuration to file "/var/lib/kubelet/config.yaml" | kubelet 的配置换成新版,程序还是旧的 |
| 7 | Applied essential addon: CoreDNS / kube-proxy | 更新 addon |
etcd 版本没变,也显示 upgraded successfully:证书续签了,要重启一次才能加载新证书。
还有一条警告:
detected that the sandbox image "" of the container runtime is inconsistent with that used by kubeadm. It is recommended to use "registry.k8s.io/pause:3.10" as the CRI sandbox image.
kubeadm 没读到 containerd 配的 pause 镜像,不影响升级,containerd 会用它自己的默认值。
apply 完,版本对不上:
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
ubuntu Ready control-plane 41d v1.31.14
...
$ kubectl version
Client Version: v1.31.14
Server Version: v1.32.13
Server 已经是 1.32.13,VERSION 列还是 1.31.14,因为 kubelet 还没换。这时 API Server 1.32、kubelet 1.31,正是版本规则允许的状态。
第四步:drain
下一步要换 kubelet。新版 kubelet 接手后会把这台节点上的容器全部重启(文档:container spec hash 变了),所以先把能挪走的 Pod 挪到别的节点:
$ kubectl drain ubuntu --ignore-daemonsets
node/ubuntu cordoned
Warning: ignoring DaemonSet-managed Pods: kube-system/calico-node-6cpps, kube-system/kube-proxy-ns9nk
node/ubuntu drained
| 动作 | 作用 |
|---|---|
| cordon | 标记 SchedulingDisabled,不再往这里派新 Pod |
| evict | 驱逐现有 Pod,让它们去别的节点重建 |
两类 Pod 挪不走:Static Pod 和节点绑死了,drain 不碰;DaemonSet Pod 每个节点一份,赶走了会立刻再建回来,所以要加 --ignore-daemonsets 跳过。
这次一个 Pod 都没驱逐,输出里没有 evicting pod。ubuntu 上本来就只有这两类:
$ kubectl get pods -A -o wide --field-selector spec.nodeName=ubuntu
NAMESPACE NAME READY STATUS RESTARTS AGE
kube-system calico-node-6cpps 1/1 Running 8 (14h ago) 41d
kube-system etcd-ubuntu 1/1 Running 9 (20m ago) 41d
kube-system kube-apiserver-ubuntu 1/1 Running 0 19m
kube-system kube-controller-manager-ubuntu 1/1 Running 0 19m
kube-system kube-proxy-ns9nk 1/1 Running 0 18m
kube-system kube-scheduler-ubuntu 1/1 Running 0 18m
AGE 和 RESTARTS 两列正好反映 apply 做了什么:
| Pod | 变化 | 原因 |
|---|---|---|
| apiserver / controller-manager / scheduler | AGE 归零 | 清单换了新镜像,Pod 被替换 |
| kube-proxy | AGE 归零,名字也换了 | DaemonSet 更新,Pod 被替换 |
| etcd | AGE 还是 41d,重启次数 +1 | 清单没实质变化,只是容器重启加载新证书 |
| calico-node | 没变 | kubeadm 不管 CNI |
第五步:升级 kubelet 和 kubectl
sudo apt-mark unhold kubelet kubectl && \
sudo apt-get update && sudo apt-get install -y kubelet='1.32.13-1.1' kubectl='1.32.13-1.1' && \
sudo apt-mark hold kubelet kubectl
sudo systemctl daemon-reload
sudo systemctl restart kubelet
apt 的输出里有一段:
Restarting services...
systemctl restart kubelet.service
这是 Ubuntu 自带的 needrestart,发现 kubelet 的程序被换了,自动重启了一次。文档还是写了 daemon-reload + restart:不是每个系统都有 needrestart;新包可能改了 kubelet 的 systemd 配置,要 daemon-reload 才会重新读取。
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
ubuntu Ready,SchedulingDisabled control-plane 41d v1.32.13
ubuntu1 Ready <none> 41d v1.31.14
ubuntu2 Ready <none> 41d v1.31.14
第六步:uncordon
$ kubectl uncordon ubuntu
node/ubuntu uncordoned
再看 ubuntu 上的 Pod,4 个 Static Pod 的 AGE 全部变成了 96s:新 kubelet 接手后把它们重建了一遍。这就是 drain 的原因。
Worker:ubuntu1、ubuntu2
和控制平面的区别:
| 控制平面 | worker | |
|---|---|---|
| 先装什么 | kubeadm | kubeadm + kubectl |
| kubeadm 命令 | upgrade apply | upgrade node |
| drain / uncordon | 本机 | 回控制平面执行 |
| 后装什么 | kubelet + kubectl | kubelet |
一台升完、确认 Ready,再升下一台。两台同时 drain,Pod 就没地方去了。
| # | 在哪 | 做什么 |
|---|---|---|
| 1 | worker | 换源,装 kubeadm 和 kubectl 1.32.13-1.1 |
| 2 | worker | sudo kubeadm upgrade node |
| 3 | ubuntu | kubectl drain <节点> --ignore-daemonsets |
| 4 | worker | 装 kubelet 1.32.13-1.1,daemon-reload,restart kubelet |
| 5 | ubuntu | kubectl uncordon <节点> |
upgrade node 在 worker 上几乎全是跳过,只做一件事:
[upgrade/preflight] Skipping prepull. Not a control plane node.
[upgrade/control-plane] Skipping phase. Not a control plane node.
[upgrade/kubeconfig] Skipping phase. Not a control plane node.
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[upgrade/kubelet-config] The kubelet configuration for this node was successfully upgraded!
[upgrade/addon] Skipping the addon/coredns phase. Not a control plane node.
[upgrade/addon] Skipping the addon/kube-proxy phase. Not a control plane node.
它从集群拉 kubelet 的配置写到本地。新配置来自 ConfigMap kubelet-config,apply 时已经更新过了。
踩坑一:在 worker 上 drain
yustia@ubuntu1:~$ kubectl drain ubuntu1 --ignore-daemonsets
E1009 ... couldn't get current server API group list: Get "http://localhost:8080/api?timeout=32s": dial tcp 127.0.0.1:8080: connect: connection refused
The connection to the server localhost:8080 was refused - did you specify the right host or port?
worker 上没有 ~/.kube/config:join 时只拿到 kubelet 自己用的凭证,没有管理员的 kubeconfig。kubectl 找不到配置文件,就去连默认地址 localhost:8080,那里什么都没有。
看到 localhost:8080,就是 kubectl 没找到 kubeconfig,先看自己在哪台机器、用的哪个用户。文档里 drain 和 uncordon 的注释都写着 execute this command on a control plane node。
踩坑二:emptyDir 挡住 drain
$ kubectl drain ubuntu2 --ignore-daemonsets
node/ubuntu2 cordoned
error: unable to drain node "ubuntu2" due to error: cannot delete Pods with local storage (use --delete-emptydir-data to override): kube-system/metrics-server-75969676f4-xbfws, continuing command...
emptyDir 的数据存在节点本地,Pod 一删就没了。drain 不知道数据重不重要,就停下来等你确认。metrics-server 的 emptyDir 里只有启动时生成的临时文件,在别的节点重建时会重新生成,可以删:
kubectl drain ubuntu2 --ignore-daemonsets --delete-emptydir-data
判断标准还是能不能重新生成:缓存和临时文件可以删;有人把真实数据放进 emptyDir,就不该加这个参数,那个 Pod 应该改用 PV。
注意第一行的 node/ubuntu2 cordoned:cordon 做成了,驱逐失败了,节点处在不接新 Pod、旧 Pod 还在跑的状态。
踩坑三:重启了 kubelet,版本还是旧的
ubuntu2 上跑了 upgrade node,装了 kubeadm 和 kubectl,重启了 kubelet,uncordon 之后:
NAME STATUS ROLES AGE VERSION
ubuntu Ready control-plane 41d v1.32.13
ubuntu1 Ready <none> 41d v1.32.13
ubuntu2 Ready <none> 41d v1.31.14
漏了装 kubelet 那一步。upgrade node 只换配置,不换程序,重启的还是 1.31.14 的 kubelet。重新 drain、装 kubelet、重启、uncordon,三台就都是 v1.32.13 了:
NAME STATUS ROLES AGE VERSION
ubuntu Ready control-plane 41d v1.32.13
ubuntu1 Ready <none> 41d v1.32.13
ubuntu2 Ready <none> 41d v1.32.13
kubeadm upgrade node 在 ubuntu2 上跑了两次,结果都一样:kubeadm 升级命令重复跑是安全的。
升级失败怎么办
这次没失败,但文档 Recovering from a failure state 那一节值得知道。apply 跑到一半断电、断网,控制平面卡在一半新一半旧:
- 再跑一次
kubeadm upgrade apply:它会对比现状和目标,没做完的补上 kubeadm upgrade apply --force:不改版本,强制再走一遍- 手动从备份恢复:kubeadm 升级时自动备份到
/etc/kubernetes/tmp/
$ sudo ls /etc/kubernetes/tmp/
kubeadm-backup-etcd-2026-10-09-21-27-33 kubeadm-kubelet-config855925081
kubeadm-backup-manifests-2026-10-09-21-27-33
| 目录 | 恢复到 |
|---|---|
kubeadm-backup-etcd-* | /var/lib/etcd |
kubeadm-backup-manifests-* | /etc/kubernetes/manifests |
kubeadm-kubelet-config* | /var/lib/kubelet/config.yaml |
这个目录不会自动清理,确认集群正常后要自己删。
小结:集群升级做了什么
| # | 步骤 | 说明 |
|---|---|---|
| 1 | 备份 | etcd 快照、证书、kubeadm-config |
| 2 | 换源 | 每台节点都要换,每个小版本一个仓库 |
| 3 | 控制平面:kubeadm → plan → apply | 换 Static Pod 和 addon |
| 4 | 控制平面:drain → kubelet + kubectl → uncordon | 换 kubelet 程序 |
| 5 | worker 逐台:kubeadm + kubectl → upgrade node → drain → kubelet → uncordon | drain 和 uncordon 回控制平面执行 |
| 6 | 检查 | kubectl get nodes 三台都是新版本 |
命令速查表
etcdctl 的命令都省略了三个证书参数。
| 命令 | 用途 |
|---|---|
sudo grep -E 'data-dir|crt|key' /etc/kubernetes/manifests/etcd.yaml | 查数据目录和证书路径 |
kubectl -n kube-system exec etcd-<节点名> -- etcdctl <子命令> | 在 etcd 容器里执行 etcdctl |
etcdctl endpoint health | 健康检查(会真实写入一次) |
etcdctl endpoint status -w table | 版本、leader、数据库大小、Raft 状态 |
etcdctl member list -w table | 成员名单 |
etcdctl snapshot save <文件> | 存快照 |
etcdctl snapshot status <文件> -w table | 验证快照(3.6 起改用 etcdutl) |
sudo crictl ps --name etcd | 从容器运行时看 etcd 容器 |
sudo crictl inspect <容器ID> | 看容器的挂载等详情 |
升级相关,版本号以 1.32.13 为例:
| 命令 | 用途 |
|---|---|
sudo sed -i 's/v1.31/v1.32/' /etc/apt/sources.list.d/kubernetes.list | 换 apt 源到新的小版本 |
sudo apt-cache madison kubeadm | 列出可装的版本 |
sudo apt-mark unhold kubeadm / hold kubeadm | 解锁 / 锁住包版本 |
sudo apt-get install -y kubeadm='1.32.13-1.1' | 装指定版本,版本号要带 -1.1 |
sudo kubeadm upgrade plan | 检查能不能升、会换什么 |
sudo kubeadm upgrade apply v1.32.13 | 升级第一台控制平面 |
sudo kubeadm upgrade node | 升级其他节点(worker 上只更新 kubelet 配置) |
kubectl drain <节点> --ignore-daemonsets | 腾空节点;有 emptyDir 再加 --delete-emptydir-data |
kubectl uncordon <节点> | 恢复调度 |
sudo systemctl daemon-reload && sudo systemctl restart kubelet | 换完 kubelet 后重启 |
kubectl version | 看 API Server 版本(Server Version) |
kubectl get pods -A -o wide --field-selector spec.nodeName=<节点> | 看某个节点上跑着哪些 Pod |
grep 常用法
| 用法 | 场景 |
|---|---|
grep -E 'A|B' | 匹配 A 或 B |
grep -v Running | 反向匹配:k get po -A | grep -v Running 找出不正常的 Pod |
grep -i error | 忽略大小写 |
grep -A 10 Events | 显示匹配行及之后 10 行:k describe po x | grep -A 10 Events |