Два сетевых интерфейса, Docker и пропавшие ответы

Как заставить сервис отвечать через тот же канал, по которому пришёл клиент
Это очередная практическая часть цикла о собственных сетевых инструментах и управлении исходящим трафиком.
Сегодня разберём ситуацию, которая поначалу выглядит почти мистически. У Linux-сервера два сетевых интерфейса. На одном — публичный адрес, SSH, VPN и обычный доступ в интернет. На втором — ещё один адрес, через который должен быть доступен отдельный сервис, например SOCKS-прокси для офисной сети.
Пакет от клиента приходит. Порт Docker опубликован. В tcpdump виден SYN. Сервер даже отправляет SYN-ACK. Но полноценное соединение не устанавливается или обрывается сразу после первых байтов.
Никакой мистики тут нет. Запрос вошёл через одну дверь, а ответ попытался выйти через другую.
Исходная схема
Возьмём сервер с двумя интерфейсами:
ethPUB— публичная сторона сервера, адрес203.0.113.10/29, шлюз203.0.113.1;ethLOC— второй провайдерский или локальный канал, адрес198.51.100.35/28, шлюз198.51.100.33.
На сервере работает Docker. Обычная контейнерная сеть — docker0, 172.17.0.0/16. Дополнительно может быть ещё один bridge, например amn0 с сетью 172.29.172.0/24.
Адреса в статье взяты из тестовых диапазонов RFC 5737. Они не предназначены для реальной сети: в командах нужно подставить свои адреса, интерфейсы, шлюзы и сети Docker.
Задача выглядит так:
- Всё, что относится к публичному адресу
203.0.113.10, должно отвечать черезethPUB. - Всё, что пришло на
198.51.100.35, должно отвечать черезethLOC. - Это правило должно работать и для опубликованных портов Docker.
Почему обычного правила from недостаточно
Для самого Linux-хоста решение кажется очевидным:
ip rule add from 198.51.100.35 lookup loc35
То есть: если пакет отправляется с адреса .35, ищи маршрут не в основной таблице, а в отдельной таблице loc35.
Для процессов, которые работают прямо на хосте, этого часто хватает. Но у ответа из Docker в момент выбора маршрута источник ещё другой.
Порядок получается примерно такой:
- Контейнер отправляет ответ с адреса
172.17.0.4. - Linux выбирает маршрут, пока source всё ещё равен
172.17.0.4. - Правило
from 198.51.100.35не срабатывает. - Система берёт default route из таблицы
main— через публичныйethPUB. - Уже позже, в
POSTROUTING, conntrack подменяет источник на198.51.100.35.
На проводе появляется пакет с источником .35, но физически он выходит через публичный интерфейс. В лучшем случае удалённая сторона его не принимает. В худшем — часть соединения работает, часть нет, а администратор успевает проверить всё, кроме порядка обработки пакета.
Ключевой момент: routing выполняется раньше финальной подмены адреса источника. Поэтому правило, которое смотрит только на внешний IP, не узнаёт ответ контейнера вовремя.
Что будем делать
Нам нужно запомнить, через какой адрес пришло конкретное соединение, а затем использовать эту информацию при маршрутизации ответа контейнера.
Для этого понадобятся три сущности:
CONNMARK— метка всего соединения в conntrack. Она сохраняется между пакетами и действует в обе стороны;fwmark, илиskb mark— метка конкретного пакета, которую умеет видетьip rule;- отдельная таблица маршрутизации
loc35, где default route направлен черезethLOC.
Логика будет такой:
- Пакет приходит на
198.51.100.35— соединению присваиваетсяCONNMARK 0xc0. - Контейнер формирует ответ.
- На пакете, который пришёл от Docker bridge, сохранённая метка соединения восстанавливается в
fwmark. - Правило
ip rule fwmark 0xc0отправляет пакет в таблицуloc35. - Ответ выходит через
198.51.100.33иethLOC.
Основная таблица main при этом остаётся простой: её default route ведёт только через публичный интерфейс.
Шаг 1. Добавляем отдельную таблицу маршрутизации
В /etc/iproute2/rt_tables добавим:
192 loc35
Число 192 — идентификатор таблицы. Для удобства используем такую же метку в шестнадцатеричном виде: 0xc0.
Совпадение номера таблицы и метки не обязательно. Это просто помогает через полгода понять собственную конфигурацию без археологической экспедиции по shell history.
Шаг 2. Создаём идемпотентный скрипт
Идемпотентный означает, что скрипт можно запускать повторно: он приводит систему к нужному состоянию и не плодит одинаковые правила при каждом старте.
Создадим /usr/local/sbin/dual-nic-pbr.sh:
#!/bin/bash
set -euo pipefail
PUB_IP=203.0.113.10
PUB_GW=203.0.113.1
PUB_DEV=ethPUB
LOC_IP=198.51.100.35
LOC_GW=198.51.100.33
LOC_DEV=ethLOC
LOC_NET=198.51.100.32/28
TABLE=192
MARK=0xc0
RULE_MARK_PRIO=99
RULE_FROM_PRIO=100
grep -qE "^[[:space:]]*${TABLE}[[:space:]]+loc35[[:space:]]*$" \
/etc/iproute2/rt_tables \
|| echo "${TABLE} loc35" >> /etc/iproute2/rt_tables
# В main оставляем default только через публичный интерфейс.
ip route replace default via "${PUB_GW}" dev "${PUB_DEV}"
# Отдельная таблица для второго адреса.
ip route replace "${LOC_NET}" dev "${LOC_DEV}" \
src "${LOC_IP}" table "${TABLE}"
ip route replace 172.17.0.0/16 dev docker0 \
table "${TABLE}" 2>/dev/null || true
ip route replace 172.29.172.0/24 dev amn0 \
table "${TABLE}" 2>/dev/null || true
ip route replace default via "${LOC_GW}" dev "${LOC_DEV}" \
table "${TABLE}"
# Удаляем старые копии правил и создаём их заново.
while ip rule del from "${LOC_IP}" table "${TABLE}" \
2>/dev/null; do :; done
while ip rule del fwmark "${MARK}" table "${TABLE}" \
2>/dev/null; do :; done
ip rule add fwmark "${MARK}" lookup "${TABLE}" \
priority "${RULE_MARK_PRIO}"
ip rule add from "${LOC_IP}" lookup "${TABLE}" \
priority "${RULE_FROM_PRIO}"
ensure_jump() {
local hook="$1" chain="$2"
iptables -t mangle -C "${hook}" -j "${chain}" 2>/dev/null \
|| iptables -t mangle -A "${hook}" -j "${chain}"
}
iptables -t mangle -N DUALNIC_PRE 2>/dev/null || true
iptables -t mangle -N DUALNIC_OUT 2>/dev/null || true
iptables -t mangle -F DUALNIC_PRE
iptables -t mangle -F DUALNIC_OUT
ensure_jump PREROUTING DUALNIC_PRE
ensure_jump OUTPUT DUALNIC_OUT
# На входе к вторичному IP помечаем соединение.
# Метку самого входящего пакета здесь не восстанавливаем.
iptables -t mangle -A DUALNIC_PRE -d "${LOC_IP}/32" \
-j CONNMARK --set-mark "${MARK}/0xffffffff"
# Восстанавливаем fwmark только на ответах от контейнеров.
iptables -t mangle -A DUALNIC_PRE -i docker0 \
-j CONNMARK --restore-mark \
--nfmask 0xffffffff --ctmask 0xffffffff
iptables -t mangle -A DUALNIC_PRE -i amn0 \
-j CONNMARK --restore-mark \
--nfmask 0xffffffff --ctmask 0xffffffff
# И на локально порождённых пакетах связанного соединения.
iptables -t mangle -A DUALNIC_OUT \
-j CONNMARK --restore-mark \
--nfmask 0xffffffff --ctmask 0xffffffff
Делаем скрипт исполняемым и запускаем:
chmod 755 /usr/local/sbin/dual-nic-pbr.sh
/usr/local/sbin/dual-nic-pbr.sh
Если у Docker есть пользовательские bridge-сети с именами вроде br-xxxxxxxxxxxx, их тоже нужно добавить в таблицу loc35 и в правила восстановления метки. Проверить реальные интерфейсы можно так:
docker network ls
ip -br address
Важно также не смешивать iptables-legacy и iptables-nft: правила должны попадать в тот же firewall backend, которым пользуется Docker.
Самое важное место во всей схеме
CONNMARK --restore-mark нельзя бездумно ставить на все пакеты в PREROUTING.
Если восстановить метку на входящем пакете клиента, получится следующее:
- Клиентский пакет приходит на
.35. - У соединения уже есть
CONNMARK 0xc0. - Эта метка восстанавливается в
fwmarkвходящего пакета. - После DNAT адрес назначения становится контейнерным, например
172.17.0.4. - Routing видит
fwmark 0xc0и смотрит вloc35. - Вместо доставки в Docker пакет уходит по default route через локальный шлюз.
Снаружи это выглядит особенно убедительно: SYN дошёл, SYN-ACK пришёл, но дальше сервер снова и снова повторяет SYN-ACK, а полезные данные до контейнера не добираются.
Поэтому правило простое:
CONNMARK --set-mark— на входящем соединении к вторичному IP;CONNMARK --restore-mark— только на пакетах, пришедших из Docker bridge, и при необходимости вOUTPUT;- не восстанавливать метку на всём входящем трафике подряд.
Шаг 3. Переживаем перезагрузку
Для Debian с ifupdown скрипт можно вызвать через post-up в конфигурации нужного интерфейса:
post-up /usr/local/sbin/dual-nic-pbr.sh
pre-down ip rule del from 198.51.100.35 table 192 2>/dev/null || true
Дополнительно удобно создать systemd unit /etc/systemd/system/dual-nic-pbr.service:
[Unit]
Description=Dual-NIC PBR for secondary IP
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/dual-nic-pbr.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
Включаем:
systemctl daemon-reload
systemctl enable --now dual-nic-pbr.service
После изменения правил старые записи conntrack могут продолжать жить без нужной метки. Для конкретного сервиса их можно сбросить, например так:
conntrack -D -d 198.51.100.35 --dport 46238 2>/dev/null || true
conntrack -D -s 198.51.100.35 --sport 46238 2>/dev/null || true
Команда оборвёт существующие соединения на этом адресе и порту, поэтому запускать её лучше осознанно, а не «для красоты».
Как проверить результат
Сначала смотрим правила и обе таблицы:
ip rule list
ip route show
ip route show table 192
iptables -t mangle -S DUALNIC_PRE
iptables -t mangle -S DUALNIC_OUT
Ожидаем увидеть:
99: from all fwmark 0xc0 lookup loc35
100: from 198.51.100.35 lookup loc35
В main default route должен вести через ethPUB. В loc35 — через 198.51.100.33 и ethLOC, плюс там должны быть подключённая сеть второго интерфейса и используемые Docker-сети.
Проверяем выбор маршрута без реального соединения:
ip route get 8.8.8.8 from 203.0.113.10
# via 203.0.113.1 dev ethPUB
ip route get 8.8.8.8 from 198.51.100.35
# via 198.51.100.33 dev ethLOC table loc35
Главная проверка — два tcpdump одновременно:
tcpdump -ni ethPUB host 198.51.100.35 and tcp port 46238
tcpdump -ni ethLOC host 198.51.100.35 and tcp port 46238
Правильный результат:
- на
ethLOCвидны и входящие пакеты к.35, и исходящие ответы от.35; - на
ethPUBнет пакетов этого соединения; - соединение проходит дальше TCP handshake и реально передаёт данные.
Проверяем метку соединения:
conntrack -L | grep 198.51.100.35 | grep dport=46238
У сессий к вторичному адресу ожидаем mark=192 — это десятичная запись 0xc0.
Для SOCKS5 можно сделать простой smoke test:
python3 - <<'PY'
import socket
for host in ("127.0.0.1", "198.51.100.35", "203.0.113.10"):
s = socket.create_connection((host, 46238), 2)
s.sendall(b"\x05\x01\x00")
print(host, s.recv(16).hex())
s.close()
PY
Ответ 0500 означает, что SOCKS5 принял вариант без аутентификации. Это уже проверка не просто открытого порта, а начала прикладного диалога.
Где можно больно ошибиться
1. Восстановить метку на всём PREROUTING
Это самая неприятная ошибка. Входящий пакет получает fwmark, попадает в таблицу второго интерфейса и после DNAT уходит мимо Docker. Restore делаем только с контейнерных bridge-интерфейсов.
2. Оставить офисные маршруты в main
Статические /22, /24 и другие «короткие» маршруты через ethLOC могут снова создать асимметрию — уже для ответов с публичного адреса. Маршруты, относящиеся ко второму каналу, лучше держать в его отдельной таблице.
3. Забыть про дополнительный Docker bridge
У Amnezia, Compose и пользовательских Docker networks могут быть свои интерфейсы. Если реальный ответ приходит не через docker0, правило восстановления метки его не увидит.
4. Включить строгий rp_filter
Strict reverse path filtering не любит многоканальные схемы и может отбрасывать вполне законные пакеты. На время настройки обычно используют rp_filter=0 или loose mode 2, а затем проверяют поведение отдельно для каждого интерфейса.
5. Проверить только открытие порта
Увидеть SYN и SYN-ACK недостаточно. Плохая схема иногда успешно начинает handshake, а ломается на ACK или первых байтах приложения. Проверять нужно весь путь: интерфейсы, conntrack, метку и прикладной ответ.
6. Забыть про старые соединения
Новые правила не перепишут историю уже созданной conntrack-сессии. После исправления политики тестируйте новым соединением или аккуратно удалите старую запись.
Плюсы и минусы схемы
Плюсы:
- публичные сервисы продолжают использовать обычный default route;
- второй IP получает симметричный путь без россыпи маршрутов в
main; - схема работает с опубликованными портами Docker;
- решение можно проверить по шагам и быстро откатить;
- тот же принцип масштабируется на другие адреса и каналы.
Минусы:
- нужно понимать порядок
mangle, routing, DNAT и SNAT; - каждый Docker bridge необходимо учитывать явно;
- конфигурацию нужно синхронизировать с сетевым менеджером и firewall backend;
- при добавлении третьего канала появятся новая таблица, новая метка и ещё один повод вести документацию.
Как откатить
Если нужно вернуть исходное состояние:
ip rule del from 198.51.100.35 table 192 2>/dev/null || true
ip rule del fwmark 0xc0 table 192 2>/dev/null || true
ip route flush table 192
iptables -t mangle -D PREROUTING -j DUALNIC_PRE 2>/dev/null || true
iptables -t mangle -D OUTPUT -j DUALNIC_OUT 2>/dev/null || true
iptables -t mangle -F DUALNIC_PRE 2>/dev/null || true
iptables -t mangle -F DUALNIC_OUT 2>/dev/null || true
iptables -t mangle -X DUALNIC_PRE 2>/dev/null || true
iptables -t mangle -X DUALNIC_OUT 2>/dev/null || true
systemctl disable --now dual-nic-pbr.service 2>/dev/null || true
После этого нужно убрать post-up из /etc/network/interfaces и вернуть штатную сетевую конфигурацию, если она менялась.
Короткий итог
Если у сервера два интерфейса, одного правила from <вторичный IP> для Docker может быть недостаточно. В момент выбора маршрута ответ контейнера ещё имеет внутренний адрес, поэтому Linux отправляет его через обычный default route.
Рабочая схема состоит из четырёх частей:
- В
mainостаётся default через публичный интерфейс. - Для второго адреса создаётся отдельная таблица маршрутизации.
- Входящее соединение к этому адресу получает
CONNMARK. - На ответе из Docker метка восстанавливается в
fwmark, иip ruleвыбирает нужную таблицу.
А главный контрольный вопрос звучит так: видим ли мы пакеты с источником второго IP на первом интерфейсе? Если видим — симметрии ещё нет. Если не видим, conntrack содержит правильную метку, а прикладной тест проходит — можно выдыхать.