选择器让 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 也只是真实服务网段的缩影。发布前还要核对真实服务网段与已分配地址,到节点上检查代理规则,并用真实域名在集群内复测解析。

延伸阅读

来源