Hỗ trợ mấy anh em Dev và intern DevOps trong team thấy được vấn đề dễ gặp khi dùng Linux và Docker nên nay chia sẻ một bài viết khá nhập môn cho anh em học và làm khi setup một server mới nhé.
Thông thường vì học mới anh em sẽ hay thấy hướng dẫn bật UFW/IPtables, chặn toàn bộ port kết nối đi vào và chỉ mở SSH (ở doanh nghiệp có quy mô, quy định còn có thể không được bật), HTTP là port 80, HTTPS là port 443. Nhìn trạng thái UFW báo Active, thường nghĩ server đã an toàn.

Tuy nhiên, khi chạy Docker Compose để dựng các ứng dụng (ví dụ như Portainer để quản lý container hoặc Grafana để xem dashboard) trên VPS chẳng hạn, vấn đề bắt đầu xuất hiện:
services:
web-app:
image: nginx:alpine
ports:
- "80:80"
portainer:
image: portainer/portainer-ce:latest
ports:
- "9000:9000"
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
Lúc này, có thể anh em nghĩ: port 9000 và 3000 mình đâu có mở trên UFW. Chắc chắn chỉ có thể truy cập từ localhost.
Nhưng thực tế không phải vậy. Bất kỳ ai từ Internet cũng có thể truy cập thẳng vào trang quản trị Portainer qua IP public của server. Nếu dùng mật khẩu mặc định hoặc chưa cấu hình tài khoản, họ có thể vào kiểm soát các container của bạn.
Tại sao UFW không chặn được Docker?
Phần này quan trọng nhé, nguyên nhân là do Docker và UFW can thiệp vào iptables ở hai chain khác nhau với thứ tự ưu tiên khác nhau:
- PREROUTING chain (NAT table): nhận gói tin đầu tiên từ card mạng, quyết định chuyển hướng (DNAT).
- FORWARD chain (Filter table): chuyển tiếp gói tin sang card mạng khác (ở đây là card mạng ảo của Docker).
- INPUT chain (Filter table): lọc gói tin đi trực tiếp vào host (đây là nơi UFW quản lý rule).
Giải thích nhanh về hai bảng này:
- NAT table: nhiệm vụ dịch địa chỉ (đổi IP hoặc Port). Docker dùng bảng này để map port từ ngoài vào trong container.
- Filter table: nhiệm vụ lọc gói tin (quyết định cho phép đi qua hay chặn lại). UFW hoạt động chính ở bảng này.
Khi có kết nối đến port 9000 của Portainer:
- Gói tin vừa đến server sẽ khớp ngay với rule DNAT của Docker tại PREROUTING để đổi IP đích thành IP nội bộ của container.
- Sau đó, gói tin đi thẳng qua FORWARD để vào container.
Vì luồng đi của gói tin là PREROUTING -> FORWARD -> Container, nó hoàn toàn bỏ qua INPUT chain. Do đó, mọi rule chặn của UFW ở INPUT chain đều vô tác dụng.
Kiểm tra các rule tự động của Docker
Khi anh em khai báo map port trong Docker Compose:
ports:
- "9000:9000"
Docker sẽ tự động chèn một quy tắc chuyển hướng (DNAT) vào bảng nat. Anh em có thể kiểm tra bằng lệnh:
sudo iptables -t nat -L DOCKER -n
Kết quả thực tế:
Chain DOCKER (2 references)
target prot opt source destination
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:9000 to:172.18.0.5:9000
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:3000 to:172.18.0.4:3000
Dòng 0.0.0.0/0 nghĩa là hệ thống chấp nhận kết nối từ mọi nguồn trên Internet gửi tới port 9000 và 3000 để chuyển vào container.
Cách xử lý cho từng trường hợp
Tùy vào nhu cầu thực tế, anh em có thể áp dụng các giải pháp sau:
Trường hợp 1: Các container chỉ cần gọi nhau nội bộ
Ví dụ: Prometheus collector chỉ cần lấy dữ liệu từ ứng dụng web, người ngoài không cần truy cập trực tiếp vào Prometheus.
Giải pháp: Bỏ hoàn toàn phần khai báo ports trong file Compose.
services:
web-app:
image: nginx:alpine
ports:
- "80:80" # Mở port này cho người dùng truy cập
collector:
image: prom/prometheus
# Không khai báo ports ở đây
Các container trong cùng một file Compose sẽ tự động kết nối với nhau qua mạng nội bộ của Docker bằng tên dịch vụ (ví dụ: http://collector:9090). port 9090 lúc này hoàn toàn không bị lộ ra ngoài.
Trường hợp 2: Cần truy cập trang quản trị nhưng phải bảo mật
Ví dụ: Bạn cần vào trang quản trị Portainer (port 9000) hoặc Grafana (port 3000) nhưng không muốn ai khác trên Internet nhìn thấy.
Giải pháp: Chỉ định rõ IP loopback 127.0.0.1 khi khai báo port.
services:
portainer:
image: portainer/portainer-ce:latest
ports:
- "127.0.0.1:9000:9000" # Chỉ cho kết nối từ chính server
grafana:
image: grafana/grafana:latest
ports:
- "127.0.0.1:3000:3000"
Lúc này, kết nối từ ngoài vào port 9000 hoặc 3000 sẽ bị chặn.
Khi cần vào làm việc, anh em tạo SSH Tunnel từ máy cá nhân đến server:
ssh -L 9000:127.0.0.1:9000 user@IP_Server
Sau đó mở trình duyệt máy mình và gõ http://localhost:9000 để sử dụng bình thường.
Trường hợp 3: Tách file Compose theo từng môi trường
Để tránh việc vô tình mở port khi lên production, anh em nên chia file Compose làm hai phần:
- docker-compose.yml (File gốc): định nghĩa các dịch vụ, không publish port nội bộ.
- docker-compose.override.yml (File chạy ở local): publish các port để dev tiện debug.
Khi chạy ở máy cá nhân, lệnh docker compose up sẽ tự động gộp hai file này. Khi deploy lên server thật, anh em chỉ chạy file gốc kết hợp với file cấu hình production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Cách này giúp hạn chế tối đa rủi ro quên đóng port dịch vụ khi lên production.
Trường hợp 4: Chỉ cho phép một vài IP cụ thể truy cập
Có những lúc anh em bắt buộc phải mở port Portainer ra ngoài để cho các thành viên trong team truy cập từ xa, nhưng chỉ muốn giới hạn cho một vài IP tin cậy (ví dụ: IP của văn phòng hoặc IP của VPN).
Như đã phân tích ở trên, việc dùng lệnh ufw allow from IP_SERVER to any port 9000 sẽ không có tác dụng vì gói tin đã bypass qua INPUT chain của UFW.
Giải pháp: Cấu hình trực tiếp vào DOCKER-USER chain của iptables. Đây là chain được Docker thiết kế riêng để người dùng tự định nghĩa các quy tắc lọc gói tin trước khi Docker xử lý định tuyến.
Chạy các lệnh sau trực tiếp trên server:
# Cho phép IP truy cập port 9000
sudo iptables -I DOCKER-USER -p tcp --dport 9000 -s <IP-tin-cay> -j ACCEPT
# Chặn toàn bộ IP còn lại kết nối vào port này
sudo iptables -A DOCKER-USER -p tcp --dport 9000 -j DROP
Để các rule này tự động khôi phục sau khi reboot server, anh em cần cài đặt:
sudo apt install iptables-persistent
sudo netfilter-persistent save
Giải pháp thay thế: Sử dụng công cụ ufw-docker
Nếu không muốn thao tác thủ công với các lệnh iptables phức tạp, anh em có thể sử dụng một công cụ mã nguồn mở rất phổ biến là ufw-docker.
Công cụ này giúp tự động hóa việc đồng bộ các rule của UFW vào hệ thống mạng của Docker. Sau khi cài đặt, anh em có thể sử dụng các cú pháp lệnh cực kỳ quen thuộc:
# Chỉ cho phép IP tin cậy truy cập port 9000 của container portainer
ufw-docker allow portainer 9000 from IP_Trust
Kết luận
Việc Docker tự động cấu hình iptables mang lại sự tiện lợi rất lớn khi triển khai ứng dụng, nhưng nó cũng vô tình vô hiệu hóa lớp bảo vệ của UFW mà nhiều người không hề hay biết.
Hãy luôn ghi nhớ quy tắc: Chỉ mở các port thực sự cần thiết cho người dùng cuối (như 80, 443), tất cả các công cụ quản trị hoặc dịch vụ nội bộ khác phải được khóa vào địa chỉ 127.0.0.1 và truy cập thông qua SSH Tunnel hoặc VPN.


