Hình dung nút gọi nhanh dán nhãn "nhà" trên điện thoại. Trên máy của bạn nó reo về nhà bạn; trên một cái máy mượn, cùng nút đó reo về nhà người khác — mà bấm nút nào cũng có người thật nhấc máy, nên bạn có thể được nối chắc chắn tới nhầm nhà mà không hề hay. host.docker.internal đúng là cái nút gọi nhanh đó: container cần gọi ngược về một dịch vụ trên máy chủ — CSDL cài trực tiếp, một API trong IDE, một tiến trình debug — và cái tên sinh ra cho việc ấy hành xử khác nhau tuỳ nền tảng, theo cách dễ làm bạn mất buổi chiều.
Tôi dựng hai dịch vụ trên máy chủ, một bind 0.0.0.0, một bind 127.0.0.1, mỗi máy trả về một chuỗi tự nhận dạng để không nhầm lẫn được.
Docker Desktop trên macOS
host.docker.internal -> 192.168.65.254
gateway.docker.internal -> 192.168.65.1
gateway mac dinh cua container -> 172.17.0.1
/etc/hosts: (khong co dong nao)
Cái tên có sẵn, không cần cờ gì, và nó đến từ DNS nhúng chứ không phải /etc/hosts.
| Gọi tới | Kết quả |
|---|---|
host.docker.internal:18190 (bind 0.0.0.0) |
TOI-LA-MACOS [200] |
host.docker.internal:18191 (bind 127.0.0.1) |
TOI-LA-MACOS [200] |
192.168.65.254:18190 |
TOI-LA-MACOS [200] |
172.17.0.1:18190 (gateway mặc định) |
[000] không kết nối được |
Hai dòng đáng chú ý. Thứ nhất: dịch vụ bind 127.0.0.1 vẫn gọi được. Docker Desktop chạy trong máy ảo và có một lớp chuyển tiếp về loopback của macOS, nên "chỉ nghe localhost" không còn là hàng rào. Thứ hai: gateway mặc định của container (172.17.0.1) không dùng được — đó là địa chỉ trong máy ảo Linux, không phải máy Mac của bạn.
--add-host=x:host-gateway trên Desktop cũng trỏ về địa chỉ đặc biệt đó chứ không phải docker0:
192.168.65.254 x
fdc4:f303:9324::254 x
Daemon Linux
Trên Linux, Docker không tự cung cấp cái tên này. Bạn phải khai:
docker run --add-host=host.docker.internal:host-gateway ...
Khi đó nó trỏ vào chính gateway của cầu Docker:
host.docker.internal -> 172.18.0.1
gateway mac dinh -> 172.18.0.1 (cung mot dia chi)
/etc/hosts: 172.18.0.1 host.docker.internal
Khác hẳn Desktop: ở đây nó là /etc/hosts, và nó là gateway.
| Gọi tới | Kết quả |
|---|---|
host.docker.internal:18190 (bind 0.0.0.0) |
TOI-LA-MAY-LINUX [200] |
host.docker.internal:18191 (bind 127.0.0.1) |
[000] không kết nối được |
172.18.0.1:18190 (gọi thẳng gateway) |
TOI-LA-MAY-LINUX [200] |
Đây là khác biệt quan trọng nhất của cả bài. Dịch vụ bind 127.0.0.1 trên Linux thì container không với tới được, vì gói tin đến từ mạng cầu 172.18.0.0/16 chứ không phải từ loopback.
Nghĩa là chuỗi sự kiện quen thuộc: bạn viết code trên macOS, mọi thứ chạy ngon; đẩy lên máy chủ Linux, container báo "connection refused" tới CSDL — vì trên máy chủ, PostgreSQL nghe listen_addresses = localhost. Cách sửa là cho nó nghe cả địa chỉ cầu Docker, và chỉ địa chỉ đó, đừng mở ra 0.0.0.0 (xem phần 35 về việc tường lửa không cứu bạn ở chỗ này).
Trên Linux, gọi thẳng 172.18.0.1 cũng chạy. Nhưng địa chỉ đó đổi theo từng mạng Docker, nên dùng tên vẫn tốt hơn — chỉ cần nhớ luôn kèm cờ.
Windows
Tôi không có máy Windows nên không đo được. Tài liệu nói Docker Desktop trên Windows hành xử như trên macOS, nhưng đó là điều tôi đọc chứ không phải điều tôi đo, nên tôi để nguyên như vậy.
Trường hợp nó gọi nhầm sang máy khác
Đây là phần thú vị nhất, và tôi vấp phải nó ngay trong lúc dựng phép đo.
Phòng thí nghiệm Linux của tôi là một daemon Docker chạy lồng trong Docker Desktop. Khi tôi chạy container không kèm cờ gì rồi gọi host.docker.internal:18190, nó trả về:
TOI-LA-MACOS [ma 200]
Nó phân giải được, kết nối được, trả về 200 — và đi tới hoàn toàn sai máy. Nếu cả hai máy cùng chạy một dịch vụ giống nhau, tôi sẽ chẳng bao giờ phát hiện ra.
Nguyên nhân: container lồng bên trong thừa hưởng resolver 192.168.65.7 của Docker Desktop, và resolver đó vui vẻ trả lời host.docker.internal bằng địa chỉ của máy Mac. Cái tên đúng, câu trả lời sai ngữ cảnh.
Dấu hiệu để nhận ra: /etc/hosts không có dòng nào. Trên Linux đúng chuẩn, cái tên này chỉ tồn tại nhờ --add-host, tức là nhờ /etc/hosts. Nếu nó phân giải được mà /etc/hosts trống, câu trả lời đang đến từ một nơi bạn không kiểm soát.
docker run --rm alpine sh -c \
'grep docker.internal /etc/hosts || echo "khong co trong /etc/hosts"'
Rút ra: hai điểm cuối trả lời giống nhau không có nghĩa là bạn đang nói chuyện với đúng điểm cuối. Cho mỗi máy chủ trả về một chuỗi tự nhận dạng là mẹo rẻ nhất tôi biết để bắt lỗi loại này.
Viết sao cho chạy được ở cả hai nơi
Khai cờ trong Compose. Trên Linux nó tạo bản ghi cần thiết; trên Desktop nó thừa nhưng vô hại:
services:
app:
image: vi-du:1.0
extra_hosts:
- "host.docker.internal:host-gateway"
Và ba điều cần nhớ:
- Dịch vụ trên máy chủ phải nghe địa chỉ cầu Docker, không chỉ
127.0.0.1— nếu không thì chạy trên macOS mà hỏng trên Linux. - Đừng dùng cách này cho việc thật. Nếu hai thứ cần nói chuyện lâu dài, cho cả hai vào chung một mạng Docker và gọi theo tên dịch vụ (phần 34).
host.docker.internallà công cụ cho môi trường phát triển. - Nó là một lỗ thủng trong cách ly. Container gọi được vào máy chủ nghĩa là container gọi được vào mọi thứ máy chủ đang nghe.
Muốn tự thấy khác biệt nền tảng ngay, cho máy chủ nghe loopback rồi gọi từ container:
python3 -m http.server 18190 --bind 127.0.0.1 &
docker run --rm --add-host=host.docker.internal:host-gateway \
curlimages/curl:8.10.1 -s -o /dev/null -w '%{http_code}\n' \
--max-time 3 http://host.docker.internal:18190/
Ra 200 là bạn đang ở Docker Desktop; ra 000 là bạn ở Linux — và đó là hành vi đúng, đổi --bind 127.0.0.1 thành địa chỉ cầu Docker là chạy.
Mẫu số chung
Bài học đầu tiên, chính là cú "phân giải được, 200, mà sai máy": một cái tên chỉ có nghĩa tương đối với ai phân giải nó — cùng một tên có thể trỏ sang chỗ khác, và việc nó phân giải-được rồi trả-lời không hề chứng minh nó trỏ tới đúng cái bạn muốn. Container lồng thừa hưởng resolver của Docker Desktop và vui vẻ trả host.docker.internal bằng địa chỉ máy Mac; tên đúng, câu trả lời sai ngữ cảnh. Cùng cái bẫy "tên tương đối theo ngữ cảnh" ở khắp nơi: import tương đối phân giải khác nhau tuỳ thư mục làm việc, localhost nghĩa khác nhau ở mỗi tầng, split-horizon DNS, một binary trong $PATH bị cái khác che, một khoá cấu hình bị đè theo môi trường. Nguyên tắc: "nối được và có người trả lời" không phải bằng chứng "đúng đích" — hãy xác nhận danh tính, đừng chỉ xác nhận với tới được (cho mỗi đầu tự khai tên là mẹo rẻ nhất).
Điều thứ hai, đọc từ chuyện chạy trên Mac mà hỏng trên Linux: nền tảng dễ dãi che mất ràng buộc mà nền tảng nghiêm khắc bắt tuân — nên "chạy trên máy tôi" thường ship kèm một lỗi ngủ đông. Bind 127.0.0.1 với tới được nhờ lớp chuyển tiếp của Docker Desktop, nhưng mạng cầu Linux không với tới loopback; ai dev trên Mac rồi deploy Linux sẽ dính "connection refused" đúng chỗ này. Cùng khoảng cách "nền tảng lỏng giấu ràng buộc của nền tảng chặt" ở khắp nơi: dấu phân cách đường dẫn, hệ tệp phân biệt hoa thường, múi giờ, ký tự xuống dòng, thứ tự byte. Nguyên tắc: viết theo giao của các ràng buộc, tức theo nền tảng nghiêm khắc nhất — bind vào địa chỉ cầu chứ đừng dựa vào lớp chuyển tiếp của máy dev, và khai cờ portable (extra_hosts: host-gateway) để cùng một cấu hình chạy được ở cả hai nơi, vì cái môi trường dễ dãi luôn sẵn lòng để bạn quên một điều mà production sẽ nhắc.
Phần sau dựng nhiều mạng cùng lúc và đo bảng "cái gì gọi được cái gì".