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 đề:
- Plaintext UDP/53: ai đứng giữa đường cũng đọc và sửa được query. ISP có thể inject response.
-
- 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.
- Failover của glibc rất tệ: mặc định
-
- 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:
```bash
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
```
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:
```bash
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'
```
`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+:
```python
#!/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)
```
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:
```mermaid
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.
All rights reserved