上篇聊到我和K8s"相遇"的前半段,也就是从只会敲命令到真正看进去理论的部分。这篇继续往下写,也是这个系列里我最想认真记录的一段——从"好像明白了"到"亲手把它跑起来"之间那段漫长又痛苦的爬坡期。K8s这种级别的技术,光靠看资料、过文档,你是永远学不会的,它只会在你动手部署第一套集群、第一个应用、第一次排查故障的时候,才真正把底牌一张张掀开。这篇就记录我从搭环境到部署Nginx、再到被各种诡异问题折腾的过程,顺便把那些让我印象深刻的坑都摊开讲,希望能帮准备入坑的朋友少走几步弯路。
1. 从"看得懂"到"用起来":搭建第一套实验环境
1.1 为什么选择本地部署而不是直接用云服务
很多朋友一上来就问我,学K8s是不是直接去买个云厂商的托管集群最快?我不反对,托管集群确实省心,但我不建议作为第一条学习路径。第一次学K8s,你需要的是"看见"它内部发生了什么,而不是"假装"它不存在。
托管集群像是租了一辆自动挡,你确实能把它开走,但引擎盖下的东西你永远摸不着。本地搭建一套集群,哪怕是单节点的,用kubeadm手动初始化,你会亲眼看到etcd、kubelet、kube-proxy、容器运行时这些组件是怎么被拉起来、怎么配合的。这个过程本身就是学费,而且是性价比最高的学费。
我的建议是:准备一台至少2核4G的虚拟机,如果条件允许,4核8G会更舒服。单节点集群完全够第一阶段的练习,之后再加节点也只是多跑几条join命令的事,第一遍不需要追求复杂拓扑。
1.2 各组件版本的搭配
版本搭配这个问题,看起来不复杂,实际上一上来就卡住我差不多一个小时。K8s对版本有着严格的"步调"要求,不是说你装一个最新版的Docker配一个最新版的Kubernetes就能万事大吉。我最初没有提前查兼容性矩阵,结果初始化集群的时候,kubeadm直接报了一堆版本过旧的错误。
用过一段时间之后,我的做法是:先确定Kubernetes的版本,再去查这个版本官方推荐的容器运行时版本。容器运行时我推荐containerd,Docker时代已经是过去式了,现在用containerd更贴近K8s生态的主流,也少一些Docker那层daemon带来的额外损耗。
另外,操作系统层面,我在虚拟机里用的是Ubuntu server LTS版本。Linux内核版本太老也容易出问题,特别是涉及网络插件和iptables的时候,一个4.15内核和5.4内核跑起来的表现完全不一样。这一点新手容易忽略,等出了问题又要从头排查,非常消耗耐心。
1.3 初始化集群的完整步骤
初始化这一步,我用的是kubeadm。很多人问我为什么不用minikube,minikube确实简单,一条命令就能起一个单节点集群,用来感受kubectl的命令确实不错。但它的网络、存储、调度逻辑很多都被封装掉了,一旦出了故障,你看不见底层的真相。所以我在玩过几次minikube之后,还是回到kubeadm这条路上来。
初始化之前有几条准备命令基本是固定的:
# 关闭swap,这一步不做,kubelet会直接起不来 swapoff -a sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab # 加载内核模块 modprobe br_netfilter # 设置内核参数 cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf net.bridge.bridge-nf-call-iptables = 1 net.ipv4.ip_forward = 1 net.bridge.bridge-nf-call-ip6tables = 1 EOF sysctl --system然后装kubeadm、kubelet、kubectl,再装containerd。这里有个小细节,装完containerd之后建议先执行containerd config default | sudo tee /etc/containerd/config.toml,它会生成一份完整的默认配置。否则containerd的配置文件几乎是空的,从K8s拉镜像的时候很多参数都没有落到实处,后续会出现各种pod一直ContainerCreating的诡异问题。
初始化命令核心的就一条:
kubeadm init --apiserver-advertise-address=192.168.100.10 \ --pod-network-cidr=10.244.0.0/16--pod-network-cidr这段地址要看你的网络插件来定,我用的是基于VXLAN的overlay方案,所以写死了10.244网段。如果你用其他网络方案,网段可能也要跟着改。这也是一个"你以为无关紧要、实际上息息相关"的配置项。
初始化完成后,kubeadm会返回几行提示,告诉你如何把kubectl的配置文件拷出来,以及如何添加工作节点。不要急着往下走,先按提示执行:
mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config kubectl get nodes执行完这一步,你的节点大概率是NotReady状态,因为网络插件还没装。不要慌,这是正常现象,下一步才是关键。
提示:初始化失败是最常见的挫折来源,绝大多数都是因为版本不对、swap没关、内核参数没配好。遇到报错别硬着头皮反复执行,把
/var/log/syslog里的kubelet日志翻出来看一眼,比猜效率高十倍。
2. 核心概念落地的第一课:部署一个应用
2.1 先理解Pod不是最小部署单元的"最小"而是"原子"
我在没有实际部署之前,对Pod的理解停留在"一组容器的集合"这种空洞的描述上。直到我亲眼看着一个Pod被调度、被创建,才发现Pod的核心价值不是"组合",而是"原子性"。
就像你点一份套餐,里面的汉堡和薯条可能来自同一个订单,但真正让你觉得方便的是它们是作为一个整体被送到的。Pod里的多个容器一定调度到同一个节点上,共享网络命名空间和存储卷,它们之间用localhost就能互相访问。这种亲密关系,是容器编排器为了让"一组进程"能像"一个单位"一样被管理而设计的,它让Pod成为K8s调度和扩缩容的原子单位。
这个认知很重要。因为后面你接触Deployment、StatefulSet、DaemonSet,它们管理的对象统统都是Pod,而不是直接操作容器。理解了"Pod是原子",才能看懂为什么Pod会被杀了重建、会被迁移到别的节点,而你根本不会注意到,因为对上层来说,Pod对外提供的"能力"还在。
2.2 第一个Deployment:为什么选Nginx
网上的教程几乎清一色都用Nginx做第一个部署,我一开始觉得有点俗,但后来发现这个选择实在高明。Nginx镜像小、本身是纯静态服务、不依赖外部数据库,出问题时的排查面非常窄。
我用的第一个Deployment YAML长这样:
apiVersion: apps/v1 kind: Deployment metadata: name: nginx-demo labels: app: nginx-demo spec: replicas: 3 selector: matchLabels: app: nginx-demo template: metadata: labels: app: nginx-demo spec: containers: - name: nginx image: nginx:1.21 ports: - containerPort: 80然后应用它:
kubectl apply -f nginx-deployment.yaml kubectl get deployments kubectl get pods -o wide你会看到3个Pod慢慢创建出来。注意一个细节:image我写的是nginx:1.21而不是nginx:latest。在K8s环境里用latest标签是很危险的。因为一旦集群重装,或者节点被Cordon之后重新调度,镜像版本可能已经悄悄变了,你等于在跑一个每一秒都可能在多变的"环境"。固定tag是为了让部署的确定性更高,这也是生产环境里很少用latest的根本原因。
2.3 通过Service把应用暴露出来
Pod创建出来只是第一步,默认情况下它们对外完全不可见。因为Pod的IP是动态分配的,每次重建都可能变,根本没有稳定的访问入口。这时候就需要Service出场了。
Service是一个稳定访问入口的抽象层,它让你可以用一个不变的ClusterIP和DNS名字去访问一组动态变化的Pod。我刚学的时候,总是分不清ClusterIP、NodePort、LoadBalancer三者的关系。后来我找到一个类比:
ClusterIP是小区里的楼牌号,只有在这个小区里的人才能看到;NodePort是在小区围墙上开的门洞,外面的人从洞可以进去;LoadBalancer是在你家门口安排了一个专业的门卫,外面的人不用管小区在哪,直接找门卫就行。
我的第一个Service配置:
apiVersion: v1 kind: Service metadata: name: nginx-service spec: selector: app: nginx-demo ports: - protocol: TCP port: 80 targetPort: 80 type: NodePort用kubectl expose deployment nginx-demo --name=nginx-service --port=80 --target-port=80 --type=NodePort也能达到类似的效果,但写YAML能让你更清楚每个字段的含义,推荐还是从YAML开始。
这个阶段你会发现,Service的selector和Pod模板里的label是怎么关联起来的。只要Pod的label和服务器的selector匹配,这个Pod就自动进入Service的后端列表。这套"以标签为纽带"的松散耦合设计,是K8s里几乎一切扩展机制的基石,你在后面接触Ingress、NetworkPolicy、HPA的时候都会遇到它。
3. 踩坑实录:我在这条路上摔过的跟头
3.1 网络插件没装对,集群一直NotReady
我第一次初始化完集群,等了一下午,节点还是NotReady。查了kubelet日志,发现它一直在说网络插件没有就绪。当时我给集群装的网络插件是Calico,但由于没有注意到kubeadm init时的--pod-network-cidr设置与Calico的默认网段不一致,导致网络始终起不来。
后来才知道,Calico默认的IP池是192.168.0.0/16,而我初始化时用的是10.244.0.0/16,两者对不上,Calico就没有正常分配Pod网段。
解决办法有两个:要么调整Calico的配置文件里的IP池与你--pod-network-cidr保持一致;要么干脆改回Calico默认网段重新初始化。我后来选择了改Calico的配置,因为重新初始化意味着之前的环境都要重新搭一遍,太浪费时间。
这个坑给我的教训是:任何组件的配置都不是孤立的,网络插件的IP池、kubeadm的Pod网段、以及Service的网段是三个相互咬合的齿轮,你动了其中一个,就得检查另外两个是否匹配。
3.2 镜像拉不下来的各种姿势
在国内环境下,镜像拉取这个问题几乎人人都会遇到。K8s组件镜像,比如kube-apiserver、kube-controller-manager等,直接从默认源拉不去的话,就得用镜像加速器,或者从其它镜像仓库同步到本地再导入。
我一开始的解决办法是,让kubeadm直接使用本地的镜像缓存。具体做法是,在另一台机器上提前拉好所有镜像,导出成tar包,再导入到目标机器。虽然操作繁琐一点,但胜在稳定可重现。这个方案后来成了我搭集群的固定流程。
另外一个更隐蔽的问题:如果你要部署的Pod镜像本身也需要从国外源拉取,比如某些互联网上的公共镜像,而你的环境访问不稳定,部署的时候就会一直处于ImagePullBackOff状态。这时候用kubectl describe pod查看事件,通常会看到Failed to pull image或者pull access denied这样的提示。如果是不存在的镜像tag,错误写的是"manifest unknown";如果是网络问题,可能是"connection refused"或者超时。排查这两个错误的方向是完全不同的,所以看日志之前先别急着盲目重试。
注意:很多人遇到镜像拉取失败就反复删除Pod重试,其实这是最低效的做法。正确姿势是先用
crictl images确认本地镜像列表,再用kubectl describe pod看拉取失败的具体原因,最后决定是配置私有镜像仓库、切换源,还是提前手动导入镜像。
3.3 探针配置不当导致Pod反复重启
部署Nginx之后的某一天,我发现自己写的一个应用Pod频繁被重启,而且重启周期没规律。后来通过kubectl get events发现是存活探针探测失败。
存活探针的作用是检查容器里的进程是否"活着",如果探测失败,kubelet就会杀掉容器并重启。我初始配置的时候,把initialDelaySeconds设得太短,应用还没完成启动,探针就开始工作,结果被判定为失败,然后反复杀、反复重启,陷入一个"预热即被杀死"的死循环。
配置探针的正确姿势,要充分考虑应用的启动时间。经验值是这样:initialDelaySeconds至少要比应用平均启动时间多出30%到50%,periodSeconds默认10秒已经够用,不要设得太频繁,否则会给应用的负载带来不小压力。
readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 20 periodSeconds: 10 timeoutSeconds: 2还有一个小细节,readinessProbe和livenessProbe不要用同一个接口。你很难保证一个接口同时适合做"是否就绪"和"是否存活"两种判断。就绪探针关心的是依赖是否全部就绪,比如数据库连接是否建立;存活探针关心的是进程是否卡死。分开设计才能应对不同的故障场景。
3.4 不设资源限制,一个容器吃垮整个Node
这是我在一次压测中踩的坑。当时我部署了一个测试用的Java应用,没在YAML里声明resources.limits。压测一打上去,Pod里的JVM直接吃掉了Node的全部内存,然后整个节点进入OOM状态,不但这个Pod被杀,连节点上的其它Pod也被连带拖下水。
K8s的调度器在调度Pod的时候,依据的是requests,而限制运行时行为靠的是limits。如果你只设limits不设requests,调度器不知道这个Pod需要多少资源;如果你只设requests不设limits,当节点压力上来时,这个Pod可以无限膨胀,挤占别人的生存空间。
正确的做法是Always两个都设:
resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi其中cpu的100m代表0.1个核,m是毫核的意思。很多新手第一次看到100m以为是一百兆,其实不是,它是CPU时间的计量单位。这个单位细节搞错的话,你看到的监控数据和实际资源使用会完全对不上。
4. 从部署到理解:调度、声明式API与控制器
4.1 声明式API才是K8s真正的门槛
很多人学K8s,觉得难在概念多、组件多、配置复杂,其实这些都是"表"不是"里"。K8s真正的核心抽象,是它的声明式API设计:你告诉系统你想要的最终状态,系统负责想办法把当前状态拉到那个最终状态。
打一个不太准确的比方。命令式编程像点菜,你要一步一步告诉厨师先洗菜、再切菜、再炒、再放盐;声明式API是把你想要的菜写成一个"配方",比如"我要一盘宫保鸡丁,不要花生,微辣",然后后厨自动根据当前食材情况去安排具体怎么做。
实际操作上,区别体现在你操作集群的方式上。你用kubectl create是命令式的,它只管"创建一个对象";用kubectl apply是声明式的,它会比较你的期望状态和集群当前状态,做一次"合入"。所以apply的幂等现象比create好很多。
这个理解的真正价值在日常维护中会体现出来。比如某个Deployment被手贱的人改了镜像版本,你只需要把之前备份的YAML重新apply一次,它就会自动回滚到期望状态。这种方式比写一堆shell脚本来"修正状态"要稳健得多。
4.2 控制器:打着工盯着状态的"监工"
理解了声明式API之后,你会自然地想知道"谁来执行这个收敛过程"。答案就是控制器。
控制器的职责用一句话说就是:持续观察集群状态,对比期望状态,如果有差异,就采取行动让它收敛。这个"观察-对比-行动"的循环,在K8s源码里叫作reconcile loop(调谐循环)。我之前一直不理解为什么K8s能从一次节点宕机中自动恢复,后来看了控制器的实现才明白,Deployment控制器每隔一段时间就会检查一下实际Pod数量,如果少于期望值,就自动补建新的Pod。
这个设计其实源自"闭环控制"思想,和恒温器的工作原理如出一辙。温度低了就加热,温度高了就降温,没有复杂的中央指挥,只有一个个局部控制器在各自负责的领域内维护状态。整套系统的容错能力,恰恰来自于这些相对独立的控制器组合,而不是一个中心化的超级大脑。
4.3 水平扩缩容:从手动到自动
当我理解了控制器之后,我对HPA(Horizontal Pod Autoscaler)的好奇心一下子被点燃了。因为之前的Deployment扩缩容是靠人去改replicas数量:
kubectl scale deployment nginx-demo --replicas=5这当然能用,但人不可能24小时盯着监控。HPA的核心思路是让"Pod数量"也变成一个受控变量,它自己根据CPU使用率或自定义指标去调节replicas。
我第一次部署HPA时的配置:
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: nginx-demo-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: nginx-demo minReplicas: 2 maxReplicas: 8 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 50这里有一个很关键的点:HPA要正常工作,底层的Pod必须设置requests.cpu,否则指标采集器拿不到"利用率"的分母,HPA会一直报unable to get metrics。这又是一个"看似无关的配置之间相互依赖"的地方,我一开始也是在这里耽误了一会儿。
自动扩缩容跑通之后,那种"看到系统自己在做决策"的感觉还是相当震撼的。你只是定义了一个规则,后面的执行和维护全交给了系统本身。
5. 关于学习和调优的一些延续思考
5.1 先会用,再看源码
我身边有些朋友学K8s,上来就直接看源码,看调度器的设计文档,看控制器管理器怎么分段执行。我的建议是不用这么急。
第一遍学习K8s,把时间花在"会用"上是效率最高的。这里的会用是:能用kubectl操作资源、能看懂YAML字段、能排查常见的Pod生命周期问题、能理解Deployment和Service的基本工作方式。这些技能构成了你"能跑通一个应用"的底线,也是你后续理解更深层机制的经验基础。
等你真正被线上问题逼到墙角,比如调度不均衡、控制器行为奇怪、性能出现瓶颈,那时候再回头看源码,你会觉得每一行都是有价值的,因为你有真实场景在脑子里打底。没有场景支撑的源码阅读,很快就会因为缺乏上下文而放弃。
5.2 一定要学会的Debug三板斧
整个学习过程中,我最依赖的三条命令是:
kubectl get events --sort-by='.lastTimestamp' kubectl describe pod <pod-name> kubectl logs -f <pod-name> -c <container-name>事件、描述、日志,这是一个从浅入深的排查路径。先看事件,了解Pod生命周期里发生了什么状态变化;再看describe,了解它当时的资源、挂载、探针、端口等等全部细节;最后才看日志,确认容器进程自己说了什么。
很多新手一看到Pod异常,第一反应就是kubectl logs,这是不对的。比如ImagePullBackOff状态下,日志通常什么都没有,因为容器根本还没起来。你必须要看describe才能定位到是镜像仓库的问题,还是secret没配好的问题。排查顺序反了,就是南辕北辙。
5.3 如果重来一次我会怎么学
如果让我重新学一遍K8s,我会给自己规划一个四周的路线:
第一周搞定实验室环境,搭好kubeadm集群,把Pod、Deployment、Service、PVC这些基础对象全部部署一遍。第二周开始做故障演练,比如手动删Pod、驱逐节点、改掉容器的探针路径,逼着自己用Debug三板斧解决。第三周去尝试StatefulSet和Ingress,把有状态服务和七层入口这两个应用广泛的场景补上。第四周回到业务本身,用K8s重构一个自己熟悉的简单服务,把它容器化后完整跑通,包括滚动更新和回滚。
这个路线没有直接去学各种高级特性,而是把大量时间花在"让它出问题再把它修好"这个循环上。事实证明,故障才是最好的老师,被坑一次的记忆,远胜于读十遍官方文档。
6. 系列预告与最后的几句话
这个系列还会继续写下去,下一篇打算聊聊我从部署第一个应用到现在,对K8s网络模型的几条深度思考,包括Pod间通信、Service的iptables实现机制,以及真正在生产环境里跑起来时,才发现文档里不会写清楚的几个"潜规则"。
说回"相遇"这个题目。我到现在还记得第一次通过Service成功访问到那个Nginx页面时的心情:一个只会在教程里反复出现的组合词,突然变成了一张我自己署名的、可以随时访问的网页。那种惊喜很原始,但也正因为有前面的困难、踩坑、熬夜,这份惊喜才显得特别真实。
最后想对所有刚起步的朋友说一句话:K8s的学习曲线确实陡,但它陡得值得。你要做的不是一步跨上去,而是接受它、适应它,然后一步一步把它走完。每一个你被坑后记录的排障过程,都在为你之后的从容铺路。
我在实际使用中最深的感受是:K8s不是一个"学完"就能掌握的技能,而是一个"用着用着"才慢慢渗透进你思维体系的东西。希望你也能在自己的集群上,感受到那种从混乱走向秩序的过程。