AI Infra 训练营
总览
  • Day 1 · 集群起步 + CNI
  • Day 2 · 控制面 + etcd
  • Day 3 · CRD + Operator + Webhook
  • Day 4 · 存储深度
  • Day 5 · 卷扩容 + 安全
  • Day 6 · 调度 + 可观测
  • Day 7 · Harbor + ArgoCD + Mesh
  • Day 8 · AI Infra
  • Day 9 · Triton + GPU
  • Day 10 · MIG + HPA + 量化
  • Day 11 · AI Agent 端到端
  • Day 12 · 灾备
  • Day 13 · Operator + 联邦 + Mesh + RAG
  • Day 14 · CKA / CKS + 总结
  • LLM 训练手册
  • RAG + Agent 手册
  • 推理优化手册
  • 上下文工程手册
  • Agent 开发手册
  • 面试深度复盘
  • 训练 v2 深度手册
HiHuo 主站
GitHub
总览
  • Day 1 · 集群起步 + CNI
  • Day 2 · 控制面 + etcd
  • Day 3 · CRD + Operator + Webhook
  • Day 4 · 存储深度
  • Day 5 · 卷扩容 + 安全
  • Day 6 · 调度 + 可观测
  • Day 7 · Harbor + ArgoCD + Mesh
  • Day 8 · AI Infra
  • Day 9 · Triton + GPU
  • Day 10 · MIG + HPA + 量化
  • Day 11 · AI Agent 端到端
  • Day 12 · 灾备
  • Day 13 · Operator + 联邦 + Mesh + RAG
  • Day 14 · CKA / CKS + 总结
  • LLM 训练手册
  • RAG + Agent 手册
  • 推理优化手册
  • 上下文工程手册
  • Agent 开发手册
  • 面试深度复盘
  • 训练 v2 深度手册
HiHuo 主站
GitHub
  • Day 0 · 环境与硬件

    • Day 0:5 节点裸 Ubuntu → K8s 装机基线
  • Week 1:K8s 内核 + 周边基础设施

    • Day 1:3 CP HA 集群 + CNI 选型 + DNS 调优
    • Day 2: 控制面 deep dive + etcd 内核 + chaos drill
    • Day 3: CRD + Operator (kubebuilder 从 0 写)
    • Day 4: Storage 主线 + Cilium 二探
    • Day 5: Volume Expansion + 安全主线
    • Day 6: 调度 + 观测主线 + Day 2 遗留修复
    • Day 7: Harbor + ArgoCD + Cilium Service Mesh
  • Week 2:制品 + GitOps + AI Infra + 综合

    • Day 8 主线 — AI Infra: GPU + k3s + vLLM + Qwen2.5
    • Day 8 主线 — AI Infra 尝试 1 (跨 WAN GPU 加入主集群)
    • Day 8 (alt) — AlertManager 真接入 + PrometheusRule 实战
    • Day 8: CI Infrastructure — Gitea + Jenkins + Kaniko
    • Day 9: Triton + GPU Metrics + 推理性能对比
    • Day 10: MIG + 量化 + HPA Custom Metrics
    • Day 11: AI Agent 业务端到端 — 把 Day 1-10 全部串起来
    • Day 12: 灾难恢复 + 生产事故注入
    • Day 13: LLM Operator + 联邦 + Mesh + RAG
    • Day 14: CKA/CKS 真题演练 + 14 天 Bootcamp 终极总结

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-110.0.24.31控制面 / kubeadm init
k8s-cp-210.0.24.29控制面
k8s-cp-310.0.24.32控制面
k8s-w-110.0.24.28worker
k8s-w-210.0.24.30worker

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 = 1Linux 默认禁 IP 转发,跨节点 Pod 通信失败
swap offkubeadm 准入条件,[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 处:

  1. SystemdCgroup = true(跟 kubelet 对齐,否则 Pod 启不来或 OOM 行为异常)
  2. sandbox = registry.aliyuncs.com/google_containers/pause:3.10(不走 registry.k8s.io,国内拉不到)
  3. config_path = '/etc/containerd/certs.d'(开启 hosts.toml 镜像源机制)
  4. 写 /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-certs Secret,有效期 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 多两个动作:

  1. 从 kube-system/kubeadm-certs Secret 拉密钥解密 CA cert
  2. 加入 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 AlwaysIP-in-IPIP-in-IP用 IP proto 4,部分防火墙挡
IPIPCrossSubnet裸 IPIP-in-IP同子网踩 source-IP filter
VXLAN AlwaysUDP 4789 封装UDP 4789 封装防火墙友好,多 50B overhead
VXLANCrossSubnet裸 IPUDP 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+ 新集群几乎默认选择。核心差异:

CalicoCilium
dataplaneiptables + BIRD/BGPeBPF(内核 hook,绕过 netfilter)
Service LBkube-proxy 维护 iptables/ipvs rule可替代 kube-proxy(eBPF map 查 O(1))
Network Policy标准 K8s NP标准 NP + L7 / DNS / mTLS(CiliumNetworkPolicy)
可观测calico-typha 加 Prometheus exporterHubble(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 VXLANCilium VXLAN对比
跨节点带宽125 Mbps142 Mbps+14%
TCP retransmits (8s)1870283↓ 6.6×
RTT 平均3.290 ms1.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 种:

  1. VIP + keepalived / kube-vip ARP —— 经典姿势,需要 IDC 允许 secondary IP 漂移。小厂 IDC 几乎都不让漂,会被 source-IP filter / MAC-IP 静态绑定 block
  2. 外部 LB —— 云厂商 ALB / nginx LB / haproxy 节点,简单但要多 1 台
  3. 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 步:

  1. kubectl get pods -o wide 看 Pod 在不在 Running、IP 是否分配
  2. kubectl exec pod1 -- ping podN_IP 看是否通(基础)
  3. ip route | grep <pod_cidr> 看 host 路由表有没有跨节点 Pod CIDR
  4. tcpdump -i eth0 host <对端节点 IP> 看真实包是否到达对端 + 检查包的源 IP
  5. 关键发现:如果 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 个收益:

  1. 延迟:本地 hairpin < 0.5ms,比 kube-proxy DNAT + 跨节点 2-5ms 快 5-10×
  2. conntrack 压力降:NOTRACK 规则绕过 conntrack,大集群关键
  3. CoreDNS QPS 降 50-90%:大部分 query 节点本地命中
  4. 故障容忍: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 流程:

  1. kubeadm 拉 kube-system/kubeadm-certs Secret 解密 CA cert
  2. 把新 member add 进 etcd 作为 learner(etcd 3.4+ 特性,无投票权)
  3. 等 learner sync 完整个 raft log
  4. 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 起不来的排障)。

在 GitHub 上编辑此页
Next
Day 2: 控制面 deep dive + etcd 内核 + chaos drill