在 Go 中为虚拟机构建一个简单的 DNS 转发器
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 解析在虚拟机内部停止工作。
两个核心问题
- 检测主机 DNS 更改(例如,向主机添加新的 VPN 名称服务器)。
- 将这些更改传播到运行中的 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"])。
将 fsnotify 与 miekg/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)。 - 否则继续尝试下一个上游。
- 若所有上游均未返回正向结果,则回退到最后一个 负向 响应(例如
NXDOMAIN或NODATA)。 - 仅当 所有 上游全部彻底失败(网络错误)时返回
SERVFAIL。
这种 “乐观回退直至正向” 的逻辑简单却强大——它映射了真实场景中的需求,如 VPN + 公共 DNS 链接。
完整实现位于 qcontroller;请参阅 latest changes。
弹性回退
如果 qcontroller 崩溃(希望不会)或停止会怎样?虚拟机仍在运行,但主机的 DNS 更新会停止。
为优雅地处理此情况,请在 QEMU 配置中配置 回退名称服务器列表(例如 8.8.8.8、1.1.1.1、9.9.9.9)。这样虚拟机将回退到公共 DNS —— 对内部/VPN 资源并非理想,但总比完全失效好。
结论
- 虚拟机始终使用单一、静态的 DNS IP
- 嵌入式转发器会动态跟随主机 DNS 的变化(包括 VPN 连接)
- 无需对客机进行重新配置 → 对运行中的服务零风险
通过 fsnotify 实现可靠检测 + 通过 miekg/dns 实现稳健解析
通过可配置的公共解析器实现平滑回退
您的虚拟机现在拥有与主机根命名空间完全相同的网络连接 — 自动。
享受在您的虚拟机集群中无忧的 DNS!