Phần 34 đo container tìm nhau bằng tên qua DNS nội bộ — nhưng phép đo đó chỉ chạy trên một mạng mặc định, mọi container cùng thấy nhau. Câu hỏi bỏ ngỏ: nếu cố tình dựng nhiều mạng và rải service ra từng mạng, cái gì còn gọi được cái gì? Đây là cách thực dụng nhất để cách ly một service khỏi phần còn lại của hệ thống — không cần tường lửa, không cần VLAN, chỉ cần docker network create đúng chỗ. Bài này dựng ba container web/api/db trên hai mạng, đo bằng pingnc thật, rồi thử tiếp câu hỏi "container đứng trên cả hai mạng có biến thành router không".

Sơ đồ web trong net-frontend, db trong net-backend, api đứng trên cả hai mạng

Toàn bộ lệnh chạy trên Docker 29.7.2 (Docker Desktop, macOS), container nền alpine:3.20.

Dựng hai mạng, mỗi mạng một dải IP riêng

$ docker network create net-frontend
$ docker network create net-backend
$ docker network inspect net-frontend --format '{{.IPAM.Config}}'
[{172.19.0.0/16 ... 172.19.0.1 map[]}]
$ docker network inspect net-backend --format '{{.IPAM.Config}}'
[{172.20.0.0/16 ... 172.20.0.1 map[]}]

$ docker run -d --name db  --network net-backend  alpine:3.20 sleep 3600
$ docker run -d --name api --network net-backend  alpine:3.20 sleep 3600
$ docker run -d --name web --network net-frontend alpine:3.20 sleep 3600

Không khai --subnet, Docker tự cấp mỗi user-defined network một dải /16 riêng trong không gian 172.x.0.0, tránh trùng nhau. dbapi cùng nằm trong net-backend; web đứng một mình trong net-frontend.

Cùng mạng: DNS phân giải tên, khác mạng: timeout cả tên lẫn IP

$ docker exec api ping -c2 -W1 db
PING db (172.20.0.2): 56 data bytes
64 bytes from 172.20.0.2: seq=0 ttl=64 time=0.118 ms
64 bytes from 172.20.0.2: seq=1 ttl=64 time=0.226 ms
--- db ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss

apidb cùng net-backend nên DNS nội bộ (127.0.0.11) phân giải db ra IP, và gói tin đi tới nơi bình thường — đúng như đã đo ở phần 34. Đổi sang gọi từ web, khác mạng:

$ docker exec web ping -c2 -W1 db
ping: bad address 'db'

$ docker exec web ping -c2 -W1 172.20.0.2
PING 172.20.0.2 (172.20.0.2): 56 data bytes
--- 172.20.0.2 ping statistics ---
2 packets transmitted, 0 packets received, 100% packet loss

Đây là điểm dễ hiểu nhầm: nhiều người nghĩ "khác mạng chỉ là không tra được tên, còn biết IP thì vẫn gọi được". Đo thực tế cho thấy không phải vậy — web biết đúng IP của db (172.20.0.2, lấy trực tiếp từ docker inspect) nhưng vẫn mất 100% gói. Cách ly xảy ra ở tầng mạng, không phải tầng tên miền. Gói tin bị rớt im lặng (packet loss, không có Connection refused) — khớp với việc Docker chặn bằng luật DROP giữa các bridge khác nhau (chain DOCKER-ISOLATION-STAGE-1/2, tài liệu Docker mô tả cơ chế này từ bản 1.13), chứ không phải không có ai lắng nghe. Phần này tôi không tự vào được namespace thật của VM Docker Desktop để đọc trực tiếp bảng iptables — lý do giống hệt đã nói ở phần 35 — nhưng hành vi đo được (mất gói, không phải bị từ chối) khớp với mô tả đó.

Không chỉ ICMP — TCP thật cũng bị chặn y hệt

ping đo ICMP, có thể ai đó nghĩ HTTP hay Postgres (TCP) sẽ khác. Dựng một listener TCP thật bằng nc trên db, cổng 5432 — cùng cổng Postgres hay dùng — rồi bắn từ hai phía:

$ docker exec -d db nc -lk -p 5432

$ docker exec api nc -zv -w2 db 5432
db (172.20.0.2:5432) open

$ docker exec web nc -zv -w2 172.20.0.2 5432
nc: 172.20.0.2 (172.20.0.2:5432): Operation timed out

Kết quả nhất quán với ping: cùng mạng thì open ngay (apidb), khác mạng thì Operation timed out dù gõ đúng IP và đúng cổng đang có ai lắng nghe thật. Đây là bằng chứng thực dụng nhất cho việc dùng nhiều docker network để cách ly service: một service db đứng trong net-backend sẽ không bị bất kỳ container nào ngoài net-backend chạm tới, kể cả khi kẻ tấn công (giả định) đã biết chính xác IP nội bộ.

Container đứng trên hai mạng — có tự thành router không?

Tình huống thật: api cần nói chuyện với cả web (nhận request) lẫn db (đọc dữ liệu), nên phải đứng trên cả hai mạng. Gắn thêm mạng cho container đang chạy, không cần tạo lại:

$ docker network connect net-frontend api
$ docker inspect api --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}'
net-backend=172.20.0.3 net-frontend=172.19.0.3

api giờ có hai địa chỉ IP, một trên mỗi mạng. web gọi được api bằng tên vì giờ cả hai cùng chung net-frontend:

$ docker exec web ping -c2 -W1 api
PING api (172.19.0.3): 56 data bytes
64 bytes from 172.19.0.3: seq=0 ttl=64 time=0.093 ms
64 bytes from 172.19.0.3: seq=1 ttl=64 time=0.147 ms
--- api ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss

Nhưng thử tiếp câu hỏi thật sự đáng hỏi: web có "mượn" được api để chạm tới db không?

$ docker exec web nc -zv -w2 172.20.0.2 5432
nc: 172.20.0.2 (172.20.0.2:5432): Operation timed out

$ docker exec web ping -c1 -W1 db
ping: bad address 'db'

Vẫn timeout, vẫn không phân giải được tên. Đứng trên cả hai mạng không biến api thành router. Lý do đơn giản: routing giữa hai mạng đòi net.ipv4.ip_forward=1 bên trong container đó cộng thêm luật NAT/forward tường minh — không có gì trong số đó được Docker tự bật chỉ vì bạn nối thêm một network. api chỉ đơn thuần có hai chân, mỗi chân nói chuyện được với mạng của chính nó; nó không tự nguyện chuyển tiếp gói giữa hai chân. Muốn api thật sự đóng vai gateway thì phải tự cấu hình forwarding trong image đó — nằm ngoài phạm vi bài này, và thường cũng không phải điều bạn muốn: mục đích của việc tách mạng là để không ai đi tắt qua được.

Docker Compose khai báo y hệt, không cần gõ tay từng lệnh

services:
  web:
    image: alpine:3.20
    command: sleep 3600
    networks: [frontend]
  api:
    image: alpine:3.20
    command: sleep 3600
    networks: [frontend, backend]
  db:
    image: alpine:3.20
    command: sleep 3600
    networks: [backend]

networks:
  frontend:
  backend:
$ docker compose up -d
$ docker compose ps --format '{{.Name}}\t{{.Networks}}'
net-test-api-1   net-test_backend,net-test_frontend
net-test-db-1    net-test_backend
net-test-web-1   net-test_frontend

Compose tự đặt tên mạng theo dạng <tên-thư-mục>_<tên-khai-báo> và tự tạo, không cần docker network create tay. Đo lại đúng hành vi cũ trong project này:

$ docker compose exec web ping -c1 -W1 db
ping: bad address 'db'
$ docker compose exec api ping -c1 -W1 db
64 bytes from 172.23.0.2: seq=0 ttl=64 time=0.068 ms

Không có gì khác so với dựng bằng tay — networks: trong Compose chỉ là cách khai báo gọn hơn cho đúng cơ chế docker network create + --network đã đo ở trên. Với một docker-compose.yml thật (web/api/db + Postgres), cách áp dụng thực dụng: đặt db trong một network riêng, chỉ liệt kê nó ở networks: của service cần đọc dữ liệu (thường chỉ api), không liệt kê ở service nhận request từ Internet (web). Compose lo phần còn lại.

--internal: cắt hẳn đường ra ngoài, không phải bằng tường lửa mà bằng thiếu route

Mạng thường (net-backend ở trên) vẫn cho container gọi ra Internet — Docker tự thêm MASQUERADE để NAT traffic đi ra ngoài:

$ docker exec db wget -q -T3 -O- http://example.com
$ echo $?
0

Cờ --internal cắt hẳn khả năng đó, nhưng cơ chế không phải "chặn bằng iptables" như phần trước — mà đơn giản là không gắn default route:

$ docker network create --internal net-isolated
$ docker run -d --name isolated-box --network net-isolated alpine:3.20 sleep 3600

$ docker exec isolated-box ip route
172.21.0.0/16 dev eth0 scope link  src 172.21.0.2

$ docker exec isolated-box wget -q -T3 -O- http://example.com
$ echo $?
1

So với container bình thường (luôn có thêm một dòng default via ... dev eth0), isolated-box chỉ có đúng một route — tới chính subnet của nó. Không có default gateway thì gói gửi ra ngoài 172.21.0.0/16 không biết đi đâu, hỏng ngay ở tầng routing của chính container, trước khi kịp chạm tới bất kỳ luật tường lửa nào. Khác về bản chất so với cách ly giữa net-frontendnet-backend ở trên (đó là bị DROP giữa hai bridge, container vẫn có default route bình thường) — nhưng hiệu quả quan sát được thì giống nhau: gói không tới nơi.

Tổng kết

Tình huống Ping bằng tên Ping/nc bằng IP Vì sao
Cùng network OK OK DNS nội bộ + cùng bridge
Khác network, không dual-home lỗi phân giải tên timeout, mất gói cách ly giữa hai bridge
api đứng trên cả hai mạng, gọi api OK (từ mạng nó có mặt) OK api thật sự có IP trên mạng đó
Gọi db qua api từ mạng khác lỗi phân giải tên vẫn timeout api không forward, không NAT
Network --internal, gọi ra ngoài timeout ngay không có default route, không phải bị chặn

Quy tắc thực dụng: muốn cách ly một service (database, cổng quản trị nội bộ), đặt nó vào một network riêng và chỉ liệt kê service cần nói chuyện trực tiếp với nó trong cùng network đó. Đừng tin việc "chỉ có api biết địa chỉ db" là đủ an toàn — nếu apidb khác mạng, kẻ tấn công biết đúng IP cũng không chạm tới được, và đó mới là điều đáng dựa vào, không phải việc giấu địa chỉ.

Thử ba mươi giây

Dựng hai container hai mạng khác nhau, đo bằng tay xem cách ly có thật không:

docker network create tmp-a && docker network create tmp-b
docker run -d --rm --name a --network tmp-a alpine:3.20 sleep 60
docker run -d --rm --name b --network tmp-b alpine:3.20 sleep 60
IP_B=$(docker inspect b --format '{{(index .NetworkSettings.Networks "tmp-b").IPAddress}}')
docker exec a ping -c2 -W1 $IP_B   # 100% packet loss dù biết đúng IP
docker rm -f a b && docker network rm tmp-a tmp-b

Bài viết liên quan