Hình dung bạn dựng một trạm gác chặn ngay trên con đường chính vào nhà, kiểm mọi xe đi qua. Yên tâm rồi đấy. Nhưng có người đã lặng lẽ mở một lối tắt và cắm sẵn tấm biển "rẽ đây" ngay đầu ngõ, nên mọi xe cần vào đều rẽ vào lối tắt trước khi tới được trạm gác của bạn. Trạm gác không hỏng — nó chỉ đang canh một con đường không xe nào chạy qua. Tường lửa với cổng Docker đúng là cái trạm gác ấy, và đây là con số cho thấy vì sao.

Tôi dựng một máy thử nghiệm, đặt hai luật tường lửa giống hệt nhau:

1  DROP  tcp  --  0.0.0.0/0  0.0.0.0/0  tcp dpt:18081
2  DROP  tcp  --  0.0.0.0/0  0.0.0.0/0  tcp dpt:18080

Rồi cho hai dịch vụ nghe hai cổng đó. Cổng 18080 là một container Docker -p 18080:80. Cổng 18081 là một tiến trình thường nghe trực tiếp. Gọi từ một máy khác:

Cổng Dịch vụ Kết quả
18081 tiến trình thường không kết nối được
18080 container Docker HTTP 200

Luật tường lửa y hệt nhau. Một cái có tác dụng, một cái không.

Vì sao

Đọc các luật Docker tự chèn thì rõ ngay:

[nat PREROUTING]  DOCKER  all  --  0.0.0.0/0  0.0.0.0/0  ADDRTYPE match dst-type LOCAL
[nat DOCKER]      DNAT    tcp  --  ...  tcp dpt:18080 to:172.17.0.2:80
[filter FORWARD]  1  DOCKER-USER
                  2  DOCKER-ISOLATION-STAGE-1

Gói tin tới cổng 18080 bị DNAT ngay ở PREROUTING — đây chính là tấm biển "rẽ đây" ở đầu ngõ, đổi đích thành 172.17.0.2:80. Từ lúc đó nó không còn là gói tin "gửi cho máy này" nữa, nó là gói tin chuyển tiếp, nên đi qua chuỗi FORWARD chứ không bao giờ chạm vào INPUT.

Mọi luật bạn viết trong INPUT, và mặc định đó cũng là nơi ufw viết luật của nó, đều nằm ngoài đường đi. Đây là lý do có cả một họ sự cố dạng "tôi bật ufw rồi mà Redis vẫn lộ ra Internet". Tường lửa không hỏng; nó chỉ canh một cánh cửa mà gói tin không đi qua.

-p mặc định mở ra mọi giao diện

Đây là nửa còn lại của vấn đề:

Lệnh Docker báo
-p 18080:80 0.0.0.0:18080->80/tcp, [::]:18080->80/tcp
-p 127.0.0.1:18081:80 127.0.0.1:18081->80/tcp
--expose 80 80/tcp

Dòng đầu mở cổng trên mọi địa chỉ của máy — cả IPv4 lẫn IPv6. Trên máy tính cá nhân sau NAT thì không sao. Trên một VPS có IP công cộng, -p 5432:5432 cho PostgreSQL nghĩa là cả Internet gõ cửa được, và tường lửa của bạn không chặn (vì lý do ở trên).

Kiểm tra nhanh những gì bạn đang mở:

docker ps --format '{{.Names}}\t{{.Ports}}' | grep '0.0.0.0'

Dòng nào hiện ra là dòng cả thế giới gọi được.

Hai cách vá, đã đo

Cách 1 — bind vào loopback. Không cần đụng tới tường lửa:

-p 127.0.0.1:18080:80
gọi từ máy khác không kết nối được
gọi trong chính máy đó 200

Đây là mặc định đúng cho mọi thứ chỉ phục vụ nội bộ: CSDL, cache, bảng quản trị. Rồi cho nginx (chạy --network host hoặc cùng mạng Docker) làm cửa ra duy nhất.

Trong Compose:

services:
  db:
    image: postgres:16-alpine
    ports:
      - "127.0.0.1:5432:5432"

Cách 2 — viết luật vào đúng chuỗi. Docker cố ý để sẵn một chuỗi rỗng tên DOCKER-USER, chạy trước mọi luật của nó trong FORWARD:

iptables -I DOCKER-USER -p tcp --dport 80 -j DROP

Đo lại: gọi từ máy khác → không kết nối được. Luật ở đây có tác dụng, vì nó nằm trên đường đi thật của gói tin — nói cách khác, ta đã dời trạm gác về đúng con đường mà xe thật sự chạy qua.

Đừng sửa các chuỗi DOCKER và DOCKER-ISOLATION-* — Docker viết lại chúng mỗi lần có container lên xuống. DOCKER-USER là chỗ duy nhất Docker hứa không đụng vào.

EXPOSE không mở gì cả

Câu hỏi thường gặp: EXPOSE 80 trong Dockerfile có mở cổng không?

Phép thử Kết quả
Container khác gọi thẳng 172.17.0.8:80 200
Có ánh xạ cổng nào trên máy chủ không không có ("80/tcp": null)

Container khác gọi được không phải nhờ EXPOSE — chúng cùng mạng nên vốn đã gọi được nhau, EXPOSE hay không cũng thế. Nó chỉ là ghi chú trong metadata cho người đọc và cho -P (viết hoa, publish mọi cổng đã expose).

Còn -p 80 không kèm số cổng máy chủ thì Docker chọn giúp một cổng cao ngẫu nhiên:

0.0.0.0:53389->80/tcp

Tiện cho test, đừng dùng cho việc thật vì số cổng đổi sau mỗi lần tạo lại.

Và --network host kèm -p thì Docker nói thẳng:

WARNING: Published ports are discarded when using host network mode

Container vẫn chạy, Ports rỗng. Đúng như phần 33 đã nói: mạng host không có gì để ánh xạ.

Mỗi cổng publish tốn hai tiến trình

Điều ít người biết: Docker sinh một tiến trình docker-proxy cho mỗi ánh xạ cổng, và vì mặc định bind cả IPv4 lẫn IPv6 nên là hai tiến trình mỗi cổng.

/usr/local/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 18080 -container-ip 172.17.0.2 -container-port 80
/usr/local/bin/docker-proxy -proto tcp -host-ip ::      -host-port 18080 -container-ip 172.17.0.2 -container-port 80

Publish một dải 20 cổng (-p 19000-19019:80-99):

Số đo
Số tiến trình docker-proxy 40
Tổng PSS 93,3 MB
Trung bình mỗi tiến trình 2,3 MB

Tôi cộng RSS trước và ra 157 MB, nhưng con số đó đếm trùng — 40 tiến trình dùng chung cùng một đoạn mã, và RSS tính phần dùng chung đó cho từng tiến trình. PSS chia đều phần dùng chung nên phản ánh đúng bộ nhớ thật sự tốn thêm. Chênh lệch giữa hai cách đo là 64 MB, đủ để đưa ra một kết luận sai hẳn.

Dù sao thì 93 MB cho việc publish 20 cổng vẫn là một cái giá đáng biết, nhất là khi bạn có vài chục container. Tắt được bằng "userland-proxy": false trong /etc/docker/daemon.json — khi đó việc chuyển tiếp do iptables lo hoàn toàn.

Tự soi cổng của bạn

docker ps --format '{{.Names}}\t{{.Ports}}' | grep -E '0\.0\.0\.0|\[::\]'

Mỗi dòng hiện ra là một cổng mở với cả Internet nếu máy này có IP công cộng — bất kể ufw status nói gì. Ba việc nên làm ngay:

  • Đổi sang 127.0.0.1: cho mọi thứ không cần lộ ra ngoài.
  • Kiểm tra bằng cách gọi từ máy khác, không phải từ chính máy đó.
  • Nếu cần luật tường lửa cho container, viết vào chuỗi DOCKER-USER.

Mẫu số chung

Bài học đầu tiên, chính là cái trạm gác đặt nhầm đường: một biện pháp kiểm soát chỉ có tác dụng khi nó nằm trên đúng con đường mà luồng cần chặn thật sự đi qua — đặt đúng nghiệp vụ nhưng sai vị trí thì nó canh một lối vắng. Luật INPUT vô hại với gói tin đã bị DNAT sang FORWARD; ufw không hỏng, nó chỉ đứng sai chỗ. Cùng cái bẫy "canh nhầm cửa" ở khắp nơi: kiểm tra hợp lệ ở giao diện nhưng API vẫn nhận thẳng request thô, kiểm quyền ở controller trong khi một job nền hay một truy vấn thẳng vào CSDL đi đường khác, giới hạn tần suất đặt ở ứng dụng còn CDN ở rìa thì đi vòng, middleware xác thực gắn sau một route công khai. Nguyên tắc: muốn chặn một luồng, hãy truy đường đi thật của nó rồi đặt chốt lên đúng đó — và luôn nghiệm thu bằng cách tấn công từ bên ngoài, gọi từ máy khác, chứ đừng tự-kiểm từ bên trong nơi mọi cửa đều mở sẵn.

Điều thứ hai, đọc từ 157 MB "đếm trùng" so với 93 MB thật: chọn sai thước đo thì cùng một hiện tượng cho ra kết luận lệch hẳn — con số chỉ có nghĩa khi nó trả lời đúng câu hỏi bạn đang hỏi. RSS cộng phần bộ nhớ dùng chung vào từng tiến trình nên phồng lên 64 MB ảo; PSS chia đều phần dùng chung mới đúng "tốn thêm bao nhiêu". Cùng cái bẫy "đo bằng thước sai" ở khắp nơi: lấy trung bình cộng các lượt có số câu khác nhau thay vì chia ở đúng mẫu số, nhìn p50 khi thứ giết bạn là p99, đọc wall-clock khi cần CPU-time, đếm dòng test mà lớp @Nested bị tính lệch. Nguyên tắc: trước khi tin một con số, hỏi "thước này có đang đếm trùng, đếm thiếu, hay trả lời một câu khác không" — vì một phép đo trông có thẩm quyền mà chọn sai đại lượng còn dẫn bạn đi lạc xa hơn là không đo gì cả.

Phần sau đo độ trễ mạng cho ra số cụ thể: bridge so với host, so với gọi trong cùng một container.