背景#

这套 homelab 有两个 K3s 集群,对外服务都挂在 meirong.dev 的子域名下,从 Cloudflare Tunnel 进来。2026 年 7 月之前,加一个新子域名固定是两步手改:

  1. cloudflare/terraform/terraform.tfvars 里的 ingress_rulesterraform apply 建一条指向 tunnel 的 CNAME;
  2. 改集群里的 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-httproutegateway-grpcroutegateway-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

flowchart LR route["HTTPRoute
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 列举。完整的请求路径变成:

flowchart LR user["访客"] --> edge["Cloudflare 边缘
(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.matchLabelsapp.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 的路子走一遍再说。

参考资料#

相关文章#