Hãy tưởng tượng bạn đưa cho bưu điện một địa chỉ thiếu tên quốc gia. Nhân viên tận tụy sẽ không gửi đi ngay — họ thử ghép nó với từng nước trong danh sách họ quen trước, gửi thử, chờ hồi âm "không có địa chỉ này", rồi mới thử nước tiếp theo. Nếu mỗi lần thử sai phải chờ cả tuần mới biết, một lá thư đơn giản có thể mất hàng tháng. Còn nếu bạn ghi rõ ràng tên nước ở dòng cuối, họ gửi thẳng một phát. Cấu hình DNS mặc định trong Kubernetes đúng là anh nhân viên tận tụy đó, và bài này đo xem sự tận tụy ấy đắt tới mức nào.
Phần 27 liệt kê tra DNS là chặng đầu tiên của một yêu cầu HTTPS và ghi "0 hoặc 1 vòng khứ hồi". Ở đây tôi đo, và tìm thấy một con số lớn hơn thế rất nhiều.
Chi phí cơ bản
getaddrinfo, 200 lần, đo trung vị:
| Tra cái gì | Trung vị | So với địa chỉ IP |
|---|---|---|
| Địa chỉ IP (không tra tên) | 2,2 µs | 1× |
| Tên nội bộ, cấu hình bình thường | 88,0 µs | 40× |
Tên ngoài (example.com) |
982,6 µs | 447× |
| Tên không tồn tại | 2.302,5 µs | 1.046× |
Hai điều đáng nhớ ngay từ bảng này.
Thứ nhất, tra một tên đắt gấp 40 lần dùng thẳng địa chỉ — và đó đã là trường hợp tốt nhất: máy chủ DNS nằm ngay trên máy, câu trả lời đã có trong bộ đệm. Thứ hai, và âm thầm hơn: tra một tên sai đắt gấp 26 lần tra một tên đúng. Một lỗi chính tả trong tên máy chủ, hoặc một dịch vụ đã bị xoá mà mã nguồn vẫn gọi tới, không cho lỗi ngay — nó cho một khoảng chờ vài mili giây trên mọi yêu cầu, âm ỉ mãi.
Rồi cấu hình mặc định của Kubernetes
Chạy lại đúng chương trình đó trong một container có 5 miền tìm kiếm và ndots:5 — đúng cấu hình mà mọi pod Kubernetes nhận được:
search a.svc.cluster.local svc.cluster.local cluster.local vi.du.mot vi.du.hai
options ndots:5
Năm lần đo mỗi tên:
| Tra cái gì | Năm lần đo (µs) |
|---|---|
nsrv |
24.038.841 / 24.027.243 / 24.022.974 / 24.021.209 / 24.014.459 |
example.com |
24.070.556 / 24.026.533 / 24.016.385 / 24.022.011 / 24.015.375 |
example.com. |
1.224 / 1.199 / 991 / 845 / 1.168 |
24 giây cho một lần tra tên. Thêm một dấu chấm ở cuối tên: 1,2 mili giây. Khoảng hai mươi nghìn lần, chỉ bằng một ký tự. Lần đầu thấy con số này tôi tưởng mình đo nhầm — nhưng năm lần chạy đều khớp.
Vì sao dấu chấm đổi được nhiều đến thế
ndots:5 nghĩa là: nếu tên có ít hơn 5 dấu chấm, hãy thử ghép nó với từng miền tìm kiếm trước, rồi mới thử chính nó.
example.com có một dấu chấm. Vậy trình phân giải hỏi theo thứ tự:
example.com.a.svc.cluster.local
example.com.svc.cluster.local
example.com.cluster.local
example.com.vi.du.mot
example.com.vi.du.hai
example.com <- cuoi cung moi toi cai dung
Sáu câu hỏi cho một cái tên — đúng anh bưu điện thử từng nước. Và trong môi trường tôi đo, năm câu đầu không được trả lời: trình phân giải nội bộ không trả NXDOMAIN mà im lặng, nên mỗi câu phải chờ hết hạn rồi thử lại. Cộng lại thành 24 giây. Dấu chấm cuối biến tên thành tuyệt đối — trình phân giải hỏi đúng một lần, không ghép gì. Ghi rõ tên nước ở dòng cuối.
Trong Kubernetes thật, số câu hỏi còn nhân đôi vì getaddrinfo hỏi cả bản ghi A và AAAA. Sáu tên × hai loại = mười hai truy vấn cho một lần kết nối.
Ba cách chữa
Một — dấu chấm cuối. Rẻ nhất, và không cần quyền gì:
DB_HOST=db.example.com.
Cẩn thận: một số thư viện và trình kiểm tra chứng chỉ TLS xử lý dấu chấm cuối không nhất quán. Kiểm tra trước khi đưa vào sản phẩm.
Hai — hạ ndots cho từng pod:
spec:
dnsConfig:
options:
- name: ndots
value: "2"
ndots:2 giữ được việc gọi dịch vụ nội bộ bằng tên ngắn (api, db — không dấu chấm nào) trong khi tên ngoài (s3.amazonaws.com — hai dấu chấm) đi thẳng.
Ba — bộ đệm DNS tại chỗ. NodeLocal DNSCache trong Kubernetes, hoặc systemd-resolved/dnsmasq trên máy chủ thường. Nó không sửa việc ghép miền tìm kiếm, nhưng làm mỗi câu hỏi hỏng rẻ đi rất nhiều.
Bộ đệm ở đâu — và chỗ không có
Đây là điều nhiều người bất ngờ: glibc không có bộ đệm DNS. Mỗi lần getaddrinfo là một câu hỏi thật ra ngoài, trừ khi:
nscdhoặcsystemd-resolvedđang chạy và được khai trong/etc/nsswitch.conf.- Ứng dụng tự cache.
Ngôn ngữ khác nhau thì hành vi khác nhau, và đây là nguồn của rất nhiều bất ngờ:
| Môi trường | Bộ đệm |
|---|---|
| C / glibc | không, trừ khi có nscd |
| Go | không (bộ phân giải thuần Go cũng không cache) |
| Node.js | không, trừ khi dùng thư viện |
| Python | không |
| JVM | có, và mặc định cũ là cache vĩnh viễn |
Dòng cuối là cái bẫy nổi tiếng nhất: một dịch vụ Java tra tên một lần lúc khởi động rồi giữ mãi địa chỉ đó. Khi máy chủ phía sau đổi IP — chuyện xảy ra mỗi lần bộ cân bằng tải hoặc RDS chuyển đổi dự phòng — dịch vụ tiếp tục gọi vào địa chỉ chết cho tới khi được khởi động lại.
# $JAVA_HOME/conf/security/java.security
networkaddress.cache.ttl=30
networkaddress.cache.negative.ttl=0
Giá trị mặc định phụ thuộc vào việc có trình quản lý bảo mật hay không, nên đừng dựa vào mặc định — khai tường minh.
Tra tên chặn cả luồng
getaddrinfo là lời gọi đồng bộ và chặn. Không có bản bất đồng bộ trong thư viện chuẩn POSIX.
Nghĩa là trong một dịch vụ dùng vòng lặp sự kiện — Node.js, Nginx với resolver mặc định, một máy chủ Go xử lý nhiều kết nối trên ít luồng — một lần tra tên chậm giữ luôn luồng đó. Với 24 giây như bảng trên, đó không còn là vấn đề hiệu năng mà là một sự cố toàn diện.
Node.js chạy getaddrinfo trên bể luồng, và bể đó mặc định có bốn luồng:
UV_THREADPOOL_SIZE=16 node app.js
Bốn lần tra tên chậm đồng thời là đủ để chặn luôn cả việc đọc tệp và nén dữ liệu của tiến trình đó.
Mẫu số chung: một "tiện lợi" âm thầm biến một thao tác thành N thao tác
Cái lõi của bài này không phải DNS, mà là một cơ chế "tiện lợi" — trình phân giải tự đoán giúp bạn tên đầy đủ — âm thầm nhân một thao tác thành nhiều, và mỗi lần đoán sai lại nuốt một khoảng chờ. Cùng cái bẫy đó rình ở mọi tầng.
Trong thế giới Java, đây đúng là bài toán N+1 query của JPA/Hibernate: bạn nạp một danh sách, rồi truy cập một quan hệ lười cho từng phần tử, và ORM âm thầm bắn thêm một câu query cho mỗi dòng — một thao tác trên giấy tờ thành N+1 vòng khứ hồi tới CSDL. Không ai viết vòng lặp đó, framework "tiện lợi" viết hộ. Cách chữa cũng cùng tinh thần dấu-chấm-cuối: nói rõ ý định trước (JOIN FETCH, @EntityGraph) thay vì để nó đoán.
Trong Go, cùng hình dạng: một http.Client không đặt timeout, mỗi lần gọi ra một dịch vụ chậm giữ luôn goroutine (song ánh getaddrinfo chặn luồng ở trên), và một retry ngây thơ biến một lần gọi hỏng thành ba lần gọi hỏng nhân với backoff. Retry là "tiện lợi" nhân thao tác.
Còn ở cơ sở dữ liệu, đây là chuyện một câu SELECT tưởng chừng đơn giản kích hoạt một trigger, mà trigger đó lại ghi một bảng khác có trigger nữa — hay một view lồng nhiều tầng nở ra thành chục lần quét bảng. Người viết thấy một dòng, engine thực thi hàng chục thao tác. EXPLAIN là cách bắt nó lộ mặt, đúng như strace/getent bắt DNS lộ ra sáu câu hỏi.
Mẫu số chung: những cơ chế "tự lo giúp bạn" — search domain, lazy loading, retry, trigger, view lồng — đều âm thầm biến một thao tác thành N, và chi phí ẩn ấy chỉ hiện ra dưới tải hoặc khi một nhánh im lặng chờ hết hạn; nhìn code không thấy vì code chỉ có một dòng. Nên câu hỏi đúng khi một thứ chậm bất thường không phải "thao tác này tốn bao nhiêu" mà là "một lần tôi gọi thật ra biến thành mấy lần dưới lớp tiện lợi kia, và có nhánh nào đang lặng lẽ chờ hết hạn không" — và cách chữa gần như luôn là nói rõ ý định để nó khỏi đoán.
Muốn tự đo cấu hình DNS của chính mình:
cat /etc/resolv.conf | grep -vE '^#|^$'
echo "--- tra ten thanh cong ---"
for i in 1 2 3; do
/usr/bin/time -f '%e s' getent hosts example.com >/dev/null
done 2>&1
echo "--- tra ten hong ---"
for i in 1 2 3; do
/usr/bin/time -f '%e s' getent hosts khong-co-ten-nay-dau.example >/dev/null
done 2>&1
echo "--- co dau cham cuoi ---"
for i in 1 2 3; do
/usr/bin/time -f '%e s' getent hosts example.com. >/dev/null
done 2>&1
Nếu dòng options ndots: của bạn lớn hơn 2 và có nhiều dòng search, hãy so hai nhóm cuối. Chênh lệch giữa chúng là thứ mọi kết nối ra ngoài của bạn đang trả, trên mỗi yêu cầu.
Phần sau: TLS — đo chi phí bắt tay và chi phí mã hoá.