Автодеплой на сервер за NAT: self-hosted runner GitHub Actions без единого открытого порта

Автодеплой на сервер за NAT: self-hosted runner GitHub Actions без единого открытого порта

Мой проект — ИИ-агент для рекламы кафе — работает на старом компьютере в самом кафе. Сначала я деплоил с ноутбука: скрипт заходил на сервер по SSH и запускал обновление. Потом в проекте появился второй человек. Он не программист, ему нужно править тексты промптов, и запускать скрипты из терминала на Windows ему незачем.

Хотелось простого: поправил файл на сайте GitHub, нажал «Commit», и через несколько минут изменения работают в боте. В статье — как я сделал это через self-hosted runner GitHub Actions. Ни одного входящего порта, ни одного ключа от сервера в GitHub, и права у runner'а урезаны до одной команды.

Два способа деплоить из GitHub Actions

Способ 1: обычный runner GitHub заходит на сервер по SSH. Самый распространённый рецепт: в секреты репозитория кладётся приватный SSH-ключ, и шаг workflow выполняет ssh server 'deploy.sh'.

Способ 2: runner на самом сервере. На сервер ставится агент GitHub Actions. Он сам подключается к GitHub, ждёт задачи и выполняет их локально.

Runner GitHub + SSH Runner на сервере
Направление соединения GitHub → сервер (входящее) Сервер → GitHub (исходящее)
Нужен открытый порт Да, SSH в интернет Нет
Ключ от сервера Лежит в секретах GitHub Не нужен
Ограничить доступ по IP Нельзя: у GitHub тысячи адресов Не нужно
Если отвалился DNS-адрес или проброс порта Деплой не работает Работает
Стоимость для приватного репозитория Минуты Actions (2000 в месяц бесплатно) Бесплатно

У меня SSH и так открыт в интернет, но держать в GitHub ключ с правом входа на сервер в кафе не хотелось: его нельзя привязать к адресу, а утечка секретов из CI — не такая уж редкость. Runner на сервере решает это в корне: GitHub ничего не знает о том, как попасть на сервер.

Важное предупреждение про публичные репозитории

Self-hosted runner выполняет код из репозитория. В публичном репозитории любой может открыть pull request из форка с изменённым workflow, и, при неудачных настройках, этот код выполнится на вашем сервере. GitHub прямо не рекомендует self-hosted runner'ы для публичных репозиториев.

У меня репозиторий приватный, и писать в него могут только два человека. Плюс ниже runner лишён почти всех прав. Но если ваш репозиторий публичный, этот рецепт не для вас.

Проверяем, что сервер достаёт до GitHub

Runner общается не только с github.com, но и с несколькими служебными доменами. Мой сервер в России, и часть внешних сервисов оттуда недоступна, поэтому сначала проверка:

for h in github.com api.github.com codeload.github.com objects.githubusercontent.com \
         pipelines.actions.githubusercontent.com results-receiver.actions.githubusercontent.com \
         broker.actions.githubusercontent.com; do
  printf "%-48s " $h; curl -s -o /dev/null -m 10 -w "%{http_code}\n" https://$h/ || echo FAIL
done

Код ответа не важен: 404 тоже означает, что соединение и TLS прошли. Плохо только 000 или FAIL. У меня все домены ответили. Если какой-то заблокирован, его можно пустить через SSH-туннель на VPS, как я делал для OpenAI и Telegram (об этом отдельная статья).

Отдельный пользователь без прав

Runner выполняет всё, что написано в workflow. Поэтому главное решение — под каким пользователем он работает. Запускать его от моего основного пользователя нельзя: тот состоит в группе docker, а это фактически root, и может читать .env со всеми ключами API.

Я завёл отдельного пользователя ghrunner:

sudo useradd --create-home --shell /bin/bash ghrunner

У него нет sudo, нет группы docker, и он не может читать файлы проекта, закрытые правами. Но деплой как-то должен запускаться. Для этого — ровно одна разрешённая команда.

Одна команда через sudo

Деплой у меня — скрипт server-update.sh в каталоге проекта. Он работает от основного пользователя (у меня он называется app): проверяет состояние, делает бэкап, собирает образы, применяет миграции, проверяет здоровье системы и откатывается при сбое. Подробно о нём — в следующей статье.

Runner'у я разрешаю запускать от имени app только маленькую обёртку /usr/local/sbin/cafe-deploy:

#!/usr/bin/env bash
# sudo -n -u app /usr/local/sbin/cafe-deploy <полный хеш коммита> [force]
set -euo pipefail

sha="${1:-}"
mode="${2:-}"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Нужен полный хеш коммита" >&2; exit 2; }
[[ -z "$mode" || "$mode" == force ]] || { echo "Второй аргумент — только force" >&2; exit 2; }

export HOME=/home/app
cd /opt/myproject
if [[ "$mode" == force ]]; then export FORCE=1; else unset FORCE; fi
exec scripts/server-update.sh "$sha"

Правило sudo, /etc/sudoers.d/cafe-deploy:

ghrunner ALL=(app) NOPASSWD: /usr/local/sbin/cafe-deploy

Почему именно так:

  • Обёртка принадлежит root (install -m 755 -o root). ghrunner не может её изменить и подсунуть свою команду.
  • Аргументы проверяются строго: 40 шестнадцатеричных символов и необязательное слово force. Никаких путей, опций или имён веток, через которые можно протащить что-то неожиданное.
  • export HOME=/home/app: деплой делает git fetch с ключом из ~/.ssh пользователя app, а sudo в зависимости от настроек может оставить HOME от вызывающего пользователя.
  • sudo -n в workflow — не спрашивать пароль. Если правило не сработало, шаг сразу упадёт, а не повиснет в ожидании ввода.

Проверяем, что ограничения работают:

$ sudo -u ghrunner sudo -n -l
User ghrunner may run the following commands on server:
    (app) NOPASSWD: /usr/local/sbin/cafe-deploy

$ sudo -u ghrunner sudo -n -u app /bin/id
sudo: a password is required

$ sudo -u ghrunner docker ps
permission denied while trying to connect to the Docker daemon socket ...

$ sudo -u ghrunner cat /opt/myproject/.env
cat: /opt/myproject/.env: Permission denied

Код, который деплоится, всё равно приходит из того же репозитория, а server-update.sh — его часть. Так что обёртка не защищает от злонамеренного коммита в main: это вопрос доступа к репозиторию. Она защищает от другого: workflow, в том числе из другой ветки, не может сделать на сервере ничего, кроме «обнови систему до коммита X обычным путём». Ни выполнить произвольную команду, ни прочитать секреты.

Установка runner'а

GitHub → репозиторий → Settings → Actions → Runners → New self-hosted runner → Linux. Страница покажет команды и одноразовый токен регистрации, он действует около часа.

sudo -u ghrunner bash -c '
  mkdir -p ~/actions-runner && cd ~/actions-runner
  curl -sSL -o r.tgz https://github.com/actions/runner/releases/download/v2.338.1/actions-runner-linux-x64-2.338.1.tar.gz
  tar xzf r.tgz && rm r.tgz'
sudo ~ghrunner/actions-runner/bin/installdependencies.sh

cd ~ghrunner/actions-runner
sudo -u ghrunner ./config.sh --unattended --url https://github.com/<owner>/<repo> \
  --token <ТОКЕН> --name cafe-server --labels cafe-prod --work _work
sudo ./svc.sh install ghrunner     # служба systemd от имени ghrunner
sudo ./svc.sh start
  • --labels cafe-prod — метка, по которой workflow выбирает именно этот runner.
  • svc.sh install ghrunner — служба systemd, которая запускается при загрузке от нужного пользователя.

В журнале службы должно появиться Connected to GitHub и Listening for Jobs. Памяти runner в простое занимает около 130 МБ. Для машины с 4 ГБ это заметно, но терпимо.

Все шаги я собрал в скрипт install-runner.sh <токен>, чтобы при переустановке сервера не вспоминать их.

Workflow

.github/workflows/deploy.yml:

name: Деплой

on:
  push:
    branches: [main]
    paths-ignore: ['docs/**', 'README.md']
  workflow_dispatch:
    inputs:
      force:
        description: Перезаписать воркфлоу, изменённые в интерфейсе n8n на сервере
        type: boolean
        default: false

concurrency:
  group: deploy-prod
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: [self-hosted, cafe-prod]
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v5

      - name: Тесты
        run: ./scripts/dev.sh test

      - name: Деплой
        run: sudo -n -u app /usr/local/sbin/cafe-deploy "$GITHUB_SHA" ${{ inputs.force && 'force' || '' }}

Что здесь сделано намеренно:

  • Два триггера. Каждый пуш в main уезжает на prod сам. Кнопка «Run workflow» во вкладке Actions позволяет передеплоить вручную, в том числе с флагом force.
  • paths-ignore — правка документации не пересобирает систему.
  • concurrency с cancel-in-progress: false — два пуша подряд не запустят два деплоя одновременно, второй подождёт. Отменять идущий деплой посередине нельзя: можно оставить систему между миграцией и запуском.
  • Тесты — на самом сервере, перед деплоем. Отдельного «облачного» шага с тестами нет: это ещё минуты Actions и ещё одно окружение, которое может отличаться от боевого. Об этом ниже.
  • $GITHUB_SHA — деплоится ровно тот коммит, на котором запущен workflow, а не «что сейчас в main».

Внутри server-update.sh есть своя защита от параллельного запуска (flock). Поэтому старый способ — деплой с ноутбука по SSH — я оставил как запасной. Если GitHub недоступен, он работает, а одновременно с автоматическим деплоем просто откажется стартовать с сообщением «Деплой уже идёт».

Первый запуск упал — и это было полезно

Первый автодеплой упал через 30 секунд на шаге «Тесты»:

not ok 1 - test
  error: 'test failed'
# Error: Cannot find module '.../lib/test'

На ноутбуке тесты проходили. Разница оказалась в версии Node.js: на ноутбуке стояла 18, на сервере 22. Команда node --test test/ с папкой в аргументе в Node 18 запускает все тесты из папки, а в Node 22 аргументы считаются шаблонами файлов, и папку она пытается загрузить как модуль. Исправление — шаблон, который понимают обе версии:

"test": "node --test test/*.test.js"

Два вывода:

  1. Prod при этом не пострадал: тесты идут до деплоя, и шаг деплоя просто не запустился.
  2. Тесты на том же окружении, где работает система, нашли то, что не нашли бы тесты на ноутбуке. Если бы тесты гонялись в облаке GitHub на «какой-нибудь» версии Node, ошибка всплыла бы позже и в другом месте. Заодно это напоминание обновить Node на ноутбуке до версии сервера.

Как это выглядит для человека, который правит промпты

Промпты лежат в репозитории обычными текстовыми файлами. Коллега:

  1. открывает файл на github.com и нажимает карандаш;
  2. правит текст и нажимает «Commit changes»;
  3. смотрит во вкладку Actions: жёлтый кружок — идёт, зелёная галочка — готово, красный крестик — что-то не так, бот работает по-старому.

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

При ошибке GitHub по умолчанию присылает письмо автору коммита. Отдельной системы уведомлений настраивать не пришлось.

Чего этот подход не даёт

  • Защиты от плохого коммита в main. Всё, что попало в main, уезжает на prod. Значит, незаконченная работа живёт в отдельных ветках, а проверки — тесты и проверки после запуска в server-update.sh — должны быть достаточно строгими.
  • Независимости от сервера. Если сервер выключен, деплоя нет, и задачи ждут в очереди. Для одной машины в кафе это честно: деплоить некуда.
  • Обновлений runner'а «из коробки» в закрытой сети. Runner обновляет себя сам, скачивая новые версии с GitHub. Если GitHub с сервера пропадёт, однажды runner станет слишком старым, и его придётся обновить вручную.

Итог

  • Сервер сам ходит на GitHub. Входящих портов и ключей от сервера в GitHub нет.
  • Runner работает под пользователем без docker, sudo и доступа к секретам.
  • Единственное, что он может, — вызвать одну проверенную команду: «обнови систему до коммита X».
  • Тесты идут на том же окружении, что и prod, и запускаются до деплоя.
  • Человек без навыков программирования правит текст на сайте GitHub, и через несколько минут бот работает по-новому.

Подписывайтесь на мой канал в Tg про разработку – Sleepless Tech