Service 与流量管理
上一篇讲了 Deployment、StatefulSet 这些工作负载怎么把 Pod 跑起来,但 Pod 跑起来之后还有一个问题没解决:别人怎么访问它。Pod 的 IP 是动态分配的,重建、扩缩容之后全变,直接拿 Pod IP 通信根本不可靠。这篇讲 K8s 给出的答案:Service 提供固定入口,EndpointSlice 管流量摘挂,Ingress / Gateway API 接外部流量,CoreDNS 负责服务发现。
Service
为什么需要 Service
Pod 有几个天然的特点,决定了它不适合被直接访问:
- IP 会变:Pod 删除重建、节点漂移、滚动更新之后,IP 都会重新分配。
- 数量会变:扩缩容时 Pod 从 3 个变 5 个再变回 3 个,靠人工维护一份 IP 列表不现实。
- 多副本要分摊流量:就算拿到了所有 Pod IP,负载均衡也得自己做。
Service 就是为解决这个问题而生的抽象:给一组 Pod 一个固定的访问入口。它有一个稳定的名字和虚拟 IP,客户端只管访问 Service;Service 按标签选择器找到背后的 Pod,把请求分发过去。Pod 怎么变,Service 的入口不变。
虚拟 IP 与 kube-proxy
Service 创建后会被分配一个 ClusterIP(虚拟 IP,VIP)。这个 IP 不绑在任何网卡上,也 ping 不通——它只存在于每个节点上 kube-proxy 维护的转发规则里。
kube-proxy 运行在每个节点上,watch API Server 中 Service 和 Endpoint 的变化,然后把规则写进本机内核(默认用 iptables,也可以切换成 IPVS)。一次访问的过程是:
- 集群内某个客户端请求 Service 的 ClusterIP。
- 请求到达本节点的网络栈,命中 kube-proxy 写入的规则。
- 规则把目的地址改写(DNAT)成某个后端 Pod 的 IP,转发过去。
所以 Service 的负载均衡是在内核态、每个节点本地完成的,不经过任何中心化的代理进程,转发开销很小。
iptables 模式下规则是一条长链表,Service 数量成千上万时匹配开销会变大;IPVS 模式用哈希表查规则,还支持轮询、最少连接等更多调度算法,大规模集群建议启用 IPVS。学习环境用默认的 iptables 即可,二者对使用者完全透明。
四种类型对比
Service 的 spec.type 决定它对谁暴露、怎么暴露:
| 类型 | 访问范围 | 典型场景 |
|---|---|---|
| ClusterIP | 仅集群内部 | 默认类型,服务间调用 |
| NodePort | 集群外(节点 IP + 高位端口) | 测试环境、没有云 LB 时临时对外 |
| LoadBalancer | 集群外(云厂商负载均衡器) | 生产环境对外暴露 |
Headless(clusterIP: None) | 不分配虚拟 IP | StatefulSet 直连 Pod、自定义服务发现 |
下面逐一展开。
ClusterIP
ClusterIP 是默认类型,不写 type 时就是它。它会拿到一个集群内唯一的虚拟 IP,只能在集群内部访问,适合绝大多数「服务调服务」的场景。
apiVersion: v1
kind: Service
metadata:
name: web
namespace: default
spec:
type: ClusterIP # 可省略,默认就是 ClusterIP
selector:
app: web # 选中带这个标签的 Pod
ports:
- port: 80 # Service 自己监听的端口
targetPort: 8080 # 转发到 Pod 容器的端口
集群内其他 Pod 访问它,可以直接写 Service 名,也可以用完整的 DNS 名称,格式为:
服务名.命名空间.svc.cluster.local
例如 web.default.svc.cluster.local。同命名空间内只写 web 就够了;跨命名空间建议至少写到 web.default 这一段,少依赖搜索域的自动补全。
kubectl get svc web
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web ClusterIP 10.96.12.34 <none> 80/TCP 1m
Service 的 ClusterIP 一旦分配,在它的生命周期内就不会再变(删掉重建才会换)。所以应用代码里可以放心地把 Service 名或 ClusterIP 写成依赖地址——这正是「Service 是稳定入口」的具体含义。
NodePort
NodePort 在 ClusterIP 的基础上更进一步:在每个节点上开一个高位端口(默认范围 30000-32767),访问任意节点的 节点IP:NodePort 都会被转发到背后的 Pod,于是集群外也能访问服务。
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: NodePort
selector:
app: web
ports:
- port: 80
targetPort: 8080
nodePort: 30080 # 不指定则自动从范围里分配一个
kubectl get svc web
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web NodePort 10.96.12.34 <none> 80:30080/TCP 1m
此时 curl http://任意节点IP:30080 就能访问到 web 服务。流量路径是:节点 30080 端口 → kube-proxy 规则 → 随机一个后端 Pod。注意这个 Pod 不一定在本节点上,可能还要再跨节点跳一次。
externalTrafficPolicy 的权衡
默认策略(externalTrafficPolicy: Cluster)会把流量均匀分发给所有后端 Pod,代价是:跨节点二次跳转时做一次 SNAT,客户端真实源 IP 丢失(后端看到的是节点 IP)。
把策略改成 Local 可以解决这个问题:
spec:
type: NodePort
externalTrafficPolicy: Local
Local 表示节点只把流量转发给本节点上的后端 Pod,不做二次跳转,源 IP 得以保留。但代价同样明显:
- 某个节点上没有该服务的 Pod 时,发到这个节点的请求会被直接丢弃。
- 各节点 Pod 数量不均时负载也跟着不均:一个节点有 3 个 Pod、另一个只有 1 个,后者承受的流量是前者的三分之一,却要和前者「平分」到达量,实际压力差三倍。
需要透传客户端 IP(做访问控制、限流、审计)就用 Local,并配合反亲和性把 Pod 均匀打散到各节点;不在乎源 IP、只想流量均匀就保持默认的 Cluster。这个字段在 LoadBalancer 类型上同样生效。
LoadBalancer
LoadBalancer 是在 NodePort 之上再叠一层:自动向云厂商申请一个外部负载均衡器,并把它的流量指到各节点的 NodePort 上。这是生产环境把服务暴露到公网的标准方式。
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: LoadBalancer
selector:
app: web
ports:
- port: 80
targetPort: 8080
在云厂商托管集群(阿里云 ACK、AWS EKS、GKE 等)上创建后,云控制器会自动创建一个负载均衡器实例,并把分配到的公网 IP 回填到 Service 上:
kubectl get svc web
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web LoadBalancer 10.96.12.34 47.100.200.30 80:31234/TCP 3m
裸机集群:MetalLB
在自建裸机集群里创建 LoadBalancer 类型的 Service,EXTERNAL-IP 会永远卡在 pending——因为没有云厂商去实现「申请一个 LB」这件事。这时可以装 MetalLB:它工作在二层模式(ARP/NDP 应答)或 BGP 模式,从管理员预分配的一段 IP 池里挑一个分给 Service,并向网络宣告,让裸机集群也能用上 LoadBalancer 类型。一句话概括思路:MetalLB 在自建环境里扮演「云厂商 LB 控制器」的角色。
# 官方部署清单(版本号以官网为准)
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.14.9/config/manifests/metallb-native.yaml
装好后用 IPAddressPool 和 L2Advertisement 两个 CRD 配置一段可用的内网 IP,LoadBalancer 类型的 Service 就能拿到 EXTERNAL-IP 了。
LoadBalancer 类型是「一个 Service 一个 LB」,对外服务多的时候 LB 数量和费用会线性增长。HTTP 服务通常改用 Ingress 或 Gateway API 统一入口(见下文),只给入口 Controller 本身配一个 LoadBalancer。
Headless Service
Headless Service 是一种特殊的 Service:把 clusterIP 显式设为 None,它就不分配虚拟 IP、也不做 kube-proxy 转发。此时 DNS 查询这个名字,直接返回所有后端 Pod 的 IP 列表,客户端自己决定连哪一个。
apiVersion: v1
kind: Service
metadata:
name: db
spec:
clusterIP: None # 关键:Headless
selector:
app: db
ports:
- port: 5432
targetPort: 5432
什么时候需要绕过负载均衡、直连具体的某个 Pod?典型场景就是 StatefulSet:数据库、消息队列这类有状态应用,每个副本身份不同(主/从、分片 0/分片 1),客户端必须能稳定寻址到「第 N 个副本」。StatefulSet 正是配合 Headless Service,给每个 Pod 生成稳定的 DNS 记录:
pod名.服务名.命名空间.svc.cluster.local
例如 StatefulSet db 的 0 号 Pod 就是 db-0.db.default.svc.cluster.local,这个名字在 Pod 重建后依然指向它。
普通 Service 像「打总机,接线员随机转给一个坐席」;Headless Service 像「查号台,把每个坐席的分机号都给你,想打谁自己拨」。需要「自己拨」的场景(有状态应用、客户端自实现负载均衡如 gRPC)就用它。
Endpoint
Service 和 Pod 之间靠什么关联?靠的是标签选择器,而落地的结果就是 Endpoint 对象。
每创建一个带 selector 的 Service,Endpoints 控制器就会扫描集群里标签匹配的 Pod,把处于 Ready 状态的 Pod 的 IP 和端口收集起来,写进一个与 Service 同名的 Endpoint 对象。kube-proxy 真正参考的其实是 Endpoint 列表,而不是 Service 本身。
kubectl get endpoints web
NAME ENDPOINTS AGE
web 10.244.1.12:8080,10.244.2.15:8080,10.244.2.16:8080 5m
这个列表是动态维护的,流量的摘和挂都由它完成:
- Pod 的 readinessProbe 失败 → IP 从 Endpoint 里摘除 → Service 不再给它转发流量;探针恢复后自动加回。
- Pod 扩容或滚动更新 → 新 Pod Ready 后加入列表;旧 Pod 被删除前先从列表摘除,再配合
terminationGracePeriodSeconds优雅退出。
滚动更新不中断服务的关键就在这个机制里:先等新 Pod Ready(出现在 Endpoint 中),再把旧 Pod 从 Endpoint 摘掉、发 SIGTERM 终止。「摘 Endpoint」和「发 SIGTERM」有先后顺序,保证旧 Pod 不再接收新流量,只把手上的请求处理完。
EndpointSlice
Endpoint 对象有一个扩展性问题:一个 Service 的所有后端地址都塞在一个对象里。后端有几千个 Pod 时,这个对象会非常大,任何一个 Pod 上下线都要全量更新并推送给所有节点的 kube-proxy,etcd 和 API Server 压力都不小。
EndpointSlice 是它的替代方案,自 K8s 1.21 起成为默认机制:把同一个 Service 的后端分片存成多个 EndpointSlice 对象(默认每片最多 100 个 endpoint)。单个 Pod 变化只影响它所在的那一片,更新和推送量都大幅下降。
它由控制器自动维护,一般不需要手动创建,但排查问题时要会看:
# 查看某个 Service 对应的 EndpointSlice
kubectl get endpointslices -l kubernetes.io/service-name=web
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
web-abc123 IPv4 8080 10.244.1.12,10.244.2.15 5m
web-def456 IPv4 8080 10.244.2.16 5m
# 看某一片的详细信息(含每个 endpoint 的 ready 状态)
kubectl describe endpointslice web-abc123
「Service 不通」类问题的标准动作之一就是对比两边:EndpointSlice 里的地址列表,和 kubectl get pod -o wide 看到的实际 Pod IP。如果 EndpointSlice 是空的或缺地址,多半是 selector 和 Pod 标签没对上,或者 readinessProbe 没通过。
Ingress
Service(NodePort/LoadBalancer)解决的是四层(TCP/UDP)的流量入口。但对外提供 HTTP 服务时,常见需求是按域名和路径路由:api.example.com 走后端服务、example.com/blog 走前端服务,还要统一做 HTTPS 卸载、限流等。这些七层能力由 Ingress 提供。
规则模型与 Controller
Ingress 的规则模型很直白:host + path → Service。一条规则就是「匹配某个域名下某类路径的请求,转发给某个 Service」。
但要注意一个关键点:Ingress 只是规则声明,本身不干活。真正干活的是 Ingress Controller——一个跑在集群里的反向代理(如 ingress-nginx):它 watch Ingress 资源,把规则翻译成自己的配置并热加载。集群里必须先装 Controller,Ingress 规则才会生效。
安装 ingress-nginx
# 官方部署清单(云厂商环境,自动申请 LoadBalancer;版本号以官网为准)
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.13.5/deploy/static/provider/cloud/deploy.yaml
# 等待 Controller Pod 就绪
kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=120s
# 查看分配给入口的 EXTERNAL-IP
kubectl get svc -n ingress-nginx ingress-nginx-controller
完整示例(含 TLS)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
namespace: default
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx # 指定由哪个 Controller 处理
tls:
- hosts:
- web.example.com
secretName: web-tls # 引用保存证书的 Secret
rules:
- host: web.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api # host + path → Service
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
TLS 部分引用同命名空间下一个 kubernetes.io/tls 类型的 Secret(内含证书和私钥),Controller 拿到后在入口统一做 HTTPS 卸载,集群内部仍走明文 HTTP。生产上一般用 cert-manager 自动签发和续期证书,不用手工维护 Secret。
Prefix 按路径前缀匹配(/api 能匹配 /api/v1/users),Exact 是精确匹配。配 Prefix 时注意 / 会匹配一切,通常放在规则最后兜底。
Gateway API
Ingress 用了多年,缺点也逐渐暴露:规则表达能力弱(复杂路由、流量切分只能靠各 Controller 私有的 annotation,互不通用)、角色边界不清(基础设施管理员和开发者改同一个对象)、只支持 HTTP。Gateway API 是 K8s 官方推出的下一代流量管理标准,目标就是解决这些问题。
三个核心资源的关系
| 资源 | 由谁管理 | 职责 |
|---|---|---|
| GatewayClass | 平台/基础设施方 | 声明「这套网关用什么实现」(nginx、envoy、云厂商 LB 等),类似 StorageClass |
| Gateway | 平台/基础设施方 | 实例化一个网关入口:监听端口、协议、TLS 证书 |
| HTTPRoute | 应用开发者 | 路由规则:host/path 匹配、转发到哪个 Service、权重、重写 |
分工是 Gateway API 设计的精髓:管理员建 Gateway(管入口),开发者写 HTTPRoute(管路由),路由通过 parentRefs 挂到网关上,权限边界天然清晰,配合 RBAC 可以做到「开发者自助发布路由,但碰不到入口配置」。
最小示例
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: main-gateway
namespace: default
spec:
gatewayClassName: nginx # 使用哪种实现
listeners:
- name: http
protocol: HTTP
port: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: web-route
namespace: default
spec:
parentRefs:
- name: main-gateway # 挂到哪个 Gateway 上
hostnames:
- web.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: web # 转发到 Service
port: 80
相对 Ingress 的优势
- 标准化:路由匹配、按
weight流量切分、请求重写、超时等都是规范的一部分,不再依赖各家私有 annotation,换实现时配置不用重写。 - 角色分离:Gateway 和 HTTPRoute 是不同资源,入口与路由的管理权限可以分开。
- 多协议:除 HTTPRoute 外还有 TCPRoute、UDPRoute、TLSRoute、GRPCRoute,Ingress 只有 HTTP。
- 官方扩展机制:跨命名空间引用(ReferenceGrant)、策略挂载(Policy Attachment)等都有标准做法。
Ingress 生态最成熟、资料最多,生产上仍是主流,必须会;Gateway API 的核心资源已 GA,ingress-nginx、Envoy Gateway、Cilium 等主流实现都在跟进,是明确的方向,建议一并掌握。两者不冲突,可以共存于同一个集群。
DNS
K8s 集群内部有一套自己的 DNS 服务(现在统一是 CoreDNS),Service 和 Pod 创建后自动获得域名,应用之间靠域名而不是 IP 互相访问。
集群内域名结构
完整域名形如:
名字.命名空间.svc.cluster.local
| 对象 | 域名示例 | 解析结果 |
|---|---|---|
| 普通 Service | web.default.svc.cluster.local | Service 的 ClusterIP |
| Headless Service | db.default.svc.cluster.local | 所有后端 Pod 的 IP |
| StatefulSet 的 Pod | db-0.db.default.svc.cluster.local | 该 Pod 的 IP |
其中 cluster.local 是默认的集群域(cluster domain),安装集群时可以自定义。
Pod 的 dnsPolicy
Pod 的 spec.dnsPolicy 决定它使用什么 DNS 配置:
| 策略 | 含义 |
|---|---|
| ClusterFirst | 默认。集群内域名交给 CoreDNS,外部域名转发到上游 DNS |
| Default | 继承节点(宿主机)的 DNS 配置,不认识集群内域名 |
| None | 完全自定义,配合 dnsConfig 手写 nameserver 和搜索域 |
普通业务 Pod 保持默认的 ClusterFirst 即可;只有使用 hostNetwork 等特殊场景的 Pod 才可能需要调整。
ndots:5 的坑
Pod 的 /etc/resolv.conf 里有一行不起眼的配置:ndots:5。含义是:查询名字里的点号(.)少于 5 个时,先依次拼上搜索域尝试解析(default.svc.cluster.local、svc.cluster.local、cluster.local……),全部失败后才去查原始域名。
这对集群内短名字(如 web)很方便,但对外部域名就是个坑:访问 www.baidu.com 只有 2 个点,小于 5,于是 CoreDNS 会先查 www.baidu.com.default.svc.cluster.local 等一串组合,每次都失败,一次外部请求变成好几次 DNS 查询。高并发下会明显拖慢解析,甚至压垮 CoreDNS。
应用访问外部服务时,把域名写成末尾带点的 FQDN 形式(如 www.baidu.com.),ndots 判定直接跳过搜索域;或者在 dnsConfig 里把 ndots 调小(比如 2)。遇到「DNS 解析慢」的问题,先怀疑 ndots。
CoreDNS
CoreDNS 是集群默认的 DNS 服务器,以 Deployment 形式跑在 kube-system 命名空间,通过名为 kube-dns 的 Service 对外提供服务——每个 Pod 的 /etc/resolv.conf 里 nameserver 指向的就是它的 ClusterIP。
Corefile 配置要点
CoreDNS 的配置文件叫 Corefile,存放在 kube-system 命名空间下名为 coredns 的 ConfigMap 里。一份典型的 Corefile 长这样:
.:53 {
errors
health {
lameduck 5s
}
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
prometheus :9153
forward . /etc/resolv.conf {
max_concurrent 1000
}
cache 30
loop
reload
loadbalance
}
几个关键插件:
- kubernetes:解析集群内 Service 和 Pod 的域名,数据源来自 API Server。
- forward:集群外的域名转发给上游 DNS,默认用节点的
/etc/resolv.conf。想统一走公司内网 DNS 或公共 DNS 就改这里,例如forward . 223.5.5.5。 - cache:解析结果缓存,数字是缓存秒数。
- hosts:像改
/etc/hosts一样自定义解析记录,常用于把某个域名固定指向内网地址:
.:53 {
hosts {
192.168.1.100 internal.example.com
fallthrough
}
...
}
改完 ConfigMap 后,reload 插件会自动热加载;不放心的话也可以 kubectl rollout restart deployment/coredns -n kube-system 强制重启。
排查 DNS 问题时,起一个带解析工具的临时 Pod 实测最直接:
kubectl run -it --rm --image=busybox:1.36 dns-test -- nslookup web.default.svc.cluster.local
服务发现
「服务发现」回答的是:一个服务怎么找到另一个服务的地址。K8s 提供了两种方式。
环境变量
Pod 启动时,kubelet 会把同命名空间内已存在的 Service 信息注入为环境变量,格式如:
WEB_SERVICE_HOST=10.96.12.34
WEB_SERVICE_PORT=80
方式简单,但有两个硬伤:只有先于 Pod 创建的 Service 才会被注入(存在顺序依赖,后建的 Service 要重启 Pod 才能拿到);Service 一多,环境变量就泛滥成灾。所以实际几乎没人用它做服务发现。
DNS(主流)
DNS 是事实标准:任何时刻创建的 Service 立刻就有域名记录,客户端按名字访问即可,和创建顺序无关;配合 Headless Service 还能拿到全部 Pod IP,由客户端自己做负载均衡(gRPC 常这么用)。
对比一下答案很自然:环境变量是「启动时刻的快照」,DNS 是「随时可查的实时目录」。微服务环境下服务数量多、变化快,只有实时目录跟得上节奏。这也是本篇的落脚点——Service 提供稳定入口,CoreDNS 给入口一个名字,Ingress / Gateway API 把外部流量接进来,合起来就是 K8s 完整的流量管理体系。
到这里,从「集群内服务互访」到「外部流量进集群」的整条链路就完整了:Service 的四种类型解决入口稳定性,Endpoint / EndpointSlice 负责流量的动态摘挂,Ingress 和 Gateway API 处理七层路由,CoreDNS 把一切串成名字。下一篇讲存储系统。