Phần 34 nói container tìm nhau bằng tên qua DNS nội bộ — chuyện bên trong mạng Docker. Phần này quay ra hướng ngược lại: cổng nào lộ ra ngoài container, và quan trọng hơn, lộ tới đâu. Phần đầu sê-ri đã đo EXPOSE không mở cổng nào — nó chỉ là metadata. Bài này đo tiếp phần còn lại: -p/--publish thật sự ràng buộc vào địa chỉ nào, và vì sao "cổng mở ra 0.0.0.0" nghe đáng sợ nhưng ý nghĩa thật của nó phụ thuộc vào bạn đang chạy Docker ở đâu.
Toàn bộ lệnh dưới đây chạy thật bằng docker run trên alpine:3.20, dùng nc làm server tối giản để không phụ thuộc image nào khác.
-p có ba dạng, và không ghi IP nghĩa là 0.0.0.0
$ docker run -d --name test-default -p 18080:80 alpine:3.20 sh -c '...'
$ docker run -d --name test-loopback -p 127.0.0.1:18081:80 alpine:3.20 sh -c '...'
$ docker run -d --name test-randport -p 127.0.0.1::80 alpine:3.20 sh -c '...'
$ docker port test-default
80/tcp -> 0.0.0.0:18080
80/tcp -> [::]:18080
$ docker port test-loopback
80/tcp -> 127.0.0.1:18081
$ docker port test-randport
80/tcp -> 127.0.0.1:60263
docker inspect xác nhận đúng con số Docker lưu lại, không phải suy diễn từ cách hiển thị:
$ docker inspect test-default --format '{{json .NetworkSettings.Ports}}'
{"80/tcp":[{"HostIp":"0.0.0.0","HostPort":"18080"},{"HostIp":"::","HostPort":"18080"}]}
$ docker inspect test-loopback --format '{{json .NetworkSettings.Ports}}'
{"80/tcp":[{"HostIp":"127.0.0.1","HostPort":"18081"}]}
Ba điều rút ra trực tiếp từ dữ liệu trên:
- Không ghi IP thì Docker tự điền
0.0.0.0— và cả[::]cho IPv6. Đây không phải giá trị "để trống", nó là một địa chỉ ràng buộc thật, nằm ngay trong JSON của container. IP::PORT(hai dấu hai chấm, bỏ trống cổng host) cho Docker tự chọn cổng còn trống, vẫn giữ nguyên IP bạn chỉ định — tiện khi chạy nhiều bản của cùng image mà không cần nhớ cổng.- Muốn biết chắc một container đang mở ra đâu, đọc
docker porthoặcdocker inspect, đừng đoán từ Dockerfile —EXPOSEkhông xuất hiện trong dữ liệu này vì nó không tạo ràng buộc nào.
Ràng buộc 0.0.0.0 không "đứng cạnh" ràng buộc 127.0.0.1 được — nó trùm lên trên
Giả định tự nhiên: hai container khác nhau, một cái xin 127.0.0.1:18081, cái kia xin 0.0.0.0:18081 — hai địa chỉ khác nhau, tưởng xin được cả hai. Đo thử:
$ docker run -d --rm --name test-diffip -p 0.0.0.0:18081:80 alpine:3.20 sh -c '...'
docker: Error response from daemon: failed to set up container networking: ...
Bind for 127.0.0.1:18081 failed: port is already allocated
Bị từ chối, dù cổng 18081 phía 127.0.0.1 đã có container khác chiếm. Đây đúng là hành vi socket của Linux chứ không phải Docker tự đặt luật: 0.0.0.0 không phải "một địa chỉ trong số nhiều địa chỉ", nó là mọi địa chỉ, nên bind 0.0.0.0:18081 xung đột thẳng với bất kỳ ai đã giữ 18081 trên bất kỳ địa chỉ cụ thể nào, kể cả loopback. Ngược lại thử 0.0.0.0:18080 lần hai (cổng đã bị test-default chiếm đúng bằng 0.0.0.0) thì lỗi y hệt — port is already allocated. Kết luận thực dụng: đừng nghĩ "ghi rõ IP là cô lập được cổng khỏi container khác trên cùng cổng đó" — nó chỉ cô lập được nguồn kết nối tới, không cô lập được quyền giữ cổng.
Trên máy tôi đang viết bài này, 0.0.0.0 không chạm được từ mạng LAN
Đây là phần đáng nói nhất, vì nó đi ngược lời cảnh báo phổ biến "đặt -p không ghi IP là mở cổng ra cả Internet". Máy tôi dùng để đo là Docker Desktop trên macOS — docker info xác nhận dockerd chạy trong một VM Linux riêng (linuxkit), không chạy thẳng trên hệ điều hành đang gõ lệnh:
$ docker info --format 'OSType={{.OSType}} OperatingSystem={{.OperatingSystem}}'
OSType=linux OperatingSystem=Docker Desktop
Dựng container với -p 0.0.0.0:18082:80, rồi thử chạm nó từ chính máy này bằng hai địa chỉ khác nhau: 127.0.0.1 và IP LAN thật của máy (192.168.1.3, cùng mạng, curl cùng máy nhưng đi qua card mạng thay vì loopback):
$ curl -sv --max-time 3 http://127.0.0.1:18082/
< HTTP/1.1 200 OK
any
$ curl -sv --max-time 3 http://192.168.1.3:18082/
* connect to 192.168.1.3 port 18082 failed: Connection refused
Connection refused, không phải timeout — nghĩa là gói tin có đến, nhưng không có gì đang nghe ở phía đó. 0.0.0.0 trong container không dịch thành "mở trên card mạng của macOS": Docker Desktop chỉ chuyển tiếp cổng đã publish vào loopback của máy host, bất kể bạn ràng buộc 0.0.0.0 hay 127.0.0.1 bên trong lệnh docker run. Tôi đã thử vào thẳng namespace của VM bằng nsenter để tìm đúng tiến trình làm việc chuyển tiếp đó, nhưng namespace tôi chạm được lại là một lớp sandbox khác, không phải VM gốc của Docker Desktop — nên tôi không khẳng định chắc được cơ chế nội bộ chính xác, chỉ khẳng định chắc kết quả đo được ở trên: hai lần curl, hai kết quả khác nhau, lặp lại nhiều lần cho kết quả giống nhau.
Ý nghĩa thực dụng: nếu bạn test "container của tôi có an toàn không" bằng cách tự curl vào IP LAN của máy dev, Docker Desktop cho bạn một câu trả lời không áp dụng được cho production. Máy dev nói "an toàn", máy chủ thật lại là chuyện khác hẳn — mục sau nói vì sao.
Trên Linux server thật, -p không có lớp sandbox nào che nữa
Từ đây tôi không đo được thêm trên máy đang dùng (không có Linux server trần để thử), nên ghi lại như kiến thức đã kiểm chứng rộng rãi trong tài liệu Docker và các báo cáo lỗi moby/moby#4737 — không phải phép đo tự chạy trong bài này, và bạn nên tự kiểm trên đúng máy chủ của mình trước khi tin.
Trên Linux, dockerd không có VM trung gian nào để giấu cổng đằng sau loopback. -p 8080:80 không ghi IP nghĩa là container publish thẳng ra mọi network interface của host, kể cả IP public nếu máy có IP public. Phần khiến nhiều người bị bất ngờ hơn: dockerd tự thêm luật DNAT vào bảng nat của iptables (chain PREROUTING, nhảy sang chain DOCKER), còn hầu hết hướng dẫn "chặn cổng" — ufw allow, firewalld mặc định — chỉ thêm luật vào chain INPUT của bảng filter. Gói tin bị DNAT rồi đi tiếp vào container sẽ đi qua chain FORWARD, không bao giờ chạm INPUT — tức là ufw deny 8080 không chặn được cổng do Docker publish, vì hai chain xử lý hai loại lưu lượng khác nhau, không phải vì ufw cấu hình sai.
Trên chính máy tôi đang dùng, dockerd (chạy trong VM) vẫn tự dựng đúng cấu trúc chain đó cho lưu lượng nội bộ giữa các container — PREROUTING nhảy sang DOCKER, POSTROUTING có MASQUERADE cho dải mạng bridge:
$ iptables -t nat -S
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -o docker0 -m addrtype --src-type LOCAL -j MASQUERADE
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
Đây là bằng chứng cho thấy kiến trúc PREROUTING → DOCKER là có thật, không phải tôi bịa ra để minh hoạ — nhưng chain DOCKER ở máy này rỗng ứng với các cổng tôi vừa publish, vì phần chuyển cổng từ macOS vào VM đi đường khác (mục trên), không qua DNAT kiểu Linux thuần. Trên server Linux không có lớp macOS ở giữa, DOCKER chain mới thật sự chứa luật DNAT cho từng cổng bạn publish — đó là phần tôi không tái hiện được ở đây.
Tổng kết
| Cách ràng buộc | Chạm được từ | Ghi trong docker inspect |
|---|---|---|
-p 80:80 (không ghi IP) |
mọi interface — phạm vi thật tuỳ máy host | HostIp: 0.0.0.0 |
-p 127.0.0.1:80:80 |
chỉ loopback của host | HostIp: 127.0.0.1 |
-p 127.0.0.1::80 |
chỉ loopback, cổng host do Docker tự chọn | HostIp: 127.0.0.1, cổng ngẫu nhiên |
EXPOSE 80 trong Dockerfile |
không ai — không tạo ràng buộc nào | không xuất hiện |
Quy tắc thực dụng rút ra: cổng nào không cần ai ngoài chính máy chủ gọi tới (cơ sở dữ liệu, cổng quản trị nội bộ, cổng debug) thì ràng buộc 127.0.0.1: tường minh, đừng để mặc định. Mặc định 0.0.0.0 đúng là cần thiết cho cổng thật sự phải public (web server sau reverse proxy), sai là khi bạn quên ghi IP cho cổng chỉ định dùng nội bộ — và trên Linux server thật, quên đó không có Docker Desktop nào che cho bạn.
Thử ba mươi giây
Kiểm tra một container đang publish cổng tới đâu — chạy được cả trên máy dev lẫn trên server, không cần đoán từ Dockerfile:
docker run -d --rm --name kiem-tra-bind -p 8080:80 nginx:alpine
docker inspect kiem-tra-bind --format '{{json .NetworkSettings.Ports}}'
# thấy "HostIp":"0.0.0.0" mà máy này có IP public thì tự hỏi: ai khác gõ được cổng 8080?
docker rm -f kiem-tra-bind