Hãy hình dung một lễ tân nối máy theo tên. Bạn gọi tổng đài, nói "cho tôi gặp phòng Kinh doanh", cô ấy nhìn bảng nối máy đang cập nhật từng phút rồi chuyển tới đúng người đang ngồi bàn đó lúc này. Đó là DNS nội bộ của Docker. Cái bẫy nằm ở chiếc điện thoại bàn của bạn: bạn cài phím tắt một lần với số máy lẻ trực tiếp của phòng Kinh doanh; khi họ chuyển sang máy lẻ khác, bảng của lễ tân đổi ngay, còn phím tắt của bạn vẫn quay số cũ — reo vào một cái bàn trống. Container tìm nhau bằng tên, và cơ chế đằng sau đúng là cô lễ tân đó: một máy chủ DNS nhỏ Docker nhét vào từng container — chỉ tồn tại trên mạng tự tạo.

[bridge tu tao]                  [bridge mac dinh]
nameserver 127.0.0.11            nameserver 192.168.65.7
options ndots:0

Địa chỉ 127.0.0.11 là resolver nhúng của Docker. Trên bridge mặc định nó không có mặt: container trỏ thẳng vào DNS của máy chủ.

Hậu quả đo được:

Tra dn-srv Tra bí danh api
bridge tự tạo 192.168.0.2 192.168.0.2
bridge mặc định không phân giải được —

Đây là lý do thật sự để luôn tự tạo mạng, mạnh hơn nhiều so với chênh lệch hiệu năng ở phần 33 (vốn không đáng kể).

Phân giải nội bộ rất nhanh — trừ khi bạn gõ sai tên

200 lần tra mỗi tên, đo bằng getaddrinfo trong Python:

Tên Trung vị p95
dn-srv (container) 0,073 ms 0,219 ms
api (bí danh) 0,065 ms 0,177 ms
google.com (ngoài) 0,982 ms 2,290 ms
khong-ton-tai-abc 2,420 ms 4,839 ms

Dòng cuối đáng chú ý nhất: tên không tồn tại là chậm nhất, gấp 37 lần một lần tra nội bộ. Vì resolver nhúng không biết tên đó, nó chuyển tiếp lên DNS bên ngoài rồi ngồi chờ NXDOMAIN.

Nghĩa là một lỗi gõ nhầm trong biến môi trường không chỉ làm hỏng kết nối — nó còn làm chậm mỗi lần thử. Ứng dụng nào thử lại trong vòng lặp sẽ đốt vài mili giây cho mỗi vòng, và trong log bạn chỉ thấy "connection refused" chứ không thấy thời gian đi đâu.

Một chi tiết dễ bỏ qua: ndots:0

Docker đặt options ndots:0 trên mạng tự tạo. Nghĩa là tên nào cũng thử tra thẳng trước, không nối thêm hậu tố tìm kiếm. Đây là lựa chọn tốt — nó tránh đúng vấn đề mà Kubernetes nổi tiếng vì mắc phải, nơi ndots:5 khiến mỗi lần tra một tên ngoài phải thử qua vài hậu tố nội bộ trước.

Nhiều container, một cái tên

Ba container cùng bí danh web, tra 20 lần:

  8 lan -> 192.168.0.3
  7 lan -> 192.168.0.4
  5 lan -> 192.168.0.5

Resolver trả về luân phiên. Đây là cân bằng tải rẻ tiền cho các bản sao của cùng một dịch vụ:

docker run -d --network ung-dung --network-alias web ...   # chạy N lần

Nhưng đừng nhầm nó với một bộ cân bằng tải thật. Nó không kiểm tra sức khoẻ: container chết vẫn nằm trong danh sách cho tới khi Docker gỡ nó ra, và client vẫn nhận địa chỉ đó. Chính chỗ này dẫn tới phần quan trọng nhất của bài.

Cái bẫy: client giữ IP cũ sau khi bạn deploy

Container đổi IP mỗi lần được tạo lại. DNS cập nhật ngay. Client thì không.

Tôi chạy một chương trình Java tra tên api mỗi giây, rồi thay container api bằng một container khác có IP khác hẳn (đặt tường minh để không thể trùng):

  ttl mac dinh: null
  ttl khi tra loi: 10
  giay  0 (03:12:33): api -> 10.77.0.10
  giay 30 (03:13:03): api -> 10.77.0.20

Container bị thay ở giây thứ 6. JVM tiếp tục dùng địa chỉ đã chết cho tới giây 30 — 24 giây gọi vào hư không. Với một dịch vụ đang nhận tải, đó là 24 giây lỗi sau mỗi lần deploy, và log của bạn sẽ đầy Connection refused trỏ vào một IP không còn tồn tại.

JVM chỉ là ví dụ rõ nhất vì nó cache mặc định 30 giây. Bất cứ runtime hay thư viện nào giữ kết quả DNS đều có vấn đề tương tự — kể cả pool kết nối giữ sẵn socket tới địa chỉ cũ.

Và cái cờ ai cũng khuyên thì không chạy

Lời khuyên phổ biến là thêm -Dnetworkaddress.cache.ttl=0. Tôi đo ba trường hợp, mỗi lần thay container rồi chờ 12 giây:

Chạy với Cập nhật trong 12 giây?
không đặt gì không
-Dnetworkaddress.cache.ttl=0 không
-Dsun.net.inetaddr.ttl=0 có

networkaddress.cache.ttl là một security property, đặt trong tệp java.security, không phải system property. Truyền qua -D thì JVM nhận vào mà chẳng ai đọc — không lỗi, không cảnh báo, và bạn tin là mình đã sửa xong.

Chương trình của tôi in ra giá trị thật ngay lúc khởi động:

ttl mac dinh: null
ttl khi tra loi: 10

null nghĩa là chưa ai đặt, JVM dùng mặc định 30 giây. Nếu bạn thêm -Dnetworkaddress.cache.ttl=0 mà dòng này vẫn in null, cờ của bạn đang rơi vào hư không.

Cách sửa đúng, chọn một trong hai:

java -Dsun.net.inetaddr.ttl=0 -jar app.jar
# hoac ghi vao $JAVA_HOME/conf/security/java.security:
#   networkaddress.cache.ttl=0

Tôi tự làm nhiễm phép đo của mình

Lần chạy đầu tiên cho kết quả trông rất thuyết phục: giây 0 ra 192.168.0.2, giây 30 ra 192.168.0.3. Đúng 30 giây, khớp hoàn hảo với lý thuyết.

Nó sai. Một container từ thí nghiệm trước vẫn còn sống và cũng mang bí danh api. Hai container cùng tên nghĩa là resolver trả về luân phiên — cái tôi tưởng là "cache hết hạn" chỉ là round-robin rơi đúng nhịp.

Điều làm lộ ra: lần chạy lại sạch sẽ cho ra không có gì thay đổi cả, vì container mới nhận đúng địa chỉ IP mà container cũ vừa trả lại. Không có hai lần chạy đó thì tôi đã công bố một con số đúng vì lý do sai.

Bản cuối đặt IP tường minh (--ip 10.77.0.10 rồi --ip 10.77.0.20) trên một mạng có subnet khai sẵn, nên không còn chỗ cho trùng lặp hay luân phiên. Bài học: khi kết quả khớp lý thuyết ngay lần đầu, hãy nghi ngờ nó nhiều hơn.

Muốn tự soi resolver và cách một tên được phân giải, dựng thử một mạng rồi hỏi thẳng:

docker network create thu
docker run -d --name db --network thu --network-alias csdl alpine sleep 300
docker run --rm --network thu alpine cat /etc/resolv.conf | grep nameserver
docker run --rm --network thu alpine getent hosts csdl
docker rm -f db && docker network rm thu

Ba dấu hiệu cần nhìn:

  • nameserver không phải 127.0.0.11 → bạn đang ở bridge mặc định, tên sẽ không phân giải được.
  • Ứng dụng báo Connection refused tới một IP mà docker inspect không tìm thấy → client đang giữ cache DNS cũ.
  • Tra một tên sai mất vài mili giây thay vì vài chục micro giây → tên đó không tồn tại và đang bị chuyển tiếp ra ngoài.

Mẫu số chung

Bài học đầu tiên, đọc thẳng từ 24 giây gọi vào IP đã chết: một lớp gián tiếp (tên → địa chỉ) chỉ có giá trị nếu bạn phân giải lại vào đúng lúc dùng — cache cái địa chỉ đã phân giải là phá bỏ chính cái lợi của lớp gián tiếp đó, và để bạn trỏ vào một cái xác sau khi mục tiêu đã dời đi. Cái tên mới là hợp đồng ổn định; cái địa chỉ là thứ phù du, đổi mỗi lần container được tạo lại. DNS cập nhật tức thì, nhưng client giữ số cũ thì mọi thứ vô nghĩa — kể cả pool kết nối vẫn ôm socket tới IP cũ. Cùng cái bẫy "phân giải một lần rồi ôm mãi" ở khắp nơi: cache DNS quá TTL, một memoize sống lâu hơn thời hạn hợp lệ của khoá, một giá trị cấu hình được cache sau khi đã reload, một client giữ URL cũ sau khi server đã 301. Nguyên tắc: bind muộn, và tôn trọng TTL — với thứ có thể dời chỗ dưới chân mình, hãy phân giải-lại thay vì nhớ, vì một lớp gián tiếp mà bạn đông cứng lại thành ra một gánh nợ chứ không còn là một tiện ích.

Điều thứ hai, chính là cái cờ -Dnetworkaddress.cache.ttl=0 rơi vào hư không: truyền một thiết lập không đồng nghĩa với việc nó có hiệu lực — phải đọc lại giá trị sống để xác nhận. JVM nhận cờ đó mà chẳng ai đọc, vì nó là security property chứ không phải system property; không lỗi, không cảnh báo, và bạn tin là mình đã sửa. May mà chương trình in ra ttl mac dinh: null, đúng bằng chứng cờ bị bỏ qua. Cùng cái bẫy "thiết lập âm thầm không ăn" ở khắp nơi: một biến môi trường bị tầng sai đọc, một khoá cấu hình gõ nhầm lặng lẽ về mặc định, một flag đặt sai phạm vi, một sysctl chưa được ghi bền. Nguyên tắc: sau khi đặt một tham số, hãy đọc lại giá trị đang thực sự áp dụng để chứng minh nó đã ăn — đừng tin rằng "đã truyền vào" là "đã có tác dụng", vì khoảng cách giữa hai điều đó là chỗ bạn ngồi gỡ lỗi một cấu hình vốn chưa bao giờ tồn tại.

Phần sau đo đường đi của gói tin khi bạn publish một cổng: -p thật ra làm gì, và vì sao nó đôi khi làm địa chỉ IP của khách biến mất.