Go로 VM용 간단한 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 서브‑명령 구성에서 네임서버 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를 선호하는 경우, 전용 네임서버가 포함된 Cloud‑Init 네트워크 구성을 제공할 수 있습니다. 이 설정은 신뢰성이 높으며, VM을 시작하면 모든 것이 자동으로 구성됩니다.
작업이 끝났다고 생각했지만, 호스트를 VPN에 연결하기 전까지는 그렇지 않았습니다. 갑자기 VPN 서브넷에 있는 리소스에 대한 DNS 해석이 VM 내부에서 작동하지 않게 되었습니다.
두 가지 핵심 문제
- 호스트 DNS 변경 감지 (예: 호스트에 새 VPN 네임서버가 추가된 경우).
- 실행 중인 VM에 해당 변경 사항 전파 – 게스트 서비스에 방해가 되거나 손상되지 않도록.
실행 중인 VM을 직접 건드리는 것은 위험합니다 — 실수하면 중요한 서비스가 중단될 수 있습니다. 더 안전한 접근 방식이 필요합니다.
Source: …
Solution Part 1 – Detecting Host DNS Changes Reliably
Linux에서는 네임서버가 전통적으로 /etc/resolv.conf에 나열됩니다. 그러나 systemd 기반 시스템에서는 /etc/resolv.conf가 보통 127.0.0.53(로컬 systemd‑resolved 리졸버)을 가리키는 스텁 파일에 대한 심볼릭 링크입니다. 실제 업스트림 서버는 다른 위치에 저장됩니다:
- 주 위치:
/run/systemd/resolve/resolv.conf(systemd 시스템) - 대체 위치:
/etc/resolv.conf(non‑systemd 설정)
qcontroller가 별도의 네트워크 네임스페이스에서 실행되기 때문에, 네임스페이스 설정을 통해 이러한 호스트 파일에 여전히 접근할 수 있습니다.
파일을 폴링(polling)하는 방법도 가능하지만 자원을 낭비합니다. 더 나은 방법은 파일 시스템 알림을 사용해 변경을 감시하는 것입니다.
Go에서는 검증된 fsnotify 라이브러리가 이를 완벽히 처리합니다. 특히 systemd의 원자적(rename) 작업을 고려해 파일 자체가 아니라 상위 디렉터리(/run/systemd/resolve/ 또는 /etc/)를 감시하면 최대 신뢰성을 확보할 수 있습니다. 이렇게 하면 생성, 삭제, 수정 이벤트를 모두 깔끔하게 포착할 수 있습니다.
Solution Part 2 – Parsing resolv.conf Without Reinventing the Wheel
변경이 감지되면 파일을 파싱하여 업스트림 서버를 추출해야 합니다. resolv.conf를 직접 파싱하는 것은 오류가 발생하기 쉬우므로, Go에서 사실상의 DNS 툴킷인 miekg/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를 결합하면 호스트에서 업데이트된 업스트림을 신뢰성 있게 감지하고 로드할 수 있습니다.
솔루션 파트 3 – VM에서 정적 DNS + 스마트 포워딩
VM을 동적으로 재구성하는 대신(위험함!), 모든 VM에 단일 정적 DNS 리졸버 IP를 부여하세요 — 네임스페이스 내부에 내장된 DNS 서버의 주소입니다.
정적 리졸버가 호스트 DNS 변경(VPN 등)을 어떻게 처리할 수 있을까요?
맞춤형 DNS 포워더를 도입합니다:
- VM 네임스페이스에서 포트 53을 청취합니다.
- 쿼리를 현재 업스트림 목록(호스트
resolv.conf에서 가져옴)으로 순차적으로 전달합니다. - 첫 번째 양성 응답(
NOERROR및 답변 > 0)이 오면 즉시 반환합니다. - 그 외의 경우 다음 업스트림으로 계속 진행합니다.
- 마지막 음성 응답(예:
NXDOMAIN또는NODATA)으로 폴백합니다. SERVFAIL은 모든 업스트림이 완전히 실패할 경우에만 반환합니다(네트워크 오류).
이 “양성 응답이 나올 때까지 낙관적 폴백” 로직은 단순하면서도 강력합니다 — VPN + 공용 DNS 체이닝과 같은 실제 요구를 반영합니다.
전체 구현은 qcontroller에 포함되어 있습니다; 최신 변경 사항을 확인하세요.
복원력을 위한 대체 방안
qcontroller가 (희망컨대 그렇지 않겠지만) 충돌하거나 중지되면 어떻게 될까요? VM은 계속 실행되지만 호스트에서 DNS 업데이트가 중단됩니다.
이를 우아하게 처리하려면 QEMU 설정에 대체 네임서버 목록을 구성하십시오(예: 8.8.8.8, 1.1.1.1, 9.9.9.9). VM은 공개 DNS로 대체됩니다 — 내부/VPN 리소스에는 이상적이지 않지만 완전한 실패보다는 낫습니다.
결론
- VM은 항상 단일, 고정 DNS IP를 사용합니다
- 내장 포워더가 호스트 DNS 변경을 동적으로 따라갑니다 (VPN 연결 포함)
- 게스트 재구성 필요 없음 → 실행 중인 서비스에 위험 제로
신뢰할 수 있는 감지는 fsnotify + 견고한 파싱은 miekg/dns
구성 가능한 공용 리졸버를 통한 원활한 폴백
이제 VM은 호스트 루트 네임스페이스와 정확히 동일한 네트워크 연결을 자동으로 가집니다.
VM 군대에서 번거롭지 않은 DNS를 즐기세요!