首页
/ Kubernetes StatefulSet 场景下的 Peer Finder 守护进程:基于 Governing Service DNS 的分布式应用成员发现方案

Kubernetes StatefulSet 场景下的 Peer Finder 守护进程:基于 Governing Service DNS 的分布式应用成员发现方案

2026-09-07 17:55:34作者:余洋婵Anita

导读

在 Kubernetes 中以 StatefulSet 运行 ZooKeeper、Cassandra、CockroachDB 等有状态分布式应用时,每个 Pod 都需要动态获知"当前集群中有哪些兄弟节点",从而完成集群组建、成员加入与拓扑重配。peer-finder 正是 Kubernetes 官方为此类需求提供的一个轻量守护进程工具:它持续监听 StatefulSet 治理服务(Governing Service)对应的 DNS SRV 记录,一旦发现 Peer 集合发生变化,就把新的节点列表通过 stdin 交给你的配置脚本。本文以 test/images/pets/peer-finder/README.md 为骨架,结合其 peer-finder.go 源码与仓库内的真实测试清单(ZooKeeper、CockroachDB),完整讲解它的工作原理、命令行参数、四种集成模式与 DNS 相关注意事项,读完即可在自己项目的 StatefulSet 中复用它帮助存量应用完成成员发现。

Peer Finder 是什么:为"不会自己查 DNS"的存量应用补齐成员发现能力

Kubernetes 官方仓库中维护着一批用于 e2e 测试的"宠物"(pets)镜像工具,peer-finder 是其中之一,源码位于 test/images/pets/peer-finder/peer-finder.go。它的定位正如 README 开头所述:

This is a simple peer finder daemon that is useful with StatefulSet and related use cases.

它的全部工作可以概括为两句话:

  1. 周期性地查询 Kubernetes Service 对应的 DNS SRV 记录,得到当前"应该属于同一集群的所有 Peer 的主机名集合";
  2. 一旦发现集合发生变化,就调用用户提供的脚本,把新的 Peer 列表(每行一个)通过 stdin 传给脚本,由脚本完成诸如改写配置文件、触发主进程 reload 等实际动作。

关键前提是:这个 Service 必须是 StatefulSet 的治理服务(spec.serviceName 指向的 Headless Service)。StatefulSet 会为该 Service 下的每个 Pod 生成稳定的 DNS 记录,格式为 <pod-name>.<service-name>.<namespace>.svc.<cluster-domain>,同时 Service 的 SRV 记录会枚举出这些 Pod 端点,从而"以 Service 为锚点"得到一份完整的成员清单。

README 还强调了一个重要前提:工具默认假设应用自己不会(或不愿)去查询 DNS

The peer-finder tool is intended to help legacy applications run in containers on Kubernetes. If possible, it may be preferable to modify an application to poll its own DNS to determine its peer set.

也就是说,如果应用本身有能力轮询 DNS 获取 Peer 列表(例如新版 ZooKeeper 的动态重配、Cassandra 的 seed provider),应优先改造应用,peer-finder 只是为无法改造的存量应用提供的一条低成本迁移路径。

工作原理解析:从 SRV 查询到脚本回调的实现细节

peer-finder 的整个逻辑只有一个约 90 行的 main() 函数 和两个辅助函数,非常适合作为理解 Kubernetes DNS 成员发现机制的入门样例。

核心循环:1 秒轮询 + 变化即触发

main() 的最后是一个无限循环(peer-finder.go L155-L171):

for peers := sets.NewString(); script != ""; time.Sleep(pollPeriod) {
    newPeers, err := lookup(*svc)
    if err != nil {
        log.Printf("%v", err)
        continue
    }
    if newPeers.Equal(peers) || !newPeers.Has(myName) {
        log.Printf("Have not found myself in list yet.\nMy Hostname: %s\nHosts in list: %s", myName, strings.Join(newPeers.List(), ", "))
        continue
    }
    peerList := newPeers.List()
    sort.Strings(peerList)
    log.Printf("Peer list updated\nwas %v\nnow %v", peers.List(), newPeers.List())
    shellOut(strings.Join(peerList, "\n"), script)
    peers = newPeers
    script = *onChange
}

循环中隐藏着三个值得注意的行为:

  • 轮询周期固定为 1 秒(常量 pollPeriod = 1 * time.Second,见 peer-finder.go L36-L38),因此 Peer 列表变化的感知延迟最坏约为 1 秒。
  • 只有"列表里包含我自己"时才触发脚本。如果当前 Pod 尚未出现在 SRV 记录中(例如刚启动,治理服务还没把未就绪的 Pod 端点加进来),它会打印 Have not found myself in list yet. 并继续等待。这是为了避免成员脚本在一个尚未真正属于集群的节点上过早执行。
  • 第一次触发使用 --on-start 脚本,之后每次变化触发 --on-change 脚本(若未提供 --on-start,首次也会使用 --on-change 并在日志中提示)。同一脚本每次收到的都是排序后的完整 Peer 列表,便于脚本做"全量重写配置"这种幂等操作,而不是计算增量。

成员发现:标准库 net.LookupSRV

Peer 列表的获取完全没有使用 Kubernetes API,而是依赖 Go 标准库 DNS 查询(lookup() 函数,peer-finder.go L48-L60):

func lookup(svcName string) (sets.String, error) {
    endpoints := sets.NewString()
    _, srvRecords, err := net.LookupSRV("", "", svcName)
    if err != nil {
        return endpoints, err
    }
    for _, srvRecord := range srvRecords {
        // The SRV records ends in a "." for the root domain
        ep := fmt.Sprintf("%v", srvRecord.Target[:len(srvRecord.Target)-1])
        endpoints.Insert(ep)
    }
    return endpoints, nil
}

它查询的是 <service>.<ns>.svc.<cluster-domain> 的 SRV 记录;返回结果中每个 srvRecord.Target 形如 pod-0.zk.<ns>.svc.cluster.local.(末尾带根域的点),因此代码截掉最后一个字符后放入集合。这也是为什么 peer-finder 只需要知道 Service 名而不需要写死副本数——端点集合天然反映了"当前集群规模"。

脚本回调:通过 stdin 传递换行分隔的 Peer 列表

当检测到变化时,shellOut()peer-finder.go L62-L86)会执行指定的脚本:把 strings.Join(peerList, "\n") 写入脚本的 stdin 后关闭管道,再收集脚本 stdout 并打日志。因此约定的接口协议是:脚本从标准输入读取,每行一个 Peer 的完整 FQDN,按字典序排序。这种"管道 + stdin"而非"命令行参数"的设计,避免了 Peer 数量很多时超出命令行长度限制的问题。

命令行参数详解

peer-finder 的全部参数定义集中在 peer-finder.go L40-L46,共 5 个:

参数 含义 默认值 / 取值说明
-on-change 当 Peer 集合发生变化时要执行的脚本路径 空字符串。脚本必须能从 stdin 读取"换行分隔的 Peer 列表"
-on-start Pod 首次启动(第一次检测到自己已在列表中)时执行的脚本路径 空字符串。与 -on-change 至少提供一个,否则进程直接退出
-service 本 Pod 所在的治理 Service 名(Governing Service,即 StatefulSet 的 spec.serviceName 空字符串,必填
-ns Pod 所在命名空间 空字符串。缺省时回退读取环境变量 POD_NAMESPACE
-domain 集群使用的 Cluster Domain 空字符串。缺省时尝试从 /etc/resolv.conf 自动推导(详见下文 DNS 小节)

参数校验逻辑见 peer-finder.go L91-L97:若 -service 为空、命名空间解析为空、且两个脚本参数都为空,进程会以 Incomplete args 错误日志退出。命名空间的解析顺序是"-ns 参数优先,否则读 POD_NAMESPACE 环境变量",这一设计让用户可以在 YAML 中直接用 fieldRef 注入当前命名空间(仓库测试清单正是这么做的)。

四种集成模式:把 Peer Finder 放进你的 Pod

README 给出了 peer-finder 与应用主容器组合的四种方式,选择的关键在于你想在哪个生命周期节点获知 Peer 集合,以及能否接受改造主镜像

模式 1:作为 initContainer(只适合 --on-start

init container 中运行 peer-finder,帮助 Pod 在刚启动时确定它的 Peer 集合(从治理服务推导期望的成员集合)。这个模式只能用 --on-start,不能用 --on-change——因为 initContainer 在 Pod 正式启动后就退出了,后续成员变化不可能再触发它。典型用法是写一个 --on-start 脚本去编辑主应用的配置文件、把 Peer 列表插入进去;配置文件必须放在 initContainer 与主容器共享的 Volume 上

模式 2:作为 sidecar 伴随容器(适合 --on-change

在主应用同一个 Pod 里放第二个容器运行 peer-finder。此时可用 --on-change,但 --on-start 意义不大——sidecar 与主容器的启动顺序无法保证。典型的 on-change 脚本是"通过 localhost 向主容器发送一条管理命令"(例如通过 HTTP 管理接口通知拓扑变化)。README 特别注释:目前 Pod 之间不共享 PID namespace,所以想用信号(如 SIGHUP)通知另一个容器并不现实。

模式 3:作为主容器的 pid 1(--on-start--on-change 都可使用)

让 peer-finder 直接当主容器进程,由它负责拉起真正的主应用。这种模式下,--on-start/--on-change 相对主应用的顺序是确定的(peer-finder 先启动、拿到列表后才 exec 主应用)。示例脚本可以"改写配置文件后向主进程发送 SIGHUP"。这种模式只有在确实需要信号机制时才值得选用。

模式 4:组合模式(1 + 2)

同时部署 initContainer 版和 sidecar 版:initContainer 负责启动时的初始配置,sidecar 负责运行期成员变化的持续通知。

README 给出了明确的取舍建议:

  • 模式 1、2、4 通常更优,因为它们不需要改动主容器镜像——peer-finder 及其脚本被装进额外的 init/sidecar 容器即可;
  • 模式 3 仅在需要信号(signalling)时使用,因为它要求把 peer-finder 与启动逻辑打进同一个主容器镜像;
  • 不可缩容的 StatefulSet 只需要 on-start 消息,因此模式 1 是最佳选择(这类集群成员集合固定,没有运行期变化需要响应)。

仓库实测案例:ZooKeeper 的 initContainer 用法

Kubernetes 自己的 e2e 测试清单中给出了 peer-finder 作为 ZooKeeper 集群 initContainer 的完整示例,见 test/e2e/testing-manifests/statefulset/zookeeper/statefulset.yaml

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: zoo
spec:
  serviceName: "zk"          # 治理服务,peer-finder 依赖它的 DNS/SRV
  replicas: 3
  selector:
    matchLabels:
      app: zk
  template:
    spec:
      initContainers:
      - name: install          # 先把 zookeeper、脚本与 peer-finder 装入共享卷
        image: registry.k8s.io/e2e-test-images/pets/zookeeper-installer:1.5
        imagePullPolicy: Always
        args:
        - "--install-into=/opt"
        - "--work-dir=/work-dir"
        volumeMounts:
        - name: opt
          mountPath: "/opt/"
        - name: workdir
          mountPath: "/work-dir"
      - name: bootstrap         # peer-finder 作为 initContainer 运行
        image: java:openjdk-8-jre
        command:
        - "/work-dir/peer-finder"
        args:
        - -on-start="/work-dir/on-start.sh"
        - "-service=zk"
        env:
        - name: POD_NAMESPACE
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: metadata.namespace
        volumeMounts:
        - name: opt
          mountPath: "/opt"
        - name: workdir
          mountPath: "/work-dir"
        - name: datadir
          mountPath: "/tmp/zookeeper"
      containers:
      - name: zk
        image: openjdk:8-jre
        command:
        - /opt/zookeeper/bin/zkServer.sh
        args:
        - start-foreground
        ...
      volumes:
      - name: opt
        emptyDir: {}
      - name: workdir
        emptyDir: {}

这个示例印证了 README 模式 1 的全部要点:

  • 命名空间通过 fieldRef 写入 POD_NAMESPACE 环境变量,配合 peer-finder 的"-ns 缺省时读 POD_NAMESPACE"逻辑,无需把命名空间写死在参数里;
  • -service=zk 指向治理服务,即 spec.serviceName: "zk"
  • 共享 Volume 传递脚本与产物:installer initContainer 会把 /on-start.sh/peer-finder 拷贝进 --work-dir=/work-dir(见 test/images/pets/zookeeper-installer/install.sh),bootstrap initContainer 与主容器共同挂载 workdiropton-start.sh 正是典型的"把解析出的 Peer 列表写入 ZooKeeper 动态配置"脚本;
  • 主容器镜像无需任何改动(直接使用 openjdk:8-jre),peer-finder 相关的成员发现逻辑被完全隔离在 init 阶段。

安装脚本中还可以看到它与 ZooKeeper 动态重配的配合思路:当 ZooKeeper 版本支持动态重配时,会向 zoo.cfg 追加 standaloneEnabled=falsedynamicConfigFile=...(install.sh 中通过解析版本号判断),随后由 on-start.sh 写入包含 peer 列表的 dynamic config 文件——这正是"peer-finder 帮助旧应用在容器里完成拓扑发现"的完整闭环。

治理服务的关键设置:让所有 Peer 在启动前进入 Endpoints

peer-finder 触发成员脚本的前提是"自己的名字已出现在 SRV 列表中"。而 Kubernetes Service 默认只把**就绪(Ready)**的 Pod 端点发布到 Endpoints,这会造成一个鸡生蛋问题:新副本(尤其是第 0 号副本丢失数据、需要决定"重建集群还是加入已有集群"的场景)在就绪前不会被列入端点,peer-finder 便会一直打印 Have not found myself in list yet. 而卡住初始化。

README 给出的对策是:在 StatefulSet 的治理服务上加注解 service.alpha.kubernetes.io/tolerate-unready-endpoints,这样在任何 Peer 启动之前,所有 Peer 都会先出现在 Endpoints 中。

需要说明的是:该注解属于早期 alpha 时代的写法(README 与源码保留了"PetSet"这一历史命名即为例证)。在现代 Kubernetes 中,官方推荐且稳定的等价配置是 Headless Service 的 spec.publishNotReadyAddresses: true。仓库里的 CockroachDB e2e 清单 test/e2e/testing-manifests/statefulset/cockroachdb/service.yaml 就是一个标准范例:

apiVersion: v1
kind: Service
metadata:
  # This service only exists to create DNS entries for each pod in the stateful
  # set such that they can resolve each other's IP addresses. It does not
  # create a load-balanced ClusterIP and should not be used directly by clients
  # in most circumstances.
  name: cockroachdb
spec:
  ports:
  - port: 26257
    targetPort: 26257
    name: grpc
  - port: 8080
    targetPort: 8080
    name: http
  clusterIP: None
  selector:
    app: cockroachdb
  # This is needed to make the peer-finder work properly and to help avoid
  # edge cases where instance 0 comes up after losing its data and needs to
  # decide whether it should create a new cluster or try to join an existing
  # one. ...
  publishNotReadyAddresses: true

该清单注释还给出了一个使用 peer-finder 时必须深思的边界场景:当第 0 号实例丢失数据后重新拉起时,它需要判断自己是"创建新集群"还是"加入已有集群"——若把未就绪端点排除在成员列表之外导致它误判,就可能出现两个彼此隔离的集群同时监听同一 Service 端点,造成数据分叉。publishNotReadyAddresses: true 让端点集在启动早期就包含所有成员,正是为了规避这类风险。

DNS 注意事项:域名的推导与显式覆盖

README 的 DNS Considerations 小节值得单独展开,因为它直接决定 peer-finder 能否找到正确的 SRV 记录:

  • 默认(未提供 -domain:peer-finder 会读取 Pod 的 /etc/resolv.conf,找到 search 行并从里面选出"最佳匹配"的搜索域作为集群域名。对应实现见 peer-finder.go L106-L139:当提供 -ns 时,它用正则 search\s+...(?P<goal>svc\.([a-zA-Z0-9-]{1,63}\.)*[a-zA-Z0-9]{2,63}) 匹配出 svc.cluster.local 形态的域名,再拼上命名空间得到 ns.svc.cluster.local;若没有命名空间,则匹配 <name>.svc.<cluster>.local 形态的整体域。
  • 依赖 ClusterFirst 策略:上述推导逻辑成立的前提是 Pod 使用默认的 dnsPolicy: ClusterFirst(kubelet 会把 cluster domain 写进 Pod 的 search 域)。如果你的 Pod 使用了非默认的 dnsPolicy(如 Default 或自定义 DNS 配置),resolv.conf 里可能根本没有可用的 search 域,此时就必须显式传 -domain
  • 显式覆盖:当传入 -domain 时,代码走的是 peer-finder.go L141-L143 的简单拼接路径:domainName = ns + ".svc." + domain在绝大多数常见配置下,-domain=cluster.local 就是正确设置

此外还可以注意到:peer-finder 判定"我自己"的方式是先取容器 hostname(os.Hostname()),再拼成 myName = hostname + "." + service + "." + domainNamepeer-finder.go L99-L149),即 pod-0.zk.<ns>.svc.cluster.local。由于 StatefulSet 的 Pod 名即 hostname,这一自引用判断成立的前提是不要对 Pod 做自定义 hostname/子域覆盖

镜像构建与版本信息

若需要自行构建该工具镜像,仓库给出了完整的构建产物(目录 test/images/pets/peer-finder):

  • peer-finder.go:全部 Go 源码(package main,无外部依赖,仅引入 k8s.io/apimachinery/pkg/util/sets 作为集合类型);
  • Dockerfile:基于构建期传入的 BASEIMAGE 参数,安装 wget bash dnsutils 后把编译好的二进制拷贝为 /peer-finder,并设置 ENTRYPOINT ["/peer-finder"](镜像内 EXPOSE 9376 仅为预留端口,工具本身不监听任何端口);
  • BASEIMAGE:声明 linux/amd64、arm64、ppc64le、s390x 四种架构分别使用 registry.k8s.io/build-image/debian-base-*:bookworm-v1.0.6 基础镜像;
  • Makefile:通过 make bin 调用上层 image-util.sh bin 完成交叉编译;
  • VERSION:当前镜像版本 1.7.0

何时使用、何时不该用:适用边界小结

综合 README 与仓库使用场景,可以总结出 peer-finder 的适用边界:

适合使用 peer-finder 的场景:

  • 有状态应用(如 ZooKeeper、CockroachDB 等)以 StatefulSet 部署,且需要每个成员知道完整集群拓扑;
  • 应用本身不支持或不方便改造为"自查询 DNS"获取成员列表(存量应用容器化迁移);
  • 不想改动主容器镜像,希望通过 initContainer / sidecar 以"附加进程"方式注入成员发现能力;
  • 治理服务已配置为发布未就绪端点(老注解 tolerate-unready-endpoints 或新字段 publishNotReadyAddresses)。

不适合(或应优先换方案)的场景:

  • 应用自身已具备 DNS 轮询能力——README 明确建议优先改造应用而非引入额外进程;
  • 需要信号(signal)类机制与主进程交互——只有模式 3 可行,但要求改造主镜像,需权衡;
  • 不可缩容、成员固定的 StatefulSet——只需 --on-start(模式 1 即可),无需常驻的 on-change 监听。

作为参考实现,peer-finder 展示了"不依赖 Kubernetes API、纯 DNS 完成成员发现"这一极简而稳健的思路:1 秒轮询 SRV、以 Service 为拓扑锚点、以 stdin 为脚本接口、以"见到自己"为触发前提。理解了这四点,即便不直接使用该二进制,也能在自研中间件时复刻这套模式,或为部署在 StatefulSet 上的存量应用快速补齐分布式协调所需的成员感知能力。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.74 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
595
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.63 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
518
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
389