Ви закриваєте хост акуратною політикою фаєрволу. Ви запускаєте контейнер із «просто тестовим портом». За кілька хвилин сканер з іншого часового поясу вже з ним спілкується. Ви нічого явно не «відкривали» — принаймні в своїй голові.
Це пастка мереж Docker: одне неправильне уявлення про взаємодію NAT і ланцюжків фаєрволу — і ваша ментальна модель перестає відповідати реальності ядра. Ядро завжди праве. Давайте зробимо так, щоб воно працювало на вашу користь.
Неправильне уявлення: «Мій фаєрвол вирішує, що доступно»
Найдорожча помилка в розумінні мереж Docker — думка, що правила хост-фаєрволу оцінюються «звично» для опублікованих портів контейнерів.
На Linux-хості із стандартним bridge-мережевим режимом Docker встановлює правила iptables, які переписують трафік (DNAT) і маршрутизують його до контейнерів. Якщо ваша політика фаєрволу виходить із простої моделі «INPUT вирішує, потім ACCEPT/DROP», ви можете помилитися двома способами одночасно:
- Ви можете фільтрувати не той ланцюг. Опубліковані порти контейнерів зазвичай — це форварднений трафік, а не локальна доставка. Тому рішення часто ухвалюється в
FORWARD, а не вINPUT. - Ви можете фільтрувати занадто пізно. NAT-перетворення відбуваються до фільтрації в спосіб, що змінює те, що бачать ваші правила. Ви думаєте, що блокуєте порт 8080 на хості, але після DNAT це стає «порт 80 на 172.17.0.2», і ваша умова більше не збігається.
Робота Docker — зробити контейнери досяжними. Ваше завдання — вирішити, звідки. Якщо ви явно не визначите цю межу в потрібному місці, Docker охоче зробить частину «досяжності» за весь інтернет.
Суха правда: стандартна поведінка «опублікувати порт» означає «зробити його досяжним». Стандартна поведінка «запустити на VM у хмарі» — «припускай, що хтось сканує тебе». Поєднайте це — і отримаєте інцидент, який нікому не до вподоби.
Реальний шлях пакета: conntrack, NAT, filter, і де Docker підключається
Мінімальна модель для розуміння, яка не брешуть
Коли пакет приходить на Linux-хост, ядро не питає ваш фаєрвол ввічливо, чи має він право існувати. Воно класифікує пакет, звертається до conntrack, пропускає через гачки NAT, потім через гачки фільтрації, а вже потім маршрутизує. Порядок має значення.
Для типового вхідного TCP-з’єднання до опублікованого порту Docker на хості (наприклад, 203.0.113.10:443) важливі такі зупинки:
- PREROUTING (nat): правило DNAT від Docker може переписати адресу призначення з IP:порт хоста на IP:порт контейнера.
- Роутинг: після DNAT ядро може вирішити, що цей трафік не призначено для самого хоста, а має бути переслано на bridge-інтерфейс (
docker0). - FORWARD (filter): саме тут багато хост-фаєрволів забувають заглянути. Docker додає правила ACCEPT для встановлених потоків і для опублікованих портів.
- POSTROUTING (nat): для вихідного трафіку з контейнерів Docker зазвичай застосовує SNAT/MASQUERADE, щоб відповіді виглядали, ніби вони прийшли від хоста.
Важливі ланцюги Docker (iptables бекенд)
На системах з iptables як бекендом Docker зазвичай створює та використовує такі ланцюги:
DOCKER(вnatіfilter): містить правила DNAT і деякі фільтри для мереж контейнерів.DOCKER-USER(вfilter): ваш рекомендований пункт вставки. Docker переходить сюди рано, тому ви можете застосувати свою політику.DOCKER-ISOLATION-STAGE-1/2: використовується для ізоляції мереж Docker одна від одної.
Чому правила UFW/firewalld можуть виглядати правильними і все одно програти
Багато інструментів рівня «фаєрвол» — це обгортки. Вони генерують правила iptables/nftables у певних ланцюгах із певними пріоритетами. Якщо вони зосереджені на INPUT, а ваш контейнерний трафік проходить через FORWARD, ваш «deny» ніколи не має голосу.
До того ж Docker може вставляти правила перед тими, що створені дистрибутивним менеджером фаєрволу, залежно від його побудови. Пакет співпаде з першим підходящим правилом і ніколи не дійде до вашого ретельно створеного drop.
Одна парафразована ідея від Werner Vogels (CTO Amazon): «Усе постійно ламається; проєктуйте й експлуатуйте так, ніби відмови — це норма». Застосуйте це і до безпеки: припускайте, що неправильні налаштування — звична річ, і будуйте запобіжники.
Цікаві факти та історичний контекст
- Linux netfilter з’явився за десятиліття до Docker. iptables став масовим на початку 2000-х і побудований на гачках netfilter у ядрі.
- Conntrack — це стан, а не магія. Трасування з’єднань дозволяє використовувати правила «ESTABLISHED,RELATED», які роблять фаєрволи зручними, але це також означає, що один дозволений пакет може створити довготривалий дозволений потік.
- Стандартний bridge Docker — це звичайний Linux bridge. Це не якийсь спеціальний «Docker-комутатор»; це та сама примітива, що використовується для ВМ і мережевих просторів імен роками.
- «Publish» означає «зв’язатися на всіх інтерфейсах», якщо не сказано інакше.
-p 8080:80зазвичай прив’язується до0.0.0.0(і часто до::), якщо ви не вказали IP. - Docker ввів DOCKER-USER як поступку реальності. Людям потрібне було стабільне місце для застосування політик, яке переживало рестарт Docker і не переписувалося.
- nftables не одразу замінив iptables на практиці. Багато дистрибутивів перейшли на nftables під капотом, але інструменти, очікування і інтеграція Docker відставали роками.
- Hairpin NAT старший за ваш поточний інцидент. Проблема «хост звертається до себе через публічний IP» існує в багатьох NAT-пристроях; Docker може спричинити схожу дивність на одній машині.
- Swarm ingress networking використовує власну прокладку. Routing mesh може публікувати порти по всіх вузлах, що дивує команди, які очікують «тільки вузол з таском відкритий».
- Cloud security groups не замінюють політику на хості. Вони — ще один рівень, а не гарантія: неправильно застосовані правила або пізні зміни все одно можуть вас відкрити.
Як «експозиція» насправді відбувається (чотири поширені режими)
Режим 1: Опублікували порт і забули, що він прив’язується до світу
docker run -p 8080:80 … зручно. Це також явна експозиція. Якщо ви мали на увазі «тільки localhost», треба вказати це: -p 127.0.0.1:8080:80.
Це помилка «я не думав, що буде публічно». Docker вважав інакше. Docker мав рацію.
Режим 2: Ваш фаєрвол фільтрує INPUT, але трафік Docker йде через FORWARD
Якщо пакет DNAT’иться на IP контейнера, він більше не призначений хосту. Це переводить його в логіку форварду. Якщо ваша політика ігнорує FORWARD, ви залишили бокові двері — широко відкриті.
Режим 3: Ви покладаєтесь на налаштування UFW/firewalld, що не враховують ланцюги Docker
Деякі менеджери фаєрволу встановлюють політику форварду на ACCEPT або взагалі не керують ланцюгами Docker. Ви можете отримати фаєрвол, який виглядає суворим для хоста, а контейнери їхатимуть по окремій швидкісній смузі.
Режим 4: Ви оптимізували під продуктивність і випадково прибрали контрольну точку
Вимкнення conntrack, зміна bridge-nf-call-iptables, переключення бекендів iptables або увімкнення «fast path» можуть змінити, які правила виконуються і коли. Саме тут «в стейджі працювало» помирає.
Жарт #1: NAT як корпоративна організаційна схема — усе маршрутизують через нього, і ніхто не визнає, що ним володіє.
Практичні завдання: команди, виводи та рішення
Це не «запустіть це, бо блог так сказав» команди. Кожна показує щось конкретне. За кожною стоїть рішення.
Завдання 1: Перелік опублікованих портів і їх IP-прив’язок
cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
NAMES IMAGE PORTS
web nginx:1.25 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
metrics prom/prometheus 127.0.0.1:9090->9090/tcp
db postgres:16 5432/tcp
Що це означає: web доступний на всіх IPv4 та IPv6 інтерфейсах. metrics лише для localhost. db взагалі не опублікований.
Рішення: Якщо сервіс не має бути доступним з інтернету, зупиніть і перезапустіть з явно вказаною IP-прив’язкою або приберіть публікацію. Не «виправляйте це потім у фаєрволі», якщо ви не контролюєте DOCKER-USER.
Завдання 2: Підтвердити, що слухає на хості
cr0x@server:~$ sudo ss -lntp | sed -n '1,8p'
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=2211,fd=4))
LISTEN 0 4096 127.0.0.1:9090 0.0.0.0:* users:(("docker-proxy",pid=2332,fd=4))
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1023,fd=3))
Що це означає: Docker експонує 8080 на всіх інтерфейсах. Навіть якщо є правила iptables, наявність сокета, що слухає — перший крок до досяжності.
Рішення: Якщо ви бачите 0.0.0.0 або :: і не планували цього — виправте прапори публікації або визначення сервісу перш за все.
Завдання 3: Перевірити використання docker-proxy (і не думати, що його немає)
cr0x@server:~$ ps -ef | grep -E 'docker-proxy|dockerd' | head
root 1190 1 0 08:11 ? 00:00:12 /usr/bin/dockerd -H fd://
root 2211 1190 0 09:02 ? 00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80
root 2332 1190 0 09:03 ? 00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 127.0.0.1 -host-port 9090 -container-ip 172.17.0.3 -container-port 9090
Що це означає: Деякі налаштування досі використовують userland proxying для опублікованих портів. Це може змінити шлях пакетів через фаєрвол хоста і вплинути на логування.
Рішення: Якщо ви шукаєте «чому моє правило INPUT не зійшлося», зверніть увагу, чи трафік проксирується локально, чи форвардиться до контейнера.
Завдання 4: Подивитися правила iptables у таблиці nat (де відбувається DNAT)
cr0x@server:~$ sudo iptables -t nat -S | sed -n '1,60p'
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-N DOCKER
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
Що це означає: Будь-який пакет до локальних адрес на порт 8080 може бути DNAT’нений до контейнера. Це включає публічні IP хоста.
Рішення: Якщо потрібно обмежити джерела, робіть це в DOCKER-USER (filter) або коректно налаштуйте публікацію/прив’язку; не боріться з DNAT пізніми INPUT-правилами.
Завдання 5: Перевірити порядок у таблиці filter, особливо DOCKER-USER
cr0x@server:~$ sudo iptables -S FORWARD
-P FORWARD DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o docker0 -j DOCKER
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT
Що це означає: Docker переходить у DOCKER-USER рано. Це добре: у вас є контрольна точка. Тут також встановлена політика FORWARD у DROP за замовчуванням, що теж добре.
Рішення: Розміщуйте allowlist/denylist у DOCKER-USER. Якщо на вашій системі немає цього переходу, виправляйте цю модель політики негайно.
Завдання 6: Подивитися ланцюг DOCKER-USER (ваш пункт застосування політики)
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -j RETURN
Що це означає: Немає жодної політики для форвардного трафіку контейнерів. Все проходить без змін.
Рішення: Додайте явні правила: за замовчуванням заборонити і дозволяти лише те, що має бути досяжним.
Завдання 7: Безпечно додати правило «deny за замовчуванням для контейнерів»
cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -i eth0 -o docker0 -m conntrack --ctstate NEW -j DROP
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -i eth0 -o docker0 -m conntrack --ctstate NEW -j DROP
-A DOCKER-USER -j RETURN
Що це означає: Нові вхідні з’єднання, що приходять на eth0 і форвардяться до контейнерів через docker0, будуть відкинуті. Встановлені потоки залишаться дозволеними іншими правилами.
Рішення: Це ваш аварійний гальмо. Після застосування вибірково дозволяйте необхідні опубліковані порти з потрібних джерел.
Завдання 8: Дозволити конкретний сервіс з конкретного діапазону джерел
cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -p tcp -s 198.51.100.0/24 -d 172.17.0.2 --dport 80 -j ACCEPT
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -p tcp -s 198.51.100.0/24 -d 172.17.0.2 --dport 80 -j ACCEPT
-A DOCKER-USER -i eth0 -o docker0 -m conntrack --ctstate NEW -j DROP
-A DOCKER-USER -j RETURN
Що це означає: Ви дозволяєте лише вказаний діапазон джерел доступ до цього порту контейнера, і цей дозвіл стоїть перед загальним drop.
Рішення: Віддавайте перевагу allowlisting по джерелу й призначенню. Якщо це неможливо, хоча б обмежте по інтерфейсу та порту.
Завдання 9: Перевірити з перспективи хоста, яким маршрутом і інтерфейсом йде форвард
cr0x@server:~$ ip route get 172.17.0.2
172.17.0.2 dev docker0 src 172.17.0.1 uid 1000
cache
Що це означає: Трафік до IP контейнера іде через docker0. Це підтверджує, що інтерфейс співпадає з вашим правилом фаєрволу.
Рішення: Якщо ви використовуєте кастомні мережі (наприклад, br-*), оновіть правила, щоби вони відповідали потрібним вихідним інтерфейсам.
Завдання 10: Перевірити bridge netfilter sysctl, що змінює, чи проходить бриджевий трафік через iptables
cr0x@server:~$ sudo sysctl net.bridge.bridge-nf-call-iptables net.bridge.bridge-nf-call-ip6tables
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
Що це означає: Бриджевий трафік проходитиме через правила iptables. Якщо ці значення 0, очікування щодо фільтрації порушаться, і ви отримаєте «фаєрвол не працює» плутанину.
Рішення: Тримайте їх увімкненими, якщо ви не розумієте глибоко наслідків і не маєте альтернативного механізму контролю.
Завдання 11: Визначити, чи iptables використовує nft бекенд (це важливо для дебагу)
cr0x@server:~$ sudo iptables -V
iptables v1.8.9 (nf_tables)
Що це означає: Команди iptables маніпулюють nftables під капотом. Порядок правил і співіснування з нативними nft-правилами можуть бути непередбачуваними.
Рішення: Під час розслідування використовуйте також nft list ruleset, а не лише вивід iptables.
Завдання 12: Переглянути nftables ruleset для взаємодії з Docker
cr0x@server:~$ sudo nft list ruleset | sed -n '1,80p'
table inet filter {
chain forward {
type filter hook forward priority 0; policy drop;
jump DOCKER-USER
jump DOCKER-ISOLATION-STAGE-1
ct state related,established accept
}
chain DOCKER-USER {
iif "eth0" oif "docker0" ct state new drop
return
}
}
table ip nat {
chain PREROUTING {
type nat hook prerouting priority -100; policy accept;
fib daddr type local jump DOCKER
}
chain DOCKER {
tcp dport 8080 dnat to 172.17.0.2:80
}
}
Що це означає: Ви бачите ту ж логічну структуру у термінах nftables: NAT у PREROUTING, фільтрація у forward та ваша політика DOCKER-USER.
Рішення: Якщо ваш дистрибутив нативно використовує nftables, розгляньте керування політикою Docker напряму в nft для послідовності — але переконайтеся, що Docker усе ще зберігає свої ланцюги стабільними.
Завдання 13: Підтвердити, чи порт доступний з зовнішньої точки
cr0x@server:~$ nc -vz -w 2 203.0.113.10 8080
Connection to 203.0.113.10 8080 port [tcp/*] succeeded!
Що це означає: Він досяжний. Жодні розмови «я думав, що фаєрвол…» цього не змінять.
Рішення: Якщо це не повинно бути досяжним, зупиніть трафік у DOCKER-USER або приберіть публікацію й перевірте знову. Потім перевірте IPv6 окремо.
Завдання 14: Окремо перевірити IPv6-експозицію
cr0x@server:~$ sudo ss -lnt | grep ':8080 '
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:*
LISTEN 0 4096 [::]:8080 [::]:*
Що це означає: Ви слухаєте також на IPv6. Якщо ваша група безпеки чи фаєрвол розглядає лише IPv4, ви можете мати випадкову IPv6-експозицію.
Рішення: Захистіть IPv6 на тих самих умовах або навмисно вимкніть його для сервісу. «Ми не використовуємо IPv6» — це не контроль.
Завдання 15: Прослідкувати проходження пакета за лічильниками (чи спрацювало ваше правило?)
cr0x@server:~$ sudo iptables -L DOCKER-USER -v -n
Chain DOCKER-USER (1 references)
pkts bytes target prot opt in out source destination
12 720 ACCEPT tcp -- * * 198.51.100.0/24 172.17.0.2 tcp dpt:80
55 3300 DROP all -- eth0 docker0 0.0.0.0/0 0.0.0.0/0 ctstate NEW
890 53400 RETURN all -- * * 0.0.0.0/0 0.0.0.0/0
Що це означає: Лічильники розповідають історію: правило allow спрацювало для 12 пакетів; drop активно блокує нові спроби.
Рішення: Якщо лічильники не рухаються — ви дивитеся не той ланцюг або не той інтерфейс. Перестаньте гадати; слідуйте за лічильниками.
Завдання 16: Перевірити мережі Docker і імена bridge-інтерфейсів
cr0x@server:~$ docker network ls
NETWORK ID NAME DRIVER SCOPE
c2f6b2a1c3c1 bridge bridge local
a7d1f9e2a5b7 appnet bridge local
cr0x@server:~$ ip link show | grep -E 'docker0|br-'
4: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
6: br-a7d1f9e2a5b7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
Що це означає: Мережі, створені користувачами, породжують інтерфейси br-*. Якщо ви написали правила лише для docker0, інші мережі можуть все ще бути відкриті.
Рішення: Оновіть політику фаєрволу, щоб охопити усі Docker bridge-інтерфейси, а не тільки дефолтний.
Швидкий план діагностики
Якщо підозрюєте, що порт контейнера відкритий (або «фаєрвол не працює»), не йдіть довгим шляхом. Зробіть це по порядку.
Перший крок: визначити поверхню експозиції
- Перелічіть опубліковані порти (
docker psз Ports) і шукайте0.0.0.0/::. - Перевірте сокети, що слухають (
ss -lntp), щоб підтвердити прив’язки й процеси. - Перевірте досяжність зовні (або з jump-хоста) за допомогою
ncабоcurl.
Другий крок: знайти, де ухвалюється рішення в netfilter
- Знайдіть правила DNAT у
iptables -t nat -S(або в nft таблиці nat). - Перевірте порядок у FORWARD і переконайтеся, що є перехід у
DOCKER-USERрано. - Корисні лічильники (
iptables -L -v) покажуть, які правила реально збігаються.
Третій крок: виправляйте найпростіше
- Краще звузити прив’язку (
-p 127.0.0.1:…) або взагалі прибрати-p. - Забезпечте базову політику у
DOCKER-USER: deny за замовчуванням для нових вхідних на Docker-бріджі; allowlist лише необхідні. - Повторно перевірте досяжність зовні і перевірте, що лічильники змінились як очікується.
Жарт #2: Якщо ви «лише відкриваєте порт на п’ять хвилин», ваші нападники пунктуальні.
Поширені помилки: симптом → корінь → виправлення
1) «UFW каже, що блокує, але контейнер все одно доступний»
Симптом: UFW блокує 8080, але nc з інтернету підключається.
Корінь: Трафік форвардиться до контейнера (FORWARD), тоді як UFW правила переважно стосуються INPUT. Правила Docker дозволяють форвард.
Виправлення: Застосуйте політику в DOCKER-USER. Або забороніть нові вхідні на Docker-бріджі за замовчуванням, або зробіть allowlist для конкретних джерел/портів.
2) «Я заблокував порт 8080 в INPUT, але він все одно працює»
Симптом: Правило DROP в INPUT для tcp/8080 не впливає.
Корінь: DNAT у PREROUTING змінює призначення на IP:порт контейнера; пакет більше не відповідає умовам INPUT для доставки хосту.
Виправлення: Фільтруйте у FORWARD (ідеально — у DOCKER-USER). Або не публікуйте на 0.0.0.0 спочатку.
3) «Експоновано лише IPv6, і ніхто не помітив»
Симптом: IPv4 захищено; IPv6-сканер знаходить відкритий порт.
Корінь: Docker опублікував на ::; IPv6-фаєрвол не еквівалентний; групи безпеки ігнорують IPv6.
Виправлення: Додайте правила для IPv6; прив’яжіть явно до IPv4 localhost, якщо потрібно; або вимикайте IPv6 усвідомлено.
4) «Після увімкнення nftables мережі Docker стали дивними»
Симптом: Опубліковані порти періодично не працюють, або правила не там, де ви чекаєте.
Корінь: Змішане керування: нативні nft-правила плюс iptables-nft трансляція плюс Docker-ланцюги. Пріоритет і порядок гачків відрізняються від очікувань.
Виправлення: Стандартизувати: або керувати послідовно через iptables (з урахуванням nft бекенду), або прийняти цілісну політику nftables, що поважає ланцюги Docker.
5) «Контейнери в кастомній мережі відкриті, хоча docker0 заблоковано»
Симптом: Правила для docker0 працюють, але контейнери на br-* доступні.
Корінь: Користувацькі мережі використовують інші bridge-інтерфейси; ваші правила не підходять їм.
Виправлення: Матчити по oifname "br-*" (nft) або додати інтерфейсні правила для кожного bridge, або згрупувати їх за допомогою ipset/nft set.
6) «Swarm опублікував порт по всьому кластеру»
Симптом: Сервіс, опублікований на одному вузлі, доступний на всіх вузлах.
Корінь: Swarm routing mesh (ingress) публікує порт по всьому кластеру; трафік форвардиться внутрішньо до активного завдання.
Виправлення: Використовуйте host mode для локальної публікації на вузлі, або застосуйте зовнішні обмеження (фаєрволи/баланси), і розглядайте Swarm ingress як спільну площину експозиції.
Три корпоративні міні-історії з практики
Міні-історія 1: Інцидент через неправильне припущення
Середня SaaS-компанія мігрувала легасі-сервіс у контейнери на парі хмарних VM. Команда мала звичку: закрити все на хості, потім відкрити лише для reverse proxy та SSH з VPN.
Під час міграції інженер опублікував порт для внутрішнього адмін-інтерфейсу: -p 8443:8443. Припущення було старим і розумним: «Хост-фаєрвол блокує, якщо ми не відкриємо». Вони цього не відкривали.
Через два дні команда безпеки помітила вихідний трафік до невідомого діапазону IP. Адмін-інтерфейс не був «зламаний» у голлівудському сенсі; він просто був досяжний. Інтерфейс мав basic auth, але також endpoint, що запускaв дорогі бекґраунд-роботи. Інтернет-рандом знайшов це і використав як безкоштовну майстерню для майнінгу.
Після інциденту звинувачування були передбачуваними. Апп-команда звинуватила фаєрвол, інфраструктура — апп-команду за публікацію порту. Обидві були наполовину праві — саме так і з’являються повторні інциденти.
Реальне виправлення було нудним: застосували default-drop для нових вхідних з’єднань до Docker-бріджів у DOCKER-USER і вимагали, щоб сервіси прив’язувалися до 127.0.0.1, якщо немає завдання на зовнішній доступ. Наступний адмін-інтерфейс опублікували локально і проксували через reverse proxy з відповідною автентифікацією та аудитом.
Міні-історія 2: Оптимізація, що відкотилася
Платформа, близька до торгівлі, гналася за затримками. Хтось помітив накладні витрати на обробку пакетів і запропонував «спростити фаєрвол», більше покладаючись на upstream security groups і менше — на хостові правила. У тому ж вікні вони змінили кілька sysctl ядра і прибрали, як їм здалося, «зайві» iptables-ланцюги.
Продуктивність справді покращилася — достатньо, щоб гарно виглядати в графіках. Потім виникла, здавалося б, несумісна проблема: сервіс у контейнері став доступним з підмережі, яка не повинна була мати доступ. Не публічний інтернет, але широка внутрішня підмережа з багатьма ноутбуками та цікавістю.
Корінь проблеми — не одна зміна, а їх поєднання. Прибравши фільтрацію форварду на хості і змінивши поведінку bridge netfilter, частина шляхів трафіка перестала потрапляти під потрібні точки контролю. Upstream security group все ще «виглядала правильно», але всередині VPC зона ураження зросла.
Вони не повністю відкотили оптимізації. Замість цього повернули контроль у DOCKER-USER з вузьким набором правил і вимірювали вплив на затримку чесно. Це було незначне навантаження. Зниження ризику — величезне.
Міні-історія 3: Нудна практика, яка врятувала день
Компанія у сфері охорони здоров’я мала просте правило: будь-який порт контейнера, опублікований не для localhost, має бути обґрунтований, задокументований і протестований з зовнішньої точки у межах зміни.
Це не було гламурно. Інженери бурчали. Але pipeline включав простий етап: розгортання на canary-хості, запуск зовнішніх перевірок доступності і відхилення зміни, якщо будь-які «мають бути приватні» порти були досяжні.
Одної п’ятниці власник сервісу тимчасово опублікував debug-порт на всі інтерфейси для тестів вендора. Запит на зміну це зазначав, але оцінка ризику була розмита. Pipeline спіймав це, бо порт був досяжний з несанкціонованої тестової мережі.
Виправлення було простим: опублікувати на 127.0.0.1 і підключитися через тимчасовий SSH-тунель з контрольованого бастіону. Вендор отримав тест, порт ніколи не став сюрпризним інтернет-ендпойнтом. Усі пішли додому вчасно — це й був реальний KPI.
Чеклисти / покроковий план
Базовий чекліст жорсткого захисту (один Docker-хост)
- Інвентар експозиції: перелічіть опубліковані порти (
docker ps) і сокети, що слухають (ss -lntp). - Визначте намір для кожного порту: публічний, лише VPN, внутрішній або localhost-only.
- Зробіть прив’язку явною: використовуйте
-p 127.0.0.1:HOST:CONTAINERдля localhost-only; вказуйте IP внутрішнього інтерфейсу, якщо потрібно. - Застосуйте базову політику в DOCKER-USER: за замовчуванням відкидати нові вхідні форвард-з’єднання.
- Allowlist для необхідного: додавайте вузькі ACCEPT перед drop — за діапазоном джерел, IP/портом контейнера та інтерфейсом де можливо.
- Перевірте IPv6: перевірте слухачі і парність правил фаєрволу; не думайте, що його немає.
- Збережіть правила: переконайтеся, що правила DOCKER-USER переживають перезавантаження (system-specific: iptables-persistent, конфігурація nftables тощо).
- Перевірте ззовні: підтвердіть, що досяжність відповідає наміру.
Покроково: перетворити «публічну публікацію» на «приватну за reverse proxy»
- Змінити публікацію на localhost:
- з
-p 8080:80на-p 127.0.0.1:8080:80.
- з
- Поставити перед цим reverse proxy на хості або в окремому edge-контейнері, який є єдиним інтернет-фаєм.
- Накласти базовий drop у DOCKER-USER для нових вхідних до bridge-інтерфейсів, щоб випадкова повторна публікація не стала миттєвим витоком.
- Додати автентифікацію, ліміти швидкості та логування на рівні reverse proxy; сервіси в контейнерах не повинні винаходити периферійну безпеку самостійно.
- Запустити зовнішню перевірку з
nc/curlз непогодженої мережі і упевнитися, що доступу немає.
Покроково: екстрене стримування при підозрі на експозицію
- Застосувати екстрений drop у
DOCKER-USERдля нових вхідних на docker-бріджі (скоповане по інтерфейсу), щоб зупинити витік. - Підтвердити зупинку досяжності через тест ззовні.
- Визначити опубліковані порти і прибрати або обмежити їх на рівні Docker run/compose.
- Замінити екстрений drop на allowlist, щоб потрібні сервіси лишилися доступні.
- Провести аудит IPv6 і застосувати аналогічну ізоляцію там.
Питання й відповіді (FAQ)
1) Чи означає EXPOSE у Dockerfile те саме, що публікація порту?
Ні. EXPOSE — це метадані. Публікація відбувається через -p/--publish або у compose через ports:. Саме публікація створює досяжність з хоста.
2) Чому мій ланцюг INPUT не бачить трафік до опублікованих портів контейнерів?
Тому що після DNAT трафік маршрутизується на IP контейнера і стає форварденим. Його зазвичай фільтрують у FORWARD, а не в INPUT.
3) Де слід розміщувати політику хоста для трафіку Docker?
У DOCKER-USER. Це спроектовано як стабільна точка вставки, яку Docker не перезаписує при рестарті.
4) Якщо я прив’язую до 127.0.0.1, чи я в безпеці?
Ви в безпеці значною мірою. Прив’язка до localhost запобігає зовнішньому мережевому доступу на рівні сокета. Проте врахуйте insider-ризики, локальні компроміси й можливість проксування іншим процесом.
5) Чи завжди Docker використовує docker-proxy для опублікованих портів?
Ні. Поведінка залежить від версії Docker, можливостей ядра та конфігурації. Не припускайте єдиний шлях пакета — перевіряйте через ss та списки процесів.
6) Як IPv6 змінює картину?
Воно додає другу площину експозиції. Можна мати «захищений IPv4» і одночасно «відкритий IPv6». Перевіряйте слухачі й правила фаєрволу для обох стеків.
7) Я використовую nftables. Чи варто припинити використовувати iptables-команди?
Якщо ваша система запускає iptables з nft бекендом, команди iptables усе ще працюють, але можуть приховувати фінальний порядок правил. Для глибокого дебагу дивіться nft list ruleset.
8) А rootless Docker — чи уникає він цих пасток фаєрволу?
Rootless режим змінює мережеву поведінку і механіку публікації портів, і може зменшити радіус уразливості привілеїв демона. Він не позбавляє необхідності ясно мислити про те, що доступно і звідки.
9) Чи змінює Docker Compose щось у цій моделі?
Ніяких фундаментальних змін. Compose — зручніший інтерфейс для тих самих примітивів. Якщо в compose вказано ports: - "8080:80", ви публікуєте у світ, якщо не вказали IP.
10) Я довіряю лише cloud security groups. Чи можу я ігнорувати фаєрвол на хості?
Можете, поки security group не буде неправильно застосована, не скопійована з іншого середовища або змінена під тиском. Defense in depth — не лозунг; це те, що робить «один поганий день» не катастрофою.
Наступні кроки, які можна зробити сьогодні
- Аудит кожного хоста: запустіть
docker psіss -lntp. Запишіть, що прив’язано до0.0.0.0і::. - Встановіть базову політику вхідного трафіку для контейнерів: реалізуйте baseline drop для нових вхідних форвард-з’єднань у
DOCKER-USER, а потім робіть allowlist. - Зробіть публікацію свідомою: вимагайте явних IP-прив’язок у compose/сервісних визначеннях; за замовчуванням використовуйте localhost і маршрутуйте через edge proxy.
- Тестуйте як нападник: перевіряйте досяжність ззовні вашої мережі, включно з IPv6.
- Операціоналізуйте: збережіть правила, додайте CI/CD перевірки на випадкові опубліковані порти і трактуйте зміни фаєрволу/Docker як production-зміни з можливістю відкату.
Якщо ви запам’ятаєте одну річ: Docker не «умисно обходить» ваш фаєрвол. Він використовує ядро точно так, як воно спроєктоване. Ваше завдання — покласти політику там, куди ядро справді заглядає.