给内部系统配域名这事我折腾了不少时间。事情的起因其实很普通托管 Kubernetes 集群里服务越来越多IP 不断在变DNS 记录却一直靠手动加。做过一次以后我就确信必须有个东西能让域名跟着 Kubernetes 资源自动变。最后落地的是 Ingress 加 ExternalDNS 的组合Ingress 负责把服务域名和路由规则声明出来ExternalDNS 负责把这些声明转成云厂商私网解析 Zone 里的记录。这套方案配好以后你新建一个 Ingress 对象内部域名几十秒内就能解析服务下线记录也自动消失不用再登录控制台一条条删。这篇文章就围绕内部域名自动解析的整个落地过程来写适合正在托管 K8s 集群上跑内部服务、又不想天天改 hosts 或者手工维护 DNS 的同学。1. 内部域名自动解析的痛点与方案选型1.1 为什么需要内部域名自动解析先说说我为什么折腾这件事。托管 Kubernetes 集群里内部系统会越铺越多监控面板、日志平台、内部 API、管理后台、定时任务控制台随便举几个就能凑出一张表。每个服务都有 ClusterIP 或者 LoadBalancer IP但这些 IP 是会变的。Pod 重建、节点扩缩容、负载均衡重新调度都可能让 IP 变化。客户端如果直接记 IP过几天就会发现某个调用断了得重新去对一遍 IP非常难受。域名是比 IP 好用的抽象但域名不会自己出现。在传统的运维习惯里大家会在 DNS 管理后台手工添加 A 记录。可一旦业务跑在 K8s 上服务的生命周期是以分钟甚至秒为单位的。今天测试环境要开一套新服务明天某套内部系统要下线每次都要手动配 DNS配合不好就会出现“域名还没生效”或者“域名被删了但服务还在跑”的情况。更麻烦的是不同环境的同学还会私自改自己的 hosts 绕过问题结果过一阵又发现所有人拿到的解析内容不一致。我们需要的其实是一套联动机制服务在的时候记录在服务下线记录也应该自动消失。域名不应该和某台具体的机器绑定应该和 Kubernetes 里声明的资源绑定。谁负责服务对外暴露谁就负责提供域名DNS 只是最后一步落地的执行者。这个场景下ExternalDNS 是天然合适的角色。1.2 手动维护和自动同步哪个更值得选我在不同阶段试过几种方案先说结论如果系统数量少、变更频率低手动维护勉强能用但只要上了 K8s服务上下线变成日常操作自动化是早晚的事。方案维护成本生效速度出错概率推荐度全公司改 hosts每台机器都要改遗忘率极高只对当前机器立即生效高临时场景控制台手动加 DNS 记录每次上下线都要登录控制台传播需要几十秒到几分钟中等忘记更新就是事故系统少可以ExternalDNS 自动同步部署一次后基本零维护秒级到分钟级低所有操作可追溯强烈推荐先说 hosts 方案。它的优点是零门槛不用申请任何权限本地改一下就行。缺点也很明显每一台需要访问的机器都要改你没法保证所有机器同步修改。你改了 A 机器B 机器没过几天还是旧记录。而且 hosts 文件在容器和自动扩缩容场景下基本不可控新加一台工作节点就等于新增一个需要维护的入口。这种方案只能在小规模临时调试时用。再说手动加记录。如果一个内部系统一个月才变更一次控制台点几下是可以接受的。但 K8s 里的服务往往要配合持续集成做频繁发布每次发布如果都要人去同步 DNS流程就多了一个失败点。更常见的坑是上线时有人记得加记录下线时没人记得删残留的旧记录把域名指向已经废弃的入口 IP排查起来非常费劲。ExternalDNS 的核心价值在于所有权和自动回收。它会把 Kubernetes 资源里声明的域名视为唯一事实来源周期性地和云厂商私网解析里的实际记录做对比然后增、改、删。记录是跟着资源走的资源删了记录没有理由继续存在。1.3 为什么这套方案里离不开 IngressExternalDNS 其实不挑数据来源Service 可以Node 也可以。但在内部系统对外提供访问这个场景里Ingress 是最合适的载体。原因很简单一个域名下面通常会有多个路径比如 app.internal.example.com 下挂 /api、/ui可能对应两个不同的 Service。Ingress 天然就是描述“域名 路径”到后端转发的资源Ingress 里的 host 又正好是域名等于路由规则和 DNS 记录的来源是同一份声明。另外Ingress 控制器通常会以负载均衡的形式暴露一个稳定的入口地址你希望 DNS 解析到的目标就是这个入口 IP而不是某一个具体 Service 的 ClusterIP。ClusterIP 只在集群内部有效出了集群没法用LoadBalancer 的入口 IP 则可以被整个 VPC 内的机器访问。用 Ingress 作为 ExternalDNS 的输入源语义上最顺。当然Inertness 之外的一些特殊场景比如内部异步任务之间直接走 Service 域名也可以把 ExternalDNS 的 source 同时配成 service。但常规的内部 Web 系统和 API 网关我建议一律走 Ingress后续做认证、限流、灰度也都有统一的切入点。2. 整体架构与核心原理拆解2.1 从 Ingress 到 DNS 的数据流长什么样先整体看一下链路让后面每一步操作都能对上号。开发同学执行 kubectl apply创建或者更新一个 Ingress 对象在 spec.rules[].host 里填内部域名。ExternalDNS 作为控制器通过 Kubernetes API watch 到新增或修改的 Ingress 资源。ExternalDNS 把 host 整理成一条“期望记录”域名是 app.internal.example.com类型是 A 记录目标 IP 是 Ingress 控制器的入口地址。ExternalDNS 调用云厂商私网解析服务的 API在对应的私网 Zone 里创建解析记录同时维护一条用于标识所有权的 TXT 记录。VPC 内的其他系统请求 app.internal.example.com 时DNS 解析器在私网 Zone 里命中记录返回入口 IP。这里面有一个容易误解的点ExternalDNS 本身并不承载 DNS 查询流量它只是把 Kubernetes 的期望状态同步到 DNS 服务。你不需要给 ExternalDNS 开 53 端口也不需要把它暴露给客户端。它只会在同步周期内主动调用云厂商 API所以把它理解成一个“Kubernetes 资源到 DNS 记录的同步器”最准确。2.2 ExternalDNS 的三层模型source、endpoint、providerExternalDNS 内部有三个核心概念理解清楚之后排查问题会顺很多。source记录的数据来源可以是 Ingress、Service、Node、Istio Gateway 等。每个 source 负责从 Kubernetes API 里读取对应资源把需要管理域名的信息抽取出来。endpoint经过 source 处理之后得到的一条记录条目。它描述的是“期望的 DNS 记录长什么样”一般包含 DNS 名称、记录类型 A/AAAA/CNAME、目标地址列表。provider云厂商 DNS 服务的实现负责把 endpoint 转换成实际的 API 调用比如私网解析创建 A 记录、更新 TTL、删除记录。同步循环大概是这样的每隔一个 intervalExternalDNS 从 source 重新生成一组 endpoint然后对比 provider 当前已有的记录根据 policy 决定新增、更新还是删除。对比的逻辑不是简单的字符串比对它会先根据 owner 和 TXT 记录识别出哪些记录是自己管理过的再决定动作。同步周期的默认值一般是 1 分钟。我在测试环境里习惯调成 30 秒这样创建 Ingress 后很快就能看到记录出现。但生产环境不建议小于 30 秒一是云厂商 API 有频率限制二是一分钟级别的延迟对内部系统来说完全可以接受。2.3 TXT 所有权记录防止被人误删或覆盖ExternalDNS 默认会启用 registrytxt也就是给每条自己管理的记录旁边再写一条 TXT 记录。为什么这么做举一个很典型的例子假设私网 Zone 里有一条记录是运维同学手工加的而 ExternalDNS 又恰好管理同一个域名如果没有所有权标记它在做 sync 的时候会分不清“这条记录是我该管的”和“这条记录是别人手动建的”。有可能直接把手工记录当成多余记录删掉。TXT 记录的内容类似这样heritageexternal-dns,external-dns/ownerdefault。ExternalDNS 在扫描现有记录时看到这条 TXT就知道对应的 A 记录是自己负责的可以安全地修改或删除如果只有 A 记录却没有对应的 TXT那就视为“外部记录”在 upsert-only 模式下不会去动它。每个 ExternalDNS 实例还要配置一个 --txt-owner-id。这个 ID 是所有者标签相当于给记录盖一个章。如果不同环境或者不同集群的 ExternalDNS 往同一个 Zone 里写必须使用不同的 owner id否则两个实例都会认为记录是自己创建的同步时可能会互相覆盖出现抖动的现象。2.4 云厂商接入的两种凭证方式把记录写到私网解析核心要解决“谁来调用 API”的权限问题。我实际操作中遇到过两种方式。第一种是传统密钥方式。在 ExternalDNS 的 Deployment 里通过环境变量或者 Secret 注入一组用于 DNS 管理的 API 密钥。优点是配置直观改个环境变量就能跑缺点也明显长期密钥直接躺在集群里Pod 一旦被攻破密钥就是泄露点。这种方式适合在非生产环境快速验证。第二种是托管集群的免密授权方式。很多云厂商的托管 K8s 集群已经支持工作负载身份联合给运行 ExternalDNS 的服务账户绑定一个角色Pod 启动时自动获取临时凭证。这个方式更安全临时凭证有时效而且可以使用最小权限策略只放行私网解析相关接口。我更推荐在生产环境用第二种。在配置角色策略时尽量把权限范围缩小到某个 Zone不要给整个账号的所有 DNS 接口都放开。权限越小事故半径越小。无论哪种方式ExternalDNS 启动时都会先去列出账号下的所有 Zone再根据 --domain-filter 过滤所以至少需要保证账号能调用 Zone 列表和记录列表这类的只读接口。3. 环境准备与 ExternalDNS 部署实操3.1 动手前需要确认的前置条件在写 YAML 之前先把下面这些条件对一遍不然装到一半才发现环境不满足很浪费时间。前置条件说明托管 K8s 集群版本建议在 1.20 以上Ingress 相关 API 稳定kubectl 权限能创建 Namespace、Deployment、ServiceAccount、ClusterRole、Ingress私网解析 Zone已创建并关联集群所在 VPC域名后缀建议 internal.example.comDNS 写权限已为 ExternalDNS 配置账号或角色的相关权限Ingress 控制器集群里已经有可用的 Ingress Controller比如社区常用实现有一个经常被忽略的问题私网解析 Zone 的生效范围是按 VPC 绑定的。如果 Zone 绑定的 VPC 和 K8s 集群所在的 VPC 不是同一个那么集群内部和 VPC 内的机器在解析时都可能拿不到记录。所以一开始就要确认好 VPC 的对应关系别等记录创建了才发现解析不到。3.2 创建私网解析 Zone 的要点创建 Zone 的步骤不复杂一般在云厂商控制台找到私网解析产品创建一个私有域比如 internal.example.com然后关联到集群所在的 VPC等状态变正常就行。但有三个细节值得留意。第一域名后缀尽量别用 .local。很多操作系统和容器环境对 .local 有特殊处理容易产生与 mDNS 相关的解析干扰。我见过因为用了 .local 后缀导致部分机器解析结果时好时坏的情况排查起来很头疼。内部域用 internal.example.com 这种带子域的形式最稳妥。第二别直接拿公网注册的一级域名做内部 Zone除非你非常确定内部解析和外网解析的预期完全一致。比如你手上有 example.com内部如果也想用 example.com 作为私有域同时公网还有真实解析两者语义会互相干扰。建议内部统一用 internal.example.com 这种隔离明显的子域。第三创建 Zone 时注意 TTL 设置。如果默认 TTL 是几小时你后面在测试环境验证的时候会等到怀疑人生。建议先把 TTL 改成 60 秒或者 300 秒观察期过了再调回合理值。3.3 用 YAML 部署 ExternalDNS 的具体步骤我倾向于直接用 YAML 部署不额外引入 Helm这样资源之间的关系更透明。需要依次创建 ServiceAccount、ClusterRole、ClusterRoleBinding、Deployment。创建服务账户apiVersion: v1 kind: ServiceAccount metadata: name: external-dns namespace: default创建集群角色注意需要的权限是读取 Kubernetes 里的服务和 Ingress 资源apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [] resources: [services, endpoints, pods, nodes] verbs: [get, list, watch] - apiGroups: [networking.k8s.io] resources: [ingresses] verbs: [get, list, watch] - apiGroups: [] resources: [namespaces] verbs: [get, list, watch]创建绑定apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default然后是核心的 DeploymentapiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: external-dns:v0.14.2 args: - --sourceingress - --sourceservice - --domain-filterinternal.example.com - --provideryour-cloud-provider - --registrytxt - --txt-owner-idk8s-internal-dns - --policyupsert-only这里的 image 地址需要替换成你实际能访问的镜像地址版本建议使用你确认过文档的稳定版本。--provider 的值不是固定的不同云厂商的 ExternalDNS provider 名称不一样按官方文档选择即可。我在 YAML 里写了 your-cloud-provider 占位实际操作时换成对应的标识符。3.4 关键参数逐个解读ExternalDNS 启动参数看起来多但真正要理解的没几个。我列了一份简化说明参数作用建议取值--source记录来源ingress、service--domain-filter只处理匹配后缀的域名写你创建的私网域--provider云厂商 DNS 实现按云厂商文档--policy同步策略先用 upsert-only--registry记录登记凭据txt--txt-owner-id所有权标识不同环境用不同值--interval同步周期默认 1m测试可改 30s--dry-run只打印不真实写调试神器--domain-filter 一定要写准确。如果账号下还挂了公网 Zone没有这个过滤器ExternalDNS 有可能把内部域名写到你不希望碰的地方。它支持多个值用逗号分隔。--policy 有两个最常用的值。upsert-only 表示只新增、更新不删记录sync 表示完全同步K8s 资源删除记录也会被删。我强烈建议第一次部署时用 upsert-only 跑几天确认没有误删风险后再切 sync。后面第 4 章会细说这个选择的原因。3.5 如何安全地验证同步是否生效部署完成之后不要急着看结果先做一轮安全验证。先把 Deployment 里的 --policy 临时加上 --dry-run重新启动。此时 ExternalDNS 会正常扫描 Kubernetes 资源也会正常对比 DNS 记录但不会真实调用创建接口只在日志里打印“准备创建”或者“准备删除”的行为。你可以通过日志确认它所理解的目标记录是否符合预期。确认无误后去掉 --dry-run重新 apply。然后执行kubectl logs -f deployment/external-dns观察一段时间正常情况下会出现类似创建 A 记录和 TXT 记录的日志。如果你的私网 Zone 里还没有任何 Ingress 域名日志里也可能一直处于空转状态这不代表有问题。等你在后续步骤中创建了 Ingress再回到这里看日志就能清楚看到同步过程。4. 一套可直接复用的 Ingress 配置流程4.1 先准备一个测试后端服务为了验证整个链路先部署一个最简单的测试服务。我用的是 nginx 镜像因为它足够轻量返回一个默认页面就能确认链路通没通。apiVersion: apps/v1 kind: Deployment metadata: name: demo-hello spec: replicas: 1 selector: matchLabels: app: demo-hello template: metadata: labels: app: demo-hello spec: containers: - name: demo-hello image: nginx:alpine ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: demo-hello spec: selector: app: demo-hello ports: - port: 80 targetPort: 80这个 Service 的类型是 ClusterIP因为我们不直接给测试服务挂公网入口。真正对外入口是后面的 Ingress。4.2 写 Ingress 并声明内部域名创建 Ingress 时重点有两个host 要落在 InternalDNS 的 domain-filter 范围内ingressClassName 要对应集群里实际运行的那个 Ingress 控制器。apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: demo-hello-ingress annotations: # 如果入口 IP 是固定的可以在这里锁定解析目标 external-dns.alpha.kubernetes.io/target: 10.0.2.15 spec: ingressClassName: nginx rules: - host: demo.internal.example.com http: paths: - path: / pathType: Prefix backend: service: name: demo-hello port: number: 80这里的 annotation 是可选的。如果集群的 Ingress 控制器已经给 Ingress 写入了 status.loadBalancer 的地址ExternalDNS 通常会直接用这个地址作为记录目标。但如果你有多个入口、或者入口 IP 不稳定显式指定目标 IP 更可控。这个 annotation 是 ExternalDNS 官方支持的不是某个云厂商特有。4.3 验证域名解析和业务访问创建 Ingress 之后先确认资源状态kubectl get ingress demo-hello-ingress如果 Ingress 控制器正常过一会儿会出现 ADDRESS 字段这个地址就是要被解析的目标。然后在 VPC 内的一台机器上执行解析验证nslookup demo.internal.example.com 10.1.0.2或者用 digdig demo.internal.example.com 10.1.0.2这里的 10.1.0.2 是我用来举例的 VPC 内 DNS 服务器地址实际以你的环境为准。建议显式指定 DNS 服务器避免命中本机缓存的旧结果。解析结果如果是 Ingress 入口 IP基本就说明链路通了。最后用 curl 验证业务访问curl -I http://demo.internal.example.com如果返回 200 或者 301说明从域名到 Ingress、从 Ingress 到 Service、从 Service 到 Pod 的整条链路都正常。如果卡在这一步优先检查入口 IP 在 VPC 内的路由和安全组是否放行再检查 Ingress Controller 的日志。4.4 验证服务下线后解析记录会被回收解析创建不是为了更好地清理而是为了确保下线时不留脏数据。测试方法很简单删除刚才的 Ingress。kubectl delete ingress demo-hello-ingress如果你采用的是 policyupsert-only记录不会自动删除这是预期行为。只有当 ExternalDNS 的 policy 设置为 sync 时它才会在下一个同步周期内发现这个域名已经不存在于 Kubernetes 资源里然后删除对应的 A 记录和所有权 TXT 记录。所以我前面建议第一次部署先用 upsert-only等确认所有记录都创建正确之后再改成 sync。改之前还要注意如果 Zone 里存在以前手工添加的记录它们没有 ExternalDNS 的所有权 TXT 标记sync 模式下有一定风险会被当成冗余记录处理。正式切 sync 之前最好先排查一遍 Zone 里是否还有无关的手工记录。5. 常见问题与排查技巧实录5.1 记录没创建可能是哪些原因这是最常遇到的一类问题。创建了 Ingress等了五分钟私网 Zone 里什么都没有。我一般按下面顺序排查。第一看日志kubectl logs -f deployment/external-dns如果日志里没有任何关于 demo.internal.example.com 的记录先检查 Ingress 的 host 是否在 --domain-filter 的范围内。ExternalDNS 会直接忽略不匹配的域名不会给你任何提示。这个原因非常隐蔽。第二看权限。日志里如果出现 forbidden 或者 unauthorized基本是 RBAC 或者云厂商角色权限不够。先检查 ClusterRole 里有没有 ingresses 的 get、list、watch 权限再检查云厂商侧的角色策略。第三看 Zone 关联。有时候 ExternalDNS 的日志显示记录已经创建成功但 VPC 内解析不到。这时候大概率是私网 Zone 没有关联到集群所在 VPC或者关联错了 VPC。去控制台核对一下 Zone 的关联 VPC。第四看 Ingress 状态。如果 Ingress 没有 status 地址ExternalDNS 可能不知道用什么作为目标地址也就不会创建记录。重点检查 ingressClassName 是否对应集群里真实存在的 Ingress Controller。5.2 解析到了公网 IP而不是内网 IP这个问题也很典型。域名解析出来了但目标地址是公网 IP不是内网负载均衡的地址结果内部网段去访问公网 IP路由和防火墙都很别扭。第一种可能是你创建的 Zone 本身就不是私网 Zone。控制台上私网解析和公网解析通常是两个独立的入口创建时选错了类型记录就会写到公网解析区。第二种可能是 Ingress Controller 的负载均衡器本身绑定的是公网 IP。很多云厂商在创建 LoadBalancer 类型的 Service 时默认分配公网 IP需要额外配置内网负载均衡。ExternalDNS 取的是 Ingress status 里的地址所以入口地址是公网 IP它就会照实写。这种情况的解法是不要依赖自动探测的地址而是通过 annotation 显式指定目标内网 IP也就是前面代码里提到的 external-dns.alpha.kubernetes.io/target。把这个值设为你内网负载均衡的 IPExternalDNS 就会以它为准。5.3 记录的“所有权”冲突问题当你的环境里存在多个 ExternalDNS 实例、或者曾经手工创建过同名记录时所有权冲突是最容易翻车的地方。典型表现是记录存在但时不时被改回旧值或者明明改了 Ingress 里的 hostDNS 里的旧域名却迟迟不被清理。这往往是因为两个实例使用了相同的 --txt-owner-id导致它们对“这条记录归谁管”的判断出现混乱。我的建议是每套环境、每个集群启用不同的 txt-owner-id比如 k8s-prod-dns、k8s-test-dns。同时在建 Zone 的初期就统一约定所有面向 ExternalDNS 管理的域名只通过 Ingress 对象声明不要在控制台手动添加。一旦混着来很容易让所有权判断变得复杂。5.4 TTL 缓存导致更新不够及时内部域名有时会遇到一种情况控制台看记录已经更新了但客户端解析到的还是老地址。大部分时候是缓存问题不是 ExternalDNS 的问题。缓存可能出现在三个地方客户端操作系统 DNS 缓存、VPC 内递归解析器缓存、以及 ExternalDNS 自身的同步周期。前两个是 DNS 的固有属性受 TTL 控制第三个是程序行为受 --interval 控制。想要快速验证现网状态建议指定一个具体的 DNS 服务器查询绕过本机缓存dig demo.internal.example.com 10.1.0.2同时把 Zone 里的 TTL 调小一点比如 60 秒。调完之后客户端最多等一分钟就会拿到新记录排查体验会好很多。5.5 排查命令与自检清单把常用命令整理成一张表方便后面快速定位问题场景命令或操作查看 ExternalDNS 日志kubectl logs -f deployment/external-dns查看 Ingress 资源详情kubectl get ing -o yaml验证解析结果dig 域名 VPC_DNS 或 nslookup检查私网记录云厂商控制台查看 Zone 记录列表检查权限云厂商策略模拟工具自查自检的思路可以按四个层次展开第一层资源有没有被 ExternalDNS 感知第二层记录有没有在 Zone 里创建第三层解析能不能命中第四层流量能不能到达后端。逐层排查基本不会漏掉问题。写在最后的一点经验这套 Ingress 加 ExternalDNS 的组合我实际用下来最大的感触是它把“期望状态”这个思路从 Kubernetes 内部延伸到了 DNS 上。Ingress 是声明ExternalDNS 是执行方私网 Zone 是最终存储。相比手动维护它最大的价值不是省几次点击而是让 DNS 记录和业务生命周期保持一致服务下线了入口就不会被残留记录继续拖着。如果你刚开始接入我建议一定先把 dry-run 模式用熟再切生产。另外把 ExternalDNS 的日志和监控接进告警系统它一旦静默挂掉域名解析会停滞在旧状态那种情况比没有自动化还难发现。最后一个小提醒所有通过这套机制管理的域名尽量统一走 Ingress 声明不要一边自动化一边手工加记录否则后面查所有权会非常痛苦。