做 Kubernetes 排障的人大概都经历过这种场面:客户端访问超时,打开控制面一看,Pod 全部 Running,CPU、内存指标也正常,日志里没几条 ERROR,Service 的 Selector 更是精确匹配。这种“资源都正常”的现象最迷惑人,我这次遇到的正是一个典型的 Invalid Container Port 案例——表面看一切健康,实际业务端口却根本不通。从开始排查到最终定位,没用重启大招,也没怀疑网络插件,问题最后落在 containerPort 的声明和容器内进程实际监听端口不一致上,整整绕了一个多小时才把真相挖出来。
这篇文章就是把那次完整链路复盘一遍,从“客户端连不上”开始,到 EndpointSlice、Pod IP、容器进程监听端口,一层层收窄,最后对比声明端口和实际端口定位错位。适合刚接触 Kubernetes 网络排障的人,也适合已经在运维岗上被“假正常”骗过的人,看完可以直接把命令和检查顺序抄走。
1. “资源都正常”的三个假象,分别骗在哪里
1.1 Pod Running 只代表容器活着,不代表该听的服务在听
Pod 的 Running 状态,判定依据是容器主进程还活着,没有退出。kubelet 只要看到进程在,就会把它标记为 Running,它不会主动去探测 8080、8081 这些端口上是不是真有服务在听。这就像租了个商铺,人在店里站着营业,不代表门头招牌写的“美容”和屋里实际做的“理发”一致。
我这次看到的现象就是典型例子:主进程启动后监听的是 8081,但 Deployment 里声明的 containerPort 是 8080。进程一直活着,kubelet 就认为一切正常,Pod 一直 Running。这里有个很反直觉的点:容器有没有 Ready,取决于有没有配置探针;而很多人集群里根本没有 readinessProbe,于是 Pod 的状态永远只有“Running”这一种,完全掩盖了端口层面的故障。
1.2 日志干净,常常只是因为进程压根没走到监听那一步
很多人看到日志里没有报错就放心了。但端口错位场景下,程序往往根本不会走到“启动监听”那一步,自然也不会在日志里留下显眼的异常。
举个很常见的例子:某个 Node 应用,开发环境默认监听 5000,生产环境通过环境变量 PORT 覆盖为 8080。如果启动脚本里变量名拼错,或者赋值语句因为引号问题没生效,程序就会继续监听 5000,此时日志里没有任何 ERROR,顶多打一行“server is running on 5000”。如果你的 YAML 里写的是 8080,而日志却诚实地写着 5000,这其实是最大的提示,可惜很多人只扫一眼有没有红字,不会去读这些信息。
还有一种情况更隐蔽:启动脚本里先 echo 一句“listening on 8080”,但实际 bind 的是另一个端口。日志是程序里的“计划描述”,不是“事实记录”。排障时,日志只能帮你排除崩溃类问题,绝不能证明端口已按声明监听。
1.3 Service 和 Selector 匹配正常,只能证明控制面自洽
Service 层看到的“正常”,来自 Selector 的标签匹配结果,和端口是否真是这个没有关系。kubectl get endpoints 或者 endpointslice 里会列出后端 Pod IP 和端口,这个端口来自 targetPort;而 targetPort 没显式写时,默认就取 containerPort 的值。
所以如果 containerPort 写的是 8080,EndpointSlice 里列出来就是 PodIP:8080。哪怕容器里进程实际监听 8081,控制面也不会感知,因为没有任何组件会主动去核对“声明端口”和“进程监听端口”是否一致。从 Service 视角看到的“资源都正常”,本质上只是“声明一致”,离“数据面真实可达”还差着一次进程监听的事实确认。
2. 从“客户端连不上”到“containerPort 写错”的完整定位链路
2.1 先用最小命令收窄范围:DNS → Service → Endpoint
接到端口不通的反馈,我第一步不是去看网络插件,而是从客户端 Pod 里先把 DNS 排除掉。直接 kubectl exec 进客户端容器,执行 getent hosts ,或者用 nslookup ,看解析出来的 ClusterIP 和 kubectl get svc 显示的是否一致。
DNS 正常后,再 curl : 。这时候访问超时还是 connection refused,都要先记下来,暂时不用过度解读。这步的目的很简单:把怀疑范围从“DNS 配置错误”压缩到“Service 后端转发有问题”。如果 DNS 都解析不到,后面看什么都是白搭。
2.2 看 EndpointSlice,而不是只看 Service
Service 本身是一个虚拟入口,真正的后端信息在 EndpointSlice 里。用 kubectl get endpointslice -o yaml,或者老版本集群用 kubectl get endpoints -o yaml。
这里重点看两个字段:addresses 和 ports。我当时看到 addresses 里确实有目标 Pod 的 IP,port 也是 8080,看起来非常健康。但要注意,这个 port 是从 targetPort 反推出来的,而 targetPort 又默认跟随 containerPort。它只证明了“Service 配置和 Deployment 声明是自洽的”,完全证明不了“容器进程真的在听 8080”。这一步是很多人的思维盲区,看到 Endpoint 列表有 IP 有端口就以为后端没问题,其实只完成了控制面检查的一半。
2.3 绕过 Service,直连 Pod IP,切断转发层变量
接下来做最关键的一步:绕开 Service 和 kube-proxy,直接访问 Pod IP。先 kubectl get pod -o wide 拿到目标 Pod 的 IP,然后从任意一个客户端 Pod 里直接 curl http:// :<你怀疑的端口>。
如果这一步就超时或者 refused,问题基本被压缩到了 Pod 网络栈和容器进程这一层,后面不用再查 iptables、不用再看 Service 规则。我当时直连 PodIP:8080,curl 直接卡住,最后报 connection timed out。到这一步,我已经把排查范围从“整个集群网络”缩小到“单个 Pod 的端口监听”,省掉了大量无效检查。
2.4 进容器看真实监听端口,用 /proc/1/cmdline 兜底
如果镜像里有 shell,最直接的办法是 kubectl exec -- ss -tlnp,看 LISTEN 状态的端口。如果没有 ss,试试 netstat -tlnp。如果镜像里连 shell 都没有,那就得换思路:在节点上找到容器 ID,用 docker inspect 查看 Config.Entrypoint 和 Config.Cmd,确认进程启动参数;或者直接读 /proc/1/cmdline:
kubectl exec <pod-name> -- sh -c 'cat /proc/1/cmdline | tr "\0" " "; echo'如果主进程带了 --port=8081 这类参数,这一步直接就能看到真相。我当时在容器里执行 ss -tlnp,看到一行:
LISTEN 0 128 0.0.0.0:8081 0.0.0.0:*而 YAML 声明是 8080,两分钟前我还盯着 EndpointSlice 里的 8080 说服自己没问题,看到这行输出才确认方向完全错了。
2.5 把声明和实际端口摆在一起,错位立刻现形
排障到最后阶段,一定要把四处信息同时摆出来对比。我当时整理出来的是:
| 项目 | 值 |
|---|---|
| Deployment containerPort | 8080 |
| Service targetPort | 8080 |
| readinessProbe httpGet.port | 8080 |
| 容器内 ss 实际 LISTEN | 8081 |
四个项目,三个是 8080,只有最后一行是 8081。这种“差 1”的端口最容易肉眼扫过,8080 和 8081 长得实在太像,我最初盯着 YAML 看了两遍都没察觉。对比表拉出来之后,问题就一目了然:不是网络层故障,是容器端口声明与实际监听端口错位。
3. containerPort 本质上只是一个“声明”,K8s 不会替你校验
3.1 这个字段在三层端口映射里的真实职责
要理解这类故障,必须认清 containerPort 在 Kubernetes 里到底是什么。它不负责给容器注入任何环境变量,不负责启动进程,也不负责在容器内开端口。它的实际职责可以概括成三件事:
- 给 kubectl expose 等命令当默认 targetPort,用来生成 Service 规则;
- 给调度器和控制面提供“这个容器期望暴露哪些端口”的元数据;
- 作为集群内文档,告诉后人这个容器约定监听什么端口。
仅此而已。它更像项目里的“接口约定”,而不是操作系统里的端口配置。进程最终监听哪里,取决于镜像里的启动脚本、命令行参数、环境变量,以及应用代码本身。
3.2 为什么错位之后 K8s 依然给 Running:这是设计使然
数据包的转发链路是这样的:客户端访问 ClusterIP:port,进入 kube-proxy 的 iptables 或 ipvs 规则,DNAT 到具体 PodIP:端口,然后进入容器网络命名空间。整个过程走的是“地址即信任”的逻辑,规则只负责把包送到目标 IP 和端口,根本不关心目标端口上有没有进程在监听。
可以类比成快递员按门牌号投递:快递单上写着“8080 房间”,快递员就只往 8080 房间塞包裹;屋里的人明明在 8081 房间等着,那这个包裹要么被退回,要么一直没人收。如果 8080 端口确实没进程监听,TCP 层通常会回 RST,表现成 connection refused;如果流量被中间负载均衡层吞掉,可能就表现成超时。两种表现我都在实际排障中遇过,取决于集群网络组件的具体实现。
所以 K8s 不报错,不是系统 bug,而是它本来就不具备“声明端口与进程监听端口一致性”的校验能力。这个能力需要运维层自己补。
3.3 错位最常见的三类来源
根据这次和之前几次排查经验,端口错位基本逃不出三类来源:
第一类是镜像升级后默认端口变了。开发环境监听 3000,打成生产镜像后监听 8080,但 Deployment 里的 containerPort 还写着 3000。这种情况通常在 CI 更新镜像版本后发生,YAML 没跟着改。
第二类是启动脚本加了一层环境变量覆盖。比如镜像默认监听 8080,但 Kubernetes YAML 里又注入了 PORT=8081,进程启动时优先读这个变量,声明和实际立刻错开。这类问题最容易在“同一个镜像复用到不同环境”时爆发。
第三类是一个镜像里塞了多个进程。比如 Nginx 监听 8080,业务进程监听 8081,而 containerPort 只写了一个。你声明其中某个端口后,另一个端口就成了“隐形端口”,流量自然进不来。
4. 修复不是改一个数字那么简单:三种修法对比
4.1 快速止血:调整 Service targetPort 对齐实际端口
如果你当前只想尽快恢复业务,那最省事的办法是把 Service 的 targetPort 改成容器实际监听的端口。例如声明是 8080、实际是 8081,可以执行:
kubectl patch svc <service-name> -p '{"spec":{"ports":[{"name":"http","port":80,"targetPort":8081}]}}'这个动作会让 EndpointSlice 立刻刷新,流量直接送到 8081。好处是秒级恢复,坏处是用“改 Service 去迁就错误声明”的方式换来了暂时可用,YAML 里依然存在一处错误信息。
这种方法我建议只用来应急,不要作为最终状态。因为当下次有人看 Service 配置,会看到一个比真实端口多 1 或者少 1 的数字,等于把故障隐患留给了以后的排障同事。
4.2 治本:让声明、探针、进程监听三点重新对齐
真正要决定的问题是:这个服务“约定”的端口到底应该是哪个。如果业务方明确约定 8080,那就应该把容器进程改回监听 8080,而不是让 YAML 迁就进程;如果历史包袱太重,约定实际就是 8081,那就把 Deployment 里的 containerPort 和探针端口都改成 8081,再调整 Service 的 targetPort。
我这次的场景属于第二种,最终围绕实际端口做了对齐:
kubectl patch deployment <deployment-name> -p '{"spec":{"template":{"spec":{"containers":[{"name":"app","ports":[{"containerPort":8081}],"readinessProbe":{"httpGet":{"path":"/healthz","port":8081}}}]}}}}'注意 patch Deployment 时要非常小心,因为模板里可能有其他字段需要保留,比如镜像版本、资源限制、env 等。生产环境我建议直接编辑完整 YAML 而不是用这种内联 patch,避免一改就丢字段。对齐之后,从 containerPort 到 targetPort 到探针,整条链路都是同一个数字,控制面和进程事实才真正一致。
4.3 更进一步:把端口配置收敛到单一来源
这次故障让我反思一件事:为什么 8080 和 8081 会在四个地方各写各的?因为端口信息散落在 YAML、镜像启动脚本、文档和人的记忆里,没有任何强制约束。
后来团队把这个服务的端口配置收敛到 Helm values 里,Deployment、Service、探针全部引用同一个变量。同时在 CI 里加了一条烟雾测试:启动容器后扫描实际监听端口,再读取 Helm values 里的声明端口,两者不一致直接构建失败。这条检查很土,但有效,因为我们从此再没被这类“端口声明错位”坑过。
5. 端口类故障的系统性预防:五个检查点和一套排障顺序
5.1 端口信息分散在五个位置,缺一不可
这次排完之后,我把端口相关配置的所有落点列成了一张检查清单,每次变更都过一遍:
| 信息位置 | 检查内容 | 错位后的典型现象 |
|---|---|---|
| Dockerfile EXPOSE | 暴露的端口是否和启动参数一致 | 镜像阅读者被误导,调试时看错端口 |
| ENTRYPOINT / CMD / 启动脚本 | 进程实际 bind 的端口来源 | 端口被脚本环境变量覆盖,最隐蔽 |
| Deployment containerPort | 期望暴露的端口声明 | EndpointSlice 端口跟着错,流量送错门 |
| Service targetPort | 后端转发目标端口 | 实际访问不通或打到错误端口 |
| readinessProbe / livenessProbe | 探针检查的端口 | 探针打错端口,Pod 状态假健康 |
要特别提醒一句:Dockerfile 里的 EXPOSE 本身也不改变任何行为,它只是镜像文档。所以五个位置里,只有“进程实际监听端口”是最终事实,其他四个都是不同层级的“声明”。排障时永远以事实为准,以声明为辅。
5.2 readiness 探针是端口类静默故障的第一道哨兵
这次故障之所以会变成“用户反馈才被发现”,根本原因就是服务没有配置 readinessProbe。如果有探针且探针直接指向真实端口,端口错位会导致 Pod 一直进入 NotReady 状态,Service 的 EndpointSlice 不会把未就绪的 Pod 纳入后端,流量也就不会打到坏节点上,故障会在分钟级内被调度系统自动隔离。
这里有一个容易踩的坑:探针里的 port 是独立字段,默认值和 containerPort 有联动关系,但如果你显式写了探针 port,它并不会自动跟随 containerPort 变化。所以很多集群即便配了探针,探针端口和 containerPort 一样是错的,照样会被“假健康”骗过。正确做法是让探针直接探测和应用约定的真实健康检查端口,并且把探针 port 与 containerPort 在配置生成阶段保持同步。
5.3 端口不通时的固定排查顺序
结合这次经验,我现在遇到端口类问题,几乎不会乱跳,按固定顺序走:
先查 DNS,在客户端 Pod 里解析服务名;再查 Service ClusterIP 连通性;然后看 EndpointSlice 的 addresses 和 ports;接着绕过 Service 直连 Pod IP;最后进容器看实际监听端口。每一步输出的结果都只是“可疑”或“排除”,不停留在“看起来没问题”的层面。
哪怕症状和这次一模一样,我也不会直接改 targetPort,因为同一个现象背后可能完全不是同一种原因。只有走到最后一步,看到进程实际监听的端口,才能确认错位确实存在。
最后分享一个我现在养成的动作:每次变更之后,拿一个临时测试 Pod 直接跑一遍 curl 冒烟,而不是等监控告警。监控告诉你的是“故障已经发生”,冒烟告诉你的是“这次变更有没有引入故障”。端口这类静默问题的可怕之处在于:它不会自己跳出来,只会等到用户报障时才被发现。把声明和事实拉齐,再给每次变更加上一条活体检查,比任何高深的网络工具都管用。