ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

K8s单节点集群扩容实战:二进制部署master2与nginx+keepalived高可用架构

K8s单节点集群扩容实战:二进制部署master2与nginx+keepalived高可用架构

先说场景吧。很多团队搞 Kubernetes 集群,第一步通常都是拿一台机器把单节点的集群先跑起来,组件全部用二进制装好、证书手动签好,验证业务没问题之后,才回过头来想生产环境和灾备的事:万一这台 master 挂了怎么办?流量入口怎么抗住?所以就有了这个标题里说的事——在已经存在的单节点基础上,横向扩展出 master2,前面再架两台机器跑 nginx 加 keepalived,把 API Server 的流量收敛到一个虚拟 IP 上。这基本是所有自建 K8s 集群从"能跑"走向"能扛"的必经一步。

先说清楚这套方案定位:它不依赖 kubeadm 初始化,而是纯二进制部署、手动维护证书和配置文件。好处是每个组件都是裸进程,逻辑透明、方便排查,也更容易定制参数;代价是新增 master 节点时没有现成的kubeadm join可用,必须手工同步证书、改 etcd 集群、注册 kubelet,任何一个环节漏了都会导致新节点起不来或者连不上集群。本文适合已经有一套单节点二进制集群、正准备扩容的运维和开发同学参考,也适合完全没接触过多节点部署、想理解 K8s 控制面组件之间是怎么协作的人。下面直接进入正题。

1. 部署架构与整体思路

1.1 目标架构解析

这套方案做完之后,集群的形态大致是这样:

  • 3 个 master 节点:原有的 master1 作为第一个控制面节点继续保留,在此基础上新增 master2,后续可能还会加 master3,所以证书和配置最好一次做到位。
  • 2 个 LB 节点:分别安装 nginx(四层负载均衡)和 keepalived(提供虚拟 IP 漂移能力),它们共同对外提供一个 VIP。所有客户端、组件、kubelet 都通过这个 VIP 访问 API Server。
  • Worker 节点:继续通过 VIP 接入控制面,不需要因为 master 变化而修改配置。

从流量路径看,请求到达 VIP 后,由当前 keepalived 的 master 节点上的 nginx 接收,再按照配置把流量转发到 master1 和 master2 的 6443 端口。任何一个 LB 节点宕机,VIP 会漂移到另一台;任何一个 master 宕机,nginx 会自动将其从上游摘除。控制面整体对外表现为一个稳定的 https 入口。

这个架构最核心的思路是把"控制面节点"和"流量入口节点"彻底分开。master 只负责跑 apiserver、etcd、controller-manager、scheduler 这些控制面组件,由前面的 nginx 统一代理;LB 层只做流量转发,不参与集群状态,也不存储任何数据。这样任意一层的故障都不会直接带崩整个集群,非常经典的解耦设计。

1.2 为什么坚持二进制部署而不是重新用 kubeadm 搭一套

我知道很多人会问这个问题。已经有单节点集群了,直接把旧的干掉,用 kubeadm init 重新来一个三 master 的高可用集群,不是更省事吗?

理论上确实省事,但实际生产里,升级改造最忌讳的就是把线上环境推倒重来。很多团队的单节点集群里已经跑着业务、配了各种自定义的 admission webhook、存储插件、网络插件,甚至手工调整过 apiserver 的启动参数。如果推倒重建,这些都得上线前重新验证一遍,回归成本极高。而二进制部署的集群,组件之间耦合度低,新增节点本质上是"复制一个 master 的配置文件 + 证书,再改少量分布式相关的参数",对现有环境的影响面最小。

另外,二进制部署虽然繁琐,但每个组件的启动参数、证书路径、监听端口都是明明白白写在 systemd unit 文件里的。出了问题,直接看 process 列表就能定位是哪个组件异常,不像 kubeadm 那样封装了一层,报错信息绕来绕去。对于要长期维护集群的团队来说,这种透明性比省那点初始化的功夫更值钱。当然,如果原有集群是用 kubeadm 装的,那直接 kubeadm join 会更顺,完全没必要手工 curl 二进制下来折腾——选择哪个方案,取决于现有集群的"出身",不要盲目套模板。

1.3 版本选择与环境规划

版本不建议太新。K8s 社区节奏快,但生产环境追求的是稳定和资料可查,一般选1.20到1.26之间比较稳妥。我这次演示基于1.23.x,etcd 使用3.5.x,这两个版本组合验证过大量生产案例,兼容性很好。

节点规划可以参考下面的表格,实际根据你手里的机器调整 IP 即可:

节点角色主机名IP 地址建议配置
旧控制面节点master1192.168.1.104C8G,100G 系统盘
新增控制面节点master2192.168.1.114C8G,100G 系统盘
负载均衡节点1lb01192.168.1.122C4G,50G 系统盘
负载均衡节点2lb02192.168.1.132C4G,50G 系统盘
虚拟 IP(VIP)-192.168.1.100keepalived 管理,不绑定到固定机器

Worker 节点规划看业务需要,这里不展开。所有节点建议使用 CentOS 7.9 或 Ubuntu 20.04+,内核版本不低于 3.10,网络插件常用的 flannel 和 calico 都能正常跑。如果机器配置允许,master 节点的数据盘尽量用独立的,etcd 的 IO 压力要比普通业务进程大得多。

2. 前期准备:LB 节点与 master2 环境初始化

2.1 基础系统配置

这一步属于"磨刀不误砍柴工",所有新节点都要做统一的基础设置。先把主机名改好,避免后面看到一堆 localhost 分不清谁是谁:

# master2 上执行 hostnamectl set-hostname master2 # lb01 上执行 hostnamectl set-hostname lb01 # lb02 上执行 hostnamectl set-hostname lb02

然后统一 hosts 解析。注意,不仅是新节点要写,旧的 master1 也要检查一遍,最好所有节点上使用的都是同一份 hosts 内容,这样证书里的 IP 和主机名才能对得上:

cat >> /etc/hosts <<EOF 192.168.1.10 master1 192.168.1.11 master2 192.168.1.12 lb01 192.168.1.13 lb02 EOF

接着是防火墙和 SELinux。生产环境我不建议直接systemctl stop firewalld,而是放行需要的端口,但很多内网测试环境图省事就直接关了。如果你只想快速验证方案,可以临时关闭:

systemctl stop firewalld && systemctl disable firewalld sed -i 's/^SELINUX=enforcing/SELINUX=disabled/' /etc/selinux/config setenforce 0

这里要提醒一句:如果你是在需要合规审计的环境里操作,别图省事关防火墙,自己按端口维度把规则写好。K8s 控制面相关端口如下:

  • apiserver:6443
  • etcd:2379(客户端)、2380(集群间通信)
  • kubelet:10250
  • scheduler:10259
  • controller-manager:10257

LB 节点如果开启了 firewalld,需要放行 VIP 的 6443 端口,以及 keepalived 的 VRRP 协议(通常是 112 端口)。

内核参数也建议提前统一。K8s 对网络转发要求比较高,尤其是 calico 使用 BGP 模式时需要开启 IP 转发:

cat >> /etc/sysctl.d/k8s.conf <<EOF net.bridge.bridge-nf-call-ip6tables = 1 net.bridge.bridge-nf-call-iptables = 1 net.ipv4.ip_forward = 1 EOF sysctl -p /etc/sysctl.d/k8s.conf

vm.swappiness 也可以顺手调成 0 或 10,减少内存交换带来的不稳定。每台新节点做完这些之后,建议统一测试一下节点间的网络连通性:ping一下所有 host,检查 6443 端口是否通(master 上),LB 之间互相 ping VIP 网段是否正常。

2.2 免密登录与文件分发准备

新增 master2 需要拷贝主节点上的大量文件,包括二进制、证书、配置文件。手打 scp 一个个传也可以,但文件多了极易出错。我习惯在 master1 上生成一对部署专用的密钥,分发给 master2 和两台 LB:

# 在 master1 上 ssh-keygen -t rsa -b 4096 -f /root/.ssh/deploy_key -N "" cat /root/.ssh/deploy_key.pub >> /root/.ssh/authorized_keys ssh-copy-id -i /root/.ssh/deploy_key.pub root@192.168.1.11 ssh-copy-id -i /root/.ssh/deploy_key.pub root@192.168.1.12 ssh-copy-id -i /root/.ssh/deploy_key.pub root@192.168.1.13

之后所有文件分发都用scp -i /root/.ssh/deploy_key指定密钥执行。用专用密钥而不是直接复用 root 密码登录,至少能减少一部分误操作风险——比如脚本里不小心把密码打出来,落在 shell history 里就很尴尬。

同时确认一下 master2 上已经准备好 Kubernetes 二进制文件。最稳妥的方式是让 master2 和 master1 使用完全相同的二进制版本,直接用 scp 从 master1 拷贝:

# 在 master1 上执行 scp -i /root/.ssh/deploy_key -r /opt/kubernetes/bin root@192.168.1.11:/opt/kubernetes/bin

如果你的单节点集群当初是把二进制放在/usr/local/bin之类的目录,就先确认一下原来的路径。后面所有 systemd unit 文件的ExecStart路径要和你拷贝到的位置保持一致,这一块前后不一致导致的启动失败,是我见过最普遍的翻车点。

2.3 目录结构与配置基线检查

二进制部署最忌讳的是每个节点目录结构不一致。master1 上的目录结构可能是这样的:

/opt/kubernetes/ ├── bin/ │ ├── kube-apiserver │ ├── kube-controller-manager │ ├── kube-scheduler │ ├── kubelet │ └── kube-proxy ├── cfg/ │ ├── kube-apiserver.conf │ ├── kube-controller-manager.conf │ ├── kube-scheduler.conf │ ├── kubelet.conf │ └── kube-proxy.conf ├── ssl/ │ ├── ca.pem │ ├── ca-key.pem │ ├── server.pem │ ├── server-key.pem │ ├── kubelet.pem │ └── ... └── logs/

在动手之前,先把 master1 上这些目录完整列出来,逐个确认每个目录里有哪些文件。然后对照检查 master2 是否也建立了相同目录:

mkdir -p /opt/kubernetes/{bin,cfg,ssl,logs}

这个"目录基线"非常重要。后面你写 systemd unit 文件时,ExecStart里引用的--config参数路径、证书路径,全部依赖这套目录结构。如果 master2 上少建了一个目录,启动时就会报 file not found,排查起来非常绕。

3. master2 加入集群:证书、etcd 与控制面组件同步

3.1 证书配置的两种处理方式

单节点集群新增 master2,最大的坑之一就是证书。如果你的 apiserver 证书签发时只写了 master1 的 IP 和 hostname,那么 master2 上的 kubelet 或者其他组件访问 apiserver 时,证书校验会直接失败,报 x509 错误。

处理方式分两种:

一种是从 master1 拷贝一套现成的证书过来。这种方式最快,但只适用于 apiserver 并没有开启严格的 client cert 校验、并且你已经知道证书的 IP SAN 里没有 master2 的场景。实际情况中,很多单节点集群的 server.pem 只包含 master1 的 IP,那直接拷贝过来是过不了 client 校验的。

另一种方式是重新签发证书,把 master2 的 IP 写进 apiserver 的 server 证书 SAN。这也是我推荐的处理方式。证书签发用你原有的 CA 来签,这样整个集群信任链不变。签发完之后,把新的 server.pem、server-key.pem 分发给所有 master 节点。

很多团队早期图省事,直接用 kubeadm init 生成了一整套证书,然后用 kubeadm 的证书密钥导出到二进制部署的集群里,这确实可行,但不建议深究,因为 kubeadm 签发的证书有效期通常只有一年,到期之后的续签又是一套功夫。自己维护 CA 和证书,可以用openssl x509 -req按需签发,过期时间自己定,主动权在手里。比如:

openssl x509 -req -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \ -out server.pem -days 3650 -extensions v3_req -extfile server-csr.conf

server-csr.conf里的subjectAltName必须包含 master1、master2 的 IP,还有 VIP 地址。下面是一个典型的配置:

[ v3_req ] keyUsage = keyEncipherment, dataEncipherment, digitalSignature extendedKeyUsage = serverAuth subjectAltName = @alt_names [ alt_names ] IP.1 = 192.168.1.10 IP.2 = 192.168.1.11 IP.3 = 192.168.1.100 DNS.1 = master1 DNS.2 = master2 DNS.3 = kubernetes.default.svc.cluster.local

因为通过 VIP 访问 apiserver,证书里不写 VIP 的话,所有外部的 kubectl 都会报证书校验失败。这是个很容易被忽略的细节,一定要提前加进去。证书签发完,在 master1 和 master2 上各存一份:

cp server.pem /opt/kubernetes/ssl/server.pem cp server-key.pem /opt/kubernetes/ssl/server-key.pem sysctl restart kube-apiserver # 先在 master1 上重启

3.2 etcd 集群扩容:从单节点到双成员

etcd 是整个集群状态的存储,也是新增 master 时最容易出问题的组件。单节点集群的 etcd 配置文件里,initial-cluster通常只有master1=http://192.168.1.10:2380这一条。要加 master2,就要把它改成两个成员。

这里要注意,千万不要直接手动改 etcd 的静态配置文件然后重启,那样 etcd 会认为集群拓扑变了,容易产生数据不一致。正确的方式是在 etcd 集群运行状态下,通过 etcdctl 动态添加成员。

假设 etcdctl 的 API 版本是 v3:

export ETCDCTL_API=3 etcdctl --endpoints=https://192.168.1.10:2379 \ --cacert=/opt/kubernetes/ssl/ca.pem \ --cert=/opt/kubernetes/ssl/server.pem \ --key=/opt/kubernetes/ssl/server-key.pem \ member add master2 https://192.168.1.11:2380

执行成功后,etcdctl 会输出一段提示信息,其中包括ETCD_INITIAL_CLUSTER环境变量。这段信息非常关键,它告诉你在 master2 的 etcd 配置文件里应该怎样填写 initial-cluster。很多人在这一步就随便写,所以 master2 上的 etcd 一直起不来。

把 master1 的 etcd 配置拿出来,找到类似这一段:

ETCD_INITIAL_CLUSTER="master1=https://192.168.1.10:2380,master2=https://192.168.1.11:2380" ETCD_INITIAL_CLUSTER_STATE="existing"

注意ETCD_INITIAL_CLUSTER_STATE的值,在动态添加成员后必须是existing,不能在 master2 上写成new,否则 etcd 节点会初始化成一个全新的空集群。

同时修改 master1 上的 etcd 配置,确保两边的 initial-cluster 信息一致,然后重启 master1 的 etcd:

systemctl restart etcd

重启 master1 的 etcd 时,可能会短暂影响控制面的读取,所以在业务低峰期操作比较好。etcd 起来之后,用 member list 确认两个成员都 healthy:

etcdctl member list

正常情况下会看到两个成员,并且 leader 存在,follower 状态正常。如果你在这一步遇到etcdserver: request timed out,大概率是 master2 上的 etcd 还没有启动,master1 在等待它心跳。这时候只需要继续把 master2 的 etcd 配置写好并启动即可。

3.3 同步 kube-apiserver 与控制面组件配置

master2 上的 kube-apiserver 配置,和 master1 相比只有少数参数不同:监听 IP 从192.168.1.10改成192.168.1.11,其他像 etcd 连接地址、service CIDR、证书路径等都是一样的。

最简单的方式是先拷贝 master1 的配置文件,再全局替换 IP:

scp -i /root/.ssh/deploy_key \ root@192.168.1.10:/opt/kubernetes/cfg/kube-apiserver.conf \ /opt/kubernetes/cfg/kube-apiserver.conf sed -i 's/192.168.1.10/192.168.1.11/g' /opt/kubernetes/cfg/kube-apiserver.conf

但用 sed 全局替换有一定风险。如果你在 apiserver 配置里写了证书 SAN 或某些 URL 参数,不该替换的也被替换了,就会出问题。所以更稳妥的方式是手动编辑配置文件,只改这两处:

  • --advertise-address改为本机 IP
  • --bind-address保持0.0.0.0或改为本机 IP

其余的--client-ca-file、--etcd-servers、--service-cluster-ip-range都保持与 master1 一致。

controller-manager 和 scheduler 的配置相对简单,它们通过 kubeconfig 连接本机 apiserver,所以--kubeconfig里的 server 地址如果写的是https://192.168.1.10:6443,也要改成https://192.168.1.11:6443或https://127.0.0.1:6443。用本机回环地址更好,少一层网络转发,也避免 apiserver 地址变化导致这两个组件掉线。

controller-manager 和 scheduler 的 systemd unit 文件通常长这样:

[Unit] Description=Kubernetes Controller Manager Documentation=https://github.com/kubernetes/kubernetes [Service] ExecStart=/opt/kubernetes/bin/kube-controller-manager \ --config=/opt/kubernetes/cfg/kube-controller-manager.conf Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target

配置文件细节确认完之后,复制到 master2 对应位置,然后逐个启动:

systemctl daemon-reload systemctl enable --now kube-apiserver systemctl enable --now kube-controller-manager systemctl enable --now kube-scheduler

启动之后不要急着继续,先确认进程状态正常,再进入下一步。检查命令:

systemctl status kube-apiserver ss -lntp | grep 6443 curl -k https://192.168.1.11:6443/healthz

如果 healthz 返回 ok,说明 apiserver 本身和服务发现都正常。注意这里 curl 加-k是因为我们访问的是 IP 而不是证书里的 DNS,跳过校验只是用于验证服务存活,并不代表客户端也能通过证书校验。

3.4 kubelet 与 kube-proxy 注册

控制面组件起来之后,还差 kubelet 和 kube-proxy。这两个组件是直接跑在每个节点上的,负责管理 Pod 和网络规则。

master2 的 kubelet 配置文件同样是拷贝 master1 的之后改 IP。但这里有个关键点:kubelet 启动时会向 apiserver 发起 CSR(证书签名请求),然后等待 controller-manager 自动批准(如果配置了 auto-approve)或者管理员手动批准。二进制部署的集群往往没有自动批准机制,所以 master2 上的 kubelet 可能一直处于 Pending 状态。

先拷贝配置并启动 kubelet:

scp -i /root/.ssh/deploy_key \ root@192.168.1.10:/opt/kubernetes/cfg/kubelet.conf \ /opt/kubernetes/cfg/kubelet.conf # 修改 kubelet.conf 里的 server 地址为 https://192.168.1.11:6443

然后启动:

systemctl enable --now kubelet

纯二进制部署时,kubelet 通常使用 bootstrap kubeconfig 来向 apiserver 发起 CSR。你可以在 master1 上查看证书签名请求:

kubectl get csr

如果看到 master2 发来的 CSR 处于 Pending 状态,手动批准即可:

kubectl certificate approve <csr-name>

如果是用--register-with-taints配置了 master 节点默认的污点,要确认 kubelet 的 systemd unit 里是否带了--node-labels=node-role.kubernetes.io/master=这类的标注。有些单节点集群配置了特殊标签,拷贝过来不改的话,master2 上 kubelet 会以 worker 身份注册,控制面节点身份就丢了。

kube-proxy 配置相对简单。拷贝配置文件,改 apiserver 地址为 VIP 或本机 IP,然后:

systemctl enable --now kube-proxy

这里我建议 kubelet 和 kube-proxy 的 apiserver 地址都指向 VIP(192.168.1.100),因为 kubelet 需要通过 apiserver 上报节点状态。如果配置指向 master1 的 IP,当 master1 故障而 VIP 正常工作,kubelet 与 apiserver 的通信会中断,节点状态就会处于 NotReady。指向 VIP 则天然具备故障转移能力。

3.5 验证 master2 已加入集群

在 master1 上执行:

kubectl get nodes

输出应该包含 master1 和 master2,状态都是 Ready。再检查组件状态:

kubectl get cs

如果 controller-manager 和 scheduler 都显示 Healthy,说明控制面组件和 apiserver 的连接没有问题。接着检查 etcd 健康状态:

etcdctl endpoint health --cluster

这个命令会输出所有 etcd 成员的健康状态。如果全部是 healthy,说明 etcd 集群本身扩容成功。此时整个集群的控制面已经具备了两个 master,但流量入口还分散着,接下来就该上 LB 层了。

4. 两台 LB 节点搭建 nginx + keepalived 高可用负载均衡

4.1 为什么要用 L4 而不是 L7 负载均衡

在给 K8s 做入口负载均衡时,最常见的争论是用 L4(四层 TCP/UDP)还是 L7(七层 HTTP/HTTPS)。如果前面已经有专业的硬件负载均衡或者云厂商的 SLB 产品,通常直接用 L4 转发 TCP 到 6443 端口最稳妥,性能和稳定性都有保障。

对于自建的 LB 节点,我强烈建议用 nginx 的 stream 模块做四层转发,而不是用 http 模块做反向代理。原因是 Kubernetes 的 apiserver 与客户端之间是长连接(HTTP/2 或 WebSocket),L7 代理会做协议解析和连接复用,apiserver 看到的客户端 IP 会被代理替换,而且遇到长连接(比如 exec、logs、port-forward)时,L7 层需要额外配置超时参数,非常容易把连接断开。而 L4 转发基本就是端口透传,客户端与 apiserver 直接建立 TCP 连接,nginx 只做流量分发,不关心 HTTP 内容,行为最接近一个高通量的 TCP 路由器。

其实还有一个隐藏问题:K8s 的 kubectl、kubelet 等组件访问 apiserver 时,证书校验很重要。如果走 L7 的 HTTP 代理,nginx 与 apiserver 之间建立了自己的 TLS 连接,客户端拿到的证书并不是 apiserver 的证书,虽然可以通过proxy_ssl_server_name之类的参数规避,但还是要额外处理。直接用 L4 透传,TLS 层完全不动,证书链路自然就不会出问题。

4.2 keepalived 高可用设计与配置

两台 LB 节点,最核心的就是 keepalived 提供 VIP。这个 VIP 由两台机器"抢"来抢去,正常情况下绑定在其中一台的网卡上,如果这台机器挂了,另一台会在几个 VRRP 通告周期内抢占过来,整个过程对客户端透明。

keepalived 配置并不复杂,但有几个容易踩的坑。首先,VIP 必须和业务网段在同一二层网络内,否则 VRRP 广播无法正常工作,VIP 也 ping 不通。配置示例:

# /etc/keepalived/keepalived.conf global_defs { router_id LVS_K8S # 禁止 keepalived 在启动时执行脚本检查,避免不必要的输出 script_user root enable_script_security } vrrp_instance VI_K8S { state BACKUP # 两台都设为 BACKUP,避免同时抢占 interface eth0 virtual_router_id 101 priority 110 # 两台不同:lb01 110,lb02 100 advert_int 1 authentication { auth_type PASS auth_pass 8u3k8s } unicast_src_ip 192.168.1.12 # 本机 IP unicast_peer { 192.168.1.13 # 对端 IP } virtual_ipaddress { 192.168.1.100/24 dev eth0 label eth0:1 } track_script { check_apiserver } priority 110 }

注意不要把两个节点都配成state MASTER,这样即便有优先级机制,在网络抖动时也容易造成脑裂。更好的做法是两台都配成BACKUP,通过 priority 大小决定谁优先抢占。lb01 配置 priority 110,lb02 配置 priority 100,正常情况下 lb01 持有 VIP;lb01 故障后,lb02 因为优先级低,会等一段时间再抢占,避免频繁切换。

unicast_src_ip和unicast_peer是很多教程会漏掉的。如果你的交换机禁用了组播(某些云环境就是这样),VRRP 组播包根本发不出去,keepalived 两侧互相看不到对方,VIP 永远漂移不起来。使用单播可以让 keepalived 在非组播环境正常工作,这也是在云服务器上部署时必备的配置。

还需要加一个健康检查脚本。单纯的 VRRP 只能发现节点宕机,如果 nginx 进程异常或者 apiserver 全部不可达,VIP 是不会自动漂移的。这会导致客户端连上了 VIP,但 VIP 背后的 nginx 已经无法转发。所以必须写个脚本,定期探活本地 nginx 或后端 apiserver:

#!/bin/bash # /etc/keepalived/check_apiserver.sh if curl -k https://192.168.1.100:6443/healthz >/dev/null 2>&1; then echo "apiserver is ok" exit 0 else echo "apiserver is down" exit 1 fi

这里探测的是 VIP 地址,因为在 LB 节点上 nginx 是绑定在 VIP 上的,如果 VIP 不在本机,探测必然失败,脚本会触发 keepalived 降低权重,配合nopreempt可以避免反复切换。脚本需要可执行权限:

chmod +x /etc/keepalived/check_apiserver.sh

4.3 nginx 四层 stream 配置

nginx 需要编译安装 stream 模块,或者使用发行版里已经包含 stream 模块的版本(一般通过nginx-mod-stream包安装)。stream 配置与 http 平级,推荐放在单独的文件里,用 include 引入。

# /etc/nginx/nginx.conf 添加以下内容 stream { log_format proxy '$remote_addr - [$time_local] ' '$protocol $status $bytes_sent $bytes_received ' '$session_time "$upstream_addr"'; access_log /var/log/nginx/stream-access.log proxy; upstream kube_apiserver { server 192.168.1.10:6443 max_fails=3 fail_timeout=10s; server 192.168.1.11:6443 max_fails=3 fail_timeout=10s; } server { listen 192.168.1.100:6443; proxy_pass kube_apiserver; proxy_timeout 300s; proxy_connect_timeout 3s; } }

关键点在于proxy_timeout。这里指的是连接空闲超时,而不是总超时。K8s 里有大量长连接场景(比如 kubectl exec 会话、kubelet 的 pod sync),如果超时设太短,空闲的连接会被 nginx 断开,客户端就会遇到 connection reset。我见过不少集群在 LB 层放了 nginx 之后,port-forward 总是莫名断掉,最后定位就是这里的超时被默认值害了。设成 300s 甚至更大都不为过。

另外listen必须绑定 VIP 的 IP,而不是0.0.0.0。这是因为 keepalived 管理 VIP 后,nginx 如果监听所有地址,那么在非 VIP owner 节点上访问也会被本机 nginx 接收,但它无法转发(因为本机不是 owner 且 VIP 没有漂移过来),行为就变得很诡异。绑定 VIP 后,只有 VIP 漂移到哪台,哪台的 nginx 才真正提供服务,逻辑非常干净。

配置完成后:

nginx -t && systemctl restart nginx

4.4 配置 health check 脚本与启动验证

keepalived 的健康检查脚本,我建议既检查本地 nginx 进程,也检查后端 apiserver。因为 nginx 进程活着但转发失效,比 nginx 挂了更难察觉。把脚本改成:

#!/bin/bash if ! pgrep -x nginx >/dev/null; then exit 1 fi # 从本机任意网卡访问后端 apiserver 进行探测 for i in 1 2; do if curl -k https://192.168.1.10:6443/healthz >/dev/null 2>&1 || curl -k https://192.168.1.11:6443/healthz >/dev/null 2>&1; then exit 0 fi done exit 1

注意这个脚本中探测的是 master 的 IP 而不是 VIP。如果在 LB 的 keepalived 健康检查脚本里探测 VIP 地址,而 VIP 就在本机,nginx 又绑定在 VIP 上,这个探测能够通过;一旦 nginx 挂了,探测也自然会失败。两种写法各有其适应场景,我用的是"探测后端 apiserver + 判断 nginx 进程",因为它的逻辑更贴近真实需求:只要有一个 apiserver 活着,且本机 nginx 活着,那么这个 LB 节点就应当持有 VIP,继续提供服务。

启动验证顺序是这样的:

# 先在 lb01 和 lb02 上分别启动 keepalived systemctl enable --now keepalived

然后看 VIP 在哪里:

ip addr show | grep 192.168.1.100

正常情况下只有一台机器有这个 IP(通常是 lb01)。接着在任意一台机器上验证连通性:

curl -k https://192.168.1.100:6443/healthz

返回ok就说明整个链路 LB -> apiserver 已经打通。

5. 高可用验证:故障注入与恢复演练

5.1 验证 VIP 漂移:模拟 LB 节点宕机

高可用架构交付前,一定要做故障注入测试,不然"高可用"只是纸面上的。最直接的就是把 lb01 的 keepalived 停掉:

# 在 lb01 上执行 systemctl stop keepalived

然后到 lb02 上查看是否抢占了 VIP:

ip addr show | grep 192.168.1.100

正常情况下,等待 1 到 3 个 VRRP 通告周期,VIP 出现在 lb02 上。再用curl -k https://192.168.1.100:6443/healthz验证请求恢复正常。这就是主备切换的基本流程。

注意观察 keepalived 日志:

tail -f /var/log/messages

日志中会出现Entering BACKUP STATE和Entering MASTER STATE之类的记录。如果日志里反复切换,说明 keepalived 的优先级或者健康检查脚本有问题,需要排查。常见原因是两台机器上 script 的返回结果不一致,导致 VRRP 权重抖动,VIP 频繁漂移。

生产环境里,VIP 漂移虽然正常,但过于频繁的切换比不切换更糟。每次切换都会让所有长连接中断,kubelet 会重新上报节点状态,对集群造成一次不小的抖动。所以 keepalived 的参数尽量保守,priority 的差距不要太大,advert_int 保持在 1 秒以上,避免脑裂和抢占有延迟的叠加。

5.2 验证后端故障:模拟 master1 的 apiserver 宕机

LB 层的高可用验证完,还要验证后端 apiserver 的高可用。在 master1 上把 kube-apiserver 停掉:

systemctl stop kube-apiserver

此时通过 VIP 访问集群还是正常的,因为 nginx 会把请求转发到 master2 的 6443 端口。验证:

kubectl get nodes

正常情况下输出 master1 状态变成 NotReady(或者完全不显示),master2 状态 Ready,集群控制面仍可读写。

这里有一个细节值得注意:kubelet 上报节点状态是有时间间隔的,master1 上的 kubelet 还会持续向 master2 的 apiserver 上报自己的心跳,所以 master1 的节点状态可能暂时显示 Ready,过一段时间才变成 NotReady。这属于正常现象。

如果你发现 master1 的 apiserver 停掉后,通过 VIP 访问直接 connection refused,问题通常出在 nginx 的 upstream 配置——max_fails和fail_timeout可能需要更短,或者 nginx 的zone配置缺失导致后端状态无法被正确判定为 down。调整后重新加载 nginx 配置即可。

5.3 验证 kubelet 通过 VIP 接入

确保所有 worker 节点(包括 master 节点)的 kubelet 和 kube-proxy 配置里的 server 地址都是https://192.168.1.100:6443。检查一下:

grep -r "192.168.1.100" /opt/kubernetes/cfg/

如果 kubelet 的 kubeconfig 里还是 master1 的 IP,建议改掉,否则 master1 apiserver 故障时,该节点上的 kubelet 会直接失联,Pod 大概率会被系统重新调度到其他节点,造成不必要的漂移。在三个 master 上都执行:

systemctl restart kubelet kube-proxy

重启后观察节点状态是否恢复正常。这个操作可能会暂时中断该节点存量 Pod 与 apiserver 之间的通信,建议分批做,不要三个 master 同时重启。我见过有人把三条命令写到一个 for 循环里一起执行,结果整个集群 apiserver 短暂全部失联,虽然最终恢复,但过程非常吓人。

6. 常见问题与排查记录

6.1 etcd 成员添加失败:日志显示"requested member already exists"

大概率是之前已经 add 过了,但没有完成后续操作,etcd 的 cluster state 里还留着这个旧信息。解决方式是移除旧 member,再重新 add:

etcdctl member remove <member-id> etcdctl member add master2 https://192.168.1.11:2380

还有一种情况是 master2 本机的 etcd 数据目录里已经有数据了。比如你之前错误地以new状态启动过一次 etcd,数据目录写入了一半内容。此时需要清掉数据目录再启动:

rm -rf /var/lib/etcd/* systemctl start etcd

这个操作要谨慎,确认 master2 上没有需要保留的数据再执行。etcd 数据目录清理是"此生不会再救回来"的操作,即使集群已经通过 add member 方式加回来了,之前残留的旧数据也可能干扰集群一致性。所以理论上,如果 master2 从来不是 etcd 成员,它不应该有 etcd 数据目录。你可以在启动前检查这个目录是否存在,如果存在且不确定来源,最好先移走而不是直接删。

6.2 apiserver 证书 x509 错误:证书的 SAN 不包含当前访问地址

新 master 加入后,kubectl 从外部访问 VIP 出来的报错是这样:

error: x509: certificate is valid for 192.168.1.10, master1, not 192.168.1.100

这说明 server.pem 里没有签 VIP。解决方法是重新签发 apiserver 的 server 证书,加上 192.168.1.100 这个 IP。注意签发完要同时更新两台 master 上的证书文件,并重启 apiserver:

systemctl restart kube-apiserver

如果改完之后还是报错,注意 kubectl 使用的 kubeconfig 可能缓存了旧的证书信息,把 ~/.kube/config 里的certificate-authority-data替换成新 CA 的内容,或者直接删除并使用集群内的 admin.conf 重新生成即可。

6.3 keepalived 抢占模式导致 VIP 反复横跳

如果你看到 keepalived 日志里频繁出现Entering MASTER STATE和Entering BACKUP STATE,并且 VIP 在两台机器上反复切换,多半是因为两台机器的 priority 设置太接近,或者健康检查脚本有误报。

推荐统一把两台都设成nopreempt模式(BACKUP 状态 + nopreempt),这样即使高优先级节点恢复后,也不主动抢占 VIP,等到下一次主节点故障时再切换。对客户端来说,主动抢占带来的长连接重连完全没有必要,非抢占模式更稳定。

6.4 nginx 配置了轮询但 apiserver 连接不稳定

表现是 kubectl 偶尔能通,偶尔 connection refused。检查 nginx 错误日志:

tail -f /var/log/nginx/error.log

如果全是connect() failed (111: Connection refused),说明某一个 apiserver 地址已经不可用,但 nginx 还不知道。加重时可以通过zone配置开启共享内存,让 nginx 维护更精确的后端健康状态:

upstream kube_apiserver { zone kube-apiserver 64k; server 192.168.1.10:6443 max_fails=3 fail_timeout=10s; server 192.168.1.11:6443 max_fails=3 fail_timeout=10s; }

加zone之后,nginx 会为这个 upstream 分配共享内存,用于保存健康检查状态,多个 worker 进程共享。这样某个后端的失败计数能被正确累计,不会因为 worker 进程各自为政而忽略故障。

6.5 kubelet 一直处于 NotReady

排查思路从下往上:

  1. systemctl status kubelet看进程是否存活
  2. journalctl -u kubelet -f看报错内容
  3. kubectl get csr看是否证书没批
  4. kubectl describe node master2看上报状态

最常见的原因就是 CSR 没批。二进制部署不会自动批准,需要手动 approve 之后 kubelet 才能拿到证书。第一次加 master2 时这一步一定会遇到,批完后 kubelet 才能成功鉴权并上报状态。

如果 CSR 一直处于 Pending 且数量在持续增加,说明 kubelet 没有正确的 bootstrap token 或 kubeconfig 参数。检查 kubelet.conf 里的certificate-authority是否指向了正确的 CA 文件,以及server的地址是否能通。

6.6 高可用验证时发现主节点 apiserver 挂了,VIP 却还是转发到它

这个现象说明 nginx 对后端的健康检查没有生效。检查 two things:

  • nginx 是否加载了 stream 模块并开启了 upstream 健康检查(版本不同,stream 模块的health_check支持情况不同,默认可能没有开启)
  • keepalived 脚本里检查的是否是后端 apiserver 的连通性,而不是单纯检查本机 nginx 进程

二进制部署时,nginx 四层转发有个特性:默认不主动发健康检查包,只根据max_fails和fail_timeout在转发失败时标记后端不可用。如果一直没有人发起请求,故障后端不会被标记为 down。所以上面的 keepalived 脚本中的主动探活反而是更可靠的一环。

写到最后的一点实际体会

这套部署方案做完之后,我最大的感受是:二进制部署的高可用 K8s 集群并不复杂,复杂的是保持每个节点的"一致性"。目录结构、证书 SAN、etcd member、kubelet 配置、nginx upstream,任何一个细节只要和 master1 差一点,故障就会以最诡异的面貌出现。所以实操时建议建立一份配置清单,每同步一个文件就打一个勾,每启动一个组件就验证一次接口,别等服务全部起来再统一排查,否则你根本不知道是哪一层出了问题。

另外,如果你在新增 master2 的过程中,发现 master1 的 apiserver 偶尔会出现etcdserver: request timed out,不要慌。检查一下 etcd 网络连接和磁盘 IO。如果你用的是机械硬盘,etcd 的 fsync 延迟会很大,控制面性能会有明显波动。这种情况下优先把 etcd 数据目录迁到 SSD,比调整任何参数都管用。

最后再分享一个小技巧:这套方案里所有配置尽量做到"用文件定义一切"。apiserver、etcd、kubelet 的配置全部落到对应的*.conf或 systemd 文件里,禁止直接在命令行上临时改参数启动。后续做节点扩容时,只需要拷文件再改 IP,就能在半小时内完成一个 master 的加入。如果一开始就图省事手工敲命令启动进程,那记录配置状态就成了纯靠记忆,迟早会出错。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进