用 external-dns 接管 homelab 的子域名 DNS
目录
背景#
这套 homelab 有两个 K3s 集群,对外服务都挂在 meirong.dev 的子域名下,从 Cloudflare Tunnel 进来。2026 年 7 月之前,加一个新子域名固定是两步手改:
- 改
cloudflare/terraform/terraform.tfvars里的ingress_rules,terraform apply建一条指向 tunnel 的 CNAME; - 改集群里的
gateway.yaml,加一条 HTTPRoute 把 hostname 指到 Service。
两处分属不同工具、不同目录,必须同步改。漏一处就是半通:只改 tfvars,流量能转发进来但集群不知道往哪路由;只改 gateway,集群能路由但公网查不到这个名字。当时也没有任何告警会告诉我漏了,我每次都得翻以前的 commit 照抄。
DNS 那一半本来就是冗余的:HTTPRoute 里已经写了 hostname,再去 tfvars 抄一遍不产生任何新信息。既然能从集群状态推出来,就不该让人抄。external-dns 就是干这个的。
external-dns 在做什么#
它是一个 controller,把集群里某些资源当作 DNS 的期望状态,按固定周期算出差异,写进 DNS provider。配置上绕不开 source、provider、registry 这三项。
source 是从哪读期望状态,可以是 Service、Ingress,也可以是 Gateway API 的那几种 route(gateway-httproute、gateway-grpcroute、gateway-tlsroute 等)。provider 是往哪写,Cloudflare、Route 53、Google Cloud DNS 之类。
registry 容易被当成可选项跳过。它管的是「这条记录是我建的」这个登记:默认值 txt 会给每条它管的记录额外写一条 TXT,内容形如 heritage=external-dns,external-dns/owner=<owner-id>,external-dns/resource=<source>。没有这层登记,它分不清 zone 里哪些记录归自己管,后面共存和迁移都谈不上。
我这边的组合是 gateway-httproute + cloudflare + txt:
hostnames: llm.meirong.dev"] -->|watch| edns["external-dns
source: gateway-httproute"] gw["Cilium Gateway
target 注解"] -->|提供 CNAME 目标| edns edns -->|CNAME + owner TXT| cf["Cloudflare zone
meirong.dev"]
它只解决 DNS 记录这一层。Cloudflare Tunnel 的 ingress 配置、WAF 规则,它都不碰。这条边界我当初画错过。
为什么不是把 DNS 塞回 Terraform#
我当时认真比过三个方向:
| 方案 | 我的结论 |
|---|---|
| 维持两步手改 | 痛点不消除,子域名越多越容易漂移 |
| Crossplane + Cloudflare provider | 否掉。cdloh/provider-cloudflare 2023-01 之后就没维护、没有 v2;而且对我这种单人、云上资源基本静态的场景,多一个控制面属于过度工程 |
| external-dns | 选它。controller 本身很轻(我这两个实例 7 天内存峰值 82Mi / 150Mi),Gateway API 的 HTTPRoute 已经是一等 source,直接消掉第 1 步 |
也想过反方向,把 gateway.yaml 也塞进 Terraform。走不通:Terraform 不 watch HTTPRoute,不感知集群运行态。合到一处以后,加子域名照样得有人去改 Terraform 的输入,那一步还在。
我这套的配置#
homelab 侧的 values 大概长这样(去掉了监控和调度相关的部分,下面再单说):
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 是作用域。我这个 Cloudflare 账号下不止一个 zone,写死 meirong.dev,别的它连看都不看。--cloudflare-proxied 让建出来的记录走 Cloudflare 代理(橙云),跟 Terraform 一直以来建的那批保持一致。txtOwnerId 是这个实例在 owner TXT 里的身份,两个集群共用一个 zone 时它是唯一的隔离手段。
policy 这行我宁可显式写死。官方 README 的说法是「record deletion requires --policy=sync; with --policy=upsert-only records are never deleted」,而我写这篇时跑的版本(chart 1.21.1 / app v0.21.0)里 --policy 的默认值恰好是 sync,也就是默认那档是更激进的那档。写成 upsert-only,误删一个 HTTPRoute 就不会连带删掉公网 DNS。我改 manifest 挺随手的,这条保险有用。
Gateway 没有地址时,target 得手工给#
gateway-httproute 这个 source 算 CNAME 目标时,逻辑是:如果父 Gateway 上有 target 注解就用注解的值,否则遍历该 Gateway 的 status.addresses。
我的 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
在这上面浪费过时间:--default-targets 和 --force-default-targets 两个 flag 我都试了,对 addressless 的 gateway source 都不生效,注解是当时唯一能用的机制。注解还得进 git 才行,这个 Gateway 归 ArgoCD 管、开了 selfHeal,手动 kubectl annotate 下一轮同步就被回滚。
Token 走 Vault#
Cloudflare 的 zone 级 API token 放在 Vault,经 External Secrets Operator 落成集群里的 Secret:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-dns-cloudflare
namespace: external-dns
spec:
refreshInterval: "1h"
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: external-dns-cloudflare
creationPolicy: Owner
deletionPolicy: Retain
data:
- secretKey: api-token
remoteRef:
key: homelab/external-dns
property: api_token
两个集群读的是同一把 token。它是 zone 级的,对两边的子域名都有效。
加一个子域名现在只剩一步#
现在新增一个对外服务,我只写一个文件,比如 k8s/helm/manifests/gateway/route-litellm.yaml:
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
push 之后 ArgoCD 同步,external-dns 在下一个 reconcile 周期建出 CNAME 和 owner TXT,cloudflare/terraform 不用再动。这句「只剩一步」在最初那版并不成立。
我以为端到端通了,其实只验了一半#
最早那次验证,我建了个一次性的测试 HTTPRoute edns-verify-test.meirong.dev,看到 external-dns 在 Cloudflare 建出了 CNAME 和 TXT,dig 也能解析,就认为端到端通了。
漏掉的是 cloudflared 那一侧:它的 ingress 当时是每个 hostname 一条显式规则加 http_status:404 兜底,没有通配。全新子域名拿到了 DNS 记录,请求进了隧道却匹配不上任何规则,直接落 404。我验的只是 DNS 解析,没验 HTTP 真能通。
教训:external-dns 只管 DNS 这一跳。链路上其他按 hostname 做匹配的环节,隧道、反向代理、证书,它都不会替你改。验证得从公网发一个真实请求,别停在
dig。
修法是把隧道 ingress 改成单条通配路由:
ingress = [
{
hostname = "*.meirong.dev"
service = var.gateway_service
},
{
service = "http_status:404"
},
]
通配到 gateway 是安全的:gateway 对没有对应 HTTPRoute 的 host 本来就返回 404。改完之后 ingress_rules 那个变量整个删掉了,Terraform 侧从此不再逐个 hostname 列举。完整的请求路径变成:
(CNAME 由 external-dns 建)"] edge --> tunnel["cloudflared
*.meirong.dev 通配路由"] tunnel --> gw["Cilium Gateway"] gw --> svc["Service
按 HTTPRoute 分发"]
把既有记录迁过去:预埋 ownership TXT#
上线 external-dns 的时候,zone 里已经有一批 Terraform 建的记录在跑着。我用的办法是先手工把 ownership TXT 写进去,让它一上来就认出这些记录归自己管。删了重建也能到同样的终态,代价是中间有一段解析空窗。
做法是给每条要移交的记录预埋一条 cname-<host> 的 TXT,内容按 external-dns 自己的格式写:
cname-llm.meirong.dev
"heritage=external-dns,external-dns/owner=homelab-externaldns,external-dns/resource=httproute/litellm/litellm"
翻车的地方在外层那对双引号。通过 Cloudflare API 建 TXT 时,content 里带引号就读回来带引号,不带则都不带;external-dns 自己写出来的是带引号那种。差这一对引号,它就认不出这是自己的 owner 记录。我当时先建了个一次性 probe HTTPRoute,逐字节比对它的真实产物,才敢照着写那批 TXT。
预埋完等 reconcile。判断接管成功、而不是删了重建,我看两个信号:
- external-dns 日志连续多轮打
All records are already up to date; - 每条 CNAME 的
modified_on全程没变。
modified_on 这条是关键:它一变就说明记录被重写过,那期间有解析空窗。移交当时两个集群加起来 15 条记录(homelab 5 条、oracle-k3s 10 条,含 SSO 那条)都是 modified_on 不变地过来的,之后陆续又加了 7 个。
移交完成后再把 Terraform 那边收干净:terraform state rm 掉这些 cloudflare_dns_record,把 for_each 从原来的 ingress_rules 换到一个默认空的新变量。这一步我原计划里写错过一次。本来打算顺手把 ingress_rules 里的 key 也删掉,但那个变量同时驱动 DNS 记录和 cloudflared 的隧道路由,删 key 会把隧道路由一起带走,5 个服务当场 404。得先把 DNS 和隧道解耦,再各自收。
一个 zone、两个集群、两个实例#
每个 external-dns 实例只 watch 它所在集群的 HTTPRoute,所以两个集群得各跑一个。两个实例写同一个 zone、用同一把 token,靠 txtOwnerId 区分:homelab 是 homelab-externaldns,oracle-k3s 是 oracle-externaldns。
owner id 撞了会互相误判所有权、去改对方的记录。官方文档在 TXT registry 那页也专门警告过这一点,说共享 hosted zone 里如果有集群跑 policy=sync 配上 --migrate-from-txt-owner=default,可能会删掉属于其他集群的记录。我这边两个实例都是 upsert-only、hostname 也不重叠,所以共存是干净的。
还有个跟 external-dns 无关、但两个实例都进 ArgoCD 就会碰到的问题。ArgoCD 默认拿 Application 名当 Helm release 名,而 release 名会进 Deployment 的 spec.selector.matchLabels(app.kubernetes.io/instance),那是不可变字段。同一个 argocd namespace 里两个 App 不能重名,第二个只能叫 external-dns-oracle;不管的话,采纳现存 release 时会直接 5 删 5 建。fullnameOverride 治不了,它只改对象名,instance 标签照旧泄漏 release 名。得显式写:
helm:
releaseName: external-dns
valueFiles:
- $values/k8s/helm/values/external-dns-oracle.yaml
加上这行之后,渲染结果和 live 对象逐字节一致,首次同步是 no-op。
它活着但不干活#
external-dns 有一类失效方式,通用的 pod 级告警(KubePodCrashLooping / KubePodNotReady)抓不到:pod 一直 Running/Ready,但 reconcile 静默失败。token 被吊销或过期、Cloudflare API 限流、HTTPRoute 的 RBAC 断了,都会落到这个状态。因为 upsert-only 从不删记录,存量域名照常解析,看起来一切正常;只有新加的 HTTPRoute 永远拿不到 DNS 记录,而且没人会告诉你。
补法是打开 chart 自带的 ServiceMonitor 抓它 :7979 的 metrics:
serviceMonitor:
enabled: true
additionalLabels:
release: kube-prometheus-stack
release 这个标签少不了,否则 kube-prometheus-stack 的 serviceMonitorSelector 不会采纳这个 ServiceMonitor。同样的坑我在别的组件上也踩过。
核心那条告警盯的是「上一次成功同步」的时间戳有没有在推进:
- alert: ExternalDNSReconcileStalled
expr: time() - external_dns_controller_last_sync_timestamp_seconds > 600
for: 10m
健康时 reconcile 周期是 1m,超过 10m 没推进就是卡住了。剩下三条分别盯 external_dns_registry_errors_total 上升(写 Cloudflare 报错)、external_dns_source_errors_total 上升(读 HTTPRoute 报错),以及 metrics 整体消失。最后这条是给前面几条兜底的:series 没了,上面那些规则就变成 no data,不会触发。
同一类疏忽还有一处,跟监控无关。这两个实例最初都没写 resources,chart 默认是 {},pod 落成 BestEffort,而我又给它们标了 priorityClassName: high。这两件事互相矛盾:kubelet 驱逐排序先看 QoS,BestEffort 的 request 恒为 0,永远落在「超出 request」那一档,priority 只在同一档内比较。于是一个标着 high 的组件,排在标着 bulk 的个人应用前面被驱逐。按 7 天实测峰值补上 request 才对得上:
resources:
requests:
cpu: 10m
memory: 96Mi
limits:
memory: 256Mi
homelab 这个实例 7 天内存峰值 82Mi、CPU p95 2m;oracle 那个管的 HTTPRoute 更多,峰值 150Mi、CPU p95 1m,request 相应给到 160Mi。
v0.22.0 的两个破坏性变更#
我写这篇时(2026-08-22)跑的是 chart 1.21.1 / app v0.21.0,这也还是官方 Helm 仓库里最新的 chart(发布于 2026-04-30)。但二进制的 v0.22.0 前一天(2026-08-21)发了,里面有两个变更会打到上面这套配置。
一个是注解前缀转正。PR #6424 把默认前缀从 external-dns.alpha.kubernetes.io/ 换成 external-dns.kubernetes.io/,理由是这套注解已经稳定很多年,继续带 alpha 不合适。我对着 v0.21.0 和 master 的 source/annotations/annotations.go 比过,DefaultAnnotationPrefix 这个常量确实改了,同一文件里没留 alpha 的回退分支。
落到我这套上:Gateway 那条 external-dns.alpha.kubernetes.io/target 会不再被读到,而这个 Gateway 是 addressless、status.addresses 为空,算出来的 target 就没了。发布说明的措辞比我预想的重,原话是「This change can delete all your DNS records」。给的两条路是改注解,或者显式加 --annotation-prefix=external-dns.alpha.kubernetes.io/;还建议先用 --dry-run=true 配 --policy=create-only 试一遍。
另一个是 --policy 变成必填、没有默认值(PR #6508),官方说就是为了兜住上面那个前缀变更带来的误删风险。v0.21.0 里它默认是 sync,到 v0.22.0 不给就起不来。我这两份 values 本来就显式写了 upsert-only,不用改。
也正因为写了 upsert-only,前缀这件事对我只是「新记录建不出来」。当初要是图省事吃了默认的 sync,同一个变更的后果就是存量记录被清空。写那行的时候我只当是买个保险,没想到兜住的是这个。
小结#
这次改动真正消掉的 toil 只有一步:加子域名不用再改 Terraform。但要让这一步成立,周边花的功夫比它本身多——Gateway 没地址得手工给 target,隧道得先改成通配路由,既有记录得预埋 TXT 才能零停机移交。
真要说下次还会照做的,是两条跟默认值有关的。一条是 policy 别吃默认值,前面那节已经说透了。另一条是迁移既有记录时盯 modified_on:只看域名解不解析,接管和删了重建长得一模一样,而后者中间是有空窗的。
现在两个集群共 22 条对外 hostname(仓库里的 HTTPRoute 数,homelab 9 条、oracle-k3s 13 条)都跑在这套上。v0.22.0 我还没升,chart 也还没跟上;等跟上了,按发布说明那条 dry-run 的路子走一遍再说。
参考资料#
- external-dns 官方文档:Gateway API sources、TXT registry、flags 全表
- external-dns v0.22.0 release notes
- PR #6424 — switch default annotations prefix to GA
- PR #6508 — require explicit –policy flag