构建企业级高可用 DNS 服务:从原理到 CoreDNS 实践

DNS,作为互联网的“电话簿”,是所有网络通信的基石。在微服务和云原生时代,它的角色从简单的域名到 IP 地址映射,演进为服务发现的核心基础设施。一次看似微小的 DNS 故障,往往能引发整个生产环境的雪崩式瘫痪。本文旨在为中高级工程师和架构师提供一个深度剖析,我们将从 DNS 的基础原理出发,深入探讨如何利用 CoreDNS 在 Kubernetes 等复杂环境中,构建一个高可用、高性能、可观测的 DNS 解析服务体系。这不是一篇入门指南,而是一线实战经验的沉淀与总结。

现象与问题背景

在复杂的分布式系统中,DNS 问题往往以诡异且难以排查的形式出现。例如,某个服务在高峰期偶发性地无法连接依赖的数据库,日志显示“unknown host”,但几秒后又自动恢复。或者,新版本发布后,部分 Pod 启动缓慢,最终因无法解析某个内部服务域名而陷入 CrashLoopBackOff。这些问题的根源,常常指向我们习以为常却又不够重视的 DNS 系统。

随着业务迁移到 Kubernetes,问题变得更加复杂:

  • 单点故障与性能瓶颈:默认的 K8s DNS (通常是 CoreDNS) 部署虽然有多个副本,但如果所有 Pod 都集中请求同一个 ClusterIP,这个虚拟 IP 背后的负载均衡(如 kube-proxy 的 iptables/IPVS 规则)本身可能成为瓶颈或故障点。
  • 缓存不一致:应用、操作系统、DNS 服务层层缓存,在服务 IP 动态变化的云原生环境中,陈旧的 DNS 记录可能导致请求被发送到已经下线的实例,造成业务错误。
  • 跨网络域名的解析延迟:当集群内的服务需要频繁解析公网域名或专线连接的 IDC 内部域名时,错误的转发策略会导致每一次解析都经历漫长的路径,显著增加服务调用的首字节延迟。
  • “ndots:5” 查询风暴:这是一个经典的 K8s DNS 坑点。Pod 内 `/etc/resolv.conf` 的默认配置,会导致对外部域名(如 `api.google.com`)的单次查询,被放大为多次无效的内部域名查询,在高并发场景下形成“查询风暴”,打垮 CoreDNS 服务。

这些现象提醒我们,不能再将 DNS 视为一个“理所当然”的基础组件。构建一个健壮的 DNS 服务,是保障整个分布式系统稳定性的前提。

关键原理拆解

在深入 CoreDNS 之前,我们必须回归计算机科学的本源,重新审视 DNS 的工作机制。这种“回到第一性原理”的思考方式,是架构师解决复杂问题的基础。

(大学教授声音)

DNS (Domain Name System) 本质上是一个分布式的、层次化的命名系统。其核心设计思想是分治(Divide and Conquer)。理解其工作流程的关键在于区分两种查询模式:递归查询 (Recursive Query)迭代查询 (Iterative Query)

想象一下客户端(比如你的浏览器或应用代码里的 HTTP Client)要解析 www.example.com.(注意末尾的点,代表 FQDN 的根):

  1. 客户端向其配置的 本地 DNS 解析器 (Local Resolver) 发起一个递归查询。这个 Local Resolver 通常由你的操作系统或 Kubernetes 集群提供(比如 CoreDNS)。客户端说:“请帮我找到 www.example.com. 的 IP 地址,并把最终结果给我。”
  2. Local Resolver 接收到请求后,它会代替客户端进行一系列迭代查询。它首先向 根域名服务器 (Root Server) 发起查询:“谁知道 .com. 的权威服务器在哪?” 根服务器不会直接回答最终结果,而是返回 .com 顶级域 (TLD) 的 NS 记录,即 TLD Name Servers 的地址列表。
  3. Local Resolver 接着向其中一个 .com TLD 服务器发起查询:“谁知道 example.com. 的权威服务器在哪?” TLD 服务器同样返回 example.com 的 NS 记录,指向负责该域名的权威域名服务器 (Authoritative Name Server)。
  4. 最后,Local Resolver 向 example.com 的权威服务器发起查询:“www.example.com. 的 A 记录是什么?” 这次,权威服务器拥有最终答案,它会返回对应的 IP 地址。
  5. Local Resolver 拿到 IP 地址后,会根据记录的 TTL (Time-To-Live) 将其缓存起来,然后将结果返回给客户端。在 TTL 过期前,对同一域名的再次请求将直接由缓存应答,极大地提升了效率。

这个过程中,有几个关键的协议与数据结构细节:

  • 协议选择:UDP vs TCP:DNS 查询主要使用 UDP 端口 53。UDP 无连接、开销小,一次请求响应即可完成,非常适合 DNS 这种小数据包、高频次的场景。但 UDP 包大小有限(传统上是 512 字节)。当响应数据超过这个限制(比如包含大量记录的 DNSSEC 响应),或者在进行需要高可靠性的区域传输 (Zone Transfer, AXFR) 时,DNS 会自动切换到 TCP 端口 53。EDNS0 (Extension Mechanisms for DNS) 扩展协议允许客户端宣告其能够处理更大的 UDP 包,但这并非万能。
  • 资源记录 (Resource Records, RRs):DNS 数据库由各种类型的记录组成,常见的有:
    • A: 将域名映射到 IPv4 地址。
    • AAAA: 将域名映射到 IPv6 地址。
    • CNAME: Canonical Name,将一个域名指向另一个别名域名。解析器会继续解析别名对应的记录。
    • NS: Name Server,指定了哪个权威服务器负责解析该域。
    • SRV: Service Record,用于服务发现,定义了服务的协议、主机名和端口号,比简单的 A 记录提供了更丰富的信息,在微服务架构中非常有用。
  • 缓存与一致性:TTL 是 DNS 系统中实现最终一致性的核心机制。它是一个典型的权衡:高 TTL 意味着更长的缓存有效期,降低了权威服务器的负载,提升了客户端解析速度,但代价是当记录变更(例如服务 IP 迁移)时,全网生效的延迟会更长。低 TTL 则相反,变更生效快,但会显著增加查询量,给 DNS 服务器带来巨大压力。

系统架构总览

基于以上原理,一个现代企业级高可用 DNS 架构通常是分层的。我们不再依赖单一的 DNS 服务器,而是构建一个有机的解析体系,每一层各司其职。

我们可以将这个体系描述为三层模型:

  • L1 – 集群 DNS 服务层 (Cluster DNS Service):这是 Kubernetes 集群内部服务发现的核心。我们在这里部署一个高可用的 CoreDNS 集群(通常是2个或更多的副本)。它负责解析集群内部的服务名(如 `my-service.my-namespace.svc.cluster.local`),并将所有其他请求转发到上游。
  • L2 – 节点级缓存层 (Node-level Cache):在每个 Kubernetes 工作节点上,通过 DaemonSet 部署一个轻量级的 DNS 缓存实例,例如 NodeLocal DNSCache。集群内所有 Pod 的 DNS 请求首先发往本机的这个缓存实例。这一层是性能优化的关键,它能大幅减少跨节点网络调用,降低 L1 CoreDNS 集群的负载,并规避 conntrack 等内核网络栈的瓶颈。
  • L3 – 上游解析器与权威 DNS (Upstream & Authoritative DNS):这是整个 DNS 体系的出口。它可以是企业内部的权威 DNS 服务器(用于解析内部系统域名),也可以是公网上的高可用解析服务(如 `8.8.8.8`, `114.114.114.114`)。L1 的 CoreDNS 集群会将无法在集群内解析的请求,智能地转发到这一层。

一个典型的查询流程是:Pod -> NodeLocal DNSCache (本机) -> Cluster CoreDNS Service (集群) -> Upstream DNS (外部)。这种分层架构,通过缓存和职责分离,实现了高可用和高性能的平衡。

核心模块设计与实现:CoreDNS 剖析

(极客工程师声音)

好了,理论讲完了,我们来点硬核的。为什么是 CoreDNS?因为它不是像 BIND 那样的“老古董”,也不是像 Dnsmasq 那样功能有限的小工具。CoreDNS 是用 Go 编写的,天然云原生,其杀手锏是它的插件链 (Plugin Chain) 架构。所有功能都由插件实现,你可以像搭乐高一样组合它们。配置文件 `Corefile` 就是你的“施工图纸”。

一个请求进来,会依次流过你在 `Corefile` 里定义的插件。如果某个插件处理了该请求(比如在缓存里命中了),它就可以中断链条,直接返回结果。否则,请求就交给下一个插件。这个责任链模式给了我们极大的灵活性。

场景一:标准的 Kubernetes 服务发现

这是 K8s 集群里最常见的 `Corefile` 配置。让我们逐行“解剖”它。


.:53 {
    errors
    health {
       lameduck 5s
    }
    ready
    kubernetes cluster.local in-addr.arpa ip6.arpa {
       pods insecure
       fallthrough in-addr.arpa ip6.arpa
    }
    prometheus :9153
    forward . /etc/resolv.conf {
       max_concurrent 1000
    }
    cache 30
    loop
    reload
    loadbalance
}
  • .:53 { ... }: 定义一个服务块,监听所有域(.)的 53 端口。
  • errors & log: 基本操作,把错误和日志打出来。生产环境必备,否则出了问题两眼一抹黑。
  • health & ready: 暴露健康检查端点。health 给 K8s 的 liveness probe 用,告诉 kubelet “我还活着”。ready 给 readiness probe 用,表示“我准备好接受流量了”。lameduck 5s 是优雅停机设置,在收到 SIGTERM 信号后,会让健康检查失败并等待 5 秒再退出,给流量切换留足时间。
  • kubernetes: 核心中的核心。它会 watch K8s 的 API Server,动态地将 Service 和 Pod 的信息翻译成 DNS 记录。比如你查询 redis-master.default.svc.cluster.local,它就会返回 Redis Master Service 的 ClusterIP。pods insecure 选项允许你直接通过 Pod 的 IP 反向解析出 Pod 的名字,比如 `10-244-1-10.default.pod.cluster.local`。
  • fallthrough: 这是 `kubernetes` 插件的一个关键指令。它的意思是,如果我(`kubernetes` 插件)无法解析这个域名(比如 `www.google.com`),那就把请求“掉下去”,交给下一个插件处理。没有它,所有非 K8s 内部的域名解析都会失败。
  • prometheus :9153: 暴露 Prometheus 指标。没有监控的系统就是在裸奔。你可以监控到查询 QPS、延迟、缓存命中率等黄金指标。
  • forward . /etc/resolv.conf: “接盘侠”插件。所有从 `kubernetes` 插件“掉下来”的请求,都会被它转发。转发到哪里?/etc/resolv.conf,也就是宿主机上的 DNS 配置。max_concurrent 限制了并发转发请求数,防止打垮上游。
  • cache 30: 缓存。为所有上游应答(包括 forward 的结果)提供 30 秒的缓存。这是性能优化的第一道防线。
  • loop: 检测简单的转发循环,防止 DNS 查询把自己搞死。
  • reload: 自动重载 `Corefile`。当你修改了 ConfigMap 里的 `Corefile`,CoreDNS 会自动应用新配置,无需重启 Pod。
  • loadbalance: 当 `forward` 配置了多个上游时,它会在这些上游之间做简单的轮询负载均衡。

场景二:使用 hosts 插件实现环境隔离与灰度发布

假设你想在预发环境将某个服务的域名 user-service.my-corp.com 指向测试集群内的地址,而不是公网的生产地址。hosts 插件就能派上用场。


user-service.my-corp.com:53 {
    hosts {
        10.10.1.23 user-service.my-corp.com
        fallthrough
    }
    log
}

.:53 {
    # ... 其他配置 ...
    forward . 8.8.8.8
    # ...
}

这里我们定义了一个更具体的 server block。所有对 `user-service.my-corp.com` 的查询会优先进入这个块。hosts 插件会像 `/etc/hosts` 文件一样,直接返回 `10.10.1.23` 这个 IP。fallthrough 保证了如果 hosts 文件里没有匹配项,请求可以继续被处理(虽然在这个例子里没后续插件了)。这种方式可以非常灵活地进行流量劫持和环境配置,在测试和灰度发布场景中非常实用。

性能优化与高可用设计

部署起来只是第一步,让它在生产环境高负载下稳定运行,才是真正的挑战。

高可用设计

  • 多副本与反亲和性:永远不要只运行一个 CoreDNS 实例。使用 Deployment 部署至少两个副本,并通过 Pod Anti-Affinity 规则,确保它们被调度到不同的物理节点上。这是避免单点故障的基础。
    
    affinity:
      podAntiAffinity:
        preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 100
          podAffinityTerm:
            labelSelector:
              matchExpressions:
              - key: k8s-app
                operator: In
                values:
                - kube-dns
            topologyKey: kubernetes.io/hostname
    
  • 客户端超时问题:Pod 内的 `/etc/resolv.conf` 会被 Kubelet 自动生成,通常包含 CoreDNS Service 的 ClusterIP。但 glibc 的 DNS 解析器行为是:默认先请求第一个 nameserver,如果 5 秒内没有响应,才去尝试第二个。5 秒,对于线上服务来说就是一场灾难。所以,仅仅增加 CoreDNS 副本是不够的,必须配合更激进的客户端策略或使用 NodeLocal DNSCache 来绕过这个问题。

性能优化:直面 `ndots:5` 查询风暴

这是 K8s DNS 的头号性能杀手。Pod 的 `/etc/resolv.conf` 里通常有这样的配置:


nameserver 10.96.0.10
search my-namespace.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

options ndots:5 的意思是,如果一个域名里的点(dot)少于 5 个,就被认为是一个“短名称”,解析器会依次尝试 `search` 列表里的后缀。当你查询 `www.google.com`(3个点,小于5)时,实际发出的 DNS 查询序列是:

  1. `www.google.com.my-namespace.svc.cluster.local.` (查询 CoreDNS) -> NXDOMAIN
  2. `www.google.com.svc.cluster.local.` (查询 CoreDNS) -> NXDOMAIN
  3. `www.google.com.cluster.local.` (查询 CoreDNS) -> NXDOMAIN
  4. `www.google.com.` (查询 CoreDNS, 转发到上游) -> 成功

一次合法的查询被放大了 4 倍!在高并发下,这会产生海量的、无意义的内部查询,瞬间压垮 CoreDNS。解决方案:

  • 应用侧:在代码里使用 FQDN (Fully Qualified Domain Name),即在域名的最后加上一个点,如 `http.Get(“http://www.google.com./”)`。这样解析器会直接查询 `www.google.com.`,跳过 search list 的拼接。
  • 平台侧:部署 NodeLocal DNSCache

终极武器:NodeLocal DNSCache

NodeLocal DNSCache 是官方推荐的解决 K8s DNS 性能和可靠性问题的最佳实践。它通过 DaemonSet 在每个节点上部署一个 CoreDNS 实例,作为该节点上所有 Pod 的专属 DNS 缓存。Pod 的 `/etc/resolv.conf` 被修改为指向一个本地地址(比如 `169.254.20.10`)。

这么做的好处是颠覆性的:

  • 极致的低延迟:DNS 查询在本机完成,几乎没有网络开销。
  • 避免网络瓶颈:请求不再经过 kube-proxy 的 DNAT 和 conntrack,绕开了所有可能出问题的网络中间环节。在高并发和连接数密集型场景(比如 UDP 协议的应用),效果尤其明显。
  • 减少核心 DNS 负载:大量的查询被本地缓存命中,只有缓存未命中时才会查询集群的 CoreDNS 服务,大大降低了其压力。
  • 更高的可用性:即使集群级的 CoreDNS 服务出现故障,只要 NodeLocal Cache 中有缓存,节点内的 Pod 仍然可以解析域名,为故障恢复争取了宝贵的时间。

部署 NodeLocal DNSCache 是一个架构决策,它将 DNS 服务的重心从“中心化”推向了“边缘”,这完全符合云原生的设计哲学。

架构演进与落地路径

构建高可用的 DNS 服务不是一蹴而就的,它应该是一个分阶段演进的过程。

  1. 阶段一:基线部署与监控 (Baseline & Monitoring)

    从 K8s 默认的 CoreDNS 部署开始。第一步不是修改配置,而是建立完善的监控。利用 `prometheus` 插件,将 CoreDNS 的核心指标(QPS、延迟、缓存命中率、上游转发错误率)接入监控大盘。先摸清你现有 DNS 服务的负载和性能基线。

  2. 阶段二:高可用加固与配置调优 (HA Hardening & Tuning)

    基于监控数据进行优化。增加 CoreDNS 副本数,并配置 Pod 反亲和性。分析查询日志,识别出 `ndots:5` 问题的重灾区,推动业务方改造代码或调整 `ndots` 阈值。根据业务特性,适当调整 `cache` 插件的 TTL,在性能和数据新鲜度之间找到平衡。

  3. 阶段三:部署节点级缓存 (Node-level Caching)

    当集群规模扩大,或者对延迟和可用性有更高要求时,果断引入 NodeLocal DNSCache。这需要对集群的网络配置进行变更,但带来的收益是巨大的。这一步完成后,你的 K8s DNS 架构才算真正达到了生产级的高标准。

  4. 阶段四:混合云与多集群联邦 (Hybrid/Federated DNS)

    对于大型企业,往往需要打通 K8s 集群与外部 IDC 或其他云环境。这时可以充分利用 CoreDNS 的 `proxy` 或 `forward` 插件,编写更精细的路由规则。例如,将所有对 `*.db.internal` 的请求转发到公司内部的 DNS 服务器,将对 `*.cluster-2.local` 的请求转发到另一个 K8s 集群的 CoreDNS,实现服务发现的联邦。

DNS 是一个“沉默”的英雄,平时你感觉不到它的存在,但它一旦出现问题,整个系统都会瘫痪。像对待数据库和消息队列一样,严肃地对待你的 DNS 架构,是每一位架构师和技术负责人的必修课。

延伸阅读与相关资源

  • 想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
    交易系统整体解决方案
  • 如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
    产品与服务
    中关于交易系统搭建与定制开发的介绍。
  • 需要针对现有架构做评估、重构或从零规划,可以通过
    联系我们
    和架构顾问沟通细节,获取定制化的技术方案建议。
滚动至顶部