0

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.
    1. 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.
    1. 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí