Gluetun: truy cập host và container khác
Container dùng network của Gluetun không có interface riêng. Hãy publish port trên Gluetun và chỉ mở các subnet cần truy cập ngoài tunnel để kết nối an toàn.
Điều gì xảy ra khi một container tham gia network của gluetun
Container đặt network_mode: service:gluetun sẽ không có network interface riêng. Nó tham gia network namespace của gluetun. Vì vậy, port publishing và firewall rule không còn thuộc tính của container đó mà trở thành thuộc tính của service gluetun. Mọi câu trả lời bên dưới đều bắt nguồn từ thực tế này.
Network namespace là bản sao riêng của network stack do kernel quản lý. Nó có interface riêng, routing table riêng, firewall rule riêng và listening socket riêng. Mặc định, Docker cấp cho mỗi container một network namespace. Khi bạn viết network_mode: service:gluetun, Docker bỏ qua bước đó và đặt container mới vào namespace mà gluetun đang sở hữu. Container vẫn giữ filesystem riêng và file /etc/hosts riêng. File thứ hai sẽ quan trọng ở phần sau.
Bạn có thể xem trực tiếp:
docker inspect -f '{{.HostConfig.NetworkMode}}' qbittorrentLệnh này in container:, theo sau là container ID của gluetun. Với container thông thường, nó sẽ in bridge. Hướng dẫn này tiếp tục từ định tuyến traffic Docker qua VPN bằng gluetun: tunnel đã hoạt động, nhưng hiện không có gì có thể kết nối đến container.
Công khai cổng trên gluetun, không phải trên ứng dụng
Để một khối ports: trên service đặt network_mode sẽ khiến Docker từ chối tạo container:
Error response from daemon: conflicting options: port publishing and the container type network modeLý do rất rõ. Công khai một cổng nghĩa là thêm một rule NAT (network address translation) để chuyển tiếp cổng trên host vào network namespace riêng của container. Container này không có network namespace riêng. Hãy chuyển ánh xạ đó sang service gluetun. Số cổng không thay đổi vì ứng dụng vẫn listen trên cổng đó trong namespace dùng chung.
services:
gluetun:
ports:
- "8080:8080/tcp" # qBittorrent web UI
qbittorrent:
network_mode: "service:gluetun"
# no ports: block hereĐể một khối expose: trên service phụ thuộc cũng không có tác dụng. Còn khối networks: tại đó sẽ khiến Compose dừng hẳn: Compose báo service khai báo đồng thời network_mode và networks, hai tùy chọn loại trừ lẫn nhau, rồi từ chối load file.
Một hệ quả sẽ xuất hiện sau đó. Mọi container trong namespace dùng chung một port space. Vì vậy, hai ứng dụng cùng mặc định dùng cổng 8080 sẽ xung đột. Ứng dụng khởi động sau sẽ fail với lỗi address already in use. Hãy đổi một ứng dụng trong cấu hình riêng của nó, chẳng hạn biến WEBUI_PORT trên image LinuxServer qBittorrent, rồi publish số cổng mới trên gluetun.
Các container phía sau gluetun liên lạc với nhau như thế nào?
Bên trong namespace, chúng đã dùng chung một loopback interface. Một container phía sau gluetun truy cập container cùng nhóm tại 127.0.0.1:<port> mà không cần Docker network.
Từ bên ngoài namespace, container này không có tên. DNS tích hợp của Docker phân giải tên service thành địa chỉ của service đó trên user-defined network, nhưng container này không có địa chỉ trên network nào. Vì vậy, một container thông thường như Sonarr không truy cập torrent client tại http://qbittorrent:8080. Nó truy cập tại http://gluetun:8080, vì socket đang listening trong namespace của gluetun, trên địa chỉ của gluetun. Điều này khiến những người biết Docker Compose network và cách service name hoạt động bất ngờ, vì họ thường kỳ vọng quy tắc đặt tên thông thường sẽ được áp dụng. Cách này cũng hoạt động mà không cần publish cổng nào lên host, vì cả hai container đều nằm trên cùng một Compose network.
Hãy kiểm tra DNS trước khi debug các vấn đề khác. Gluetun chạy resolver riêng và ghi đè /etc/resolv.conf trong container của nó, nhưng /etc/resolv.conf là file riêng của từng container, nên file do gluetun ghi không phải file mà application của bạn đọc.
docker exec qbittorrent cat /etc/resolv.confLàm thế nào để truy cập một service đang chạy trên Docker host?
Dùng host.docker.internal. Cần cấu hình 2 thiết lập ở 2 vị trí khác nhau vì có 2 vấn đề khác nhau.
Trước tiên là tên. /etc/hosts áp dụng cho từng container, nên mục extra_hosts phải được đặt trên application container, không phải trên gluetun.
prowlarr:
network_mode: "service:gluetun"
extra_hosts:
- "host.docker.internal:host-gateway"host-gateway là giá trị đặc biệt mà Docker thay thế bằng địa chỉ nội bộ của chính host. Trên bản cài Docker Linux thông thường, đó là địa chỉ của bridge docker0, thường là 172.17.0.1. Xác nhận địa chỉ của bạn bằng ip -4 addr show docker0 trên VPS. Docker Desktop tự phân giải tên này. Vì vậy, các hướng dẫn viết cho laptop thường bỏ qua dòng extra_hosts, còn cùng file đó lại fail trên server.
Tiếp theo là route. Chỉ thêm tên mới cho container biết địa chỉ cần dùng. Packet vẫn đi qua default route của gluetun, tức tunnel, và firewall của gluetun sẽ drop packet đó. Triệu chứng là connection bị treo rồi timeout, thay vì bị từ chối ngay. Refusal nghĩa là packet đã đến nơi và có thành phần trả lời từ chối. Timeout nghĩa là packet chưa đến nơi.
gluetun:
environment:
- FIREWALL_OUTBOUND_SUBNETS=172.17.0.1/32Sau đó kiểm tra service trên host có thực sự listening trên địa chỉ đó hay không. PostgreSQL chỉ bind vào 127.0.0.1 sẽ không thể được truy cập từ bất kỳ container nào, dù có tunnel hay không, vì 127.0.0.1 bên trong namespace là loopback của chính namespace đó. Thay vào đó, bind service vào 172.17.0.1: service sẽ nhận connection từ các container nhưng không mở trên public interface. Xác minh bằng ss -lntp | grep 5432 trên host.
FIREWALL_OUTBOUND_SUBNETS thực sự thay đổi gì
Tài liệu gluetun mô tả biến này là danh sách các subnet, phân tách bằng dấu phẩy, mà gluetun và các container dùng chung network stack của nó được phép truy cập. Tài liệu cũng ghi rõ biến này liên quan đến thay đổi firewall và routing. Cả hai phần đều quan trọng. Gluetun thêm một route cho mỗi subnet được liệt kê, đi qua Docker bridge gateway, nên các packet đến những địa chỉ đó sẽ đi qua eth0 thay vì tunnel. Gluetun cũng mở firewall cho các subnet này, vì nếu không, gluetun sẽ drop outbound traffic không hướng đến VPN server.
Ghi giá trị này mà không có khoảng trắng sau dấu phẩy.
- FIREWALL_OUTBOUND_SUBNETS=172.17.0.1/32,192.168.1.0/24,100.64.7.9/32Có hai đặc điểm dễ bị bỏ qua. Đây là thiết lập ở cấp namespace, nên áp dụng cho mọi container nằm sau gluetun, không chỉ container bạn đang nghĩ đến. Thiết lập này cũng chỉ áp dụng cho outbound traffic: nó kiểm soát các kết nối do container chủ động tạo. Các kết nối đi vào một port đã publish sẽ đi theo đường khác và không cần khai báo tại đây.
Truy cập web UI từ một peer Tailscale
Tailscale cấp cho mỗi máy một địa chỉ trong 100.64.0.0/10, dải địa chỉ dành riêng cho carrier-grade NAT. Hai chiều kết nối cần cách xử lý khác nhau.
Chiều inbound đơn giản hơn. Publish 8080:8080 trên gluetun sẽ bind cổng đó trên tất cả địa chỉ của host. Interface tailscale0 của host cũng nằm trong số đó, nên peer chỉ cần mở http://<machine-name>:8080 là truy cập được container. Gluetun không tham gia vào đường đi này, vì rule NAT của Docker nằm trên host, bên ngoài namespace.
Để UI chỉ truy cập được qua tailnet, hãy bind cổng đã publish vào địa chỉ Tailscale của host thay vì bind vào mọi địa chỉ.
ports:
- "100.101.102.103:8080:8080/tcp"Trên host, dùng tailscale ip -4 để tìm địa chỉ đó. Trong trường hợp này, bind là biện pháp kiểm soát chặt hơn firewall rule, vì cổng hoàn toàn không được mở trên interface public. Cách này cũng tránh được vấn đề trong Docker publish cổng trực tiếp qua ufw.
Chiều outbound mới là nơi FIREWALL_OUTBOUND_SUBNETS quay lại. Nếu container phải gọi đến một peer, hãy thêm địa chỉ của peer đó và ưu tiên dùng /32 cho từng peer thay vì dùng toàn bộ /10. Tên MagicDNS sẽ không resolve được bên trong container, vì container không dùng resolver của host. Do đó, hãy dùng địa chỉ 100.x dạng số hoặc cố định địa chỉ bằng một dòng extra_hosts. Điều tương tự cũng áp dụng khi bạn chạy control server Tailscale riêng bằng Headscale.
Một file compose hoàn chỉnh cho cấu trúc phổ biến
Một download client chạy sau VPN, hai web UI chỉ phản hồi trên tailnet và một container đọc cơ sở dữ liệu PostgreSQL chạy trên host.
services:
gluetun:
image: qmcgaw/gluetun:latest
container_name: gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
ports:
- "127.0.0.1:8000:8000/tcp" # gluetun control server, host only
- "100.101.102.103:8080:8080/tcp" # qBittorrent UI, tailnet only
- "100.101.102.103:9696:9696/tcp" # Prowlarr UI, tailnet only
volumes:
- ./gluetun:/gluetun
environment:
- VPN_SERVICE_PROVIDER=mullvad
- VPN_TYPE=wireguard
- WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
- WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
- SERVER_CITIES=Amsterdam
- FIREWALL_OUTBOUND_SUBNETS=172.17.0.1/32,100.64.7.9/32
- TZ=Europe/Amsterdam
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
network_mode: "service:gluetun"
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Amsterdam
- WEBUI_PORT=8080
volumes:
- ./qbittorrent:/config
- /srv/downloads:/downloads
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
network_mode: "service:gluetun"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Amsterdam
- PROWLARR__POSTGRES__HOST=host.docker.internal
- PROWLARR__POSTGRES__PORT=5432
- PROWLARR__POSTGRES__USER=prowlarr
- PROWLARR__POSTGRES__PASSWORD=${PROWLARR_DB_PASSWORD}
- PROWLARR__POSTGRES__MAINDB=prowlarr-main
- PROWLARR__POSTGRES__LOGDB=prowlarr-log
volumes:
- ./prowlarr:/config
depends_on:
gluetun:
condition: service_healthy
restart: unless-stoppedHãy đọc file để hiểu mô hình, không tập trung vào tên sản phẩm. Cả hai UI đều được publish trên gluetun và bind vào địa chỉ tailnet của host, nên chỉ phản hồi qua Tailscale, không phản hồi ở nơi nào khác. Chỉ Prowlarr có dòng extra_hosts vì Prowlarr là container phân giải các tên host.docker.internal. FIREWALL_OUTBOUND_SUBNETS khai báo hai địa chỉ đơn: địa chỉ Docker bridge của host để Prowlarr mở kết nối đến cơ sở dữ liệu và một peer trong tailnet.
Server PostgreSQL được cố ý không đưa vào file. Nó chạy trên VPS dưới dạng system service thông thường và listening trên 172.17.0.1:5432. Đây là cùng một mô hình phân lớp như một arr stack trên Docker Compose, với cơ sở dữ liệu được chuyển ra ngoài Docker.
Không đưa private key WireGuard vào compose file. ${WIREGUARD_PRIVATE_KEY} đọc key từ file .env nằm cùng thư mục, theo mô hình được trình bày trong env file và secret cho Docker Compose. Mệnh đề condition: service_healthy sử dụng healthcheck có sẵn trong image gluetun, nên không có gì khởi động cho đến khi tunnel báo đã hoạt động. Healthcheck của Compose giải thích cấu trúc tổng quát.
Publish trên mọi địa chỉ thay vì chỉ trên tailnet
Xóa tiền tố địa chỉ và các port bind trên 0.0.0.0; tùy chọn này bao gồm public IP của VPS. Chỉ làm vậy khi có firewall do bạn kiểm soát, và hãy đọc ghi chú về ufw ở trên trước.
ports:
- "8080:8080/tcp"Xác nhận tunnel vẫn đang truyền lưu lượng
Chạy cùng một request 2 lần, một lần từ bên trong namespace và một lần từ host, rồi so sánh kết quả.
docker run --rm --network=container:gluetun curlimages/curl:latest -s https://api.ipify.org
curl -s https://api.ipify.orgLần đầu phải in ra địa chỉ exit của nhà cung cấp VPN. Lần thứ hai phải in ra địa chỉ VPS. Nếu 2 địa chỉ này giống nhau, lưu lượng của container không đi qua tunnel. Khi đó, mọi cách khắc phục trong hướng dẫn này đều không có tác dụng cho đến khi sửa được lỗi đó.
Routing table cho biết lưu lượng nào đi ngoài tunnel và lưu lượng nào không.
docker run --rm --network=container:gluetun alpine:3.22 ip route showDefault route phải trỏ đến tunnel interface, tun0. Bên dưới, bạn phải thấy một route cho mỗi entry trong FIREWALL_OUTBOUND_SUBNETS, trỏ đến Docker bridge gateway. Bất kỳ route nào khác đi qua eth0 đều là lưu lượng bỏ qua VPN.
Control server của Gluetun báo cáo cùng public IP trên port 8000, tại /v1/publicip/ip. Các version gần đây yêu cầu bạn cấu hình authentication cho các route của control server, vì vậy hãy thiết lập phần này trước khi phụ thuộc vào nó.
Lỗ hổng do một subnet sai tạo ra
FIREWALL_OUTBOUND_SUBNETS là một lỗ trên firewall được cố ý mở, nên kích thước lỗ cũng chính là mức độ rủi ro. Có 4 cách khiến lỗ này quá lớn:
0.0.0.0/0gửi toàn bộ lưu lượng ra ngoài tunnel. Hai lần kiểm tra IP ở trên sẽ phát hiện lỗi này ngay lần chạy đầu tiên, vì chúng sẽ trả về cùng một địa chỉ.- Một dải rộng hơn mục tiêu. Mở
10.0.0.0/8để truy cập một máy tại10.0.1.7cũng mở mọi địa chỉ mà một peer torrent có thể quảng bá trong dải đó. Hãy ghi10.0.1.7/32. - Một dải chồng lấn với các địa chỉ của chính tunnel. Tài liệu gluetun cảnh báo rằng trường hợp này khiến gluetun gửi lưu lượng VPN qua bridge thay vì tunnel, làm port forwarding bị hỏng. Kiểm tra giá trị
WIREGUARD_ADDRESSEStrước khi mở bất kỳ private range nào. 100.64.0.0/10cho Tailscale. Cách này mở khoảng 4 triệu địa chỉ chỉ để có thể truy cập một peer. Hãy liệt kê các peer cần dùng dưới dạng các mục/32.
Hãy nhớ rằng thiết lập này áp dụng cho toàn bộ namespace. Mở một subnet để indexer truy cập một host service cũng mở cùng subnet đó cho torrent client đang dùng chung namespace. Chạy lại kiểm tra public IP sau mỗi lần thay đổi biến này, vì đây là bài kiểm tra duy nhất cho biết thay đổi có thực hiện đúng mục đích hay không.
Điều gì hỏng khi bạn restart gluetun
gluetun sở hữu namespace, nên vòng đời của gluetun cũng là vòng đời của namespace. Khởi động một container phụ thuộc trong khi gluetun đang dừng sẽ fail ngay lập tức:
Error response from daemon: cannot join network of a non running containerRestart gluetun tại chỗ là lỗi khó phát hiện hơn. Các container phụ thuộc vẫn tiếp tục chạy trong khi namespace mà chúng đã attach bị dựng lại bên dưới, nên docker ps báo mọi thứ vẫn healthy nhưng không dịch vụ nào phản hồi. Sau bất kỳ thay đổi nào đối với service gluetun, hãy recreate toàn bộ nhóm thay vì chỉ restart một phần.
docker compose up -d --force-recreateĐiều tương tự cũng áp dụng cho việc cập nhật image. Pull image gluetun mới rồi chỉ recreate service đó sẽ khiến các service còn lại trỏ đến một namespace không còn tồn tại.
FAQ
Vì sao Docker báo "chế độ network của container type không hỗ trợ publish port"?
Vì block ports: vẫn còn trong một service đồng thời đặt network_mode: service:gluetun. Việc publish port thêm một NAT rule để chuyển tiếp port trên host vào network namespace riêng của container, nhưng container ở chế độ này không có namespace riêng. Xóa block ports: khỏi service đó và thêm mapping giống hệt vào service gluetun. Số port không đổi vì ứng dụng vẫn đang lắng nghe trên port đó trong namespace dùng chung.
Các container khác truy cập service nằm sau gluetun bằng cách nào?
Các container trong cùng namespace truy cập lẫn nhau qua 127.0.0.1. Các container bên ngoài dùng tên service gluetun, vì vậy http://gluetun:8080 hoạt động còn http://qbittorrent:8080 thì không. Container ứng dụng không có địa chỉ trên bất kỳ Docker network nào, nên DNS tích hợp không có địa chỉ nào để phân giải cho tên của nó. Không cần publish port cho trường hợp này, miễn là cả hai container dùng chung một Compose network.
Tôi nên đặt gì trong FIREWALL_OUTBOUND_SUBNETS?
Chỉ thêm các địa chỉ mà container phía sau gluetun phải chủ động kết nối đến, với phạm vi hẹp nhất có thể. Một máy đơn lẻ là /32. Hai mục thường dùng là Docker host tại 172.17.0.1/32 và một /32 cho mỗi Tailscale peer mà bạn kết nối đến. Không bao giờ thêm 0.0.0.0/0 và không bao giờ thêm một range chồng lấn với địa chỉ tunnel riêng của VPN. Kết nối inbound đến port đã publish không cần mục này.
Vì sao container không phân giải được tên Tailscale MagicDNS của tôi?
MagicDNS hoạt động bằng cách trỏ resolver của host đến DNS server của Tailscale, nhưng container không dùng resolver của host. Container dùng cấu hình trong /etc/resolv.conf của chính nó; khi chạy phía sau gluetun, đó là cấu hình DNS của gluetun. Xác nhận bằng docker exec <container> cat /etc/resolv.conf. Dùng địa chỉ số 100.x của peer hoặc ghim tên bằng một entry extra_hosts trên container đó.
Làm cách nào xác nhận traffic vẫn đi qua VPN?
Chạy một request từ bên trong namespace và chạy cùng request đó trên host, sau đó so sánh kết quả. docker run --rm --network=container:gluetun curlimages/curl:latest -s https://api.ipify.org phải trả về địa chỉ exit của nhà cung cấp VPN, còn curl -s https://api.ipify.org trên VPS phải trả về địa chỉ của VPS. Hai kết quả giống nhau cho thấy tunnel không chuyển traffic của container. Chạy lại kiểm tra này sau mỗi thay đổi đối với FIREWALL_OUTBOUND_SUBNETS.