从零搭一套生产可用的 K8s 高可用集群(Rocky Linux 9 · kubeadm · 3 主 4 工)
面向:有 Linux 基础、想在自己机器/机房里落一套「能扛住单节点故障」的 K8s 集群的同学。
环境:Rocky Linux 9.x × 10 台(2C/8G 起)· Kubernetes v1.36 · containerd 2.x · Calico · Keepalived + HAProxy · Harbor。
目标:不是「跑个 demo」,而是 7 节点 Ready、VIP 可访问、私有仓库可拉、监控面板能用、能备份能恢复。
一、先想清楚:为什么要「高可用」而不是「单 Master」
单 Master 的 kubeadm 集群,学起来够用,但只要那台机器重启,kubectl 就全挂------控制面没有第二个副本,etcd 只有一份数据。生产环境里这不是「可用性差一点」,而是「一次内核升级就全网停摆」。
高可用要解决两件事:
| 问题 | 解法 | 本方案 |
|---|---|---|
| API Server 挂了怎么办 | 多个 apiserver + 一个虚拟 IP 在前 | 3 台 Master + Keepalived VIP + HAProxy |
| etcd 数据只有一份 | etcd 奇数副本(3/5) | 3 台 Master 各跑一个 etcd |
一句话记住顺序 :LB 必须先于
kubeadm init。因为controlPlaneEndpoint指向 VIP,init 过程中会通过 VIP 去写 ClusterRoleBinding;VIP 还没挂上,init 就会卡在upload-config阶段报context deadline exceeded。这个坑我踩过,排查了两小时。
二、规划表(先画表,再动手)
| 主机名 | IP | 角色 | 说明 |
|---|---|---|---|
| lb01 | 192.168.5.20 | Keepalived MASTER + HAProxy | VIP 持有者 |
| lb02 | 192.168.5.21 | Keepalived BACKUP + HAProxy | 秒级接管 |
| k8s-m1 | 192.168.5.22 | Master1 + etcd | 第一个 init |
| k8s-m2 | 192.168.5.23 | Master2 + etcd | join |
| k8s-m3 | 192.168.5.24 | Master3 + etcd | join |
| k8s-w1~w4 | 192.168.5.25--27 / .29 | Worker ×4 | 跑业务 |
| harbor | 192.168.5.28 | 私有镜像仓库 | 可选但强烈建议 |
- VIP :
192.168.5.200(必须和节点同网段、未被占用) - Pod 网段 :
10.244.0.0/16;Service 网段 :10.96.0.0/12(与宿主机网段不冲突即可) - 规格底线 :Master 2C/4G 起(etcd 吃磁盘 IO,务必 SSD);Worker 按业务给。内存小于 2G 的节点 kubeadm 会直接拒绝。
三、十步落地
步骤 0:所有节点统一基础环境
bash
# 关 swap(kubelet 硬要求)
swapoff -a && sed -ri 's/^([^#].*swap.*)$/#\1/' /etc/fstab
# 关 SELinux(或设 permissive)
setenforce 0 && sed -i 's/^SELINUX=enforcing/SELINUX=permissive/' /etc/selinux/config
# 内核模块 + 转发
cat >/etc/modules-load.d/k8s.conf <<'EOF'
overlay
br_netfilter
EOF
modprobe overlay && modprobe br_netfilter
cat >/etc/sysctl.d/99-k8s.conf <<'EOF'
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sysctl --system
# 时间同步(etcd 对时钟敏感)
dnf install -y chrony && systemctl enable --now chronyd
坑位 :模板机/克隆机常带失效的旧 yum 源 (指向已 404 的镜像站),
dnf install会卡 5 分钟才报错。先dnf repolist看一眼,把死的源enabled=0。
步骤 1:换国内源 + 装 containerd
bash
# Rocky 9 阿里云源(注意路径是 /rockylinux/9/,不是 /9.5/)
sed -e 's|^mirrorlist=|#mirrorlist=|g' \
-e 's|^#baseurl=http://dl.rockylinux.org/$contentdir|baseurl=https://mirrors.aliyun.com/rockylinux|g' \
-i.bak /etc/yum.repos.d/rocky*.repo
dnf makecache
# containerd(用 docker-ce 仓库里的包)
dnf config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
dnf install -y containerd.io
containerd 配置(2.x 关键差异):
bash
containerd config default > /etc/containerd/config.toml
要改三处:
toml
# 1) cgroup 驱动必须 systemd(否则 kubelet 起来节点 NotReady)
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options]
SystemdCgroup = true
# 2) sandbox 镜像换成国内可达的
[plugins.'io.containerd.cri.v1.images'.pinned_images]
sandbox = 'registry.aliyuncs.com/google_containers/pause:3.10'
# 3) 私有仓库证书目录(2.x 段名变了!)
[plugins.'io.containerd.cri.v1.images'.registry]
config_path = '/etc/containerd/certs.d'
最容易翻车的一条 :containerd 1.x 的段名是
io.containerd.grpc.v1.cri,2.x 改成了io.containerd.cri.v1.images。段名写错,certs.d静默不生效,私有仓库一直ImagePullBackOff,而日志里一个字都不提示。第二条 :
sed改配置千万别用..通配。我用s|config_path = ..|...|时误伤了plugin_config_path,把 containerd 配置写坏,全集群容器停摆 3 分钟。改前cp config.toml config.toml.bak,改后containerd config dump >/dev/null校验再 restart。
步骤 2:装 kubeadm / kubelet / kubectl
bash
cat >/etc/yum.repos.d/kubernetes.repo <<'EOF'
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core/stable/v1.36/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core/stable/v1.36/rpm/repodata/repomd.xml.key
EOF
dnf install -y kubelet kubeadm kubectl --disableexcludes=kubernetes
systemctl enable --now kubelet # 此时会 CrashLoop,正常,等 init
步骤 3:先起 LB(顺序不能反)
lb01 / lb02 各装 keepalived + haproxy:
bash
dnf install -y keepalived haproxy
/etc/keepalived/keepalived.conf(lb02 只改 state BACKUP 和 priority 90):
conf
vrrp_script chk_haproxy {
script "/usr/bin/killall -0 haproxy"
interval 2
weight -20
}
vrrp_instance VI_1 {
state MASTER
interface ens160
virtual_router_id 51
priority 100
advert_int 1
authentication { auth_type PASS; auth_pass k8s-lab }
virtual_ipaddress { 192.168.5.200/24 }
track_script { chk_haproxy }
}
/etc/haproxy/haproxy.cfg 关键段:
conf
frontend k8s-api
bind *:6443
mode tcp
default_backend k8s-masters
backend k8s-masters
mode tcp
balance roundrobin
option tcp-check
server k8s-m1 192.168.5.22:6443 check
server k8s-m2 192.168.5.23:6443 check
server k8s-m3 192.168.5.24:6443 check
验证:ip -o -4 addr show ens160 | grep 200 能看到 VIP;curl -k https://192.168.5.200:6443/version 此时还连不上(apiserver 没起),这是正常的。
步骤 4:kubeadm init(配置文件比命令行可靠)
kubeadm-config.yaml:
yaml
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 192.168.5.22
bindPort: 6443
nodeRegistration:
name: k8s-m1 # 不写就是 "node",后面改名很麻烦
criSocket: unix:///run/containerd/containerd.sock
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
clusterName: k8s-lab
controlPlaneEndpoint: 192.168.5.200:6443 # ← 必须写在 ClusterConfiguration 段!
kubernetesVersion: v1.36.0
networking:
podSubnet: 10.244.0.0/16
serviceSubnet: 10.96.0.0/12
apiServer:
certSANs:
- 192.168.5.200
- 192.168.5.22
- 192.168.5.23
- 192.168.5.24
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
bash
kubeadm init --config kubeadm-config.yaml --upload-certs | tee init.log
两个静默坑:
controlPlaneEndpoint属于 ClusterConfiguration 段。用sed插到localAPIEndpoint前面的话,它会落到 InitConfiguration 段里------kubeadm 不报错、静默忽略 ,join 命令退回单点 IP,高可用形同虚设。init 完一定grep controlPlaneEndpoint init.log验证 join 命令里是 VIP。nodeRegistration.name默认是node,不改的话第一个 Master 的节点名就叫node。
步骤 5:装 CNI(Calico)
bash
curl -fsSL https://cdn.jsdelivr.net/gh/projectcalico/calico@v3.32.2/manifests/calico.yaml -o calico.yaml
sed -i 's|value: "192.168.0.0/16"|value: "10.244.0.0/16"|' calico.yaml # 用 | 做分隔符,别用 /
kubectl apply -f calico.yaml
raw.githubusercontent.com和ghproxy在国内大概率不通,jsdelivr 实测稳定 。
sed替换串里带/时,分隔符必须换|,否则报s 的未知选项。
步骤 6:其余节点 join
bash
# 在 m1 上生成
kubeadm token create --print-join-command
# control-plane 节点额外带 --control-plane --certificate-key(init 时 --upload-certs 输出的那条)
步骤 7:私有仓库(Harbor)
bash
# 自签证书必须带 SAN,否则 containerd 校验不过
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout harbor.key -out harbor.crt \
-subj "/CN=harbor" -addext "subjectAltName=IP:192.168.5.28,DNS:harbor"
# 顺序很重要:先 load 镜像,再 prepare,最后 compose up
docker load -i harbor.v2.11.1.tar.gz
./prepare
docker compose up -d
每个 K8s 节点分发 CA:
bash
mkdir -p /etc/containerd/certs.d/192.168.5.28
cp harbor.crt /etc/containerd/certs.d/192.168.5.28/ca.crt
坑 :
docker push harbor/library/nginx:v1会跑去 Docker Hub 找镜像------镜像名首段不含.或:且不是localhost时,docker 不认它是 registry 。统一用 IP:192.168.5.28/library/nginx:v1。
步骤 8:metrics-server(kubectl top 靠它)
三个必要件缺一不可:ClusterRole system:metrics-server、APIService v1beta1.metrics.k8s.io、Service selector 与 Pod 标签一致 。少任何一个的表现分别是:nodes is forbidden、Metrics API not available、MissingEndpoints。
步骤 9:监控面板(Prometheus + Grafana + node-exporter)
node-exporter 用 DaemonSet + hostNetwork,prometheus 用 kubernetes_sd_configs 自动发现,grafana 走 NodePort 暴露。数据源用 ConfigMap 预置,省掉手工点。
bash
kubectl apply -f monitoring-stack.yaml
kubectl get pods -n monitoring # 期望 9 个 Running(7 node-exporter + 1 prom + 1 grafana)
curl -s http://<任一worker>:30300/api/health # {"database":"ok",...}
验收别只看 Pod Running。要看 Prometheus 的
up指标 :count(up==1)应该等于节点数 × 2(node-exporter + cadvisor 各一条)。只看 Pod Ready,很容易漏掉「抓不到数据」这种半残状态。
步骤 10:验收清单(逐条打勾,别跳)
| # | 检查项 | 命令 | 期望 |
|---|---|---|---|
| 1 | 节点 | kubectl get nodes |
全部 Ready |
| 2 | 控制面 | kubectl -n kube-system get pod -l tier=control-plane |
每 Master 3 组件 Running |
| 3 | etcd | kubectl -n kube-system get pod -l component=etcd |
3/3 |
| 4 | 网络 | kubectl -n kube-system get pod -l k8s-app=calico-node |
与节点数一致 |
| 5 | VIP | curl -k https://<VIP>:6443/version |
版本 JSON |
| 6 | 业务 | curl http://<worker>:<NodePort>/ |
200 |
| 7 | LB 漂移 | lb01 systemctl stop keepalived |
VIP <2s 漂到 lb02,服务不断 |
| 8 | 私有仓库 | 用镜像地址建 Deployment | Pod Running |
| 9 | 监控 | count(up==1) |
节点数×2 |
| 10 | 备份 | etcd snapshot + 配置打包 | 可 restore |
四、故障演练:不做这一步,等于没验收
| 层级 | 动作 | 实测结果(2C/8G × 7) |
|---|---|---|
| Pod | kubectl delete pod --force |
3 秒重建,且漂到其它节点 |
| Worker | vmrun stop soft |
60s 内 NotReady;重开 4s 起、约 6 分钟 Ready |
| Master | vmrun stop soft |
关机期间其余 6 节点全 Ready、kubectl 全程可用;重开约 2 分钟 Ready |
Master 关机慢(93s vs Worker 9s),因为 etcd 要走完 raft 收尾,这是正常的。
Worker 挂掉超过 5 分钟(默认
tolerationSeconds=300)才会驱逐 Pod;节点在 5 分钟内回来,Pod 不会迁移------这是设计行为,不是 bug。
五、备份与恢复(生产的分水岭)
一套能恢复的集群,才算交付。备份三件套:
bash
# 1) etcd 快照(数据面)
ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
snapshot save /var/lib/etcd/snap-$(date +%Y%m%d).db
# 2) 配置面
tar czf etc-kubernetes.tar.gz -C / etc/kubernetes
# 3) 各节点 containerd/kubelet/sysctl/LB/Harbor 配置
坑 :etcd 官方镜像是 distroless ,里面没有
/bin/sh、没有ls、没有which。kubectl exec ... -- /bin/sh -c "etcdctl ..."会报stat /bin/sh: no such file or directory。必须直接 exec 绝对路径:kubectl exec <pod> -- /usr/local/bin/etcdctl ...。
恢复时三台 Master 用同一份快照 各自 snapshot restore,--name 和 --initial-advertise-peer-urls 按本机改,数据目录属主保持 root:root 700。
六、国内网络下的下载源实测(2026-10 结论)
| 目标 | 结果 |
|---|---|
mirrors.aliyun.com/rockylinux/9/ |
✅(/9.5/ 是 404,用 /9/) |
pkgs.k8s.io |
✅ 302 可跟随 |
download.docker.com |
❌ SSL 重置 → 用阿里云 docker-ce 镜像 |
raw.githubusercontent.com |
❌ 超时 |
ghproxy.net |
❌ 502 |
cdn.jsdelivr.net/gh/... |
✅ 稳定(Calico 清单走这里) |
docker.m.daocloud.io |
✅ 可直接 crictl pull |
quay.io |
✅ 常可达(Calico 镜像备用) |
registry.aliyuncs.com/google_containers |
✅ metrics-server 等直接拉 |
镜像拉不动的三招 :① 国内 CDN 全路径 docker.m.daocloud.io/library/x:tag;② quay.io 拉下来 ctr -n k8s.io images tag 改成期望的名字;③ 离线 ctr images export/import。
七、一张纸的排错顺序
节点 NotReady 时,按这个顺序看,基本 5 分钟内能定位:
bash
systemctl status kubelet # 1. kubelet 活不活
journalctl -u kubelet -n 100 --no-pager # 2. 报错第一行
crictl ps -a | head # 3. 容器运行时通不通
crictl logs <sandbox-id> # 4. pause 容器起不来 = CNI/镜像问题
grep -i "cgroup\|sandbox" /var/log/messages
kubectl get node <n> -o yaml | grep -A5 Conditions
cgroup driver mismatch→ containerd 的SystemdCgroup没开failed to get sandbox image→ pause 镜像拉不到,换阿里云network plugin not ready→ CNI 没装或 Pod 网段与 calico 配置不一致x509: certificate signed by unknown authority→ 自签 CA 没分发到/etc/containerd/certs.d/<registry>/ca.crt
八、这套东西的边界
- 能扛:单 Master 宕机、单 Worker 宕机、单 LB 宕机、API 短时抖动。
- 不能扛:两台 Master 同时挂(etcd 失去多数派)、存储单点(本地盘 PV 挂了数据就没了)、机柜断电。
- 真要上生产,还得补:外部 etcd 集群、etcd 定时快照到对象存储、
kubeadm certs renew的证书轮换日历、NetworkPolicy、RBAC 最小权限、审计日志落盘。
参考实现(脚本化、可重复):
deploy-k8s.sh把上面十步按run <stage>串起来,10 台机器一遍过;monitoring-stack.yaml是上面第九步的完整清单。所有命令都是实机跑过的,报错原文见配套的《实机经验与坑位清单》。