干过信创项目的人都有体会:开发环境里随便敲几条命令就能把k8s拉起来,真到了隔离内网,所有依赖、镜像、组件包都得自己一车一车拉进去,少一个包就是一夜白忙。这篇文章聊的是我最近在麒麟V11(银河麒麟高级服务器操作系统V11)上,用containerd 2.1.5全离线安装k8s 1.32.11,再接上KubeSphere的完整过程。内容覆盖离线物料包的准备逻辑、麒麟V11的系统初始化、containerd落地配置、kubeadm init的完整执行链路,以及那个让很多人卡到怀疑人生的报错——the api server is not healthy after 4m0.00747357s——该怎么一层层排查。如果你同样是做信创环境交付、国产化适配,或者只是想在完全没有外网的情况下把k8s跑起来,这篇应该能帮你省掉好几个通宵。
1. 为什么锁死这套组合:麒麟V11、containerd 2.1.5和k8s 1.32.11的兼容性底牌
1.1 信创环境里选型,第一原则是"别给自己留暗雷"
信创项目有个典型约束:交付环境往往是隔离内网,或者只有一条极其有限的白名单出口,外部的软件源、镜像仓库基本不可用。这就意味着你在开工前定下来的版本组合,必须在一台能联网的机器上完整复现一遍,把所有依赖都验证过、物料都备齐,才能进场操作。我见过不少同事在进场第一天才发现k8s版本和容器运行时版本不兼容,结果在客户现场临时改方案,进度直接废掉。
选择这套组合的逻辑其实很朴素:麒麟V11是目前信创项目里占有率非常高的服务器系统,内核和用户态组件都比较新,对容器生态的支持远好于早期CentOS 7时代的国产系统;k8s 1.32.11属于当前主流稳定分支,安全修复和稳定性都在线;containerd 2.1.5则是最新的大版本容器运行时,直接对接CRI接口,不需要docker那一层的额外开销。三者放在一起,既符合"新装系统用新组件"的原则,又不会出现某方版本过老、官方已经不维护的尴尬。
1.2 containerd替代docker,不是选择题而是必答题
很多从旧项目过来的人还在习惯性问"要不要先装docker?"。这里必须说清楚:从k8s 1.24开始,dockershim这个中间层就被移除了,kubelet不再支持绕道docker来调用容器运行时。到了k8s 1.32这个版本,你实际可选的CRI实现基本就是containerd或者CRI-O,而containerd的生态成熟度和文档完整度明显更好。
另外,containerd 2.x版本的配置和1.x比也有不少变化,最直观的就是config.toml的格式更规范了,默认生成的配置就带CRI插件,不用像1.x那样自己手工拼一堆路径。我这次用的2.1.5,在麒麟V11上跑得很稳,和systemd的交互也正常,没有出现容器起不来、cgroup驱动对不上的问题。
1.3 版本矩阵,一进场就定死
我整理过一套自己用的版本对照,每次做信创环境都按这个思路来核对:
| 组件 | 版本选择 | 关键理由 |
|---|---|---|
| 操作系统 | 银河麒麟高级服务器操作系统V11 | 信创项目主流,内核较新 |
| 容器运行时 | containerd 2.1.5 | 对接CRI,2.x配置简洁 |
| 容器网络 | CNI plugins v1.x + Calico | 离线可备,支持多种网络策略 |
| Kubernetes | v1.32.11 | 稳定分支,持续有安全修复 |
| 管理平台 | KubeSphere 3.x / 4.x | 可视化运维,离线可交付 |
这套组合最关键的一点是:所有组件都在一台联网的机器上先跑通过,再打包进内网。后文所有操作步骤,都是在这个前提下展开的。
2. 离线物料包清单:在能联网的那台机器上一趟备齐
2.1 先列出"进场物资清单",别信记忆力
离线安装最大的敌人不是技术难度,而是"不知道缺什么"。我的习惯是先在联网机器上建一个清单文件,逐项确认:
- 系统层工具:
yum-utils、createrepo(用来做本地rpm源) - 容器运行时:
containerd-2.1.5-linux-amd64.tar.gz - Kubernetes组件:
kubeadm、kubelet、kubectl三个rpm包,版本都锁在1.32.11 - CNI插件包:
cni-plugins-linux-amd64-v1.x.tgz - 所有镜像tar包:控制平面镜像(pause、etcd、kube-apiserver、kube-controller-manager、kube-scheduler、coredns)、Calico组件镜像、KubeSphere相关镜像
清单确认后,用脚本批量拉取。rpm包建议用yumdownloader --resolve把依赖一起拉下来,避免到了内网发现某个依赖库缺失。
2.2 用createrepo把rpm包整理成内网yum源
到了内网之后,如果每台机器都手工rpm -ivh发现缺依赖,那会非常痛苦。所以我习惯把rpm包丢到一个目录里,执行:
mkdir -p /root/k8s-rpms # 把从联网机器上下载的所有rpm放到这个目录 createrepo /root/k8s-rpms然后在麒麟V11上写一个repo文件:
[k8s-local] name=K8s Local Repo baseurl=file:///root/k8s-rpms enabled=1 gpgcheck=0这样之后安装kubeadm、kubelet、kubectl都可以直接用yum完成,依赖自动解析,干净利落。如果节点多,还可以把这个目录通过HTTP共享出去,所有节点统一配置。
2.3 镜像tar包:用ctr导出比docker save更稳
在联网机器上,如果你恰好装有containerd,可以直接用ctr导出镜像,比用docker save再倒腾到containerd少一层转换:
ctr -n k8s.io images export k8s-control-plane.tar registry.aliyuncs.com/google_containers/kube-apiserver:v1.32.11把所有需要的内网镜像导出成一个或几个tar文件,进场后用同一套命令导入到每台节点的containerd里:
ctr -n k8s.io images import k8s-control-plane.tar这里要特别提醒:ctr导入时指定命名空间为k8s.io很关键,因为kubelet的CRI插件默认就是从这个命名空间读镜像的。你导到别的命名空间,kubelet拉镜像时依然认为本地没有,会尝试去远程拉,离线环境就卡死了。
2.4 节点多的时候,建议内网起一个私有镜像仓库
如果只有两三台节点,每台手工导入镜像没问题。但如果我有几十台节点,我宁愿在内网先跑一个私有镜像仓库,所有节点把/etc/containerd/config.toml里的仓库地址指向内网仓库,kubelet拉镜像时走内网流量,比每台机器导入快得多也可靠得多。
私有仓库的准备也是在联网机器上完成的:导出registry的镜像tar,进场后先跑起来,再把所有需要的镜像push进去。这个动作看起来多了一步,但在节点规模稍大的信创项目里非常值得。
3. 麒麟V11系统底噪清理与containerd落地细节
3.1 麒麟V11的系统初始化,别跳过任何一个"小操作"
进入内网前,我会先在每台节点上做一遍系统底噪清理。这一步看起来零碎,但少了任何一个,后面都可能以诡异的方式炸出来。
首先是关闭防火墙和SELinux:
systemctl stop firewalld systemctl disable firewalld sed -i 's/SELINUX=enforcing/SELINUX=disabled/' /etc/selinux/config setenforce 0然后关闭swap,k8s对swap是零容忍的:
swapoff -a sed -i 's/.*swap.*/# &/' /etc/fstab加载内核模块并设置sysctl参数:
modprobe overlay modprobe br_netfilter cat > /etc/sysctl.d/k8s.conf <<EOF net.bridge.bridge-nf-call-iptables = 1 net.bridge.bridge-nf-call-ip6tables = 1 net.ipv4.ip_forward = 1 EOF sysctl --system还有一件事容易被忽略:检查是不是之前装过docker或者旧版本容器运行时。如果装过,docker0网桥和那一堆iptables链会留着,后面CNI插件起来的时候经常出现路由冲突。我的做法是确认干净环境再进场,或者进场后先清理残留网络设备。
3.2 containerd 2.1.5的安装与config.toml关键配置
containerd的二进制安装没什么花头,解压到/usr/local目录,之后写systemd服务。但在启动之前,第一件事是生成配置文件:
containerd config default > /etc/containerd/config.toml打开这个文件,重点改两处。第一是CRI插件的sandbox镜像,默认值在离线环境里根本拉不到,你必须改成和你导入本地镜像的tag一致:
[plugins.'io.containerd.grpc.v1.cri'] sandbox_image = "registry.aliyuncs.com/google_containers/pause:3.10"第二是SystemdCgroup,必须设置为true,否则kubelet和containerd的cgroup驱动不一致,你会在kubelet日志里看到各种failed to run kubelet或者pod一直ContainerCreating:
[plugins.'io.containerd.grpc.v1.cri'.containerd.runtimes.runc.options] SystemdCgroup = true2.x的配置结构比1.x清晰,改完直接启动:
systemctl daemon-reload systemctl enable --now containerd3.3 用crictl验证运行时"真的能用"
配置好crictl的端点是很有必要的,它和kubelet一样通过CRI去操作containerd,比ctr更贴近k8s的视角:
runtime-endpoint: unix:///run/containerd/containerd.sock image-endpoint: unix:///run/containerd/containerd.sock timeout: 10 debug: false保存为/etc/crictl.yaml后,跑几条命令确认状态:
crictl version crictl imagescrictl images能看到你之前导入的镜像,说明CRI链路已经通了。这个动作别省,我看过太多人kubeadm init失败才发现containerd压根没起来,或者cri插件没加载,前面全白干。
3.4 CNI插件放对位置,网络插件才不闹脾气
containerd的CNI插件路径一般在/opt/cni/bin,如果你的cni-plugins包没解压到这里,后面Calico或者flannel创建网络接口时会直接报错。国产系统有个特点,某些目录权限和历史版本的路径习惯不太一样,最稳妥的做法是:
mkdir -p /opt/cni/bin tar -zxvf cni-plugins-linux-amd64-v1.x.tgz -C /opt/cni/bin装完containerd和CNI插件,我会在每台节点上手动跑一个最小容器验证,确认网络和镜像导入都是正常的。这些都稳定了才进入kubeadm阶段。
4. kubeadm init全链路与api-server不健康报错的保姆式排查
4.1 用配置文件初始化,别直接敲一长串参数
连续执行节点多了之后,直接在命令行里敲kubeadm init --apiserver-advertise-address xxx --pod-network-cidr xxx太容易漏参数。我习惯把配置写成文件,每次进场只改IP和主机名。
创建kubeadm-config.yaml:
apiVersion: kubeadm.k8s.io/v1beta4 kind: InitConfiguration localAPIEndpoint: advertiseAddress: 192.168.1.11 bindPort: 6443 nodeRegistration: criSocket: unix:///run/containerd/containerd.sock name: k8s-master-01 kubeletExtraArgs: cgroup-driver: systemd --- apiVersion: kubeadm.k8s.io/v1beta4 kind: ClusterConfiguration kubernetesVersion: v1.32.11 imageRepository: registry.aliyuncs.com/google_containers networking: podSubnet: 10.244.0.0/16 serviceSubnet: 10.96.0.0/12 --- apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration cgroupDriver: systemd需要注意:imageRepository这个值要和你导入的镜像前段路径对应,如果直接用默认的registry.k8s.io,离线环境肯定是拉不到的。podSubnet要和后面装的CNI插件保持一致,不然后患无穷。
然后执行初始化:
kubeadm init --config=kubeadm-config.yaml成功后会看到kubeconfig的路径指引,按提示把admin.conf拷贝到当前用户下:
mkdir -p $HOME/.kube cp -i /etc/kubernetes/admin.conf $HOME/.kube/config chown $(id -u):$(id -g) $HOME/.kube/config4.2 经典报错:the api server is not healthy after 4m0.00747357s
这个报错几乎每个离线装k8s的人都遇到过,字面意思是kubelet等了四分钟还没等到apiserver通过健康检查。但真正的原因通常不在"等待时间不够",而是apiserver压根就没正常起来。我遇到的情况基本都是下面几种。
第一步,看kubelet日志。kubelet是static pod的启动者,apiserver以static pod方式运行在kubelet之下。如果kubelet本身没起来,后面的所有事情都无从谈起:
journalctl -u kubelet --no-pager -n 100如果这里有failed to run kubelet之类的错误,大概率是cgroup driver配置问题,回到上一章检查containerd的SystemdCgroup和kubelet的cgroup-driver是否都是systemd。
第二步,看容器运行时里有没有apiserver容器。kubelet正常运行时,会读取/etc/kubernetes/manifests/kube-apiserver.yaml并交给containerd创建容器。用crictl确认一下:
crictl ps -a | grep kube-apiserver如果这里连容器都没有,说明manifest没被kubelet加载,或者kubelet的CRI连接有问题。还有种情况是你明明导入了镜像,但tag和manifest里的不完全一致,containerd本地找不到就去远程拉,结局就是ImagePullBackOff。
第三步,看apiserver容器的日志。如果apiserver容器存在但反复重启,直接看日志:
crictl logs <container-id>我遇到过最典型的有三种:etcd没起来导致apiserver连不上存储后端;节点时间偏差过大导致证书校验和通信异常;内存不足导致容器OOMKilled。
我把常见根因整理成了一张排查表,每次进场都按这个顺序过:
| 现象 | 可能根因 | 处理方式 |
|---|---|---|
| kubelet日志报cgroup错误 | containerd与kubelet的cgroup驱动不一致 | 统一改为systemd |
| apiserver容器一直ImagePullBackOff | 本地镜像tag和manifest不一致 | 重新导入或改config.toml的sandbox_image |
| apiserver连接etcd超时 | etcd容器没起来或时间不同步 | 检查etcd镜像和日志,配置chrony |
| apiserver容器反复OOMKilled | 节点内存不足 | 增加内存或减少不必要的组件 |
最后一步,清理现场重来。如果排查了还是起不来,别硬等,先把环境reset干净再重新init。直接跑reset会清掉当前集群状态,但不会动你导入的镜像,不用重新导包:
kubeadm reset -f rm -rf /etc/cni/net.d rm -rf $HOME/.kube然后修复之前定位到的问题,再次初始化。这个过程我也经历过几次,关键是把每一步的日志看清楚,不要反复盲目重试。
4.3 控制节点就绪后,工作节点加入
控制节点正常后,生成join命令:
kubeadm token create --print-join-command把输出的命令在工作节点上执行即可。如果工作节点之前也跑过init,记得先kubeadm reset清理。全部节点加入后,用kubectl get nodes看一下状态,刚加入的节点会处于NotReady,因为CNI网络插件还没装。
4.4 离线环境下的Calico落地
网络插件这一步离线环境容易出问题。先在每台节点上把Calico相关的镜像导入containerd,然后从准备好的manifest文件直接应用:
kubectl apply -f calico.yamlCalico manifest里如果指定了镜像版本,请务必将对应镜像预先导入。这一点特别坑:很多人把公众号教程里的manifest直接拿来用,没注意到里面image tag和本地导入的不一致,结果所有calico-node Pod卡在ImagePullBackOff。我的习惯是进场前在联网机器上把最终要用的manifest和镜像版本核对一遍,锁死后再走离线流程。
5. KubeSphere离线落地:从安装器到控制台对外可达
5.1 先搞清楚你拿到的是3.x还是4.x的离线包
KubeSphere版本不同,离线安装的方式差别很大。3.x系列用的是ks-installer这个安装器,在已有k8s集群上apply几个yaml就能跑;4.x系列则改成了以ks-core为核心的chart安装方式,通常配合kk工具使用。拿到离线包的第一步,先确认包内的安装器形态,再决定后续命令,不要拿着3.x的教程去执行4.x的包,会到处碰壁。
我这次环境里用的离线包结构大概是这样的:kubesphere-installer.yaml、cluster-config.yaml,加上一个大的镜像tar包。这种结构是3.x的典型形态。
5.2 用ks-installer在已有k8s集群上部署
先把KubeSphere的镜像全部导入每台节点。注意这个镜像包通常比较大,导入时要确认磁盘空间足够,containerd的存储目录默认在/var/lib/containerd。
然后执行:
kubectl apply -f kubesphere-installer.yaml kubectl apply -f cluster-config.yamlks-installer会创建一个Job,在kubesphere-system命名空间里跑组件部署。查看进度:
kubectl get pods -n kubesphere-system -w全离线环境下这个过程可能会比较久,耐心等。看到所有pod变成Running,基本就完成了一大半。中间如果有pod一直Pending,多半是镜像没导全或资源不足,对照kubectl describe pod里的事件去排查。
5.3 控制台访问:NodePort和externalIP到底怎么选
KubeSphere默认安装完成后,控制台服务是ks-console,默认映射的NodePort是30880。最直接的访问方式就是:
http://<节点IP>:30880但在实际交付中,"客户端和k8s节点不在同一个网络里"的情况很常见。这时候我给两个思路。
思路一:直接用NodePort。所有节点都能访问30880,运维简单,但端口不够优雅,且暴露面比较大。
思路二:给控制台服务配置externalIP。这个方法在信创内网里很实用,给ks-console这个Service指定一个固定的内网IP,客户端直接访问这个IP即可:
kubectl -n kubesphere-system edit svc ks-console在spec下面加上:
externalIPs: - 192.168.1.200这里要提醒:externalIP的本质是让k8s把Service的流量路由到指定IP,它不是负载均衡器,没有健康检查和自动故障转移。所以你填的这个IP必须真实落在你的内网网段,并且能路由到集群节点,否则客户端照样访问不通。
如果客户环境里连NodePort这种端口映射也不方便用,可以考虑在集群里装MetalLB,用LoadBalancer类型把控制台暴露出来,但这需要额外准备MetalLB的镜像和配置,离线环境下就要预留这部分物料。
5.4 登录与初始账号
KubeSphere安装完成后,默认管理员是admin,初始密码是P@ssw0rd(如果安装包不同可能会变,离线包的文档里一般会写明)。第一次登录系统会要求改密码,这一步别偷懒,生产环境尤其重要。
如果登录后看到有些功能模块显示不可用,先检查底层必然关联的存储类是不是ready。KubeSphere的大部分组件都需要PVC,没有可用的存储类,所有依赖存储的Pod都会卡住。这个话题在下一章展开讲。
6. 跑了一周之后发现的几个坑和补救方案
6.1 节点重启后kubelet不自动起来
这个问题发生在一个很自然的场景:机房断电,服务器重启,整个集群就剩控制节点一个心眼。检查之后发现kubelet其实装了,但忘了enable。随手补上:
systemctl enable --now kubelet这看起来是小事,但在信创项目里,客户那边可能没有专职的k8s运维,节点重启后没人会想到去手动启动kubelet。建议每台节点装完组件后,统一执行一遍enable操作,形成交付清单的一部分。
6.2 kubeadm证书一年有效期,提前告诉客户
k8s 1.32.11里kubeadm默认签发的证书有效期仍然是一年。KubeSphere登录正常、业务跑着跑着,某天apiserver突然拒绝连接,十有八九是证书到期。运维要记得提前执行:
kubeadm certs renew all然后重启kubelet和apiserver相关组件。这件事最好在交付时就写进运维手册,或者挂个定时任务提醒。我一般会让客户把证书到期时间标注在维护日历上,别等炸了再排查。
6.3 存储类缺失导致的PVC Pending
KubeSphere离线装完之后,如果客户是全新环境,大概率没有默认存储类。最典型的表现是:创建企业空间、创建项目这些功能正常,但一旦要部署带持久化的应用,PVC永远Pending。
确认方式:
kubectl get storageclass如果没有,就需要内网准备一个存储插件,比如local-path-provisioner或者NFS provisioner。离线环境下同样是导出镜像、导入、apply manifest三步走。很多人在准备离线物料时完全没把存储插件算进去,我建议把它纳入标准清单,因为KubeSphere本身对存储的依赖非常重。
6.4 镜像tag的"冰山":临时补包是最痛苦的
离线环境里最难受的莫过于:进场后执行某个组件时才发现少了一个镜像,或者tag不一致,然后要在内网里临时想尽办法补包。我吃过几次亏之后,养成了一个习惯:在联网机器上把最终要用的所有yaml文件先跑一遍kubectl prepare或者直接扫码所有image字段,逐条核对镜像清单,确认绝对完整了再封包。
另外要注意架构标签:信创环境里x86和ARM的机器都很常见,镜像tar包要按linux/amd64、linux/arm64分开准备,混着导入会出各种奇怪的问题。这个坑我在一套鲲鹏机器上踩过,镜像导进去显示存在,但Pod拉取时平台不匹配,一直调度失败。
6.5 时间同步是整个集群最容易忽略的暗雷
k8s组件对节点间的时间偏差非常敏感。证书校验、etcd选举、日志时间戳,全部依赖一致的时间。麒麟V11默认不一定配了NTP客户端,我建议在所有节点统一配置chrony,时间源指向内网时间服务器。这一步半小时就能搞定,但能避免很多"看起来毫无规律"的故障。
我在实际项目里最深的体会是:离线的k8s集群,真正考验的不是某个命令会不会敲,而是有没有把所有"看似不重要"的细节在进场前消化干净。一个tag不一致、一个cgroup选项没开、一份证书没提醒,都可能让整个交付延期。希望这篇把我在麒麟V11上折腾这套组合的经验整理出来,能帮同行少走几趟弯路。