做K8S Service服务暴露与负载均衡实验,绕不开一个躲不掉的现实:Pod在网络里的身份很“脆弱”。今天换个IP,明天滚动更新换一批,如果你把Pod的IP直接写死在配置里,那运维基本是给自己找罪受。Service这个抽象层就是来解决这件事的——它给你一个不变的访问入口,把底下不断变化的Pod集合动态编排起来,顺便把流量分发给多个后端副本。
这篇文章我会按自己平时实验的完整路径来写:先讲清楚为什么需要Service,再把ClusterIP、NodePort这些类型的差异和原理拆开,然后给你一套本地几分钟就能起来的实验环境,最后进入实操,从Deployment、Service YAML到负载均衡验证,一步一步来。这篇内容不只适合刚学K8S的人,也适合那些“照文档配好了但不知道为什么通”的朋友,因为我会把很多关键决策背后的原因一并讲透。
1. 为什么需要Service:Pod“住址”会变,访问必须有个稳定入口
1.1 Pod IP天生不稳定,直接访问Pod并不可靠
在Kubernetes里,Pod是调度和运行的最小单位,但它的网络身份非常“随风飘”。每次新建或重建一个Pod,集群IP池都会动态分配一个IP,这个IP只在集群网络内有效,离开集群就不可达。更麻烦的是,Deployment做滚动更新、故障重启、手动扩容缩容时,Pod副本会不断更换,旧的IP自动回收、新的IP重新分配。如果你在别的服务里写死了某个Pod的IP,只要一次重建,配置就彻底过期了。
这个道理很像你去一栋写字楼找人,如果只记住某个员工的工位号,他一旦换工位、离职或者临时出差,你就找不到了。更合理的做法是记一个前台总机号码,由前台帮你转接,你不需要关心对方现在坐在哪一层。
所以在K8S里,直接通过Pod IP访问后端,只适合排查问题时临时进入查看,不适合作为服务间调用的稳定入口。实验中你会频繁验证这一点,比如删除一个Pod再等它重建,它的IP一定变了,而Service的ClusterIP始终保持不变,这就是稳定入口的含义。
1.2 Service承担的职责:稳定入口、动态后端、负载均衡
Service的本质是一个抽象层,它不直接跑容器,也不占资源,只是一组规则加一个虚拟IP。当用户创建一个Service时,K8S会分配一个固定的ClusterIP,并生成对应的DNS记录。ClusterIP是虚拟的,不会绑定到某个具体网卡,而是由每个节点上的kube-proxy组件翻译成具体转发规则。
Service通过标签选择器(selector)来自动匹配一组Pod。你定义了selector: app=demo-web,那么所有带app=demo-web标签的Pod都会自动进入这个Service的后端名单。这个名单会被记录在Endpoints对象里,Pod新增、删除、灰度发布时,Endpoints会跟着实时变化,Service本身却完全不用改。
负载均衡则是由转发机制完成的。请求到达Service后,kube-proxy会把流量按规则转发到名单里的某个Pod。默认策略下,流量在不同Pod之间大致均匀分布,这刚好是我们实验要重点验证的内容:一个入口,多个后端,流量被分摊。
2. Service的四种类型怎么选:ClusterIP、NodePort、LoadBalancer、ExternalName
2.1 ClusterIP:集群内部的默认选择
K8S里如果创建Service时不写type,默认就是ClusterIP。它分配一个集群内部可达的虚拟IP,只能从集群内访问,外部网络和集群外机器都直连不到。这种类型特别适合微服务之间的内部调用,比如订单服务调用用户服务,两边都在集群内,不需要暴露到外部。
实验中最常见的问题是把ClusterIP当作公网IP去访问,那肯定不通。它的验证方式是用另一个临时Pod或业务Pod,在集群内部去请求这个IP或DNS名字。ClusterIP还有一个特点:它是虚拟的,你kubectl get svc能看到ClusterIP,但到节点上用ip addr看不到这个IP出现在任何网卡上,因为它只存在于kube-proxy的转发规则里。很多第一次做实验的人会到处找这个IP到底在哪,找不到很正常,它本来就不属于任何一张真实网卡。
2.2 NodePort:把端口开在每个节点上,外部访问最直接的入口
NodePort是在ClusterIP之上叠加的一层能力。创建Service时指定type: NodePort,K8S会从默认的30000-32767端口段里分配一个nodePort,并在每个工作节点上都监听这个端口。此时外部请求访问任意节点的这个端口,都会被kube-proxy接住,再通过Service转发到后端Pod。
这里有个容易被忽略的点:NodePort模式下,ClusterIP并不是被替代,而是依然存在、依然有效。两个入口都能访问服务,只是适用范围不同,ClusterIP只限集群内,NodePort则对集群外打开了一个明确的端口。端口的默认范围可以改,但一般不建议随便动,因为很多云平台安全组规则、防火墙策略都默认按这个段来配置。
NodePort适合本地实验、没有负载均衡器的裸机集群,以及临时需要把服务暴露出来调试的场景。生产环境用NodePort直接对用户开端口并不是最优解,但作为理解K8S服务暴露的基础实验,它是必练的一环。
2.3 LoadBalancer和ExternalName:云上入口与集群外服务映射
LoadBalancer一般用在公有云环境。云服务商提供的外部负载均衡器会拿到一个公网IP,流量先到公网入口,再分发到各节点的NodePort或直接进入集群,具体实现因云厂商而异。自建机房或本地实验环境没有云负载均衡能力,所以裸金属环境通常配合MetalLB这类方案来补上这个能力。
ExternalName则是完全不同的玩法,它不创建ClusterIP,也没有selector和Endpoints,而是把Service名字直接映射成一个外部域名。比如你想让集群内的服务通过某个稳定别名去访问外部系统,就可以用ExternalName。从机制上讲,这更像DNS层面的别名,不是流量转发。
另外经常有人把Ingress和Service类型混在一起。Ingress不是Service的第五种类型,它是一层七层反向代理入口,通常由Ingress Controller(比如Nginx Ingress)实现,对外提供域名、路径、TLS终结等能力,再把请求转发到集群内部的Service。所以Ingress是“站在Service前面的入口”,和Service本身并不冲突。我这个实验里先把Service基础打扎实,之后再玩Ingress会顺畅很多。
3. 本机五分钟搭一套K8S实验环境:工具选型与验证
3.1 选哪种工具:minikube更适合做Service实验
做Service实验不需要一套生产级高可用集群,你需要的是一个能快速启动、能敲kubectl命令、能看kube-proxy和Endpoints变化的环境。常见选择有minikube、kind、k3s、kubeadm。我的建议是本地实验用minikube,原因是它开箱即用,自带完善的组件管理,对Service和NodePort的语义模拟得和正规集群一致,而且一条命令就能启动多节点或单节点集群。
如果直接用kubeadm搭集群,虽然更接近生产,但前期的初始化、证书、网络插件配置会消耗大量时间,实验重点反而被冲淡。kind虽然启动快,但它的节点是容器模拟出来的,做NodePort这类网络实验时,访问体验和minikube有一点差异。所以这个实验我用minikube来演示。
启动集群前,确保本机已经装好Docker。启动命令如下:
minikube start --driver=docker --cpus=2 --memory=2048如果之前用过其他驱动,建议先清理一下再启动:
minikube delete minikube start --driver=docker等待几分钟,看到Done!字样,kubectl就能直接连上这个集群了。
3.2 验证集群状态:节点、核心组件、kube-proxy
集群起来后,第一件事是确认节点和核心组件都健康。
kubectl get nodes kubectl get pods -n kube-systemkubectl get nodes会显示一个Ready状态的节点。kubectl get pods -n kube-system能看到kube-proxy等系统组件是否处于Running状态。kube-proxy是Service转发链路里最关键的组件之一,如果它没起来,后面做的所有Service实验都会表现成“ClusterIP能创建但始终不通”。
验证完基础状态,建议再看一眼命名空间。默认情况下,我们创建的对象都会进入default命名空间,实验时不要搞混。命名空间也经常是Service连不通的隐藏原因之一,后面排查部分还会再提到。
3.3 准备一个能“报出自己名字”的测试镜像
做负载均衡实验最怕的情况是:后端是三份完全一样的Nginx,请求打到了谁身上完全看不出来。所以我建议换一个能返回自己主机名的容器镜像,这样每次请求返回的内容里都带着Pod名,谁在处理流量一目了然。
我实验里喜欢用hashicorp/http-echo这个轻量镜像,它启动后监听一个端口,把启动时传入的文本内容作为HTTP响应体。我们传入Pod名,响应体里就是“来自哪台Pod”的信息。它的默认端口是5678,配置YAML时targetPort要对应写5678。
如果你更喜欢用Nginx做后端也没问题,但验证负载均衡时得自定义返回页面,或者看nginx的访问日志,操作起来没那么直观。后面实验我直接用http-echo镜像,大家不用另外折腾。
4. 完整实验步骤:从Deployment到Service再到NodePort暴露
4.1 先创建后端工作负载:Deployment、标签、探针一个都不能少
Service要转发,首先得有一组后端Pod。我们创建一个叫demo-web的Deployment,副本数设为3,每个Pod都带app: demo-web标签。
apiVersion: apps/v1 kind: Deployment metadata: name: demo-web spec: replicas: 3 selector: matchLabels: app: demo-web template: metadata: labels: app: demo-web spec: containers: - name: http-echo image: hashicorp/http-echo args: - "-text=Hello from $(POD_NAME)" env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name ports: - containerPort: 5678 readinessProbe: httpGet: path: / port: 5678 initialDelaySeconds: 3 periodSeconds: 5保存为deploy.yaml,执行:
kubectl apply -f deploy.yaml kubectl get pods -o wide你看到三个Running状态的Pod,并且每个Pod都有自己的IP。注意观察标签,等下Service的selector要和app: demo-web保持一致。这里我给容器加了readinessProbe,它在Service转发中很重要:只有探针成功、Pod处于Ready状态时,Pod才会被纳入Endpoints列表。如果探针失败,K8S会自动把Pod从Service后端名单里摘掉,这在后面的故障排查里你就会明白为什么我会特意加它。
4.2 编写Service配置:selector、port、targetPort怎么填
后端Pod就绪后,创建第一个Service,用ClusterIP类型。下面是完整的YAML:
apiVersion: v1 kind: Service metadata: name: demo-svc spec: type: ClusterIP selector: app: demo-web ports: - port: 80 targetPort: 5678这里三个字段要特别讲清楚:
selector决定了Service会关联哪些Pod。它必须和后端Pod模板里的标签匹配,否则Endpoints会是空的,流量无处可转。
port是Service对外提供访问的端口,客户端访问的是这个端口。targetPort是后端Pod容器实际监听的端口,这里填5678,对应http-echo的默认监听端口。如果两者相同,targetPort甚至可以省略,但实验里明确写出来更方便理解。
protocol默认是TCP,如果没写就使用默认值。实验不需要UDP,所以保持默认就好。
执行创建:
kubectl apply -f svc.yaml kubectl get svc demo-svc kubectl get endpoints demo-svc理想状态下,kubectl get endpoints会列出三个IP和端口,每个IP对应一个后端Pod。这时Service已经具备负载均衡能力了。
4.3 在集群内部验证负载均衡:多副本轮流响应
ClusterIP只能在集群内部访问,所以验证时需要起一个临时客户端Pod。我习惯用curlimages/curl这个镜像,因为它自带curl,退出时自动清理Pod也很干净。
kubectl run -it --rm debug --image=curlimages/curl -- sh进入容器后,向Service发起多次请求:
for i in $(seq 1 10); do curl -s http://demo-svc/; echo; done如果实验顺利,你会看到返回内容里的Pod名在三个Pod之间轮换出现。这说明请求到达Service后,被分发到了不同的后端Pod,负载均衡生效了。
也可以用集群内部的DNS名字访问:http://demo-svc.default.svc.cluster.local。Service创建后,集群DNS会自动生成这条记录,集群内任意Pod都能通过服务名直接访问。实验中你可以对比一下,直接访问Pod IP和访问Service的区别——Pod IP请求始终返回同一个Pod,Service则会在多个Pod之间切换。
退出临时客户端后,清理一下:
exit4.4 把Service升级为NodePort,打通外部访问
ClusterIP验证通过后,接下来做NodePort实验。有两种改法:一种是直接kubectl edit svc demo-svc,另一种是修改YAML后重新apply。我推荐用修改YAML的方式,路径清晰、可回放。
apiVersion: v1 kind: Service metadata: name: demo-svc spec: type: NodePort selector: app: demo-web ports: - port: 80 targetPort: 5678 nodePort: 30080这里多了一个nodePort: 30080。如果不指定,K8S会自动随机分配一个30000-32767范围内的端口。手动指定便于实验记忆,但要注意范围必须落在默认段内,否则会被API Server拒绝。
重新应用:
kubectl apply -f svc.yaml kubectl get svc demo-svc这次输出的PORT(S)列会显示80:30080/TCP,代表ClusterIP 80端口和节点上的30080端口都可用。外部访问时,先拿到minikube节点IP:
minikube ip然后用curl访问:
curl http://$(minikube ip):30080/同样能看到后端Pod名在轮换。此时你从集群外部通过节点的IP加端口,就成功访问到了集群内部的Service,再经由Service转发到Pod。NodePort的关键特征是:请求访问的是节点端口,但真正处理流量的是后端Pod,中间跨了两个转发环节。
5. 深入看负载均衡:kube-proxy、Endpoints与externalTrafficPolicy
5.1 kube-proxy的iptables模式与IPVS模式,流量到底怎么分发
Service的虚拟IP之所以能访问,全靠kube-proxy在每个节点上写入转发规则。kube-proxy最常见的两种工作模式是iptables和IPVS。
iptables模式是多数K8S发行版的默认选择。它的做法是:当请求访问ClusterIP:Port时,命中一条DNAT规则,iptables再用statistic模块的random算法从Endpoints列表里挑一个后端做转发。这个模式实现简单、兼容性好,但集群规模很大、Service数量很多时,iptables规则会变得又长又多,更新时有明显的性能开销。
IPVS模式则使用了Linux内核自带的IP虚拟服务器能力,它把Service的转发规则转换成IPVS虚拟服务器,后端Pod作为真实服务器。IPVS天然支持多种调度算法,比如轮询rr、最少连接lc,性能远好于iptables。如果你有三台以上后端Pod,用IPVS的rr算法做负载均衡实验,看到的结果会更均匀。
minikube可以用如下参数开启IPVS模式:
minikube start --driver=docker --extra-config=kube-proxy.proxy-mode=ipvs启动后进入节点查看规则:
minikube ssh sudo ipvsadm -Ln在输出里你能看到K8S的Service对应一条虚拟服务器记录,后端是三个真实Pod IP。这个可视化结果对理解负载均衡非常有帮助。如果当前是iptables模式,也可以用sudo iptables-save | grep demo-svc来观察规则,但可读性差一些。
5.2 Endpoints与Readiness探针:后端名单的动态维护机制
Service之所以能自动适应后端变化,是因为K8S内部有一整套联动机制。Service通过selector匹配Pod,匹配结果写入Endpoints对象。当Pod被创建、标签变化、删除或者Pod状态不是Ready时,Endpoints都会自动更新。
Readiness探针在这个机制里的作用很关键。一个Pod如果通过了readinessProbe,K8S就认为它“可以接收流量”,它才会出现在Endpoints列表里。你可以做个实验来观察:
kubectl edit deployment demo-web把readinessProbe里的路径改成一个不存在的路径,比如/healthz-fail。等待几秒后查看Endpoints:
kubectl get endpoints demo-svc你会发现Endpoints里的IP数量减少了,甚至变成空列表。Pod虽然还在Running,但已经被从Service后端名单中摘除。这个行为在生产环境特别重要:服务发版时,新Pod如果还没就绪,流量不会打过去;崩溃时,探针失败后会自动摘除,避免请求打到坏Pod上。
5.3 externalTrafficPolicy:Cluster和Local对来源IP的影响
Service还有一个参数叫externalTrafficPolicy,默认值是Cluster。它控制的是NodePort或LoadBalancer类型的Service收到外部流量后,是否可以在节点之间二次跳转。
Cluster模式下,任意节点的NodePort收到请求,都可以把流量转发给集群内任意节点的后端Pod。这样做的好处是负载更均衡,坏处是节点转发时会做SNAT,客户端源IP会被替换掉,后端Pod看到的源地址不是真实客户端。
Local模式下,节点收到请求后只会转发给本节点上的Pod,不跨节点转发。好处是保留了客户端源IP,坏处是如果一个节点上没有Pod副本,这个节点上的NodePort就无法正常提供服务。生产环境使用Local模式时,通常要配合外部负载均衡器的健康检查,让没有后端Pod的节点自动被摘掉。
我在实验中会做一次对比:把externalTrafficPolicy改为Local,然后访问没有Pod所在节点的NodePort,你会观察到连接失败;访问有Pod的节点则正常。这个实验能帮你理解为什么服务暴露不只是一个“配置下就完事”的动作,它和节点拓扑、来源IP、负载均衡策略都强相关。
6. Service排查实录:没有Endpoints怎么办、端口不通从哪查
6.1 Service没有Endpoints:按这个顺序查准没错
这是Service实验里最经典的故障:Service创建好了,IP也有,但访问总是不通。此时第一件事是看Endpoints:
kubectl get endpoints demo-svc如果输出为空,说明Service没有匹配到任何Pod。排查顺序我建议固定下来:先用kubectl get pods --show-labels查看实际Pod的标签,再对照Service里spec.selector的键值。常见错误是selector里写成app: demo,但Pod标签是app: demo-web,看起来很像,实际上完全不匹配。
第二个容易踩坑的地方是命名空间。Service只会在同命名空间内做selector匹配,如果你把Service建在default,Pod却建在dev,Endpoints一样是空的。第三种情况是targetPort写错,比如容器监听5678,Service写成80,虽然Endpoints里有IP,但请求会连接失败。
最后还可以用一条命令快速看Service的事件:
kubectl describe svc demo-svc这里能显示标签选择器、Endpoints列表以及最近事件,基本能定位大多数问题。
6.2 NodePort端口连不通:范围、防火墙、kube-proxy三件套
外部访问NodePort不通时,先确认nodePort落没落在30000-32767区间。如果端口写小了,比如8080,API Server创建Service时就会直接报错。其次检查云安全组或本机防火墙是否放行了对应的端口段,本地实验经常是防火墙挡住了外部访问。
再看节点上端口是否真的在监听:
minikube ssh sudo ss -lntp | grep 30080如果监听存在,说明kube-proxy规则已经生效;如果监听不存在,重点检查kube-proxy Pod状态:
kubectl get pods -n kube-system | grep kube-proxykube-proxy异常时,NodePort和ClusterIP都会表现异常,这是Service转发链路的主心骨。还有一类隐蔽问题:如果Service的externalTrafficPolicy是Local,你访问的节点上刚好没有后端Pod,连接会被直接丢弃。这个我已经在上面的实验里提到过,排查时千万别忽略。
6.3 负载结果“偏科”:一个Pod扛了大量流量,这是什么原因
有时候实验会发现,10次请求里有7次打在同一个Pod上,看起来完全不均衡。这不一定代表配置错误,可能是这些原因:
第一,客户端复用HTTP连接。curl客户端的连接池如果复用同一条长连接,反复请求时TCP连接本身没断,负载均衡规则只在新建连接时生效,自然每次都命中同一个后端。用curl -v查看,或者每次调用后主动关闭连接就能复现这种差异。
第二,iptables模式下的随机策略是概率性分布,短连接数量少的时候,个别Pod被多分配几次很正常。如果拉了100次请求还是明显偏斜,才需要考虑后端Pod本身是否节点分布不均、某台节点资源被打满,或者某个Pod处于Terminating状态。
第三,如果开了IPVS模式,要看调度算法是否设置为了rr。如果容器重启过、Pod被重新创建,IPVS列表更新不及时也会出现短暂偏斜。
排查时建议结合以下命令来交叉验证:
kubectl get endpoints demo-svc kubectl get pods -o wide -l app=demo-web kubectl logs demo-web-xxxx-xxxx看请求日志落在哪些Pod上,再比对这些Pod是否都处于Ready状态。
6.4 修改Service类型后客户端还在访问旧地址,如何确认
实验里经常遇到“改了Service类型但好像没生效”的困惑。其实K8S里Service配置变更后,转发规则是立即更新的。但客户端侧可能存在DNS缓存或连接复用,导致看起来仍然访问的是旧入口。
如果你在临时Pod里做验证,建议用nslookup demo-svc或dig demo-svc确认解析到的ClusterIP是否和kubectl get svc一致。集群内部的DNS记录由CoreDNS管理,默认有一定的缓存时间。如果强依赖最终一致性,也可以直接删除旧Service再创建同一名字的Service,ClusterIP会重新分配,DNS记录会马上更新。
还有一个隐藏细节:修改Service的type后,ClusterIP本身不会变。这意味着集群内通过ClusterIP访问的客户端不会感知到任何变化,只是对外多了一个NodePort入口。如果实验里发现“外部能访问,内部反而访问失败”,那基本要回到selector和Endpoints层面去查,而不是怀疑Service类型改错了。
写在最后的一点个人习惯
做了这么多次Service相关实验,我自己最深的体会是:别急着把YAML堆出来就收工。每次创建Service后,先花十秒钟看kubectl get endpoints,确认后端名单符合预期;每改一次类型或探针配置,再看一遍Endpoints是否跟着联动变化。K8S的网络抽象虽然看起来就是几个字段,但等你逐步验证了kube-proxy转发、IPVS调度、Readiness摘除这些机制之后,再去排查生产故障,就会明显从容很多。
另外还有一个实用小技巧:实验里如果不想每次手动进入临时Pod测试,可以一条命令解决:
kubectl run -it --rm --image=curlimages/curl --restart=Never test-$(date +%s) -- sh -c "for i in $(seq 1 10); do curl -s http://demo-svc/; echo; done"这条命令用完即走,不会在集群里留一堆测试Pod。以后你做Ingress、Headless Service或者更复杂的流量治理实验时,这套验证方法也能直接复用,延长不少调试效率。