状态:实测(2026-10-31)。 是在 TT Lab 实验 VM 中使用 k0sctl v0.33.1 搭建 k0s v1.37.1+k0s.0 测得的数据。2 台场景新建了 VM 从头运行了两次,两次的数据均已填入表中。闭网的搭建方法与 第 1 篇相同。节点地址已替换为示例网段(192.0.2.0/24)。编写规则:实测数据为基础,无法测量的标记为“未实测”,原因未确认的解释标记为“推测”。
目录
1. 先说结论
- 2 台节点无法实现控制平面高可用。 2 个 etcd 成员的半数是 2,因此只要挂掉一个,写入就会停止。所以 2 台服务器采用了 1 个控制器 + 1 个工作节点。
- 即使控制器挂掉,工作节点的服务仍会继续。 在停止控制器 90 秒并以 5 秒间隔测量 18 次期间,API 虽然 18 次全部失败,但工作节点的 nginx 却 18 次全部返回了 200。重新开启控制器后,API 在 3~4 秒内恢复。
- 停止的是“变更操作”。 部署、扩缩容以及工作节点挂掉时的重新调度都需要经过控制器。2 台配置的风险不是服务中断,而是在此期间无法进行任何干预。
- 如果节点有两块网卡,仅靠 k0sctl 的
privateAddress是不够的。 由于 API 地址是在默认路由网卡上确定的,导致工作节点加入在 172 秒后失败,spec.api.onlyBindToAddress: true解决了该问题。 - 其他实测数据:2 台安装 1 分 58 秒~2 分 44 秒,添加工作节点 1 分 42 秒~2 分 31 秒,通过 drain 和
reset: true删除耗时 24~39 秒,k0sctl backup16~32 秒(416~440KB)。未测量从备份中恢复(未实测)。
2. 为什么 2 台是“1 个控制器 + 1 个工作节点”
k0s 的多控制器架构使用 etcd。etcd 要求成员过半数同意才能接受写入。引用 etcd FAQ中的表格如下。
| 成员数 | 半数 | 可承受故障 |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 | 2 | 1 |
2 个成员可承受的故障数与 1 个相同,但可能宕机的服务器却增加了一台。因此,“两台都是控制器”甚至不如 1 台控制器。为了检查而关掉一台时,剩下的一台的 API 连写入也会停止。在 2 台方案中实际需要抉择的是 控制器 1 + 工作节点 1 ,剩下的问题是控制器是否要兼任工作节点。
- 如果兼任,就会多出一个可以放置工作负载的节点。
- 但代价是工作负载的内存压力会动摇 API 和 etcd,且若因检查而关闭该节点,控制平面和工作负载会同时丢失。
这次没有选择兼任。兼任通常在有 3 个节点时使用(第 3 篇)。关于在数据库和搜索引擎中测量相同问题的文章,有 为什么 2 台服务器实现不了 HA 系列。
3. k0sctl 搭建
1 台时是手动安装的,但从 2 台开始使用 k0sctl。它通过单个文件声明:从导入服务器通过 SSH 将二进制文件和包上传到各个节点、写入配置、生成加入令牌并连接工作节点。在闭网中使用的配置如下。
apiVersion: k0sctl.k0sproject.io/v1beta1
kind: Cluster
metadata: {name: k0s-two}
spec:
hosts:
- role: controller
ssh: {address: 192.0.2.3, user: root, keyPath: /root/.ssh/id_ed25519}
hostname: ctrl01
privateInterface: eth1
privateAddress: 192.0.2.3
uploadBinary: true
k0sBinaryPath: /opt/airgap/bin/k0s-v1.37.1+k0s.0-amd64
- role: worker
ssh: {address: 192.0.2.4, user: root, keyPath: /root/.ssh/id_ed25519}
hostname: worker01
privateInterface: eth1
privateAddress: 192.0.2.4
uploadBinary: true
k0sBinaryPath: /opt/airgap/bin/k0s-v1.37.1+k0s.0-amd64
installFlags: [--kubelet-extra-args=--node-ip=192.0.2.4]
files:
- {name: k0s-airgap-bundle, src: /opt/airgap/images/k0s-airgap-bundle-v1.37.1+k0s.0-linux-amd64.tar, dstDir: /var/lib/k0s/images/, perm: "0644"}
- {name: apps-bundle, src: /opt/airgap/images/apps-bundle.tar, dstDir: /var/lib/k0s/images/, perm: "0644"}
k0s:
version: v1.37.1+k0s.0
config:
apiVersion: k0s.k0sproject.io/v1beta1
kind: ClusterConfig
metadata: {name: k0s}
spec:
api: {onlyBindToAddress: true} # NIC 가 둘이면 필요하다(3.1)
network: {provider: kuberouter, podCIDR: 172.20.0.0/16, serviceCIDR: 172.21.0.0/16}
images: {default_pull_policy: Never}
telemetry: {enabled: false}
只挑出对闭网有意义的行。
uploadBinary: true+k0sBinaryPath. 如果不提供路径,k0sctl 会尝试从 GitHub 发布页面获取 k0s。必须指定已导入的二进制文件,才不会访问外网。如果提供了路径,k0sctl 会留下“此主机不进行版本检查”的警告,这是无害的。files只放在工作节点上 。纯控制器不运行 Pod,因此不需要镜像包。如果src是 URL,那也会成为访问外网的途径,因此应使用本地路径。installFlags的--node-ip. 会让 kubelet 以内网地址自报家门。如果有privateAddress,k0sctl 会自动添加此标志(host.go),但为了在文件中直接看到使用内容,这里将其显式写出。
在导入服务器上运行 k0sctl apply 后,两次运行分别在 1 分 58 秒、2 分 44 秒(k0sctl 报告)内完成。即使使用相同的配置,每次运行也有这样的差异,原因未做深入探究。对于纯工作节点,由于 k0sctl 会通过“waiting for node to become ready”一直等到其变为 Ready,因此 apply 结束时工作节点已经处于 Ready 状态。控制器兼任工作节点时没有这种等待,这一点将在第 3 篇中再次提到。
3.1 坑:加入令牌指向了错误的地址
最初没有写 onlyBindToAddress ,只写了 privateInterface·privateAddress 。明明明确写了要使用内网地址,以为会成功,但工作节点加入却挂起了 172 秒,并以如下方式结束。
failed to connect to kubernetes api using the join token ...
The connection to the server <NAT 주소>:6443 was refused
<NAT 주소> 是这些测试 VM 默认路由网卡的地址。由于 KubeVirt 的 masquerade 网络为所有 VM 分配了相同的 NAT 地址,因此当工作节点访问该地址的 6443 端口时,会访问到 自己。那里没有 API,因此遭到拒绝。
为什么令牌中会包含那个地址,已在 k0sctl 源码中得到确认。 configure_k0s.go将
privateAddress 用于 etcd 对端地址(spec.storage.etcd.peerAddress),但仅当 spec.api.address 为 true 时才填充 onlyBindToAddress 。 如果为空,则由 k0s 决定。根据 配置文档,默认值是“节点上找到的第一个非回环地址”,在此 VM 中即为默认路由网卡的 NAT 地址。加入令牌中便包含了此地址。
spec.api.onlyBindToAddress: true 提供 address 会同时改变两件事。k0sctl 会为每个主机将 privateAddress 填充为该主机的
,并且 API 服务器将仅绑定到该地址,而不是所有接口。k0s 配置文档也指出,对于“多宿主控制平面节点”,可能需要此选项。修改后,2 台安装在 1 分 58 秒内以 rc=0 结束。 config 在第 3 篇的 3 个控制器方案中,此方法实际上是唯一的出路。k0sctl 的 address 在集群中只有一个,因此没有位置为每个控制器填写不同的
#1026(无法为每个 HA 控制器指定不同的地址),
#1035(工作节点无法通过私有地址连接到 API),
#760(privateInterface 无法选择正确的地址)。
4. 停止控制器 90 秒会怎样
4.1 测量方法
在工作节点上运行两份 nginx(Deployment web)和 NodePort Service(30080)后,停止了控制器的 k0scontroller 服务。在导入服务器上大约每 5 秒记录两项内容:
- API:
kubectl get nodes是否成功。 - 服务:
curl http://<워커>:30080/的状态码。
大约第 20 秒时,杀死了工作节点中 nginx 最旧的进程,即主进程(pkill -o -x nginx)。这是为了观察在没有 API 的情况下容器死亡时由谁来重新启动它。90 秒过后,重新开启控制器并测量了 API 恢复所花费的秒数。
4.2 结果
| 轮次 | API | 工作节点 nginx | nginx 主进程 kill 后重启次数 | 控制器重启 → API 恢复 |
|---|---|---|---|---|
| 1 | 18/18 失败 | 18/18 = 200 | 0 → 1(在 API 恢复后报告) | 4秒 |
| 2(最终) | 18/18 失败 | 18/18 = 200 | 0 → 0 | 3秒 |
工作节点的 k0sworker 服务自始至终处于 active 状态。
服务持续运行的原因。 请求经过的路径全都在工作节点内部。将 NodePort 转发到 Pod 的 kube-proxy 规则、运行容器的 containerd 以及保护 Pod 的 kubelet 全都在工作节点上运行,控制器仅在决定“把什么放在哪里”时才需要。因此,已经启动的 Pod 即使没有控制器也能响应。
重新启动死去的容器。 在第 1 轮中,杀死 nginx 主进程后,重启次数从 0 上升到 1。由于 kubelet 拥有已经接收到的 Pod 定义和重启策略,因此即使没有 API 也能重新启动容器。只不过它无法将该事实报告给 API,所以该数字直到 API 恢复后才显示出来。在最后一轮中执行了同样的操作,但重启次数保持为 0。在此期间,nginx 的响应 18 次全部为 200。为什么这次没有被计入重启次数,原因未能确认(原因未确认)。因此,“kubelet 在没有 API 的情况下进行重启”仅作为在两次中观测到一次的事实记录。
停止的内容。 在没有控制器的期间,以下操作全都无法进行:
- 新部署、扩缩容、修改配置。全都要经过 API。
- 当工作节点挂掉时,在其他地方重新启动 Pod。在 2 台配置中,原本就没有其他工作节点可供迁移。
- 状态报告。像重启次数这样在此期间发生的事情,只有在 API 恢复后才能看到。
2 台配置的风险不是“服务停止”,而是 “在停止期间无法更改任何内容,且在此期间若工作节点挂掉则没有主体来将其复活” 。因此,在 2 台方案中,控制器的快速恢复即备份(第 6 节)比高可用性更为优先。
5. 添加和删除工作节点
添加。 在 k0sctl.yaml 的 hosts 中添加了 worker02 并执行了 apply。由于 k0sctl 是声明式的,因此它没有触碰已有的节点,仅运行了“Install workers”阶段,耗时 1 分 42 秒 · 2 分 31 秒 后 worker02 就变成了 Ready 状态。这就是第 3 篇中进行对比的「控制器 1 + 工作节点 2」架构。
减。 顺序很重要。
kubectl drain worker01 --ignore-daemonsets --delete-emptydir-data将 Pod 迁移到 worker02。因为需要有可迁移的目标,所以先执行了加节点。- 在 k0sctl.yaml 的 worker01 主机中
reset: true并执行 apply。k0sctl 在「Reset workers」阶段删除了该节点的 k0s,节点列表中也找不到 worker01 了。 24 秒 · 39 秒 耗时。
两个 nginx 实例在 worker02 上一直可用。重置时 failed to remove existing configuration /etc/k0s/k0s.yaml: file does not exist 虽然会出现警告,但由于工作节点上原本就没有 k0s.yaml,因此该警告无害。
6. 备份
k0sctl backup --config k0sctl.yaml
| 次数 | 耗时 | 文件 |
|---|---|---|
| 1 | 16 秒 | k0s_backup_<epoch>.tar.gz 416KB |
| 2 | 32 秒 | 440KB |
k0sctl 会在控制器上运行 k0s backup 并将结果拉取到导入服务器。 k0s 备份文档显示,内部包含 etcd 快照、证书以及密钥。由于其中也包含集群 CA 的私钥, 备份文件必须像机密信息一样妥善保管。 不能因为身处封闭网络,就将该文件随便放在某个共享文件夹中。
恢复(k0sctl apply --restore-from)这次并未进行测量(未实测)。相关文档指出,恢复操作必须在控制器上进行,并且当恢复到不同 IP 的节点时,有单独的地址匹配步骤。要想在 2 节点架构中用备份来替代高可用性,接下来的工作就是测量恢复时间。
7. 总结
| 检查项 | 原因(本文实测结果) |
|---|---|
| 如果是 2 节点,则控制器只有一个 | etcd 2 成员能承受的故障数为 0,只要宕掉一个,写入就会停止 |
如果网卡(NIC)有两个, onlyBindToAddress: true |
如果没有,加入令牌将包含默认路由地址,并在 172 秒后失败 |
k0sBinaryPath 如果缺少 files |
路径或使用了 URL,导入服务器将尝试访问外网 |
| 通过「不可变」来应对控制器故障 | API 18/18 失败期间服务 18/18 正常,恢复需 3~4 秒 |
减节点应在 drain 之后进行,遵循 reset: true |
顺序时两个 web 实例不会中断 |
| 备份文件应作为机密信息保管 | 其中包含 etcd 快照和 CA 密钥 |
| 恢复时间需单独测量 | 本次未实测,但在 2 节点架构中,这就是恢复计划的全部 |
本文的实测可以在 TT Lab 的实训课程「封闭网络 k0s:1、2、3 节点及其之上的 Ceph 与 PostgreSQL」(网站路径 /course/k0s-airgap-multinode)的第二个模块「2 节点」中,使用相同的版本和相同的导入清单进行上手演练。你可以在真实的虚拟机上进行控制器停止、工作节点加减以及备份操作,并且评分系统会在每个阶段验证结果。
TT 小结。 班长(控制器)短暂离开座位,课堂(服务)也会继续进行。只是在此期间,不能调换座位,也不能引入新同学。而且,虽然选两个班长似乎会更让人安心,但如果规则是必须两人一致同意才能做决定,那么只要有一个人生病,就什么也决定不了啦!