一个服务要能被公网访问,DNS 那条记录总得有人建。手工建的麻烦不在于费事,在于它和集群状态会分家:hostname 已经写在 Ingress 或 HTTPRoute 里了,去 DNS 那边再抄一遍,就多出一份必须同步维护的副本。

漏掉一边的表现还挺讨厌。只建了 DNS 记录,流量能进来但集群不知道往哪路由;只加了路由,集群能路由但公网查不到这个名字。两种都是「半通」,而且不会有任何告警告诉你漏了——我在自己的 homelab 上按这个流程加过十几个子域名,每次都得翻以前的 commit 照抄。

external-dns 收掉的就是这份副本。它是个 controller,watch 你指定的那类资源,把上面的 hostname 当作 DNS 的期望状态,周期性地和 DNS provider 对齐。

版本会影响下面每一条注解怎么写。我写这篇时(2026-09-08),官方 Helm 仓库里最新的 chart 是 1.21.1(2026-04-30 发布,appVersion: 0.21.0),而二进制最新是 v0.22.0(2026-08-20 发布)。v0.22.0 把默认注解前缀从 external-dns.alpha.kubernetes.io/ 转正成 external-dns.kubernetes.io/ 且不再回退,发布说明原话是「This change can delete all your DNS records」。下面统一按 chart 装出来的 v0.21.0 写,也就是带 alpha 的那个前缀。

手工建的那条记录,是集群状态的副本#

值得先想清楚这活为什么该交出去,因为答案决定了交给谁。

我原来那两步里,DNS 那一半不产生任何新信息:HTTPRoute 的 spec.hostnames 已经写了 llm.meirong.dev,Terraform 变量里再写一遍 llm,只是把同一个事实抄到第二个地方。凡是能从现有状态推导出来的东西,让人来抄就迟早会漂移。

那为什么不是「把 gateway.yaml 也塞进 Terraform」,让一个工具管两头?因为 Terraform 不感知集群运行态,它不会去 watch HTTPRoute。无论怎么合并,加一个子域名仍然是人去改 Terraform 的输入。方向得反过来:让集群成为真相源,DNS 跟着集群走。这才是 controller 这个形态解决的问题——它在集群里,能 watch,能在资源变化后自己去对齐。

交给 controller 之后要自己回答的三件事#

「DNS 跟着集群走」这句话落到配置上会散成三个独立的决策,三者互不替代,缺一个它就没法动手:读哪种资源上的 hostname、这些 hostname 指向哪里、以及它凭什么认为某条记录是自己的。三件事在 values 里各占几行,下一节会拼起来看。

读哪种资源上的 hostname#

这一项就是 sources。常用的是三类,各自取 hostname 的路子不一样:

source hostname 从哪来
service hostname / internal-hostname 注解 → --compatibility--fqdn-template
ingress spec.rules[].hostspec.tls[].hostshostname 注解 → --fqdn-template
gateway-httproute route 的 spec.hostnames

Service 那行的意思是:光有一个 LoadBalancer Service 它不会自作主张给你建记录,得你打注解告诉它域名叫什么。Ingress 相反,spec 里本来就写着 host,什么注解都不用打就能用;想只信其中一路时用 ingress-hostname-source 注解,annotation-only 忽略 spec 里那两处,defined-hosts-only 反过来忽略注解。

我这边用的是 gateway-httproute。HTTPRoute 的 hostname 是 Gateway API 规定的必要字段,本来就没有第二处可抄,加一个对外服务现在只写一个文件:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: litellm
  namespace: litellm
spec:
  parentRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: homelab-gateway
      namespace: kube-system
      port: 80
  hostnames:
    - "llm.meirong.dev"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - group: ""
          kind: Service
          name: litellm
          port: 4000

Service 和 Ingress 这两行我没在自己集群上跑过,取自官方 Service source 文档Ingress source 文档;HTTPRoute 那段是仓库里真在跑的文件。

target 指向哪里#

hostname 有了,记录的值还得有人给。这条链和上面那条是分开算的:先看资源上有没有 target 注解,有就用注解的值;没有才去读运行态里的地址。「运行态里的地址」具体指什么,按资源类型分岔:

资源 没有 target 注解时读什么
Ingress status.loadBalancer.ingress 里非空的 ip / hostname
Service(LoadBalancer) spec.externalIPs,再 status.loadBalancer.ingress
Service(普通 ClusterIP) 默认不建记录,要打 internal-hostname 注解或开 --publish-internal-services,值取 spec.clusterIP
Service(headless) 遍历选中的 endpoint,Pod 的 target 注解 → endpoints-type 注解 → Pod IP
Service(NodePort) spec.externalTrafficPolicy 挑节点,节点地址受 access: public / private 注解影响
Gateway API route 父 Gateway 的 target 注解,否则该 Gateway 的 status.addresses

「普通 ClusterIP 默认不建」这条最容易撞:注解打了、日志也没报错,但记录就是不出来。

我这边撞的是另一头。Cilium Gateway 是 NodePort 类型、藏在 Cloudflare Tunnel 后面,压根没有可读的 LB 地址,status.addresses 是空的,所以只能在 Gateway 上显式给:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: homelab-gateway
  namespace: kube-system
  annotations:
    external-dns.alpha.kubernetes.io/target: <tunnel-id>.cfargotunnel.com

两个看起来该管这件事的 flag——--default-targets--force-default-targets——我都试过,对 addressless 的 gateway source 不生效,注解是唯一的路。注解还得写进 git:这个 Gateway 归 ArgoCD 管、开着 selfHeal,手动 kubectl annotate 会在下一轮同步被回滚。

哪些记录归这个实例管#

第三件事最容易被当成可选项跳过,但它决定了这个 controller 敢不敢碰 zone 里已有的东西。registry: txt 会给每条它建的记录再挂一条 TXT 做登记,内容里带着 owner id。没有这层登记,它区分不出哪些记录是自己建的、哪些是别人的。

owner id 就是 txtOwnerId。我两个集群各跑一个实例、写同一个 zone、用同一把 zone 级 token,靠这一项分家:

txtOwnerId: homelab-externaldns
txtOwnerId: oracle-externaldns

不写也能跑,这是它危险的地方——--txt-owner-id 的默认值是字符串 default,两个集群都不写就都叫 default,于是各自把对方的记录认成自己的,开始互相改。官方文档在 TXT registry 那页专门警告过共享 hosted zone 的场景。我这两个实例 hostname 不重叠、又都是 upsert-only,共存是干净的。

如果 zone 里已经有一批手工或 Terraform 建的记录要移交给它管、又不想有解析空窗,做法是先按它的格式手工预埋 ownership TXT。格式细节(外层那对双引号差一个都认不出来)和怎么区分「接管」与「删了重建」,我在 homelab 那次接管里单独写了一节。

三个答案落成一份 values#

装的时候只有一个 chart 和一份 values:

helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm repo update

helm upgrade --install external-dns external-dns/external-dns \
    --namespace external-dns --create-namespace \
    --version 1.21.1 \
    --values values/external-dns.yaml

下面这份是 homelab 那个实例现在跑着的,上面三个决策分别落在 sources、Gateway 注解(不在这个文件里)、registrytxtOwnerId 上:

provider:
  name: cloudflare

env:
  - name: CF_API_TOKEN
    valueFrom:
      secretKeyRef:
        name: external-dns-cloudflare
        key: api-token

sources:
  - gateway-httproute

domainFilters:
  - meirong.dev

policy: upsert-only
registry: txt
txtOwnerId: homelab-externaldns

interval: 1m

extraArgs:
  - --cloudflare-proxied

多出来的几行里,domainFilters 划的是作用域,同一个账号下的其它 zone 它就不会去看;--cloudflare-proxied 让建出来的记录走 Cloudflare 代理,跟 Terraform 一直以来建的那批格式保持一致。

两个偏危险的默认值:policy 的 sync 和 owner id 的 default#

owner id 那个上面说了。另一个是 policy,它决定这个 controller 敢做哪几类变更。三档的语义我照 plan/policy.go 里的定义看过:

policy 允许的变更
sync create、update、delete 全做,注释原话是「sync allows all changes without restriction」
upsert-only 建和改,删除被过滤掉
create-only 只建新记录,改和删都被过滤掉

v0.21.0 里这个 flag 默认 sync,也就是默认那档是最激进的那档。v0.22.0 起它变成必填、没有默认,官方说就是为了兜住前缀转正带来的误删风险。

不管跑哪个版本,我更倾向把这行显式写死成 upsert-only。误删一个 HTTPRoute 不会连带删掉公网 DNS;开头提到的那个前缀变更打到我这份配置上时,后果也只是新记录建不出来,而不是存量记录被清空——差别就在这一行。

验证到哪一步才算通#

看它自己怎么说是最快的:

kubectl -n external-dns logs deploy/external-dns --tail=20

稳定态下会连续多轮打 All records are already up to date。然后建一个一次性的测试 hostname,去 provider 侧确认真的多出两条记录——一条 CNAME(或 A,取决于 target 是 hostname 还是 IP)和一条 owner TXT。

dig 通了不等于服务能访问。它只管 DNS 这一跳,隧道、反向代理、证书这些同样按 hostname 匹配的环节都不在它手里。我第一次验证就停在 dig,结果解析正常、HTTP 落 404(那次的经过)。真实请求得从公网发。

长期还有一种失效形态,通用的 pod 告警(KubePodCrashLooping / KubePodNotReady)看不见:pod 一直 Running/Ready,reconcile 却在静默失败。token 过期或被吊销、provider API 限流、读 source 的 RBAC 断掉,都会落到这里。配了 upsert-only 反而更难发现——存量记录不会被删,域名照常解析,只有新加的 hostname 一直拿不到记录。

chart 自带 ServiceMonitor,打开它抓 :7979

serviceMonitor:
  enabled: true
  additionalLabels:
    release: kube-prometheus-stack

少了 release 这个标签,kube-prometheus-stack 的 serviceMonitorSelector 就不会把它挑进去(这个 selector 我在 postgresql 那次采集排查里踩过)。真正兜住上面那种失效的是这条规则,盯的是「上一次成功同步」的时间戳有没有在推进:

- alert: ExternalDNSReconcileStalled
  expr: time() - external_dns_controller_last_sync_timestamp_seconds > 600
  for: 10m

阈值跟着 interval 定:我配的是 1m,10m 还没推进就不正常了。我另外还配了三条(写 provider 报错、读 source 报错、metrics 整体消失兜底),那三条为什么长成那样在上面那篇里。

小结#

把手工建 DNS 这件事交出去,换来的不是「不用配了」,而是把一次性的抄写换成三个长期成立的决策:读哪种资源、target 指向哪里、哪些记录归这个实例。前两个错了立刻能看出来——记录建不出来,或者建出来指错地方。第三个错了不一定当场发现,等到两个集群互相改记录才暴露。

两个默认值我宁可每份 values 里都显式写死:policy 在 v0.21.0 默认 sync--txt-owner-id 默认 default,都是「不写也能跑,出事时代价最大」的那类。

要从 chart 现在给的 v0.21.0 升到 v0.22.0,注解前缀会跟着变,按发布说明先用 --dry-run=true--policy=create-only 走一遍再说。

参考资料#

相关文章#