Hình dung các khu trong một sân bay. Sảnh chờ ai cũng vào được; qua cửa an ninh cần thẻ lên máy bay; ra tới sân đỗ máy bay thì chỉ nhân viên có thẻ riêng mới bước qua. Một nhân viên bốc dỡ đeo cả hai thẻ có thể đi lại giữa sảnh và sân đỗ — nhưng chuyện anh ta qua lại được không biến anh ta thành một cánh cửa để hành khách bám theo mà lọt ra sân đỗ. Đó chính xác là cách mạng của Docker phân tầng, và cái bẫy nằm đúng ở chỗ "người có hai thẻ không phải là lối đi".
Kiến trúc ba tầng cổ điển: web ra Internet, api ở giữa, CSDL và cache giấu kín. Docker làm được bằng mạng, nhưng ranh giới nằm ở đâu thì phải đo mới biết.
Tôi dựng đúng hình đó:
mang-ngoai mang-trong (--internal)
┌──────────┐ ┌──────────────────────┐
│ i-web │ │ i-db i-cache │
│ i-api ───┼──────────┼──▶ │
└──────────┘ └──────────────────────┘
i-api nằm trên cả hai mạng, nên nó có hai địa chỉ IP:
i-web mang-ngoai(192.168.0.2)
i-api mang-ngoai(192.168.0.3) mang-trong(192.168.16.2)
i-db mang-trong(192.168.16.3)
i-cache mang-trong(192.168.16.4)
Bảng đủ 12 chiều gọi
| Từ | Tới | Phân giải tên | Kết nối |
|---|---|---|---|
| i-web | i-api | 192.168.0.3 | ✅ |
| i-web | i-db | không | ❌ |
| i-web | i-cache | không | ❌ |
| i-api | i-web | 192.168.0.2 | ✅ |
| i-api | i-db | 192.168.16.3 | ✅ |
| i-api | i-cache | 192.168.16.4 | ✅ |
| i-db | i-web | không | ❌ |
| i-db | i-api | 192.168.16.2 | ✅ |
| i-db | i-cache | 192.168.16.4 | ✅ |
| i-cache | i-web | không | ❌ |
| i-cache | i-api | 192.168.16.2 | ✅ |
| i-cache | i-db | 192.168.16.3 | ✅ |
Hai điều rút ra ngay:
Cách ly là theo mạng, không theo dịch vụ. i-db và i-cache gọi nhau thoải mái vì chung mạng. Nếu bạn muốn CSDL không nói được với cache, cần thêm một mạng nữa — Docker không có khái niệm "chỉ cho phép cặp này".
Tên không phân giải được là hàng rào đầu tiên. i-web hỏi i-db thì DNS nhúng trả về rỗng, chứ không phải trả về IP rồi chặn gói tin. Ứng dụng nhận lỗi phân giải tên ngay lập tức thay vì timeout — dễ chẩn đoán hơn nhiều.
Cùng một tên, IP khác nhau tuỳ ai hỏi
i-web (mang-ngoai) hoi 'i-api' -> 192.168.0.3
i-db (mang-trong) hoi 'i-api' -> 192.168.16.2
DNS nhúng trả lời theo mạng của người hỏi. Đây là lý do đừng bao giờ ghi cứng IP container vào cấu hình: cùng một cái tên đúng cho cả hai bên, còn địa chỉ thì không.
Container hai mạng KHÔNG làm cầu nối
Câu hỏi tự nhiên: i-api nối hai mạng, vậy i-web có đi nhờ qua nó để tới i-db không?
i-web ping thang 192.168.16.3 -> KHONG toi duoc
bang dinh tuyen cua i-web:
default via 192.168.0.1 dev eth0
192.168.0.0/20 dev eth0 scope link src 192.168.0.2
Không có tuyến nào tới 192.168.16.0/20. Gói tin đi ra gateway mặc định rồi chết ở đó. Việc i-api có mặt ở cả hai mạng chỉ giúp chính nó, không tạo đường cho ai khác.
Muốn i-web chạm tới i-db thì i-api phải làm proxy ở tầng ứng dụng — tức là đúng cái bạn muốn: mọi truy cập CSDL đều đi qua tầng api.
--internal: cắt hẳn đường ra ngoài
docker network create --internal mang-trong
| Container | Mạng | curl https://example.com |
|---|---|---|
| i-web | thường | 200 |
| i-db | --internal |
000 |
| i-api | cả hai | 200 |
Container trên mạng internal không ra được Internet. Và nó thất bại sớm:
phan giai example.com -> KHONG
Tên bên ngoài cũng không phân giải được, vì DNS nhúng không chuyển tiếp ra ngoài trên mạng internal. Lại là kiểu hỏng dễ chẩn đoán.
Chú ý dòng i-api: container nào có một chân trên mạng thường thì vẫn ra Internet bình thường. --internal không phải thuộc tính của container, nó là thuộc tính của mạng.
Đây là hàng rào đáng giá cho CSDL: kể cả khi ai đó chiếm được container CSDL, họ không tải được công cụ về và không gửi dữ liệu đi đâu.
Cái bẫy: tắt giao tiếp nội mạng thì tên vẫn phân giải
Docker cho phép cấm các container trên cùng một mạng nói chuyện với nhau:
docker network create --opt com.docker.network.bridge.enable_icc=false mang-kin
Đo:
| Kết quả | |
|---|---|
k-1 phân giải tên k-2 |
192.168.32.3 — thành công |
k-1 ping k-2 |
bị chặn |
(đối chứng) i-db ping i-cache trên mạng thường |
tới được |
Khác hẳn trường hợp khác mạng: ở đây DNS vẫn trả về địa chỉ, chỉ có gói tin bị iptables chặn.
Với ứng dụng, đó là kiểu hỏng khó chịu nhất: phân giải tên thành công nên nó tưởng mọi thứ ổn, rồi kết nối treo tới lúc hết giờ. Nếu bạn bật enable_icc=false, hãy chuẩn bị tinh thần đọc log kiểu "connect timeout" thay vì "unknown host".
Đổi mạng khi container đang chạy
Không cần khởi động lại:
docker network disconnect mang-trong i-api
-> i-api goi i-db: KHONG toi duoc
docker network connect mang-trong i-api
-> i-api goi i-db: toi duoc
Rất tiện khi gỡ lỗi: nối tạm một container công cụ vào mạng nội bộ, làm xong thì gỡ ra.
Viết trong Compose
services:
web:
image: nginx:1.27-alpine
networks: [ngoai]
ports: ["127.0.0.1:8080:80"]
api:
image: vi-du:1.0
networks: [ngoai, trong]
db:
image: postgres:16-alpine
networks: [trong]
networks:
ngoai:
trong:
internal: true
Ba điều đáng nhớ khi thiết kế:
- Mặc định của Compose là một mạng cho cả project, nghĩa là mọi service nói chuyện được với nhau. Muốn cách ly thì phải khai tường minh như trên.
- Đừng publish cổng cho tầng trong. CSDL không cần
ports:gì cả — api gọi nó theo tên. Publish ra là mở đường vòng qua toàn bộ thiết kế này (phần 35). - Gọi theo tên dịch vụ, đừng ghi IP (phần 34).
Tự vẽ bảng cách ly của bạn
Vẽ bảng cách ly cho chính hệ thống của bạn:
for a in $(docker ps --format '{{.Names}}'); do
for b in $(docker ps --format '{{.Names}}'); do
[ "$a" = "$b" ] && continue
docker exec "$a" getent hosts "$b" >/dev/null 2>&1 && echo "$a -> $b"
done
done
Mỗi dòng in ra là một chiều gọi được. Dòng nào bạn không cố ý tạo ra thì đó là một cách ly bị thiếu — thường là do mọi thứ đang nằm chung một mạng mặc định của Compose.
Mẫu số chung
Bài học đầu tiên, chính là người bốc dỡ đeo hai thẻ: nối được-với-A và nối được-với-B không hề biến bạn thành đường đi từ A sang B — khả năng thông suốt là tính chất của ranh giới, không phải của cá nhân bắc ngang nó. i-api có mặt trên cả hai mạng chỉ giúp chính nó; gói tin của i-web vẫn chết ở gateway vì bảng định tuyến không có lối sang mạng trong. Cùng nguyên lý "không tự động bắc cầu" ở khắp nơi: một tiến trình mở được hai CSDL không cho phép CSDL này đọc CSDL kia; một người ở trong hai nhóm quyền không làm hai nhóm hoà vào nhau; một module import hai gói không nối hai gói đó lại. Nguyên tắc: muốn vượt một ranh giới thì phải là một hành động tường minh, đặt đúng tầng — ở đây là i-api proxy tại tầng ứng dụng — chứ việc cùng có mặt ở hai phía không bao giờ tự nó tạo ra đường thông, và đó chính là điều khiến kiến trúc ba tầng an toàn.
Điều thứ hai, đọc từ hai kiểu hỏng "tên không phân giải" so với "phân giải xong rồi treo": chỗ bạn cắt một kết nối quyết định lỗi trông ra sao — chặn ở tầng phân giải tên thì báo sai ngay tức khắc, rõ ràng; chặn ở tầng gói tin thì phân giải vẫn thành công rồi kết nối treo tới lúc hết giờ. Khác mạng cho lỗi "unknown host" tức thì; enable_icc=false cho "connect timeout" âm thầm dù tên vẫn ra IP. Cùng cặp "hỏng to-sớm" so với "hỏng im-muộn" ở khắp nơi: DNS NXDOMAIN so với timeout kết nối, một cổng trả RST (bật ngay) so với tường lửa lặng lẽ thả gói (treo), một 404 so với một request treo, một ngoại lệ ném ngay so với một khoá chết. Nguyên tắc: thiết kế ranh giới để hỏng to và sớm — cái bị chặn mà vẫn phân giải-được, vẫn trông-như-ổn là kiểu hỏng tàn nhẫn nhất; hãy ưu tiên tầng nói "không" ngay lập tức hơn tầng bắt bạn ngồi chờ.
Phần sau chuyển sang lưu trữ: volume, bind mount và tmpfs khác nhau thế nào, đo tốc độ ghi từng loại.