Podman + UFW: Khi Container Không Thể Gọi Ra Ngoài Internet
Ghi lại quá trình debug lỗi container timeout khi gọi third-party API trên VPS có cài Tailscale và UFW — DNS resolve được nhưng TCP bị drop hoàn toàn, từ triệu chứng, phân tích từng lớp, đến fix dứt điểm.
Mục lục
Container gọi third-party API timeout hoàn toàn. DNS resolve được IP, nhưng TCP không bắt tay được. Trên local chạy bình thường. Chỉ xảy ra trên VPS. Đây là hành trình debug từng lớp để tìm ra hai nguyên nhân ẩn sau UFW.
1. Triệu chứng ban đầu
API service chạy trong Podman Compose gọi đến các third-party endpoint (payment gateway, external API) đều timeout. Không có response nào trả về. Không có error log rõ ràng — chỉ là im lặng cho đến khi hết timeout.
Điểm đặc biệt:
- Trên local: hoạt động bình thường
- Trên VPS: tất cả third-party đều timeout, không phân biệt domain
- DNS vẫn resolve được: có trả về IP
Test trực tiếp trong container:
podman exec -it tiemtaphoa_app curl -v --max-time 5 https://httpbin.org/get
* Host httpbin.org:443 was resolved.
* IPv4: 54.172.102.128, 32.194.43.65, 34.234.203.36 ...
* Trying 54.172.102.128:443...
* Trying 32.194.43.65:443...
* Connection timed out after 5003 milliseconds
curl: (28) Connection timed out after 5003 milliseconds
DNS hoạt động (IPs trả về đủ), nhưng TCP SYN không nhận được reply — packet bị drop ở đâu đó trên đường ra.
2. Kiến trúc hệ thống
Stack chạy trên VPS Debian với các thành phần:
Cloudflare → Nginx (host) → Podman Compose
├── app (PHP-FPM / Go / Node)
├── postgres
├── redis
└── mongodb
Bảo mật:
- UFW: tường lửa chính, incoming deny, outgoing allow
- Tailscale: VPN để truy cập nội bộ (MongoDB, Redis, Postgres qua tailscale0)
- Podman rootful: container runtime, bridge network
10.88.0.0/16
3. Phân tích từng lớp
Lớp 1: Xác nhận host có outbound không
curl -v --max-time 5 https://httpbin.org/get
# 200 OK ✓
Host có outbound bình thường. Vấn đề nằm trong container, không phải routing của VPS.
Lớp 2: Kiểm tra iptables FORWARD
sudo iptables -L FORWARD -n -v
Chain FORWARD (policy DROP 957 packets, 56980 bytes)
pkts bytes target prot opt in out source destination
3586 3143K ts-forward all -- * * 0.0.0.0/0 0.0.0.0/0
957 56980 ufw-before-forward all -- * * 0.0.0.0/0 0.0.0.0/0
...
Policy DROP với 957 packets bị chặn. Chỉ có ts-forward của Tailscale được pass qua, không có rule nào cho Podman.
Lớp 3: Kiểm tra POSTROUTING
sudo iptables -t nat -L POSTROUTING -n -v
Chain POSTROUTING (policy ACCEPT 5440 packets, 358K bytes)
pkts bytes target prot opt in out source destination
5466 359K ts-postrouting all -- * * 0.0.0.0/0 0.0.0.0/0
Không có MASQUERADE rule nào cho subnet 10.88.0.0/16. Chỉ có Tailscale được NAT.
Lớp 4: Xác nhận UFW config
sudo ufw status verbose
Default: deny (incoming), allow (outgoing), deny (routed)
deny (routed) là confirmation. UFW block toàn bộ traffic routed/forwarded — đây chính là thứ Podman cần để container đi ra ngoài.
4. Sơ đồ luồng lỗi
Vấn đề 1 — FORWARD bị DROP:
Container (10.88.x.x)
│
│ TCP SYN → 54.172.102.128:443
▼
podman bridge (10.88.0.0/16)
│
▼
iptables FORWARD chain
│
├─ ts-forward: ACCEPT (chỉ Tailscale traffic)
├─ ufw-before-forward: không có rule match Podman subnet
│
└─ policy: DROP ← packet bị drop tại đây
Vấn đề 2 — Thiếu MASQUERADE:
Ngay cả nếu packet qua được FORWARD, khi ra đến eth0, source IP vẫn là 10.88.x.x (private). Server đích không thể route reply về địa chỉ private → connection timeout từ phía remote.
Container (10.88.0.5)
│ src: 10.88.0.5 → dst: 54.172.102.128
▼
eth0 (VPS public IP)
│ src: 10.88.0.5 → dst: 54.172.102.128 ← remote không biết route về đây
▼
httpbin.org
│ reply về 10.88.0.5? → dropped
Cần MASQUERADE để rewrite source IP thành public IP của VPS trước khi ra eth0.
5. Fix dứt điểm
Bước 1 — Xác nhận subnet của Podman
podman network inspect podman | grep subnet
# "subnet": "10.88.0.0/16"
Bước 2 — Cho phép FORWARD trong UFW
sudo sed -i 's/DEFAULT_FORWARD_POLICY="DROP"/DEFAULT_FORWARD_POLICY="ACCEPT"/' /etc/default/ufw
grep DEFAULT_FORWARD_POLICY /etc/default/ufw
# DEFAULT_FORWARD_POLICY="ACCEPT" ✓
Bước 3 — Thêm MASQUERADE rule vào UFW
Thêm vào đầu file /etc/ufw/before.rules, trước block *filter:
sudo sed -i '1s/^/*nat\n:POSTROUTING ACCEPT [0:0]\n-A POSTROUTING -s 10.88.0.0\/16 -o eth0 -j MASQUERADE\nCOMMIT\n\n/' /etc/ufw/before.rules
Kiểm tra kết quả:
head -8 /etc/ufw/before.rules
*nat
:POSTROUTING ACCEPT [0:0]
-A POSTROUTING -s 10.88.0.0/16 -o eth0 -j MASQUERADE
COMMIT
# ...rest of file
Bước 4 — Reload UFW
sudo ufw reload
Xác nhận ufw status verbose hiển thị allow (routed) thay vì deny (routed).
Bước 5 — Test
podman exec -it tiemtaphoa_app curl -v --max-time 5 https://httpbin.org/get
# HTTP/2 200 ✓
6. Tóm tắt toàn bộ chuỗi fix
| # | Vấn đề | Nguyên nhân | Fix |
|---|---|---|---|
| 1 | Container timeout khi gọi third-party | UFW deny (routed) drop toàn bộ FORWARD traffic | DEFAULT_FORWARD_POLICY="ACCEPT" trong /etc/default/ufw |
| 2 | Reply không về được container dù packet đi ra | Thiếu MASQUERADE, source IP 10.88.x.x không routable | Thêm MASQUERADE rule cho 10.88.0.0/16 trong /etc/ufw/before.rules |
7. Bài học
UFW “allow outgoing” không áp dụng cho container
allow (outgoing) trong UFW chỉ kiểm soát traffic originate từ host. Traffic từ container đi qua host là routed/forwarded traffic — thuộc FORWARD chain, không phải OUTPUT chain. UFW quản lý riêng hai chain này.
Khi setup VPS cho container workload, cần kiểm tra Default: ... deny (routed) ngay từ đầu.
Hai điều kiện cần đủ cho container có outbound
Thiếu một trong hai thì traffic vẫn không đi được:
- FORWARD ACCEPT — kernel cho phép forward packet từ Podman bridge ra
eth0 - MASQUERADE — rewrite source IP từ private sang public IP của VPS để nhận reply
Hay nhầm lẫn chỉ fix một trong hai và tưởng vẫn lỗi do nguyên nhân khác.
Tailscale không phải vấn đề ở đây, nhưng cần lưu ý
Tailscale tạo ts-forward rule riêng cho traffic của nó, nên hoạt động bình thường dù FORWARD policy là DROP. Điều này che giấu vấn đề — nhìn vào FORWARD chain thấy ts-forward pass traffic tưởng là OK, nhưng Podman traffic lại không có rule tương đương.
Checklist debug outbound từ container
# 1. Host có outbound không?
curl -v --max-time 5 https://httpbin.org/get
# 2. FORWARD chain policy là gì?
sudo iptables -L FORWARD -n -v | head -5
# 3. Có MASQUERADE rule cho subnet Podman không?
sudo iptables -t nat -L POSTROUTING -n -v
# 4. UFW routed policy là gì?
sudo ufw status verbose | grep routed
# 5. Subnet của Podman là gì?
podman network inspect podman | grep subnet
Môi trường: Debian 13, Podman rootful, podman compose, UFW, Tailscale, Cloudflare.