Правило, которое написали вы, и правило, которое написал Docker
Две программы редактируют один и тот же firewall, исходя из разных предположений, — и рассказывает вам об этом только одна из них. ufw пишет свои правила в цепочку INPUT таблицы filter — путь, которым идёт пакет, если он предназначен самому хосту. Docker пишет в таблицу nat и в цепочку FORWARD — путь, которым идёт пакет, если он предназначен куда-то ещё.
Проследите путь одного пакета — и разрыв станет очевиден. Кто-то в другой стране открывает соединение к вашему адресу на порту 5432. Пакет приходит, и первое, что он встречает, — это nat PREROUTING, которая отправляет его в цепочку Docker под названием DOCKER. Там правило DNAT переписывает адрес назначения на 172.17.0.2:5432 — это и есть контейнер. Пакет больше не адресован вашему серверу, поэтому ядро его перенаправляет, а не доставляет локально: он проходит через FORWARD, где Docker уже установил ACCEPT для трафика, идущего на опубликованный порт. Ни в один момент этого пути пакет не проходит через INPUT — единственную цепочку, которую фильтрует ufw. Ваше правило никогда не было неверным. Его просто никогда не спрашивали.
Вот почему симптом так сбивает с толку. ufw status verbose исправно докладывает Default: deny (incoming), разрешены только 80, 443 и ваш порт SSH, а в это время сканер с другого континента держит открытую сессию к вашей базе данных. Оба утверждения истинны одновременно. Firewall делает ровно то, что вы настроили, — с тем трафиком, который до него дошёл, а трафик, который на самом деле важен, до него просто не доходит.
Ничего из этого не баг, и Docker здесь ни при чём — он не проявляет небрежности. Движку контейнеров приходится программировать правила NAT и forwarding, иначе контейнеры вообще не достучатся до сети, а безопасно угадать, какие из правил вашего firewall были рассчитаны на них, он не может. Поэтому Docker поступает честно: управляет собственными цепочками и отдаёт вам выделенную цепочку — DOCKER-USER, которая проверяется раньше всего остального в FORWARD — и обещает никогда её не перезаписывать. Разрыв не в самом существовании этого механизма. Разрыв в том, что -p 5432:5432 читается как «сделать доступным», а означает «опубликовать это на каждом адресе, на который отвечает эта машина, в обход firewall, на который вы потратили час».
Как открытый порт выглядит снаружи
Интернет замечает быстрее, чем принято думать. Хостинговые диапазоны сканируются непрерывно и исчерпывающе — не потому, что кто-то заинтересовался лично вами, а потому что коммерческие краулеры, исследовательские проекты и оппортунистические ботнеты прочёсывают каждый маршрутизируемый адрес по каждому интересному порту и публикуют или продают результат. Промежуток между docker compose up -d и первым незваным подключением к свежеопубликованному порту базы данных измеряется часами. Никому не нужно было угадывать имя вашего хоста. Никому не нужно было знать ваше имя. Адрес просто оказался в диапазоне.
Что именно найдут — целиком зависит от того, что вы опубликовали, а типичные случаи безрадостны. Контейнер PostgreSQL или MySQL, поднятый по стартовому сниппету из образа, — с тем же тривиальным паролем, что был в этом сниппете. Инстанс Elasticsearch или MongoDB, для которого аутентификацию вообще не настраивали, потому что предполагалось, что до него дотянется только контейнер приложения. memcached, отвечающий кому угодно, — это не только утечка данных, но и усилитель, который можно направить на кого-то ещё, превратив ваш сервер в участника того самого флуда, что описан в нашем runbook на первый час DDoS-атаки. Админ-панель или дашборд очереди сообщений, рассчитанные на приватную сеть. Эндпоинт с метриками, невозмутимо перечисляющий каждый внутренний сервис, имя хоста и версию, которую вы используете.
Стоит отдельно назвать вторичный ущерб, потому что первичный — это ещё не весь счёт. База данных, доступная из интернета, не просто читается — в большинстве движков она ещё и доступна для записи, а значит, злоумышленнику не нужен дополнительный эксплойт, чтобы закрепиться в системе, а некоторые движки несложно заставить писать файлы или выполнять команды на хосте прямо из привилегированной сессии. На машине, которую вы выбрали именно потому, что за ней не стоит ничья личность, плацдарм без аутентификации — это ещё и связь со всем остальным, до чего эта машина может дотянуться: цель для бэкапов, ключи в переменных окружения, другие контейнеры в том же bridge.
Неприятная часть в том, что ничего из этого не объявляет о себе само. Нет такой строки в логе, которая гласила бы «ваш firewall обошли». Сервис работает, приложение подключается, сайт открывается, а единственный внешний признак — счётчик соединений, за которым никто не следит. Эту брешь обнаружите либо вы сами — сознательно, в ближайшие десять минут, — либо кто-то другой, по своему собственному расписанию.
SP·03Прочитайте, что вы на самом деле публикуете
Начните с того, что, по мнению самого движка, происходит. docker ps печатает колонку PORTS, и различие внутри неё — это весь предмет разговора в этом руководстве: 0.0.0.0:5432->5432/tcp означает «любой адрес на машине», 127.0.0.1:5432->5432/tcp означает «только loopback», а голый 5432/tcp без стрелки означает, что порт виден другим контейнерам, но нигде не опубликован. Прочитайте каждую строку этой колонки для каждого контейнера, прежде чем что-либо менять.
Затем посмотрите на сокеты через ss -tulpen. На установке по умолчанию вы увидите docker-proxy, держащий опубликованные порты, — Docker всё ещё запускает небольшой процесс в userland на каждый опубликованный порт. Вот ловушка, которая стоит людям целого вечера: если userland-proxy отключён в вашем демоне — это распространённая настройка, и некоторые дистрибутивы поставляют её именно так, — на хосте вообще нет слушающего сокета. ss не показывает ничего, lsof не показывает ничего, а порт всё равно широко открыт, потому что всю работу делает правило DNAT в ядре, которому не нужен процесс, держащий адрес. Тихий вывод ss — не доказательство того, что порт закрыт.
Поэтому читайте сами правила. iptables -t nat -S DOCKER выводит по одной строке DNAT на каждый опубликованный порт, и в каждой строке есть нужный вам ответ: правило с -d 127.0.0.1/32 — это публикация на loopback, а правило без ограничения по адресу назначения применяется к любому адресу, который держит машина. Сделайте то же самое с ip6tables, потому что оба семейства настраиваются независимо, и бывает, что на одном заперто, а на другом настежь открыто.
И наконец — единственный шаг, который что-то реально доказывает, — посмотрите на машину откуда-то ещё. Все команды выше выполняются на самом хосте и наследуют его собственный взгляд на свою же сеть. Трафик loopback обходит стороной те самые цепочки, так что успешный curl 127.0.0.1:5432 ничего не говорит о том, сможет ли то же самое сделать посторонний, а неудачный — говорит ещё меньше. Достоверная проверка — это скан с другой машины из другой сети, по обоим семействам IP. Всё, что было до него, — не более чем гипотеза.
Привязка к loopback и разница между ports и expose
Самое маленькое полезное исправление — это одиннадцать символов. -p 127.0.0.1:5432:5432 говорит Docker записать своё правило DNAT с ограничением по адресу назначения, так что переписывание адреса срабатывает только для трафика, который и так был локальным. Удалённый пакет, нацеленный на ваш публичный адрес, больше не подходит под правило, не перенаправляется в контейнер и наконец-то попадает туда, куда вы всегда и предполагали: в INPUT, где ufw его отклоняет. В Compose-файле то же самое пишется как ports: ["127.0.0.1:5432:5432"], и кавычки здесь важны — значение с двоеточиями без кавычек рано или поздно сломает парсинг.
Но правильнее спросить, зачем вообще публиковать этот порт. Контейнеры, подключённые к одной и той же пользовательской сети, достучатся друг до друга напрямую — по имени сервиса, на собственном порту контейнера, вообще без какой-либо публикации. Ваше приложение подключается не к 127.0.0.1:5432, а к postgres:5432, который встроенный DNS Docker разрешает в адрес на приватном bridge. Базе данных в такой схеме не нужна строка ports: — ни в варианте с loopback, ни в каком-либо другом. Самый безопасный опубликованный порт — тот, который вы удалили. Оставляйте ports: только для одного-двух сервисов, которые по-настоящему смотрят наружу, а всему остальному давайте разговаривать в приватной сети.
Вот тут же чаще всего неверно понимают expose:. Она ничего не публикует и ничего не открывает; это просто документация, фиксирующая, какой порт слушает сервис, и на firewall она не влияет ни в одну, ни в другую сторону. Её добавляют, надеясь, что это безопасная версия ports: — и это правда, в том же смысле, в каком комментарий является безопасной версией кода. Если вам нужен сервис, доступный только своим соседям по сети, вам не нужна expose: — вам нужно отсутствие ports:.
У привязки к loopback есть два честных ограничения. Первое: она защищает границу хоста, а не соседей по сети, — контейнеры в одной и той же bridge-сети по-прежнему свободно достучатся друг до друга, так что у скомпрометированного фронтенд-контейнера остаётся прямой путь к базе данных, которая вообще ничего не публикует. Разносите сервисы по отдельным сетям и помечайте бэкенд-сеть как internal: true, когда радиус поражения имеет значение. Второе: 127.0.0.1 — это адрес IPv4, и он ограничивает только IPv4; если у хоста есть маршрутизируемый /64 — а он есть на каждом нашем тарифе, — думайте про v6 отдельно и тестируйте его отдельно.
DOCKER-USER: цепочка, которая существует именно для этого
Привязка к loopback чинит те контейнеры, о которых вы помните. DOCKER-USER — это способ перестать быть в одном -p от следующего инцидента. Docker устанавливает её первым прыжком в FORWARD, перед своими собственными разрешающими правилами, и — в отличие от всего остального в своих цепочках — не трогает её содержимое ни при перезапусках, ни при обновлениях, ни при появлении новых контейнеров. Это официально поддерживаемое место для политики, которую движок не в состоянии вывести сам: каким источникам вообще разрешено достучаться до контейнеров на этом хосте.
Схема — это три правила на публичном интерфейсе, и всё дело именно в их порядке. Сначала — return для установленных и связанных соединений, чтобы ответы на запросы, которые открыли ваши контейнеры, продолжали доходить. Затем — return для источников, которых вы действительно хотите впустить: адрес офиса, хост мониторинга, сервер-партнёр. И только потом — drop для всего остального, что приходит из интернета. Перепутать порядок с правилом conntrack — классическая здешняя ошибка: поставьте DROP первым — и каждое исходящее соединение из каждого контейнера умрёт на обратном пакете, а это выглядит как «Docker сломал DNS и установку пакетов» и отправляет людей искать причину совершенно не там.
Есть две практические детали, от которых зависит, переживёт ли это столкновение с реальностью. Правило обязано быть привязано по имени именно к публичному интерфейсу — -i eth0, или к тому, что показывает ip route get 1.1.1.1 на вашей машине, — иначе вы заодно начнёте дропать трафик между собственными bridge-сетями. И его обязательно нужно применять заново после каждой перезагрузки, причём после того, как демон Docker создаст цепочку. Надёжная форма — systemd-юнит с oneshot, упорядоченный через After=docker.service; сохранение через netfilter-persistent тоже работает, если вы принимаете, что восстановление целого набора правил, который Docker в этот же момент пересобирает, — это гонка, которую стоит проверить, а не принимать на веру.
Теперь — то же самое для IPv6. Если ip6tables -S DOCKER-USER выводит цепочку, зеркально повторите в ней каждое правило. Если выдаёт ошибку — ваш демон вообще не управляет правилами v6, а это ничего не говорит о том, доходит ли трафик v6 до ваших контейнеров, — лишь то, что Docker его не фильтрует. Не пытайтесь додумать ответ логически: пути отличаются в зависимости от версии, настроек демона и дистрибутива, а уверенный неверный вывод хуже, чем отсутствие вывода. Просканируйте себя через nmap -6 и поверьте результату.
Чего делать не стоит: тянуться к "iptables": false в /etc/docker/daemon.json. Это останавливает Docker от вмешательства в firewall, но заодно останавливает и настройку NAT для контейнеров, исходящего masquerading и изоляции между сетями — их больше вообще некому настраивать. Вы не убрали проблему, а унаследовали её как работу — вручную, для каждого контейнера, навсегда. На одном VPS честный выбор — это позволить Docker управлять своими цепочками, а себе оставить DOCKER-USER.
Другая дверь: сокет и то, что работает от root
Всё, о чём шла речь выше, — про приходящие пакеты. Этот раздел — про то, что они там находят, и есть один пункт настолько хуже остальных, что его стоит выделить отдельно: смонтировать /var/run/docker.sock в контейнер — то же самое, что выдать этому контейнеру root на хосте. Не «почти то же самое». Именно то же самое. Всё, что способно говорить с этим сокетом, может запустить новый привилегированный контейнер с примонтированной внутрь файловой системой хоста, а дальше — читать любой ключ, писать любой файл и ставить что угодно. За доступ к нему просят многие удобные образы — дашборды, авто-апдейтеры, reverse proxy со service discovery. Относитесь к такой просьбе как к решению доверять образу настолько же, насколько вы доверяете собственному root-шеллу, и проявляйте ту же осторожность с Docker API по TCP: демон без аутентификации на порту 2375 — та же самая дверь, только открытая всему интернету.
Помимо этого, контейнерный рантайм даёт вам четыре дешёвых способа снизить риск, и ни один не требует ничего перестраивать. Запускайте от имени непривилегированного пользователя через user: "1000:1000", потому что по умолчанию внутри namespace вы root, а это отправная точка для любого побега из контейнера. Задайте security_opt: ["no-new-privileges:true"] — это не даёт setuid-бинарнику внутри образа получить больше прав, чем было у запустившего его процесса. Сбросьте все capabilities через cap_drop: [ALL] и верните только то, что сервису доказанно необходимо, — большинству веб-приложений не нужно ничего. Монтируйте корневую файловую систему как read_only: true и выделите ей небольшой tmpfs под временные файлы — тогда «подбросить веб-шелл в директорию приложения» превращается из шага в тупик.
Та же логика касается и того, что передаётся контейнеру. Секреты, переданные через переменные окружения, видны всему, что способно прочитать окружение процесса, и добросовестно копируются в вывод docker inspect и в любой лог или отчёт о сбое, который выгружает конфигурацию; файл, примонтированный только для чтения по известному пути, — менее удобно, зато заметно менее дыряво. И контейнеру не нужно больше сети, чем требует его работа: у воркера, который общается только с базой данных, нет никаких причин уметь открывать соединения в интернет вообще.
Если всё это ощущается как борьба с поведением по умолчанию — это справедливое ощущение, и оно же аргумент в пользу rootless-режима Docker или Podman: там демон и контейнеры работают от имени непривилегированного пользователя, побег из контейнера приводит атакующего именно к этому пользователю, а не к root, а опубликованные порты держит обычный процесс в userland на хосте — а значит, ваши правила ufw применяются к ним самым обычным образом. Плата за это реальна: часть capabilities, часть storage-драйверов и часть сетевых трюков либо работают иначе, либо не работают вовсе. Стоит знать, что такой выбор существует и что он даёт, — а не узнавать после инцидента, что поведение по умолчанию тоже было чьим-то выбором.
SP·07Образы, которые писали не вы, на машине, которую нельзя позволить себе потерять
Образ контейнера — это чужая файловая система, работающая на вашей машине, собранная из слоёв, которые вы не читали. Это не аргумент против их использования — это аргумент за то, чтобы знать, какие именно вы используете и насколько они устарели. На небольшой инфраструктуре реально случаются два отказа, и оба банальны: либо образу изначально нельзя было доверять, либо ему можно было доверять в марте, а с тех пор его никто не пересобирал.
Первый отказ в основном решается дисциплиной в выборе источника образов. Предпочитайте официальные репозитории или репозитории производителя удобному форку с тремя звёздами, и относитесь с особым подозрением к образам, вся привлекательность которых в том, что они упаковывают шесть сервисов в одну строку YAML. Там, где это важно, закрепляйте версию по digest, а не по тегу: postgres:17 — подвижная цель, которая может измениться у вас под ногами между двумя запусками docker compose pull, тогда как postgres@sha256:… — это ровно та файловая система, которую вы тестировали. Закрепление по digest меняет автоматические исправления на воспроизводимость, и это правильный обмен, если у вас есть привычка пересобирать образы, и неправильный, если её нет.
Второй отказ кусает тихо. Автоматические обновления не трогают ваши контейнеры. Автоматические обновления безопасности, которые вы настроили в первый час, патчат пакеты хоста и вообще не видят userland внутри образа, — так что машина, которая рапортует о полной пропатченности, вполне может гонять веб-сервер из базового образа с годом непропатченных уязвимостей внутри. Контейнеры не обновляют, их заменяют: тяните новый образ, пересоздавайте контейнер и убирайте то, на что больше нет ссылок, — на том ритме, который вы реально соблюдаете. Раз в месяц, записанное на бумаге, бьёт идеальное намерение.
Из-за этого Compose-файл становится самым ценным объектом на сервере. Это единственное полное описание того, чем является эта машина, и пересборка по нему должна быть рутинной операцией, а не археологической экспедицией, — держите его в системе контроля версий, держите рядом файлы окружения и храните и то, и другое в том же зашифрованном бэкапе вне сервера, где лежат ваши данные. Проверка контейнерного хоста — не в том, работает ли он сейчас. Она в том, сможете ли вы поднять его заново, один в один, на свежем VPS примерно за 15 min.
SP·08Логи, тома и состояние, которое переживёт контейнер
Контейнеры рекламируют как одноразовые — и это правда для самого процесса, но неправда для всего, что он после себя оставляет. На хосте с Docker накапливаются состояния двух видов, и оба застигают врасплох в самый неподходящий момент.
Первое — это логи. Драйвер json-file, используемый по умолчанию, захватывает каждую строку, которую ваши контейнеры пишут в stdout и stderr, и — если не сказать ему иначе — никогда их не ротирует. Болтливое приложение может забить этим диск за считаные недели, а полный диск на хосте базы данных — это уже собственный инцидент. Один раз задайте потолок в /etc/docker/daemon.json — он будет применяться ко всему, что вы запустите после этого.
У этой же настройки есть и сторона приватности, и на сервере, который вы выбрали ради приватности, она, пожалуй, важнее. Неротируемый лог доступа — это постоянная, неиндексированная запись IP-адреса, user agent и пути запроса каждого посетителя, лежащая на диске, который физически не под вашим контролем. Вы не решали это хранить. За вас решили настройки по умолчанию. Решать осознанно — значит ограничить срок хранения, а ещё лучше — обрезать то, что вообще записывается: формат лога на прокси, который опускает или усекает адрес клиента, сохраняет всю операционную пользу и снимает риск. Данные, которых вы никогда не писали, нельзя ни изъять, ни затребовать по повестке, ни слить, — и это та же логика, по которой люди в принципе стараются не оставлять своё имя на сервере.
Второе — это тома, и острый край здесь — это всего один флаг. docker compose down останавливает и удаляет контейнеры, не трогая именованные тома; docker compose down -v удаляет и их тоже, безвозвратно и с той же уверенностью. В этих томах живут базы данных. И всё остальное, о потере чего вы бы пожалели. Храните данные в именованных томах, а не в анонимных, точно знайте, какую команду вы набираете в два часа ночи, и помните, что docker system prune существует, чтобы освобождать место, и с радостью освободит в том числе ваше.
Остаётся сам бэкап, и здесь контейнерная абстракция в последний раз вводит в заблуждение. Копирование директории тома живой базы данных — это не бэкап; это копия файлов, в которые в момент чтения ещё шла запись, и восстанавливается она в виде повреждённой базы данных именно тогда, когда она нужнее всего. Делайте бэкап через сам движок — pg_dump, mysqldump, собственный API снапшотов движка, — а затем шифруйте результат и отправляйте туда, куда ваш сервер дотянуться не может. Вот эта последняя оговорка и есть та, что имеет значение, когда злоумышленник уже внутри контейнера: бэкап, который может удалить ваш же скомпрометированный хост, — тоже не бэкап.
Шаг за шагом
-
01
Проведите инвентаризацию того, что машина публикует прямо сейчас
Прежде чем что-либо менять, зафиксируйте текущее состояние — вам ещё пригодится, с чем сравнивать. Прочитайте колонку
PORTSдля каждого контейнера, затем прочитайте правила NAT, которые Docker реально установил, — по обоим семействам IP. Всё, что выводится без адреса назначения127.0.0.1, доступно из интернета, что бы ни утверждалufw status.docker ps --format 'table {{.Names}}\t{{.Ports}}' ss -tulpen | grep -E 'docker|LISTEN' sudo iptables -t nat -S DOCKER # -d 127.0.0.1/32 = loopback only sudo ip6tables -t nat -S DOCKER # may not exist; that is an answer too sudo ufw status verbose # what you believed was trueЗаодно запишите имя публичного интерфейса — он понадобится дальше для правил firewall, а
eth0есть далеко не на каждом образе.ip route get 1.1.1.1 | awk '{print $5; exit}' -
02
Просканируйте себя откуда-то ещё
Хост не может проверить сам себя: трафик loopback никогда не проходит через те цепочки, которые вы пытаетесь протестировать. Запускайте проверку с другой машины в другой сети — второй VPS от $8.00/мес в другом регионе — честный тестовый стенд, который и потом останется полезен. Сканируйте оба семейства, потому что они настраиваются отдельно друг от друга.
# from ANOTHER machine, against your server nmap -Pn -sS -p- --min-rate 1000 203.0.113.10 nmap -Pn -6 -p 22,80,443,3306,5432,6379,9200,27017 2001:db8::1 # no nmap? one port at a time is enough to settle an argument nc -zv 203.0.113.10 5432
Каждый открытый порт, который не 80, не 443 и не ваш порт SSH, — это находка. Сохраните вывод: это половина доказательства «до», вторая половина понадобится вам в конце.
-
03
Перестаньте публиковать то, чему не нужно быть публичным
Теперь исправьте это у источника, в Compose-файле. Бэкенд-сервисы переезжают в приватную сеть и полностью теряют строку
ports:— приложение достучится до них по имени сервиса. Всё, что должно быть доступно с самого хоста, получает явную привязку к loopback вместо голого порта. Пометьте бэкенд-сеть какinternal, чтобы ничто в ней не могло само по себе заговорить с интернетом.services: db: image: postgres:17 # ports: ["5432:5432"] # deleted: the app reaches it as db:5432 networks: [back] volumes: [dbdata:/var/lib/postgresql/data] app: image: myapp:1.4 environment: DATABASE_URL: postgres://app@db:5432/app ports: ["127.0.0.1:8080:8080"] # loopback only; proxy sits in front networks: [back, front] networks: front: {} back: internal: true volumes: dbdata: {}Пересоздайте стек и убедитесь, что правила NAT изменили форму — строка DNAT для приложения теперь должна нести адрес назначения loopback, а у базы данных не должно остаться вообще никакой строки.
docker compose up -d docker ps --format 'table {{.Names}}\t{{.Ports}}' sudo iptables -t nat -S DOCKER -
04
Поставьте перед публикой ровно одну вещь
Когда всё сидит на loopback, единственной публичной поверхностью становится один reverse proxy — единственное место, где терминируется TLS, единственное место, где вообще виден адрес клиента, и единственное место, где решается формат лога. Запускайте его на хосте или в контейнере, который публикует 80 и 443 и больше ничего; если он работает в контейнере, пусть присоединяется к сети
frontи проксирует на имена сервисов, а не на loopback.# in the http{} block of /etc/nginx/nginx.conf — no client address recorded log_format privacy '- - [$time_local] "$request" $status $body_bytes_sent'; # /etc/nginx/conf.d/app.conf (proxy on the host) server { listen 443 ssl; listen [::]:443 ssl; server_name example.com; access_log /var/log/nginx/app.log privacy; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } }Если цель всего упражнения в том, чтобы никто вообще не узнал, где стоит машина, — самое место прокси совсем на другой машине: это устройство разобрано в материале скройте IP источника за reverse proxy, и оно прекрасно сочетается со всем, что описано здесь.
-
05
Закройте разрыв через DOCKER-USER — для обоих семейств
Привязки к loopback чинят сегодняшние контейнеры; это чинит те, что вы ещё не написали. Три правила на публичном интерфейсе: сначала conntrack, чтобы исходящий трафик контейнеров продолжал работать, затем разрешённые источники, затем drop. Проверьте исходящую связность контейнера сразу после применения правил — именно этот порядок люди чаще всего путают.
PUB=$(ip route get 1.1.1.1 | awk '{print $5; exit}') sudo iptables -I DOCKER-USER 1 -i "$PUB" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN sudo iptables -I DOCKER-USER 2 -i "$PUB" -s 203.0.113.7/32 -j RETURN # your admin address sudo iptables -I DOCKER-USER 3 -i "$PUB" -j DROP sudo iptables -S DOCKER-USER docker run --rm alpine sh -c 'apk update' >/dev/null && echo 'egress OK'Зеркально повторите то же самое для IPv6, если демон вообще управляет правилами v6, а затем сделайте так, чтобы это переживало перезагрузку, — юнитом, который запускается уже после того, как Docker пересоберёт свои цепочки.
sudo ip6tables -S DOCKER-USER || echo 'no v6 chain: verify v6 reachability by scanning' # /etc/systemd/system/docker-user-rules.service [Unit] After=docker.service Requires=docker.service [Service] Type=oneshot ExecStart=/usr/local/sbin/docker-user-rules.sh RemainAfterExit=yes [Install] WantedBy=multi-user.target
-
06
Урежьте привилегии внутри контейнеров и ограничьте логи
Сократите то, что способен сделать скомпрометированный образ, и остановите накопление на хосте записей, которые никто сознательно не решал хранить. Все четыре настройки контейнера ничего не стоят для обычного веб-сервиса; потолок логов применяется к каждому контейнеру, запущенному после перезагрузки демона.
services: app: image: myapp:1.4 user: "1000:1000" read_only: true tmpfs: [/tmp] cap_drop: [ALL] security_opt: ["no-new-privileges:true"] # never: volumes: ["/var/run/docker.sock:/var/run/docker.sock"]# /etc/docker/daemon.json { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }sudo systemctl reload docker docker inspect --format '{{.HostConfig.LogConfig}}' app -
07
Проверьте снаружи и запишите runbook
Перезагрузите машину намеренно, пока от ответа ничего не зависит, и докажите, что построенное вами состояние — это именно то состояние, которое возвращается после перезагрузки. Затем повторите внешнее сканирование из второго шага и сравните его с сохранённым выводом — результат этого сравнения и есть искомое, а не сами команды.
sudo reboot # after it returns: sudo iptables -S DOCKER-USER # rules reapplied? docker ps --format 'table {{.Names}}\t{{.Ports}}' # from ANOTHER machine again: nmap -Pn -p- 203.0.113.10 nmap -Pn -6 -p- 2001:db8::1Запишите где-нибудь, где точно найдёте, пять строк: к какому интерфейсу привязаны правила, где лежит юнит, какие сервисы публичны намеренно, когда вы в последний раз пересобирали образы и как восстановить тома. Контейнерный хост хорош ровно настолько, насколько хорошо описание, которое позволяет пересобрать его после самого худшего дня.


