K8s Service 核心类型实验:ClusterIP、NodePort、LoadBalancer 与 Ingress 实践指南
后端服务上了 K8s 之后最绕不开的一个点就是 Service。每次部署一批 PodIP 都是一次性的重建之后地址就变了前端总不能追着 IP 改配置吧大家嘴上说的服务发现负载均衡集群内外网访问实际操作起来全是围绕 Service 这一层做文章。所以这次我专门搭了一套测试环境把 Service 的核心类型从头到尾过了一遍从 ClusterIP、NodePort、LoadBalancer 到 Ingress验证各种暴露方式的行为差异也踩了几个实打实的坑。这篇内容就是整个实验过程的完整记录适合刚接触 K8s Service 的运维和开发同学也适合想彻底搞懂转发细节、准备排查问题的人。1. 实验目标与整体思路拆解1.1 先聊痛点Pod IP 是活的一个 Deployment 背后挂了三个副本每个 Pod 都有自己的 IP。Pod 重建后IP 会变化哪怕用 StatefulSet 保住了网络标识副本扩容缩容同样会造成端点集合漂移。如果客户端直连 Pod IP一旦 Pod 被调度到别的节点、或者被杀掉了连接就断了这就是经典的 Pod IP 不稳定问题。更尴尬的是K8s 集群内部的 DNS 只会记录 Service 域名不会给每个 Pod 单独做稳定域名Headless Service 除外。所以应用之间的调用不能依赖 Pod IP而是要依赖一个稳定的访问入口。这个入口就是 Service。1.2 Service 介入之后发生了什么Service 本身不跑任何进程不管理 Pod它更像是一组转发规则的定义。它有一个虚拟 IPClusterIP这个 IP 是集群内部稳定的虚拟地址。客户端访问这个 VIP流量会按规则转发到后端的某个 Pod 上。实际动手时你会发现Service 还有一个配套的组件叫 Endpoints或者新版里的 EndpointSlice它负责维护 Service 和后端 Pod IP 列表之间的映射关系。只要 Pod 的标签匹配上了 Service 的 SelectorEndpoints 就会自动把新 Pod 的 IP 加进来销毁的 Pod 自动剔除。这个机制保证了后端列表永远是最新的也是 Service 能实现服务发现和负载均衡的基础。1.3 实验环境搭建方案这次实验用的是三节点集群一个控制节点两个工作节点版本在 1.28 左右。网络组件用了 FlannelDNS 用的 CoreDNS。为了后面实验干净我单独建了一个测试命名空间demo-svc所有资源都丢在里面。需要提前准备好几个镜像一个是用来测试的 nginx方便看返回的页面内容另一个是简单的 HTTP 响应服务能回显 Pod 名称和宿主机名称。后面验证负载均衡时从 Pod 返回的 hostname 就能看出请求落在哪个副本上不用额外抓包非常直观。实验整体分四步走先部署多副本后端应用再依次创建 ClusterIP、NodePort、LoadBalancer 三种 Service然后用 Ingress 把 HTTP 路由打通期间记录访问结果并做故障注入顺带验证健康检查失效时流量会不会被摘除。2. Service 类型选型先别急着写 YAML2.1 四种基础类型的定位与适用场景K8s Service 有四种基础类型ClusterIP、NodePort、LoadBalancer、ExternalName。新手最容易犯的错误是上来就写 NodePort觉得端口暴露了就万事大吉其实得先想清楚使用者是谁。ClusterIP 是默认类型只在集群内部可达适合服务间的内部调用NodePort 在 ClusterIP 基础上多了一层宿主机端口映射集群外部通过节点 IP 端口就能访问适合测试环境或者没有云负载均衡能力的场景LoadBalancer 需要云厂商或者自建负载均衡器配合由外部负载均衡器把流量导入节点端口适合生产环境对外服务ExternalName 比较特殊它不创建 VIP而是把 Service 名映射到集群外部的 DNS 域名适合做跨集群或者指向老系统的服务抽象。四种类型并不是互相排斥的NodePort 和 LoadBalancer 底层都会先创建 ClusterIP。往生产环境做的时候一般线上用 LoadBalancer测试环境用 NodePort纯内部调用用 ClusterIP。云环境里如果你不看 Prompt 就乱建很容易浪费一堆公网 IP。2.2 Selector、Endpoints 与自动关联机制Service 是怎么知道要转发给谁靠 Selector 和 Endpoints。你创建一个 Service里面写了selector: app: demo-app控制器就会去查找当前命名空间里带有这个标签的 Pod把它们各自 IP 和端口收集起来生成 Endpoints 对象。我这里手动模拟一下这个过程更容易理解。创建一个 Deployment三个副本标签是appweb-demoPod 监听 8080 端口。接着写 ServiceapiVersion: v1 kind: Service metadata: name: web-svc namespace: demo-svc spec: selector: app: web-demo ports: - protocol: TCP port: 80 targetPort: 8080创建完 Service 后查看 Endpointskubectl -n demo-svc get endpoints web-svc如果输出类似NAME ENDPOINTS AGE web-svc 10.244.1.11:8080,10.244.2.15:8080,10.244.2.18:8080 12s说明自动关联成功。如果ENDPOINTS显示none基本就是标签没匹配上或者 Pod 没有就绪。这一点在排查时非常关键因为很多人容易漏看 Endpoints。2.3 kube-proxy 的三种转发模式对比Service 的 VIP 和负载均衡规则最终由 kube-proxy 组件落实。早期版本有 userspace 模式性能太差已经被淘汰现在主流是 iptables 模式和 IPVS 模式。iptables 模式通过创建一堆链来实现 DNAT访问 VIP 时流量被改写到后端 Pod IP。每条规则的匹配是从上到下顺序遍历规则多了以后性能下降明显而且当某个后端 Pod 不可用时iptables 规则不会自动剔除连接会不断重试直到超时。IPVS 模式是后来推荐的方案它利用了 Linux 内核的 IPVS 模块在数据面做调度支持 rr、wrr、lc、sh 等算法规则数量翻了十倍也能保持不错性能。唯一需要注意的是不同节点上的 kube-proxy 都要开启--proxy-modeipvs并且要确认内核加载了相关模块。我这次实验先把 kube-proxy 切到了 IPVS因为后面要验证负载均衡算法IPVS 的调度行为更直观。查看节点上 IPVS 规则ipvsadm -L -n输出里能看到TCP 10.96.0.10:80 rr这样的调度项说明 Service 的 VIP 已经被 IPVS 接管。2.4 负载均衡算法与会话保持细节IPVS 的默认调度算法是 rr 轮询。对无状态服务来说轮询最省心但如果后端有会话状态或者客户端希望尽量落在同一台 Pod 上就需要调整会话保持策略。Service 层支持sessionAffinity: ClientIP意思是基于客户端 IP 做会话保持。设置之后同一个来源 IP 的请求会始终被转发到同一个后端 Pod直到 Pod 发生变化。这个能力在 iptables 模式里实现方式是ip hash在 IPVS 模式里是 sh 调度算法。不过这里有个坑要讲清楚如果用户在客户端和环境之间有 NAT比如办公网络统一出口 IP那所有员工的请求都会被当成一个客户端负载均衡直接失效。生产环境里尽量依赖应用层的会话机制比如 Redis 共享 Session而不要在 Service 层做 ClientIP 保持除非你确实明白它在 NAT 下的副作用。3. 实操从 Deployment 到三种 Service 逐一验证3.1 搭建实验环境命名空间与基础资源在开始之前先准备好命名空间kubectl create namespace demo-svc后面所有资源都会指定-n demo-svc。干净命名空间有好处测试完了直接kubectl delete namespace demo-svc就能清理掉一组关联资源不用一个一个查。实验用的后端镜像我直接用 nginx 改了个默认首页因为 nginx 默认访问页面是 80 端口后面 Service 的 targetPort 写 80 更直观。如果你想在返回内容里看到 Pod hostname可以参考这个简化写法kubectl -n demo-svc create deployment web-demo --imagenginx:1.27-alpine --replicas3不过为了模拟真实的后端服务我更推荐用一个小容器里面跑 python http.server返回 hostname。如果你手头有现成的测试镜像目标端口按自己的来就行。3.2 部署多副本后端应用实际项目中我一般会先把 Deployment 写好而不是用命令行的kubectl create。YAML 可以进版本库也能控制更多参数。这里给出一个带健康检查的版本apiVersion: apps/v1 kind: Deployment metadata: name: web-demo namespace: demo-svc spec: replicas: 3 selector: matchLabels: app: web-demo template: metadata: labels: app: web-demo spec: containers: - name: web image: nginx:1.27-alpine ports: - containerPort: 80 readinessProbe: httpGet: path: /healthz port: 80 initialDelaySeconds: 3 periodSeconds: 5注意 readinessProbe 的探测路径写的是/healthz但 nginx 默认没有这个路径直接拿这个 YAML 上去会一直不 Ready。这里我故意留一个坑到第四章排查部分再反过来讲。实际部署时你要么把路径改成/要么在 nginx 里配一个 healthz 页面。部署完成之后确认三个副本都处于 Running 状态kubectl -n demo-svc get pods -o wide输出类似NAME READY STATUS RESTARTS AGE IP NODE web-demo-58bc7b46f6-7z4p1 1/1 Running 0 35s 10.244.1.11 node-1 web-demo-58bc7b46f6-d2mxm 1/1 Running 0 35s 10.244.2.15 node-2 web-demo-58bc7b46f6-kqzw8 1/1 Running 0 35s 10.244.2.18 node-23.3 创建 ClusterIP Service 并验证集群内负载均衡后端跑起来了先创建 ClusterIP 类型的 ServiceYAML 上面已经写过。创建后检查 Servicekubectl -n demo-svc get svc web-svc能看到CLUSTER-IP一栏比如 10.96.1.15。这个 IP 是虚拟的并不存在于任何节点的网卡上。再进入集群里一个测试容器kubectl -n demo-svc run test-client --imagenginx:1.27-alpine --rm -it -- /bin/sh进入容器后连续请求while true; do curl -s http://web-svc/; echo; sleep 1; done如果后端首页能返回内容就说明 ClusterIP 转发成功。要验证负载均衡最好让后端返回本机 hostname这样你能看到每次响应的内容在不同 Pod 之间轮换。我实验时为了让效果明显直接在 Deployment 里挂了个 sidecar 写 response header返回 Pod 名。如果没有这个条件也可以从 nginx 日志里看kubectl -n demo-svc logs -l appweb-demo -f然后多请求几次观察访问日志来自哪个 Pod。3.4 NodePort Service 实现宿主机端口暴露ClusterIP 只能在集群内访问要集群外访问改一下 Service 类型apiVersion: v1 kind: Service metadata: name: web-svc-nodeport namespace: demo-svc spec: type: NodePort selector: app: web-demo ports: - port: 80 targetPort: 80 nodePort: 30080nodePort 字段如果不写K8s 会从 30000-32767 里随机分配一个。我实验时固定用 30080方便自己在防火墙上开端口写文档也好描述。创建后kubectl 会看到PORT(S)列变成80:30080/TCP。集群外访问时用任意节点的 IP 加 30080 端口curl http://192.168.1.101:30080/你会发现一个问题只要这个节点上的 kube-proxy 正常就算 Pod 没调度到这台节点流量也会被转发到别的节点的 Pod 上。这就是 Service 跨节点转发的能力。但同时也意味着 NodePort 会在每个节点上都监听这个端口安全组只需要放行对应端口即可。NodePort 有个实际使用中的注意点如果集群有多个节点客户端访问不同节点 IP 拿到结果都一样但你没法保证每次都能轮询到不同后端。因为从节点进来后kube-proxy 的转发规则决定的是从当前节点发往 Pod如果你想实现节点级别的负载均衡就需要在服务前面再加一层负载均衡器这也是下一节要演示的 LoadBalancer 存在的意义。3.5 LoadBalancer 与 Ingress模拟云负载均衡LoadBalancer 类型在裸机集群上其实不能直接分配外部 IP除非装了 MetalLB 这类组件。为了实验方便我用 MetalLB 模拟如果你用的是云托管集群创建 LoadBalancer Service 后云平台会自动分配一个公网 IP 或内网 SLB 地址。YAML 很简单apiVersion: v1 kind: Service metadata: name: web-svc-lb namespace: demo-svc spec: type: LoadBalancer selector: app: web-demo ports: - port: 80 targetPort: 80创建后kubectl get svc的EXTERNAL-IP一栏如果是pending说明集群里没有可用的负载均衡器。装上 MetalLB 后它会从配置的地址池里分配一个 IP并自动映射到节点端口。这里要明确一点LoadBalancer 服务本质上还是 NodePort只是多了外部负载均衡器的分配动作。MetalLB 这类实现会把外部的虚拟 IP 广播到网络上流量到达节点后依然走 NodePort 的逻辑。如果说 Service 是四层负载均衡Ingress 就是七层 HTTP 路由。Ingress 本身不是一种 Service 类型它需要配合 Ingress Controller 使用。Controller 会读取 Ingress 规则把域名和路径映射转发给后端的 Service。我实验里用的 Ingress 配置apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: web-ingress namespace: demo-svc spec: rules: - host: demo.example.local http: paths: - path: / pathType: Prefix backend: service: name: web-svc port: number: 80部署好 Ingress Controller 后把域名解析到 Ingress Service 的对外 IP访问http://demo.example.local就能看到后端页面。4. 遇到过的坑与排查实录4.1 集群内 DNS 解析失败的三种可能从测试容器里访问http://web-svc最怕报could not resolve host。此时先查 Service 名有没有拼错再查是不是跨了命名空间。K8s 的 DNS 命名规则是svc名.命名空间.svc.cluster.local如果你在别的命名空间访问要写全名curl http://web-svc.demo-svc.svc.cluster.local如果全名也解析不了大概率是 CoreDNS 的问题。检查 CoreDNS Pod 是否正常kubectl -n kube-system get pods -l k8s-appkube-dns还有一种情况是集群节点的 DNS 策略问题。有些自定义的 Pod 设置了dnsPolicy: Default它就会读宿主机的/etc/resolv.conf自然解析不到集群内部域名。这种情况把dnsPolicy改为ClusterFirst就好。4.2 NodePort 能访问但 ClusterIP 不通的排查路径这个比较有意思。集群外访问节点IP:30080是通的但集群内访问 ClusterIP 超时说明问题出在 Service 到 Pod 这一段和 NodePort 映射无关。先看 Endpoints 是否正常kubectl -n demo-svc get endpoints web-svc-nodeport如果 endpoints 里有 IP再手工抓一下规则iptables -t nat -L KUBE-SERVICES -n常见原因有两个一是 kube-proxy 没在跑或者 Pod 和 kube-proxy 不在同一个节点二是节点上有安全组规则丢弃了 ClusterIP 网段的包。后者在云环境里特别容易出现因为部分安全组默认会丢弃源/目标为虚拟网段的流量。最粗暴快速的排查工具是直接在节点上用nsenter或者tcpdump抓包。抓包前你需要知道 ClusterIP 对应的后端 Pod IP然后tcpdump -i cni0 -n host 10.244.2.15如果只有 SYN 包发出但没有回包基本是 Pod 网络或者宿主机 IPVS 规则放行有问题如果能看到回包再查客户端侧的路由。4.3 健康检查引发的流量中断这是我在实验里埋下的那个坑。丫 YAML 里 readinessProbe 写的是/healthz但 nginx 没有这个路径Pod 一直处于NotReady状态Endpoints 列表会是空的。这样 Service 转发不到任何 Pod访问直接 connection refused。解决思路不是把 readinessProbe 删掉而是确保探测路径正确。真实场景里一个服务启动时需要时间加载配置readinessProbe 的作用就是告诉 K8s我这个 Pod 现在能接流量了它返回非 200流量就不会放进去。这一点尤其重要很多生产事故就是 readiness 探针配置得太宽松或者太严格导致的。比如把启动探针和就绪探针混用服务依赖数据库还没就绪readiness 一直失败流量全挂。我后来把路径改成了 nginx 自带的/index.html副本恢复 ReadyEndpoints 也自动更新web-svc 10.244.1.11:80,10.244.2.15:80,10.244.2.18:80 5s4.4 排查工具与技巧速查场景核心命令说明看 Service 暴露端口kubectl -n demo-svc get svc -o wide查看 ClusterIP、NodePort、外部 IP看后端 Pod 是否 Readykubectl -n demo-svc get podsReady 列是 1/1 才行看 Endpoints 关联kubectl -n demo-svc get endpoints web-svc空列表基本就是 Selector 写错看 DNS 解析nslookup web-svc.demo-svc.svc.cluster.local从测试容器内执行看节点转发规则ipvsadm -L -nkube-proxy 使用 IPVS 模式时生效看 Service 事件kubectl -n demo-svc describe svc web-svc有些异常会有 Event抓包确认流量走向tcpdump -i cni0 -n port 80在相关节点上执行还有一个技巧在排查问题时可以在集群任意节点上用curl访问http://ClusterIP:Port如果通了说明转发链路没问题问题大概率在集群外部的网络链路或者 DNS。5. 进阶玩法无头服务与外部服务抽象5.1 Headless Service把 DNS 变成真实 IP 列表普通 Service 的 DNS 解析返回的是 VIP而 Headless Service 不设置 ClusterIPclusterIP: NoneDNS 解析会直接返回所有后端 Pod 的真实 IP 列表。这个特性适合那些需要直连 Pod 的场景比如数据库主从、消息队列的节点发现。创建一个 Headless ServiceapiVersion: v1 kind: Service metadata: name: web-svc-headless namespace: demo-svc spec: clusterIP: None selector: app: web-demo ports: - port: 80 targetPort: 80创建后在测试容器里执行nslookup web-svc-headless.demo-svc.svc.cluster.local会看到返回三个 A 记录对应三个 Pod 的 IP。这就是把服务发现从稳定 VIP换成了真实端点列表。需要注意的是Headless Service 不会做负载均衡拿到 IP 列表后怎么用是你自己的事。一般配合 StatefulSet 使用因为 StatefulSet 的每个 Pod 有稳定 hostnameHeadless Service 能为每个 Pod 创建独立的 DNS 记录比如pod名.服务名.命名空间.svc.cluster.local。5.2 ExternalName直接指向集群外域名还有一类 Service 叫 ExternalName它没有 Selector也不定义端口而是直接指定一个外部 DNS 名字。访问集群内的这个 Service 时DNS 返回的就是外部域名解析结果相当于给外部服务起了一个集群内别名。想搞清楚这个的意义可以想象你在做系统迁移旧系统在集群外新系统在集群内两边接口都想保持一致或者希望应用代码里只写内部 Service 名。用 ExternalName 就能把内部调用无缝切到外部老系统等新系统完全替掉老系统再直接改 Service 的 type 和 Selector代码不用动。配置例子apiVersion: v1 kind: Service metadata: name: legacy-svc namespace: demo-svc spec: type: ExternalName externalName: api.legacy.example.com此时kubectl get svc legacy-svc的EXTERNAL-IP列会显示api.legacy.example.com。如果你需要更多的端口控制还是用普通 Service 手动指定 Endpoints 比较稳妥。5.3 Service 与网络策略的联动Service 解决了服务发现和转发但不解决访问控制。默认情况下K8s 集群内任意 Pod 都能访问任意 Service。如果你希望只允许某个命名空间的 Pod 访问 web-svc要配 NetworkPolicy。这并不复杂但很多人会忽略。NetworkPolicy 匹配 Pod 和来源然后放行或拒绝。一个简单的策略apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: web-svc-allow-demo namespace: demo-svc spec: podSelector: matchLabels: app: web-demo policyTypes: - Ingress ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: demo-client这条策略表示web-demo这个后端 Pod 只允许来自demo-client命名空间的访问其他命名空间全部拒绝。加了策略之后再去别的命名空间访问这个 Service连接会被直接 drop。这也是 Service 实验里容易被忽略的一环网络策略和 Service 没有直接绑定关系却对访问效果有决定性影响。实际操作里我把这条策略加到集群里然后从默认命名空间启动一个 curl 容器请求http://web-svc.demo-svc.svc.cluster.local发现一直超时而启动在demo-client命名空间里的容器访问则正常。这说明访问控制是在 Pod 网络层生效的不是 Service 层。最后再分享一个自己的习惯每次创建 Service 之后先不要想当然猜转发逻辑先动手查一遍 Service、Endpoints、Pod 三者是否对齐。很多问题看起来复杂核心就是这三者的匹配关系出了差错。把这个思路固化成排查肌肉记忆比记一百条命令都好使。