Илья Овсенев
← Все статьи

Содержание

  1. Исходные данные
  2. 1. Установка
  3. 2. Группа docker
  4. 3. Ловушка: Docker обходит UFW
  5. 4. Решение 1: публиковать порты только на localhost
  6. 5. Решение 2: страховка в DOCKER-USER
  7. 6. Инцидент: iptables-persistent удалил UFW
  8. 7. Логи контейнеров и live-restore
  9. 8. Проверка после перезагрузки
  10. Итог: чеклист

Docker на сервере Ubuntu 26.04: установка и UFW

Шпаргалка по Docker на сервере, который уже настроен по статье о первичной настройке и защите. Что внутри: установка из официального репозитория, группа docker и почему она равна root, ловушка «Docker обходит UFW» и два способа её закрыть, ограничение логов контейнеров — и инцидент, после которого сервер остался с открытым периметром. Это не экспертный гайд, а то, что я сам сделал и на чём споткнулся.

Исходные данные

Сервер после первичной настройки: Ubuntu 26.04, пользователь demo с sudo, вход по ключу, UFW открыт на SSH и 80/443 (Caddy, статья о reverse proxy), fail2ban. Схема сайта: reverse proxy Caddy работает на хосте и слушает 80/443, приложение — в контейнере на 127.0.0.1:3000. <IP-сервера> подставляем свой.

💡 Все команды выполняем от demo, где нужен root — с sudo.


1. Установка

Docker запускает приложения в контейнерах: приложение со всеми зависимостями упаковано в образ и работает изолированно от системы. Обновить или откатить приложение — заменить контейнер, на хосте при этом ничего не меняется.

1.1. Какой пакет ставить

Зачем выбирать: Docker есть в двух местах, и пакеты из них конфликтуют — ставим только из одного.

ОткудаПакетыВерсия на 2026-10-15
Репозиторий Ubuntudocker.io, docker-compose-v229.1.3
Официальный репозиторий Dockerdocker-ce и плагины29.9.1

Пакет Ubuntu отстаёт на несколько минорных версий, но рабочий, и docker compose в нём тоже есть (пакет docker-compose-v2). Я ставлю из официального репозитория: свежие версии и исправления приходят сразу, а инструкции в документации Docker пишутся под него.

Проверяем, что не стоит ничего, что конфликтует с пакетами Docker (список — из официальной инструкции):

dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc 2>/dev/null

dpkg --get-selections — какие из перечисленных пакетов установлены; 2>/dev/null — скрыть сообщения no packages found про отсутствующие. Root не нужен.

  • ✅ Пустой вывод — ничего мешающего нет
  • ❌ Есть строки с install → удалить эти пакеты: sudo apt remove и их имена. docker.io конфликтует с docker-ce, а containerd и runc из Ubuntu — с containerd.io от Docker

1.2. Ключ репозитория

Зачем: apt ставит пакеты только из репозиториев, подписи которых может проверить. Чтобы он доверял репозиторию Docker, кладём на сервер ключ, которым Docker подписывает пакеты.

sudo apt install ca-certificates curl

ca-certificates — корневые сертификаты для HTTPS, curl — скачать ключ. На Ubuntu Server оба уже стоят, apt ответит is already the newest version.

sudo install -m 0755 -d /etc/apt/keyrings

install -d — создать папку, -m 0755 — сразу с правами «владелец пишет, все читают». Сюда кладут ключи сторонних репозиториев.

sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
  • curl -fsSL — скачать: -f — при ошибке HTTP не сохранять страницу ошибки вместо ключа, -s — без прогресса, -S — но ошибки показать, -L — идти по редиректам;
  • -o …/docker.asc — сохранить в файл;
  • chmod a+r — всем право чтения: apt читает ключ не от root.

1.3. Репозиторий

Зачем: ключ есть, теперь говорим apt, откуда брать пакеты и каким ключом их проверять.

sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null << EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

Описание репозитория в формате deb822 — в нём же лежат системные репозитории Ubuntu (/etc/apt/sources.list.d/ubuntu.sources):

  • URIs — адрес репозитория;
  • Suites — релиз Ubuntu; $(. /etc/os-release && echo "$VERSION_CODENAME") подставляет его кодовое имя, для 26.04 — resolute;
  • Components: stable — только стабильные версии;
  • Architectures — только архитектура сервера (amd64); без строки apt будет спрашивать и другие включённые архитектуры и писать Skipping acquire…;
  • Signed-By — каким ключом проверять: только этим, а не любым из системных.

sudo tee файл > /dev/null — записать текст в файл, куда пишет только root; > /dev/null — не дублировать текст на экран.

💡 Здесь EOF без кавычек: подстановка $(...) должна выполниться. В статье о первичной настройке (харденинг SSH) было 'EOF' в кавычках — там подстановки не нужны.

sudo apt update

Обновить списки пакетов — теперь вместе с репозиторием Docker.

  • ✅ В выводе есть строка https://download.docker.com/linux/ubuntu resolute InRelease
  • ❌ 404 Not Found для resolute → Docker ещё не выпустил пакеты под этот релиз, ставим docker.io из Ubuntu
  • ❌ NO_PUBKEY → ключ не скачался или не читается, повторить 1.2

1.4. Пакеты

sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
ПакетЧто это
docker-ceДемон dockerd — служба, которая управляет контейнерами
docker-ce-cliКоманда docker
containerd.ioСреда, которая непосредственно запускает контейнеры
docker-buildx-plugindocker build (сборка образов)
docker-compose-plugindocker compose — запуск нескольких контейнеров по compose.yaml

⚠️ Без -y. Перед подтверждением прочитать блок The following packages will be REMOVED — почему это важно, в разделе 6.

1.5. Проверка

sudo docker version

Версии клиента (docker) и сервера (dockerd). Сервер отвечает, только если служба запущена — apt запускает её и включает в автозагрузку сам.

  • ✅ Есть блоки Client и Server
  • ❌ Cannot connect to the Docker daemon → sudo systemctl status docker

2. Группа docker

Что это: docker без sudo работает только у участников группы docker — так Docker решает, кому можно управлять демоном. Удобно, но у удобства есть цена, и решить надо до того, как добавлять.

sudo usermod -aG docker demo
  • -G docker — добавить в группу docker;
  • -a — добавить к существующим группам.

⚠️ Без -a пользователь будет удалён из всех остальных групп, в том числе из sudo.

Группа применится при новом входе — перезайти по SSH. Проверка:

id | grep -o docker && docker ps

id — группы текущего пользователя, grep -o docker — вывести слово docker, если оно там есть; && — только тогда выполнить docker ps (список контейнеров) без sudo.

  • ✅ docker и заголовок таблицы CONTAINER ID …
  • ❌ Пусто → не перезашёл по SSH

⚠️ Группа docker = права root. Любой участник группы может выполнить:

docker run -v /:/host -it ubuntu chroot /host

— смонтировать весь диск хоста в контейнер и войти в него — и получить root-оболочку на хосте без пароля sudo и без записи в auth.log. Второй фактор из статьи о первичной настройке (пароль sudo) для такого пользователя больше не работает: защита держится только на SSH-ключе.

Варианты:

  • В группе — удобно, но утёкший ключ demo = root на сервере.
  • Без группы, sudo docker … — на пару символов длиннее, зато пароль sudo остаётся вторым фактором.

Пользователей для CI и автодеплоя в группу docker не добавлять никогда: ключ CI лежит в чужой системе (GitHub, GitLab), и если он утечёт, у атакующего будет root на сервере. Для деплоя — отдельный пользователь с правами только на папку сайта.


3. Ловушка: Docker обходит UFW

Зачем проверять: кажется, что UFW закрывает всё, кроме разрешённого. Для портов контейнеров это не так — и ufw status этого не покажет.

Проверено на практике. Запускаем тестовый контейнер с опубликованным портом:

sudo docker run -d --name test -p 8080:80 nginx
sudo ufw status
  • docker run nginx — скачать образ веб-сервера nginx и запустить контейнер;
  • -d — в фоне, --name test — имя, чтобы потом удалить;
  • -p 8080:80 — опубликовать порт: запросы на порт 8080 хоста передавать в порт 80 контейнера.

ufw status показывает 22, 80 и 443 — порта 8080 в нём нет. А он при этом доступен из интернета.

У меня такой вывод:

Status: active

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW       Anywhere
80/tcp                     ALLOW       Anywhere                   # http
443/tcp                    ALLOW       Anywhere                   # https
443/udp                    ALLOW       Anywhere                   # http/3
...

Почему. Docker при -p 8080:80 создаёт правило DNAT в цепочке PREROUTING. Пакет переписывается до маршрутизации: к моменту, когда до него могли бы дойти правила UFW в цепочке INPUT, он уже адресован контейнеру и идёт через FORWARD. Там переходы Docker (DOCKER-USER, DOCKER-FORWARD) стоят первыми, раньше цепочек ufw-*, и пропускают пакет — до правил UFW он не доходит.

Проверять только снаружи — с локальной машины:

curl.exe -m 5 -I http://<IP-сервера>:8080

-m 5 — ждать не больше 5 секунд, -I — только заголовки ответа.

  • ❌ HTTP/1.1 200 OK → порт открыт в интернет
  • ✅ Connection timed out → закрыт

У меня такой вывод:

HTTP/1.1 200 OK
Server: nginx/1.31.7
Content-Type: text/html
Content-Length: 896
...

200 OK от nginx из контейнера — порт 8080 отвечает снаружи, хотя UFW его не пропускал.

Убираем тестовый контейнер:

sudo docker rm -f test

rm -f — остановить и удалить контейнер одной командой.


4. Решение 1: публиковать порты только на localhost

Зачем: если порт контейнера слушает только 127.0.0.1, из интернета до него не достучаться вообще — неважно, что делает firewall. Наружу его отдаёт reverse proxy на хосте, а порты reverse proxy (80/443) UFW видит и контролирует. Это основное правило — всегда.

В docker run — адрес перед портом:

sudo docker run -d --name test-local -p 127.0.0.1:8080:80 nginx

--name test-local — имя, чтобы потом удалить контейнер по нему; -p 127.0.0.1:8080:80 — порт 8080 только на localhost.

В compose.yaml:

ports:
  - "127.0.0.1:3000:3000"   # ✅ только с хоста
  - "3000:3000"             # ❌ = 0.0.0.0:3000, в интернет

Без адреса Docker публикует порт на всех адресах (0.0.0.0 и [::]).

Проверка — на каких адресах опубликованы порты всех контейнеров:

sudo docker ps --format '{{.Names}}  {{.Ports}}'

--format — вывести только имя контейнера и его порты.

  • ✅ 127.0.0.1:3000->3000/tcp
  • ❌ 0.0.0.0:3000->3000/tcp или [::]:3000->3000/tcp → порт опубликован на всех адресах, исправить ports и пересоздать контейнер

Убираем тестовый контейнер — иначе он держит порт 8080, и тест в 5.3 не запустится:

sudo docker rm -f test-local

5. Решение 2: страховка в DOCKER-USER

Зачем: решение 1 держится на внимательности: один забытый 127.0.0.1: в compose — и порт снова в интернете. Страховка — правило firewall, которое режет все новые соединения из интернета к контейнерам, что бы ни было написано в ports.

Ставим его в цепочку DOCKER-USER — Docker создаёт её пустой, проверяет до своих правил и никогда не перезаписывает. Правила в остальных своих цепочках Docker при перезапуске затирает.

5.1. Имя внешнего интерфейса

Зачем: правило режет трафик, пришедший из интернета, — для этого нужно знать, через какой сетевой интерфейс он приходит.

ip -br link

Список сетевых интерфейсов, -br — кратко, по строке на интерфейс.

Внешний интерфейс — не lo и не docker0/br-…/veth… (это внутренние сети Docker). На моём сервере — eth0, у других провайдеров бывает ens3, enp1s0. Дальше везде подставляем своё имя.

5.2. Правило

sudo iptables -I DOCKER-USER -i eth0 -m conntrack --ctstate NEW -j DROP
  • -I DOCKER-USER — вставить правило в начало цепочки;
  • -i eth0 — только для пакетов, пришедших из интернета;
  • -m conntrack --ctstate NEW — только новые входящие соединения;
  • -j DROP — отбросить.

⚠️ --ctstate NEW обязателен. Без него правило отбросит и ответы на исходящие соединения контейнеров — они потеряют доступ в интернет (apt, npm install, внешние API).

⚠️ Правило только для IPv4. Пока IPv6 в сетях Docker не включён (по умолчанию — нет), этого достаточно: порты на [::] обслуживает процесс на хосте, и их закрывает UFW. Если включаешь IPv6 для контейнеров — то же правило через ip6tables и вторая строка в юните (5.4).

Reverse proxy на хосте это правило не задевает: он ходит к контейнеру через 127.0.0.1, а не через eth0.

5.3. Проверка по счётчикам

Зачем: правило может быть синтаксически верным, но не стоять на пути трафика (например, не тот интерфейс). Счётчики показывают, срабатывает ли оно на самом деле.

sudo iptables -L DOCKER-USER -n -v

-L DOCKER-USER — правила цепочки, -n — адреса цифрами, без DNS, -v — со счётчиками пакетов (pkts) и байтов.

Запускаем тот же тестовый контейнер, что в разделе 3, — порт 8080 на всех адресах, ровно то, от чего страхует правило:

sudo docker run -d --name test -p 8080:80 nginx

С локальной машины стучимся снаружи — как в разделе 3:

curl.exe -m 5 -I http://<IP-сервера>:8080

И снова счётчики:

sudo iptables -L DOCKER-USER -n -v
  • ✅ Счётчик pkts у правила DROP вырос, curl.exe — таймаут → правило на пути и работает
  • ❌ pkts 0 при попытках снаружи → правило не на пути трафика (не тот интерфейс?)

Убираем тестовый контейнер — иначе он так и останется на 0.0.0.0:8080:

sudo docker rm -f test

💡 Универсальный приём отладки firewall: не рассуждать о логике правил, а смотреть, какие счётчики двигаются.

5.4. Сохранение после перезагрузки

Зачем: правило, добавленное командой, живёт только до перезагрузки. Сохраняем его systemd-юнитом, который добавляет правило при каждом старте Docker. Не через iptables-persistent — почему, в разделе 6.

sudo tee /etc/systemd/system/docker-user-rule.service > /dev/null << 'EOF'
[Unit]
Description=Add DOCKER-USER firewall rule
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'iptables -C DOCKER-USER -i eth0 -m conntrack --ctstate NEW -j DROP 2>/dev/null || iptables -I DOCKER-USER -i eth0 -m conntrack --ctstate NEW -j DROP'

[Install]
WantedBy=multi-user.target
EOF
  • After= + Requires=docker.service — запускаться после Docker и только вместе с ним: цепочку DOCKER-USER создаёт Docker при старте;
  • Type=oneshot — команда выполняется один раз и завершается; RemainAfterExit=yes — после этого юнит всё равно считается active;
  • iptables -C … || iptables -I … — сначала проверить (-C), есть ли правило, и добавить, только если нет. Без проверки повторный запуск юнита добавит дубль;
  • WantedBy=multi-user.target — запускать при обычной загрузке системы.
sudo systemctl daemon-reload
sudo systemctl enable --now docker-user-rule.service
  • daemon-reload — systemd перечитывает юниты: файл создан напрямую, без этого systemd о нём не знает;
  • enable --now — включить в автозагрузку и сразу запустить.

Проверка — сколько правил DROP в цепочке:

sudo iptables -L DOCKER-USER -n | grep -c DROP

grep -c — посчитать строки с DROP.

  • ✅ 1
  • ❌ 0 → юнит не отработал: sudo systemctl status docker-user-rule
  • ⚠️ 2 и больше → дубли, лишние удалить: sudo iptables -D DOCKER-USER -i eth0 -m conntrack --ctstate NEW -j DROP (-D — удалить одно такое правило)

💡 Всё это — для штатного firewall-бэкенда Docker (iptables). В Docker 29 есть экспериментальный бэкенд nftables ("firewall-backend": "nftables" в daemon.json) — в нём цепочки DOCKER-USER нет, и этот раздел не подходит.


6. Инцидент: iptables-persistent удалил UFW

Зачем этот раздел: это самая дорогая ошибка во всей настройке — сервер работал с открытым периметром, и ничто об этом не сообщало. Здесь — как это случилось, как проверить, что с тобой такого нет, и как починить.

Что произошло. Чтобы сохранить правило DOCKER-USER, я поставил iptables-persistent (пакет, который сохраняет правила iptables и загружает их при старте) с флагом -y. apt молча удалил ufw: в пакете ufw прописано Breaks: iptables-persistent, netfilter-persistent — оба управляют netfilter. Флаг -y пропустил подтверждение, и строка The following packages will be REMOVED: ufw утонула в выводе.

Как проявилось — только после перезагрузки:

sudo: ufw: command not found
Chain INPUT (policy ACCEPT)          ← должно быть DROP
iptables -L ufw-user-input           ← No chain by that name

Каркас цепочек ufw-* восстановился из снимка, но правил внутри не было, а политика — ACCEPT: сервер работал с полностью открытым периметром.

Как нашли: контрольная перезагрузка с чеклистом (раздел 8). Без неё сервер так бы и жил.

На 26.04 воспроизводится. Проверить, не устанавливая:

apt-get -s install iptables-persistent | grep -E 'REMOVED|^Remv'

apt-get -s — симуляция: показать, что apt сделал бы, ничего не меняя; root не нужен. grep — оставить только строки об удалении.

The following packages will be REMOVED:
Remv ufw [0.36.2-9build1]

Диагностика — если есть подозрение, что UFW пропал:

command -v ufw || echo "ufw нет"
zgrep -h '^Remove:.*ufw' /var/log/apt/history.log*
sudo iptables -L INPUT -n | head -1
  • command -v ufw — есть ли команда ufw; если нет — напечатать «ufw нет»;

  • zgrep … history.log* — история apt, включая сжатые старые файлы: строки Remove: с ufw. Обычное обновление ufw (Upgrade:) сюда не попадает;

  • iptables -L INPUT -n | head -1 — первая строка цепочки INPUT с политикой.

  • ✅ Путь к ufw, zgrep ничего не вывел (ufw через apt не удаляли), policy DROP

  • ❌ ufw нет, zgrep нашёл строку Remove: … ufw или policy ACCEPT → чинить

Починка:

sudo apt remove iptables-persistent netfilter-persistent
sudo apt install ufw
sudo ufw show added
sudo ufw enable
sudo iptables -L INPUT -n | head -1
  • apt remove … — убрать пакеты, из-за которых удалился ufw;
  • apt install ufw — вернуть UFW. Его конфиг в /etc/ufw/ переживает remove (удаляется только при purge), правила сохраняются;
  • ufw show added — список добавленных правил; смотрим до enable: правило 22/tcp должно быть на месте, иначе можно закрыть себе SSH;
  • ufw enable — включить;
  • iptables -L INPUT … | head -1 — обязательно: должно стать policy DROP.

Как избежать:

ПравилоПочему
Не ставить системные и сетевые пакеты с -y-y убирает не только вопрос, но и момент, когда ты прочитал бы will be REMOVED
Перед установкой незнакомого пакета — apt-get -s install ПАКЕТПокажет, что будет удалено, ничего не меняя
Не ставить два менеджера одного ресурсаufw + iptables-persistent, ufw + firewalld, systemd-resolved + dnsmasq
Контрольная перезагрузка на новом сервереЕдинственный способ узнать правду до инцидента
Проверять policy, а не только ufw statusStatus: active при policy ACCEPT = периметр открыт

7. Логи контейнеров и live-restore

Зачем: по умолчанию логи контейнеров растут без ограничений — /var/lib/docker/containers/<id>/<id>-json.log за месяцы съедает диск. Симптом — «куда делись 30 ГБ». Заодно включаем live-restore, чтобы перезапуск Docker не останавливал контейнеры.

Настройки демона Docker лежат в /etc/docker/daemon.json. Сначала смотрим, есть ли он:

cat /etc/docker/daemon.json
  • ✅ No such file or directory → создаём командой ниже
  • ⚠️ Файл есть → команда ниже его перезапишет: добавить ключи в существующий файл вручную (sudo nano)
sudo mkdir -p /etc/docker

Страховка: папку /etc/docker создаёт сам пакет docker-ce, но если её нет, tee ниже упадёт с No such file or directory. -p — без ошибки, если папка уже есть.

sudo tee /etc/docker/daemon.json > /dev/null << 'EOF'
{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" },
  "live-restore": true
}
EOF
  • log-driver: json-file — логи в файлах (так и по умолчанию), docker logs их читает;
  • max-size / max-file — лог одного контейнера не больше трёх файлов по 10 МБ;
  • live-restore — контейнеры продолжают работать, пока демон Docker перезапускается или обновляется в пределах патч-версии (29.9.1 → 29.9.2). При переходе на следующую версию (29.9 → 29.10) это не поддерживается: демон может потерять связь с контейнерами, и останавливать их придётся вручную.

Проверить файл до перезапуска — с ошибкой в JSON Docker не запустится:

sudo dockerd --validate --config-file /etc/docker/daemon.json
  • ✅ configuration OK
  • ❌ Ошибка разбора → исправить файл, не перезапускать
sudo systemctl restart docker

Применяем настройки перезапуском демона. ⚠️ Запущенные контейнеры при этом остановятся; снова поднимутся только те, у кого задан restart: в compose (unless-stopped/always). На новом сервере контейнеров ещё нет, так что сейчас самое время.

💡 Если контейнеры уже работают, live-restore можно включить без простоя: sudo systemctl reload docker. Лимиты логов reload не применяет: они заработают только после restart демона, и то лишь для новых контейнеров (ниже).

💡 Лимиты логов применяются только к новым контейнерам. Уже запущенные нужно пересоздать: docker compose up -d --force-recreate.

Проверка — спрашиваем демон, а не файл. live-restore:

sudo docker info --format '{{.LiveRestoreEnabled}}'

docker info — настройки работающего демона, --format — вывести одно поле.

  • ✅ true
  • ❌ false → демон не перечитал файл: перезапуск не прошёл или ключ с опечаткой

Лимиты логов — по тому, что получает новый контейнер (docker info их не показывает, а json-file он выведет и без daemon.json — это драйвер по умолчанию):

sudo docker create --name logtest nginx
sudo docker inspect -f '{{.HostConfig.LogConfig.Type}} {{.HostConfig.LogConfig.Config}}' logtest
sudo docker rm logtest
  • docker create — создать контейнер, не запуская его: настройки логов он получает уже при создании;
  • inspect -f — вывести из настроек контейнера только драйвер логов и его параметры;
  • rm — удалить тестовый контейнер.
  • ✅ json-file map[max-file:3 max-size:10m]
  • ❌ json-file map[] → лимиты не применились: перезапуск не прошёл или ключи с опечаткой

8. Проверка после перезагрузки

Зачем: правила firewall, юниты и настройки Docker должны пережить перезагрузку. Пока сервер ни разу не перезагружали, это не проверено — именно так нашёлся инцидент из раздела 6. На новом сервере делаем контрольную перезагрузку и сразу проверяем всё одним блоком.

sudo reboot

Через минуту снова подключаемся по SSH и выполняем:

echo "=== UFW ==="; sudo ufw status | head -1; sudo iptables -L INPUT -n | head -1
echo "=== DOCKER ==="; sudo docker ps --format '{{.Names}}  {{.Ports}}'
echo "=== DOCKER-USER ==="; sudo iptables -L DOCKER-USER -n | grep -c DROP
echo "=== FAIL2BAN ==="; systemctl is-active fail2ban

Каждая строка — заголовок и проверка из разделов выше: статус и политика UFW, порты контейнеров, правило DOCKER-USER, fail2ban.

Проверка✅ Хорошо❌ Плохо → чинить
UFWStatus: active + policy DROPinactive / policy ACCEPT / command not found → раздел 6
DOCKERпорты только 127.0.0.1:…0.0.0.0:… или [::]:… → раздел 4
DOCKER-USER10 → юнит не отработал, раздел 5.4
FAIL2BANactivefailed → sudo journalctl -u fail2ban -n 30 --no-pager

Итог: чеклист

  • Docker из официального репозитория, конфликтующих пакетов нет (dpkg --get-selections docker.io … runc до установки → пусто)
  • sudo docker version показывает Client и Server
  • Решено, состоит ли demo в группе docker (группа = root); CI-пользователи — не в группе
  • Порты всех контейнеров — на 127.0.0.1 (sudo docker ps --format '{{.Names}} {{.Ports}}')
  • Правило DOCKER-USER стоит и переживает перезагрузку (sudo iptables -L DOCKER-USER -n | grep -c DROP → 1)
  • Проверка 5.3 пройдена: с тестовым контейнером на :8080 счётчик DROP растёт, curl.exe — таймаут; контейнер удалён
  • iptables-persistent не ставили; UFW на месте, policy DROP (sudo iptables -L INPUT -n | head -1)
  • Логи контейнеров ограничены (docker inspect нового контейнера → max-file:3 max-size:10m), live-restore включён (docker info → true)
  • Контрольная перезагрузка пройдена, все проверки раздела 8 — ✅