Docker Networking
Xem thửTình huống: ứng dụng gọi database bằng localhost thì bị từ chối, bằng IP thì chạy được hôm nay và hỏng hôm sau
Minh chuyển gdev-api vào container và để chuỗi kết nối database là localhost:5432, đúng như trên laptop. Kết quả là Connection refused, dù container database đang chạy khỏe. Minh tra IP của container database, thấy 172.17.0.3, dán vào cấu hình, và mọi thứ chạy tốt. Sáng hôm sau đội khởi động lại các container theo thứ tự khác, database nhận IP 172.17.0.4, và gdev-api lại báo lỗi.
Hai lỗi này cùng cho thấy Minh chưa có mô hình đúng về mạng trong Docker: mỗi container là một máy nhỏ có mạng riêng, và các máy nhỏ đó tìm thấy nhau theo quy tắc riêng. Bài này dựng mô hình đó, rồi bạn tự tái hiện và sửa cả hai lỗi trên máy mình.
Sau bài này bạn làm được các việc sau:
- Giải thích vì sao
localhostkhông dùng được giữa hai container, và dùng gì thay. - Phân biệt network
bridgemặc định với user-defined bridge, và chỉ ra chỗ khác nhau bằng lệnh thật. - Tách các container vào những network riêng để một dịch vụ chỉ nhìn thấy đúng những gì nó cần.
- Chẩn đoán lỗi "container A không gọi được container B" theo trình tự cố định.
Bạn cần có sẵn: Docker, image gdev/api:1.0 từ bài đầu chương, và đã đọc bài Docker Compose.
Ba mô hình cần nhớ
- Mỗi container có mạng riêng của nó: có địa chỉ IP, bảng cổng và địa chỉ
localhostriêng.localhosttrong container A luôn chỉ về chính A. - User-defined bridge là mạng nội bộ có DNS: các container trên cùng network gọi nhau bằng tên. Network
bridgemặc định không có tính năng này, chỉ gọi nhau được bằng IP, và IP có thể đổi. - Network là ranh giới cách ly: container chỉ thấy các container cùng network với nó. Muốn một dịch vụ không bị nhìn thấy, đừng cho nó cùng network với kẻ không cần gọi nó.
Chuẩn bị môi trường lab
Bài này dùng ba thứ sẵn có: image gdev/api:1.0 (lắng nghe cổng 8080 và có /healthz), postgres:16-alpine làm database, và alpine:3.20 làm container "máy khách" để chạy wget và getent. Nếu stack Compose cũ còn chạy, hãy docker compose down để tránh nhầm lẫn.
Docker Engine trên Linux có các driver (kiểu network) tích hợp sau, theo tài liệu chính thức:
| Driver | Hành vi | Dùng khi |
|---|---|---|
bridge | Mỗi network là một mạng ảo trên máy, container nối vào nó và ra ngoài qua máy chủ | Mặc định, mọi lab trong bài này |
host | Container dùng thẳng mạng của máy, không có mạng riêng | Cần hiệu năng mạng tối đa, chấp nhận mất cách ly |
none | Chỉ có lo, không có mạng ngoài | Công việc không cần mạng |
overlay, macvlan, ipvlan | Mạng nhiều máy hoặc gắn thẳng vào mạng vật lý | Nằm ngoài phạm vi bài này |
Khi bạn chạy docker run mà không có --network, container được nối vào network bridge mặc định. Phần lớn bài này là so sánh network mặc định đó với network do bạn tự tạo.
Network mặc định: gọi bằng IP được, bằng tên thì không
Dự đoán trước khi chạy: bạn khởi động gdev-api không chỉ định network, rồi từ một container khác gọi tới nó bằng tên gdev-a. Có phân giải được không? Và bằng IP thì sao?
docker run -d --name gdev-a gdev/api:1.0
docker run --rm alpine:3.20 wget -qO- -T 3 http://gdev-a:8080/healthz
docker inspect gdev-a --format '{{.NetworkSettings.IPAddress}}'
docker run --rm alpine:3.20 wget -qO- -T 3 http://172.17.0.2:8080/healthz
Kết quả minh họa, địa chỉ IP của bạn có thể khác (thay 172.17.0.2 bằng giá trị lệnh inspect in ra):
wget: bad address 'gdev-a:8080'
172.17.0.2
{"status":"ok"}
Đọc kết quả: gọi bằng tên thất bại với bad address, nghĩa là không có ai phân giải được tên gdev-a. Gọi bằng IP thành công. Theo tài liệu chính thức, trên network bridge mặc định các container chỉ gọi nhau được bằng địa chỉ IP, và tài liệu coi đây là chi tiết kế thừa, không khuyến nghị dùng cho môi trường chạy thật.
Vấn đề thứ hai nằm ở chính IP. Địa chỉ được cấp theo thứ tự khởi động, và có thể đổi khi container được tạo hay khởi động lại. Thử:
docker stop gdev-a
docker run -d --name gdev-b gdev/api:1.0
docker start gdev-a
docker inspect gdev-a --format '{{.NetworkSettings.IPAddress}}'
Kết quả minh họa:
172.17.0.3
gdev-a từng là 172.17.0.2, nay nhận 172.17.0.3 vì gdev-b đã lấy địa chỉ cũ trong lúc nó dừng. Cấu hình cứng IP, như Minh đã làm, sẽ trỏ nhầm hoặc trỏ vào khoảng không. Dọn dẹp trước khi sang phần sau:
docker rm -f gdev-a gdev-b
User-defined bridge: có DNS, gọi bằng tên
Tạo network riêng và chạy lại thí nghiệm:
docker network create gdev-net
docker run -d --name gdev-api --network gdev-net gdev/api:1.0
docker run --rm --network gdev-net alpine:3.20 wget -qO- -T 3 http://gdev-api:8080/healthz
docker run --rm --network gdev-net alpine:3.20 getent hosts gdev-api
docker run --rm --network gdev-net alpine:3.20 cat /etc/resolv.conf
Kết quả minh họa:
{"status":"ok"}
172.26.0.2 gdev-api gdev-api
nameserver 127.0.0.11
options edns0 trust-ad ndots:0
Phân tích từng dòng:
{"status":"ok"}: container máy khách gọigdev-apibằng tên và nhận được phản hồi.getent hosts gdev-apihỏi hệ thống phân giải tên của container và nhận về IP172.26.0.2. Lệnh này là cách kiểm tra "tên có phân giải được không" mà không cần ứng dụng cócurl.nameserver 127.0.0.11là DNS nhúng (embedded DNS) của Docker. Trên network do bạn tạo, mọi container hỏi DNS ở địa chỉ này, và Docker trả lời bằng tên container, alias hoặc tên service hiện tại. Với tên không thuộc network, nó chuyển câu hỏi cho DNS của máy chủ, nên container vẫn tra được tên miền ngoài Internet.
Xem thông tin network:
docker network inspect gdev-net --format '{{(index .IPAM.Config 0).Subnet}} {{(index .IPAM.Config 0).Gateway}}'
docker network inspect gdev-net --format '{{json .Containers}}'
Kết quả minh họa: 172.26.0.0/16 172.26.0.1 rồi một danh sách có gdev-api kèm IP 172.26.0.2/16. Subnet là dải địa chỉ của mạng ảo này và Gateway là địa chỉ của máy chủ trên mạng đó. Tên service trong Compose hoạt động đúng như vậy: Compose tạo một user-defined network cho stack và tên service là tên DNS, nên db trong bài Docker Compose phân giải được.
Có thể đặt thêm tên phụ cho container bằng --network-alias, ví dụ --network-alias backend. Khi đó cả gdev-api lẫn backend đều phân giải được trong network đó, tiện cho việc đổi container phía sau mà không đổi cấu hình của người gọi.
Hãy xem tên được phân giải thế nào, và điều gì xảy ra khi container được tạo lại. Trước khi bấm, dự đoán: container gdev-api bị tạo lại và nhận IP mới. Người gọi bằng tên và người gọi bằng IP, ai còn gọi đúng?
Đang tải Animation…
Tại sao localhost thất bại giữa hai container
Dự đoán: container gdev-api đang chạy trên gdev-net. Từ một container khác trên cùng network, bạn gọi http://localhost:8080/healthz. Kết quả là gì?
docker run --rm --network gdev-net alpine:3.20 wget -qO- -T 3 http://localhost:8080/healthz
docker run --rm --network gdev-net alpine:3.20 wget -qO- -T 3 http://gdev-api:8080/healthz
Kết quả:
wget: can't connect to remote host (127.0.0.1): Connection refused
{"status":"ok"}
Triệu chứng: gọi localhost thì bị từ chối, gọi theo tên thì được, dù cùng một cổng. Nguyên nhân: mô hình 1. localhost phân giải thành 127.0.0.1, địa chỉ loopback của chính container máy khách, nơi không có gì nghe cổng 8080. Cách kiểm chứng: chạy docker run --rm --network gdev-net alpine:3.20 netstat -ln và thấy không có cổng nào đang nghe. Cách sửa: dùng tên của container đích, hoặc tên service trong Compose. Lỗi này là lỗi số một khi chuyển ứng dụng từ máy lên container: mọi chuỗi kết nối chứa localhost hay 127.0.0.1 tới dịch vụ khác đều phải đổi thành tên.
Có một ngoại lệ dễ nhầm. Khi ứng dụng trong container cần gọi một dịch vụ chạy trên máy chủ, localhost cũng không đúng vì nó là container. Trên Docker Desktop có tên đặc biệt host.docker.internal cho việc này, còn trên Linux Engine bạn cần cấu hình thêm. Cách thiết kế sạch hơn là đưa dịch vụ đó vào cùng network dưới dạng một container.
Tách network: backend không bị nhìn thấy
Mô hình 3 vào việc thật như sau. Dựng một network gdev-front cho đường vào và một network gdev-back cho database. gdev-api nối vào cả hai, database chỉ nối vào gdev-back:
docker network create gdev-front
docker network create gdev-back
docker run -d --name gdev-db --network gdev-back -e POSTGRES_PASSWORD=gdev-dev-only postgres:16-alpine
docker run -d --name gdev-api2 --network gdev-back gdev/api:1.0
docker network connect gdev-front gdev-api2
Lệnh docker network connect nối thêm một container đang chạy vào network thứ hai, không cần khởi động lại (đây là việc bridge mặc định không làm được). Kiểm tra từ phía gdev-front:
docker run --rm --network gdev-front alpine:3.20 wget -qO- -T 3 http://gdev-api2:8080/healthz
docker run --rm --network gdev-front alpine:3.20 nc -zv -w 2 gdev-db 5432
docker exec gdev-api2 python -c "import socket; socket.create_connection(('gdev-db', 5432), 2); print('api reaches db')"
Kết quả minh họa:
{"status":"ok"}
nc: bad address 'gdev-db'
api reaches db
Đọc kết quả: máy khách ở gdev-front gọi được gdev-api2 (cùng network) nhưng không thấy gdev-db: tên không được phân giải vì database không thuộc network đó. Chỉ gdev-api2, đứng ở cả hai network, chạm được database. Đây là cấu trúc "đường vào, ứng dụng, dữ liệu" thu nhỏ: nếu ai đó làm chủ được một container ở phía gdev-front, họ cũng chưa thể quét thẳng database.
Trong Compose cách làm tương tự là khai báo hai network ở mức cao nhất và liệt kê networks: cho từng service. Phần ports: ở bài sau mới quyết định điều gì thấy được từ bên ngoài máy chủ.
Kiểu none cho thấy cực đoan bên kia. Container docker run --rm --network none alpine:3.20 ip -o addr chỉ có một giao diện lo 127.0.0.1/8, không có đường ra nào.
Dọn dẹp:
docker rm -f gdev-api gdev-api2 gdev-db
docker network rm gdev-net gdev-front gdev-back
Chẩn đoán "container A không gọi được container B"
Khi gặp lỗi thật, hãy đi theo một trình tự cố định thay vì đoán. Trước khi xem sơ đồ: bạn thấy Connection refused khi gọi http://gdev-api:8080 từ một container khác. Bước kiểm tra đầu tiên của bạn là gì?
Đang tải sơ đồ…
Cách đọc sơ đồ:
- Bước đầu tiên là cùng network chưa:
docker inspect B --format '{{json .NetworkSettings.Networks}}'cho biết B thuộc những network nào. Hai container ở hai network khác nhau không thấy nhau, đúng như lab tách network. - Bước thứ hai là tên có phân giải không.
getent hosts Bchạy trong container A tách lỗi DNS khỏi lỗi kết nối.bad addressnghĩa là DNS, cònConnection refusednghĩa là DNS đã qua và vấn đề nằm ở phía cổng. - Bước thứ ba là B có nghe đúng nơi không. Gọi giữa các container dùng cổng bên trong container (8080 với
gdev-api), không dùng cổng đã công bố ra máy chủ. Và ứng dụng phải nghe trên0.0.0.0: nếu nó chỉ nghe127.0.0.1thì chỉ chính nó gọi được. Bài sau xem kỹ lỗi này. - Câu trả lời cho câu hỏi ở đầu:
Connection refusedđã cho biết tên phân giải được, vì vậy bạn bỏ qua hai nhánh đầu và kiểm tra cổng, địa chỉ nghe và log của B.
Lỗi thường gặp
| Triệu chứng | Nguyên nhân hay gặp | Cách kiểm chứng |
|---|---|---|
bad address khi gọi bằng tên | Container ở bridge mặc định, hoặc khác network | docker inspect mục Networks, getent hosts |
Connection refused tới localhost | localhost là chính container máy khách | Đổi sang tên service hoặc container đích |
| Chạy hôm trước, hôm sau lỗi, cấu hình chứa IP | IP đổi khi container được tạo lại | Dùng tên thay vì IP |
| Gọi tới cổng đã công bố của container khác và lỗi | Giữa các container dùng cổng bên trong, không dùng cổng máy chủ | Gọi tên:cổng_trong_container |
| Tạo network trùng tên | Docker báo network đã tồn tại | docker network ls rồi dùng lại hoặc xóa |
| Không xóa được network | Còn container đang nối | docker network inspect, mục Containers |
Dùng --network host rồi hai dịch vụ tranh cùng cổng | Container dùng thẳng mạng máy chủ nên không còn cách ly cổng | Quay lại bridge và công bố cổng bằng -p |
Tự kiểm tra
Hãy trả lời trước khi đọc đáp án ngay bên dưới mỗi câu.
Câu 1. docker run -d --name db postgres và docker run -d --name api gdev/api:1.0, cả hai không chỉ định network. Từ api, getent hosts db cho kết quả gì, và vì sao?
Đáp án: Không phân giải được. Cả hai ở network bridge mặc định, nơi không có DNS theo tên container, chỉ gọi được bằng IP. Tạo user-defined network và nối cả hai vào sẽ có DNS.
Câu 2. Database nằm ở gdev-back, gdev-api ở cả gdev-front và gdev-back, máy khách ở gdev-front. Máy khách gọi được những gì?
Đáp án: Gọi được gdev-api vì cùng network gdev-front. Không thấy gdev-db vì database không ở network đó, và tên cũng không phân giải được từ máy khách.
Câu 3. Container A gọi B bằng http://B:8080 bị Connection refused, còn getent hosts B trả về IP. Hai nghi ngờ hợp lý nhất là gì?
Đáp án: Ứng dụng trong B không nghe cổng 8080, hoặc nghe nhưng chỉ trên 127.0.0.1 thay vì 0.0.0.0. Tên đã phân giải nên network và DNS không phải vấn đề.
Nếu chỉ nhớ 3 điều
localhosttrong container là chính container đó. Gọi dịch vụ khác bằng tên, không bằnglocalhostvà không bằng IP.- Tự tạo user-defined network (hay để Compose tạo): ở đó có DNS nhúng tại
127.0.0.11. Bridge mặc định không có DNS theo tên. - Network là ranh giới cách ly. Đưa database vào network riêng và chỉ nối những container thật sự cần gọi nó.
Bài tiếp theo là Quản lý port Docker: công bố cổng ra máy chủ thế nào cho an toàn, và vì sao tường lửa của máy đôi khi không chặn được cổng Docker.
Tài liệu chính thức: Networking overview và Bridge network driver.