在 Go 中为虚拟机构建一个简单的 DNS 转发器

发布: (2026年1月30日 GMT+8 22:43)
8 分钟阅读
原文: Dev.to

Source: Dev.to

在现代 Linux 系统中,systemd‑resolved 能透明地处理 DNS 解析——几乎不需要你去思考它。它就是直接可用。

但在使用 qcontroller 管理基于 QEMU 的虚拟机时,情况就更有意思了。qcontroller 支持两种主要方式来为 VM 实例配置网络和 DNS:

  • DHCP(默认回退)
  • Cloud‑Init 网络配置

当不使用 Cloud‑Init 的网络配置时,它会回退到 DHCP。正如前一篇文章所述,qcontroller 将 QEMU 进程运行在一个专用的网络命名空间中,并通过 veth 对连接到宿主机的根命名空间。

这种命名空间隔离非常强大:在命名空间内部,53 端口(DNS)是空闲的,因此我们可以运行自己的 DHCP 和 DNS 服务而不会产生冲突。

基于 DHCP 的设置

对于 DHCP,我使用优秀且模块化的 CoreDHCP 服务器——嵌入式并在单独的 goroutine 中运行。其关键配置字段之一是 DNS 服务器 IP(DHCP 客户端始终在 53 端口查询 DNS)。我只需从 QEMU 子命令配置中传递 nameserver IP:

{
  "linuxSettings": {
    "network": {
      "name": "br0",
      "gateway_ip": "192.168.71.1/24",
      "bridge_ip": "192.168.71.3/24",
      "dhcp": {
        "start": "192.168.71.4/24",
        "end": "192.168.71.254/24",
        "lease_time": 86400,
        "dns": ["8.8.8.8", "8.8.4.4"],
        "lease_file": "./build/run/qcontroller-dhcp-leases"
      },
      "start_dns": true
    }
  }
}

此配置会启动内部 DNS 服务器,并使用 dns 字段中指定的 IP 作为备用 DNS 解析器。

Cloud‑Init(静态 IP)设置

当首选使用静态 IP 时,您可以提供带有专用 DNS 服务器的 Cloud‑Init 网络配置。此设置可靠:启动虚拟机后,一切会自动完成配置。

我以为工作已经完成——直到我将主机连接到 VPN。突然,VPN 子网中的资源的 DNS 解析在虚拟机内部停止工作。

两个核心问题

  1. 检测主机 DNS 更改(例如,向主机添加新的 VPN 名称服务器)。
  2. 将这些更改传播到运行中的 VM,而不干扰或危及客体服务。

直接操作运行中的 VM 是危险的——一旦出错可能会导致关键服务中断。我们需要一种更安全的方法。

Solution Part 1 – Detecting Host DNS Changes Reliably

在 Linux 上,名称服务器传统上列在 /etc/resolv.conf 中。然而,在基于 systemd 的系统上,/etc/resolv.conf 通常是指向一个存根文件的符号链接,该文件指向 127.0.0.53(本地 systemd‑resolved 解析器)。真正的上游服务器存放在其他位置:

  • Primary location: /run/systemd/resolve/resolv.conf (systemd systems)
  • Fallback: /etc/resolv.conf (non‑systemd setups)

因为 qcontroller 在独立的网络命名空间中运行,我们仍然可以通过命名空间设置访问这些主机文件。

轮询文件可以工作,但会浪费资源。更好的方法是 使用文件系统通知来监视更改

在 Go 中,经过实战检验的 fsnotify 库可以完美处理此任务。为获得最大可靠性(尤其是针对 systemd 的原子重命名),应监视 父目录/run/systemd/resolve//etc/),而不是直接监视文件本身。这样可以干净地捕获创建、删除和修改。

解决方案 第2部分 – 在不重新发明轮子的情况下解析 resolv.conf

一旦检测到变化,我们需要解析文件以提取上游服务器。手动解析 resolv.conf 容易出错,因此我们使用成熟的 miekg/dns 库,它是 Go 语言中事实上的 DNS 工具包。该库自带内置解析器:

import (
    "net"

    "github.com/miekg/dns"
)

func loadUpstreams() ([]string, error) {
    paths := []string{
        "/run/systemd/resolve/resolv.conf",
        "/etc/resolv.conf",
    }

    var cfg *dns.ClientConfig
    var err error
    for _, p := range paths {
        cfg, err = dns.ClientConfigFromFile(p)
        if err == nil {
            break
        }
    }
    if err != nil {
        return nil, err
    }

    upstreams := make([]string, 0, len(cfg.Servers))
    for _, s := range cfg.Servers {
        upstreams = append(upstreams, net.JoinHostPort(s, cfg.Port))
    }
    return upstreams, nil
}

upstreams 现在包含上游地址(例如 ["8.8.8.8:53", "10.8.0.1:53"])。

fsnotifymiekg/dns 结合使用,就可以可靠地检测并加载主机上更新的上游服务器。

Solution Part 3 – Static DNS in VMs + Smart Forwarding

与其动态重新配置 VM(风险较大!),不如为每个 VM 分配 单一、静态的 DNS 解析器 IP —— 即我们在命名空间内部嵌入的 DNS 服务器的地址。

静态解析器如何应对主机 DNS 变化(VPN 等)?
使用 自定义 DNS 转发器

  • 在 VM 命名空间的 53 端口监听。
  • 按顺序将查询转发到当前的上游列表(来自主机的 resolv.conf)。
  • 在收到第一个 正向 响应时立即返回(NOERROR 且 answers > 0)。
  • 否则继续尝试下一个上游。
  • 若所有上游均未返回正向结果,则回退到最后一个 负向 响应(例如 NXDOMAINNODATA)。
  • 仅当 所有 上游全部彻底失败(网络错误)时返回 SERVFAIL

这种 “乐观回退直至正向” 的逻辑简单却强大——它映射了真实场景中的需求,如 VPN + 公共 DNS 链接

完整实现位于 qcontroller;请参阅 latest changes

弹性回退

如果 qcontroller 崩溃(希望不会)或停止会怎样?虚拟机仍在运行,但主机的 DNS 更新会停止。

为优雅地处理此情况,请在 QEMU 配置中配置 回退名称服务器列表(例如 8.8.8.81.1.1.19.9.9.9)。这样虚拟机将回退到公共 DNS —— 对内部/VPN 资源并非理想,但总比完全失效好。

结论

  • 虚拟机始终使用单一、静态的 DNS IP
  • 嵌入式转发器会动态跟随主机 DNS 的变化(包括 VPN 连接)
  • 无需对客机进行重新配置 → 对运行中的服务零风险

通过 fsnotify 实现可靠检测 + 通过 miekg/dns 实现稳健解析

通过可配置的公共解析器实现平滑回退

您的虚拟机现在拥有与主机根命名空间完全相同的网络连接 — 自动

享受在您的虚拟机集群中无忧的 DNS!

Back to Blog

相关文章

阅读更多 »