选择器让 Service 从一段固定列表变成动态视图:新增移除都在后台完成,Pod 重启时客户端无需改任何配置。
先声明,再分配地址
普通 ClusterIP Service 在对象存续期间提供虚拟地址,并在集群 DNS 支持下提供稳定发现名称。带选择器的 Service 还声明本命名空间中哪些 Pod 是预期后端。其他形态不同:无头服务不分配虚拟 IP,ExternalName 使用 DNS 间接引用,无选择器服务可以配合单独管理的 EndpointSlice。不能把每种 Service 都套进同一种分配流程。
地址稳定仅限 Service 对象存续期间,不是某个名称出现之后永远占用同一个地址。删除重建可能得到新地址。客户端应遵守约定的发现方式,并适当重新解析,而不是把抄下来的 ClusterIP 当成永久基础设施身份。这层稳定性主要隔离后端更替,不能消除对象本身重建和发现系统变化带来的影响。
选择器就是实时成员
EndpointSlice 控制器异步协调匹配 Pod 与端点条件。未就绪 Pod 可以继续出现在对象中,只是 ready 为 false,并非一定从所有端点对象里消失。消费者根据这些条件选择路由目标。publishNotReadyAddresses 会改变普通就绪解释,终止中端点又有额外处理细节,不能仅凭列表存在地址就断言后端应接收普通流量。
标签一致也不能证明请求健康。端口错误、不适当探针或过期数据路径仍然可能导致失败。应同时检查预期选择器与实际 EndpointSlice,再验证应用行为。自动协调减少手工维护后端名单,但不承诺瞬间更新,也不会消除所有漂移。控制面对象、节点转发和客户端连接各有自己的状态及更新时序。
Pod 变了地址依旧稳定
稳定目的地允许替换后端,而不必向调用方逐个公布 Pod IP,但不保证每次请求都不中断。已有连接可能仍关联旧后端,新连接依赖已经收敛的路由状态;没有可用端点的 Service 也不能制造缺失容量。不能因为名称没有改变,就认为所有连接已经自动迁移到新进程。
就绪、优雅终止、发布期间的可用容量和客户端重试策略,仍是可用性设计的一部分。域名在发布后继续存在是一项属性,请求成功和恢复有界是另外的属性,需要分别观察。下面的分配练习刻意不测试这些行为;它解释的是地址台账,不应把输出当成真实网络已经验证的证据。
坏掉的样子
地址池是有限的,耗尽是最字面的失败。具有虚拟 IP 的 Service 按配置的地址族从服务地址池占用地址,如果网段当初划得很小,或者堆满了无人使用的旧 Service,新 Service 就会在创建时直接报分配错误。此时 Pod 本身看起来一切正常,只有 Service 始终无法就绪。看到 failed to allocate IP 要先当容量账来算:删除不用的对象前先核对归属,再按实际集群版本规划受支持的地址池扩展,不能把扩容当成随意修改网段的操作。
第二类故障是把无头与普通 Service 混用。写下 clusterIP: None 就意味着主动放弃虚拟地址,域名解析直接返回一组不断变化的 Pod 地址,而不是唯一的虚拟入口。为单一稳定入口编写的客户端,会突然面对多个漂移地址,还得自己做均衡与重试。反过来也会坏事:本来指望按域名逐个发现 Pod 的代码,在有人把 Service 重建成普通类型之后,会一直打到同一个虚拟地址上。由于 clusterIP 基本不可改,切换形态只能删除重建,而调用方所有写死的假设都会在那一刻断裂。
最后两类故障藏在接口之下。不同 kube-proxy 模式实现同一个虚拟地址的方式完全不同:iptables 靠追加规则链,IPVS 靠写入虚拟服务表,代理卡住或正在重启时,地址能解析、流量却黑洞,直到规则重新同步才会恢复。还有人喜欢把 ClusterIP 抄进配置文件或环境变量,这等于冻结了一个控制面随时可以收回重分的值:虚拟地址只在 Service 对象存续期间稳定,删掉重建就可能把旧号码发给别人。调用方必须用域名、用时再解析,且虚拟地址用于内部服务路由,外部是否可达取决于明确网络配置。
快速验证
把下面程序保存到临时工作目录的 clusterip_pool.py,运行 python3 clusterip_pool.py。它只用标准库模拟一个迷你分配器,管理 10.96.0.0/29 网段并预留首地址,进程之外什么都不改:一个分配类负责发号、拒绝重复与越界、允许无头服务绕过地址池、回收后复用,五个场景逐条断言这些规则,其中故意重复申请是反例,证明冲突必须报错而非悄悄共享。
"""用小型服务网段模拟 ClusterIP 分配。"""
import ipaddress
class ClusterIPPool:
"""分配虚拟地址;无头 Service 绕过地址池。"""
def __init__(self, cidr, reserved=1):
network = ipaddress.ip_network(cidr)
hosts = list(network.hosts())
self._all = set(hosts)
self._reserved = set(hosts[:reserved])
self.free = sorted(set(hosts) - self._reserved)
self.allocated = {}
def allocate(self, service, requested=None, headless=False):
if headless:
return None
if service in self.allocated:
raise ValueError(f"{service} 已持有 {self.allocated[service]}")
if requested is not None:
ip = ipaddress.ip_address(requested)
if ip in self._reserved or ip not in self._all:
raise ValueError(f"{requested} 超出可分配范围")
if ip not in self.free:
raise ValueError(f"{requested} 已被占用")
self.free.remove(ip)
self.allocated[service] = ip
return str(ip)
if not self.free:
raise RuntimeError("服务网段耗尽:没有可用 ClusterIP")
ip = self.free.pop(0)
self.allocated[service] = ip
return str(ip)
def release(self, service):
ip = self.allocated.pop(service, None)
if ip is None:
raise ValueError(f"{service} 没有可释放的地址")
self.free.append(ip)
self.free.sort()
return str(ip)
def check(condition, message):
if not condition:
raise AssertionError(message)
def main():
pool = ClusterIPPool("10.96.0.0/29", reserved=1)
web = pool.allocate("web")
api = pool.allocate("api", requested="10.96.0.5")
check(web != api, "顺序创建的服务必须拿到不同地址")
print("allocated:", web, api)
try:
pool.allocate("worker", requested="10.96.0.5")
raise SystemExit("冲突被悄悄共享了")
except ValueError as exc:
print("conflict rejected:", exc)
try:
pool.allocate("evil", requested="10.99.0.1")
raise SystemExit("越界申请被接受了")
except ValueError as exc:
print("out-of-range rejected:", exc)
headless = pool.allocate("db-headless", headless=True)
check(headless is None, "无头服务必须绕过地址池")
check(len(pool.allocated) == 2, "无头服务不得占用地址池")
print("headless bypass: None")
freed = pool.release("web")
worker = pool.allocate("worker")
check(worker == freed, "释放的地址必须可复用")
print("reused:", freed, "-> worker")
pool.allocate("extra-1")
pool.allocate("extra-2")
pool.allocate("extra-3")
try:
pool.allocate("overflow")
raise SystemExit("耗尽没有报错")
except RuntimeError as exc:
print("exhaustion reported:", exc)
print("PASS: allocation, conflict rejection, release reuse, and exhaustion hold")
if __name__ == "__main__":
main()
运行结果和健康分配器完全一致:顺序创建拿到不同地址,指定地址的申请会钉住想要的值,重复申请被明确拒绝而不是共享。释放一个服务后旧地址可以被后人复用,占满最后空位则证明耗尽只报错,不回收在用地址。本轮在 Linux、Python 3.12.14 上运行两种语言模型通过。首主机地址预留与分配顺序是教学选择,不是 Kubernetes 分配器的通用保证。
这个模拟只验证分配台账,不实现接口服务、持久存储与代理编程,迷你的 /29 也只是真实服务网段的缩影。发布前还要核对真实服务网段与已分配地址,到节点上检查代理规则,并用真实域名在集群内复测解析。
延伸阅读
来源
Kubernetes Service,说明 Service 语义,已核验 2026-10-15。
Virtual IPs and Service Proxies,说明 ClusterIP 分配与 kube-proxy 模式,已核验 2026-10-15。
Primary reference,端点条件与异步发现,已核验 2026-10-15。