用 external-dns 自动创建 DNS 记录:source、target、所有权登记,以及两个偏危险的默认值
目录
一个服务要能被公网访问,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[].host → spec.tls[].hosts → hostname 注解 → --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 注解(不在这个文件里)、registry 加 txtOwnerId 上:
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 走一遍再说。
参考资料#
- external-dns 官方文档:Service source、Ingress source、Gateway API sources、TXT registry、flags 全表
plan/policy.go:三档 policy 的实现- external-dns v0.22.0 release notes