Phần 33 đo bốn driver mạng bằng iperf3 và thấy bridge là lựa chọn mặc định hợp lý cho phần lớn ứng dụng một máy. Bài này ở lại bridge, nhưng đổi câu hỏi: hai container trên cùng mạng đó tìm nhau bằng cách nào khi IP có thể đổi mỗi lần container khởi động lại? Câu trả lời là DNS nội bộ của Docker, và nó có một cái bẫy không nằm ở tốc độ phân giải mà ở chỗ ai giữ kết quả đó lâu hơn Docker định.

Ba cột minh hoạ DNS nội bộ Docker: mạng tự định nghĩa phân giải được, bridge mặc định trả NXDOMAIN, và bẫy haproxy giữ IP cũ sau khi container backend bị tái tạo

Môi trường đo

Docker Engine 29.7.2 trên Docker Desktop (macOS), daemon chạy trong VM linuxkit. Mọi lệnh dưới đây chạy thật, dán trực tiếp từ terminal.

$ docker version --format '{{.Server.Version}}'
29.7.2

DNS nội bộ nằm ở 127.0.0.11, không phải ở đâu khác

Tạo một mạng tự định nghĩa rồi xem /etc/resolv.conf bên trong container:

$ docker network create dns-test
$ docker run -d --name web1 --network dns-test alpine:3.20 sleep 3600
$ docker run --rm --network dns-test alpine:3.20 cat /etc/resolv.conf
# Generated by Docker Engine.
nameserver 127.0.0.11
options ndots:0
# Based on host file: '/etc/resolv.conf' (internal resolver)
# ExtServers: [host(192.168.65.7)]

127.0.0.11 không phải là loopback thật của container — Docker chèn một DNS server giả lập ngay trong network namespace, đọc bảng ánh xạ tên–IP nội bộ (cập nhật mỗi khi container start/stop) và chỉ forward ra ExtServers (ở đây là DNS thật của host, 192.168.65.7) khi tên không khớp bất kỳ container hay alias nào. Phân giải tên container xong:

$ docker run --rm --network dns-test alpine:3.20 getent hosts web1
172.19.0.2        web1  web1

Bridge mặc định: cùng lệnh, không có DNS

Chuyển sang mạng bridge mặc định (không --network, hoặc khai rõ --network bridge) và lặp lại đúng lệnh:

$ docker run -d --name web2 --network bridge alpine:3.20 sleep 3600
$ docker run --rm --network bridge alpine:3.20 cat /etc/resolv.conf
# Generated by Docker Engine.
nameserver 192.168.65.7
# Based on host file: '/etc/resolv.conf' (legacy)

$ docker run --rm --network bridge alpine:3.20 getent hosts web2
$ echo $?
2

Không một dòng cảnh báo nào, chỉ là resolv.conf trỏ thẳng ra DNS của host, bỏ qua 127.0.0.11 hoàn toàn — mạng bridge mặc định chưa từng có embedded DNS, kể cả ở bản Docker Engine mới nhất. Đây là bẫy thực dụng đầu tiên, gặp trước cả bẫy cache: đổi từ docker run --link (deprecated) hay mong đợi tên container tự resolve trên bridge mặc định là sai ngay từ giả định. Muốn container gọi nhau bằng tên, việc đầu tiên là docker network create một mạng tự định nghĩa — Docker Compose làm việc này ngầm cho bạn, đó là lý do resolve-by-service-name "tự nhiên hoạt động" trong Compose nhưng không hoạt động với docker run trần không khai --network.

Đo độ trễ phân giải: tên container không cần "làm nóng"

Phân giải tên nội bộ là tra một bảng trong bộ nhớ của embedded DNS, không phải một truy vấn mạng thật, nên câu hỏi "có cache không" gần như không có ý nghĩa — nó đã nhanh ngay từ lần đầu:

$ docker run --rm --network dns-test alpine:3.20 sh -c \
    "apk add -q bind-tools && for i in $(seq 10); do dig web1 +noall +stats | grep 'Query time'; done"
;; Query time: 1 msec
;; Query time: 0 msec
;; Query time: 0 msec
;; Query time: 0 msec
;; Query time: 0 msec
;; Query time: 0 msec
;; Query time: 0 msec
;; Query time: 0 msec
;; Query time: 0 msec
;; Query time: 0 msec

So sánh với một tên miền thật phải forward ra ngoài qua ExtServers:

$ docker run --rm --network dns-test alpine:3.20 sh -c "apk add -q bind-tools; \
    echo '--- lần đầu (cold) ---'; dig example.com +noall +stats | grep 'Query time'; \
    echo '--- lặp lại 5 lần (warm) ---'; for i in $(seq 5); do dig example.com +noall +stats | grep 'Query time'; done"
--- lần đầu (cold) ---
;; Query time: 7 msec
--- lặp lại 5 lần (warm) ---
;; Query time: 1 msec
;; Query time: 1 msec
;; Query time: 1 msec
;; Query time: 1 msec
;; Query time: 1 msec

Chênh lệch 7 ms → 1 ms chỉ xuất hiện với tên miền bên ngoài: embedded DNS cache lại kết quả forward theo TTL của nameserver upstream, vì lần đó nó thật sự phải đi ra khỏi container qua mạng. Với tên container nội bộ thì không có "ngoài" nào để đi tới, nên không có khoảng cách cold/warm — cả 10 lần đều ~0–1 ms. Nói cách khác: cache DNS trong Docker chỉ tồn tại cho lookup ra bên ngoài, còn service discovery nội bộ vốn đã rẻ tới mức không cần cache.

Cái bẫy thật không nằm ở Docker — nó nằm ở ai giữ kết quả DNS lâu hơn Docker

Nếu embedded DNS không cache tên container (mỗi lần hỏi đều lấy IP mới nhất), thì đổi container lấy IP mới có làm mọi thứ tự cập nhật theo không? Có — miễn là bên hỏi hỏi lại. Bẫy thật nằm ở những thành phần tự cache kết quả DNS lâu hơn thời điểm chúng nên hỏi lại. Dựng lại bằng haproxy (cài thẳng bằng apk trong alpine:3.20, khỏi cần kéo image ngoài) đứng trước một backend, cấu hình server trỏ vào tên, không phải IP:

# backend V1, gán IP tường minh để lát nữa ép đổi IP thật sự
$ docker run -d --name backend --network dns-test --ip 172.19.0.50 alpine:3.20 sh -c \
    "apk add -q busybox-extras && mkdir -p /www && echo BACKEND-V1 > /www/index.html && \
     httpd -f -p 0.0.0.0:80 -h /www"
# haproxy.cfg
frontend fe
    bind *:80
    default_backend be
backend be
    server backend1 backend:80
$ docker run -d --name proxy --network dns-test \
    -v $(pwd)/haproxy.cfg:/etc/haproxy.cfg:ro alpine:3.20 sh -c \
    "apk add -q haproxy && haproxy -f /etc/haproxy.cfg"

$ docker run --rm --network dns-test alpine:3.20 wget -qO- http://proxy/
BACKEND-V1

Xoá backend và tạo lại ở một IP khác hẳn trong cùng subnet — mô phỏng đúng những gì xảy ra khi container bị recreate trên một mạng đang bận, nơi IP vừa giải phóng hiếm khi được cấp lại ngay cho container tiếp theo:

$ docker rm -f backend
$ docker run -d --name backend --network dns-test --ip 172.19.0.99 alpine:3.20 sh -c \
    "apk add -q busybox-extras && mkdir -p /www && echo BACKEND-V2 > /www/index.html && \
     httpd -f -p 0.0.0.0:80 -h /www"

Hỏi thẳng embedded DNS ngay lúc này — nó đã cập nhật và gọi trực tiếp backend cũng đã ra V2:

$ docker run --rm --network dns-test alpine:3.20 getent hosts backend
172.19.0.99       backend  backend
$ docker run --rm --network dns-test alpine:3.20 wget -qO- http://backend/
BACKEND-V2

Nhưng gọi qua proxy:

$ docker run --rm --network dns-test alpine:3.20 wget -T 5 -qO- http://proxy/
wget: download timed out

DNS đã đúng, haproxy thì chưa biết. Nó resolve tên backend trong server backend1 backend:80 một lần lúc tiến trình khởi động, giữ thẳng địa chỉ IP đó cho mọi kết nối tiếp theo, và không tự dò lại giữa chừng — đó là hành vi mặc định khi khai server bằng tên tĩnh, khác với cấu hình resolvers (DNS động, có TTL, dùng cho các bản HAProxy hỗ trợ service discovery liên tục). IP 172.19.0.50 không còn ai lắng nghe nên request treo tới hết timeout connect rồi timeout hẳn. Buộc nó hỏi lại bằng cách khởi động lại tiến trình:

$ docker restart proxy
$ docker run --rm --network dns-test alpine:3.20 wget -T 5 -qO- http://proxy/
BACKEND-V2

Đây chính là "bẫy cache DNS" đúng nghĩa: không phải TTL của Docker quá dài, mà là tầng ứng dụng phía trên tự cache kết quả DNS lâu hơn tầng DNS bên dưới, và không có tín hiệu lỗi nào ngoài timeout lúc request thật đi tới. Bài này chỉ đo haproxy, nhưng lớp lỗi giống hệt xảy ra với bất kỳ công cụ nào resolve hostname một lần lúc khởi động rồi giữ nguyên kết quả — nginx với proxy_pass trỏ tên tĩnh (không dùng biến kèm resolver) được tài liệu chính thức của nginx ghi rõ là hoạt động theo đúng kiểu này, dù bài này chưa tự đo lại.

Tổng kết

Tình huống Có phân giải được tên container? Vì sao
Mạng tự định nghĩa (docker network create, hoặc Compose) Có, ~0–1 ms, không cần làm nóng resolv.conf trỏ 127.0.0.11, tra bảng service discovery live
Mạng bridge mặc định Không, NXDOMAIN/exit 2 Chưa từng có embedded DNS, resolv.conf trỏ thẳng DNS host
Tên miền ngoài, gọi lặp lại Có cache, 7 ms → 1 ms Embedded DNS cache kết quả forward theo TTL upstream
haproxy với server ten-tinh:80 sau khi container đổi IP Không, tới khi restart HAProxy cache IP lúc start, không tự dò lại giữa chừng nếu thiếu resolvers

Thực dụng: nếu container của bạn cần gọi nhau bằng tên, luôn dùng mạng tự định nghĩa (Compose đã lo sẵn). Nếu có haproxy, nginx hay bất kỳ reverse proxy nào đứng trước một service hay bị recreate (rolling update thủ công, docker compose up -d <service> riêng lẻ), kiểm tra xem cấu hình có đang cache IP tĩnh hay không trước khi tin rằng "DNS tự cập nhật là xong việc" — DNS đã đúng, tầng phía trên chưa chắc đã hỏi lại.

Thử ba mươi giây

Kiểm tra nhanh xem một container đứng trước bạn (proxy, load balancer, API gateway) có đang resolve tên chỉ một lần hay không: đổi container đích rồi gọi lại ngay, không đợi restart gì cả.

docker run --rm --network <ten-mang> alpine:3.20 getent hosts <ten-service-dich>

Nếu kết quả đổi theo IP mới nhưng ứng dụng của bạn vẫn nối vào chỗ cũ, DNS không phải là nơi cần sửa — nơi cần sửa là chỗ giữ IP đó lâu hơn nó nên giữ.

Bài viết liên quan