Ваш шаг деплоя зависает на внутреннем имени хоста, хотя та же команда мгновенно отвечает с ноутбука, а сборка клиента на обеих машинах идентична. Job выполняется на runner, размещённом GitHub: виртуальной машине, созданной для одного этого job в облаке GitHub, без маршрута в ваше частное адресное пространство, без записи resolver для внутренней зоны и без сертификата, который примет частный endpoint. Этому ограничению отвечают только две схемы, и ни одну нельзя включить позже, не перестраивая workflow. Выберите между ними до первой строки YAML, потому что миграция задним числом стоит дороже первой попытки.

Выбор между runner, размещённым там, где сеть уже заканчивается, и туннелем, который живёт ровно один job. Оба годами тянут продакшн-деплои и отказывают по-разному.

Где на самом деле проходит граница

Всё, что вы ставите на ноутбук, останавливается на ноутбуке, а всё, что ставите во время job, исчезает вместе с runner. Постоянного сетевого namespace, к которому клиент мог бы остаться привязанным между запусками, нет, поэтому каждый запуск начинается в одном и том же недостижимом состоянии. Azure документирует эту границу для агентов, размещённых Microsoft: связи VPN и ExpressRoute в частные сети там не поддерживаются, а runner, размещённые GitHub, несут ту же форму ограничения, а не отдельный механизм. Читайте это как свойство продукта, а не как дефект, который надо обойти.

Ни одна настройка репозитория это не смягчает. Метка job решает, какой пул возьмёт работу, а размещённая метка означает машину вне вашего периметра на весь запуск.

Частная доступность поэтому должна исходить из того, что принадлежит вам. Runner там, где сеть уже заканчивается, или туннель, который живёт один job. Остальная статья о том, чтобы заплатить за один из вариантов осознанно, а не обнаружить счёт в продакшне.

Решайте по частоте запусков, а не по привычке

Берите собственный runner в VPC или офисной сети, когда деплои приходят несколько раз в день, когда job трогает больше одного частного порта или когда команда платформы уже отвечает за патчи и ротацию учётных данных. Job на такой машине резолвит и маршрутизирует как любой другой хост за файрволом. Сетевая работа делается один раз, при установке, а не при каждом запуске.

Стройте туннель внутри job, когда доступ случается редко и вам не нужен долгоживущий агент с учётными данными в вашу сеть. Job на несколько минут становится собственным сетевым клиентом и ничего после себя не оставляет — часто в этом весь смысл.

Оцените цену туннеля, прежде чем выбирать его. Каждый запуск платит за материализацию профиля, старт клиента, готовность и демонтаж. Туннель, который поднимается, но никогда не проверяется, даёт деплой, падающий на полпути и оставляющий частное состояние в виде, которого никто не планировал. Повтор после этого редко бывает чистым, а вторую ошибку обычно списывают на версию клиента.

Писать профиль так, чтобы шаг не мог прерваться

Держите профили и учётные данные вне репозитория и материализуйте их из секретов в том шаге, которому они нужны. Порядок решает, выживет ли job: файл, созданный под sudo, принадлежит root, и непривилегированная оболочка шага уже не может в него писать.

- name: Bring up OpenVPN
  run: |
    umask 077
    profile="$RUNNER_TEMP/client.ovpn"
    printf '%s\n' "$VPN_PROFILE" > "$profile"
    : > "$RUNNER_TEMP/openvpn.log"
    printf '%s\n' "$VPN_CREDENTIALS" > "$RUNNER_TEMP/credentials.txt"
    sudo openvpn --daemon openvpn-job \
      --config "$profile" \
      --auth-user-pass "$RUNNER_TEMP/credentials.txt" \
      --writepid "$RUNNER_TEMP/openvpn.pid" \
      --log "$RUNNER_TEMP/openvpn.log"

Обе детали в этом блоке существуют для последующих шагов. С umask 077 профиль попадает в режим 0600 и принадлежит пользователю runner, sudo по-прежнему может его читать, а клиент пишет свой лог в файл, которым runner уже владеет, так что цикл готовности читает этот лог без второго sudo. --writepid записывает PID, на который будет нацелен демонтаж.

Профилю с аутентификацией по клиентскому сертификату нужен ключ, записанный так же, а профилю со статическим вызовом — соответствующий режим аутентификации. Профилям, которые проталкивают опции DNS, нужен openvpn-systemd-resolved на Ubuntu и Debian, потому что само проталкивание на базовом образе ничего не меняет.

Готовность — это проба, а не пауза

Фиксированный sleep держится ровно до того момента, когда концентратор замедлится, и тогда шаг деплоя идёт раньше, чем появился маршрут. Ошибка всплывает уже внутри инструмента деплоя как timeout к частному хосту, что ничего не говорит о туннеле.

- name: Await private endpoint
  run: |
    for attempt in $(seq 1 30); do
      if getent hosts private.example.internal > /dev/null && \
         timeout 3 bash -c 'exec 3<>/dev/tcp/private.example.internal/8443' 2>/dev/null; then
        echo "endpoint answered after $attempt attempts"
        exit 0
      fi
      sleep 2
    done
    grep -Ei 'error|verify|auth' "$RUNNER_TEMP/openvpn.log" | tail -40
    exit 1

Цикл смотрит на два наблюдения, потому что любое из них может быть сломанной половиной. getent hosts доказывает, что протолкнутый resolver отвечает за это имя, а открытие сокета доказывает, что на порту, который будет использовать деплой, кто-то слушает; профилю только с маршрутом нужен буквальный адрес и удалённая строка getent.

Когда цикл истекает, лог — единственное, что разделяет три разные неисправности: отклонённый сертификат, неверный режим аутентификации и маршрут, который так и не пришёл. Фильтрация перед tail держит этот вывод достаточно коротким, чтобы его можно было прочитать, пока job ещё падает. Читайте первую ошибку, а не последнюю строку, потому что клиент, который не может аутентифицироваться, повторяется, пока цикл не сдастся. ip -4 route show dev tun0 отвечает на более простой вопрос, был ли маршрут вообще протолкнут, и стоит одной строки в том же цикле.

Демонтаж и вопрос об артефакте

Закрывайте job шагом под защитой if: always(), который выполняет sudo kill "$(sudo cat "$RUNNER_TEMP/openvpn.pid")" и ничего больше. На общем собственном runner никогда не убивайте по имени процесса: другой job может владеть клиентом с тем же бинарником и похожими аргументами. Ошибка тогда падает на job, который отработал вторым, и его лог указывает на workflow, ничего не нарушивший.

Защита важна, потому что упавший деплой всё равно оставляет живой туннель. Без always() шаг очистки пропускается ровно тогда, когда он нужен, и следующий запуск стартует против уже подключённого клиента.

Остаётся один вопрос, прежде чем принять любую из схем: нужен ли стадии сборки частный маршрут вообще? Агент деплоя, который уже сидит внутри сети, может забрать одобренный артефакт, и это держит обычные сборки в стороне от частного пути. Меньше job, держащих туннель, — меньше мест, где может спрятаться устаревший маршрут.

Похожие статьи