Docker обходить UFW: чому порт залишається доступним
Docker додає DNAT-правила iptables, які обходять UFW: заборонений порт 8080 усе одно відповідає з інтернету. Як це виправити двома способами.
Чому Docker обходить UFW
Docker обходить UFW, тому що опубліковані порти контейнерів не проходять через правила брандмауера, якими керує UFW. Коли ви запускаєте docker run -p 8080:80, Docker записує правило DNAT (трансляції мережевої адреси призначення) у власний ланцюжок DOCKER у таблиці ядра nat, до якого переходить ланцюжок PREROUTING. Це правило змінює адресу призначення кожного пакета на приватну адресу контейнера ще до того, як ядро визначить, куди передавати пакет. Потім змінений пакет пересилається до контейнера через ланцюжок FORWARD, яким керує Docker. Правила UFW містяться в ланцюжку INPUT, але пакет до нього не потрапляє. Тому ufw status показує політику заборони за замовчуванням, sudo ufw deny 8080 повідомляє про успішне виконання, а порт 8080 усе одно доступний з усього інтернету.
Це не помилка Docker, і UFW не працює неправильно. Обидва інструменти налаштовують один і той самий брандмауер ядра. Правила Docker просто застосовуються на попередньому етапі проходження пакета, тому до правил UFW справа не доходить. У цьому посібнику показано обхід правил, пояснено механізм і розглянуто два робочі способи виправлення: публікація портів на 127.0.0.1 та фільтрація в ланцюжку DOCKER-USER. Якщо ви ще не працювали з UFW, спочатку налаштуйте його за посібником з основ брандмауера UFW, оскільки брандмауер із забороною за замовчуванням залишається правильною базою для решти сервера.
Перевірте обхід на власному сервері
Почніть із VPS, на якому активний UFW із політикою deny за замовчуванням для вхідного трафіку. Запустіть web-контейнер із опублікованим портом:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose показує Default: deny (incoming), allow (outgoing) і не містить правила для порту 8080. Згідно з власним звітом firewall, порт закритий. Тепер перевірте це з іншої машини, а не з самого сервера:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKКонтейнер відповідає. Додайте явне правило deny і повторіть перевірку:
sudo ufw deny 8080/tcpПорт і далі відповідає, оскільки правило deny міститься в ланцюжку, до якого пакет не доходить. UFW не працював неправильно. До нього взагалі не зверталися. Саме тому проблему так важко помітити: помилка ніде не виводиться, розгортання завершується успішно, а вивід стану firewall виглядає так само, як у справді захищеного сервера.
Механізм: PREROUTING виконується перед INPUT
Ядро обробляє вхідний пакет у фіксованому порядку, і вся проблема полягає саме в цьому порядку.
- Спочатку виконується
PREROUTING. Правила, до яких переходять звідси, можуть змінити адресу призначення пакета. Правило Docker для опублікованого порту в ланцюжкуDOCKER, до якого переходитьPREROUTING, робить саме це. - Далі виконується маршрутизація. Пакет, адресований самому хосту, переходить до ланцюжка
INPUT. Пакет, адресований іншій машині, переходить до ланцюжкаFORWARD. - Правила UFW містяться в
INPUT. Правила Docker містяться вFORWARD.
Перегляньте правило Docker для контейнера, який ви щойно запустили:
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:80Рядок DNAT пояснює все. Для будь-якого пакета, що надходить на порт 8080, адресу призначення буде змінено на 172.17.0.2:80 — адресу контейнера у приватній bridge-мережі Docker. Після цієї зміни пакет уже не адресований хосту, тому рішення маршрутизації спрямовує його шляхом FORWARD. Там Docker уже додав правила, які дозволяють трафік до власних мереж. Правило deny 8080/tcp очікує пакет у INPUT, але такий пакет туди не надходить.
В Ubuntu 24.04 команда iptables є інтерфейсом над nftables, але порядок ланцюжків і результат залишаються такими самими. UFW і Docker записують правила в той самий конвеєр обробки пакетів ядра, а точка входу Docker розташована раніше. Це не особливість UFW: firewalld на VPS із Rocky або AlmaLinux фільтрує трафік у тій самій точці конвеєра, і те саме правило DNAT обходить його. Тому наведені нижче способи виправлення підходять і для таких систем.
Повсякденне рішення: публікуйте порти на 127.0.0.1
Більшість контейнерів узагалі не потрібно було робити публічними. База даних, сервер застосунку за reverse proxy, панель адміністрування, endpoint метрик — жоден із них не повинен напряму відповідати в Інтернеті. Публікуйте їх на loopback-адресі:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineАбо у файлі Compose:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"Це працює, оскільки правило DNAT у Docker тепер зіставляє лише пакети, адресовані до 127.0.0.1, а в Docker Engine 28.0.0 і новіших версіях Docker також додає правило в таблиці raw, у ланцюжку PREROUTING. Це правило відкидає будь-який пакет для цього loopback-порту, що надходить через інтерфейс, відмінний від loopback. У цих версіях порт доступний із хоста й більше ні звідки. У версіях, старіших за 28.0.0, Docker зазначає, що хости в одному сегменті L2, наприклад машини, підключені до одного мережевого комутатора, усе ще можуть отримувати доступ до портів, опублікованих на localhost. Тому перевірте docker version і оновіть Docker, перш ніж покладатися на цю поведінку. Перевірте прив’язку:
sudo ss -tlnp | grep 8080У виводі має бути 127.0.0.1:8080, а не 0.0.0.0:8080 або [::]:8080. Потім з іншої машини переконайтеся, що curl http://your-vps-ip:8080/ не отримує відповіді. Пакет, надісланий на вашу публічну адресу, більше не переписується Docker, тому він надходить до INPUT. Там політика UFW із типовою забороною та правило deny 8080/tcp, наведене раніше, мовчки відкидають його, а не відхиляють. Тому curl очікує завершення з’єднання, а потім завершується через тайм-аут, а не з помилкою "connection refused".
Для сервісів, які мають бути доступні з Інтернету, запустіть один reverse proxy, що володіє портами 80 і 443 та маршрутизує запити за іменем хоста. Інші порти не публікуйте. Саме таку схему описує посібник із reverse proxy Traefik, і саме завдяки їй self-hosted застосунок на кшталт Nextcloud на VPS залишається недоступним інакше, ніж через свій проксі. Оголошення записів ports: та решту робочого процесу Compose описано в посібнику з основ Docker Compose.
Коли всі внутрішні контейнери працюють через loopback, UFW знову виконує свою звичайну функцію: захищає порти, які обслуговує сам хост. Створіть тут цей набір правил, а потім виконайте команди по черзі:
Практична фільтрація: ланцюжок DOCKER-USER
Іноді порт контейнера потрібно залишити опублікованим у мережі, але обмежити доступ до нього. Наприклад, порт репліки бази даних може бути доступний лише з однієї офісної адреси. Для цього Docker надає ланцюжок DOCKER-USER. Кожен пакет, адресований будь-якому контейнеру, проходить через DOCKER-USER до застосування власних правил приймання Docker. Цей ланцюжок призначений для ваших правил, і Docker не видаляє та не змінює їх, зокрема після перезапуску daemon.
Цей ланцюжок належить стандартному iptables backend брандмауера Docker. У Docker Engine 29 додано експериментальний nftables backend, який потрібно ввімкнути параметром daemon firewall-backend. У ньому ланцюжка DOCKER-USER немає: власну фільтрацію потрібно розмістити в окремій nftables table, створеній вами, з base chain на hook forward. Якщо ви не змінювали цей параметр, використовується iptables і все нижче актуальне. Відмінності між цими двома інструментами описано в посібнику з порівняння iptables і nftables.
Перед виконанням команди врахуйте один нюанс: коли пакет досягає DOCKER-USER, перезапис DNAT уже виконано. Порт призначення пакета — це порт контейнера (80 у нашому прикладі), а не опублікований порт (8080). Тому правило, що перевіряє --dport 8080, нічого не збігає. Надійний спосіб — перевірити порт, до якого клієнт підключався спочатку. Його зберігає connection tracker ядра:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPЦе правило означає: для пакетів, що надійшли через eth0 і належать до з’єднання з початковим портом призначення 8080, відкидати всі пакети, надіслані не з 10.0.0.10. Умова --ctdir ORIGINAL обмежує правило напрямком від клієнта до контейнера, тому пакети відповідей не потрапляють під нього помилково. Замініть eth0 на назву вашого публічного інтерфейсу; ip route | grep default показує цю назву. Перевірте правило так само, як раніше: curl з дозволеної адреси має завершитися успішно, а з будь-якої іншої адреси з’єднання має завершитися тайм-аутом. Таке зависання свідчить, що правило DROP працює, а не що за портом немає сервісу. Відмінність між відхиленим з’єднанням і з’єднанням, що завершується тайм-аутом — найшвидший спосіб відрізнити відфільтрований порт від сервісу, який просто не прослуховує порт.
Правила, додані командою iptables, зникають після перезавантаження. Оскільки UFW уже керує цим брандмауером, зберігати їх потрібно у /etc/ufw/after.rules. Додайте блок у кінець файлу:
*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
COMMITПісля цього виконайте sudo ufw reload. UFW повторно застосовує цей файл під час кожного перезавантаження конфігурації та кожного запуску системи. Тому фільтрація контейнерів зберігається разом з рештою правил брандмауера та переживає і перезавантаження, і оновлення Docker.
Чому не слід вимикати інтеграцію Docker з iptables
У старих рекомендаціях для цієї проблеми пропонують встановити { "iptables": false } у /etc/docker/daemon.json. Не робіть цього. Правила firewall Docker роблять значно більше, ніж публікують порти. Правило masquerade надає контейнерам доступ до зовнішнього інтернету через адресу хоста. Тому після вимкнення інтеграції контейнери не зможуть звертатися до дзеркал пакетів або викликати будь-який зовнішній API (application programming interface). Правила DNAT та ізоляції також зникнуть. У документації Docker зазначено, що без правил-замінників порти контейнерів стануть доступними для хостів у локальній мережі. Щоб усунути цей обхід, доведеться порушити мережеву взаємодію контейнерів. Крім того, усі ці правила доведеться створювати й обслуговувати вручну. У власній документації Docker називає цей параметр непридатним для більшості користувачів і зазначає, що створення правил після цього є надзвичайно складним. Ланцюжок DOCKER-USER існує саме для того, щоб нікому не доводилося використовувати цей перемикач.
Стосовно IPv6 виникає та сама проблема
Спочатку перевірте, як опублікований порт виглядає в IPv6:
sudo ss -tlnp | grep 8080Починаючи з Docker Engine 27, Docker за замовчуванням керує ip6tables. У Docker network з увімкненим IPv6 опублікований порт так само обробляється через DNAT у таблицях IPv6. Тому обхід також існує, і застосовується те саме виправлення: ланцюжок DOCKER-USER також існує в ip6tables, тож продублюйте правило за допомогою sudo ip6tables -I DOCKER-USER ... і перевірте доступ ззовні через curl до публічної IPv6-адреси сервера, наприклад curl -6 http://[2001:db8:2a::1]:8080/. Це стосується стандартного iptables backend у Docker. В експериментальному nftables backend ланцюжка DOCKER-USER немає в жодному стеку, а фільтрацію IPv6 потрібно налаштовувати у власній nftables table ip6.
У network без IPv6 клієнти IPv6 натомість обробляються через docker-proxy — звичайний user-space процес, який слухає [::]:8080 і пересилає трафік у container через IPv4. Трафік до host process проходить через INPUT, тому UFW може фільтрувати цей шлях, але лише якщо UFW взагалі керує IPv6. Питання про те, чи керує він IPv6, а також про інші способи виникнення проблем із IPv6 на VPS, розглянуто в посібнику з UFW та IPv6.
Публікація через loopback усуває всю цю проблему: -p 127.0.0.1:8080:80 прив’язується лише до IPv4 loopback, тому IPv6 listener відсутній. Починаючи з Docker Engine 28.0.0, ззовні немає до чого підключатися в жодному зі стеків.
Схема, яка працює стабільно
- Публікуйте кожен внутрішній порт на
127.0.0.1, щоб він узагалі не був доступний зовні, у Docker Engine 28.0.0 або новішій версії (у старіших випусках до нього можуть підключатися хости в тому самому сегменті L2). - Передайте публічний доступ одному reverse proxy, який використовує порти 80 і 443.
- Залиште для хоста політику UFW
default deny, дозволивши SSH і порти проксі. - Фільтруйте справді публічні порти контейнерів у
DOCKER-USERу стандартному iptables backend Docker, зіставляючи правила з початковим портом призначення, і зберігайте їх у/etc/ufw/after.rules. - Не вимикайте інтеграцію Docker з iptables.
Після одноразового налаштування це усуває неоднозначність: ufw status описує хост, а DOCKER-USER — контейнери. Нічого не публікується випадково, а наступна команда docker run -p, яку ви введете, відкриє рівно те, що ви задумали.
FAQ
Чому я можу підключитися до Docker-контейнера, коли UFW блокує порт?
Тому що Docker публікує порт за допомогою правила DNAT у ланцюжку DOCKER таблиці nat, до якого пакет потрапляє з PREROUTING. Це правило змінює адресу призначення пакета на адресу контейнера ще до початку фільтрації. Потім пакет проходить шляхом FORWARD, а правила UFW розташовані в INPUT — ланцюжку, до якого пакет не потрапляє. Брандмауер не перевіряє цей пакет, тому його правила deny не впливають на опубліковані порти контейнера.
Як зробити так, щоб UFW блокував опубліковані порти Docker?
Сам UFW цього зробити не може, оскільки його правила розташовані в неправильному ланцюжку. Або припиніть публікувати порт, задавши його як 127.0.0.1:8080:80, щоб у Docker Engine 28.0.0 і новіших версіях до нього міг підключатися лише host, або фільтруйте трафік у ланцюжку DOCKER-USER за допомогою правила iptables, яке через conntrack перевіряє початковий порт призначення. Збережіть це правило в /etc/ufw/after.rules, щоб воно переживало перезавантаження та ufw reload.
Чи слід установити "iptables": false у daemon.json Docker?
Ні. Цей параметр видаляє більшість правил firewall і NAT, які створює Docker, тому наслідки будуть значно серйознішими за сам обхід UFW. Контейнери втратять доступ до зовнішнього інтернету, оскільки зникне правило masquerade. Docker також попереджає, що без правил-замінників порти контейнерів стануть доступними для host у локальній мережі. Замість цього використовуйте публікацію через loopback і ланцюжок DOCKER-USER. Вони усувають зовнішню доступність, не порушуючи мережеву взаємодію контейнерів.
Чи обходить Docker UFW також через IPv6?
У Docker Engine 27 і новіших версіях керування ip6tables увімкнене за замовчуванням. Тому порт, опублікований у Docker network з підтримкою IPv6, переписується в обхід UFW так само, як і через IPv4. У стандартному backend iptables Docker для нього потрібне таке саме правило DOCKER-USER, продубльоване через ip6tables. В експериментальному backend nftables з Engine 29 ланцюжка DOCKER-USER немає, тому таку фільтрацію потрібно розмістити у власній таблиці nftables. У мережах без IPv6 процес docker-proxy прослуховує [::], і цей трафік проходить через INPUT, де UFW може його фільтрувати, якщо UFW керує IPv6. Публікація через 127.0.0.1 усуває обидва випадки, оскільки через IPv6 взагалі нічого не прослуховується.