k8s系列写到第四篇,不少人会卡在同一个地方:集群能起来了,YAML也能写,Deployment、Service、Pod这些概念都认识,可一旦遇到“服务访问不了”“Pod一直重启”“数据一重启就没了”这种真实问题,立刻就没方向。这一篇我不打算再讲概念,而是把精力和篇幅花在“日常使用中最容易出问题的四个环节”上——Service网络、持久化存储、ConfigMap/Secret配置管理、以及一套能直接照搬的排错思路。
写给自己看也好,写给正准备上生产环境的朋友也好,这个阶段最需要的不是原理背得多少,而是遇到问题知道往哪个方向查、该用哪些命令、每一步的判断依据是什么。这篇讲的东西,全部来自我自己在真实集群里踩过的坑和验证过的做法,算是给“k8s(四)”定位成一个偏实战、偏运维的篇章。如果你已经有k8s基础,但还缺一套系统性的排错方法,那么这篇应该能接得上你的需求。
1. 先想清楚这一篇要解决什么
1.1 从这个阶段的学习目标说起
前几篇大概覆盖了基础概念、集群部署、核心资源对象。到了第四篇,真正要紧的是把你从“会用”推向“能解决问题”。这一点很多教程不强调,但它才是实际工作的分水岭。
为什么这么讲?因为在你部署完集群、跑通第一个Pod之后,很快就会遇到一连串现象级问题。比如服务之间ping不通、流量偶尔超时、容器反复重启、数据卷路径没权限、改了配置不生效等等。这些问题的根源往往不在某一个文件里,而是散落在kubelet、kube-proxy、CoreDNS、CSI插件、存储驱动等各个组件中。你如果只盯着一个Pod看,永远找不到原因。
所以第四篇的定位,我个人定义为“排查知识+实战经验”。它不是新功能教学,而是把前面学到的所有碎片拼成一张地图:知道每个流量入口、每个存储挂载点、每个配置注入路径上最容易疏忽的位置。有了这张地图,哪怕遇到没见过的问题,你也知道从哪开始下手。
1.2 围绕“问题”学k8s比围着“文档”学更快
我见过不少朋友学k8s,习惯是捧着文档从头读,读完Service再看Ingress,看完PV/PVC再看StorageClass,觉得都懂了,一上线还是懵。原因很简单:文档是按“资源对象”组织的,而实际运行是按“故障现象”展开的。同一个问题,可能涉及多个对象的配合错误。
我建议的学习方式是:拿到一个问题先判断它属于网络、存储、调度、配置中的哪一类,然后把这类相关的对象全部拉出来过一遍。比如服务访问不通,涉及Service、Endpoints、kube-proxy、CoreDNS、Ingress;Pod一直Pending,涉及调度器、资源配额、节点污点、存储卷的可用性。按“问题域”学习,知识是粘连的,不是孤立的。
这一篇我也会按这个思路来编排。前面三部分分别讲网络、存储、配置管理的典型坑和判断逻辑,最后一部分给一套通用的排查动作顺序。这样你以后遇到问题,脑子里的路径是清晰的:先从现象定位到模块,再到具体对象,最后看日志和事件确认。
2. Service网络:服务互相访问不通,先查类型和代理模式
2.1 搞清楚四种Service类型到底什么时候用
Service在k8s网络里是个“虚拟入口”,它把一组Pod的访问方式固定下来。但很多问题的根源,是选错了Service类型,或者对类型背后的转发链路理解不到位。
- ClusterIP:这是默认类型,只能在集群内部访问。常用于服务之间的互相调用。
- NodePort:在ClusterIP基础上,在每个节点上开一个端口,外部可以通过任意节点的IP加端口访问。适合临时调试、或者没有负载均衡器的环境。
- LoadBalancer:由云平台或负载均衡组件分配一个公网IP,流量先到负载均衡器,再转到NodePort或直接转到Pod。适合对外提供服务的场景。
- ExternalName:不创建代理,而是把Service名字解析到外部域名。适合把集群外部的服务,伪装成集群内部服务来调用。
我在实际项目里最常遇到的错误,是有人在集群内部访问一个NodePort服务时,下意识去填“节点IP:NodePort”,结果发现网络时通时不通。其实集群内部直接访问Service的ClusterIP或者Service名称就行,NodePort只在从外部进入时需要用到。链路越短,出问题的概率越小。
判断网络问题的第一步,永远是问一句:访问方在哪里,目标服务是什么类型,走的是哪一层转发。这相当于排查之前的“定位动作”,能帮你直接砍掉一大半无关因素。
2.2 kube-proxy的iptables还是ipvs,性能与排错差异很大
Service真正把流量转发到Pod的,不是kubelet也不是DNS,而是节点上的kube-proxy组件。kube-proxy支持userspace、iptables、ipvs三种模式。现在新的集群版本里,早期模式已经很少用了,常见的就是iptables和ipvs两种。
iptables模式:规则全部通过iptables实现,每个Service都会产生一组规则。它的优点是实现简单、兼容性好,问题在于Service数量多了以后,规则链表非常长,匹配性能会下降,而且规则更新时存在“先删后加”的间隙,大批量变更时可能出现极短的流量中断。
ipvs模式:基于内核的IPVS模块,查询是哈希表形式,性能比iptables高很多,规则清晰,也更适合大规模集群。但它有一个前提要求——Linux内核需要加载ip_vs相关模块。很多人在裸机部署k8s时没留意,结果kube-proxy日志报错,ipvs模式启动失败,自动回退到iptables模式,抓日志才知道。
我建议你装完集群先确认一下当前模式:
kubectl get cm -n kube-system kube-proxy -o yaml | grep mode如果看到mode后面是“iptables”而你想用ipvs,可以通过修改ConfigMap或者启动参数来切换。如果是新环境,直接按ipvs来规划;如果是老集群,切换前要评估流量窗口,因为这会重建整个转发链。
排错时还要记得,iptables模式下查Service连通性,可以用:
iptables -t nat -L KUBE-SERVICES -nipvs模式下要看:
ipvsadm -L -n这两条命令能直接告诉你,是否有对应的虚拟服务和后端端点。如果Service存在、但ipvs列表里没有,说明kube-proxy没同步;如果列表里有但没有后端,说明Endpoints为空或者Pod没就绪。这个顺序我已经用过无数次,几乎一次就能锁死问题所在。
2.3 一个典型的“服务访问超时”排查复盘
之前某同事遇到过一个现象:服务A通过Service名称访问服务B,请求偶尔超时,多试几次又通了。他第一反应是去看服务的CPU和内存,发现都很正常,然后去翻应用日志,也没发现异常。后来我和他一起排查,定位到并不是应用问题,而是转发链路。
当时排查步骤是这样的:
- 先确认Servive和Endpoints是否一致。如果Pod虽然存在,但没有通过健康检查,Endpoints里就没有它的IP,转发就会失败。
- 确认访问域名解析是否正常。进入服务A的Pod,执行
nslookup 服务B.命名空间.svc.cluster.local,看返回的ClusterIP。 - 确认ClusterIP到Pod之间的转发。这一步就需要看kube-proxy的规则了。当时用的是iptables模式,我进去一看规则,发现某条链里新旧规则并存,存在重复的DNAT规则。
- 最后判断是kube-proxy的规则刷新问题,在长时间运行后出现了部分规则未收敛。重启kube-proxy后问题消失。
这次复盘给我的经验是:Service访问超时,不要一上来就怀疑应用代码,流量链路的每一层都要看一遍。最好从“最靠近访问方”的地方开始排查,逐段往前推。先DNS,再ClusterIP,再Pod端点,再容器端口,这条链路上任何一个环节出错,表现都是“通但慢”或“时通时不通”。
2.4 Ingress的坑:重写路径和超时时间
Ingress是集群外部流量的入口,这类组件配置相对复杂,最常踩的坑是路径重写。比如你用如下规则暴露一个前端应用:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: demo-ingress annotations: nginx.ingress.kubernetes.io/rewrite-target: /$2 spec: rules: - host: demo.example.local http: paths: - path: /app(/|$)(.*) pathType: Prefix backend: service: name: demo-svc port: number: 80如果不加rewrite-target,你访问 /app 时,后端收到的是 /app,而不是根路径 /。很多前端应用直接挂在根路径下,结果页面404。这其实和nginx的location处理逻辑一模一样,理解了nginx规则,这块就不难。
另一个容易被忽略的是Ingress Controller的默认超时时间。有人做文件上传接口,超过60秒就失败,一开始怀疑是后端超时,排查下来发现是Ingress默认的proxy-read-timeout只有60秒。解决办法是加一条annotation:
nginx.ingress.kubernetes.io/proxy-read-timeout: "120" nginx.ingress.kubernetes.io/proxy-send-timeout: "120"做这类配置变更时,记得先看Ingress Controller访问日志,有时候后端明明正常,日志里直接就写了upstream timeout,错误指向非常明确。把Controller日志的位置记下来,能省很多事。
3. 持久化存储:PV/PVC用不好,数据一重启就丢
3.1 PV与PVC的绑定逻辑:先有“能用的仓库”再申请“配额”
很多人对PersistentVolume(PV)和PersistentVolumeClaim(PVC)的关系一知半解,只知道“PVC要绑定PV”,但为什么要这样分层,其实和工作中的权限划分有关。
把PV理解为管理员预先准备好的存储资源池,它可能是NFS的一个目录、云硬盘的一块卷、或者本地磁盘的一个分区。PVC则是用户发起的一个申请单,申请多少容量、以哪种方式访问。k8s会找一个满足容量和访问模式的PV来绑定这个申请单。绑定完成后,PVC就以卷的形式挂载到Pod里。
这种设计解决了什么问题?在于“存储提供者”和“应用使用者”的解耦。管理员不需要关心具体应用,使用者也不需要知道底层存储长什么样,大家只跟接口打交道。
但这也带来一个新手常见错误:明明创建了PV,PVC却一直Pending。多半是容量对不上,或者访问模式不一致。比如PV是ReadWriteOnce,PVC申请ReadWriteMany,那就永远匹配不上。另一个隐蔽问题是,PV没有设置storageClassName,而PVC指定了,也会匹配失败。我自己排查过很多次,最后的落点都是这两个原因。
3.2 动态存储供给比“手动创建PV”好用得多
如果集群规模不大,手动创建PV还能接受;一旦环境里有几十个应用,每个都手动建PV,效率太低了。更好的方案是用StorageClass做动态供给。
举个例子,如果你有NFS存储,可以部署一个NFS Provisioner,然后创建一个StorageClass:
apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-storage provisioner: example.com/nfs之后PVC里只要指定存储类:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name:>allowVolumeExpansion: true而且只有在PVC绑定的存储类型支持动态扩容时才能生效。对于本地存储、某些NFS实现,扩容基本是不可能的,只能通过迁移数据重建PV的方式来解决。
所以如果你想给工作负载预留扩展空间,第一选择是:使用支持动态供给和allowVolumeExpansion的存储方案,从最开始就不要把自己困死在一个静态PV上。第二选择是:提前规划容量,流量上来前就监控PVC的使用率,而不是等写满了再去处理。
4. 配置管理:ConfigMap和Secret里那些“看起来没问题”的坑
4.1 注入方式不同,修改后的生效路径完全不同
ConfigMap和Secret是k8s里管理配置的两类对象。很多人理解它们的用法,但忽略了一个关键事实:注入方式决定了配置的更新行为。
环境变量注入方式是这样:
env: - name: APP_MODE valueFrom: configMapKeyRef: name: app-config key: mode这种方式的特点是:Pod创建时,kubelet会把ConfigMap的值读取出来,写入容器环境变量。一旦Pod启动,ConfigMap改了,容器里的环境变量不会变。想让新配置生效,只能重建Pod。这属于设计如此,并不是bug。
卷挂载方式是这样:
volumes: - name: config-volume configMap: name: app-config containers: - volumeMounts: - name: config-volume mountPath: /etc/config这种方式把ConfigMap里的内容映射成文件,挂在Pod目录下。kubelet会周期性同步ConfigMap的变更到挂载文件里,默认同步周期大约1分钟。所以改完ConfigMap后,等一会儿文件内容就会变化,许多应用能自发读取新配置,而不必重建Pod。
不过注意,如果你在volumeMount里用了subPath,情况就不一样了。subPath挂载的是“单个文件”,它的内容不会随ConfigMap更新而更新。很多人在线上改了配置后一直不生效,最后找到原因就是这个细节。我把这个隐藏在“配置管理”里最容易翻车的地方,单独放在下面讲。
4.2 subPath的坑:同一卷下的不同文件,更新行为完全两样
什么是subPath?简单说就是只挂载卷里的某个子目录或子文件,而不是把整个卷都挂进去。举个例子:
volumeMounts: - name: config-volume mountPath: /etc/nginx/nginx.conf subPath: nginx.conf这种写法很常见,因为很多镜像里已经有默认配置文件,你只想用ConfigMap覆盖其中一个文件,不期望把整个目录覆盖。逻辑上完全合理,问题出在更新行为上。
kubelet在做ConfigMap热同步时,针对subPath挂载的文件并不做更新处理。结果就是:ConfigMap改了,Pod里的nginx.conf依然是旧版本。如果你用这种方案,要么接受“改配置必须重启Pod才能生效”,要么换成“不指定subPath,把整个ConfigMap目录挂进去,然后通过软链接或应用配置路径指定需要的文件”。
我个人的处理习惯是:对于需要热更新的配置,尽量采取目录挂载方式,让ConfigMap里每个文件都对应一个真实文件;对于更新频率极低的配置,比如启动参数、静态开关,可以用环境变量注入或subPath挂载。把配置按“是否频繁变更”分类,选不同的注入方式,是最贴合实际的方案。
4.3 Secret的编码和创建方式,别被“加密”两个字迷惑
Secret的官方定位是保管敏感信息,但它本身只是base64编码,不是加密。任何人拿到Secret的YAML,都可以直接用base64命令解码得到原始内容。这一点要特别提醒刚上手的朋友,不要把包含明文密码的Secret文件直接提交到公开仓库。
创建Secret时有两种常见写法。第一种:
kubectl create secret generic db-secret \ --from-literal=username=admin \ --from-literal=password=123456第二种,在YAML里直接写:
apiVersion: v1 kind: Secret metadata: name: db-secret type: Opaque data: username: YWRtaW4= password: MTIzNDU2第二种里的data字段必须是base64编码后的值,手写很容易出错。还有一个更直观的字段叫stringData,它接受明文:
stringData: username: admin password: "123456"用stringData创建时,kubectl会自动帮你完成编码。这个字段适合调试和开发环境,因为它可读性高,不容易写错。
还有一个隐蔽问题:用volume方式挂载Secret时,文件内容是base64解码后的原文,这没问题;但如果把Secret通过环境变量注入,有些应用会把你传进去的值再做一次base64解码,导致得到一串乱码或错误内容。这种问题排查起来很费劲,因为常规检查看起来配置都在,实际传进容器的值和预期完全不同。遇到这种状况,先进容器用env命令确认一下最终值,再决定是调整Secret编码方式还是修改应用读取逻辑。
4.4 另一种常见的ConfigMap使用困惑:多个配置项怎么组织
有经验的人创建ConfigMap时,会特别留意一个设计问题:是每一个配置项单独一个ConfigMap,还是所有配置集中在一个ConfigMap里?
单独一个ConfigMap的好处是改动范围小、引用关系清晰,缺点是数量多。集中一个ConfigMap的好处是管理方便,缺点是:如果同一个ConfigMap被多个Deployment引用,其中任何一个人改了其中一个key,所有引用它的服务都会在同步周期后看到变化,可能引发意料之外的配置漂移。
我推荐的做法是:按“配置变更范围”划分ConfigMap。比如数据库连接相关配置放一个,应用功能开关放一个,中间件调优参数放一个。尽量让一个ConfigMap只服务于一个“变更域”,这样即使多个服务引用同一个ConfigMap,也只说明这些服务确实共享了这一组配置。如果你发现某个ConfigMap里同时有几十个毫无关联的key,那大概率在审计变更时会有麻烦。
5. 日常排错:Pod状态、事件、日志,按这个顺序来
5.1 Pod状态速查表:看到状态就知道往哪查
Pod在生命周期中会停留在很多状态,每个状态对应的排错方向都不一样。下面这个表我经常放在手边,遇到问题对着查效率很高。
| Pod状态 | 含义 | 第一排查动作 |
|---|---|---|
| Pending | 尚未调度到节点 | 看调度器日志、节点资源、污点、PV是否可调度 |
| ContainerCreating | 容器正在创建 | 看kubelet日志、存储卷挂载、镜像拉取状态 |
| Running | 正常运行 | 无需处理,关注重启次数即可 |
| CrashLoopBackOff | 容器启动后退出,反复重启 | 看应用日志和启动命令,排查退出码 |
| ImagePullBackOff | 镜像拉取失败 | 看镜像名、tag、仓库权限、节点能否访问仓库 |
| Completed | 任务正常结束 | 无需处理,属于一次性任务的终态 |
| Error | 容器异常退出 | 看退出码和日志 |
| Terminating | 正在终止 | 看是否有finalizer阻塞、节点失联、卷占用 |
这个速查表的核心价值在于:让你第一眼就知道把精力放在哪。很多新手看到CrashLoopBackOff就直接重启Pod,这没有意义,因为问题本质是容器自身的启动逻辑,重启一万次也一样。
5.2 describe、logs、events三类命令的配合顺序
排查Pod问题,我个人的标准动作是固定的,很少跳步:
- 先看整体状态
kubectl get pods -o wide这一步确认哪些Pod异常、状态是什么、在哪个节点上。
- 看详细描述
kubectl describe pod <pod-name>关注两个关键区块:Conditions和Events。Conditions告诉你Pod当前是否被调度、是否就绪;Events则是kubelet在创建过程中留下的各种警告,比如镜像拉取失败、存储挂载失败、端口冲突、资源不足。
- 看容器日志
kubectl logs <pod-name> -c <container-name>如果容器之前崩溃过,还想看上一次退出的日志:
kubectl logs <pod-name> -c <container-name> --previous- 查看集群级事件
kubectl get events --sort-by=.metadata.creationTimestamp这一步是全局视角,尤其适用于“不知道哪个Pod有问题,但系统整体异常”的场景。
这套顺序的精髓是:先看到“发生了什么”,再猜“为什么”。describe的Events区域往往会直接写出原因,比如“FailedScheduling”“FailedMount”“Back-off pulling image”,这些错误关键词本身就是排查入口。
5.3 CrashLoopBackOff的完整排查过程,一步一步来
CrashLoopBackOff是我被问到最多的问题。它本身不是一种独立故障,而是“容器启动后退出、kubelet反复重启”的结果。
排查第一步是看退出码:
kubectl get pod <pod-name> -o yaml | grep -i exitCode退出码很关键。代码为0代表正常退出,CrashLoopBackOff通常不会是0;代码为1一般代表应用内部错误,可能是参数错误、连接不上依赖等;代码为137代表被OOM Kill,说明内存超限;代码为143代表被SIGTERM终止,可能是preStop钩子或优雅关闭逻辑有问题。
次要看启动命令和探针设置。如果应用本身启动慢,比如需要10秒才能监听端口,而你设置的readinessProbe只给2秒初始延迟,那探针就会失败,kubelet认为容器不健康,反复重启。这类问题靠看应用日志是发现不了的,因为应用其实一直在正常启动,只是探针太急。
再看日志:
kubectl logs <pod-name> --previous这一步能看到上一次容器退出的打印输出。别小看这个动作,很多人忽略了--previous参数,看到的始终是“当前容器”的启动日志,绕了一大圈也没找到崩溃时留下的信息。
最后一个隐藏原因:数据库、Redis等外部依赖还没就绪,应用启动时连不上就退出。这类问题要把启动脚本里去掉“启动即fail”的逻辑,或者用initContainer先检测依赖就绪。用Kubernetes的initContainer做依赖检查,是目前最优雅的方式之一。
5.4 Pod一直Pending,表面是拷资源,实际是集合了多种问题
Pending状态比CrashLoopBackOff更让人困惑,因为表面看Pod没有报错,只是“等在那里不动”。Pod调度不成功,无外乎几个原因。
资源不足是最常见的。你可以用:
kubectl describe node <node-name>看节点的Allocated resources和Conditions,比如内存Pressure、磁盘Pressure。如果Requests超出了节点可用资源,调度器根本没有候选节点。
其次是污点和容忍度问题。节点上如果打了污点,比如键为key、值为value、效果为NoSchedule,那么没有对应容忍度的Pod就会被排挤走。可以用:
kubectl describe node <node-name> | grep Taints查当前节点的污点,再决定是移除污点还是给Pod加容忍度。
再者是存储卷问题。如果PVC没有绑定成功,或者绑定到了错误节点,Pod会一直Pending。判断方法是:
kubectl describe pod <pod-name>Events里通常会看到“0/XXX nodes are available”和后面跟的具体原因。如果提到volume,那基本就是PV/PVC的匹配问题。
调度问题的排查,我一直强调看Events比看应用日志优先。因为调度器本身已经把失败原因写在了事件里,你只需要读出来就行。与其瞎猜,不如直接把Events贴出来分析。
6. 写在后面:建议保留一个“故障盘点笔记”
前面聊了很多具体问题和命令,最后我想分享一个个人习惯,也算是对这一篇的收尾。
我在维护k8s集群的过程中,发现最值钱的并不是某一条命令,而是“每次排查完问题后,把原因、判断过程、最终解法记下来”。不用写很长的文档,几句话加上命令就行。比如“某天Pod CrashLoopBackOff,原因是指定了错误的工作目录,表现为日志里找不到配置文件,解法是修正workingDir并重建Deployment”。
为什么建议这么做?因为k8s的故障往往有很强的相似性。很多问题半年后你会再次遇到,如果当时没记录,就得重新排查一遍;如果有记录,照着之前的思路十分钟就能定位。
我现在排查问题时,第一步翻的经常不是官方文档,而是自己的故障笔记。尤其是在接手别人维护的集群时,谁也不能保证所有背景都清楚,只有自己在实际操作中积累起来的案例才是最快、最可靠的参考。
另外一个值得养成的习惯是:每做一个操作前,先问自己一句“这个操作的影响范围是什么”。是换一条Ingress注解,还是修改全局的kube-proxy模式?影响的范围完全不同。我见过不少人只是改了一个Service的type,就把整个集群的网络搞得一团乱。想清楚再动手,比熟练敲命令更重要。
希望这篇“k8s(四)”能帮你建立起一套属于自己的排查框架。实践永远是最好的学习方式,遇到问题的时候,按篇里说的顺序慢慢地查一遍,你会比看一百篇文档都进步得快。