离线部署Kubernetes集群这件事,我在20260117这批环境里又完整走了一遍,顺手把整个流程和测试结果记录在这里。用kubeadm、containerd做运行时、Calico做网络插件,从三台裸虚机开始装出一个1控制面+2Worker的集群,再把节点状态、应用调度、DNS解析、跨节点通信全部验一遍。如果你也在内网环境里独立搭建K8s,或者正在准备一次测试集群,这篇记录里的每一步都是可以直接照着做的,版本对应关系也都按Kubernetes 1.28.0的默认清单来写。
离线部署K8s真正费劲的地方不是最后的kubeadm init命令,而是init之前那一大堆准备工作:rpm包怎么带进去、镜像怎么导进去、网络插件的网段怎么和初始化参数对齐。这些细节如果不提前搞清楚,等到初始化时报错再查,往往一头雾水。下面我按完整的实操顺序来说。
1. 项目整体设计与方案选型
1.1 离线部署的核心痛点与解决思路
为什么说离线部署K8s比在线部署更考验思路?因为在线环境里,yum install和拉镜像都是现成的,按文档跑就完事;但离线环境下,整个K8s运行链条要依赖的东西全得自己带进去,缺一样就起不来。
拆开看,离线部署要解决四类东西:第一,系统层的软件包,kubeadm、kubelet、kubectl以及它们依赖的socat、conntrack-tools、kubernetes-cni等,这些要做一个本地软件源放内网;第二,容器运行时containerd本身就依赖rpm包,也需要离线安装;第三,集群组件镜像,apiserver、controller-manager、etcd、pause、coredns这些镜像全部要提前拉到有网机器再导入内网;第四,网络插件,这里选用Calico,它的好几个镜像也要一起准备。
所以我的整体思路就一句话:把有网环境当成加工车间,所有包、所有镜像都在这个阶段准备好、验证好;内网机器只做安装、导入、配置、初始化和测试五件事,过程中完全不需要外网。这个思路在三十台以内的集群里非常可靠,实际操作下来也没有翻车过。
1.2 版本选型与架构规划
版本选型这部分,我直接给一套自己验证过的组合:Kubernetes 1.28.0 + containerd 1.7.x + Calico v3.27.0。为什么不选更新的版本?因为离线环境的排障成本比在线高很多,1.28是功能成熟、资料多、周边兼容性好的版本,对于测试环境和中小型生产环境都够用。等你确认集群跑了一段时间没有问题,再考虑跨版本升级。
关于kubeadm、kubelet、kubectl三个组件,版本必须严格一致,都是1.28.0。三个二进制版本不统一是最常见的问题,装完后面初始化时就会报各种奇怪的错。
节点规划我按三台来做,一张表看得清楚:
| 角色 | 主机名 | 配置建议 | 操作系统 |
|---|---|---|---|
| control-plane | master01 | 2核4G | CentOS 7.9 / Rocky 9 |
| worker | node01 | 2核4G | CentOS 7.9 / Rocky 9 |
| worker | node02 | 2核4G | CentOS 7.9 / Rocky 9 |
如果你只有两台机器,master和worker合在一起也行,但没法验证跨节点调度。所以我这里按最标准的1主2从来做。
1.3 离线镜像方案的两种路线
动手之前还要想清楚一件事:镜像怎么进内网。这个选择会影响后面的每一步,常见的路线有两种。
第一种是打包导入。在有网机器上把镜像拉下来,用docker save或者直接保存tar包,拷进内网后用ctr导入。这种方式最简单,适合节点数量少、没有专门镜像仓库的测试环境。第二种是自建Harbor私有仓库。先在有网环境搭一个Harbor,把所有镜像推到Harbor里,内网节点全部从Harbor拉取。这种方式适合正式环境、节点数量多、以后还要长期扩容的场景。
这篇文章先按tar包导入的方式展开。原因很简单:部署K8s最先要做的是把流程跑通,而不是花时间再搭一套镜像仓库。等你确认整个离线链路没问题,后续再引入Harbor,逻辑是一样的。选型时记住这个原则就够了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与离线基础仓库搭建
2.1 节点系统初始化
三台机器装好操作系统之后,第一步不是安装K8s组件,而是把系统层的基础配置一次性做对。我在每台节点上执行下面这组命令:
bash复制# 设置主机名,每台机器不同
hostnamectl set-hostname master01 # node01 / node02 同理
# 关闭swap分区
sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab
swapoff -a
# 加载内核模块
cat <<EOF | tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
modprobe overlay
modprobe br_netfilter
# 内核参数调整
cat <<EOF | tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sysctl --system
这里有两个地方特别容易踩坑。第一,swapoff -a只是临时关闭swap,重启后又会挂载回来,所以必须同时修改/etc/fstab里swap那行,把它注释掉才真正生效。kubeadm初始化时如果检测到swap没关干净,会直接卡在等待kubelet启动那一步。第二,net.ipv4.ip_forward这个参数决定了IP转发是否开启,Calico部署后Pod间的流量全靠它,不设置的话后面跨节点通信基本不通。
系统初始化最后还得做时间同步。内网环境连不上外部NTP服务器,就指向内网自己搭的时间源。这一点千万别忽略,K8s的证书校验对时间偏差特别敏感,三台机器时间差超过一两分钟,初始化时就会报x509证书已过期或者尚未生效之类的错误,排查起来非常头疼。
2.2 本地yum源制作与配置
离线装K8s相关rpm包,最靠谱的是做一个本地yum源。做法是:在有网机器上下载好所有rpm,拷到内网,用createrepo生成仓库元数据,内网机器通过本地file源来安装。
有网准备机上操作:
bash复制yum install -y createrepo
mkdir -p /opt/k8s-rpms/Packages
cd /opt/k8s-rpms/Packages
# 下载kubeadm相关的rpm及全部依赖
yumdownloader --resolve kubeadm-1.28.0 kubelet-1.28.0 kubectl-1.28.0
这里要注意,kubelet运行还需要socat、cri-tools、conntrack-tools等间接依赖。yumdownloader --resolve会自动把依赖一起拉下来,这一步非常关键。如果省了,拷到内网装的时候就会发现缺这个缺那个,而内网又没有在线源,整个环节就卡住了。
下载完成后生成元数据:
bash复制createrepo /opt/k8s-rpms
把整个/opt/k8s-rpms目录拷到内网所有节点,比如放到/opt/k8s-rpms,然后在/etc/yum.repos.d/下新建一个local.repo:
ini复制[local-k8s]
name=Local K8s RPM Repository
baseurl=file:///opt/k8s-rpms
enabled=1
gpgcheck=0
执行yum clean all && yum repolist,能看到local-k8s这个源就说明仓库配置成功。Debian/Ubuntu系统原理也一样,只是换成用apt-get download和dpkg-scanpackages生成Packages索引。
2.3 containerd运行时的离线安装
用containerd作为容器运行时,这个选择很重要。K8s从1.24版本开始不再直接内置Docker支持,containerd本身就是CRI的官方实现,不需要额外的适配层,和kubelet的对接更直接,性能上也更好。
containerd也需要离线安装。在有网环境拿到containerd.io这个rpm包,以及它依赖的runc、libseccomp等,一起放进本地源,然后内网机器统一安装:
bash复制yum install -y containerd.io
装完后生成默认配置并修改两个关键项:
bash复制containerd config default > /etc/containerd/config.toml
vim /etc/containerd/config.toml
第一项是sandbox_image,必须和kubeadm期望的pause镜像完全一致。以K8s 1.28.0为例,应该设置成registry.k8s.io/pause:3.9。如果这里写的是别的tag,而后面的镜像导入也有偏差,那么每个Pod都会因为找不到sandbox镜像而启动失败。第二项是SystemdCgroup = true,这个必须开启。kubelet默认使用systemd cgroup驱动,containerd如果不切到systemd,两个组件在cgroup管理上就会冲突,典型表现是节点显示Ready,但Pod运行一段时间后被莫名杀死。
改完配置后重启服务:
bash复制systemctl enable containerd
systemctl restart containerd
systemctl status containerd
到这步,系统层面对K8s来说已经就绪了。
3. 核心组件离线安装与镜像准备
3.1 kubeadm、kubelet、kubectl的离线安装
本地源就绪后,安装这三个组件就是一条命令的事:
bash复制yum install -y kubeadm-1.28.0 kubelet-1.28.0 kubectl-1.28.0
安装完成后,用kubeadm version和kubelet --version确认版本,确保三者都是1.28.0。这一步不要跳过,三个组件版本不一致会在初始化时直接报错。
注意kubelet服务可以先不启动。kubeadm init会自己拉起kubelet服务,你只需要确保它的配置文件在正确的位置、服务处于可用状态即可。
3.2 镜像清单获取与打包
镜像准备是整个离线部署里最重要、也最容易出错的环节。我强烈建议不要靠记忆去填镜像列表,而是用kubeadm自带的命令生成,在准备机上执行:
bash复制kubeadm config images list --kubernetes-version v1.28.0
输出大概是这样的清单:
code复制registry.k8s.io/kube-apiserver:v1.28.0
registry.k8s.io/kube-controller-manager:v1.28.0
registry.k8s.io/kube-scheduler:v1.28.0
registry.k8s.io/kube-proxy:v1.28.0
registry.k8s.io/pause:3.9
registry.k8s.io/etcd:3.5.9-0
registry.k8s.io/coredns/coredns:v1.10.1
这里要注意两点。第一,不同版本的kubeadm对应的镜像版本和仓库前缀都不同,比如coredns的完整地址是registry.k8s.io/coredns/coredns,不是registry.k8s.io/coredns。第二,如果你在准备机上是从其他镜像源同步下来的镜像,仓库名前缀可能不同,一定要用docker tag改回kubeadm期望的完整地址再打包。否则到了内网导入后,镜像名和kubeadm期望的不一致,初始化时就会不断拉取失败。
在有网机器上拉取并打包的参考命令:
bash复制docker pull registry.k8s.io/kube-apiserver:v1.28.0
docker pull registry.k8s.io/kube-controller-manager:v1.28.0
# ...其余镜像同理
docker save -o k8s-core-images.tar \
registry.k8s.io/kube-apiserver:v1.28.0 \
registry.k8s.io/kube-controller-manager:v1.28.0 \
registry.k8s.io/kube-scheduler:v1.28.0 \
registry.k8s.io/kube-proxy:v1.28.0 \
registry.k8s.io/pause:3.9 \
registry.k8s.io/etcd:3.5.9-0 \
registry.k8s.io/coredns/coredns:v1.10.1
除了这7个集群组件镜像,网络插件的镜像也要一并准备。Calico v3.27.0需要calico/cni、calico/node、calico/kube-controllers三个镜像。另外我还建议把后续测试要用的镜像也带上:nginx:1.25-alpine和busybox:1.36,否则集群装好后想部署测试应用时又会卡在镜像上。
3.3 内网节点镜像导入
镜像tar包拷进内网节点后,用ctr命令导入:
bash复制ctr -n k8s.io images import k8s-core-images.tar
ctr -n k8s.io images import calico-images.tar
ctr -n k8s.io images import test-images.tar
-n k8s.io这个参数是重中之重。ctr工具默认的namespace是default,而kubelet和kubeadm在containerd里默认从k8s.io这个namespace找镜像。如果你不加-n k8s.io,镜像确实导入了,但是导到了default里,Kubelet完全看不到,后续Pod全部拉取失败。我见过好几个人栽在这个细节上。
导入后核对一遍:
bash复制ctr -n k8s.io images list | grep registry.k8s.io
核心镜像全部在列,并且名称和期望的完全一致,这一环才算真正完成。
4. 集群初始化与网络插件部署
4.1 kubeadm init集群初始化
在master01上先准备一份kubeadm配置文件。用默认模板生成后修改:
bash复制kubeadm config print init-defaults > kubeadm-config.yaml
vim kubeadm-config.yaml
修改后的关键内容如下:
yaml复制apiVersion: kubeadm.k8s.io/v1beta3
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 10.0.1.10 # 改成master01的内网IP
bindPort: 6443
nodeRegistration:
criSocket: unix:///run/containerd/containerd.sock
apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
kubernetesVersion: v1.28.0
networking:
podSubnet: 10.244.0.0/16
serviceSubnet: 10.96.0.0/12
这里podSubnet这个值要和后面Calico的配置完全一致。它决定了集群里所有Pod的IP范围,Calico的CALICO_IPV4POOL_CIDR必须和它相同,否则节点能起来,但Pod之间网络完全不通。
执行初始化:
bash复制kubeadm init --config kubeadm-config.yaml
初始化会先做环境检查,然后创建证书、生成kubeconfig、启动apiserver和etcd等组件。正常情况下几分钟后就看到初始化成功的提示。然后把kubeconfig复制到当前用户目录:
bash复制mkdir -p $HOME/.kube
cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
查一下节点状态:
bash复制kubectl get nodes
此时master01大概率是NotReady状态,这是正常的,因为网络插件还没装。继续下一步。
4.2 网络插件Calico离线部署
Calico的YAML文件需要在有网环境提前拿到,拷进内网后调整两个地方。
第一个是镜像地址。calico.yaml里默认引用docker.io/calico/xxx:v3.27.0,这些镜像我们已经在第3步导入了本地节点,所以直接apply就能用。如果你后续改用Harbor,就把YAML里的calico相关镜像地址统一替换成内网仓库地址。第二个是CALICO_IPV4POOL_CIDR,修改成和第4.1节里podSubnet一致的值:
yaml复制- name: CALICO_IPV4POOL_CIDR
value: "10.244.0.0/16"
确认无误后应用:
bash复制kubectl apply -f calico.yaml
等待几十秒,看Calico组件状态:
bash复制kubectl get pods -n calico-system
全部Running后,再查节点状态:
bash复制kubectl get nodes
master01变成Ready,控制平面就正常工作了。
4.3 Worker节点加入集群
在master上生成join命令:
bash复制kubeadm token create --print-join-command
输出的命令格式如下:
bash复制kubeadm join 10.0.1.10:6443 --token xxxxx --discovery-token-ca-cert-hash sha256:xxxxx
在node01、node02上分别执行这条命令。这里有一个容易被忽视的点:token默认有效期是24小时。如果你是提前生成好、隔了几天再join,token早就过期了,会直接提示token过期或者连接失败。遇到这种情况,回到master上重新生成一次就行。
所有节点join完成后,回到master执行:
bash复制kubectl get nodes -o wide
看到三台节点全部Ready,版本都为v1.28.0,集群就组建好了。如果某个节点一直NotReady,先看系统时间是否一致,再看Calico组件状态,这两项排查思路放在第6部分详细说。
5. 集群完整测试与功能验证
节点全部Ready不算真的完成,完整测试才是验证集群真正可用的关键。我按从底层到上层的顺序,把每一层链路都验一遍。
5.1 基础资源状态检查
先看整体状态:
bash复制kubectl get nodes -o wide
kubectl get pods -A
期望看到三节点Ready,kube-system命名空间下的coredns、etcd、apiserver等Pod全部Running。如果有Pod反复重启或Pending,用kubectl logs -n kube-system <pod名>查日志定位。还需要看下事件:
bash复制kubectl get events --all-namespaces --sort-by=.lastTimestamp | tail -30
这一步能发现一些隐藏问题,比如某节点调度异常、镜像拉取失败等。
5.2 应用部署与调度测试
用nginx作为测试负载,验证调度器是否正常把副本散到不同节点:
bash复制kubectl create deployment nginx --image=nginx:1.25-alpine --replicas=2
kubectl get pods -o wide
两个nginx副本分别运行在不同节点,说明调度器工作正常。如果都跑到同一个节点,可以删掉其中一个Pod让它重新调度,观察调度行为。
接着暴露服务:
bash复制kubectl expose deployment nginx --port 80 --target-port 80
kubectl get svc
拿到Service的ClusterIP,后面访问会用到。
5.3 DNS与集群内通信验证
ClusterIP能被正常使用,前提是DNS解析畅通。部署一个busybox做验证:
bash复制kubectl run busybox --image=busybox:1.36 --restart=Never -- sleep 3600
kubectl exec -it busybox -- nslookup kubernetes.default.svc.cluster.local
kubectl exec -it busybox -- nslookup nginx.default.svc.cluster.local
如果DNS能解析出对应的ClusterIP,说明CoreDNS正常。再用wget访问一下nginx服务:
bash复制kubectl exec -it busybox -- wget -qO- http://nginx.default.svc.cluster.local
能返回nginx欢迎页,说明Service到Pod的转发链路没有问题。
跨节点通信的验证也很重要。在node01和node02上分别拿到两个Pod的IP,然后在busybox里curl对端Pod的IP:80或者直接ping。如果丢包或者不通,多半是Calico的网段配置有问题,或者net.ipv4.ip_forward没开。
5.4 NodePort与对外访问测试
最后验证对外访问能力。将nginx服务改成NodePort:
bash复制kubectl patch svc nginx -p '{"spec":{"type":"NodePort"}}'
kubectl get svc nginx
拿到NodePort端口后,在集群外任意一台同网段的机器访问任一节点IP:NodePort,能打开nginx页面说明kube-proxy的转发链路也没问题。到这一步,从底层容器运行时到上层服务暴露,每个环节都验证过了,这个集群才算是真正可以投入使用了。
6. 实操问题与避坑经验
6.1 常见问题速查表
离线部署过程中的常见问题,按我实际碰到的频率整理成一张表:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| kubeadm init提示镜像拉取失败 | 导入的镜像tag与kubeadm期望不一致 | 用kubeadm config images list --kubernetes-version v1.28.0核对差异,重新导正确tag |
| kubeadm init卡在等待kubelet启动 | swap没关干净、containerd的SystemdCgroup没开 | swapoff -a并注释fstab;确认config.toml里SystemdCgroup = true,重启containerd |
| CoreDNS一直Pending | CNI网络没装好 | 检查Calico Pod是否Running,Node是否Ready |
| Calico Pod CrashLoopBackOff | podSubnet与CALICO_IPV4POOL_CIDR不一致、IP转发未开 | 统一两个网段;执行sysctl -w net.ipv4.ip_forward=1 |
| Pod一直ImagePullBackOff | 镜像没导入当前节点的k8s.io命名空间、镜像名不对 | ctr -n k8s.io images list核对;确认导入时带了-n k8s.io |
| kubelet日志报x509证书问题 | 节点时间不同步 | 统一内网NTP时间源,校正全部节点时间后重启kubelet |
| join节点提示token过期 | token默认24小时有效期 | 在master重新执行kubeadm token create --print-join-command |
6.2 几个值得强调的细节
表格里都是明面上的问题,但还有几个细节属于操作习惯层面的经验,比排障本身更重要。
第一个是镜像导入namespace的问题。ctr命令默认namespace是default,不带-n k8s.io的后果不是立即报错,而是镜像确实导入了却完全不会被Kubelet使用,后续Pod全部处于拉取失败状态,排查起来非常隐蔽。这个动作必须形成肌肉记忆。
第二个是rpm包依赖的完整性。离线拷贝rpm时经常出现装A包发现缺B包、装B包又缺C包的情况。所有依赖最好在准备机上一次性用yumdownloader --resolve拉全,宁可多拷几个用不到的包,也不要到内网才发现缺东西。
第三个是大tar包的完整性。镜像tar动辄几百MB甚至1GB以上,拷贝后先用tar -tf或者ctr import时的报错判断文件是否完整。我遇到过U盘拷贝中断导致tar包损坏,导入时ctr不报错,但运行时各种不可名状的错误,最后重新拷了一遍才发现源头文件就是坏的。
第四个是测试业务镜像要提前准备。很多人集群装完兴致勃勃执行kubectl create deployment nginx,结果镜像根本拉不下来。nginx、busybox这类测试镜像必须和集群组件镜像一样,提前在有网环境拉到本地再导入,否则测试环节会直接卡住。
6.3 我对离线部署的一点经验总结
这套流程完整走下来,我最大的体会是:kubeadm init真正执行的时间只有十几分钟,占用大量精力的是前期的准备工作。准备阶段做得越细,后面越顺;准备阶段偷懒,后面所有问题会集中爆发。
我现在习惯先把有网准备和内网落地两个阶段严格分开。每台机器用同样的系统初始化清单,镜像打包时无论多麻烦都坚持用kubeadm config images list逐条核对,网络插件的版本和Pod网段写在笔记里,装完后再做一遍完整的应用测试。这套流程多走几遍,离线部署的稳定率会显著提升,至少不会在深夜加班时对着一个莫名其妙的镜像tag怀疑人生。
