课程目录(第 18 章 / 共 33 章)
课程/进阶:生产实践

生产集群部署:从节点规划到 kubespray

18 章 / 共 33·25 分钟·进阶生产实践集群部署kubesprayetcd

把集群从本地几个容器搬到真实机房:规划节点与磁盘、用 fio 验证 etcd 磁盘、用 kubespray 完成高可用部署,并学会后续的扩缩容与升级。

学完这一章,你将能够

  • 说得出生产集群与本地 kind 的差别,并写出节点、磁盘与端口的规划
  • 用 fio 验证 etcd 磁盘的 fsync 延迟是否达标
  • 用 kubespray 的 inventory 与 group_vars 部署一个高可用集群并完成验证
  • 会安全地扩容节点、按组件升级集群

从 kind 到生产集群:差在哪里

前面十几章里,你的集群很可能是 kind 创建的:几个 Docker 容器扮演节点,控制面和 worker 挤在同一台笔记本里。它足够用来学习,但一旦要接管真实流量,几乎所有前提都变了。

生产集群最核心的三个变化是:节点是多台真实机器控制面必须高可用磁盘是会被写坏的。任何一环偷懒,都会在某天凌晨变成事故。

维度kind(本地学习)生产集群
节点1~3 个 Docker 容器独立物理机或虚拟机
控制面单实例,删了重建3 节点高可用,容忍 1 台故障
etcd 磁盘宿主机磁盘,可能还是笔记本 SSD独占 NVMe,禁止与其他 I/O 共享
网络容器网桥 + 端口映射真实路由与 VIP,内网 10Gb 起步
升级kind delete cluster 重建逐节点滚动升级,业务不中断

节点规划:控制面、worker 与磁盘

规划的第一件事是数机器。控制面必须用奇数台,因为 etcd 靠多数派(quorum)投票,3 台能容忍 1 台故障,5 台能容忍 2 台,2 台反而比 1 台更脆弱。所以生产的起步答案是 3 台。

角色数量建议规格参考说明
控制面 + etcd3(最多 7)4C 8G 起etcd 独占 NVMe,节点之间低延迟
worker(CPU)按业务量8C 16G 起按 requests 总量除以单机可分配量估算
worker(GPU)按业务量按卡型通常单独打标签、单独污点
负载均衡 / VIP2 台或复用控制面小规格给 apiserver 一个稳定入口
存储节点3 起独立数据盘跑 Rook/Ceph 时另算

worker 数量没有公式,但有两条经验:先把所有工作负载的 requests 加起来,按 70% 的分配率折算(其余留给系统组件与突发);节点规格尽量一致,否则调度会偏向大机器。

第二件事是把磁盘拆开。这是新手最容易忽略、代价也最大的一步。

挂载点用途要求
/操作系统SSD,50~100GB 足够
/var/lib/etcdetcd 的 WAL 与数据库独占一块 NVMe,禁止机械盘、NAS、SAN、iSCSI、Ceph RBD、NFS
/var/lib/containerd镜像与容器可写层SSD,容量按镜像数量估算

为什么 etcd 不能和别人共享磁盘

etcd 的每次写都要把 WAL 同步落盘(fsync)才敢确认。如果同一块盘上还有日志刷盘、镜像拉取、数据库压测,fsync 延迟就会被拖长,表现是 apiserver 突然变慢、甚至频繁重新选主。给 etcd 一块独占盘,是最便宜的保险。

网络与端口清单

节点之间至少要有千兆、推荐万兆内网互联,并且全部节点互通。控制面节点之间的网络尤其重要:etcd 的心跳与日志复制都走它。

防火墙不要「先关了再说」,而是按清单放行。下面这张表是自建集群最常见的一套端口。

端口协议组件用途
6443TCPkube-apiserverkubectl 与所有节点访问控制面的入口
2379-2380TCPetcd客户端请求与 peer 复制
10250TCPkubeletapiserver 访问 kubelet、exec、logs
30000-32767TCPNodePortService 对外暴露端口段
8472 / 4789UDPVXLANFlannel、Cilium、Calico 的封装
179TCPBGPCalico 与物理网络做 BGP 对等

还有一条软性要求:所有节点时间必须同步。用 chrony 或 systemd-timesyncd 都行,偏差控制在毫秒级,否则证书校验和事件时间线都会出问题。

etcd 最怕磁盘慢:部署前用 fio 验一遍

磁盘性能是这个模块里唯一能在部署前量化验证的东西。方法很直接:用 fio 模拟 etcd 写 WAL 的行为,测 fsync 延迟。

bash
# fio 版本建议 >= 3.5,旧版本不输出 fdatasync 百分位
sudo mkdir -p /var/lib/etcd
sudo fio --rw=write --ioengine=sync --fdatasync=1 \
    --directory=/var/lib/etcd \
    --size=22m --bs=2300 \
    --name=etcd-test

三个关键参数:--fdatasync=1 让 fio 每次写完都做一次 fdatasync,复刻 etcd 的落盘动作;--bs=2300 匹配 etcd WAL 条目的典型大小;--size=22m 产生约一万个样本,让 p99 统计有意义。

看输出里的 sync percentiles (usec) 段:

text
sync percentiles (usec):
  | 99.00th =[ 2376]     ← 2.4ms,健康;接近 10000 就是余量不足

判据只有一条:99.00th 小于 10000 usec(10ms)算合格。上面这组 2.4ms 属于很健康;如果 p99 已经在 9ms 附近,说明余量不足,真实 etcd 还叠加了其他 I/O,建议换盘再上。参考口径是:小集群至少 50 顺序写 IOPS,大集群推荐 500 以上。

动手:给你的磁盘打一次分

  1. 准备一台目标节点,用 lsblkdf -h 确认哪块盘将承载 /var/lib/etcd
  2. 装上 fio:sudo apt install -y fiosudo dnf install -y fio
  3. 跑上面的命令,记下 99.00th 的数值。
  4. 再跑一次,把 --directory 换成 /tmp(系统盘),对比两者差距。如果差距很大,说明把 etcd 放系统盘一定会拖慢控制面。

部署之后还要持续盯着运行时指标:etcd_disk_wal_fsync_duration_seconds 的 p99 应低于 10ms,etcd_disk_backend_commit_duration_seconds 的 p99 应低于 25ms,etcd_server_leader_changes_seen_total 应长期为 0;etcd 3.6+ 再看一眼 etcd_disk_wal_write_duration_seconds,它覆盖 write() 系统调用本身的延迟——出现过 fsync 正常但 write() 因内核问题极慢的案例。

注意 10ms 是运行目标,不是告警线:etcd 官方告警规则要到 WAL fsync p99 超过 500ms 才 warning、1s 才 critical,告警响的时候集群早就严重劣化了。另外 --bs=2300 只是经验值,可以在自己的集群上用 sudo strace -p $(pgrep -x etcd) -e write 复核真实 WAL 条目大小;同节点有 I/O 竞争时用 sudo ionice -c2 -n0 -p $(pgrep -x etcd) 给 etcd 提优先级。参考:reference/k8s-in-action/k8s/etcd-disk-performance.md

用 kubespray 部署集群

kubespray 是官方社区维护的 Ansible 部署工具,它把 kubeadm、etcd、CNI、CoreDNS 等组件串成一套可重复执行的剧本。相比手工 kubeadm,它更适合「以后还要反复扩容和升级」的场景。

准备安装机

找一台能 SSH 免密登录所有节点的机器作为安装机(可以是控制面之一,也可以单独一台):

bash
export KUBESPRAY_VERSION="2.31.0"
wget https://github.com/kubernetes-sigs/kubespray/archive/refs/tags/v${KUBESPRAY_VERSION}.tar.gz
tar zxf v${KUBESPRAY_VERSION}.tar.gz
cd kubespray-${KUBESPRAY_VERSION}

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

写 inventory

复制一份示例 inventory,再改成你的机器:

bash
cp -r inventory/sample inventory/mycluster

编辑 inventory/mycluster/inventory.ini,主机名只是标签,ansible_host 才是真实地址:

text
[kube_control_plane]
mn-10-0-0-1 ansible_host=10.0.0.1 etcd_member_name=etcd1
mn-10-0-0-2 ansible_host=10.0.0.2 etcd_member_name=etcd2
mn-10-0-0-3 ansible_host=10.0.0.3 etcd_member_name=etcd3

[etcd:children]
kube_control_plane

[kube_node]
cn-10-0-1-1 ansible_host=10.0.1.1
cn-10-0-1-2 ansible_host=10.0.1.2

[k8s_cluster:children]
kube_control_plane
kube_node

关键 group_vars

只需改几个文件里的少量字段。group_vars/k8s_cluster/k8s-cluster.yml

yaml
kube_version: v1.31.5
kube_network_plugin: cilium
kube_service_addresses: 172.23.0.0/16
kube_pods_subnet: 172.24.0.0/13
kube_proxy_mode: ipvs
container_manager: containerd

group_vars/k8s_cluster/addons.yml 里打开 kube-vip,给控制面一个高可用地址:

yaml
kube_vip_enabled: true
kube_vip_arp_enabled: true
kube_vip_address: 10.0.0.100
kube_vip_interface: eth0

group_vars/all/etcd.yml 确认 etcd 的数据目录和配额:

yaml
etcd_data_dir: /var/lib/etcd
etcd_deployment_type: host
etcd_quota_backend_bytes: "8589934592"

etcd_deployment_type: host 表示 etcd 以 systemd 服务跑在宿主机上,这是当前版本的默认值。配额一项要留意:kubespray 2.31 的默认值是 2147483648(2GiB),etcd 官方把 8GiB 视为常规环境的上限建议,所以这里显式写成 8GiB 是一个常见做法;配额调大也会增加 etcd 的内存占用,机器只有 4GB 内存时不要照抄。

Pod 与 Service 网段别和机房冲突

kube_pods_subnetkube_service_addresses 一旦定下来,后期修改代价很高。规划时先问清楚机房、VPN、公司内网用掉了哪些网段,避开它们。

执行部署并验证

bash
source .venv/bin/activate
ansible-playbook -i inventory/mycluster/inventory.ini -b -u=root cluster.yml

从任意控制面节点取 kubeconfig,并把 server 改成 VIP 地址:

bash
mkdir -p ~/.kube
scp root@10.0.0.1:/etc/kubernetes/admin.conf ~/.kube/config
# 把 server 字段改成 https://10.0.0.100:6443
sed -i 's#https://10.0.0.1:6443#https://10.0.0.100:6443#' ~/.kube/config
kubectl get nodes -o wide

怎么算成功?kubectl get nodes 里每台机器都是 Ready、版本一致,kube-system 下 apiserver、scheduler、controller-manager、etcd、CoreDNS、CNI 的 Pod 全部 Running,且 CNI 的 DaemonSet 副本数等于节点数:

bash
kubectl get pods -n kube-system

高可用控制面与 VIP

高可用的意思是:任意一台控制面挂掉,集群仍然能接受请求。先看一张完整的拓扑,把每个角色的位置对上号:

text
              kubectl / kubelet / 控制器
                        │ https://10.0.0.100:6443
              ┌─────────▼──────────┐
              │ VIP 10.0.0.100     │  kube-vip(ARP 或 BGP)
              └─────────┬──────────┘
        ┌───────────────┼───────────────┐
        ▼               ▼               ▼
  ┌──────────┐    ┌──────────┐    ┌──────────┐
  │ cp1      │    │ cp2      │    │ cp3      │
  │ apiserver│    │ apiserver│    │ apiserver│
  │ etcd1    │◄──►│ etcd2    │◄──►│ etcd3    │  多数派:32
  └────┬─────┘    └────┬─────┘    └────┬─────┘
       └───────┬───────┴───────┬───────┘
               ▼               ▼
         ┌─────────┐     ┌─────────┐
         │ worker1 │     │ worker2 │   kubelet + CNI
         └─────────┘     └─────────┘

节点只认 VIP,不关心背后是哪台控制面;etcd 之间的日志复制走控制面之间的内网,和业务流量分开。高可用由两部分组成:etcd 的多数派——3 个成员里只要有 2 个活着就能选出 leader,所以控制面必须 3 台起,且最好跨机柜或跨可用区;apiserver 前面的稳定入口——kubeconfig、kubelet、controller 都指向这个 VIP,由 kube-vip(ARP 或 BGP 模式)或外部 HAProxy/Keepalived、F5 承担,它只把连接送到还活着的 apiserver,不参与 etcd 投票。

判断高可用是否真的生效,不要只看机器数量,而是随便关掉一台控制面,再用 kubectl get nodeskubectl get --raw='/readyz?verbose' 验证集群还能用。

升级与扩缩容节点

扩容:把新节点加进 inventory,然后只对它执行 scale 剧本,避免全量重跑。

bash
ansible-playbook -i inventory/mycluster/inventory.ini -b -u=root playbooks/facts.yml
ansible-playbook -i inventory/mycluster/inventory.ini -b -u=root scale.yml --limit=cn-10-0-1-3
kubectl get nodes -o wide

下线节点:先驱逐业务,再从集群里摘掉。

bash
kubectl drain cn-10-0-1-3 --ignore-daemonsets --delete-emptydir-data
kubectl delete node cn-10-0-1-3

升级:kubespray 的剧本是幂等的,改掉 kube_version 后重跑即可;先升控制面再升 worker,只更新单个组件时用 tag 缩小范围。

bash
ansible-playbook -i inventory/mycluster/inventory.ini -b -u=root \
  -e kube_version=v1.32.0 cluster.yml --limit=kube_control_plane
ansible-playbook -i inventory/mycluster/inventory.ini -b -u=root \
  -e kube_version=v1.32.0 cluster.yml --limit=kube_node
ansible-playbook -i inventory/mycluster/inventory.ini -b -u=root cluster.yml --tags=coredns

升级前一定要用 etcdctl snapshot save 备份 etcd,并确认节点时间同步、磁盘空间充足。

控制面扩容不能用 scale.yml

scale.yml 是给 worker 用的:它按「新增节点」处理并重写 etcd 成员配置,控制面从 1 台扩到 3 台时很容易把 ETCD_INITIAL_CLUSTER 写坏。正确做法是把新节点当成「一个坏掉的成员来恢复」,用 recover-control-plane.yml:新节点同时登记进 kube_control_planebroken_etcdbroken_kube_control_plane 两个组。

text
[kube_control_plane]
mn-10-0-0-1 ansible_host=10.0.0.1 etcd_member_name=etcd1
mn-10-0-0-2 ansible_host=10.0.0.2 etcd_member_name=etcd2

[broken_etcd]
mn-10-0-0-2 ansible_host=10.0.0.2 etcd_member_name=etcd2

[broken_kube_control_plane]
mn-10-0-0-2 ansible_host=10.0.0.2
bash
ansible-playbook -i inventory/mycluster/inventory.ini -b -u=root playbooks/facts.yml
ansible-playbook -i inventory/mycluster/inventory.ini -b -u=root \
  recover-control-plane.yml --limit=etcd,kube_control_plane -e etcd_retries=10

etcd_member_name 必须唯一,且两个组里的写法要一致。跑完*务必把 `broken_ 两个组从 inventory 删掉**,否则以后每次执行 playbook 都会把这些节点当成故障节点处理。用 etcdctl ... member list 验证:每个成员都是 started、成员数为奇数,才算成功。**控制面缩容比扩容更危险**,必须先移除 etcd 成员、等集群重新选出 leader,再下线机器,顺序反了会直接丢多数派。参考:reference/k8s-in-action/k8s/kubespray/kubespray-2.28.0/control-plane-scale.md`

换版本前先核对这几条破坏性变更

kubespray 的破坏性变更通常来自它带上的 Kubernetes 与 etcd,而不是它自己。以 2.30 → 2.31(默认 K8s 1.35、etcd 3.6)为例:

变更影响上线前怎么确认
cgroup v1 默认拒绝启动K8s 1.35 起 kubelet 在 cgroup v1 节点上直接启动失败stat -fc %T /sys/fs/cgroup 必须是 cgroup2fs,再 grep -r unified_cgroup_hierarchy /etc/default/grub* 确认没被手工降级
etcd 3.5 → 3.6大版本升级,升级与回退都要按官方流程,不能只换二进制升级前 etcdctl snapshot save,确认数据目录与配额
kube_proxy_mode: ipvs 被废弃K8s 1.35 起启动即告警,未来移除新集群直接选 nftables,老集群把迁移排进计划
ingress-nginx / dashboard addon 被移除这两个 addon 开关不再生效入口改用 Gateway API 或其它控制器,看板换 Headlamp
bash
stat -fc %T /sys/fs/cgroup   # cgroup2fs 才安全
ansible-playbook -i inventory/mycluster/inventory.ini -b -u=root cluster.yml --list-tags

参考:reference/k8s-in-action/k8s/kubespray/kubespray-2.31.0/kubespray-changes.md

OS 与 VIP 准备:跑 playbook 之前先对齐基线

节点装好系统之后、执行 cluster.yml 之前,有几项必须在所有节点上对齐,它们都属于事后返工代价极高的类型。先把节点列表写进一个文件(如 all 里写 mn-10-0-0-[1-3]cn-10-0-1-[1-2]),然后用 pdsh 批量执行,别一台台 SSH:

bash
pdsh -w ^all ip link show eth0 | grep mtu   # 期望全部 mtu 1500,不一致会让 CNI 封装出怪问题
pdsh -w ^all hostnamectl --static           # 主机名必须全局唯一,克隆虚拟机最容易重复
pdsh -w ^all timedatectl                    # 时区与时同步状态

两条最容易被忽略的基线:网卡名要统一,CNI 依赖固定接口名,物理机建议用 netplan 的 match.macaddress + set-name 固定成 eth0节点间 `ping` 延迟大于 0.05ms 就关掉 CPU 省电模式,用 tuned-adm profile latency-performance,否则 etcd 心跳与选举会抖。

内网环境还要在 group_vars/all/all.yml 里补三个变量,否则装完才发现 CoreDNS 起不来或时间漂移:upstream_dns_servers(节点访问不到外网 DNS 时,CoreDNS 与 NodeLocal DNS 会直接起不来)、ntp_servers(内网 NTP,时间漂移会导致证书校验失败)、http_proxy / https_proxyno_proxy(节点不能出网时拉不到镜像)。参考:reference/k8s-in-action/k8s/kubespray/kubespray-2.31.0/README.md

如果 apiserver 入口不用 kube-vip,而是 keepalived + haproxy 这套传统做法,下面几个参数必须核对:

text
interface eth0           # 换成真实网卡名
virtual_router_id 52     # 0-255 随机取;同网段有别人的 keepalived 必须避开
virtual_ipaddress        # VIP 掩码必须与物理网络一致,否则可能不可达:10.0.0.100/24
unicast_peer             # 用单播而非组播(云上、跨机柜常禁组播),列出其余控制面 IP
track_script chk_haproxy # haproxy 进程没了就交出 VIP

参考:reference/k8s-in-action/os/os.mdreference/k8s-in-action/os/keepalived.md

常见坑与排错

部署类问题的排查顺序很固定:先看节点是否注册成功,再看 kubelet 与容器运行时的日志,最后才怀疑网络和存储。下面这张表把最常见的几种症状对上原因。

现象原因怎么确认怎么办
kubectl 偶发超时etcd 磁盘 fsync 变慢或同盘有其他 I/Oetcd_disk_wal_fsync_duration_seconds 的 p99、iostat -xm 1把 etcd 挪到独占 NVMe,迁走同盘的压测与日志刷盘
某台节点一直 NotReadykubelet 或容器运行时版本不匹配、swap 没关、CNI Pod 没起来kubectl describe node <name>journalctl -u kubelet -n 100统一版本、swapoff -a、检查 CNI DaemonSet 副本数
ansible-playbook 卡在某台机器SSH 免密没配好、时间不同步、防火墙拦了端口ansible -i inventory/mycluster/inventory.ini all -m pingchronyc tracking修免密与时间同步,对照本章端口表逐条放行
新加的节点不接活缺少业务需要的标签,或节点上有污点kubectl get nodes --show-labelskubectl describe node <name>补标签、确认污点与容忍是否匹配
控制面频繁重新选主控制面之间网络抖动,或 etcd 盘太慢etcd_server_leader_changes_seen_total 持续增长检查跨机柜网络与磁盘延迟,必要时重排部署位置
apiserver 报证书错误节点时间偏差过大或证书已过期datekubeadm certs check-expiration修好 chrony 后再排查证书

常见坑

  • etcd 磁盘慢导致 apiserver 抖动:症状是 kubectl 偶发超时、etcd_server_leader_changes_seen_total 增长。先看 fsync p99,再看同节点是否跑了 I/O 密集型负载。
  • kubelet 与容器运行时版本不匹配:containerd 或 CRI-O 太旧会导致节点 NotReady,报错通常在 journalctl -u kubelet 里;时间不同步则表现为证书校验失败与事件时间错乱。两者都在部署前统一。
  • swap 未关闭:kubelet 默认拒绝启动。用 swapoff -a 并注释 /etc/fstab 里的 swap 行。
  • 防火墙未放行端口:节点之间不通,表现为 CNI Pod 起不来或 apiserver 连不上 kubelet。对照本章的端口表逐条放行。
  • 主机名或 machine-id 重复:从模板克隆的虚拟机最容易出这个问题,会导致节点注册混乱。

自测题

自测:为什么控制面要 3 台,2 台反而比 1 台更危险?(点击展开答案)

etcd 靠多数派(quorum)决定能否写入,2 台的多数派是 2,也就是任何一台挂掉就失去多数派,集群立刻无法写入、无法选主。你付出了两台机器的成本和两倍的故障概率,却换来了零容忍能力。1 台至少是「坏了就全坏」,逻辑简单;3 台才第一次获得「坏 1 台仍然可用」的收益,5 台能容忍 2 台。所以生产的最小正确答案是 3 台,并且分散到不同机柜或可用区。

自测:fio 的 99.00th 是 9ms,判据是小于 10ms,为什么还要谨慎?(点击展开答案)

因为判据是上线门槛,不是安全线。这个 9ms 只是裸盘上空跑出来的数字,真实 etcd 还要同时处理 WAL 写入、后端 compaction、快照传输,叠加之后 p99 很容易突破 10ms,表现就是 apiserver 偶发超时和 leader 频繁切换。经验做法是留出一倍以上余量(比如 p99 在 2~4ms),或者干脆换一块更好的盘。反过来,如果 p99 已经接近 10ms,那就不该把 etcd 放上去。

自测:kube-vip 提供了 VIP,它参与 etcd 投票吗?(点击展开答案)

不参与。VIP 只是 apiserver 的入口,负责把客户端连接送到还活着的控制面节点上;etcd 的成员身份、日志复制和投票完全由 etcd 自己完成,和 VIP 无关。理解这一点很重要:VIP 挂了只影响你「怎么连」apiserver,不会让 etcd 失去多数派;反过来,etcd 失去多数派时,即使 VIP 正常,集群也无法接受写操作。

小结

  • 生产集群和 kind 的差别在节点、高可用与磁盘三件事上,规划阶段就要定下来。
  • 控制面用奇数台(3 起),etcd 数据盘必须独占 NVMe,部署前用 fio 确认 fsync p99 小于 10ms。
  • 端口、时间同步、swap 是三张必须提前处理的清单。
  • kubespray 的日常操作就是改 inventory 与 group_vars,然后跑 cluster.ymlscale.yml 或带 tag 的部分更新。
  • 高可用由 etcd 多数派加 apiserver 的 VIP 共同保证,验收方式是真的关掉一台机器试试。

练习

  1. 给你手上一台机器规划一份最小生产集群清单:几台控制面、几台 worker、每台的磁盘怎么分,并说明理由。
  2. 在目标磁盘上跑一遍 fio,把 p99 结果和本章的判据对照,判断这块盘能不能放 etcd。
  3. 想一下:如果机房只给你 2 台机器做控制面,为什么说这比 1 台更危险?

fio 的用法和第 20 章 生产存储里的性能验证是同一套思路,可以对照着看。集群装好了,但 Pod 之间还不能互通——接着看第 19 章 集群网络:CNI 怎么选、NetworkPolicy 怎么写;集群真出问题时的通用顺序在排障手册