DEV Community

EME GUG
EME GUG

Posted on

Don't Bet Your Infrastructure on One DNS Resolver: Building a Resilient Encrypted DNS Layer with Unbound

Tuần này trên Hacker News, tin Quad9 rút khỏi Pháp đạt gần 1000 điểm. Lý do nằm ở chuyện pháp lý, không phải kỹ thuật. Nhưng dân dev chúng ta nên rút ra một bài học thực tế: DNS resolver mà bạn hardcode trong server, container hay router có thể thay đổi hành vi, bị chặn hoặc biến mất mà bạn không làm được gì. Ở Việt Nam, chuyện này còn quen hơn. ISP chặn domain, DNS bị hijack trả về trang quảng cáo, đứt cáp quang biển làm latency tới resolver nước ngoài tăng vọt. Mình đã gặp đủ cả.

Trong bài này, mình chia sẻ setup mình đang dùng cho server và máy dev: một local resolver bằng Unbound, forward qua DNS-over-TLS (DoT) tới nhiều upstream khác nhau, có cache, có serve-expired và có script health check. Mục tiêu là nếu một provider "bay màu" thì hệ thống vẫn chạy bình thường.

Vì sao nameserver 8.8.8.8 là một single point of failure

Đa số chúng ta setup server theo kiểu: sửa /etc/resolv.conf, thêm một hai dòng nameserver, xong. Cách này có ba vấn đề:

  1. Plaintext UDP/53: ai đứng giữa đường cũng đọc và sửa được query. ISP có thể inject response.
  2. Failover của glibc rất tệ: mặc định timeout:5 attempts:2. Nếu nameserver đầu tiên chết, mỗi lần lookup có thể mất tới 5 giây trước khi chuyển sang server thứ hai. App của bạn sẽ bị timeout dây chuyền.
  3. Không có cache ở local: mỗi process tự resolve lại, latency cộng dồn, nhất là với microservice gọi nhau qua hostname.

Kiến trúc mình đề xuất trông như thế này:

flowchart LR
    A[App / Container] --> B[systemd-resolved<br/>127.0.0.53]
    B --> C[Unbound<br/>127.0.0.1:5335]
    C -->|cache hit| B
    C -->|DoT 853| D[Upstream 1]
    C -->|DoT 853| E[Upstream 2]
    C -->|DoT 853| F[Upstream 3]
    G[Health check script] -.monitor.-> C

Unbound đóng vai trò lớp trung gian: cache, mã hóa và tự chọn upstream nhanh nhất dựa trên RTT. Nó cũng tự động bỏ qua upstream đang chết.

Cấu hình Unbound với nhiều upstream DoT

Mình dùng Unbound 1.19+ (Ubuntu 24.04 có sẵn bản 1.19.2, Debian 12 có 1.17 cũng chạy được). Cài đặt:

sudo apt install -y unbound dnsutils
sudo mkdir -p /etc/unbound/unbound.conf.d

sudo tee /etc/unbound/unbound.conf.d/resilient.conf > /dev/null <<'EOF'
server:
  interface: 127.0.0.1
  port: 5335
  access-control: 127.0.0.0/8 allow
  tls-cert-bundle: /etc/ssl/certs/ca-certificates.crt

  # Cache & resilience
  prefetch: yes
  cache-min-ttl: 60
  serve-expired: yes
  serve-expired-ttl: 86400
  serve-expired-client-timeout: 1800

  # Bỏ upstream chậm/chết nhanh hơn
  infra-host-ttl: 300
  infra-keep-probing: yes

  # Privacy
  qname-minimisation: yes
  hide-identity: yes
  hide-version: yes

forward-zone:
  name: "."
  forward-tls-upstream: yes
  forward-addr: 9.9.9.9@853#dns.quad9.net
  forward-addr: 1.1.1.1@853#cloudflare-dns.com
  forward-addr: 194.242.2.2@853#dns.mullvad.net
  forward-addr: 8.8.8.8@853#dns.google
EOF

sudo unbound-checkconf && sudo systemctl restart unbound
dig @127.0.0.1 -p 5335 github.com +short
Enter fullscreen mode Exit fullscreen mode

Có vài điểm đáng chú ý:

  • #hostname sau @853 là bắt buộc nếu muốn Unbound verify TLS certificate. Thiếu nó thì kết nối vẫn được mã hóa nhưng không chống được MITM.
  • Chọn upstream ở các jurisdiction khác nhau. Đừng đặt toàn bộ vào một công ty hay một quốc gia. Câu chuyện của Quad9 cho thấy rủi ro pháp lý là có thật.
  • serve-expired là option mình thích nhất. Nếu tất cả upstream cùng chết, chẳng hạn khi đứt cáp, Unbound vẫn trả record cũ trong cache, tối đa 24h. Phần lớn service nội bộ sẽ vẫn chạy được.

Sau đó trỏ systemd-resolved (systemd 247 trở lên hỗ trợ cú pháp IP:port) về Unbound:

sudo mkdir -p /etc/systemd/resolved.conf.d
printf '[Resolve]\nDNS=127.0.0.1:5335\nDomains=~.\nDNSStubListener=yes\n' \
  | sudo tee /etc/systemd/resolved.conf.d/unbound.conf
sudo systemctl restart systemd-resolved
resolvectl status | grep -A2 'Global'
Enter fullscreen mode Exit fullscreen mode

Domains=~. ép mọi query đi qua Unbound, kể cả khi DHCP đẩy DNS của ISP xuống.

Health check: biết upstream nào đang có vấn đề

Unbound tự failover, nhưng bạn vẫn cần biết khi nào một upstream bắt đầu chậm hoặc trả lỗi. Đây là script mình chạy bằng cron mỗi 5 phút. Script dùng dnspython 2.6+:

#!/usr/bin/env python3
# pip install dnspython==2.6.1
import time, json, sys
import dns.message, dns.query, dns.rcode

UPSTREAMS = {
    "quad9":      ("9.9.9.9", "dns.quad9.net"),
    "cloudflare": ("1.1.1.1", "cloudflare-dns.com"),
    "mullvad":    ("194.242.2.2", "dns.mullvad.net"),
    "google":     ("8.8.8.8", "dns.google"),
}
TEST_NAMES = ["github.com", "vnexpress.net", "pypi.org"]

def probe(ip, sni, qname):
    q = dns.message.make_query(qname, "A")
    t0 = time.perf_counter()
    try:
        r = dns.query.tls(q, ip, server_hostname=sni, timeout=3)
        ms = round((time.perf_counter() - t0) * 1000, 1)
        return {"ok": r.rcode() == dns.rcode.NOERROR, "ms": ms}
    except Exception as e:
        return {"ok": False, "error": type(e).__name__}

report, healthy = {}, 0
for name, (ip, sni) in UPSTREAMS.items():
    results = [probe(ip, sni, n) for n in TEST_NAMES]
    ok = sum(r["ok"] for r in results)
    lat = [r["ms"] for r in results if "ms" in r]
    report[name] = {"ok": f"{ok}/{len(TEST_NAMES)}",
                    "avg_ms": round(sum(lat) / len(lat), 1) if lat else None}
    healthy += ok == len(TEST_NAMES)

print(json.dumps(report, indent=2))
# Exit code != 0 khi còn dưới 2 upstream khỏe -> cron/monitoring alert
sys.exit(0 if healthy >= 2 else 1)
Enter fullscreen mode Exit fullscreen mode

Ngưỡng healthy >= 2 là có chủ đích: còn một upstream thì vẫn chạy được, nhưng đã mất redundancy và bạn cần biết ngay. Kết hợp với unbound-control stats_noreset | grep -E 'num.queries|cachehits|num.expired' sẽ thấy tỉ lệ cache hit và số lần phải serve record expired. Nếu num.expired tăng đột biến, gần như chắc chắn upstream đang có vấn đề.

Áp dụng cho Kubernetes và Docker

Trong cluster, CoreDNS mặc định forward . /etc/resolv.conf, tức là dùng lại DNS của node. Nếu node đã chạy Unbound như trên thì bạn hưởng lợi luôn. Nếu muốn CoreDNS tự xử lý, bạn có thể dùng plugin forward với nhiều upstream DoT:

sequenceDiagram
    participant Pod
    participant CoreDNS
    participant U1 as Upstream 1 (DoT)
    participant U2 as Upstream 2 (DoT)
    Pod->>CoreDNS: A api.example.com
    CoreDNS->>U1: query (health_check fail)
    U1--xCoreDNS: timeout
    CoreDNS->>U2: retry
    U2-->>CoreDNS: 203.0.113.10
    CoreDNS-->>Pod: cached answer

Đoạn config tương ứng trong Corefile là forward . tls://9.9.9.9 tls://1.1.1.1 { tls_servername ... ; health_check 5s; policy sequential }. Lưu ý: tls_servername chỉ nhận một giá trị cho cả block. Vì vậy, nếu các upstream có SNI khác nhau, bạn phải trỏ CoreDNS về Unbound trên node thay vì để CoreDNS forward thẳng ra ngoài. Đây cũng là lý do mình thích giữ Unbound làm lớp "chuẩn" ở node.

Với Docker, nhớ rằng container không dùng được 127.0.0.53 của host. Bạn nên bind Unbound vào IP của bridge docker0 (thường là 172.17.0.1) và set "dns": ["172.17.0.1"] trong /etc/docker/daemon.json.

Kết luận

DNS là thứ ai cũng coi là hiển nhiên, cho tới khi nó hỏng. Khi đó mọi thứ hỏng theo: deploy fail, webhook timeout, apt update treo. Những việc mình khuyên làm ngay:

  • Chạy local caching resolver (Unbound hoặc systemd-resolved) trên mọi server. Đừng để app gọi thẳng ra resolver bên ngoài.
  • Dùng tối thiểu 3 upstream DoT ở các provider và quốc gia khác nhau, luôn kèm #hostname để verify cert.
  • Bật serve-expired để sống sót qua những đợt mất upstream ngắn. Với hạ tầng ở Việt Nam hay bị ảnh hưởng bởi cáp biển, option này rất đáng giá.
  • Monitor từng upstream riêng biệt bằng script như trên và alert khi số upstream khỏe dưới 2.
  • Kiểm tra lại các chỗ đang hardcode IP resolver: daemon.json, Corefile, cloud-init, router. Chạy grep -rn 'nameserver\|8.8.8.8\|9.9.9.9' /etc để tìm.

Setup này tốn khoảng 15 phút. Lần tới có resolver nào rút khỏi một quốc gia, bị chặn hay sập, hệ thống của bạn sẽ chỉ thấy RTT tăng nhẹ thay vì cả team bị gọi dậy lúc 3 giờ sáng.

Top comments (0)