Сетевое взаимодействие контейнерных приложений
Предыдущие темы курса рассматривали контейнер как отдельный объект: сначала как воспроизводимый артефакт сборки, затем как управляемый процесс в среде выполнения. Однако реальные приложения почти никогда не сводятся к одному контейнеру. Веб-сервер обращается к базе данных, фоновая задача публикует сообщения в брокер очередей, шлюз проксирует запросы во внутренние сервисы. Все эти взаимодействия требуют сети — и устроены они в контейнерной среде иначе, чем в классической многопроцессной системе на одном хосте.
Сеть в контейнерной среде имеет двухуровневый характер. С одной стороны, сетевой стек контейнера изолирован от хоста средствами ядра операционной системы; контейнер не видит интерфейсов хоста и не может произвольно слушать его порты. С другой стороны, эта изоляция должна быть прозрачно «прорезана» в нужных местах: контейнеры должны находить друг друга по именам, а внешние клиенты — попадать в нужный контейнер через известный порт. Понимание того, как именно среда выполнения создаёт эти точки прозрачности, и составляет основное содержание темы.
Материал темы строится по схеме «от изоляции к публикации». Открывается тема кратким напоминанием базовых сетевых понятий, нужных дальше по тексту. Затем рассматривается, как устроено изолированное сетевое пространство контейнера и какие виртуальные интерфейсы обеспечивают его связь с внешним миром. Далее — как Docker предоставляет несколько режимов подключения (drivers) с разным компромиссом между изоляцией и удобством. После этого разбираются механизмы публикации сервисов наружу и обнаружения сервисов друг другом внутри одного хоста. Завершает тему обзор типичных эксплуатационных рисков и ограничений локальной контейнерной сети, которые мотивируют переход к оркестрации в последующих темах.
Минимальные сведения о компьютерных сетях
Дальнейший материал опирается на ряд понятий из курса компьютерных сетей. Глубокое их изложение выходит за пределы темы, но без короткого напоминания текст превратится в перечень терминов. Поэтому ниже собраны те и только те сведения, которые потребуются по ходу разбора контейнерной сетевой модели. Студент, желающий разобраться с предметом основательнее, найдёт ссылки в дополнительных материалах темы.
IP-адреса, подсети и сетевые интерфейсы
Каждое устройство, участвующее в сетевом обмене, идентифицируется IP-адресом (англ. Internet Protocol address). В курсе мы практически всегда имеем дело с адресами IPv4 — четыре десятичных числа, разделённые точками: например, 192.168.1.10. Адрес сам по себе не даёт полной информации о расположении узла; к нему нужна подсеть (англ. subnet) — диапазон адресов, считающихся «локальными». Подсеть задаётся длиной префикса в записи 192.168.1.0/24: первые 24 бита определяют сеть, оставшиеся 8 — конкретный узел в ней.
Подсеть нужна узлу, чтобы решить, как отправить пакет. Если адрес назначения попадает в локальную подсеть, пакет передаётся в неё напрямую, на канальном уровне. Если нет — пакет уходит через шлюз по умолчанию (англ. default gateway), специально настроенный узел, отвечающий за передачу пакетов в другие сети. Сопоставление «куда пойдёт пакет с данным адресом назначения» хранится в таблице маршрутизации (англ. routing table) узла; именно эту таблицу будет иметь свой экземпляр у каждого контейнера.
Связь узла с сетью реализуется через сетевой интерфейс (англ. network interface) — программную сущность, к которой привязан IP-адрес и через которую идут пакеты. Физическому Ethernet-порту обычно соответствует интерфейс eth0, петлевому интерфейсу — lo (адрес 127.0.0.1, видимый только изнутри узла). На одном узле может быть несколько интерфейсов; в контейнерной среде к этому добавятся виртуальные интерфейсы, создаваемые ядром по запросу.
Транспортные порты и сокеты
IP-адрес определяет узел, но не приложение. На одном узле обычно работают десятки сетевых процессов одновременно: веб-сервер, СУБД, агенты мониторинга. Чтобы пакет дошёл до нужного процесса, на транспортном уровне используется понятие порта (англ. port) — целое число от 1 до 65535. Сочетание «IP-адрес + порт» называется сокетом (англ. socket) и однозначно указывает конкретную точку обмена в сети.
Большинство сетевых соединений в курсе — это TCP-соединения (англ. Transmission Control Protocol): надёжный поток байтов с установлением и закрытием соединения. Реже встречается UDP (англ. User Datagram Protocol) — обмен отдельными пакетами без подтверждений, применяется, например, для DNS-запросов. На уровне работы с контейнерами различия между этими протоколами чаще всего не критичны; критично то, что приложение должно «слушать» (англ. listen) определённый порт, чтобы клиенты могли к нему подключиться.
Адрес, на котором приложение слушает, имеет значение. Привязка к 127.0.0.1 (петлевой интерфейс) делает порт доступным только локальным процессам узла. Привязка к конкретному IP-адресу одного из интерфейсов — только клиентам, идущим через этот интерфейс. Привязка к 0.0.0.0 означает «на всех интерфейсах»: порт доступен и локально, и снаружи. Различение этих режимов окажется важным при разборе публикации портов контейнеров.
Разрешение имён и трансляция адресов
Запоминать IP-адреса вручную неудобно, поэтому в реальных сетях работает разрешение имён через систему DNS (англ. Domain Name System). DNS — это иерархическая распределённая база данных, в которой именам узлов ставятся в соответствие их IP-адреса. Когда приложение обращается к имени db.example.com, операционная система отправляет DNS-запрос настроенному серверу имён и получает в ответ адрес. Параметры DNS на узле задаются файлом /etc/resolv.conf. В контейнерной среде среда выполнения подменяет содержимое этого файла, чтобы контейнеры обращались к встроенному DNS-серверу Docker, — но механизм остаётся стандартным.
Второе понятие, неизбежное при разборе контейнерных сетей, — трансляция адресов NAT (англ. Network Address Translation). NAT — это техника, при которой узел-посредник переписывает заголовки проходящих пакетов: подменяет адрес источника, адрес назначения или оба. Самый частый сценарий — преобразование частных адресов локальной сети в один публичный адрес шлюза при выходе наружу; ответы шлюз возвращает в обратном направлении, восстанавливая исходный адрес. Среда выполнения Docker использует NAT для двух задач: чтобы наружу контейнер был виден под адресом хоста (исходящие соединения) и чтобы пакет с порта хоста попадал в нужный контейнер (опубликованные порты). Подробного знания механики NAT от студента не требуется; достаточно помнить, что трансляция выполняется в ядре хоста, прозрачно для приложения и не оставляет следов в самом контейнере.
Базовая модель «клиент — сервер» и фильтрация трафика
Большинство сетевых взаимодействий в курсе укладываются в модель «клиент — сервер»: серверный процесс заранее открыл сокет и ждёт подключений на известном порту, клиент устанавливает соединение по адресу и порту сервера, обменивается с ним сообщениями и закрывает соединение. Серверный процесс может одновременно обслуживать множество клиентов, каждое соединение различается своей четвёркой «адрес-порт клиента — адрес-порт сервера».
Помимо собственно передачи пакетов, в современных операционных системах действует межсетевой фильтр (англ. firewall) — подсистема ядра, проверяющая каждый пакет по заданным правилам. На Linux это netfilter, управляемый утилитами iptables или nftables. Фильтр может пропускать, отбрасывать или модифицировать пакеты; именно через эти модификации реализуются и проброс портов, и NAT для исходящего трафика контейнеров. Знать конкретный синтаксис правил студенту не нужно — но важно понимать, что результаты команд docker run -p ... и docker network create ... наблюдаемы как изменения в межсетевом фильтре хоста, и при диагностике сетевых проблем именно туда стоит смотреть в первую очередь.
Сетевые пространства контейнеров
Сетевая изоляция контейнера — частный случай более общего механизма пространств имён (англ. namespaces), рассмотренного в теме 3 в разделе об изоляции контейнеров. Сетевое пространство имён (англ. network namespace) — это отдельный экземпляр сетевого стека ядра: собственный набор сетевых интерфейсов, своя таблица маршрутизации, свои правила фильтрации, своя таблица сокетов. Процессы, помещённые в одно сетевое пространство, видят только его интерфейсы; процессы хоста видят интерфейсы хоста; пересечения нет, пока его явно не настроить.
С практической точки зрения это означает, что внутри контейнера команда ip addr покажет только два интерфейса: локальную петлю lo и одно виртуальное Ethernet-устройство, через которое контейнер подключён к внешней сети. Никаких физических интерфейсов хоста, никаких других контейнеров в этом списке нет — даже если на хосте одновременно работают десятки контейнеров. Изоляция сетевого стека делает контейнер минимально похожим на отдельную виртуальную машину с точки зрения сетевой адресации, но при этом не требует виртуализации оборудования.
Виртуальные интерфейсы и базовая модель подключения
Передача пакетов между изолированным пространством контейнера и сетью хоста реализуется через пару виртуальных интерфейсов (англ. veth pair). Это устройство, которое ведёт себя как двусторонний сетевой кабель: один его конец помещается в сетевое пространство контейнера, другой остаётся в пространстве хоста. Всё, что записывается в один конец, выходит из другого. Внутри контейнера этот конец обычно называется eth0; на хосте — имеет техническое имя вида vethXXXX и сам по себе для пользовательских задач не используется.
Простое существование пары виртуальных интерфейсов ещё не делает контейнер достижимым по сети. Хостовый конец пары должен быть подключён к какой-либо коммутирующей структуре, через которую пакеты пойдут дальше. В стандартной конфигурации Docker эта структура — программный сетевой мост (англ. bridge) с именем docker0. Все хостовые концы пар veth от контейнеров одной мостовой сети подключены к этому мосту, что позволяет пакетам ходить между контейнерами и наружу через таблицу маршрутизации хоста.
Маршрутизация в самом контейнере при этом устроена просто: задан адрес шлюза по умолчанию, совпадающий с адресом моста на стороне хоста. Любой пакет, не относящийся к локальной подсети контейнера, отправляется через этот шлюз и далее — обычными средствами хоста. Внешние ответы возвращаются по тому же пути, и Docker обеспечивает корректную трансляцию исходного адреса (англ. source NAT), чтобы внешним сервисам контейнер был виден под адресом хоста.
Внутренняя связность и внешний доступ — два разных вопроса
При работе с сетью контейнеров полезно с самого начала разделять два вопроса, которые часто смешивают. Первый: «могут ли два контейнера обращаться друг к другу?» Второй: «может ли клиент извне обратиться к контейнеру?» Ответы на них даются разными механизмами и регулируются разными настройками.
Внутренняя связность — это вопрос о том, какие контейнеры подключены к одной и той же сети и какие правила фильтрации между ними действуют. Если оба контейнера находятся в стандартной мостовой сети docker0, они могут обращаться друг к другу по IP-адресам, но не по именам — стандартный мост не предоставляет встроенного разрешения имён. Если же оба контейнера подключены к пользовательской сети, созданной командой docker network create, между ними автоматически работает встроенный DNS-сервис Docker, и они могут обращаться друг к другу по имени контейнера.
Внешний доступ — это вопрос о том, какие порты хоста проброшены в контейнер. Без явного проброса контейнер недоступен извне, даже если он слушает порт 0.0.0.0:8080: с точки зрения хоста контейнер находится в собственном пространстве адресов, и снаружи не виден. Эта на первый взгляд избыточная двухуровневая модель оказывается удобной в эксплуатации: она позволяет запустить десяток контейнеров, каждый из которых внутри слушает порт 8080, и не получить конфликта на хосте — наружу публикуется только то, что нужно, через явно выбранные порты.
Сетевые драйверы и режимы подключения
Конкретный способ организации сети для контейнера в Docker задаётся сетевым драйвером (англ. network driver). Драйвер — это реализация конкретной модели сетевого подключения; на одном хосте могут одновременно существовать сети нескольких типов, и каждый контейнер подключается к одной или нескольким из них. Базовая поставка Docker включает несколько встроенных драйверов; на практике в учебном курсе и в большинстве однохостовых сценариев достаточно двух из них — bridge и host.
Мостовая сеть как основной режим
Драйвер bridge — стандартный сетевой режим Docker. Именно он используется по умолчанию, если при запуске контейнера не указано иное. Для контейнеров создаётся изолированное сетевое пространство, в нём — интерфейс eth0, подключённый через пару veth к программному мосту в пространстве хоста. Этот мост действует как ethernet-коммутатор второго уровня: пакеты между контейнерами одной мостовой сети передаются напрямую, без участия таблицы маршрутизации хоста.
Стандартный мост docker0 создаётся автоматически при установке Docker, к нему подключаются все контейнеры, для которых явно не указана сеть. Однако опираться на него для прикладных задач не рекомендуется. Как уже было отмечено, он не предоставляет разрешения имён, что вынуждает разработчиков жёстко прописывать IP-адреса и сильно усложняет воспроизводимость окружения. Вместо этого следует создавать пользовательскую мостовую сеть командой docker network create my-net. С точки зрения пакетной коммутации она устроена так же, как стандартный мост, но дополнительно включает встроенный DNS-сервер: каждый контейнер этой сети доступен другим её участникам по имени, заданному при запуске.
Пользовательские сети также удобны для логического разделения групп связанных контейнеров. На одном хосте могут одновременно существовать несколько таких сетей, контейнеры из разных сетей не видят друг друга на канальном уровне. Это даёт минимальную, но рабочую модель сетевой сегментации — она не заменяет полноценные сетевые политики оркестратора, но позволяет разделить, например, фронтальную часть приложения и внутренние сервисы на двух разных мостах.
Режим хоста и особые драйверы
Драйвер host принципиально отличается от мостового. Контейнер, запущенный в этом режиме, не получает собственного сетевого пространства имён: он использует сетевой стек хоста напрямую. С точки зрения сети это означает, что приложение внутри контейнера слушает порты хоста так же, как обычный процесс — без проброса, без NAT, без дополнительных интерфейсов.
Режим host имеет ограниченную область применения. С одной стороны, он минимизирует сетевую нагрузку: пакеты не проходят через дополнительный мост и не подвергаются трансляции адресов, что бывает заметно для приложений с высокой пропускной способностью. С другой стороны, он полностью устраняет сетевую изоляцию контейнера: контейнер видит все интерфейсы хоста, может слушать любой порт и потенциально конфликтовать с другими процессами хоста. На одном хосте нельзя запустить два контейнера в режиме host, оба слушающих один и тот же порт. По этим причинам режим host используется в качестве оптимизации в специфических случаях, а не как режим общего назначения.
Помимо bridge и host, среда Docker предоставляет несколько специализированных драйверов: none (полное отсутствие сети, для контейнеров вспомогательных задач), macvlan и ipvlan (для случаев, когда контейнеру нужен собственный MAC- или IP-адрес в физической сети), overlay (для распределённых развёртываний на нескольких хостах в режиме Docker Swarm). В учебном курсе они подробно не разбираются: задачи, которые они решают, относятся либо к узким сетевым сценариям, либо к оркестрации, рассматриваемой в последующих темах.
Публикация сервисов и межконтейнерное взаимодействие
Создание сети и подключение к ней контейнеров — только первая часть задачи. Прикладной смысл сети раскрывается через два конкретных механизма: публикацию сервисов наружу и обнаружение сервисов внутри сети.
Проброс портов и публикация наружу
Проброс портов (англ. port mapping или port publishing) — основной способ сделать сервис, работающий в контейнере, доступным внешним клиентам. При запуске контейнера ключом -p HOST_PORT:CONTAINER_PORT указывается соответствие между портом хостовой машины и портом внутри контейнера. Среда выполнения настраивает правила трансляции в межсетевом фильтре хоста; внешний пакет, пришедший на указанный порт хоста, перенаправляется на указанный порт контейнера. Обратный путь обеспечивается стандартными механизмами трансляции состояний.
При выборе порта на стороне хоста часто возникает соблазн использовать тот же номер, что и внутри контейнера. Это удобно для разработки, но создаёт неявные ограничения в эксплуатации: на одном хосте нельзя запустить два контейнера, публикующих один и тот же хостовый порт. По этой причине в реальных конфигурациях номер хостового порта обычно либо выбирается осознанно из выделенного диапазона, либо поручается среде выполнения через синтаксис -p :CONTAINER_PORT, при котором Docker подбирает свободный порт автоматически.
Полезно ясно различать инструкцию EXPOSE в Dockerfile и фактический проброс портов при запуске. EXPOSE — это документация намерения: она сообщает, какие порты приложение собирается слушать, но сама по себе не открывает доступ извне. Реальная публикация формируется только в момент запуска контейнера и определяется параметрами docker run или эквивалентной конфигурацией оркестратора. Смешение этих двух понятий — частый источник недоразумений: студенту кажется, что приложение должно быть доступно, потому что в Dockerfile есть EXPOSE 8080, а на самом деле никакого проброса не выполнено.
Имена сервисов и встроенное разрешение имён
Внутри пользовательской сети Docker контейнеры обращаются друг к другу не по IP-адресам, а по именам. Среда выполнения поднимает встроенный DNS-сервер; в каждом контейнере, подключённом к пользовательской сети, файл /etc/resolv.conf указывает на этот сервер. Когда приложение в контейнере web обращается к адресу db, DNS-запрос разрешается во внутренний IP-адрес контейнера db в той же сети.
С точки зрения архитектуры приложения это даёт важное свойство: имена сервисов фиксируются в конфигурации приложения, а конкретные адреса — нет. Если контейнер пересоздан и получил другой IP-адрес, имя осталось тем же, и приложение продолжит работать без правок. Это первый шаг от модели «сервер с фиксированным IP» к модели «сервис с устойчивым именем», которая в полной мере раскрывается в оркестраторе.
Стандартный мост docker0 встроенного DNS не имеет; для разрешения имён необходимо явно создать пользовательскую сеть. По этой причине рекомендация «не использовать стандартный мост в прикладных конфигурациях» имеет не стилистический, а практический характер: только пользовательская сеть даёт устойчивое именование, а значит — переносимую конфигурацию приложения.
Разделение фронтальной и внутренней связности
Типичная многосервисная конфигурация на одном хосте требует разделения сетевых ролей. Часть сервисов обслуживает внешних клиентов (фронтальный веб-сервер, API-шлюз) — они публикуют порты наружу. Другая часть работает только во внутренней сети приложения (база данных, очередь сообщений, кеш) — наружу они не публикуются принципиально и должны быть недоступны для прямых внешних обращений.
Простейший способ обеспечить это разделение — две пользовательские сети: одна, в которой находятся фронтальные сервисы и шлюз, и вторая, к которой подключены шлюз и внутренние сервисы. Шлюз присутствует в обеих сетях; внутренние сервисы видят его, но не видят фронтального трафика напрямую. Такая структура — базовый сетевой шаблон контейнерных приложений: «опубликовать минимум, оставить остальное во внутреннем периметре». Декларативное описание подобной топологии естественным образом ложится в формат docker compose, рассматриваемый в одной из следующих тем.
Сетевые риски и эксплуатационные ограничения
Сетевая модель Docker удобна и компактна, но именно её простота создаёт несколько типичных эксплуатационных рисков. Они проявляются не в учебных сценариях, а на этапе сопровождения, когда конфигурация переходит из рук разработчика в эксплуатацию.
Первый и наиболее распространённый риск — избыточная сетевая открытость. Привычка публиковать порт «на всякий случай» приводит к тому, что внутренние сервисы — базы данных, административные интерфейсы, отладочные эндпоинты — оказываются доступны извне. На рабочей машине разработчика это незаметно, но при переносе конфигурации на сервер с публичным сетевым адресом ошибка превращается в открытую дверь. Правило здесь простое: публиковаться наружу должны только порты, действительно нужные внешним клиентам; всё остальное должно оставаться во внутренней пользовательской сети.
Второй риск связан с используемым адресом привязки на стороне хоста. По умолчанию -p 8080:8080 биндится на адрес 0.0.0.0, то есть на все интерфейсы хоста, включая внешние. Если требуется ограничить публикацию только локальной машиной, привязку нужно делать явно: -p 127.0.0.1:8080:8080. Это типовая ошибка при работе с серверами, имеющими несколько сетевых интерфейсов: разработчик считает, что сервис «слушает локально», а на деле он слушает все интерфейсы.
Третье ограничение — пределы локальной сетевой модели. Стандартные сети Docker (мостовые и пользовательские) работают в пределах одного хоста. Если приложение нужно распределить по нескольким хостам, эти средства уже недостаточны: требуются либо специализированные драйверы вроде overlay в режиме Docker Swarm, либо переход к оркестратору с собственной сетевой моделью. Локальная контейнерная сеть удобна для разработки, тестирования и одно-узловой эксплуатации; при выходе за этот сценарий она перестаёт быть достаточной, и попытки обойти ограничение «вручную» обычно приводят к хрупким конфигурациям.
Наконец, в эксплуатации значимым становится документирование сетевых зависимостей между сервисами. В контейнерной среде эти зависимости естественным образом скрыты внутри частных сетей, и их легко потерять из виду. Какой сервис обращается к какому, на каких портах, по какой сети — всё это должно быть явно зафиксировано в декларативном описании приложения и в эксплуатационной документации. Без такой фиксации диагностика проблем превращается в реверс-инжиниринг: при сетевом сбое непонятно, какой компонент чего ждал и где именно разорвалось соединение.
Итоги темы
Сетевое взаимодействие контейнерных приложений строится на двухуровневой модели: с одной стороны — изолированное сетевое пространство имён каждого контейнера, с другой — явные точки прозрачности, через которые контейнер сообщается с внешним миром и с другими контейнерами. Связь между уровнями обеспечивают пары виртуальных интерфейсов и программные мосты в пространстве хоста; пакеты ходят между мостом и контейнером прозрачно, а наружу — через таблицу маршрутизации и трансляцию адресов хоста.
Конкретный режим сети задаётся драйвером. Для подавляющего большинства учебных и однохостовых задач используется драйвер bridge, причём предпочтительна не стандартная сеть docker0, а явно созданная пользовательская сеть со встроенным разрешением имён. Режим host отказывается от изоляции в пользу прямого доступа к стеку хоста и применим только в узких сценариях оптимизации. Специальные драйверы (macvlan, ipvlan, overlay) решают задачи, выходящие за рамки одного хоста, и подробно рассматриваются в темах, посвящённых оркестрации.
Прикладная сторона сети — это два механизма: проброс портов наружу и обнаружение сервисов внутри. Первый делает контейнер доступным внешним клиентам через явный выбор хостового порта; второй позволяет контейнерам обращаться друг к другу по устойчивым именам, не завися от конкретных IP. Сочетание этих механизмов и нескольких пользовательских сетей даёт основной сетевой шаблон контейнерных приложений: «публиковать только то, что должно быть видно снаружи; всё внутреннее оставлять в частных сетях».
Эксплуатационные риски сетевой модели предсказуемы и связаны не с её сложностью, а с привычкой разработчика к удобной открытой среде. Избыточно опубликованные порты, привязка к 0.0.0.0 без необходимости, недокументированные зависимости между сервисами — типичные источники проблем. Локальные средства Docker рассчитаны на один хост; задача распределённого размещения и сетевого взаимодействия между узлами выходит за пределы этой темы и решается уже на уровне оркестратора.