1. 最初的问题:服务间调用为什么会间歇性失败
我接手的一套Kubernetes集群在某次扩容后,线上开始出现奇怪的现象:订单服务调用支付服务时,时不时冒出Connection timed out,但重试几次又能成功。最诡异的是,同一个Deployment里的多个Pod表现完全不一致,有的Pod一直正常,有的Pod几乎每次调用都超时。监控面板上CPU、内存、网络流量全部正常,找不到任何明显的资源瓶颈。
这种情况在Kubernetes环境里其实相当典型。表面上看起来是"网络抖动",但底层往往藏着服务发现机制、DNS解析、以及网络策略三件事的叠加问题。我当时的第一反应是查Service和Endpoint,接着又查了CoreDNS的解析日志,最后才发现根子出在网络策略的Egress规则上——规则本身写错了,导致部分Pod访问特定目标时被拦截,而由于Kubernetes DNS的负缓存机制,问题被掩盖成了"间歇性"。
这篇文章我不打算只讲一个孤立的问题,而是把这次实战里涉及的Kubernetes服务发现链路、网络策略设计、以及故障排查方法完整拆开。适合刚接触Kubernetes网络、或者已经在生产环境里遇到类似"奇怪网络问题"的读者参考。我会把每一步的思考逻辑和验证手段都写出来,而不是只给结论。
2. 服务发现链路拆解:从Service到Pod的完整路径
2.1 DNS解析环节:CoreDNS的工作原理与负载均衡陷阱
Kubernetes里的服务发现,最常用的是DNS方式。集群内的Pod访问一个Service时,默认会通过CoreDNS解析service.namespace这样的域名,拿到ClusterIP,然后发起连接。这个链路看起来简单,但每一步都有坑。
CoreDNS本身是一个多副本的Deployment,默认情况下通过ClusterIP暴露给集群内部。Pod里的/etc/resolv.conf会指向kubelet配置的DNS服务地址,也就是CoreDNS的ClusterIP。当Pod发起解析请求时,CoreDNS会返回对应的A记录。这里有个关键点:如果请求的是普通Service,返回的是ClusterIP;如果请求的是Headless Service,返回的是Pod的IP列表。
我在排查问题时发现,大部分"间歇性失败"都跟DNS缓存有关。比如某些基础镜像里带了nscd,或者应用自己做了DNS缓存,那么第一次解析拿到一个IP后,之后很长一段时间都会复用这个IP。如果这个IP对应的Pod已经挂掉,连接自然就失败。而Kubernetes默认的ClusterIP是稳定的——只要Service不重建,ClusterIP就不会变——所以这一步一般不是问题。真正的坑出在Headless Service和外部域名解析上。
2.2 ClusterIP的转发规则:iptables与IPVS的行为差异
当Pod拿到ClusterIP后,接下来的流量会经过kube-proxy设置的转发规则。kube-proxy有两种主流模式:iptables和IPVS。
在iptables模式下,每个Service的流量会随机命中一条DNAT规则,把ClusterIP转换成某个后端Pod的IP。问题在于,iptables规则的命中是纯随机的,连接建立后,后续的数据包会走conntrack跟踪,保持同一个后端。这种模式对连接数少的场景没问题,但在高并发下有两个隐患:一是规则数量随Service数量线性增长,二是在大量并发新建连接时,iptables的查表性能会有抖动。
IPVS模式则不一样,它内核态维护一张hash表,查询效率更稳定,还支持更丰富的调度算法,比如rr轮询、lc最少连接等。如果条件允许,尽量用IPVS模式。我测试过同一套应用,在iptables模式下并发创建1万条短连接时,CPU毛刺明显;切到IPVS后,毛刺降低了很多。
2.3 Headless Service与EndpointSlice:用来做服务发现的高级选项
普通Service解决了"稳定入口"的问题,但有些场景需要直接拿到后端Pod的IP列表——比如数据库集群、消息队列、或者需要自己实现负载均衡的客户端。这时候就要用Headless Service,也就是在Service定义里设置clusterIP: None。
Headless Service不会创建ClusterIP,DNS解析会直接返回所有就绪Pod的IP。客户端可以自己决定连接哪个IP,也可以轮询使用。这种模式很适合那些对延迟敏感、想要精确控制连接分布的场景。
但Headless Service有个文档里不怎么强调的坑:当后端Pod数量很大时,DNS解析返回的A记录非常多,响应包可能被截断,客户端只能拿到部分IP。我自己遇到的一个场景是某个服务有几十个副本,客户端拿到的IP列表总是不全,导致部分副本长期没有流量。后来从DNS解析改为使用EndpointSlice API,客户端侧直接通过API watch Endpoint变化,才彻底解决问题。
EndpointSlice是Kubernetes 1.21之后默认开启的API,它把Service关联的Pod IP按组分片暴露给客户端。相比DNS,它的优势是实时性更好、信息更完整,而且不需要经过DNS服务器中转。如果你们团队的应用都在集群内部,且相互间调用频繁,我建议认真考虑用EndpointSlice做服务发现的底层数据源。
3. 网络策略:不是"装了就安全",而是要精确表达
3.1 NetworkPolicy的准入逻辑:三层规则如何叠加生效
服务发现解决的是"找到对方",网络策略解决的是"能不能访问对方"。很多团队在Kubernetes里装了NetworkPolicy控制器,写了几个示例策略,就以为网络已经安全了。实际上,NetworkPolicy的规则语义非常容易写错,尤其是当多条规则叠加的时候。
一条NetworkPolicy由三个核心部分组成:podSelector(目标Pod)、policyTypes(Ingress/Egress)、以及具体的ingress/egress规则。规则里可以是from和to的组合,每个组合还可以嵌套多个匹配条件。
这里最关键的一点是:同一类型下的多条规则之间是OR关系,不同规则类型(比如Ingress和Egress)之间是AND关系。具体来说,如果两条Ingress规则都匹配同一个Pod,那么只要流量符合其中任意一条规则,就会被放行。但如果Pod同时被Ingress和Egress规则约束,流量必须同时通过Ingress检查和Egress检查才能成功——任何一个方向被拦截,连接就会失败。
3.2 默认拒绝策略:先关后开是唯一安全策略
生产环境里我强烈建议先落三条"默认拒绝"策略,把每个命名空间的流量入口和出口全部封死:
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-ingress spec: podSelector: {} policyTypes: - Ingress --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-egress spec: podSelector: {} policyTypes: - Egress注意,这个podSelector: {}表示选中命名空间里的所有Pod。两条策略分别声明了Ingress和Egress的默认拒绝。一旦应用这两条策略,除非你再写额外的放行规则,否则命名空间里的所有Pod既不能对外发流量,也不能被外部访问。
这一步是我见过最多团队跳过的环节。很多人直接写"特定Pod只允许被XX访问"这样的白名单规则,但没有先落地默认拒绝。这样做的隐患在于:凡是没被规则覆盖到的Pod,保持完全开放状态,规则只约束了"一部分",而网络边界还是敞开的。默认拒绝的意义是让你明确知道"哪些流量是被有意放行的",而不是靠规则碰巧挡住。
3.3 Egress规则里容易漏掉的隐式依赖
默认拒绝策略落地后,你很快就会遇到第一个崩溃瞬间:业务Pod里访问数据库没问题,但日志组件连不上、监控拉不到指标、镜像拉取失败……因为Egress规则把所有出口方向都封了。
举个例子,你有一个业务Pod需要访问Kubernetes集群里的数据库Service,于是你写了一条Egress规则:
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-egress-to-db spec: podSelector: matchLabels: app: order policyTypes: - Egress egress: - to: - podSelector: matchLabels: app: mysql这条策略看起来没什么问题,但仔细想一下:业务Pod调数据库前,是不是要先通过DNS解析mysql.middleware这个域名?域名解析需要访问CoreDNS,而CoreDNS的Pod上并没有app=mysql这个标签。于是Egress规则会把发往CoreDNS的DNS查询也拦截掉,业务Pod连域名都解析不出来。
这就是Egress规则里最容易漏掉的隐式依赖。解决办法是在每一条Egress规则里同时放行DNS流量:
egress: - to: - podSelector: matchLabels: app: mysql - to: - namespaceSelector: {} - podSelector: matchLabels: k8s-app: kube-dns ports: - port: 53 protocol: UDP - port: 53 protocol: TCP规则加了两段:第一段放行到app=mysql的Pod流量,第二段放行到CoreDNS的53端口流量。注意namespaceSelector: {}表示所有命名空间都匹配,因为CoreDNS可能在工作负载所在的命名空间,也可能在kube-system里,写上这个选择器可以避免漏配。
3.4 CNI插件的能力边界:Calico与Cilium的选择思路
NetworkPolicy本身只是Kubernetes API层的一种声明,真正执行规则的是CNI插件。主流支持NetworkPolicy的CNI有Calico、Cilium、Weave Net等。我在实际操作中主要用Calico和Cilium,两者的表达能力差异很大。
Calico的NetworkPolicy实现基于iptables和IPVS,支持Kubernetes原生NetworkPolicy之外,它还扩展了自己的calicoPolicy类型,增加了更丰富的匹配条件,比如基于端口范围、协议、CIDR、以及AS号等。如果你们的集群规模在几百个节点以内,Calico足够用,而且它的调试工具多,遇到问题相对容易排查。
Cilium则基于eBPF,网络策略的执行点在更底层的Linux内核层面,可以做到非常细粒度的匹配,比如按HTTP路径、按方法过滤,甚至按消息队列的Topic放行。但代价是学习曲线更陡,对内核版本有要求(通常需要4.19及以上),排查问题也需要理解eBPF的基本概念。
选择哪套CNI不光是性能问题,更关键的是你的网络策略需要表达多细。如果只需要简单的"谁可以访问谁",Calico完全够;如果要做七层策略或者跨集群策略,Cilium更有优势。我建议在规划期就先想清楚这个表达粒度,因为CNI切换是件伤筋动骨的事,后面再换成本很高。
4. 完整踩坑复盘:一次多环境灰度发布引发的流量中断
4.1 事故现场与初步排查
回到开头那个"间歇性连接超时"的问题。我当时排查的完整链路是这样的:
先看现象——订单服务调用支付服务的失败率从0.01%爬升到15%左右,持续了20分钟后自动恢复。表面上看是典型的"抖动",但直觉告诉我,20分钟这个时间窗口很可疑,于是去翻了这段时间的变更记录。结果是:当天上午,我们刚在测试环境应用了一批NetworkPolicy规则,打算模拟生产环境的隔离策略。虽然改的是测试环境,但测试环境里的支付服务和订单服务跟生产环境用的是同一个Service名和命名空间名,只是集群不同。这个"同名"设置,让问题看起来像"跨环境串扰",但实际跟它一点关系都没有。
进一步定位时,我先用了kubectl get networkpolicy -n online查看当前测试环境里的策略,发现里面有一条规则,匹配的是支付服务的Pod,policyTypes: [Ingress, Egress],但Ingress规则里只放行了来自网关命名空间的流量。也就是说,订单服务的Pod访问支付服务时,从Egress方向没问题,但从支付服务的Ingress方向看,来自"订单命名空间"的流量不在白名单里,于是被拦了。
4.2 根因定位:为什么"间歇性"而不是"完全不通"
如果是规则拦截,那应该是100%失败才对,怎么会出现"部分Pod偶尔成功"?这里的迷惑性很强。我仔细想了一下,发现跟Kubernetes的DNS查询缓存策略有关。
当订单服务Pod第一次访问payment-svc.payment.svc.cluster.local时,会先发起DNS解析,CoreDNS返回ClusterIP。但某些业务应用自己做了短连接池,连接失败后会重试,重试时如果Pod内部的DNS缓存已经过期,会重新发起解析——这个重解析的时序不是固定的,所以表现成"过一会儿又能连上"。
另外,订单服务有多个副本,每个副本发起连接的时间点不同,有的副本在规则生效前建立了长连接,存活的老连接不受影响;有的副本在规则生效后新建连接,直接被拦截。所以监控上才会看到"部分Pod成功率正常,部分Pod成功率很低"。
要确认是NetworkPolicy拦截,我用了两个手段。第一个是查看支付服务Pod的kubectl describe networkpolicy,确认规则里是否有放行订单命名空间的条目;第二个是手工进入订单服务Pod,用curl和nc直接访问支付服务的ClusterIP,观察返回码,再用kubectl exec进支付服务Pod里抓包,看是否有SYN包到达。两个手段互相印证,最终锁定根因就是Ingress白名单漏了订单服务对应的NamespaceSelector。
4.3 修复与验证:改规则比改应用快得多
找到问题后修复很快,只需要在支付服务的Ingress规则里加上一条from,把订单服务所在命名空间放行:
ingress: - from: - namespaceSelector: matchLabels: name: order改完规则后,需要等CNI插件把策略推送到各个节点的数据平面。Calico一般几秒内生效,Cilium也差不多。验证时我做了三件事:
第一,进入订单服务的Pod,连续执行100次curl到支付服务的ClusterIP,成功率恢复100%; 第二,在支付服务Pod上抓包,确认SYN包能进来,ACK能回去; 第三,观察两个服务Pod的监控指标,确认连接建立数恢复正常曲线。
这里想多说一句:改NetworkPolicy规则时,别只盯着"规则内容",还要考虑规则覆盖的Pod范围。比如这条规则里的podSelector.matchLabels如果写的是app: payment,但实际支付服务Deployment的标签是app: payment-v2,那规则等于没生效。我用kubectl get pods -n payment --show-labels核对过标签,确保匹配范围准确。
5. 优化后的验证手段与日常巡检
5.1 连通性测试的正确姿势
故障修完不代表以后不会踩同类坑。我后来总结出一套日常巡检方法,核心思路是"先主动探测,再被动监控"。
主动探测方面,我会在集群里部署一个简单的连通性测试工具,定期从各个业务Pod向依赖服务发起TCP连接测试。这个工具可以是自研的DaemonSet,也可以是现成的网络诊断镜像——比如带curl和nslookup的最小容器。关键是探测路径要能覆盖真实流量路径:客户端Pod → DNS解析 → ClusterIP → 网络策略数据平面 → 服务端Pod。
有一个测试细节值得注意:用curl测HTTP接口时,要关注HTTP状态码而不是只关注"能通";用nc测TCP端口时,要关注连接能否完成三次握手。这两者的区别在于,HTTP可能被服务端应用层拒绝,而TCP握手能通说明网络策略和负载均衡链路是通的。如果两者结果不一致,问题可能出在应用层而不是网络层。
5.2 规则变更前的影响面评估
NetworkPolicy是强副作用声明,改一条规则可能影响一大批Pod。我建议任何规则变更都走"影响面评估"流程。
评估的核心是搞清楚三件事:这段流量从哪来、到哪去、走哪个端口。具体操作时,我会先用kubectl get networkpolicy -A -o yaml导出当前所有策略,做一次全量梳理,挑出跟目标Service有关的策略,逐一核对podSelector、namespaceSelector和ports。尤其注意那些没有指定ports的规则,因为没指定端口意味着这个方向的全部端口都放行,白名单效果被稀释了。
然后,在测试环境里用"先加后减"的方式变更规则——先加放行规则,验证业务正常后,再删旧的拒绝规则或收紧规则。如果直接删了旧规则再加新规则,中间有个窗口期,既可能误伤正常流量,也可能让违规流量溜进来。
最后,变更完成后至少观察一个完整的业务周期,比如一个结算日或者一个刷量高峰段,不要只看几分钟就宣布"没问题"。
5.3 可观测性补全:日志、指标、事件三管齐下
网络策略的拦截动作在数据平面发生,如果没做可观测性补充,它就像"隐形墙"——你只知道流量丢了,不知道丢在哪。
Calico提供了calico/log字段的FluentBit集成,可以输出丢包日志;Cilium则有cilium monitor命令,能实时跟踪eBPF数据平面上的每一条被策略拒绝的流。我在集群里专门加了一套针对网络策略事件的对账任务,定时从CNI的监控接口拉取被拒绝的流记录,跟业务流量做比对,看是否有"应该被放行却被拒绝"的异常项。
日志之外,我还会在Prometheus里配置几组关键指标:每个网络策略的命中次数、拒绝次数、DNS解析延迟、Pod之间的TCP建连成功率。这些指标放在同一个Dashboard里,一旦出现"某个服务的连接失败率升高",我能一眼看出是被网络策略拦截还是DNS解析变慢。
这里有一个经验之谈:网络问题排查的排查顺序永远是"先数据平面,再控制平面,最后应用层"。数据平面就是实际的流量转发和策略执行,看抓包和连接日志;控制平面是Service、EndpointSlice、NetworkPolicy这些API对象的状态,看kubectl get和describe;应用层才去看业务日志和代码逻辑。大多数"诡异问题",最后都会落在数据平面和控制平面的交互缝隙里。
5.4 给不同规模集群的优化侧重点
如果你负责的集群比较小,几十个Pod、几个命名空间,那么服务发现和网络策略的优化重心在于"清晰"——把命名空间划分好,策略写明确,规则数量控制在个位数。小集群不需要引入太复杂的服务发现机制,DNS加ClusterIP完全够用。
如果集群规模到了几百个Pod、多个团队共享,那优化的重心变成"可维护"——用命名空间隔离环境,用默认拒绝策略覆盖全局,在关键命名空间里用标签规范Pod分组,确保团队之间能看懂彼此的规则。
如果集群已经上千个Pod,还涉及多集群互通,那服务发现要考虑EndpointSlice和跨集群服务网格,网络策略要支持跨集群的Identity,普通PodSelector就不够灵活了。这时候我建议把策略模型升级到基于服务身份的方案,比如Cilium的Identity概念,让策略挂在身份上而不是挂在Pod标签上。
规模上升之后,手动维护一条条NetworkPolicy会变得非常痛苦。我自己的做法是维护一个策略生成模板,把业务团队的访问需求抽象成JSON配置,再自动渲染成NetworkPolicy YAML。比如团队A声明"我的订单服务需要访问支付服务"加上"需要访问数据库中间件",模板就会生成对应的Ingress和Egress规则并自动附上DNS放行规则。这样既保证了规则覆盖完整,也避免了团队自己写策略时忘了放行DNS这类低级错误。
最后再分享一个实用小技巧
整个排查过程中,我发现一个特别容易被忽略但极其有用的工具:kubectl exec进入Pod后,临时安装tcpdump,直接抓本Pod的网卡流量。这个操作在数据平面排查里是最直观的手段——你能亲眼看到SYN包发出去了,但没有SYN-ACK回来,那问题一定出在对端或者中间链路上。
像网络策略这种"看起来应该通、实际不通"的问题,抓包能直接看到流量被丢弃在哪一跳,省掉大量猜疑时间。如果你用的是Calico,还可以用calicoctl查看节点上的策略命中统计;用Cilium则用cilium monitor --type drop监听丢弃事件。工具掌握得越多,排障时越能快速缩小范围。
这些经验和手段不复杂,但都是在真实故障里摸爬滚打攒出来的。如果你也在维护Kubernetes集群,建议先把自己的网络策略体系梳理一遍:默认拒绝有没有落地?Egress规则漏没漏DNS?策略规则跟Pod标签匹配是否准确?这三个问题检查完,相信你的集群也会少很多"间歇性诡异故障"。