Контейнер не достаёт до API, доступного только через VPN? Проксируйте его на хосте
Туннель живёт на хосте, поэтому у контейнера нет маршрута к частному адресу. Опубликуйте узкий обратный прокси и обращайтесь к нему как host.docker.internal.
Ваш сервис в Compose упирается в тайм-аут при обращении к API, который браузер на той же машине открывает без всяких жалоб. Эта разница и есть подсказка: работающий запрос уходит с хоста, где поднят туннель VPN, а зависающий запрос уходит из контейнера с собственным сетевым стеком. Ничто внутри контейнера не знает маршрутов туннеля, поэтому частный адрес остаётся недостижимым, как бы ни было записано имя. Решение — намеренный переход через хост, а не более хитрая таблица маршрутизации.
Начните с того, почему два пути различаются, и затем решите, где должен жить туннель.
Почему хост работает, а контейнер нет
Поднятие туннеля даёт хосту интерфейс, набор маршрутов и, как правило, запись резолвера от VPN. Контейнер ничего из этого не наследует: он стартует с одним интерфейсом на мосту движка и маршрутом по умолчанию, который указывает на этот мост. Маршруты, установленные на хосте, невидимы для всего, что работает внутри этого пространства имён.
Поэтому симптомы выглядят несогласованными, а не просто поломанными. Хост разрешает частное имя и подключается, потому что там есть и имя, и маршрут. Контейнер отправляет свой пакет на частный адрес, до которого ничто на пути через мост не доберётся, и запрос заканчивается тайм-аутом. Добавление имени в файл hosts контейнера ничего не меняет, потому что сам адрес недостижим оттуда, где находится контейнер.
Доберитесь до хоста изнутри контейнера
Процесс в контейнере, который набирает localhost, набирает сам контейнер. Docker Desktop публикует host.docker.internal как имя, указывающее обратно на машину, где работает движок; движки Linux оставляют его неопределённым, пока сервис не попросит, и именно это делает привязка ниже. С ней приложение нацеливается на имя и порт вместо частного адреса.
services:
app:
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
INTERNAL_API_HOST: "host.docker.internal:8666"Туннель остаётся на стороне хоста этого перехода, а порт в той переменной — тот, на котором будет слушать прокси. Контейнер, потерявший туннель, теперь падает на одном известном переходе вместо попытки маршрута, которого у него никогда не было.
Выберите, где живёт туннель
Для сервиса Compose, которому нужен частный API, возможны три формы, и они различаются тем, какой процесс владеет туннелем. Клиент VPN может работать внутри образа контейнера, что требует открыть устройство туннеля и выдать связанную с ним возможность администрирования сети. Приложение может разделять сетевое пространство имён отдельного контейнера туннеля, и тогда оба процесса видят один стек и одну таблицу маршрутизации. Либо туннель может остаться на хосте, с узким перенаправляющим прокси из хоста в контейнер.
Первые два тянут в проект файлы профилей, поведение переподключения и политику резолвера и ставят каждый сервис в файле в зависимость от здоровья туннеля. Для одного вызова API это слишком много связей. Для стека разработки, читающего единственную внутреннюю конечную точку, перенаправление на стороне хоста — наименьшее изменение, которое всё же оставляет учётные данные там, где ими управляет команда платформы.
Опубликуйте один узкий путь на хосте
Начните с того, что наследует перенаправитель: запустите его под учётной записью, которой принадлежит туннель, и его исходящие сокеты подхватят маршруты хоста и резолвер хоста. Один блок location в nginx покрывает один путь.
server {
listen 8666;
location /search/ {
proxy_pass http://10.20.30.40:8090/;
proxy_set_header Host internal-api.example;
proxy_read_timeout 60s;
}
}Считайте адрес и порт заготовками и подставьте ту пару, на которой API действительно слушает. Каждый следующий upstream заслуживает собственного блока location, и каждый блок должен пропускать только те пути, которые вызывает этот сервис, чтобы прокси не стал общей дверью в частную сеть. Привяжите слушателя к адресу моста для клиентов, приходящих через host-gateway, к loopback только когда клиент разделяет сетевое пространство имён хоста, и никогда к 0.0.0.0. Выберите и порт, который ещё свободен: занятый номер оставляет прокси не слушающим, а контейнер продолжает упираться в тайм-аут, и это неотличимо от сетевой неисправности, с которой вы начали.
Порты, имена и решение о TLS
Разрешение имён делится так же, как маршрутизация. Публичные запросы контейнера идут через резолвер движка, поэтому частное имя, разрешаемое на хосте, не разрешится внутри контейнера. Прокси — тот компонент, который разрешает частное имя, потому что наследует резолвер хоста.
TLS — решение, которое откладывают, а потом оно объясняет следующую ошибку. Если прокси передаёт байты нетронутыми, контейнер видит собственный сертификат API и нуждается во внутреннем CA в своём хранилище доверия. Если прокси завершает TLS, это доверие переходит на хост, и приложение начинает строить ссылки из получаемого заголовка host. Выберите одну схему и применяйте её к каждому ресурсу, потому что перенаправление, выданное другой, указывает на имя хоста, которое контейнер не может разрешить.
Проверьте изнутри сервиса
Отправляйте запрос из контейнера, а не с хоста: обычный curl по пути прокси, затем собственная проверка здоровья приложения. Когда прокси отвечает, а приложение всё равно падает, сравните три вещи: базовый URL, который оно строит, любую переменную прокси в его окружении и CA, которому оно доверяет. Всё это есть в окружении и журналах контейнера, и исправление любого из них не трогает VPN.
Запишите выбранную схему в README проекта, пока она свежа. Иначе следующий человек, столкнувшийся с этим тайм-аутом, проведёт тот же день заново открывая, что туннель живёт на хосте.