Day 1:3 CP HA 集群 + CNI 选型 + DNS 调优
接上 Day 0 的 5 节点基线,开装 K8s 主体。这一天会踩到 K8s 生产部署里最经典的 5 个坑,每一个都是面试 senior 时常被追问的:
- 私有 IDC 不让漂 VIP →
keepalived方案失败,per-node HAProxy 救场 - 并发 SSH 收集信息没带 hostname 标识 → IP 映射全错,kubeadm init etcd bind fail
- IDC 做 source-IP filtering → Calico IPIPCrossSubnet 直接 drop 裸 Pod 包,必须切 VXLAN
- 拆 Calico 没清 BIRD 残留路由 → 装上 Cilium 后跨节点带宽掉到 26 Mbps
- node-local-dns forward 配错 → DNS 自己 forward 给自己死循环
整篇按 A → G 7 个阶段走,每阶段先说做什么,再讲一个真坑。
集群拓扑与 IP 规划
5 节点都在 10.0.24.0/24 同子网,没有外部 LB:
| 节点 | 内网 IP | 角色 |
|---|---|---|
| k8s-cp-1 | 10.0.24.31 | 控制面 / kubeadm init |
| k8s-cp-2 | 10.0.24.29 | 控制面 |
| k8s-cp-3 | 10.0.24.32 | 控制面 |
| k8s-w-1 | 10.0.24.28 | worker |
| k8s-w-2 | 10.0.24.30 | worker |
K8s 网络段:
- Pod CIDR:
10.244.0.0/16 - Service CIDR:
10.96.0.0/12 kubeadm的--control-plane-endpoint:k8s-api:16443(指向 per-node HAProxy)
A. containerd + 内核 prereqs
A.1 vdb 100G remount 到 /var/lib/containerd
Day 0 时看到 IDC 默认把 100G 数据盘 vdb1 挂在 /www。装 K8s 前第一件事是 remount 到 /var/lib/containerd —— containerd 的 image 缓存 + 容器层是节点最吃盘的部分,30G 根盘 image 拉几个就爆。
ssh m1 'bash -s' <<'EOF'
umount /www 2>/dev/null || true
mkdir -p /var/lib/containerd
sed -i.bak 's|/www|/var/lib/containerd|' /etc/fstab
rmdir /www 2>/dev/null || true
mount -a
df -h /var/lib/containerd
EOF
为什么直接挂 /var/lib/containerd 而不是 /data:因为 containerd 的 root 目录默认就是这个路径,挂上去零配置改动自动生效。
改完 fstab 必须 mount -a 现场验证,否则可能下次重启起不来。
A.2 内核模块 + sysctl + swap
K8s 节点必须满足 3 个内核要求:
# 加载模块(立刻 + 持久化)
modprobe overlay && modprobe br_netfilter
cat > /etc/modules-load.d/k8s.conf <<'MOD'
overlay
br_netfilter
MOD
# sysctl 三件套
cat > /etc/sysctl.d/k8s.conf <<'SYS'
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
SYS
sysctl --system
# 关 swap
swapoff -a
sed -i '/[[:space:]]swap[[:space:]]/s/^/# /' /etc/fstab
每一项的意义:
| 项 | 不做会怎样 |
|---|---|
overlay 模块 | containerd 启不来或报「overlay snapshotter is not supported」 |
br_netfilter 模块 | bridge 流量绕过 iptables,Service / NodePort 在 bridge 上「消失」 |
bridge-nf-call-iptables = 1 | 同上,是上面的系统级开关 |
ip_forward = 1 | Linux 默认禁 IP 转发,跨节点 Pod 通信失败 |
| swap off | kubeadm 准入条件,[ERROR Swap] 直接 fail |
模块持久化用 /etc/modules-load.d/*.conf,sysctl 持久化用 /etc/sysctl.d/*.conf。别去动 /etc/sysctl.conf 和 /etc/modules,那是 Debian 老风格,跟系统升级有冲突。
A.3 装 containerd
用 Docker 官方 apt 源(比 Ubuntu 自带新),生成默认配置后改 4 处:
SystemdCgroup = true(跟 kubelet 对齐,否则 Pod 启不来或 OOM 行为异常)sandbox = registry.aliyuncs.com/google_containers/pause:3.10(不走registry.k8s.io,国内拉不到)config_path = '/etc/containerd/certs.d'(开启hosts.toml镜像源机制)- 写
/etc/containerd/certs.d/docker.io/hosts.toml,把docker.io转发到docker.m.daocloud.io
# 装
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq && apt-get install -y -qq containerd.io
# 配
mkdir -p /etc/containerd
containerd config default > /etc/containerd/config.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
# v3 schema 的字段名是 `sandbox`,不是 v1/v2 的 `sandbox_image`!
sed -i "s|sandbox = 'registry.k8s.io/pause:[^']*'|sandbox = 'registry.aliyuncs.com/google_containers/pause:3.10'|" /etc/containerd/config.toml
mkdir -p /etc/containerd/certs.d/docker.io
cat > /etc/containerd/certs.d/docker.io/hosts.toml <<'HOSTS'
server = "https://docker.io"
[host."https://docker.m.daocloud.io"]
capabilities = ["pull", "resolve"]
HOSTS
systemctl enable --now containerd
A.3 真坑:containerd v2 schema 跟网上 v1 教程不兼容
2024 后 Docker 官方源装出来的 containerd 是 v2.x,config schema 升到 v3,字段名都变了。网上能搜到的 99% 教程都是 v1 时代的 sandbox_image,但 v3 里这个字段名根本不存在:
grep -nE "pause|sandbox" /etc/containerd/config.toml
# 51: sandbox = 'registry.k8s.io/pause:3.10.1' <- v3 的字段名
照搬老教程改 sandbox_image 你会发现 sed 什么都没匹配上。要先 containerd config dump 看实际生成的字段名再改。
另外 config_path 在 v3 默认就是空字符串 '',不能 append 一条新的,会触发 TOML duplicate key 让 containerd 起不来。要用 sed 修改已有的那行,不是新加:
# 改已有的空字符串值,不是 append
sed -i "s|config_path = ''|config_path = '/etc/containerd/certs.d'|" /etc/containerd/config.toml
生产环境很多团队故意停在 containerd 1.6.x / 1.7.x 不上 2.x,正是因为 schema 不兼容 + 部分 plugin 还在适配。学习场景用 2.x 没问题,但要知道这层 caveat。
B. kubeadm/kubelet/kubectl + HA 控制面 endpoint
B.1 装二进制
# K8s apt 源 —— 注意是 `kubernetes-new` 不是 `kubernetes`
curl -fsSL https://mirrors.aliyun.com/kubernetes-new/core/stable/v1.30/deb/Release.key \
| gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://mirrors.aliyun.com/kubernetes-new/core/stable/v1.30/deb/ /' \
> /etc/apt/sources.list.d/kubernetes.list
apt-get update && apt-get install -y kubeadm kubelet kubectl cri-tools
# 防 apt upgrade 跨 minor 升级
apt-mark hold kubeadm kubelet kubectl
# crictl 配置(指向 containerd socket)
cat > /etc/crictl.yaml <<'CRICTL'
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10
CRICTL
systemctl enable kubelet
两个易错点:
kubernetes-new是新仓库(镜像pkgs.k8s.io),kubernetes是已废弃的apt.kubernetes.io。装到老的最多到 1.27。apt-mark hold是防apt upgrade跨 minor 升级 K8s。kubeadm不支持跨 minor 跳级(1.30 → 1.32 必须经过 1.31),hold 后想升级必须apt-mark unhold显式解锁,强制留一个人为决策点。
启动后 kubelet 会 activating (auto-restart) —— 这是预期,因为 /var/lib/kubelet/config.yaml 还没生成。kubeadm init 时会写这个文件,kubelet 自动恢复。
B.2 HA 控制面 endpoint:VIP 漂不动,转 per-node HAProxy
经典姿势是 keepalived 漂 VIP,3 CP 抢一个 10.0.24.100,kubeadm 用这个 VIP 作 --control-plane-endpoint。先测一下能不能漂:
ssh m1 'ip addr add 10.0.24.100/24 dev eth0'
ssh m2 'ping -c 3 10.0.24.100'
# From 10.0.24.29 icmp_seq=3 Destination Host Unreachable
# 100% packet loss
cp-1 本机看得到 secondary IP(inet 10.0.24.100/24 secondary),但其他节点 100% 丢包。错误是 Destination Host Unreachable(L3 路由失败),不是 Request timeout(L2 ARP 失败)—— IDC 网关做了 source-IP 白名单或 MAC-IP 静态绑定,只让分配给你的合法 IP 通过。
这是小厂 IDC 防 ARP spoof 的标准做法。keepalived ARP 模式 / kube-vip ARP 模式都同样不可行。
退路:每节点本地跑 HAProxy,监听 127.0.0.1:16443,backend 是 3 CP 的 :6443,kubeadm 用 k8s-api:16443,/etc/hosts 把 k8s-api 解析到 127.0.0.1。
apt install -y haproxy
echo "127.0.0.1 k8s-api" >> /etc/hosts
cat > /etc/haproxy/haproxy.cfg <<'EOF'
global
daemon
maxconn 4096
defaults
mode tcp
timeout connect 5s
timeout client 60s
timeout server 60s
frontend k8s-apiserver
bind 127.0.0.1:16443
default_backend k8s-apiserver-backend
backend k8s-apiserver-backend
option tcp-check
balance roundrobin
server cp-1 10.0.24.31:6443 check inter 2s rise 2 fall 3
server cp-2 10.0.24.29:6443 check inter 2s rise 2 fall 3
server cp-3 10.0.24.32:6443 check inter 2s rise 2 fall 3
frontend stats
bind 127.0.0.1:8404
mode http
stats enable
stats uri /stats
EOF
# 必须 restart,不是 start —— 见下文坑
systemctl enable haproxy
systemctl restart haproxy
为什么 5 台都装 HAProxy(不止 3 CP):worker 也要连 apiserver,让 worker 的 kubelet 也连 localhost:16443 即可,统一姿势 = 简单。
为什么 mode tcp + tcp-check:apiserver 是 HTTPS,HAProxy 不终止 TLS(不然要导客户端证书),直接 TCP 转发 + L4 健康检查。
B.2 真坑:systemd 首次启动 race,HAProxy 不 bind 端口
按计划 systemctl enable --now haproxy 后:
systemctl is-active haproxy→active✅ss -tlnp | grep 16443→ 空 ❌journalctl没报错,只有 "New worker forked"
但 systemctl restart haproxy 后立刻 OK 端口正常 listen。
根因(推测):Ubuntu 22.04 的 haproxy 2.4 包 postinst 第一次启动时,config 文件可能在 daemon fork 后才完整写入,worker 启动读到的 config 是空/不完整的。
经验:service 装完 + 写 config 后总是 restart 一次,不要依赖 enable --now 的首次启动。这条规则同样适用于 containerd、mysql 等。
C. kubeadm init 起第一个 CP
# /root/kubeadm-init.yaml
apiVersion: kubeadm.k8s.io/v1beta3
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 10.0.24.31 # cp-1 真实内网 IP
bindPort: 6443
nodeRegistration:
criSocket: unix:///run/containerd/containerd.sock
name: k8s-cp-1
---
apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
kubernetesVersion: v1.30.14
controlPlaneEndpoint: "k8s-api:16443"
imageRepository: registry.aliyuncs.com/google_containers
networking:
podSubnet: 10.244.0.0/16
serviceSubnet: 10.96.0.0/12
apiServer:
certSANs:
- "k8s-api"
- "127.0.0.1"
- "10.0.24.31"
- "10.0.24.29"
- "10.0.24.32"
- "k8s-cp-1"
- "k8s-cp-2"
- "k8s-cp-3"
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
---
apiVersion: kubeproxy.config.k8s.io/v1alpha1
kind: KubeProxyConfiguration
mode: ipvs
# 预拉镜像(走阿里云 mirror 加速)
kubeadm config images pull --config /root/kubeadm-init.yaml
# init(含 --upload-certs 给 HA cp join 用)
kubeadm init --config /root/kubeadm-init.yaml --upload-certs
关键设计:
- 用 YAML 配置而不是 CLI flags —— 可 git commit、可重现、可 diff
controlPlaneEndpoint: k8s-api:16443—— 写进 apiserver cert SAN + 所有 kubeconfig 的 server URL。每个节点必须能解析 + 连通这个名字certSANs—— 把所有可能用来访问 apiserver 的名字都加上(k8s-api / 127.0.0.1 / 3 个 IP / 3 个 hostname),否则用未列出的名字访问会x509: certificate is valid for ... not for X--upload-certs—— 把 CA + apiserver / front-proxy / sa 证书加密上传到kube-system/kubeadm-certsSecret,有效期 2 小时。cp-2 / cp-3 join 时用--certificate-key拉mode: ipvs—— kube-proxy 用 IPVS(内核哈希表)而非 iptables(链式遍历),>1000 Service 时性能高 1-2 个量级
C 真坑:并发 SSH 收集信息没带 hostname 标识 → IP 映射全错
跑 kubeadm init 之后立刻报:
[etcd] failed to start: listen tcp 10.0.24.28:2380: bind: cannot assign requested address
[wait-control-plane] context deadline exceeded after 4m0s
etcd 试图 bind 10.0.24.28:2380 但这个 IP 不在 cp-1 上。ip addr show eth0 暴露真相:cp-1 的真实内网 IP 是 10.0.24.31,根本不是我以为的 10.0.24.28。
往回查 Day 0 时收集机器信息的脚本:
# Day 0 我做的(错的)
probe ***.201.73.31 &
probe ***.201.73.81 &
probe ***.205.31.214 &
probe ***.205.31.180 &
probe ***.205.31.10 &
wait
# 5 个 IP_PRIVATE 行交错返回到终端,但我以为输出按发起顺序回来
并发 SSH 输出由网络 + 调度决定回到本地的顺序,完全随机。我看到 IP_PRIVATE: 10.0.24.28 就以为对应第一个 SSH 的 ***.201.73.31,实际它属于别的机器。结果整张 IP 映射表全错位。
正确姿势:每条输出必须自带 hostname 标识自己,客户端不能猜顺序。
probe() {
ip=$1
ssh $ip "echo \"=== \$(hostname) ===\"; echo \"IP_PRIVATE: \$(ip -4 addr show eth0 | awk '/inet /{print \$2}')\""
}
# 或者更稳的:每行加 prefix
for ip in ...; do
ssh $ip 'ip addr' 2>&1 | sed "s/^/[$ip] /"
done
这是面试讲「多机器并发收集信息」时区分初级和资深的关键点 —— Ansible 用 gather_facts + hostvars[host] 就是为了避免这个坑。
修复完整链:kubeadm reset -f cp-1 → 改 5 台 /etc/hosts → 改 5 台 HAProxy backend → 改 kubeadm-init.yaml 的 advertiseAddress → 重跑 init,21.5 秒过。
D. cp-2 / cp-3 + 2 worker join
# 控制面 join(cp-2 / cp-3)
kubeadm join k8s-api:16443 \
--token <token> \
--discovery-token-ca-cert-hash sha256:<hash> \
--control-plane --certificate-key <cert-key> \
--apiserver-advertise-address <这台节点的内网 IP> \
--cri-socket unix:///run/containerd/containerd.sock
# Worker join(w-1 / w-2,无 --control-plane / --certificate-key)
kubeadm join k8s-api:16443 \
--token <token> \
--discovery-token-ca-cert-hash sha256:<hash> \
--cri-socket unix:///run/containerd/containerd.sock
控制面 join 比 worker join 多两个动作:
- 从
kube-system/kubeadm-certsSecret 拉密钥解密 CA cert - 加入 etcd raft cluster 作为新 voter(先 learner → 同步完成后 promote 成 voter)
D 真坑:5 个串联问题
这一步是 Day 1 最容易出错的地方,因为有 5 个独立隐患串在一起:
1. cp-2 拉镜像失败(IDC 节点间网络不一致)
cp-3 join 顺利,cp-2 在拉阿里云 mirror 镜像时超时。tracepath 显示从 cp-2 出去某一跳被 ACL 切了 —— 小厂 IDC 节点间网络质量差异极常见,不要假设所有节点出网一致。
修法:从 cp-1(网络好)导出 7 个 K8s 镜像到 tar,流式管道传给 cp-2 不落盘:
ssh m1 'ctr -n k8s.io images export /tmp/k8s.tar \
registry.aliyuncs.com/google_containers/kube-apiserver:v1.30.14 \
registry.aliyuncs.com/google_containers/kube-controller-manager:v1.30.14 \
registry.aliyuncs.com/google_containers/kube-scheduler:v1.30.14 \
registry.aliyuncs.com/google_containers/kube-proxy:v1.30.14 \
registry.aliyuncs.com/google_containers/coredns:v1.11.3 \
registry.aliyuncs.com/google_containers/pause:3.9 \
registry.aliyuncs.com/google_containers/etcd:3.5.15-0 && \
cat /tmp/k8s.tar' | \
ssh m2 'cat > /tmp/k8s.tar && ctr -n k8s.io images import /tmp/k8s.tar'
# 然后 join 加 --ignore-preflight-errors=ImagePull 跳过 preflight
2. etcd ghost member
cp-2 上一次 join 失败时 kubeadm 已经把它注册到 etcd raft 集群(作为 learner)。这次重 join:
etcdserver: can only promote a learner member which is in sync with leader
etcd 看到 cp-2 已经存在但状态 unstarted:
etcdctl member list:
| 85047be2f29d3191 | unstarted | | https://10.0.24.29:2380 | <- ghost
| 953b8e085f0fea77 | started | k8s-cp-3 | ... |
| e4ddd8b9376888ab | started | k8s-cp-1 | ... |
手动移除 ghost:
kubectl -n kube-system exec etcd-k8s-cp-1 -- etcdctl \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
--endpoints=https://127.0.0.1:2379 \
member remove 85047be2f29d3191
3. containerd sandbox 版本和 kubeadm 不匹配
A.3 时把 containerd 的 sandbox 配成 pause:3.10,但 kubeadm 1.30 默认用 pause:3.9。cp-2 网络又拉不到 pause:3.10,导致 kubelet 起 pod sandbox 时一直 fail。
教训:containerd config 的 pause 版本和 kubeadm config 的 pause 版本要对齐,要么手动 ctr import 把两个版本都准备好。
4. kubeadm reset 不彻底
kubeadm reset 不清以下东西,下次 join 必然有残留:
kubeadm reset -f
rm -rf /etc/cni/net.d /var/lib/etcd /root/.kube /etc/kubernetes
iptables -F && iptables -X && iptables -t nat -F && iptables -t nat -X
ipvsadm --clear
systemctl restart containerd
把上面这串做成一个 full-reset.sh 脚本,每次有问题先跑一遍。
5. --upload-certs 上传的 Secret 2 小时过期
如果踩坑修了 1-2 小时才重试 join,上传的 cert Secret 已过期。在 cp-1 上重新生成:
kubeadm init phase upload-certs --upload-certs
# 输出会打印新的 cert-key,用新的再 join
修完这 5 条,3 CP HA + 2 worker 全 Ready(虽然还 NotReady,因为没装 CNI):
NAME STATUS ROLES AGE
k8s-cp-1 NotReady control-plane 39m
k8s-cp-2 NotReady control-plane 3m
k8s-cp-3 NotReady control-plane 35m
k8s-w-1 NotReady <none> 4s
k8s-w-2 NotReady <none> 6s
E. Calico CNI:IPIPCrossSubnet 在私有 IDC 一定会翻车
用 Calico operator (tigera-operator) 装 Calico v3.28.2:
# quay.io 镜像 mirror
mkdir -p /etc/containerd/certs.d/quay.io
cat > /etc/containerd/certs.d/quay.io/hosts.toml <<'EOF'
server = "https://quay.io"
[host."https://quay.m.daocloud.io"]
capabilities = ["pull", "resolve"]
EOF
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.2/manifests/tigera-operator.yaml
cat <<EOF | kubectl apply -f -
apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
name: default
spec:
calicoNetwork:
ipPools:
- blockSize: 26
cidr: 10.244.0.0/16
encapsulation: IPIPCrossSubnet # <- 这里出问题
natOutgoing: Enabled
nodeSelector: all()
EOF
IPIPCrossSubnet 的设计是:同子网用裸 IP 路由(性能好),跨子网才 IP-in-IP 封装。5 节点都在 10.0.24.0/24 同子网时,Calico 不封装,直接发 raw Pod IP 包。
E 真坑:source-IP filter drop 裸 Pod 包
5 节点 calico-node 全 Ready 后,跨节点 Pod ping 100% 丢包,DNS 解析超时。tcpdump 抓包看到包发出去了,但对端 eth0 完全没收到。
原因:
- 节点 w-1(
10.0.24.28)给 w-2(10.0.24.30)发 Pod 包,源 IP =10.244.196.67(Pod IP),目的 IP =10.244.47.5(Pod IP) - IDC 网关 / 交换机做 source-IP filtering:看到 w-1 发出的包 src 是
10.244.x.x,但分配给 w-1 的合法 IP 是10.0.24.28,直接 drop - 跟 B.2 VIP 漂不动是同根问题(IDC source-IP 白名单严格)
修复:切到 VXLAN Always。VXLAN 用 UDP 4789 封装,外层 UDP 包的 src 是节点 IP(合法),内层才是 Pod-to-Pod 包,IDC 只看外层就放行。
kubectl patch ippool default-ipv4-ippool --type=merge \
-p '{"spec":{"ipipMode":"Never","vxlanMode":"Always"}}'
# 等 1 分钟 calico-node 重建 vxlan.calico 设备 + 重 sync 路由
5 种 Calico encapsulation 模式横向对比:
| 模式 | 同子网 | 跨子网 | 备注 |
|---|---|---|---|
| None | 裸 IP | 不可达 | 0 开销,但跨子网失败 |
| IPIP Always | IP-in-IP | IP-in-IP | 用 IP proto 4,部分防火墙挡 |
| IPIPCrossSubnet | 裸 IP | IP-in-IP | 同子网踩 source-IP filter |
| VXLAN Always | UDP 4789 封装 | UDP 4789 封装 | 防火墙友好,多 50B overhead |
| VXLANCrossSubnet | 裸 IP | UDP 4789 封装 | 跟 IPIPCrossSubnet 同坑 |
小厂 / 私有 IDC 默认就用 VXLAN Always,不要省那 50B overhead,否则一定踩。云上(AWS / GCP)节点间网络更开放,IPIPCrossSubnet 才安全。
修完之后跨节点 Pod ping 0% loss,DNS / Service 全打通。
F. 拆 Calico 换 Cilium:BIRD 残留路由真坑
Cilium 是 CNCF graduated CNI,2023+ 新集群几乎默认选择。核心差异:
| Calico | Cilium | |
|---|---|---|
| dataplane | iptables + BIRD/BGP | eBPF(内核 hook,绕过 netfilter) |
| Service LB | kube-proxy 维护 iptables/ipvs rule | 可替代 kube-proxy(eBPF map 查 O(1)) |
| Network Policy | 标准 K8s NP | 标准 NP + L7 / DNS / mTLS(CiliumNetworkPolicy) |
| 可观测 | calico-typha 加 Prometheus exporter | Hubble(eBPF-native 流量级 trace) |
F.1 拆 Calico 的完整清单
# 1. K8s 层
kubectl delete installation default
# 卡 finalizer:强抹
kubectl patch installation default -p '{"metadata":{"finalizers":[]}}' --type=merge
# 2. ns Terminating 卡住:绕 admission 强删
for ns in calico-system calico-apiserver tigera-operator; do
kubectl get ns $ns -o json | jq '.spec.finalizers = []' | \
kubectl replace --raw "/api/v1/namespaces/$ns/finalize" -f -
done
# 3. 节点层每台清残留
rm -f /etc/cni/net.d/*calico* /etc/cni/net.d/calico-kubeconfig
rm -f /opt/cni/bin/calico*
ip link del vxlan.calico 2>/dev/null
# 删 cali* veth + iptables cali-* 链
ip route flush proto bird # <- 关键,下文真坑就是漏了这条
systemctl restart kubelet
第 3 步的 ip route flush proto bird 就是下面这个真坑的根源。
F 真坑:BIRD 路由残留导致跨节点带宽掉到 26 Mbps
按上面 cleanup 后装 Cilium:
cilium install \
--version 1.16.5 \
--set ipam.operator.clusterPoolIPv4PodCIDRList='{10.244.0.0/16}' \
--set routingMode=tunnel --set tunnelProtocol=vxlan \
--set k8sServiceHost=k8s-api --set k8sServicePort=16443 \
--set image.repository=quay.m.daocloud.io/cilium/cilium \
--set operator.image.repository=quay.m.daocloud.io/cilium/operator
5 节点 Cilium Ready,跨节点 Pod 通,但 iperf3 测带宽只有 26 Mbps(Calico VXLAN baseline 是 125 Mbps)。
查节点 w-1 的路由表:
10.244.0.0/24 via 10.244.2.103 dev cilium_host proto kernel <- Cilium 路由
10.244.111.192 via 10.0.24.31 dev eth0 proto bird <- Calico BIRD 残留
拆 Calico 时删了 vxlan.calico 接口和 cali* veth + iptables 链,但忘了清 BIRD proto 学到的路由。这些路由还在 kernel routing table 里,跟 Cilium 新装的路由冲突 —— 部分包走 cilium_vxlan 隧道,部分被 BIRD 路由从 eth0 直接发出(走错下一跳)。
每节点跑:
ip route flush proto bird
重测 iperf3:142 Mbps(反超 Calico 14%)。
CNI 切换的完整 cleanup checklist
把 Calico 这个坑泛化成所有 CNI 切换的清单:
- K8s 层:Installation CR / DaemonSet / Deployment / Service / RBAC / CRD / Namespace
- 节点层:
/etc/cni/net.d/*配置文件/opt/cni/bin/*二进制- iptables 自定义链(
iptables-save | grep <cni-prefix>找) ip route flush proto bird(Calico)/proto static(手动加的)- 网络接口:
vxlan.calico/tunl0/cali*veth /cilium*等
漏了任何一项都可能让新 CNI 半坏不坏。
Calico vs Cilium 实测数据
| 指标 | Calico VXLAN | Cilium VXLAN | 对比 |
|---|---|---|---|
| 跨节点带宽 | 125 Mbps | 142 Mbps | +14% |
| TCP retransmits (8s) | 1870 | 283 | ↓ 6.6× |
| RTT 平均 | 3.290 ms | 1.088 ms | ↓ 3× |
| CNI 收敛时间 | 15 min 阵痛 | 3.5 min | ↓ 4× |
Cilium 强的工程原因:
- eBPF 走内核 hook,不经过 netfilter / iptables,Service / NetworkPolicy 查表 O(1)(hash map),不是 iptables 链遍历 O(n)
- 大集群优势更明显:5000 Service 时 Calico 的 iptables 表 ~50000 条,kube-proxy 每次 sync 几秒;Cilium eBPF map 更新 O(1)
- Hubble 提供流量级观测,Calico 没有等价物
G. CoreDNS 调优 + node-local-dns
DNS 是 K8s 集群「看似 OK 实则慢 / 漏」的最大隐藏问题。3 件事:
G.1 CoreDNS Corefile 调优
默认 cache 30s、不 prefetch、不缓存 denial —— 高 QPS 时 CoreDNS 易过载。
kubectl patch cm -n kube-system coredns --type merge -p '
data:
Corefile: |
.:53 {
errors
log . { class denial error } # 只记 denial/error 减日志量
health { lameduck 5s }
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
prometheus :9153
forward . /etc/resolv.conf {
max_concurrent 1000
}
cache 300 {
success 9984 300 # 成功响应缓存 300s + 最多 9984 entries
denial 9984 5 # 失败响应缓存 5s(防 DOS)
prefetch 100 60s 15% # top 100 热点在 TTL 剩 15% 时主动刷
}
loop
reload
loadbalance round_robin
}'
kubectl rollout restart deploy -n kube-system coredns
为什么 denial 5s 短:NXDOMAIN cache 不持续,防 DNS 探测攻击留下长 cache。
为什么 prefetch:top 100 热点 query 在 TTL 剩 15% 或 60s 时主动后台刷新,客户端永远命中 cache —— 降 p99 latency 大杀器。
G.2 装 node-local-dns
node-local-dns 是 DaemonSet hostNetwork=true 在每节点跑一个 dnsmasq-like server,bind link-local 169.254.20.10:
curl -fsSL https://raw.githubusercontent.com/kubernetes/kubernetes/master/cluster/addons/dns/nodelocaldns/nodelocaldns.yaml \
-o nodelocaldns.yaml
# 替换 4 个占位符(注意 forward 上游不能填 kube-dns Service IP,见下文坑)
UPSTREAM_IP=$(kubectl get svc -n kube-system kube-dns-upstream -o jsonpath='{.spec.clusterIP}')
sed -e "s/__PILLAR__DNS__SERVER__/10.96.0.10/g" \
-e "s/__PILLAR__LOCAL__DNS__/169.254.20.10/g" \
-e "s/__PILLAR__DNS__DOMAIN__/cluster.local/g" \
-e "s|__PILLAR__UPSTREAM__SERVERS__|/etc/resolv.conf|g" \
-e "s|registry.k8s.io|docker.m.daocloud.io/dyrnq|g" \
nodelocaldns.yaml > nodelocaldns-patched.yaml
kubectl apply -f nodelocaldns-patched.yaml
# 改 kubelet clusterDNS 指向 node-local-dns
python3 -c "
import yaml
cfg = yaml.safe_load(open('/var/lib/kubelet/config.yaml'))
cfg['clusterDNS'] = ['169.254.20.10']
yaml.dump(cfg, open('/var/lib/kubelet/config.yaml','w'))
"
systemctl restart kubelet
收益:
- 延迟:Pod →
10.96.0.10走 conntrack + kube-proxy DNAT + 跨节点路由 ~2-5ms;走169.254.20.10本地 hairpin < 0.5ms,5-10× 提升 - conntrack 压力:node-local-dns 用 NOTRACK iptables 绕过 conntrack,大集群关键优化
- CoreDNS QPS 大降:大部分 query 在节点本地命中,CoreDNS 实际接到的请求降 50-90%
- 故障容忍:CoreDNS 全挂时 Pod 仍能查到 cache 内的 name
G 真坑:node-local-dns forward 死循环
照抄网上教程,把所有 __PILLAR__*__ 占位符 sed 替换时偷懒,全替成 10.96.0.10。结果 Corefile 长这样:
cluster.local:53 {
bind 169.254.20.10 10.96.0.10 # node-local-dns 既 bind link-local 又 bind 10.96.0.10
forward . 10.96.0.10 { # forward 也指 10.96.0.10
由于 NOTRACK 跳过 conntrack,node-local-dns forward 给 10.96.0.10 时,包又被本地的 node-local-dns 接走(因为它也 bind 了这个 IP)→ 自己 forward 给自己 → 死循环 timeout:
[ERROR] plugin/errors: ... dial tcp 10.96.0.10:53: i/o timeout
CoreDNS 的 loop plugin 不检测跨 server block 的 loop,所以不报错。
修复:forward 应该指向 kube-dns-upstream Service(不同的 ClusterIP,backend 真正是 CoreDNS pods):
UPSTREAM_IP=$(kubectl get svc -n kube-system kube-dns-upstream -o jsonpath='{.spec.clusterIP}')
sed -i "s|forward . 10.96.0.10|forward . $UPSTREAM_IP|g" nodelocaldns-cm.yaml
K8s DNS 完整解析链路(9 跳)
Pod /etc/resolv.conf nameserver
↓
169.254.20.10(link-local,node-local-dns 本机)
↓
┌─ cache hit → 直接返回(< 0.5ms)
└─ miss → forward kube-dns-upstream Service
↓
kube-proxy iptables DNAT
↓
CoreDNS pod
↓
┌─ cluster.local → kubernetes plugin 查 etcd
└─ 外部 → /etc/resolv.conf upstream(节点的 nameserver)
9 跳之多,任何一跳挂或慢 → 整个集群 DNS 异常。这是 K8s 网络故障排查最复杂的链路。
ndots:5 病
kubelet 默认给 Pod 的 resolv.conf 加 options ndots:5 + 3 个 search domain。查 docker.io(1 dot < 5)会先尝试 search domain 拼接:
docker.io.default.svc.cluster.local → NXDOMAIN
docker.io.svc.cluster.local → NXDOMAIN
docker.io.cluster.local → NXDOMAIN
docker.io → 真实结果
= 4 次 DNS 往返。cache miss 时 latency 翻 4×。
修法:应用代码用 FQDN(带尾点 docker.io.)或 Pod spec 设 dnsConfig.options: [{name: ndots, value: "2"}]。
总结:Day 1 后集群形态
5 节点全 Ready:
NAME STATUS ROLES AGE
k8s-cp-1 Ready control-plane 2h
k8s-cp-2 Ready control-plane 2h
k8s-cp-3 Ready control-plane 2h
k8s-w-1 Ready <none> 1h45m
k8s-w-2 Ready <none> 1h45m
具备:
- 3 CP HA(etcd 3-node raft,per-node HAProxy
127.0.0.1:16443转发 apiserver) - Cilium eBPF dataplane + VXLAN tunnel,跨节点 142 Mbps
- node-local-dns + CoreDNS(cache 300 + prefetch top 100,9 跳解析链路全通)
- kube-proxy IPVS 模式
- 镜像源走阿里云 / daocloud mirror
面试常见题
Q1:K8s HA 控制面方案有几种?你怎么选?
3 种:
- VIP + keepalived / kube-vip ARP —— 经典姿势,需要 IDC 允许 secondary IP 漂移。小厂 IDC 几乎都不让漂,会被 source-IP filter / MAC-IP 静态绑定 block
- 外部 LB —— 云厂商 ALB / nginx LB / haproxy 节点,简单但要多 1 台
- per-node HAProxy —— 每节点本地跑 HAProxy 转发到 3 CP,kubespray / talos / k0s 默认方案
实战踩坑:私有 IDC 测 VIP ip addr add 后其他节点 ping 报 Destination Host Unreachable(L3 路由失败,不是 L2 ARP 失败),说明 IDC 网关做了 source-IP 白名单。这种环境只能 per-node HAProxy。
Q2:跨节点 Pod 不通你怎么排查?
5 步:
kubectl get pods -o wide看 Pod 在不在 Running、IP 是否分配kubectl exec pod1 -- ping podN_IP看是否通(基础)ip route | grep <pod_cidr>看 host 路由表有没有跨节点 Pod CIDRtcpdump -i eth0 host <对端节点 IP>看真实包是否到达对端 + 检查包的源 IP- 关键发现:如果 src 是 Pod IP 没经过封装 → CNI encap mode 配错(应该用 VXLAN)
故事性总结:「我踩过 Calico IPIPCrossSubnet 在私有 IDC 被 source-IP filter drop 的坑,tcpdump 看到包的源 IP 是裸 Pod IP 没封装是关键线索,切到 VXLAN Always 立刻通。」
Q3:CNI 切换需要清哪些残留?
K8s 层 + 节点层 8 项:
- K8s 层:Installation CR、DaemonSet、Deployment、Service、RBAC、CRD、Namespace
- 节点层(容易漏):
/etc/cni/net.d/*配置/opt/cni/bin/*二进制- iptables 自定义链
ip route flush proto bird(Calico 学到的路由)- 网络接口(
vxlan.calico/tunl0/cali*veth)
故事性追问:「装上 Cilium 后跨节点带宽只有 Calico 的 20%,查路由表发现 BIRD proto 的路由跟 Cilium 路由共存导致包走错下一跳,ip route flush proto bird 后立刻恢复正常并反超 14%。」
Q4:node-local-dns 为什么是生产必装?
4 个收益:
- 延迟:本地 hairpin < 0.5ms,比 kube-proxy DNAT + 跨节点 2-5ms 快 5-10×
- conntrack 压力降:NOTRACK 规则绕过 conntrack,大集群关键
- CoreDNS QPS 降 50-90%:大部分 query 节点本地命中
- 故障容忍:CoreDNS 全挂时 cache 内的 name 还能解析
部署易错点:forward 上游必须指 kube-dns-upstream Service,不能指 kube-dns Service —— 否则 node-local-dns 自己 forward 给自己(因为它也 bind 了 10.96.0.10),死循环 timeout。
Q5:etcd 在 K8s 里是怎么 HA 的?new CP join 时是什么流程?
etcd 是 raft 协议,多数派可用即可写。kubeadm HA 的 etcd 是 stacked 模式(etcd 跑在 control-plane 节点上的 static pod)。
新 CP join 流程:
- kubeadm 拉
kube-system/kubeadm-certsSecret 解密 CA cert - 把新 member add 进 etcd 作为 learner(etcd 3.4+ 特性,无投票权)
- 等 learner sync 完整个 raft log
- promote 成 voter(这时才参与多数派计算)
为什么 learner 设计:避免新 member 还没同步完就有投票权导致 quorum 计算错误。
Ghost member 处理:如果 join 失败,member 残留在 raft 配置里,etcd 不会自动清,必须 etcdctl member remove <id> 手动删,否则下次 join 同名节点会冲突。
下一步
Day 1 结束,5 节点 K8s 集群就位。Day 2 进入 control plane 深度:apiserver 启动参数、audit policy、etcd raft / MVCC / compaction / defrag 实操,然后 chaos drill(杀 etcd member、模拟 split-brain、apiserver 起不来的排障)。