Dlaczego Docker omija UFW i jak naprawić ten problem
Docker automatycznie dodaje reguły iptables, które omijają zaporę UFW. Dowiedz się, dlaczego porty kontenerów są publicznie dostępne i jak poprawnie zabezpieczyć dostęp.
Dlaczego Docker omija UFW
Docker omija UFW, ponieważ opublikowane porty kontenerów nigdy nie przechodzą przez reguły zapory zarządzane przez UFW. Po uruchomieniu docker run -p 8080:80, Docker zapisuje regułę DNAT (destination network address translation) we własnym łańcuchu DOCKER w tabeli nat jądra systemu, do której odwołuje się łańcuch PREROUTING. Reguła ta nadpisuje adres docelowy każdego pakietu na prywatny adres kontenera, zanim jądro podejmie decyzję o dalszej trasie pakietu. Następnie przekierowany pakiet trafia do kontenera przez łańcuch FORWARD, nad którym kontrolę sprawuje Docker. Reguły UFW znajdują się w łańcuchu INPUT, do którego pakiet nigdy nie dociera. W rezultacie ufw status wskazuje domyślne odrzucanie połączeń, sudo ufw deny 8080 zgłasza sukces, a port 8080 nadal odpowiada na zapytania z całego Internetu.
Nie jest to błąd Dockera ani awaria UFW. Oba narzędzia konfigurują tę samą zaporę sieciową jądra. Reguły Dockera działają po prostu na wcześniejszym etapie ścieżki pakietu, więc UFW nie otrzymuje zapytania. Niniejszy przewodnik demonstruje to zjawisko, wyjaśnia mechanizm jego działania, a następnie omawia dwa skuteczne rozwiązania: publikowanie portów na 127.0.0.1 oraz filtrowanie w łańcuchu DOCKER-USER. Jeśli UFW jest nowym narzędziem, należy najpierw skonfigurować je zgodnie z przewodnikiem po podstawach zapory UFW, ponieważ domyślna polityka odrzucania połączeń stanowi właściwą podstawę dla wszystkich pozostałych usług na serwerze.
Weryfikacja obejścia zabezpieczeń na własnym serwerze
Należy rozpocząć od serwera VPS, na którym aktywny jest UFW z domyślną polityką odrzucania ruchu przychodzącego (deny). Następnie należy uruchomić kontener WWW z opublikowanym portem:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose wskazuje Default: deny (incoming), allow (outgoing) oraz brak reguły dla portu 8080. Według raportu zapory sieciowej port jest zamknięty. Teraz należy przeprowadzić test z innej maszyny, nie z poziomu samego serwera:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKKontener odpowiada na żądania. Należy dodać jawne regułę odrzucania (deny) i przetestować ponownie:
sudo ufw deny 8080/tcpPort nadal odpowiada, ponieważ reguła odrzucania znajduje się w łańcuchu, do którego pakiet nigdy nie trafia. UFW nie zawiódł. Zapora nie została nawet odpytana. Jest to również powód, dla którego problem jest trudny do wykrycia: nigdzie nie pojawia się komunikat o błędzie, wdrożenie przebiega poprawnie, a status zapory wygląda dokładnie tak, jak w przypadku poprawnie zabezpieczonego serwera.
Mechanizm: PREROUTING działa przed INPUT
Jądro przetwarza przychodzący pakiet w ustalonej kolejności i to właśnie ona stanowi źródło problemu.
PREROUTINGdziała jako pierwszy. Reguły wywoływane z tego miejsca mogą nadpisać adres docelowy pakietu, co dokładnie robi reguła Docker dla opublikowanego portu w łańcuchuDOCKER, do którego skaczePREROUTING.- Następnie podejmowana jest decyzja o routingu. Pakiet zaadresowany do samego hosta trafia do łańcucha
INPUT. Pakiet zaadresowany do innej maszyny trafia do łańcuchaFORWARD. - Reguły UFW znajdują się w
INPUT. Reguły Docker znajdują się wFORWARD.
Spójrz na regułę Docker dla kontenera, który właśnie uruchomiono:
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80Linia DNAT wyjaśnia całą sytuację. Każdy pakiet przychodzący na port 8080 ma nadpisywany adres docelowy na 172.17.0.2:80, czyli adres kontenera w prywatnej sieci bridge Docker. Po nadpisaniu adres nie wskazuje już na hosta, więc decyzja o routingu kieruje pakiet ścieżką FORWARD, gdzie Docker dodał już reguły akceptujące ruch w swoich sieciach. Twoja reguła deny 8080/tcp czeka w INPUT na pakiet, który nigdy tam nie dotrze.
W systemie Ubuntu 24.04 polecenie iptables stanowi interfejs dla nftables, ale kolejność łańcuchów i wynik są identyczne. Zarówno UFW, jak i Docker zapisują reguły w tym samym potoku pakietów jądra, a punkt wejścia Docker znajduje się wcześniej. Nie jest to specyficzne tylko dla UFW: firewalld na VPS z systemem Rocky lub AlmaLinux filtruje ruch w tym samym punkcie potoku i jest pomijany przez tę samą regułę DNAT, dlatego poniższe rozwiązania mają zastosowanie również w tym przypadku.
Codzienna naprawa: publikowanie portów na 127.0.0.1
Większość kontenerów w ogóle nie musi być publicznie dostępna. Baza danych, serwer aplikacji za odwrotnym proxy, panel administracyjny czy punkt końcowy metryk: żaden z nich nie powinien odpowiadać bezpośrednio na zapytania z Internetu. Należy publikować je na adresie pętli zwrotnej (loopback):
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineLub w pliku Compose:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"Działa to, ponieważ reguła DNAT w Dockerze dopasowuje teraz tylko pakiety zaadresowane do 127.0.0.1, a w Docker Engine 28.0.0 i nowszych Docker dodaje również regułę w łańcuchu PREROUTING tabeli raw, która odrzuca każdy pakiet dla tego portu pętli zwrotnej przychodzący na interfejs inny niż loopback. W tych wydaniach port jest osiągalny z hosta, ale z niczego innego. W wydaniach starszych niż 28.0.0 Docker dokumentuje, że hosty w tym samym segmencie L2, na przykład maszyny w tym samym przełączniku sieciowym, nadal mogą uzyskać dostęp do portów opublikowanych na localhost, więc sprawdź docker version i wykonaj aktualizację przed poleganiem na tym rozwiązaniu. Zweryfikuj powiązanie:
sudo ss -tlnp | grep 8080W danych wyjściowych oczekiwane jest 127.0.0.1:8080, a nie 0.0.0.0:8080 lub [::]:8080. Następnie potwierdź z innej maszyny, że curl http://your-vps-ip:8080/ nie otrzymuje odpowiedzi. Pakiet wysłany na adres publiczny nie jest już nadpisywany przez Dockera, więc dociera do INPUT, gdzie domyślna polityka odrzucania UFW oraz reguła deny 8080/tcp z wcześniejszego etapu po cichu go porzucają, zamiast odrzucać. Z tego powodu polecenie curl zawiesza się i kończy przekroczeniem czasu połączenia (connection timeout), a nie komunikatem "connection refused".
Dla usług, które powinny być wystawione do Internetu, uruchom jedno odwrotne proxy, które przejmuje porty 80 i 443 oraz kieruje ruch według nazwy hosta, nie publikując niczego innego. Jest to wzorzec, na którym opiera się przewodnik po odwrotnym proxy Traefik i w ten sposób aplikacja self-hosted, taka jak Nextcloud na VPS, pozostaje nieosiągalna inaczej niż przez swoje proxy. Sposób deklarowania wpisów ports: oraz reszta przepływu pracy w Compose zostały omówione w przewodniku po podstawach Docker Compose.
Gdy każdy wewnętrzny kontener znajduje się na pętli zwrotnej, UFW wraca do pełnienia swojej standardowej roli: ochrony portów obsługiwanych bezpośrednio przez hosta. Zbuduj ten zestaw reguł tutaj, a następnie wykonaj polecenia w podanej kolejności:
Filtrowanie rzeczywiste: łańcuch DOCKER-USER
Czasami port kontenera musi pozostać opublikowany w sieci, ale z ograniczeniami, na przykład w przypadku portu repliki bazy danych, do którego dostęp powinien mieć tylko jeden adres biurowy. W tym celu Docker udostępnia łańcuch DOCKER-USER. Każdy pakiet kierowany do dowolnego kontenera przechodzi przez DOCKER-USER przed regułami akceptacji samego Dockera. Łańcuch ten jest przeznaczony dla reguł użytkownika, a Docker nie usuwa ich ani nie modyfikuje, nawet po restarcie demona.
Łańcuch ten należy do domyślnego backendu zapory sieciowej iptables w Dockerze. Docker Engine 29 wprowadził eksperymentalny backend nftables, który należy włączyć za pomocą opcji demona firewall-backend. W tym trybie łańcuch DOCKER-USER nie istnieje: niestandardowe filtrowanie umieszcza się w osobnej, własnej tabeli nftables z łańcuchem bazowym typu hook forward. Jeśli ta opcja nie została zmieniona, używany jest iptables i zastosowanie mają poniższe informacje. Różnice między tymi dwoma narzędziami opisano w przewodniku iptables vs nftables.
Ważna uwaga przed wykonaniem polecenia: w momencie, gdy pakiet dociera do DOCKER-USER, operacja DNAT została już wykonana. Portem docelowym pakietu jest port kontenera (w naszym przykładzie 80), a nie port opublikowany (8080). Reguła dopasowująca --dport 8080 nie dopasuje zatem niczego. Niezawodną metodą jest dopasowanie portu, z którym klient pierwotnie się łączył, co jest zapamiętywane przez mechanizm śledzenia połączeń jądra:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPNależy to odczytywać następująco: dla pakietów, które weszły przez eth0 i należą do połączenia, którego pierwotnym portem docelowym był 8080, odrzuć wszystko, co nie zostało wysłane z 10.0.0.10. Dopasowanie --ctdir ORIGINAL ogranicza regułę do kierunku klient-kontener, dzięki czemu pakiety odpowiedzi nie są omyłkowo blokowane. Zastąp eth0 swoim publicznym interfejsem; ip route | grep default oznacza jego nazwę. Przetestuj to w ten sam sposób co poprzednio: curl z dozwolonego adresu kończy się sukcesem, a z każdego innego połączenie przekracza limit czasu. To zawieszenie jest sygnaturą reguły DROP wykonującej swoje zadanie, w przeciwieństwie do portu, za którym nic nie ma, a różnica między odrzuconym połączeniem a przekroczeniem limitu czasu jest najszybszym sposobem na odróżnienie przefiltrowanego portu od usługi, która po prostu nie nasłuchuje.
Reguły dodane za pomocą polecenia iptables znikają po restarcie. Ponieważ UFW zarządza już tą zaporą, najczystszym miejscem na ich utrwalenie jest /etc/ufw/after.rules. Dołącz blok na końcu pliku:
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMITNastępnie wykonaj sudo ufw reload. UFW odtwarza ten plik przy każdym przeładowaniu i każdym starcie systemu, dzięki czemu filtrowanie kontenerów znajduje się w tym samym miejscu co reszta zapory i przetrwa zarówno restart, jak i aktualizację Dockera.
Dlaczego nie należy wyłączać integracji Docker z iptables
Starsze odpowiedzi na ten problem sugerują ustawienie { "iptables": false } w /etc/docker/daemon.json. Nie należy tego robić. Reguły firewalla Docker wykonują znacznie więcej zadań niż tylko publikowanie portów. Reguła masquerade zapewnia kontenerom dostęp do Internetu poprzez adres hosta, więc po wyłączeniu tej integracji kontenery tracą możliwość połączenia z repozytoriami pakietów oraz wywoływania zewnętrznych API (application programming interface). Reguły DNAT oraz izolacji również przestają działać, a dokumentacja Docker ostrzega, że bez zastępczych reguł porty kontenerów stają się dostępne dla hostów w sieci lokalnej. Próba naprawy tego obejścia prowadzi do uszkodzenia sieci kontenerów, a każdą z tych reguł trzeba byłoby pisać i utrzymywać ręcznie. Dokumentacja Docker określa to ustawienie jako nieodpowiednie dla większości użytkowników i opisuje ręczne tworzenie reguł jako proces niezwykle skomplikowany. Łańcuch DOCKER-USER istnieje właśnie po to, aby nikt nie musiał korzystać z tego przełącznika.
Problem IPv6 w tym samym kontekście
Najpierw należy sprawdzić, jak wygląda opublikowany port w protokole IPv6:
sudo ss -tlnp | grep 8080Od wersji Docker Engine 27, Docker domyślnie zarządza ip6tables. W sieci Docker z włączonym IPv6, opublikowany port podlega takiemu samemu mechanizmowi DNAT w tablicach IPv6, co oznacza, że występuje tam to samo obejście zabezpieczeń i stosuje się tę samą poprawkę: łańcuch DOCKER-USER istnieje również w ip6tables, więc należy odzwierciedlić regułę za pomocą sudo ip6tables -I DOCKER-USER ... i przetestować połączenie z zewnątrz narzędziem curl, wskazując publiczny adres IPv6 serwera, na przykład curl -6 http://[2001:db8:2a::1]:8080/. Powyższe dotyczy domyślnego backendu iptables w Dockerze; w eksperymentalnym backendzie nftables łańcuch DOCKER-USER nie występuje w żadnym ze stosów, a filtrowanie IPv6 odbywa się w zdefiniowanej przez użytkownika tablicy ip6 nftables.
W sieci bez obsługi IPv6, klienci IPv6 są obsługiwani przez docker-proxy, standardowy proces działający w przestrzeni użytkownika, który nasłuchuje na [::]:8080 i przekazuje ruch do kontenera za pośrednictwem IPv4. Ruch kierowany do procesu na hoście przechodzi przez INPUT, więc UFW może filtrować tę ścieżkę, ale tylko wtedy, gdy UFW w ogóle zarządza obsługą IPv6. Kwestia tego, czy tak jest, oraz inne sposoby powstawania luk IPv6 na serwerach VPS, zostały opisane w przewodniku dotyczącym UFW i IPv6.
Publikowanie na interfejsie zwrotnym (loopback) pozwala całkowicie uniknąć tego problemu: -p 127.0.0.1:8080:80 wiąże się wyłącznie z adresem zwrotnym IPv4, więc nie powstaje żaden proces nasłuchujący IPv6, a w Docker Engine 28.0.0 i nowszych nie ma możliwości uzyskania dostępu z zewnątrz w żadnym z tych stosów.
Wzorzec zapewniający bezpieczeństwo
- Publikuj każdy port wewnętrzny na
127.0.0.1, aby nie był on w ogóle wystawiony na zewnątrz, w przypadku Docker Engine 28.0.0 lub nowszego (starsze wersje pozwalają hostom w tym samym segmencie L2 na uzyskanie do nich dostępu). - Przekaż obsługę ruchu publicznego do jednego reverse proxy, które zajmuje porty 80 oraz 443.
- Utrzymuj domyślną politykę deny w UFW dla hosta, zezwalając jedynie na SSH oraz porty proxy.
- Filtruj faktycznie publiczne porty kontenerów w
DOCKER-USERna domyślnym backendzie iptables Dockera, dopasowując je do oryginalnego portu docelowego i utrwalając w/etc/ufw/after.rules. - Pozostaw włączoną integrację Dockera z iptables.
Skonfigurowanie tego raz eliminuje niespodzianki: ufw status opisuje hosta, a DOCKER-USER opisuje kontenery. Żaden port nie jest publikowany przypadkowo, a każde kolejne polecenie docker run -p, które wpiszesz, wystawi dokładnie to, co zamierzałeś.
FAQ
Dlaczego mogę uzyskać dostęp do kontenera Docker, mimo że UFW blokuje port?
Ponieważ Docker publikuje port za pomocą reguły DNAT w łańcuchu DOCKER tabeli nat, wywoływanym z PREROUTING, co nadpisuje adres docelowy pakietu na adres kontenera przed wykonaniem jakiegokolwiek filtrowania. Pakiet następnie podąża ścieżką FORWARD, a reguły UFW znajdują się w łańcuchu INPUT, do którego pakiet nigdy nie trafia. Zapora sieciowa nie jest sprawdzana, więc jej reguły blokujące nie mają wpływu na opublikowane porty kontenerów.
Jak sprawić, by UFW blokował porty opublikowane przez Docker?
UFW nie jest w stanie tego zrobić samodzielnie, ponieważ jego reguły znajdują się w niewłaściwym łańcuchu. Należy albo przestać wystawiać port, publikując go jako 127.0.0.1:8080:80, aby tylko host mógł uzyskać do niego dostęp (w Docker Engine 28.0.0 i nowszych), albo filtrować ruch w łańcuchu DOCKER-USER za pomocą reguły iptables, która dopasowuje oryginalny port docelowy poprzez conntrack. Regułę tę należy utrwalić w /etc/ufw/after.rules, aby przetrwała restarty oraz ufw reload.
Czy powinienem ustawić "iptables": false w pliku daemon.json Dockera?
Nie. To ustawienie usuwa większość reguł zapory sieciowej i NAT Dockera, co powoduje znacznie więcej problemów niż tylko obejście zabezpieczeń. Kontenery tracą dostęp do Internetu, ponieważ reguła maskarady zostaje usunięta, a Docker ostrzega, że bez reguł zastępczych porty kontenerów stają się dostępne dla hostów w sieci lokalnej. Należy korzystać z publikowania na interfejsie zwrotnym (loopback) oraz łańcucha DOCKER-USER; rozwiązuje to problem wystawienia portów bez naruszania sieci kontenerów.
Czy Docker omija UFW również w przypadku IPv6?
W Docker Engine 27 i nowszych zarządzanie ip6tables jest domyślnie włączone, więc port opublikowany w sieci Docker z obsługą IPv6 jest przekierowywany z pominięciem UFW dokładnie tak samo jak w IPv4. W przypadku domyślnego backendu iptables Dockera wymagana jest ta sama reguła DOCKER-USER, odzwierciedlona za pomocą ip6tables. W eksperymentalnym backendzie nftables od wersji Engine 29 nie ma łańcucha DOCKER-USER, więc filtrowanie należy umieścić we własnej tabeli nftables. W sieciach bez IPv6 proces docker-proxy nasłuchuje na [::] i ten ruch przechodzi przez INPUT, gdzie UFW może go filtrować, jeśli UFW zarządza obsługą IPv6. Publikowanie na 127.0.0.1 pozwala uniknąć obu tych przypadków, ponieważ na IPv6 nie nasłuchuje żaden proces.