Сети Docker на VPS: bridge, host и macvlan на практике
Как работают сети bridge, host и macvlan в Docker на одном VPS. DNS-резолвинг в пользовательском bridge, публикация портов, настройка IPv6 и изоляция сетей через Docker Compose.
У тебя один VPS. Несколько контейнеров должны общаться друг с другом и с внешним миром. Docker предлагает три сетевых драйвера (network driver), которые здесь важны: bridge, host и macvlan. Каждый делает свои компромиссы между изоляцией, производительностью и удобством.
Эта статья объясняет, когда выбирать какой драйвер, как связывать контейнеры через пользовательские bridge-сети и Docker Compose, и как избежать типичных ошибок, из-за которых сервисы оказываются недоступными или случайно открытыми наружу.
Требования: базовые знания Docker CLI и знакомство с Docker Compose.
В чём разница между bridge и host сетями Docker?
Bridge-сеть даёт каждому контейнеру собственное сетевое пространство имён (network namespace) с виртуальной ethernet-парой (veth), подключённой к программному мосту на хосте. Контейнеры получают приватные IP (обычно из диапазона 172.17.0.0/16) и выходят наружу через NAT, управляемый iptables. Host-сеть полностью убирает сетевое пространство имён: контейнер разделяет сетевой стек хоста, привязываясь напрямую к его портам без трансляции адресов.
Сравнение одним взглядом:
| Критерий | Пользовательский Bridge | Host | Macvlan |
|---|---|---|---|
| Сетевая изоляция | Полная (свой namespace) | Нет (общий с хостом) | Полная (свой MAC-адрес) |
| DNS-резолвинг | Автоматический по имени контейнера | DNS хоста | Нет встроенного DNS |
| Проброс портов | Нужен (-p) |
Не нужен (прямой bind) | Не нужен (реальный IP) |
| Overhead производительности | Небольшой (NAT + veth) | Нет | Нет |
| Риск безопасности | Низкий (изолирован по умолчанию) | Высокий (нет сетевой границы) | Средний (нужен promiscuous mode) |
| Актуальность для VPS | Основной выбор | Мониторинг, высокая нагрузка | Почти не применим |
Зачем нужна пользовательская bridge-сеть вместо дефолтной?
Дефолтная сеть bridge (она же docker0) не обеспечивает DNS-резолвинг между контейнерами. Контейнеры могут обращаться друг к другу только по IP-адресу, который меняется при каждом перезапуске. Пользовательские bridge-сети дают автоматический DNS, лучшую изоляцию и возможность подключать/отключать контейнеры на лету. Используй пользовательский bridge для всего. Дефолтный bridge — это наследие.
Проблема с DNS
При запуске Docker создаёт дефолтную bridge-сеть. Каждый контейнер без явного флага --network попадает в неё. Но контейнеры в дефолтном bridge не могут резолвить друг друга по имени:
# Start two containers on the default bridge
docker run -d --name web nginx:alpine
docker run -d --name client alpine sleep 3600
# Try name resolution - this fails
docker exec client ping -c1 web
# ping: bad address 'web'
Теперь то же самое с пользовательским bridge:
# Create a custom bridge network
docker network create app-net
# Start containers on it
docker run -d --name web2 --network app-net nginx:alpine
docker run -d --name client2 --network app-net alpine sleep 3600
# Name resolution works
docker exec client2 ping -c1 web2
# PING web2 (172.18.0.2): 56 data bytes
# 64 bytes from 172.18.0.2: seq=0 ttl=64 time=0.089 ms
Как работает встроенный DNS Docker
Каждый контейнер в пользовательской сети получает 127.0.0.11 как DNS-сервер. Это встроенный DNS-сервер Docker. Он резолвит имена контейнеров и алиасы сервисов в их текущие IP-адреса. Если имя не соответствует контейнеру, запрос перенаправляется на DNS-серверы, настроенные на хосте.
docker exec client2 cat /etc/resolv.conf
# nameserver 127.0.0.11
DNS-сервер работает внутри сетевого namespace контейнера, но фактический резолвинг происходит в Docker-демоне на хосте. Чтобы избежать конфликтов с сервисами, которые могут использовать порт 53 внутри контейнера, DNS-слушатель Docker использует случайный высокий порт и перенаправляет запросы через правила iptables.
Эквивалентного адреса для IPv6 нет. Адрес 127.0.0.11 работает и в контейнерах с чистым IPv6.
Встроенный DNS возвращает TTL 600 секунд (10 минут) для записей имён контейнеров. Это важно для blue-green деплоев: если ты заменишь контейнер, другие контейнеры могут ещё 10 минут резолвить старый IP. Приложения с агрессивным кешированием DNS (Java по умолчанию кеширует бессрочно) будут держать устаревшие адреса ещё дольше.
Контейнеры в одной пользовательской bridge-сети видят все порты друг друга без флага -p. Публикация портов управляет только доступом извне сети (с хоста или из интернета). Два контейнера в app-net общаются свободно по любому порту.
Дефолтный bridge vs пользовательский bridge
| Возможность | Дефолтный bridge (docker0) |
Пользовательский bridge |
|---|---|---|
| DNS по имени контейнера | Нет | Да |
| Динамическое подключение/отключение | Нет (нужен перезапуск контейнера) | Да |
| Настройка per-network | Нет (нужна правка daemon.json + перезапуск) |
Да |
| Изоляция от других стеков | Нет (все неназначенные контейнеры в одной сети) | Да |
| Рекомендован для продакшна | Нет | Да |
Когда использовать host-сеть на VPS?
Используй host-сеть, когда контейнеру нужна максимальная сетевая производительность или он должен динамически привязываться к множеству портов. Контейнер напрямую разделяет сетевой стек хоста, минуя NAT и виртуальный ethernet bridge. Это убирает измеримый overhead для высоконагруженных задач.
Типичные случаи на VPS:
- Агенты мониторинга типа Prometheus node-exporter или Netdata, которым нужно видеть все интерфейсы и трафик хоста
- DNS-серверы, которые должны слушать на реальном IP хоста
- Чувствительные к производительности сервисы, где overhead NAT заметен (высокий packet rate, UDP-интенсивные нагрузки)
# Run a container with host networking
docker run -d --name node-exporter --network host \
prom/node-exporter:latest
Флаг --publish игнорируется при host-сети. Контейнер привязывается напрямую к портам хоста. Если порт 9100 уже занят на хосте, контейнер не запустится.
Компромисс безопасности
Host-сеть даёт ту же сетевую изоляцию, что и запуск процесса прямо на хосте: никакой. Контейнер видит все сетевые интерфейсы, может привязаться к любому порту и перехватывать трафик. Используй только когда есть конкретная причина.
# Verify which ports a host-networked container opened
ss -tlnp | grep node_exporter
# LISTEN 0 4096 *:9100 *:* users:(("node_exporter",pid=12345,fd=3))
Обрати внимание: вывод показывает, что процесс слушает на *:9100, то есть на всех интерфейсах. С bridge-сетью ты контролируешь это через флаг -p. С host-сетью приложение само решает, к чему привязываться.
Что такое macvlan и нужен ли он на VPS?
Macvlan назначает каждому контейнеру свой MAC-адрес, заставляя его выглядеть как отдельное физическое устройство в сети. Контейнер получает реальный IP из подсети твоей LAN и доступен напрямую без проброса портов.
На VPS macvlan тебе почти наверняка не нужен. Вот почему:
- Большинство облачных провайдеров блокируют его. Macvlan требует, чтобы физическая сетевая карта работала в promiscuous mode. Гипервизоры VPS обычно блокируют это на уровне виртуального коммутатора.
- Нет LAN для подключения. У твоего VPS один публичный интерфейс. Macvlan предназначен для сред, где контейнерам нужны собственные адреса в существующей LAN, например домашняя лаборатория с IoT-устройствами или legacy-приложения, ожидающие выделенный IP.
- Связь хост-контейнер ломается. Ядро Linux по своей архитектуре не позволяет macvlan-контейнеру общаться с хостом, на котором он работает. Нужны обходные решения, например подключение второй bridge-сети.
Если провайдер VPS даёт выделенный сервер с полным доступом к NIC и несколькими IP, macvlan становится реальным вариантом. Для стандартных VPS-деплоев оставайся на пользовательских bridge-сетях.
Для справки, создание macvlan-сети выглядит так (на VPS это скорее всего не понадобится):
docker network create -d macvlan \
--subnet=192.168.1.0/24 \
--gateway=192.168.1.1 \
-o parent=eth0 \
macnet
Опция parent указывает интерфейс хоста для привязки. Контейнер получает свой IP из подсети и свой MAC-адрес. Другие устройства в LAN могут обращаться к нему напрямую.
В чём разница между expose и publish порта в Docker?
EXPOSE в Dockerfile — это документация. Он объявляет, на каком порту слушает приложение. Он не открывает этот порт для хоста или внешнего мира. --publish (или -p) при запуске создаёт реальный маппинг порта с хоста на контейнер. Без -p внешний трафик до контейнера не доберётся.
# In your Dockerfile - this is documentation only
EXPOSE 8080
# This actually maps host:8080 -> container:8080
docker run -d -p 8080:8080 myapp
# This binds to localhost only - external traffic cannot reach it
docker run -d -p 127.0.0.1:8080:8080 myapp
Паттерн привязки к 127.0.0.1
Когда ты запускаешь сервисы за reverse proxy типа Nginx, Traefik или Caddy Traefik vs Caddy vs Nginx: сравнение Docker reverse proxy, публикуй порты контейнеров только на 127.0.0.1. Это блокирует прямой доступ из интернета, направляя весь трафик через reverse proxy, где происходит TLS-терминация, rate limiting и контроль доступа.
# docker-compose.yml - binding to localhost only
services:
app:
image: myapp:latest
ports:
- "127.0.0.1:3000:3000" # Only reachable from localhost
Проверь привязку:
ss -tlnp | grep 3000
# LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("docker-proxy",...))
Обрати внимание: вывод показывает 127.0.0.1:3000, а не 0.0.0.0:3000. внешний трафик на публичном IP твоего VPS не может напрямую добраться до порта 3000.
Без префикса 127.0.0.1 Docker публикует на всех интерфейсах по умолчанию. На VPS с публичным IP это означает, что твой сервис доступен из интернета, минуя reverse proxy и, возможно, файрвол.
Быстрая справка по публикации портов
| Синтаксис | Привязка к | Доступен из |
|---|---|---|
-p 8080:80 |
0.0.0.0:8080 + [::]:8080 |
Отовсюду (IPv4 + IPv6) |
-p 127.0.0.1:8080:80 |
127.0.0.1:8080 |
Только localhost |
-p 0.0.0.0:8080:80 |
0.0.0.0:8080 |
Все интерфейсы IPv4 |
-p [::1]:8080:80 |
[::1]:8080 |
Только IPv6 localhost |
Как изолировать сети контейнеров на одном VPS?
Создавай отдельные bridge-сети для каждого стека приложений. Контейнеры в разных сетях не могут общаться, если ты явно не подключишь их к общей сети. Для баз данных и внутренних сервисов используй флаг internal: true, чтобы заблокировать любой исходящий доступ в интернет.
Мультисетевая архитектура
Типичная продакшн-конфигурация на VPS выглядит так:
Internet
|
[Reverse Proxy]
/ \
[frontend] [api-net]
| |
webapp api-server
|
[db-net: internal]
|
postgres
Reverse proxy подключён к frontend и api-net. API-сервер подключён к api-net и db-net. PostgreSQL находится только в db-net, без маршрута в интернет.
Docker Compose файл для этой конфигурации:
services:
proxy:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
networks:
- frontend
- api-net
webapp:
image: mywebapp:latest
networks:
- frontend
api:
image: myapi:latest
environment:
- DATABASE_URL=postgresql://appuser:${DB_PASSWORD}@db:5432/appdb
networks:
- api-net
- db-net
db:
image: postgres:16-alpine
environment:
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- db-net
networks:
frontend:
driver: bridge
api-net:
driver: bridge
db-net:
driver: bridge
internal: true # No internet access for the database network
volumes:
pgdata:
internal: true на db-net означает, что PostgreSQL не может выйти в интернет. Он не может скачать обновления, связаться с внешним сервером или быть использован как точка для исходящих соединений. API-сервер может обращаться к базе данных, потому что подключён и к api-net, и к db-net.
Проверка изоляции сети
После запуска стека убедись в изоляции:
# List networks created by Compose
docker network ls --filter "name=myproject"
# Inspect a network to see which containers are connected
docker network inspect myproject_db-net --format '{{range .Containers}}{{.Name}} {{end}}'
# myproject-api-1 myproject-db-1
# Confirm the database cannot reach the internet
docker exec myproject-db-1 ping -c1 -W2 8.8.8.8
# ping: sendto: Network unreachable
Обрати внимание: «Network unreachable» подтверждает, что флаг internal: true работает. У контейнера базы данных нет default gateway.
Как включить IPv6 для контейнеров Docker?
Включи IPv6 глобально в /etc/docker/daemon.json, затем включи его для каждой сети с указанием подсети. Docker поддерживает dual-stack (IPv4 + IPv6) из коробки на Linux. Параметр ip6tables, включённый по умолчанию, управляет правилами IPv6-файрвола для контейнеров.
Шаг 1: настройка демона
Отредактируй /etc/docker/daemon.json:
{
"ipv6": true,
"fixed-cidr-v6": "fd00:dead:beef::/64",
"ip6tables": true,
"default-address-pools": [
{ "base": "172.17.0.0/16", "size": 24 },
{ "base": "fd00:dead:beef::/48", "size": 64 }
]
}
fixed-cidr-v6 назначает IPv6-подсеть /64 дефолтному bridge. Блок default-address-pools указывает Docker, как выделять подсети для новых сетей: блоки /24 из IPv4-диапазона и блоки /64 из IPv6-диапазона.
Используй ULA-префикс (fd00::/8) для приватного трафика между контейнерами. Если твоему VPS выделен публичный IPv6-диапазон от провайдера, можешь использовать подсеть из него для контейнеров, которым нужны публичные IPv6-адреса.
Перезапусти Docker:
sudo systemctl restart docker
Проверь, что IPv6 включён:
docker network inspect bridge --format '{{.EnableIPv6}}'
# true
Шаг 2: создание dual-stack сети
docker network create --ipv6 --subnet 172.20.0.0/24 --subnet fd00:dead:beef:1::/64 app-v6
Или в Docker Compose:
networks:
app-v6:
enable_ipv6: true
ipam:
config:
- subnet: 172.20.0.0/24
- subnet: fd00:dead:beef:1::/64
Шаг 3: проверка dual-stack связности
docker run --rm --network app-v6 alpine ip -6 addr show eth0
# inet6 fd00:dead:beef:1::2/64 scope global
IPv6 поддерживается только в Docker-демонах на Linux. На старых системах может потребоваться загрузить модуль ядра ip6_tables перед созданием IPv6-сетей:
sudo modprobe ip6_tables
Как определить сети в Docker Compose?
Docker Compose автоматически создаёт дефолтную сеть для каждого проекта. Каждый сервис в Compose-файле подключается к этой сети и может резолвить другие сервисы по имени сервиса. Для большинства приложений с одним стеком дефолтного поведения достаточно.
Когда нужно несколько сетей для изоляции, определи их явно в секции networks:
services:
web:
image: nginx:alpine
ports:
- "127.0.0.1:8080:80"
networks:
- public
app:
image: node:20-alpine
networks:
- public
- private
redis:
image: redis:7-alpine
networks:
- private
networks:
public:
driver: bridge
private:
driver: bridge
internal: true
В этом файле web может обращаться к app через сеть public. app может обращаться и к web, и к redis. redis может обращаться только к app через private и не имеет доступа в интернет.
Host-сеть в Compose
services:
node-exporter:
image: prom/node-exporter:latest
network_mode: host
pid: host
restart: unless-stopped
network_mode: host заменяет любое определение networks. Нельзя комбинировать host-режим с пользовательскими сетями для одного сервиса.
Подключение к внешним сетям
Если сеть создана вне Compose (другим стеком или вручную), ссылайся на неё как на внешнюю:
networks:
shared-proxy:
external: true
Это позволяет нескольким Compose-проектам использовать одну сеть. Типичный паттерн: один Compose-стек запускает reverse proxy и создаёт сеть. Другие стеки объявляют её внешней и подключают к ней свои сервисы.
Справочник команд Docker network
| Команда | Что делает |
|---|---|
docker network ls |
Показать все сети |
docker network create mynet |
Создать bridge-сеть |
docker network create --ipv6 --subnet fd00::/64 mynet |
Создать dual-stack сеть |
docker network inspect mynet |
Показать детали сети (подсеть, контейнеры, опции) |
docker network connect mynet container1 |
Подключить работающий контейнер к сети |
docker network disconnect mynet container1 |
Отключить контейнер от сети |
docker network prune |
Удалить все неиспользуемые сети |
docker network rm mynet |
Удалить конкретную сеть |
Пределы масштабирования
Bridge-сети становятся нестабильными, когда к одной сети подключено больше 1000 контейнеров. Это ограничение ядра Linux на bridge device. Если у тебя много контейнеров, распределяй их по нескольким сетям по функциям, а не складывай всё в один bridge.
Что-то пошло не так?
Контейнеры не резолвят друг друга по имени.
Скорее всего ты в дефолтном bridge. Создай пользовательскую сеть и подключи оба контейнера. Проверь через docker inspect <container> --format '{{json .NetworkSettings.Networks}}'.
Порт опубликован, но недоступен извне.
Проверь, не привязан ли он к 127.0.0.1 вместо 0.0.0.0. Выполни ss -tlnp | grep <port> на хосте. Также проверь правила файрвола.
Контейнер не может выйти в интернет.
Возможно, у сети установлен internal: true. Проверь через docker network inspect <network> --format '{{.Internal}}'. Если возвращается true, сеть блокирует исходящий трафик по замыслу.
IPv6 не работает в контейнерах.
Проверь, включён ли ip6tables в daemon.json. На старых ядрах загрузи модуль: sudo modprobe ip6_tables. Проверь, включён ли IPv6 в сети: docker network inspect <network> --format '{{.EnableIPv6}}'.
«Address already in use» с host-сетью.
Другой процесс (или контейнер) уже занял этот порт. Найди его через ss -tlnp | grep <port>. Host-сеть не делает проброса портов, так что конфликты прямые.
Дальнейшие шаги
Сеть контейнеров настроена. Что дальше:
- Добавить reverse proxy для TLS и маршрутизации Traefik vs Caddy vs Nginx: сравнение Docker reverse proxy
- Исправить обход файрвола Docker, чтобы опубликованные порты подчинялись правилам UFW/nftables
- Добавить лимиты ресурсов и healthcheck-и к сервисам Compose Лимиты ресурсов, healthcheck-проверки и политики перезапуска в Docker Compose
- Вернуться к обзору Docker на VPS для полного чеклиста продакшна
Готовы попробовать?
Запускайте Docker-контейнеры на быстром VPS. →