简介基于Docker与Kubespray离线部署高可用K8S集群的完整资源包面向运维工程师与容器平台实施人员解决国内网络环境下依赖外网拉取软件包导致部署受阻的问题。包体共72个文件以47个RPM离线安装包、13个TAR镜像包和4个Shell脚本为主另有Calico网络插件、etcd二进制及Kubespray编排源码大小554.46MB覆盖了集群部署所需的全部依赖。已有938人学习使用适合希望快速搭建生产级Kubernetes集群的读者。资源内含初始化环境、SSH密钥配置、内核升级、ipvsadm负载均衡等自动化脚本同时保留工具缓存与镜像压缩包无需逐一下载外部组件即可按官方方案完成多节点高可用部署有效缩短实施周期并降低网络不确定性带来的失败风险。1. 在国内互联网环境用 kubespray 部署高可用 K8S为什么先做部署资源包在国内机房或纯内网环境里用 kubespray 工具部署一套高可用 K8S 集群最折磨人的不是 ansible 剧本而是资源进不来quay.io、registry.k8s.io 这些源站拉镜像经常超时docker 镜像下载慢是常态kubeadm、kubelet 二进制也一样依赖官方软件源。于是就有了“部署资源包”这种打法在网络条件好的准备机上把 kubespray 源码、工具镜像、组件镜像、系统依赖全部打成离线包拿进目标环境基于 docker 运行 kubespray 一次装完。本文把这套方案从原理、资源包制作、高可用参数到排障讲完整给正在做 k8s 安装部署的运维和交付工程师一条可复现的路径。2. 拆解方案docker 里的 kubespray、高可用骨架与资源包清单2.1 先把一件事说清楚kubespray 容器与 K8S 容器运行时不是一回事很多第一次接触 kubespray 的人会被标题里的“基于 docker”误导以为 K8S 的容器运行时是 docker。这里要先拆清楚kubespray 本身是一套 ansible playbook它不常驻运行而是在你有部署动作时才执行。官方文档提供两种运行方式一种是宿主机装 python 和 ansible另一种是直接拉一个官方 kubespray 镜像在容器里执行 ansible 剧本。标题说的“基于 docker”指的正是第二种也就是把 kubespray 当一个工具镜像跑挂载 inventory 和 ssh 密钥进去它远程登录目标节点装集群。而集群内的容器运行时kubespray 从 2.2x 这条版本线开始默认使用 containerd不再把 docker 作为 K8S 节点运行时。这两个角色经常被问“k8s和docker区别”实际区别就在这里K8S 负责编排容器生命周期containerd 负责拉镜像、启停沙箱docker 只是“能用”的容器引擎之一。部署完成后你在节点上会看到 kubelet 通过 containerd 的 socket 管理 pod而部署机上的 docker 只负责跑 kubespray 这个 ansible 执行环境。理解这个边界后面做镜像导入时就不会把文件塞错地方。2.2 高可用骨架3 台控制面、外部 etcd 与 apiserver 入口kubespray 里的高可用不是把节点堆起来就算关键在三个地方kube_control_plane 组至少三台etcd 组成独立组且至少三台apiserver 前面要有一个统一入口。控制面节点通常复用为 etcd 成员生产上建议单独用三台低延迟机器跑 etcd避免 kubelet、apiserver 和 etcd 抢磁盘 I/O。入口这部分有三个常见做法。入口方式实现原理适用环境注意点keepalived haproxy控制面节点上跑 haproxy 转发 6443keepalived 提供 VIP 漂移物理机、VM、同二层网络VRRP 报文要放通VIP 不能与现网地址冲突kube-vipleader 选举后通过 ARP/BGP 宣告 VIP新版 kubespray、云环境依赖网卡对 ARP 的支持部分公有云受限外部负载均衡对接已有 F5、SLB 等已有基础设施的机房只需把地址填进配置不依赖集群内组件对应到 kubespray 的配置在 inventory/mycluster/group_vars/all/all.yml 里维护入口参数我一般这样写apiserver_loadbalancer_domain_name: lb.k8s.local loadbalancer_apiserver: address: 10.10.0.99 port: 8443address 就是 VIPport 是 kubelet、kubectl 和 kubeconfig 统一访问 apiserver 的端口。kubespray 检测到这三个参数后会在控制面节点自动生成 haproxy 和后端配置并把 kubeconfig 里的 server 地址指向这个 VIP。“集群故障转移”这层语义最终就落到 VIP 漂移和 etcd leader 选举上某一台 master 挂了haproxy 从后端摘掉它VIP 自动切到另一台存活的控制面节点kubelet 与 apiserver 的连接通过 VIP 保持不中断。2.3 资源包到底要装什么五类内容与“方案一”的边界所谓“部署资源包”就是把在线安装时要从各处下载的东西冻结成离线文件。需要装进包里的内容分五类。类别内容说明kubespray 源码ansible 剧本、roles、inventory 模板锁定某个 release tag不追最新kubespray 工具镜像quay.io/kubespray/kubespray 镜像部署机用它跑 ansible必须提前导出K8S 组件镜像etcd、kube-apiserver、kube-controller-manager、kube-scheduler、kube-proxy、pause、coredns、calico 相关镜像目标是导入节点 containerd不是导入部署机 docker系统软件包kubelet、kubeadm、crictl 及 yum/apt 依赖用本地 repo 文件指向资源包目录python 依赖ansible、jinja2、netaddr 等不用容器方式时的备选主方案可以跳过这里要说明“方案一”的边界。我经常遇到的还有一种半在线做法机房内有 Harbor节点能访问内网镜像仓库那就只需要把镜像同步到 Harbor不需要全部打成 tar。而标题里的方案一更彻底目标环境完全不依赖外网源站所有文件都以 tar、rpm、whl 形式带进去。这个方案的优点是环境适配性强缺点是要提前准备的东西多漏一个镜像后面都会卡住。所以资源包本身要组织得清晰我习惯用固定目录结构把源码、镜像、二进制、软件源分区放等会第 3 章展开讲。3. 制作部署资源包源码、镜像、依赖与 inventory 一次备齐3.1 锁定 kubespray 版本并准备源码目录制作资源包的第一步是找一台网络条件好、装了 docker 和 python3 的机器当准备机。kubespray 的版本选择很关键太新的 release 对内核和 CRI 版本要求激进太老的又不支持新版 K8S。我一般会选一个成熟的中线版本比如 release-2.24 这条线对应的 K8S 版本在 1.28/1.29 附近具体以仓库里 group_vars/k8s_cluster/k8s-cluster.yml 的 kube_version 为准。获取源码mkdir -p /opt/resource cd /opt/resource git clone https://github.com/kubernetes-sigs/kubespray.git -b release-2.24 kubespray如果你的准备机访问 GitHub 也慢可以改用 release 归档包下载效果一样。下载完成后检查一下 requirements.txt 是否存在它列出的是 kubespray 容器里预装的 python 库清单。这里有个经验不要在生产环境用 master 分支资源包里的 kubespray 版本一旦定了后续 ansible 命令、镜像 tag、inventory 语法全部围绕这个版本乱改很容易踩坑。3.2 镜像准备拉取、还原 tag、导出 tar镜像离线化是资源包里工作量最大的一步。kubespray 需要的镜像分散在多个 roles 的默认变量里有两条途径拿到完整清单一是从 roles 目录里 grep二是在已经部署好的节点上执行 crictl images 导出。在没有现成节点的情况下我先用 grep 收集再人工去重cd /opt/resource/kubespray grep -rhE image: .* roles/ | sort -ugrep 结果会有不少变量引用和注释噪声但能让你看到主要的镜像仓库和 tag。一般核心列表包括 registry.k8s.io 下的 kube-apiserver、kube-controller-manager、kube-scheduler、kube-proxy、pause、coredns以及 quay.io 下的 calico 组件和 etcd。拉取和导出我写成循环images( registry.k8s.io/kube-apiserver:v1.28.5 registry.k8s.io/kube-controller-manager:v1.28.5 registry.k8s.io/kube-scheduler:v1.28.5 registry.k8s.io/kube-proxy:v1.28.5 registry.k8s.io/pause:3.9 registry.k8s.io/coredns/coredns:v1.10.1 quay.io/calico/node:v3.26.1 quay.io/calico/cni:v3.26.1 quay.io/calico/kube-controllers:v3.26.1 quay.io/etcd-io/etcd:v3.5.10 ) for img in ${images[]}; do name$(echo $img | tr / _ | tr : _) docker pull $img docker save $img -o /opt/resource/images/${name}.tar done这段脚本的核心是“按原始镜像名导出”。docker save 出来的 tar 在导入时保留原始 tag这是离线部署不翻车的关键。准备机如果拉不动这些源站可以先从国内镜像仓库拉同架构镜像再用 docker tag 把名称还原成原始地址最后 savedocker tag registry.cn-hangzhou.aliyuncs.com/google_containers/pause:3.9 registry.k8s.io/pause:3.9 docker save registry.k8s.io/pause:3.9 -o /opt/resource/images/pause_3.9.tar这个“还原 tag”的动作能避开离线部署里最常见的坑镜像内容一样但名字对不上kubelet 按原始地址找不到镜像报 ImagePullBackOff。导出完成后确认文件名列表是否与资源包清单一致建议在准备机上生成一份 images.md5 校验文件传输后核对防止 tar 包损坏。3.3 生成 inventory脚本开头 手工补高可用参数kubespray 自带 inventory 生成脚本把主机 IP 按顺序传入它会自动分组。前三台会被分到 kube_control_plane 和 etcd后面的进 kube_node。这个自动分配基本符合最小高可用架构但生成后必须打开文件人工核对cd /opt/resource/kubespray cp -rfp inventory/sample inventory/mycluster declare -a IPS(10.10.0.11 10.10.0.12 10.10.0.13 10.10.0.21 10.10.0.22) CONFIG_FILEinventory/mycluster/hosts.yaml python3 contrib/inventory_builder/inventory.py ${IPS[]}生成的 hosts.yaml 结构大致如下注意组名和主机名不能随便改kubespray 的 roles 按组名寻找目标all: hosts: master1: ansible_host: 10.10.0.11 ip: 10.10.0.11 master2: ansible_host: 10.10.0.12 ip: 10.10.0.12 master3: ansible_host: 10.10.0.13 ip: 10.10.0.13 node1: ansible_host: 10.10.0.21 ip: 10.10.0.21 node2: ansible_host: 10.10.0.22 ip: 10.10.0.22 children: kube_control_plane: hosts: master1: master2: master3: etcd: hosts: master1: master2: master3: kube_node: hosts: master1: master2: master3: node1: node2:这里我建议把 kube_node 里也放入三台 master。kubespray 的默认模板会把控制面节点同时标记为可调度节点吗默认情况下控制面节点不承担工作负载但加入 kube_node 组可以让你用 node-role.kubernetes.io/control-plane 污点控制调度策略而不是彻底失去调度能力。生产上可以根据负载决定。接着在 all.yml 里写高可用入口参数并打开 HA 开关# group_vars/all/all.yml apiserver_loadbalancer_domain_name: lb.k8s.local loadbalancer_apiserver: address: 10.10.0.99 port: 8443 supports_ha: truesupports_ha 这个开关让 kubespray 在控制面节点部署 haproxy 与 keepalived并把 kubelet 的 apiserver 指向改为 VIP。注意 VIP 地址必须和集群网段在同一二层且不能被 DHCP 分配出去否则 keepalived 抢地址时会冲突。3.4 把 kubespray 工具镜像和 python 依赖也放进去部署资源包里最容易漏的就是 kubespray 工具镜像本身。我们计划在目标环境的部署机上用 docker 运行 kubespray那这个工具镜像也得离线化docker pull quay.io/kubespray/kubespray:release-2.24 docker save quay.io/kubespray/kubespray:release-2.24 -o /opt/resource/kubespray-tool.tar如果你不想在生产环境引入 docker也可以直接在部署机上离线装 ansible。用 pip download 把所有依赖下成 whl 文件带过去cd /opt/resource/kubespray pip download -r requirements.txt -d /opt/resource/python-libs到目标环境后 pip install --no-index --find-links 本地目录即可。两个方案二选一我优先推荐工具镜像省去 python 版本和 ansible 兼容性排查但这要求部署机上有 docker 环境。资源包整体结构完成后用 tar 打成一个大包或者 rsync 到内网跳板机准备进入部署阶段。4. 执行部署导入镜像、启动 kubespray 容器、跑通 cluster.yml4.1 目标节点初始化关闭 swap、SELinux 与防火墙资源包传输到部署机和各节点后第一件事不是急着跑 ansible而是把节点操作系统状态调整到 kubespray 期望的样子。用户节点一般跑 CentOS、Rocky 或 Ubuntu以 Rocky 9 为例我习惯先在每台节点上执行一遍初始化脚本systemctl disable --now firewalld sed -i s/^SELINUXenforcing/SELINUXdisabled/ /etc/selinux/config setenforce 0 swapoff -a sed -i /swap/s/^/#/ /etc/fstab modprobe br_netfilter cat /etc/sysctl.d/k8s.conf EOF net.bridge.bridge-nf-call-iptables 1 net.ipv4.ip_forward 1 EOF sysctl --system这里每一行都有明确目的firewalld 会挡掉 flannel 或 calico 的 VXLAN 报文SELinux 不改会导致 kubelet 访问目录被拒swap 是 kubelet 的硬性检查项不支持开启。如果你照着 docker 安装教程去配节点方向就错了kubespray 要的是 containerd不是 docker。初始化完成后还要确认主机名、/etc/hosts 里各节点 IP 与主机名互相解析ssh 私钥已经从部署机分发到所有节点ansible 才能免密登录。4.2 把离线镜像导入节点的 containerdK8S 节点的容器运行时是 containerd镜像必须导入到它的 k8s.io 命名空间而不是部署机的 docker。在每台节点上批量导入for f in /opt/resource/images/*.tar; do ctr -n k8s.io images import $f donectr 命令要和 kubelet 用的 containerd 实例一致默认节点上 containerd 的命名空间就是 k8s.io这是 kubelet 与 CRI 对接时约定的命名空间。如果导入时报 manifest 不完整检查 tar 是否传完整用 md5 文件核对。导入完成后验证一下crictl imagescrictl 会列出 kubelet 视角下可用的镜像列表。看到 pause、coredns、calico 这些都在部署过程的镜像拉取阶段就能快速通过。这里有个容易忽略的细节节点的 kubelet 是 kubespray 通过系统包管理器装的它配置的 pause 镜像名来自 kubeadm 的默认值如果资源包里的 pause tag 和 kubeadm 期望的不一致kubelet 启动后会反复拉取失败。导入后最好用 crictl images | grep pause 确认 tag 与 kubespray 版本一致。4.3 启动 kubespray 容器并执行 cluster.yml部署机上先导入 kubespray 工具镜像再启动容器。用 docker load 导入docker load -i /opt/resource/kubespray-tool.tar docker tag quay.io/kubespray/kubespray:release-2.24 kubespray:localtag 成短名方便后面写命令。接着启动一个常驻容器把 ssh 密钥和 inventory 目录挂进去docker run -d --name kp \ -v /root/.ssh:/root/.ssh:ro \ -v /opt/resource/kubespray/inventory:/inventory \ kubespray:local sleep infinity挂载 .ssh 时注意权限ansible 对私钥文件权限敏感/root/.ssh 和私钥文件都要保证宿主侧权限不能过宽。进容器执行部署docker exec -it kp bash cd /kubespray ansible-playbook -i /inventory/mycluster/hosts.yaml -b --become-userroot cluster.yml-b 告诉 ansible 使用 become 提权--become-userroot 指定提权目标用户这样就算 ssh 登录用户是非 root 的运维账号也能拿到 root 权限执行安装。cluster.yml 是 kubespray 的主入口剧本内部包含 etcd、containerd、kubelet、kubeadm、网络插件等全部角色。首次执行时间取决于机器性能和组件数量通常在 20 到 40 分钟。如果中途失败不要急着从头跑ansible 是幂等的找到失败任务、修好原因再执行一遍同一命令即可续跑。加 -vv 参数能看到更细的任务输出排障时很有用。5. 部署避坑五次翻车对应的现象、原因与处理5.1 节点镜像拉取报 ImagePullBackOff现象ansible 执行到 kubeadm init 阶段成功但随后 kubectl get pods 看到大量 ImagePullBackOff事件里提示 Failed to pull image。原因资源包漏导了镜像或者镜像 tag 与 kubespray 要求不一致。最常见的是 pause 镜像和 calico 镜像。解决在节点上执行 crictl images 对比 kubespray roles 里的镜像列表缺哪个导哪个。导入后手动 crictl rmi 清掉失败镜像的缓存再 crictl images 确认。这件事值得在资源包制作阶段用 md5 清单强制约束漏镜像在现场补导很被动。5.2 kubelet 启动失败journalctl 里报找不到 CRI socket现象节点上 kubelet 一直处于 activatingjournalctl -u kubelet 看到 Failed to connect to /var/run/containerd/containerd.sock。原因节点初始化时没有安装 containerd或者安装的 containerd 版本和 kubelet 期望的 CRI 版本不兼容。kubespray 会在 playbook 里自动装 containerd但如果你手动预装过旧版本它会覆盖不完全。解决检查 containerd 服务是否在运行systemctl status containerd。如果装了旧版停掉后用资源包里的 containerd 版本重新装再重启 kubelet。另一种情况是节点上同时存在 docker 安装教程装的 containerd.sock 路径差异确认 /etc/containerd/config.toml 里的 socket 路径与 kubelet 一致。5.3 apiserver 容器 CrashLoopBackOff现象集群装完但 kubectl get nodes 连不上crictl ps -a 看到 kube-apiserver 容器反复退出。原因apiserver 启动依赖 etcd 健康如果 etcd 集群没有起来apiserver 会一直等待。常见诱因是 etcd 镜像没导入、etcd 节点间 2380 端口不通。解决先看 etcd 容器状态crictl logs 查看 etcd 容器输出。排查节点间防火墙和路由确保 etcd 成员间 2380/2379 端口互通。etcd 起来后再看 apiserver 日志通常证书和 token 问题会随 etcd 恢复一起解决。这条是集群故障转移演练时也会遇到的问题主节点重启顺序不对经常触发。5.4 VIP 配置了但 curl 8443 不通现象keepalived 起来了ip addr 也能看到 VIP但外部访问 VIP:8443 超时。原因haproxy 没有监听 8443或云安全组挡掉了端口。keepalived 健康检查脚本检查的是 haproxy 进程haproxy 没起来时 VIP 不会漂移。解决在控制面节点执行 ss -lntp | grep 8443看 haproxy 是否监听。haproxy 配置文件由 kubespray 生成在 /etc/haproxy/haproxy.cfg检查后端 server 地址是否为三台 master 的 6443 端口。如果是云主机还要确认安全组放行 8443。这条坑在 VM 环境特别多本地 ping 通 VIP 不代表端口通。5.5 kubectl get nodes 显示 NotReadycalico 一直启动失败现象节点注册成功但状态 NotReadycalico-node pod 处于 CrashLoopBackOff。原因calico 依赖的镜像没有完全导入或 Pod 网段与节点所在物理网段冲突导致 BGP 路由异常。解决先确认 calico 三个镜像都在节点上再检查 inventory 里设置的 kube_pods_subnet 默认 10.233.64.0/18 是否和现有网络冲突。veth 和 tunl 接口起不来时通常就是网段冲突。改掉 kube_pods_subnet 后要重新生成 kubeadm 配置并重置节点这个改动越早做代价越小。6. 部署完后别急着收工用 VIP 健康检查验证高可用是真是假集群装完、节点 Ready很多人就认为高可用落地了其实没验证过的 VIP 都是“纸面高可用”。我习惯在验收阶段跑一段持续探测脚本直接在任意能访问 VIP 的机器上执行while true; do code$(curl -k -o /dev/null -s -w %{http_code} https://10.10.0.99:8443/healthz) echo $(date %F %T) healthz$code sleep 2 done脚本每两秒请求一次 apiserver 的健康检查接口并打印响应码。保持这个窗口运行然后到机房或通过带外管理重启一台 master 节点。正常情况下你会看到连续几个 000 或空响应随后恢复 200中断时间应该在几秒以内因为另一台 master 上的 apiserver 通过 VIP 继续服务。如果中断时间超过 10 秒说明 keepalived 的 VRRP 报文被防火墙挡了或者 haproxy 健康检查间隔太长需要回来调检查参数。为了确认故障转移真的发生重启完成后在曾经持有 VIP 的节点上查看地址ip addr show | grep 10.10.0.99这个 VIP 应该出现在另一台控制面节点上说明 keepalived 已经完成了地址漂移。之后再把重启的节点加回集群用 kubectl get nodes 确认它重新 Ready用 etcdctl endpoint health 检查 etcd 成员状态确保每个 etcd 节点都跟上 leader 的进度。这里的验证过程也暴露过一类问题有些环境在重启 master 后VIP 能漂移但 kubelet 报证书过期或无法连接 apiserver根源是新主机的时钟偏移导致 kubelet 与 apiserver 的 TLS 握手失败。所以我会在部署阶段就统一用 chrony 同步时间资源包里也带了对应的 rpm 包。演练时如果发现这类问题第一时间查各节点 date 和 chronyc tracking而不是去怀疑 kubespray 的配置。我个人的习惯是把这套验证写进交付文档每次部署完必须做一次主动重启演练并把中断时长记录在案。有一回因为资源包漏带了 kubespray 工具镜像现场所有节点都准备好了部署机却没有能跑 ansible 的环境只能临时想办法白白折腾到半夜。从那以后资源包清单第一项永远是工具镜像第二项才是组件镜像顺序不能变。希望本文能帮你把这条部署路径走顺少踩我踩过的坑。本文还有配套的精品资源点击获取