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

Обложка

Как заставить сервис отвечать через тот же канал, по которому пришёл клиент

Это очередная практическая часть цикла о собственных сетевых инструментах и управлении исходящим трафиком.

Сегодня разберём ситуацию, которая поначалу выглядит почти мистически. У Linux-сервера два сетевых интерфейса. На одном — публичный адрес, SSH, VPN и обычный доступ в интернет. На втором — ещё один адрес, через который должен быть доступен отдельный сервис, например SOCKS-прокси для офисной сети.

Пакет от клиента приходит. Порт Docker опубликован. В tcpdump виден SYN. Сервер даже отправляет SYN-ACK. Но полноценное соединение не устанавливается или обрывается сразу после первых байтов.

Никакой мистики тут нет. Запрос вошёл через одну дверь, а ответ попытался выйти через другую.

Исходная схема

Возьмём сервер с двумя интерфейсами:

На сервере работает Docker. Обычная контейнерная сеть — docker0, 172.17.0.0/16. Дополнительно может быть ещё один bridge, например amn0 с сетью 172.29.172.0/24.

Адреса в статье взяты из тестовых диапазонов RFC 5737. Они не предназначены для реальной сети: в командах нужно подставить свои адреса, интерфейсы, шлюзы и сети Docker.

Задача выглядит так:

  1. Всё, что относится к публичному адресу 203.0.113.10, должно отвечать через ethPUB.
  2. Всё, что пришло на 198.51.100.35, должно отвечать через ethLOC.
  3. Это правило должно работать и для опубликованных портов Docker.

Почему обычного правила from недостаточно

Для самого Linux-хоста решение кажется очевидным:

ip rule add from 198.51.100.35 lookup loc35

То есть: если пакет отправляется с адреса .35, ищи маршрут не в основной таблице, а в отдельной таблице loc35.

Для процессов, которые работают прямо на хосте, этого часто хватает. Но у ответа из Docker в момент выбора маршрута источник ещё другой.

Порядок получается примерно такой:

  1. Контейнер отправляет ответ с адреса 172.17.0.4.
  2. Linux выбирает маршрут, пока source всё ещё равен 172.17.0.4.
  3. Правило from 198.51.100.35 не срабатывает.
  4. Система берёт default route из таблицы main — через публичный ethPUB.
  5. Уже позже, в POSTROUTING, conntrack подменяет источник на 198.51.100.35.

На проводе появляется пакет с источником .35, но физически он выходит через публичный интерфейс. В лучшем случае удалённая сторона его не принимает. В худшем — часть соединения работает, часть нет, а администратор успевает проверить всё, кроме порядка обработки пакета.

Ключевой момент: routing выполняется раньше финальной подмены адреса источника. Поэтому правило, которое смотрит только на внешний IP, не узнаёт ответ контейнера вовремя.

Что будем делать

Нам нужно запомнить, через какой адрес пришло конкретное соединение, а затем использовать эту информацию при маршрутизации ответа контейнера.

Для этого понадобятся три сущности:

Логика будет такой:

  1. Пакет приходит на 198.51.100.35 — соединению присваивается CONNMARK 0xc0.
  2. Контейнер формирует ответ.
  3. На пакете, который пришёл от Docker bridge, сохранённая метка соединения восстанавливается в fwmark.
  4. Правило ip rule fwmark 0xc0 отправляет пакет в таблицу loc35.
  5. Ответ выходит через 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.

Если восстановить метку на входящем пакете клиента, получится следующее:

  1. Клиентский пакет приходит на .35.
  2. У соединения уже есть CONNMARK 0xc0.
  3. Эта метка восстанавливается в fwmark входящего пакета.
  4. После DNAT адрес назначения становится контейнерным, например 172.17.0.4.
  5. Routing видит fwmark 0xc0 и смотрит в loc35.
  6. Вместо доставки в Docker пакет уходит по default route через локальный шлюз.

Снаружи это выглядит особенно убедительно: SYN дошёл, SYN-ACK пришёл, но дальше сервер снова и снова повторяет SYN-ACK, а полезные данные до контейнера не добираются.

Поэтому правило простое:

Шаг 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

Правильный результат:

Проверяем метку соединения:

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-сессии. После исправления политики тестируйте новым соединением или аккуратно удалите старую запись.

Плюсы и минусы схемы

Плюсы:

Минусы:

Как откатить

Если нужно вернуть исходное состояние:

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.

Рабочая схема состоит из четырёх частей:

  1. В main остаётся default через публичный интерфейс.
  2. Для второго адреса создаётся отдельная таблица маршрутизации.
  3. Входящее соединение к этому адресу получает CONNMARK.
  4. На ответе из Docker метка восстанавливается в fwmark, и ip rule выбирает нужную таблицу.

А главный контрольный вопрос звучит так: видим ли мы пакеты с источником второго IP на первом интерфейсе? Если видим — симметрии ещё нет. Если не видим, conntrack содержит правильную метку, а прикладной тест проходит — можно выдыхать.

#network