后端服务上了 K8s 之后,最绕不开的一个点就是 Service。每次部署一批 Pod,IP 都是"一次性"的,重建之后地址就变了,前端总不能追着 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,它更像是一组转发规则的定义。它有一个虚拟 IP(ClusterIP),这个 IP 是集群内部稳定的虚拟地址。客户端访问这个 VIP,流量会按规则转发到后端的某个 Pod 上。
实际动手时你会发现,Service 还有一个配套的组件叫 Endpoints(或者新版里的 EndpointSlice),它负责维护 Service 和后端 Pod IP 列表之间的映射关系。只要 Pod 的标签匹配上了 Service 的 Selector,Endpoints 就会自动把新 Pod 的 IP 加进来,销毁的 Pod 自动剔除。这个机制保证了"后端列表永远是最新的",也是 Service 能实现服务发现和负载均衡的基础。
1.3 实验环境搭建方案
这次实验用的是三节点集群,一个控制节点,两个工作节点,版本在 1.28 左右。网络组件用了 Flannel,DNS 用的 CoreDNS。为了后面实验干净,我单独建了一个测试命名空间demo-svc,所有资源都丢在里面。
需要提前准备好几个镜像,一个是用来测试的 nginx(方便看返回的页面内容),另一个是简单的 HTTP 响应服务,能回显 Pod 名称和宿主机名称。后面验证负载均衡时,从 Pod 返回的 hostname 就能看出请求落在哪个副本上,不用额外抓包,非常直观。
实验整体分四步走:先部署多副本后端应用,再依次创建 ClusterIP、NodePort、LoadBalancer 三种 Service,然后用 Ingress 把 HTTP 路由打通,期间记录访问结果并做故障注入,顺带验证健康检查失效时流量会不会被摘除。
2. Service 类型选型:先别急着写 YAML
2.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,三个副本,标签是app=web-demo,Pod 监听 8080 端口。接着写 Service:
apiVersion: v1 kind: Service metadata: name: web-svc namespace: demo-svc spec: selector: app: web-demo ports: - protocol: TCP port: 80 targetPort: 8080创建完 Service 后,查看 Endpoints:
kubectl -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-mode=ipvs,并且要确认内核加载了相关模块。
我这次实验先把 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 --image=nginx:1.27-alpine --replicas=3不过为了模拟真实的后端服务,我更推荐用一个小容器,里面跑 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 类型的 Service,YAML 上面已经写过。创建后检查 Service:
kubectl -n demo-svc get svc web-svc能看到CLUSTER-IP一栏,比如 10.96.1.15。这个 IP 是虚拟的,并不存在于任何节点的网卡上。再进入集群里一个测试容器:
kubectl -n demo-svc run test-client --image=nginx: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 app=web-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-app=kube-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,副本恢复 Ready,Endpoints 也自动更新:
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 是否 Ready | kubectl -n demo-svc get pods | Ready 列是 1/1 才行 |
| 看 Endpoints 关联 | kubectl -n demo-svc get endpoints web-svc | 空列表基本就是 Selector 写错 |
| 看 DNS 解析 | nslookup web-svc.demo-svc.svc.cluster.local | 从测试容器内执行 |
| 看节点转发规则 | ipvsadm -L -n | kube-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 不设置 ClusterIP(clusterIP: None),DNS 解析会直接返回所有后端 Pod 的真实 IP 列表。这个特性适合那些需要直连 Pod 的场景,比如数据库主从、消息队列的节点发现。
创建一个 Headless Service:
apiVersion: 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 有稳定 hostname,Headless 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 三者是否对齐。很多问题看起来复杂,核心就是这三者的匹配关系出了差错。把这个思路固化成排查肌肉记忆,比记一百条命令都好使。