离线内网环境使用containerd与kubeadm搭建混合架构K8S高可用集群
发布时间:2026/9/17 14:30:46 锦皓数字建站

先说个背景我最近一个月主要精力都花在了这个项目上——在完全隔离的内网环境里用 containerd 作为容器运行时通过 kubeadm 把 K8S 1.33.3 集群部署成了高可用架构并且集群内同时混有 x86_64 和 aarch64 两种 CPU 架构的节点。整个过程中所有 rpm 包、容器镜像、配置模板都要提前准备好再通过脚本一次性拉起集群。这也是标题里“容器版”的含义K8S 控制面组件以静态 Pod 形态运行运行时统一走 containerd而不是依赖 Docker。这篇文章我会把完整过程拆开讲清楚从为什么选这套方案、离线物资怎么准备到 containerd 配置、haproxy keepalived 高可用搭建、kubeadm 初始化与异构节点加入最后是踩坑实录。适合三类人看一是正在做离线 K8S 交付的运维和实施工程师二是想把存量 x86 服务器和新增 ARM 服务器统一纳管的架构师三是准备 K8S 相关认证或面试、想了解真实部署链路的技术人。你不需要网上再翻几十篇“单节点安装教程”拼拼凑凑照着这篇文章的路径走基本能省掉三分之二的摸索时间。1. 项目背景与整体设计思路1.1 这类需求到底在什么场景下出现先说为什么会有“x86_64 aarch64 混合架构 离线 高可用”这种组合需求。我接触到的典型场景是这样公司存量的物理服务器或虚拟机清一色是 Intel/AMD 的 x86_64 架构跑了多年的业务不可能一次性淘汰。但新增采购的服务器里很大一部分是 ARM 架构aarch64采购理由通常是功耗低、核数多、单位算力成本更划算。这时候问题就来了业务系统不想拆成两套集群分别维护存量 x86 服务器又必须继续用于是只能做一套混合架构的 K8S 集群。控制面我建议单一架构比如全部放在 x86_64 节点上工作节点则 x86_64 和 aarch64 混跑这样稳定性最好。也有团队做双架构控制面理论可行但 etcd、kube-controller-manager 这些组件在两种架构上的镜像都要准备故障排查时心智负担比较重除非你确实想把控制面也铺到 ARM 节点上做容灾否则我不推荐第一版就这么玩。“离线”这个约束同样来自真实环境。不少生产内网和公网物理隔离或者只开放安全厂商白名单里的少数出口。你不可能在部署当天让每台机器自己去拉镜像、装依赖所以必须提前准备一套离线物资包——包括所有 rpm、所有容器镜像、所有部署脚本和配置文件。这套物资包最好能做到“拷到内网机器上执行一个入口脚本全自动完成”。“高可用”对应的是业务可用性要求。K8S 的 API Server 是整个集群的入口如果只有单 Master一旦节点宕机kubectl、控制器、调度器全部失效业务侧虽然 Pod 还在跑但集群已经没人管理了。所以至少三个控制面节点前面再放一个 VIP虚拟 IP做流量入口是离线内网环境最务实的高可用方案。1.2 组件选型背后的取舍逻辑先讲容器运行时。K8S 从 1.24 版开始正式移除 dockershim之后如果你想用 Docker 作为运行时必须额外部署 cri-dockerd 来适配多了一层转换既占用资源又增加了故障点。而 containerd 本身就是 Docker 底层的容器运行时直接从 CRI 接口对接 kubelet链路短、性能好、内存占用低。这也是新版集群默认推荐 containerd 的原因。离线部署时containerd 的 rpm 包和依赖比 Docker 少很多打包更省事。再讲部署工具。生产环境可选的无非三类kubeadm、二进制手动部署、sealos/kubekey 这类自动化工具。sealos 这类工具打包能力强一条命令能把整个集群拉起来但它的内部逻辑是个黑盒出问题后排查链路很长而且高版本和特定 K8S 版本的兼容性不一定跟得上。我选择 kubeadm原因很朴素它是官方工具所有关键参数都写在配置文件里可控、可审计出了问题你能清楚地知道是哪一步挂了。配上一套自己的离线包和脚本效率和 sealos 其实差不了多少但心里踏实很多。网络插件选了 Calico。多架构支持上 Calico 做得很成熟x86_64 和 aarch64 的镜像都有BGP 模式在大规模集群里扩展性也更好。相比之下 flannel 简单但功能弱cilium 强但依赖的内核特性较多离线环境里内核版本参差容易出幺蛾子。Calico 是折中后最不折腾的选择。最后是高可用入口。公云环境可以直接用云厂商 LB但离线内网没有这东西硬负载设备也不是处处都有。最通用的方案就是 haproxy keepalivedhaproxy 在三台 Master 前做四层 TCP 负载均衡keepalived 通过 VRRP 协议提供一个虚 IP。这个组合的优点是纯软件、不挑环境、配置直白。1.3 “一键部署”脚本的分层思路“一键”不是说写一个巨大的 shell 脚本从头跑到尾那是灾难。我习惯把脚本按阶段拆成 6 个模块每个模块只做一件事最后用一个入口脚本按顺序调用offline-k8s/ ├── rpm/ # 所有离线 rpm 包 ├── images/ # 所有容器镜像 tar 包 ├── templates/ # haproxy/keepalived/calico 配置模板 ├── scripts/ │ ├── 00-prepare-system.sh # 主机名、内核参数、swap、时间同步 │ ├── 01-install-containerd.sh │ ├── 02-import-images.sh # 离线导入镜像到 containerd │ ├── 03-deploy-lb.sh # 部署 haproxy keepalived │ ├── 04-init-controlplane.sh │ ├── 05-join-node.sh │ └── 06-verify.sh └── install.sh # 入口按角色执行为什么拆这么细离线部署最怕的就是中途报错后整个锅端。模块化脚本允许你一台一台地执行第 3 步挂了不会影响第 1、2 步的成果排查效率高也方便让不同角色的节点执行不同脚本比如 Worker 节点根本不需要执行 04。这个思想贯穿整篇文章。2. 离线物资准备把所有依赖包一次备齐2.1 先从一份可复制的打包清单开始离线部署前最重要的一件事是列出完整的物资清单。我自己的模板大概长这样类别内容说明系统依赖tar、gzip、curl、wget、ipvsadm、socat、conntrack-tools装完系统后需要补的基础工具容器运行时containerd、runc、cni-plugins版本建议 containerd 1.7.x 系列K8S 核心组件kubeadm、kubelet、kubectl、kubernetes-cni版本必须和集群版本一致高可用组件haproxy、keepalived所有 Master 节点都要装容器镜像kube-apiserver、kube-controller-manager、kube-scheduler、kube-proxy、etcd、coredns、pause、calico 相关镜像见 2.3 表格配置文件kubeadm-config.yaml、haproxy.cfg、keepalived.conf、calico.yaml提前改好参数拿到清单后先在联网的跳板机上把包备齐。这里有个经验rpm 包不要随手到处下很容易漏依赖。规范做法是找一台和线上系统同版本、同架构的虚拟机用yum install --downloadonly把所有要装的包连同依赖一起下载到指定目录。2.2 内网 yum 源失效的根因与本地仓库搭建这块我多说几句因为“Cannot find a valid baseurl for repo”这个报错几乎每个做离线部署的人都遇到过。原因很简单系统默认的 yum 源指向公网地址但内网机器无法访问于是 yum 找不到任何可用的 repo自然无法安装任何东西。解决办法有两种。第一种是在内网搭一个本地 yum 仓库用 nginx 或者 httpd 把离线 rpm 目录发布出去然后写一个.repo文件指向内网地址。适合需要长期维护的集群后续装什么软件都方便。第二种是短期性质的直接把 rpm 包拷到机器上用yum localinstall *.rpm或者rpm -ivh *.rpm --nodeps本地安装。脚本化部署我会优先用第一种因为yum install会自动处理依赖比手工rpm -ivh稳得多。搭建本地 yum 源时有一个细节离线 rpm 目录需要先用createrepo生成元数据否则 yum 识别不了。命令只有一行createrepo /data/k8s-rpm之后内网机器的.repo文件里baseurlhttp://内网服务器IP/k8s-rpmgpgcheck0。这样一个可用的内网 yum 源就起来了。老系统如果遇到官方源下线导致 baseurl 失效的问题本质也是换一个有效源地址原理一样。2.3 多架构容器镜像怎么拉、怎么导、怎么存这是整个离线部署里最容易被忽视又最容易翻车的一步。K8S 组件镜像很多是 multi-arch 的同一个 tag 下包含linux/amd64和linux/arm64两个平台的镜像。但docker pull默认只会拉取你当前平台的那一份你在一台 x86 机器上用docker pull拿到的是 amd64 镜像把这个 tar 包拿到 aarch64 节点上去 load跑起来就是错的。正确做法是用 skopeo 直接复制完整 manifest list把多架构信息原封不动地包进 tarskopeo copy --all \ docker://registry.k8s.io/kube-apiserver:v1.33.3 \ docker-archive:kube-apiserver-v1.33.3.tar--all参数是关键它告诉 skopeo 保留镜像的所有平台版本而不是只保留当前平台。这样导出的 tar 在任何架构的节点上导入后都能找到自己需要的架构镜像文件。核心组件镜像列表如下镜像说明用途节点kube-apiserver / kube-controller-manager / kube-scheduler控制面组件Masteretcd集群存储Masterkube-proxy网络代理所有节点pause每个 Pod 的基础容器所有节点coredns / coredns-cache集群 DNSMaster 与 DNS 运行节点calico-node / calico-kube-controllers / calico-cni-pluginCNI 插件所有节点拿到所有 tar 包后在每台节点上执行批量导入。这里有个容易踩的坑ctr命令默认操作的命名空间是default而 kubelet 通过 CRI 使用的命名空间是k8s.io。如果不指定命名空间导入成功后crictl images依然看不到镜像。正确的导入命令是ctr -n k8s.io images import kube-apiserver-v1.33.3.tar批量导入可以写成循环导入完成后用crictl images验证每台机器上都有哪些镜像顺手把镜像清单输出成文件方便后面核对。2.4 长期维护场景提前搭建内网镜像仓库如果你这套集群后续要长期运行并且经常部署新应用那光靠“预导入 tar 包”就不够了。我更推荐在离线环境里搭一个 Harbor 私有镜像仓库把所有镜像 push 到 Harbor各节点的 containerd 通过 registry 配置指向它。这样后面拉起新业务镜像时走内网 Harbor 就能拉不需要再导出导入 tar 包。不过要注意Harbor 本身也依赖镜像和相关组件你依然需要在联网环境把 Harbor 的安装镜像和依赖提前准备好。第一种方式是“纯离线导入”适合一次性交付第二种是“内网仓库”适合长期运营。实际项目里我一般两种都做初期用 tar 导入把集群拉起来稳定后立刻补一套 Harbor 接管后续镜像流。3. 实操记录五步把混合架构高可用集群拉起来3.1 节点规划与基础系统配置我这里给一个标准示例。三个 Master 统一用 x86_64 架构两个 Worker 分别跑 x86_64 和 aarch64VIP 作为 K8S API Server 的统一入口地址。节点名IP架构配置角色k8s-m1192.168.10.11x86_644C8GMasterk8s-m2192.168.10.12x86_644C8GMasterk8s-m3192.168.10.13x86_644C8GMasterk8s-w1192.168.10.21x86_648C16GWorkerk8s-w2192.168.10.22aarch648C16GWorkerVIP192.168.10.100--高可用虚拟 IP基础配置环节每台节点都要做四件事。第一改主机名并把所有节点 IP 写入/etc/hosts否则 kubeadm 初始化时主机名解析不一致会报错。第二关闭 swap。K8S 默认不允许节点启用 swap关闭后还要注释掉/etc/fstab里的对应行防止重启后又自动挂载。第三加载内核模块并调内核参数。overlay和br_netfilter必须加载net.bridge.bridge-nf-call-iptables必须为 1net.ipv4.ip_forward必须为 1这是容器网络转发的基础。第四时间同步。证书校验对时间偏差极其敏感集群内时差超过 5 分钟各种 TLS 握手会集体失败。离线环境要搭一个内网 NTP 服务器所有节点指向它。3.2 containerd 安装与四个关键配置项装完 rpm 包后先用一条命令生成默认配置containerd config default /etc/containerd/config.toml然后打开配置文件重点核对四个位置。第一是SystemdCgroup。默认配置里这个值是false必须改成true让 containerd 使用 cgroupfs不对准确说是让 containerd 使用 systemd cgroup driver。kubelet 默认使用的 cgroup driver 就是 systemd如果 containerd 保持默认的 cgroupfs两者不一致会直接导致 kubelet 报错、节点一直 NotReady。第二是sandbox_image。默认值是registry.k8s.io/pause:3.9一类离线环境下如果 containerd 本地没有这个镜像创建 Pod 时会疯狂拉取失败。要么提前导入 pause 镜像要么把 sandbox_image 改成内网仓库地址二选一即可。第三是registry.mirrors配置。如果你走 Harbor 方案要把registry.k8s.io、docker.io等都映射到内网 Harbor 地址或者用 mirrors 指定替代拉取源。走纯 tar 导入方案时这一步可以先跳过。第四是确认 containerd socket 路径。默认是/run/containerd/containerd.sock后面 kubeadm 配置里会用这个路径。启动 containerd 后用ctr version和crictl version各验证一次确保 CRI 插件正常响应。3.3 haproxy keepalived给 API Server 加一道 VIP三台 Master 上都要装 haproxy 和 keepalived其中 haproxy 负责把访问 VIP 的请求转发到三台真实的 API Server 端口。配置非常简单核心就是定义四层转发global log /dev/log local0 maxconn 4096 defaults mode tcp timeout connect 5s timeout client 50s timeout server 50s frontend k8s-apiserver bind *:6443 mode tcp default_backend k8s-masters backend k8s-masters mode tcp balance roundrobin server k8s-m1 192.168.10.11:6443 check server k8s-m2 192.168.10.12:6443 check server k8s-m3 192.168.10.13:6443 checkkeepalived 配置的核心是 VRRP 实例和健康检查脚本。健康检查脚本每两秒探测一次 haproxy 进程是否存在不存在就把优先级降低让其他节点接管 VIP。配置大致如下vrrp_script check_haproxy { script /usr/bin/killall -0 haproxy interval 2 weight 2 } vrrp_instance k8s-vip { state BACKUP interface eth0 virtual_router_id 51 priority 100 advert_int 1 authentication { auth_type PASS auth_pass k8s-ha } virtual_ipaddress { 192.168.100.100/24 } track_script { check_haproxy } }这里我给一个忠告所有节点都用state BACKUP优先级最高的那台实际成为 VIP 持有者。不要一台设 MASTER 其余设 BACKUP真出故障时容易因为优先级竞争引发脑裂。配置完成后用ip a查看 VIP 落在哪台机器上然后手动停一台机器的 haproxy再观察 VIP 是否自动漂移。这一步一定要验证通过再进入下一步 kubeadm 初始化否则后面全乱。3.4 用 kubeadm 初始化首个控制面节点先准备kubeadm-config.yaml。这个文件是 kubeadm 的输入控制中心参数比命令行直观很多。我用的版本对应kubeadm.k8s.io/v1beta4apiVersion: kubeadm.k8s.io/v1beta4 kind: InitConfiguration localAPIEndpoint: advertiseAddress: 192.168.10.11 bindPort: 6443 nodeRegistration: criSocket: unix:///run/containerd/containerd.sock --- apiVersion: kubeadm.k8s.io/v1beta4 kind: ClusterConfiguration kubernetesVersion: v1.33.3 controlPlaneEndpoint: 192.168.10.100:6443 imageRepository: registry.k8s.io apiServer: certSANs: - 192.168.10.100 - 127.0.0.1三个必须讲清楚的点。第一controlPlaneEndpoint填的是 VIP 地址加端口而不是某台 Master 的 IP这样 kubelet、kubectl、控制器访问集群入口时走的是 VIPhaproxy 才能做负载均衡和故障切换。第二certSANs必须加入 VIP 地址否则你通过 VIP 访问 API Server 时证书里没有这个 IPTLS 校验会失败。第三criSocket明确指向 containerd 的 socket别让它去探测默认的 docker socket。初始化命令kubeadm init --config kubeadm-config.yaml --upload-certs--upload-certs表示把 etcd 和 API Server 的证书加密后上传到集群其他 Master 加入时可以用--certificate-key自动拉取这样就不用手工通过不安全方式分发证书文件了。初始化成功后命令行会输出两段关键信息一段是配置 kubectl 用的命令一段是其他节点加入集群用的 join 命令和参数务必保存。3.5 扩展控制面节点与异构 Worker 的加入第一台 Master 初始化完成后先在本机配好 kubectlmkdir -p $HOME/.kube ln -s /etc/kubernetes/admin.conf $HOME/.kube/config然后在 k8s-m2、k8s-m3 上执行第二段输出里的 join 命令也就是带--control-plane和--certificate-key的那条。成功后这两个节点会成为额外的控制面节点。这里的时间窗口要注意--upload-certs生成的 certificate-key 默认有效期只有 2 小时超时就要重新执行kubeadm init phase upload-certs生成新 keytoken 也是同理超时可以用kubeadm token create --print-join-command再生成一份。Worker 节点的加入就更简单了不带--control-plane直接用kubeadm join命令就行。k8s-w1 是 x86_64k8s-w2 是 aarch64两条 join 命令只要 IP、token、ca-cert-hash 一致即可不需要任何架构差异。异构加入这里有个容易被忽略的地方Worker 节点上最好也提前导入了 pause 和 kube-proxy 镜像。虽然 kubeadm join 本身不要求本地有镜像但节点加入后 kubelet 要立刻启动 kube-proxy 和 pause如果 containerd 无法从集群仓库拉取节点会长时间处于 NotReady。提前把这两个镜像导入到每一台节点能省掉很多周折。3.6 部署 CNI 插件与全集群验证CNI 不装节点永远 NotReadyPod 也永远 ContainerCreating。我用的 Calico部署方式是在联网环境把官方提供的 manifest 保存下来替换镜像地址为内网仓库或直接依赖本地导入然后执行kubectl apply -f calico.yaml如果走 tar 导入方案alico 的镜像名要提前导入到所有节点并且在 calico.yaml 里确认imagePullPolicy是IfNotPresent这样本地有镜像就不会再去远端拉。CNI 部署完等待一分钟后查看节点状态kubectl get nodes -o wide正常输出里每个节点的STATUS都应该是Ready。看架构列可以用kubectl get node --show-labels | grep archx86_64 节点显示kubernetes.io/archamd64aarch64 节点显示kubernetes.io/archarm64这就是混合架构集群最直观的证据。最后我习惯跑一个双副本的小应用验证调度Deployment 里用 nodeSelector 分别把 Pod 调度到不同架构节点上。不用太复杂能启动、能kubectl logs看到输出即可。这一步的意义是验证整套链路走通API 经由 VIP 进入Controller 正常调度Kubelet 拉起容器CNI 分配网络。4. 离线部署避坑清单与问题实录4.1 yum 源失效系统装不了任何包这个坑我在 2.2 里提过展开说。报错形如 “Cannot find a valid baseurl for repo: base/7/x86_64”第一反应不是去改 repo 文件指向某个公网地址而是检查自己到底有没有一个可用的内网源。建议直接在/etc/yum.repos.d/下新建一个本地源文件指向内网服务器上已经createrepo好的目录其他.repo文件全部移走。这里提醒一句baseurl的路径要写对用file://还是http://取决于你的落地方式比较容易因为路径问题反复报错排查时可以先用yum repolist确认已经识别到本地源。4.2 镜像导入了kubelet 还是去远端拉取碰到 crictl 能看到镜像但 kubelet 创建 Pod 时依然卡在ErrImagePull先不要怀疑镜像缺失。检查三件事镜像名是否完全一致包括 registry 前缀和 tagimagePullPolicy是否是IfNotPresentcontainerd 里sandbox_image是否和 pause 镜像匹配。很多时候问题是镜像 tag 多了个:latest而本地导入的是具体版本号导致 containerd 认为本地镜像不存在又去远端拉。4.3 节点一直 NotReady先别急着重装节点 NotReady 几乎占了部署问题的半壁江山但原因通常就三类CNI 没部署或者部署失败节点无法访问 API Serverkubelet 与 containerd 的 cgroup driver 不一致。排查顺序我建议这样先journalctl -u kubelet -f看 kubelet 日志日志里会直接告诉你最接近的原因。如果是网络插件没起就看 CNI Pod 的日志如果是 cgroup driver 报错改 containerd 配置里的SystemdCgroup true重启 containerd 和 kubelet。4.4 aarch64 节点拉到了 amd64 镜像这是混合架构特有的坑。我在 2.3 里强调过导出归档时一定要用skopeo copy --all。如果已经踩坑了节点上全是 amd64 的镜像最简单的补救是删除错误镜像用正确的多架构 tar 重新导入ctr -n k8s.io images rm 镜像名。如果走 Harbor检查一下 Harbor 里该镜像的 manifest 是否同时包含两个架构没有的话就要重新用 skopeo 或 docker buildx 推送多架构版本。4.5 VIP 不漂移keepalived 状态异常VIP 在故障时不切换先从 keepalived 的状态日志查起。常见原因有三个健康检查脚本路径不对导致检查脚本本身报错virtual_router_id在几台机器上不一致VRRP 报文不匹配防火墙拦截了 VRRP 协议或 6443 端口。另外验证 VIP 漂移时要特别注意在客户端机器上用telnet 192.168.10.100 6443测连通性而不是只在 Master 上看 IP因为有的环境里 VIP 已经漂移但客户端 ARP 缓存还没刷新。4.6 join 节点时卡住或证书校验失败kubeadm join卡在[wait-control-plane]大概率是 Worker 节点到 API Server 的 6443 端口不通或者 token 已过期。证书校验失败则多数是 hash 不对--discovery-token-ca-cert-hash里的sha256:前缀不要漏。如果证书和 token 都确认无误还不通检查一下 VIP 是否还正常haproxy 是否把新节点指向的后端端口误判为 down。4.7 离线环境里 Harbor 拖沓的镜像缓存最后补一个 Harbor 的坑。Harbor 做代理缓存时默认会缓存拉过的镜像但多架构仓库如果配置不对可能只缓存了第一次访问的架构。解决方法是给 Harbor 项目启用“镜像代理”并确保源仓库地址支持多架构清单同时在各节点 containerd 配置里用 mirrors 指向 Harbor让 kubelet 拉镜像时统一走 Harbor这样才不会被“远端仓库不可达”卡住。收个尾这套方案我在实际项目里跑了不止一遍最大的体会是离线部署的难点从来不是某个命令不会敲而是“你以为准备好了实际缺了一环”。rpm 依赖漏了一个、镜像只导了单架构、VIP 没有提前验证、证书 SAN 没加 VIP 地址每一个坑都足够让你多花半天。所以做离线包的时候务必在一台干净的同架构机器上完整演练一遍输出一份核对清单再带到现场执行。再分享一个小技巧入口脚本不要设计成“出错继续往下走”每个阶段都要set -euo pipefail并且阶段之间打印清晰的分隔日志。这样一旦部署失败你翻日志很快能定位到是第几个阶段的问题也比全场刷屏强得多。这套集群之后如果还要扩展建议把 aarch64 节点逐步加进来业务调度用 topologySpreadConstraints 把 Pod 均匀铺到两种架构上能更充分地利用异构算力。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。