Docker для панелей и ботов на VPS
Панель, бот и кабинет в контейнерах: как не выставить базу в интернет, не утонуть в логах, связать сети и обновляться без сюрпризов.
Docker удобен ровно до момента, когда выясняется, что порт бота торчит в мир мимо файрвола, а логи контейнера съели диск. Ниже то, что стоит сделать сразу после docker compose up -d, а не после инцидента.
Порты: только 127.0.0.1
По умолчанию Docker публикует порт на всех адресах хоста: 0.0.0.0 и [::]. Документация прямо называет это небезопасным: порт становится доступен не только хосту, но и всему интернету.
Всё, что стоит за reverse proxy (панель, API бота, страница подписки, кабинет), публикуй только на loopback:
services:
bot:
ports:
- "127.0.0.1:8080:8080"Caddy или nginx на хосте ходит на 127.0.0.1:8080, а снаружи порт не виден. Если прокси тоже в контейнере и в той же сети, ports: не нужен вовсе: прокси обращается к сервису по имени (remnawave:3000, remnawave_bot:8080).
Проверить, что торчит наружу:
ss -tlnp | grep docker-proxyСтрока с 0.0.0.0:8080 или [::]:8080 значит, что порт открыт миру. В сообществе это регулярная находка:
порт 8080 бота открыт наружу, лечится префиксом 127.0.0.1:.
Грабли
До Docker 28.0 порт, опубликованный на 127.0.0.1, был доступен хостам из того же L2-сегмента (соседям по коммутатору у хостера). Это сказано в документации Docker. Если движок старше, обновись.
Чтобы новые сети по умолчанию публиковали порты на loopback, в /etc/docker/daemon.json есть default-network-opts:
{
"default-network-opts": {
"bridge": {
"com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
}
}
}Это страховка на случай забытого префикса, а не замена ему. Явный 127.0.0.1: в compose читается и через год.
Почему ufw и nftables не спасают
Опубликованный порт Docker пробрасывает через NAT до того, как пакет дойдёт до цепочки input. Документация Docker говорит прямо: Docker и ufw несовместимы, трафик к контейнеру уходит в обход правил ufw. Правило ufw deny 8080 ничего не закроет. С цепочкой input из
гайда по nftables та же история.
Если порт всё-таки должен быть публичным, но не для всех, фильтруй в цепочке, которую Docker проверяет раньше своих правил.
Бэкенд iptables (по умолчанию): цепочка DOCKER-USER
DOCKER-USER в документации описана как место для пользовательских правил, которые обрабатываются до цепочек DOCKER-FORWARD и DOCKER. Пример: опубликованные порты доступны только с одного адреса администратора, ответы на исходящие соединения контейнеров не режутся:
iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.10 -j DROP
iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPT-I без номера вставляет правило в начало цепочки, поэтому второе правило окажется выше первого. Проверь порядок:
iptables -L DOCKER-USER -n -v --line-numbersПакеты в DOCKER-USER приходят уже после DNAT, то есть с адресом и портом контейнера. Чтобы сматчить исходный порт на хосте, нужен conntrack:
iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdst 198.51.100.2 --ctorigdstport 443 -j ACCEPTЗаметка
Документация Docker предупреждает, что matching через conntrack может снижать производительность. На ноде с большим трафиком лучше вообще не публиковать лишнее.
Бэкенд nftables
В Docker 29 появился экспериментальный бэкенд "firewall-backend": "nftables". В нём нет цепочки DOCKER-USER. Свои правила кладут в отдельную таблицу с base chain на том же хуке, а таблицы Docker не трогают: он считает их своими и перезапишет правки.
Пример из документации Docker, добавь его в свой конфиг nftables:
table ip my-table {
chain my-filter-forward {
type filter hook forward priority filter; policy accept;
iifname "eth0" oifname "br-*" ip saddr != 203.0.113.0/24 counter drop
}
}Вместо eth0 подставь внешний интерфейс (ip -br link).
Не делай так
flush ruleset удаляет все таблицы nftables, включая таблицы Docker (и бэкенда nftables, и iptables, работающего через nft). После такого сброса делай systemctl restart docker, иначе контейнеры останутся без сети.
Логи json-file: лимит до того, как кончится диск
Драйвер json-file по умолчанию не ограничивает размер: max-size равен -1. Разговорчивый бот пишет в stdout, файл растёт, пока диск не кончится.
Глобальный лимит:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}Значения в log-opts обязаны быть строками, включая "3". Перед рестартом проверь синтаксис, иначе демон не поднимется:
dockerd --validate --config-file=/etc/docker/daemon.json
systemctl restart dockerВажно
Настройка применяется только к новым контейнерам. Существующие продолжат писать без лимита. Compose пересоздаёт контейнер, только если изменился его конфиг или образ, поэтому после правки daemon.json нужно явно:
docker compose up -d --force-recreateЛимит можно задать и в самом compose, для конкретного сервиса. Так делают, например, со страницей подписки:
services:
remnawave-subscription-page:
logging:
driver: json-file
options:
max-size: "30m"
max-file: "5"Грабли
Не добавляй настройки в daemon.json через tee -a. Дописанный второй JSON-объект делает файл невалидным, и Docker не стартует. Если там уже есть registry-mirrors, объединяй ключи в один объект.
Как найти и срочно ужать уже раздувшийся лог, описано в гайде «Диск кончился».
MTU в сетях compose
Если у внешнего интерфейса хоста MTU меньше 1500 (туннель, некоторые хостеры), а сеть контейнеров остаётся на 1500, крупные пакеты могут теряться по дороге. В сообществе рабочее значение для сетей бота и панели 1350 ( Docker, sysctl, BBR, MTU).
Для пользовательских bridge-сетей MTU задаётся опцией драйвера com.docker.network.driver.mtu:
networks:
bot_network:
driver: bridge
driver_opts:
com.docker.network.driver.mtu: "1350"Ключ "mtu" в daemon.json действует только на стандартный мост docker0 и на compose-сети не влияет. Для всех новых bridge-сетей по умолчанию используй default-network-opts с тем же ключом com.docker.network.driver.mtu.
Узнать MTU хоста: ip link show eth0. Опции драйвера задаются при создании сети, у существующей сети их не поменять: сеть нужно пересоздать, например docker compose down && docker compose up -d в проекте, который её создаёт.
External-сети: панель и бот в одной сети
Панель и бот обычно живут в разных compose-проектах. Чтобы бот ходил в панель по http://remnawave:3000, оба контейнера должны быть в одной сети. Сеть создаёт один проект (панель), второй подключается к ней как к внешней:
services:
bot:
networks: [remnawave-network]
networks:
remnawave-network:
name: remnawave-network
external: trueЧто важно знать про external: true:
- Compose такую сеть не создаёт и падает с ошибкой, если её нет. Сначала поднимай проект-владельца.
- Для внешней сети значимо только поле
name.driver,ipam,driver_optsигнорируются, поэтому subnet и MTU задаются в проекте, который сеть создаёт. - Без
name:Compose добавляет к имени сети префикс проекта. Поэтому сеть бота называетсяremnawave-bedolaga-telegram-bot_bot_network, а неbot_network( Docker в базе). Точное имя смотри вdocker network ls.
docker network connect remnawave-network remnawave_bot подключает контейнер на лету, но это временно: при пересоздании контейнера подключение пропадёт. Прописывай сеть в compose.
Внутри контейнера localhost означает сам контейнер. Прокси в одной сети с панелью ходит по имени сервиса, а не на 127.0.0.1.
Обновление
cd /opt/remnawave
docker compose pull
docker compose up -dpull только скачивает образы. up -d пересоздаёт контейнеры, у которых изменился образ или конфиг, тома при этом сохраняются. Для сервисов с build: (бот, собираемый из исходников) нужен docker compose up -d --build.
Лайфхак
Перед обновлением сделай бэкап базы и посмотри релиз: у панели и нод бывают несовместимые версии, и latest иногда приходится закреплять на конкретном теге (
установка и обновление). Бэкапы — в
гайде по restic.
Чистка
После каждого обновления старые образы остаются на диске ( ошибки и фиксы). Сначала посмотри, что занимает место:
docker system dfБезопасный минимум:
docker image prune # только dangling-образы (<none>)
docker builder prune # кэш сборкиНе делай так
docker system prune -a удаляет все остановленные контейнеры, все сети без контейнеров, все образы, не используемые контейнерами, и кэш сборки. Если стек в этот момент остановлен, его контейнеры и образы уйдут. Если остановлена панель, которая создала remnawave-network, уйдёт и сеть, и бот с external: true перестанет стартовать. --volumes дополнительно удаляет анонимные тома. Именованные тома (postgres_data) не удаляются без явной команды, но проверять это на проде лучше не стоит.
Типовые ошибки
Pool overlaps with other one on this address space. Subnet в compose пересекается с уже существующей сетью. Найди занятые подсети и выбери свободную (
источник):
docker network inspect $(docker network ls -q) -f '{{.Name}} {{range .IPAM.Config}}{{.Subnet}}{{end}}'Подсеть Docker совпала с домашней сетью клиента или с сетью хостера. Среди пулов Docker по умолчанию есть 192.168.0.0/16. Если это мешает, задай свои пулы в daemon.json через default-address-pools:
{
"default-address-pools": [
{ "base": "172.17.0.0/16", "size": 24 }
]
}network ... already exists. Сеть с таким именем уже создал другой проект. Объяви её в compose как external: true (
ошибки и фиксы).
Cannot connect to host remnawave:3000 / host not found. Бот или прокси не в той сети, что панель. Проверь docker network inspect remnawave-network и допиши сеть в compose.
Настройки логов не применились. Контейнер не пересоздавался. docker compose up -d --force-recreate.
Источники
- Docker: Port publishing and mapping
- Docker: Packet filtering and firewalls
- Docker: Docker with iptables (DOCKER-USER)
- Docker: Docker with nftables
- Docker: Bridge network driver
- Docker: JSON File logging driver
- Docker: dockerd reference (daemon.json, --validate, default-network-opts)
- Docker: Networking overview (default-address-pools)
- Compose file reference: networks
- Compose file reference: services (ports, logging)
- docker compose up, docker compose pull
- docker system prune, docker image prune, docker system df, docker buildx prune
- docker network create
- iptables(8), iptables-extensions(8)