Skip to content

Выкладки: из Git — в кластеры и на хосты ​

Раздел хаба «Выкладки» выкладывает ваши приложения из репозитория Git: манифестом в кластеры Kubernetes, Helm-релизом или сценарием хаба. Сборку образов и тесты делает ваш CI (GitHub Actions, GitLab CI, Gitea) или вы сами — nkt берёт на себя вторую половину: «готово → выложить».

Выкладки

Почему так: ключи от хостов и кластеров остаются на хабе. CI получает только право «дёрнуть» вебхук конкретного конвейера, а что и куда выкладывать, решает описание на хабе — из запроса берутся лишь ветка, коммит и тег.

Экспериментальная возможность

Выкладка меняет стеки на хостах — поднимает, заменяет и убирает их, а при смене хоста или имени стека убирает старый. Перед выкладкой — «Сухой прогон», обкатывайте на тестовых хостах, проверяйте журнал задания. Та же плашка — вверху раздела «Выкладки».

Конвейер ​

«Новый конвейер» → имя и описание в YAML. Правка — в окне с диффом, у описания есть история редакций с заметками; перед записью оно проверяется. Секретов в описании нет — они в «Секретах».

yaml
repo: https://github.com/org/app.git
ref: main                 # push в эту ветку — выкладка
tags: "v*"                # и/или теги репозитория по шаблону

action: manifest          # manifest | helm | script
manifests: [deploy/k8s.yaml]
clusters: [prod]          # кластеры хаба
# group: prod             # или кластеры хостов группы

# poll: 5m                # опрашивать репозиторий
# registry: ghcr.io/org/app
# registry_tags: '^v?\d+\.\d+\.\d+$'
# registry_poll: 5m

Все поля ​

ПолеДля чегоЗначение
repoвсегдаhttps://…, ssh://… или git@host:path. Логин и пароль в адрес не пишутся — токен или ключ задаются в «Секретах»
refветкаPush в неё — выкладка её вершины. Нужен ref или tags
tagsтегиШаблон (glob, v*): тег репозитория по нему — выкладка этого тега
actionвсегдаmanifest, helm или script
manifestsmanifestСписок файлов в репозитории
clustersmanifest, helmИмена кластеров из раздела «Кластеры»
groupmanifest, helmГруппа хостов: все кластеры, чей первый control plane в этой группе. Можно вместе с clusters
helm.repo_name, helm.repo_urlhelmРепозиторий чартов (helm repo add) или oci://…
helm.chart, helm.versionhelmЧарт и версия (пусто — последняя)
helm.release, helm.namespacehelmИмя релиза и namespace
helm.valueshelmФайл values в репозитории
helm.tag_keyhelmКлюч values, куда подставить тег образа: image.tag
scriptscriptСценарий хаба в репозитории (deploy/deploy.nkt)
pollтриггерИнтервал опроса ветки, не короче 1m
registryтриггерОбраз, за тегами которого следить: ghcr.io/org/app, org/app (Docker Hub), registry.example.com:5000/app
registry_tagsтриггерРегулярное выражение подходящих тегов; по умолчанию ^v?\d+(\.\d+){1,2}$
registry_pollтриггерИнтервал проверки registry, по умолчанию 5m

Все значения проверяются по отдельности: ветки, теги и пути — по строгим шаблонам, git вызывается с --, файлы читаются только внутри checkout (выход наружу, в том числе ссылкой, запрещён).

Подстановки ​

В манифестах и values подставляются:

ПодстановкаЗначение
{{nkt.tag}}тег из запроса, кнопки или registry; без тега — короткий коммит (12 знаков)
{{nkt.commit}}полный коммит
{{nkt.ref}}ветка или тег репозитория

В сценарии те же значения приходят параметрами TAG, COMMIT, REF (param TAG "тег образа" в начале сценария).

Действия ​

  • manifest — файлы манифестов из репозитория склеиваются, в них делаются подстановки, и на control plane каждого кластера идёт kubectl apply (через API узла, по тому же каналу, что и остальные запросы к хосту). Каждое применение попадает в библиотеку манифестов хаба («Кластеры» → «Манифесты») с заметкой pipeline <имя> <коммит>.
  • helm — helm upgrade --install в каждом кластере: values из файла, тег — в tag_key. На control plane нужен Helm — если его нет, поставьте кнопкой «Установить Helm» во вкладке Kubernetes узла.
  • script — сценарий хаба из репозитория: docker stack … up, git clone, перезапуск службы, wait … http — всё, что умеет язык сценариев. Кластер не нужен.

Кластеры берутся только в статусе «готов». Если выкладка не удалась в части кластеров, задание кончается ошибкой «не удалась в N из M», а в журнале видно, где и почему.

Compose-стек ​

Действие compose выкладывает docker compose-стек из репозитория на хосты хаба — по очереди:

yaml
repo: https://codeberg.org/me/shop.git
ref: main
action: compose
compose:
  file: deploy/docker-compose.yml   # compose-файл в репозитории
  project: shop                     # стек на хосте: /srv/compose/shop
  hosts: [web1, web2]               # или group: prod; машина хаба — localhost
  files: [deploy/nginx.conf, deploy/conf/]  # что ещё нужно стеку
  wait_timeout: 5m                  # ждать подъёма и healthcheck
  site: shop.example.com            # после выкладки проверить по HTTPS (или блоком — см. ниже)
  • В compose-файле и файлах рядом делаются подстановки: image: ghcr.io/org/shop:{{nkt.tag}}.

  • Файлы из files (каталоги — целиком) должны лежать внутри каталога compose-файла: на хосте стек — этот каталог, и относительные ссылки compose (./nginx.conf) должны остаться верными. Только текстовые файлы; двоичным место в образе.

  • На каждом хосте: файлы пишутся в /srv/compose/<project> с историей версий, затем docker compose config проверяет стек — если не принял, файлы возвращаются как были. Дальше заданием хоста — pull и up -d --remove-orphans --wait: задание ждёт, пока контейнеры поднимутся и пройдут healthcheck. Разовые сервисы (restart: "no", например заведение администратора в примере Forgejo) завершаются — если завершились только они и с кодом 0, а остальное работает и здорово, выкладка успешна. Следующий хост — только после успеха предыдущего; первая неудача останавливает выкладку.

  • Без Docker — Podman (podman compose): ожидание — пока все контейнеры проекта работают и здоровы.

  • Перед первым хостом хаб спрашивает у всех хостов, есть ли docker (или podman) и работает ли compose. Если где-то нет — выкладка не начинается, и ни один хост не тронут: иначе часть хостов обновилась бы, а на остальных стек остался бы прежним.

  • Только готовые образы: сервис с build: без image: хаб отвергнет до обращения к хостам — сборку делает CI. Чужой compose-файл с build: (без форка) — через images: в описании конвейера: хаб поставит сервису готовый образ с pull_policy: always (образ из описания всегда берётся из registry, даже если тот же тег уже на хосте) и уберёт build::

    yaml
    compose:
      file: docker-compose.yml          # из github.com/postmanlabs/httpbin
      images:
        httpbin: kennethreitz/httpbin   # сервис: образ ({{nkt.tag}} можно)
      ports:
        httpbin: []                     # не публиковать 80:80 из файла: 80/443 нужны прокси сайта

    ports: заменяет публикации сервиса целиком (["127.0.0.1:8080:80"]), пустой список убирает их — в чужом файле без форка.

  • Имя образа — адрес[:порт]/путь:тег, без http:// и https://: Docker сам ходит в registry по https. Образ из переменной (image: ${APP_IMAGE:-ghcr.io/org/app:latest}) раскрывается значениями .env конвейера — по раскрытому имени хаб подбирает ключ registry из «Секретов». Имя со схемой или без адреса (/test/app:1 — пустая переменная перед /) хаб отвергает в описании, а на хосте — до pull, с именем сервиса и тем, что исправить.

  • pull_policy. При missing (if_not_present) или nevercompose pull не скачивает образ, который уже есть на хосте, — тег latest не обновится, поднимется прежний. Журнал выкладки называет такие сервисы; чтобы образ брался из registry — pull_policy: always (в compose-файле или переменной .env) или образ в images:.

  • up после pull. Образы скачиваются отдельным шагом (с ключами registry из «Секретов»), а up запускается с --pull never — берёт только что скачанное и в registry второй раз не ходит (иначе при pull_policy: always он пошёл бы туда уже без ключа). При compose.pull: false флаг не ставится. Compose на хосте без up --pull (очень старый docker compose, podman-compose) — флаг не ставится, журнал предупреждает. Если up всё же упал на скачивании образа, журнал называет причину и что на хосте на самом деле: по ID контейнеров до и после up — «работает прежняя версия», «стек и до выкладки не работал», «пересозданы до ошибки» или «контейнеров нет».

  • Порты — только на 127.0.0.1. Публикация без адреса (8080:80) слушает на всех адресах, и docker открывает её своими правилами iptables в обход ufw и firewalld. Поэтому хаб ставит таким публикациям адрес bind: — по умолчанию 127.0.0.1 (8080:80 → 127.0.0.1:8080:80, 443 → 127.0.0.1::443, в длинной записи — host_ip): наружу стек выходит через прокси сайта. Публикации с указанным адресом остаются как есть; bind_force: true заменяет и их. Открыть наружу — bind: 0.0.0.0 или явный адрес в ports: ("0.0.0.0:25:25"). Запись с переменной (${PORT}:80) не разобрать — журнал предупредит. Что стало с каждым портом — в журнале выкладки и сухого прогона.

  • Один порт хоста — одна публикация. 0.0.0.0:8080 уже включает 127.0.0.1:8080, поэтому две такие публикации (в одном сервисе или в разных, в короткой или длинной записи) не поднимутся вместе: второй bind падает с «port is already allocated». Хаб проверяет это по итоговому compose-файлу (после ports: и bind:): сухой прогон называет пару как проблему, выкладка отказывает ещё до обращения к хостам. Разные конкретные адреса (127.0.0.1 и 192.168.1.5) и разные протоколы (53/tcp и 53/udp) — не конфликт.

  • .env стека задаётся в «Секретах» конвейера: хранится на хабе зашифрованным, на хост пишется с правами 0600, в историю версий и журналы не попадает. На хосты он попадает следующей выкладкой — после записи нажмите «Выложить» (вебхук и опрос репозитория на смену .env не срабатывают). Если .env изменился с прошлой выкладки, контейнеры пересоздаются принудительно (--force-recreate, и у docker, и у podman), чтобы взять новые значения.

  • .env — не окружение контейнера. docker compose берёт его только для подстановки ${ИМЯ} в сам compose-файл. В контейнер переменная попадает, если сервис на неё ссылается (environment: X: ${X}), подключает env_file: .env или она перечислена в env_keys. Сухой прогон называет переменные .env, которые не попадут ни в один контейнер.

  • Секреты, записанные в чужом compose-файле значениями (APP_SECRET: replace-me…), .env не перекрывает — env_keys: в конвейере: хаб в копии файла заменяет значения этих переменных на ${ИМЯ}, сами значения — из .env конвейера. Каких-то нет в .env — выкладка не начнётся, сухой прогон их назовёт.

    yaml
    compose:
      env_keys:
        umami: [APP_SECRET, TWO_FACTOR_ENCRYPTION_KEY, DATABASE_URL]
        db: [POSTGRES_PASSWORD]

Compose по ссылке. В окне нового конвейера — вставьте ссылку на compose-файл (GitHub …/blob/<ветка>/<путь>, GitLab …/-/blob/…, Codeberg/Gitea/Forgejo …/src/branch/…, их raw-варианты), выберите хосты — описание заполнится само: репозиторий, ветка, путь, имя стека. Дальше — обычная правка с диффом. «Примеры» — готовые конвейеры, каждый проверен выкладкой; подробности и шаблоны .env — examples/ репозитория. Выбранный пример помнится: повторное «Заполнить описание» (скажем, после смены хостов) снова заполнит его целиком.

Для своей ссылки хаб скачивает сам compose-файл (один коммит, без истории; закрытый репозиторий — ключами конвейера из «Секретов») и собирает описание по нему:

  • сайт — веб-сервис, найденный по портам образов (EXPOSE из registry; без них — ports/expose compose), базы, кэши и очереди (postgres, mysql, redis…) не берутся; порт — внутри контейнера. Рядом с кнопкой появляется выбор «Сайт»: «авто», любой сервис файла или «без сайта»; смена выбора пересобирает описание. Блок site: остаётся закомментированным — впишите имя и раскомментируйте;
  • файлы рядом — относительные тома (./nginx.conf) → files:;
  • сервисы со сборкой (build: без image:) → images: с просьбой указать готовый образ;
  • образы без версии (latest) — комментарий «закрепите тег»;
  • публикации наружу (не на 127.0.0.1) у остальных сервисов — закомментированный ports:: bind по умолчанию оставит их на loopback;
  • переменные ${…} — шаблон .env в комментарии: значения по умолчанию — как есть, похожие на пароль или ключ — <секрет>, ${X:?…} — «обязательна».

Окно описания одинаково у нового и у сохранённого конвейера на compose: у сохранённого («Описание» в списке) блок «по ссылке» уже заполнен по описанию — ссылка (строка # compose: …, которую пишет «Заполнить описание», а без неё — по repo:, ref: и compose.file), хосты, имя стека, сервис сайта; «Заполнить описание» пересобирает описание по файлу, и перед записью видна разница. Имя конвейера — только для чтения. Хосты и имя стека в этом блоке правят описание сразу — только строки hosts: и project:, остальное не трогается; и наоборот, правка этих строк в тексте меняет поля. Раньше выбранный там хост попадал в описание лишь по «Заполнить описание», и «Сохранить» записывал прежний.

Имя стека (compose.project) — имя проекта docker compose на хосте: каталог /srv/compose/<имя>/ (compose-файл, файлы рядом, .env), контейнеры <имя>-<сервис>-1, сеть <имя>_default, тома <имя>_<том>. По нему выкладка обновляет и удаление убирает именно этот стек; двум конвейерам на одном хосте нужны разные имена. Пусто — имя репозитория из ссылки.

Смена хоста или имени стека. Хаб помнит, где и под каким именем стек выложен последней удачной выкладкой:

  • Новое имя стека на том же хосте — замена: прежний стек останавливается перед подъёмом нового (его порты и имена контейнеров не считаются занятыми), новый не поднялся — прежний запускается обратно, поднялся — прежний убирается. Данные не переносятся: у нового свои тома и каталог, он начнёт с пустыми; каталог прежнего (с ./data) — в /srv/compose/.nkt-removed/, откуда его можно забрать.
  • Другой хост — выкладка идёт на новый, прежний ей не нужен. После успеха стек с прежнего хоста убирается; если тот недоступен или удалён из хаба, выкладка всё равно удачна, а у конвейера появляется пометка «стек … остался на …» с кнопками «Убрать» (когда хост вернётся) и «Забыть». Данные на новый хост тоже не переносятся.
  • Сухой прогон пишет, что будет со старым стеком (уберётся, заменится или останется на недоступном хосте), и предупреждает о данных; имя контейнера (container_name), занятое контейнером другого проекта, — проблема до выкладки, а не ошибка compose up.
  • Имя хоста в описании сравнивается без учёта регистра и пробелов; неизвестное — с подсказкой похожих имён. Сайт конвейера при переезде, если домен ещё указывает на прежний хост, — понятная строка «смените DNS-запись перед выкладкой».
  • Удаление конвейера убирает стек там, где он выложен этим конвейером, и оставшиеся старые стеки на доступных хостах — см. «Удаление конвейера compose» ниже.

Закрытый репозиторий без ключей: окно говорит «репозиторий закрыт — задайте имя и „Секреты“, затем „Заполнить описание“ снова», а описание пока собирается по одной ссылке — с заглушками <сервис из compose> и <порт контейнера> в site::

ПримерЧто показывает
httpbinодин контейнер
Uptime Kumacompose-файл проекта как есть, данные в ./data
umami + PostgreSQLсекреты из .env (env_keys), закреплённая версия (images)
n8n + PostgreSQLтри сервиса, файл рядом (files), всё из .env проекта
Forgejo + PostgreSQLбез мастера установки — администратор из .env разовым сервисом; второй порт наружу — SSH 2222 (ports с 0.0.0.0)
WordPress + MariaDBсайт за HTTPS-прокси (X-Forwarded-Proto)
Plausibleтри сервиса с ClickHouse, каталог конфигов (files)

Примеры

Compose по ссылке

Хосты проверяются сразу при выборе: рядом с именем — «docker с compose — готов», «нет docker и podman» или «docker без compose». Где нет — кнопка «Установить Docker» (или «Установить compose»): docker.io и плагин compose из репозитория дистрибутива, фоновым заданием хоста в стандартном окне журнала. Уже стоящий docker (в том числе docker-ce из репозитория Docker) не трогается — доставляется только compose. Та же проверка — в мастере «Сайтов», когда цель — сервис стека.

Сухой прогон — кнопка в окне «Выложить» и в редакторе конвейера (там проверяется текущий текст, в том числе несохранённый). Делает всё, что сделала бы выкладка, кроме изменений: берёт репозиторий, делает подстановки, собирает файлы стека, и на каждом хосте:

  • docker или podman и версия compose;
  • стек новый или есть — какие файлы появятся, изменятся, останутся, что с .env;
  • compose config на копии стека во временном каталоге рядом со стеками (сам стек и контейнеры не трогаются, копия удаляется);
  • сервисы и образы: есть ли образ в registry (только манифест, без скачивания), скачан ли на хост. «В registry нет» — ошибка; «проверить не удалось» (закрытый registry без входа, podman без skopeo) — предупреждение;
  • порты хоста из публикаций: занятый другим процессом (не этим стеком) — проблема: up на нём упадёт;
  • архитектура образа против архитектуры хоста (uname -m): образ только для amd64 на arm64-хосте — проблема (exec format error).
  • имена образов после подстановки .env: http(s):// в адресе или пустой адрес (/путь) — проблема; pull_policy не always, а образ уже на хосте — предупреждение «выложится прежний»; pull_policy: never без образа — проблема; build: из каталога, которого на хосте нет (context: ../..), — предупреждение, при pull_policy: build — проблема;
  • ключи registry из «Секретов», которые не подходят ни одному образу стека (опечатка в адресе ключа или образа), — предупреждение.

Ещё сухой прогон проверяет — проблемы (выкладка или сайт не пройдут): ${VAR} в compose без значения в .env (если «переменная» — на деле кусок значения со знаком $, например хеш пароля $2y$10$…, проверка называет файл и строку: в compose такой $ удваивается — $$, в .env значение берётся в одинарные кавычки); демон docker не запущен; мало места для образов или памяти; кто держит 80/443 на хосте (не прокси сайта — certbot и прокси не встанут); имя уже в чужой конфигурации nginx и читает ли nginx conf.d; AAAA-запись имени не на хост; DNS не на хост и порт 80 снаружи закрыт; nkt на хосте старее хаба. Предупреждения: образы без закреплённой версии (latest), сервисы без healthcheck (--wait ждёт только запуска), стек уже есть на хосте, а этот конвейер его не выкладывал, или его выкладывает и другой конвейер. Сведения: свободная память и место, годный сертификат (сколько осталось) или выпуск нового.

Не поднялся стек при настоящей выкладке — хост дописывает в журнал состояние контейнеров (compose ps -a) и последние строки журналов упавших сервисов, а ошибка называет причину: «порт 127.0.0.1:8080 занят», «образ не для этой архитектуры», «контейнер httpbin завершился (код 1)», «не прошёл healthcheck».

Кнопка открывает окно с галочками проверок — по умолчанию включены все: Docker и compose, описание стека, образы, порты хоста, ресурсы, healthcheck, стек на хосте, сайт (DNS, снаружи, хост, сертификат и nginx), версия nkt на хосте. Репозиторий и разбор описания проверяются всегда. Снятая проверка пропускается (хост не ходит в registry, не смотрит порты и т. д.), в журнале — строка «не проверялось: …». Выбор запоминается у конвейера на хабе — общий для администраторов; запоминаются снятые галочки, так что проверка, добавленная в новой версии, будет включена сама. Настоящая выкладка галочки не учитывает: её проверки (движок, демон, .env) — всегда. Старый nkt на хосте галочек не знает и проверяет всё — лишнее хаб в журнал не выводит.

Итог — задание хаба с журналом: «выкладка прошла бы» или число проблем. В журналах выкладки и сухого прогона проблемы (✗) — жирным красным, предупреждения (?, !) — жирным оранжевым. Запись в историю выкладок не делается.

Сухой прогон

Пример: httpbin. В репозитории nkt — examples/httpbin: стек go-httpbin на готовом образе mccutchen/go-httpbin:2.25.0 и конвейер к нему. Выкладывается прямо по ссылке https://github.com/piqab/nkt/blob/main/examples/httpbin/deploy/docker-compose.yml (имя стека — httpbin) или «Примеры» → «httpbin» в окне нового конвейера: она заполнит ссылку и имена, останется выбрать хост. Сайт — блоком site: в конвейере (закомментирован в описании) или вручную в «Сайтах»: стек httpbin, сервис httpbin, порт 8080. Compose-файл оригинала, postmanlabs/httpbin, собирает образ из исходников (build: '.') — его хаб отвергнет с объяснением.

Сайты ​

Вкладка «Сайты» — домен на хосте за прокси с сертификатом Let's Encrypt:

Новый сайт

  1. Хост и имена → «Проверить». Снаружи — с nkt-edge с ролью «проверки снаружи», если он подключён, иначе с хаба: указывает ли каждое имя на хост, и отвечают ли порты 80 и 443. «Свободен» (отказ соединения) — путь открыт, certbot сам встанет на порт; «нет ответа» — порт, скорее всего, закрыт файрволом провайдера или NAT, и Let's Encrypt до хоста не дойдёт. Если имя ведёт в частную сеть — пометка: хаб видит порты изнутри. С хоста — какие прокси установлены и работают, кто держит 80/443, стеки в /srv/compose с сервисами и портами, файрвол.
  2. Прокси: nginx, HAProxy или Caddy — показано, что установлено и что работает. Если не установлено ничего — ставится nginx. Если 80/443 держит контейнер (Traefik, свой Caddy) — предупреждение: такой прокси nkt не настраивает.
  3. Цель: сервис compose-стека (стек, сервис, порт контейнера) или адрес:порт (приложение не в compose). Неопубликованный порт сервиса nkt публикует только на 127.0.0.1 — файлом compose.nkt.yml рядом со стеком (выкладка и профили его учитывают), так что наружу сервис доступен лишь через прокси. Уже опубликованный на всех адресах порт используется как есть, с предупреждением.
  4. «Настроить» — задание хаба: DNS и порты, прокси (установка, если нужно), затем задание хоста — открыть 80/443 в ufw/firewalld (по галочке), опубликовать сервис, сертификат (действующий дольше 20 дней — как есть, иначе certbot certonly --standalone: прокси на время выпуска останавливается и запускается снова; продлевает nkt так же), конфигурация прокси через «Конфигурации» — с проверкой (nginx -t, haproxy -c, caddy validate), откатом при ошибке и историей версий:
    • nginx — conf.d/nkt-<домен>.conf: 80 → редирект на HTTPS, 443 с HTTP/2, WebSocket и заголовками X-Forwarded-*;
    • HAProxy — блок nkt-sites в haproxy.cfg (все сайты nkt хоста: frontend 80/443 с SNI и backend на сайт), сертификат — объединённым PEM в /etc/haproxy/nkt-certs; если 80/443 уже слушает другой frontend — отказ с объяснением;
    • Caddy — /etc/caddy/nkt/<домен>.caddy, подключённый из Caddyfile; сертификат Caddy получает и продлевает сам (ключи certbot ему, не root, не прочитать).
  5. Проверка HTTPS снаружи — код ответа и срок сертификата; «Проверить» в строке сайта повторяет её в любой момент.

«Удалить» — задание хаба: на хосте убирается конфигурация прокси (с записью в историю), снимается публикация сервиса на 127.0.0.1 (сервис пересоздаётся без неё), по галочке — удаляется сертификат (если он не нужен другому сайту); затем сайт удаляется с хаба. Не получилось — сайт остаётся с пометкой «удаление не завершено», кнопка — «Повторить удаление». Сайт из конвейера вернёт следующая выкладка, если не убрать блок site:.

Сайт в конвейере ​

Сайт можно описать прямо в конвейере action: compose — блоком site: вместо строки:

yaml
compose:
  file: deploy/docker-compose.yml
  project: shop
  hosts: [web1]                  # сайт — только при одном хосте
  site:
    domains: [shop.example.com, www.shop.example.com]
    service: web                 # сервис стека
    port: 80                     # порт контейнера
    proxy: nginx                 # необязательно: иначе какой есть на хосте, нет ни одного — nginx
    firewall: true               # открыть 80/443 (по умолчанию да)
    cert: manual                 # сертификат: manual (по умолчанию), auto или /путь к своему файлу
  • Первая выкладка: стек, затем сайт — тем же путём, что «Настроить» в мастере (DNS и порты снаружи, прокси, публикация на 127.0.0.1, сертификат, конфигурация, HTTPS). Сайт появляется во вкладке «Сайты» с пометкой «конвейер …».
  • Следующие выкладки: сайт настроен и в site: ничего не менялось — только проверка HTTPS, сертификат заново не выпускается. Поменялись имена, сервис, порт, прокси или хост — сайт настраивается заново (со старого хоста конфигурация прокси убирается).
  • Не настроился (DNS ещё не указывает на хост, порт 80 закрыт) — выкладка всё равно удалась: стек обновлён. Причина — в журнале выкладки и у сайта во вкладке «Сайты».
  • port — порт внутри контейнера, тот, что слушает образ, а не порт хоста: в "127.0.0.1:8080:80" это последнее число, 80 (port: 8080 здесь — ошибка с подсказкой). port: auto или без port — порт берётся из образа, если тот объявляет ровно один. Публикация уже есть в ports: (127.0.0.1:8080:80) — прокси смотрит на неё (127.0.0.1:8080), второй не будет; нет — nkt публикует сам на 127.0.0.1. До установки прокси и выпуска сертификата хаб сверяет его с портами, которые объявляет образ (EXPOSE): не тот порт — ошибка сайта с подсказкой («образ kennethreitz/httpbin объявляет только порт 80»), сертификат не тратится. Образ на хосте ещё не скачан — его EXPOSE сухой прогон берёт из registry без скачивания (манифест под архитектуру хоста и конфигурация образа; публичные Docker Hub, ghcr.io, quay.io — без входа), в журнале — «порты образа … — из registry». Registry не ответил — порты известны только из ports/expose compose, а это не доказательство: тогда не ошибка, а предупреждение «сайт проверится после выкладки». Образ портов не объявляет — проверить не с чем; если сайт ответил 502, журнал подскажет проверить порт. Другие публикации у сервиса сайта (у Forgejo — SSH 2222:2222 в compose.ports) сайту не мешают: журнал просто отмечает, что они идут отдельно.
  • Чего нет на хосте — ставится: прокси и certbot (для выпуска и продления сертификата; с Caddy не нужен) — пакетами дистрибутива, фоновыми заданиями хоста, журнал — в журнале сайта. Хост без apt-get — ошибка с просьбой поставить вручную. Docker и compose проверяются до выкладки (кнопка «Установить Docker»), git на хабе — плашка «Установить git».
  • Сухой прогон показывает, что будет с сайтом: прокси (или что он будет установлен), certbot, файрвол хоста (выключен, 80/443 уже открыты, будут открыты или правило не записать — тогда открыть вручную), DNS и порты снаружи, есть ли такой сервис в стеке и объявлен ли порт — несовпадение порта считается проблемой.
  • Сертификат по умолчанию — вручную (cert: manual или без cert). Выкладка не запускает certbot и не останавливает прокси: она берёт готовый действующий сертификат Let's Encrypt на все имена из /etc/letsencrypt/live — выпущенный в «Сертификатах», руками или раньше. Нет его или истёк — сайт не настраивается («выпустите в „Сертификатах“ или укажите cert: auto»), стек при этом выложен; осталось меньше 30 дней — строка в журнале. Продление — автопродление nkt или «Сертификаты».
    • cert: auto — как раньше: годный сертификат (больше 20 дней) берётся, иначе выпуск certbot --standalone, прокси (nginx, HAProxy) на время проверки останавливается — сайты на нём эти секунды недоступны.
    • cert: /etc/ssl/app.pem — свой файл: сертификат на все имена (подходит и *.домен), ключ в том же файле или отдельно — cert_key: /etc/ssl/app.key. Для HAProxy nkt собирает из них один PEM.
    • Сухой прогон пишет, какой сертификат будет взят и сколько ему осталось, или что его нет (это проблема).
  • Прокси настроен вручную. Если у HAProxy на хосте свой frontend на 80/443 или имя уже в чужом server nginx, nkt эту конфигурацию не трогает: сайт получает состояние «прокси вручную» (причина — в подсказке во вкладке «Сайты»), а выкладки дальше только проверяют HTTPS — ни настройки, ни certbot, ни остановки прокси. Чтобы nkt и не пытался, запишите site: строкой. Выйти из этого состояния — поменять сайт в site: или «Настроить» во вкладке «Сайты».
  • Строка site: имя по-прежнему — только проверка HTTPS после выкладки.
  • Правка такого сайта в мастере сработает, но следующая выкладка вернёт настройки из site:, если они расходятся.

Когда выкладывать ​

  • Кнопка «Выложить» — вершина ветки из описания; можно указать другую ветку или тег репозитория и тег образа.
  • Вебхук — push в ветку ref или тег по шаблону tags; запрос из CI с тегом образа (см. ниже).
  • Опрос (poll) — хаб сам видит новый коммит в ветке.
  • Registry (registry) — хаб видит новый тег образа, новее прежнего (сравнение по числам: v1.10.0 новее v1.9.3), и выкладывает ветку ref с этим тегом.

Переключатель «Включён» в списке конвейеров — только об автоматических выкладках (вебхук, опрос, registry); выключенный конвейер помечен «только вручную», кнопка «Выложить» работает всегда.

Опрос и registry начинают работать после первой выкладки кнопкой — только что сохранённый конвейер сам ничего не выкладывает. Выключенный конвейер («Включён» снят) не реагирует ни на что, кроме кнопки. Пока выкладка конвейера идёт, новая от опроса не запускается. Упавшая выкладка не повторяется опросом и registry: хаб запоминает коммит (или тег), на котором она упала, и ждёт нового; в строке конвейера — «ждёт нового коммита», в журнале упавшей выкладки — об этом строка. Кнопкой «Выложить» её можно запустить в любой момент.

Вебхук ​

Окно «Вебхук» конвейера: адрес (напрямую к хабу — https://хаб/api/hub/hooks/<id>, и через nkt-edge, если он настроен), «Показать секрет» (с записью в журнал действий), «Новый секрет» и готовые инструкции для GitHub, GitLab и шага CI.

Подпись обязательна; поддерживаются:

ОтправительЗаголовкиПроверка
GitHubX-Hub-Signature-256: sha256=…, X-GitHub-DeliveryHMAC-SHA256 тела
Gitea, ForgejoX-Gitea-Signature / X-Forgejo-SignatureHMAC-SHA256 тела
GitLabX-Gitlab-Tokenсекрет как есть
Шаг CI (nkt)X-NKT-Timestamp, X-NKT-SignatureHMAC-SHA256 строки <отметка>.<тело>, отметка не старше 5 минут
  • Повтор той же доставки отвергается: хаб помнит идентификаторы доставок (у подписи nkt — саму подпись).
  • Ответы скупые: и неизвестный адрес, и неверная подпись — 401, чтобы подбором нельзя было узнать настоящий. Тело — до 1 МБ.
  • ping от GitHub отвечает pong.
  • Из тела берутся только ref (refs/heads/… или refs/tags/…), коммит (after, checkout_sha или commit) и tag. Удаление ветки или тега, другая ветка, тег не по шаблону — 200 с ignored и причиной.

Подпись nkt из шага CI — когда образ уже собран и нужно выложить ветку с его тегом:

bash
BODY=$(printf '{"ref":"refs/heads/main","commit":"%s","tag":"%s"}' "$SHA" "$TAG")
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$NKT_HOOK_SECRET" -hex | sed 's/^.* //')
curl -fsS -X POST "$NKT_HOOK_URL" \
  -H "Content-Type: application/json" \
  -H "X-NKT-Timestamp: $TS" -H "X-NKT-Signature: $SIG" \
  -d "$BODY"

Без ref (только {"tag":"v1.2.3"}) хаб выложит ветку из описания с этим тегом. Ответ 202 — {"status":"deploying","deployment_id":…}.

Готовые конвейеры CI — на странице Примеры CI/CD.

Секреты ​

Окно «Секреты» открывается кнопкой у конвейера в списке и кнопкой «Секреты» в окне описания. У нового конвейера она сначала сохраняет его с введённым именем, а без имени — с именем из project: описания или из ссылки на compose; окно дальше правит уже сохранённый конвейер. В окне три вкладки: «Репозитории», «Реестры» и «.env». При открытии и после записи ключа вкладка «Репозитории» сразу проверяет доступ сохранёнными ключами: git ls-remote по repo: — «доступ есть, ветка найдена», «нет доступа» с причиной (Permission denied, неверный токен) или «ветки нет»; если в описании есть registry: — вход в registry и число тегов. «Проверить снова» — повтор.

Если в окне описания уже вставлена ссылка на compose, а «Заполнить описание» ещё не нажимали, «Секреты» сначала заполняет описание из ссылки — проверяется её репозиторий, а не адрес-заглушка шаблона (github.com/org/app; если описание так и осталось шаблоном, проверка прямо об этом и говорит). Причины отказа — словами (исходный вывод git — в подсказке):

ПричинаЧто значит
закрыт или его нетключей нет, а сервер просит войти — так он отвечает и на закрытый, и на несуществующий репозиторий
токен не подошёлневерный, истёк или без права на чтение: Forgejo/Gitea — repository: Read, GitHub — Contents: Read, GitLab — read_repository
ключ ssh не подходитне добавлен в ключи развёртывания репозитория
ключи приняты, репозитория нетопечатка в адресе
сервер недоступенимя не разрешается, нет соединения или сертификат не принят

Токен — просто токеном или логин:токен. Хаб входит так же, как обычный git: сервер отвечает «нужен вход», и git отдаёт логин и токен (без заранее отправленного заголовка — его отбрасывают некоторые прокси перед сервером). Без логина в поле: для GitLab — oauth2, для остальных (GitHub, Forgejo, Gitea, Codeberg) логин не важен. Сохранённые пароли системы (credential.helper) git хаба не использует — только доступ конвейера. Проверка показывает последние четыре знака сохранённого токена: сверьте с токеном на сервере. После записи ключей надпись «репозиторий закрыт» в окне описания пропадает, а «Заполнить описание» отмечает, что файл скачан с ключами конвейера.

Вкладки «Секретов»:

  • Репозитории — ключ основного репозитория (repo:): токен (для https://, например GitHub fine-grained с Contents: read) или закрытый ключ развёртывания (для ssh/git@). Ниже — ключи других репозиториев по началу адреса: github.com/vendor/ — ко всем репозиториям vendor, git.example.com/team/lib — к одному. Нужны для submodules: если в репозитории есть .gitmodules, хаб сам скачивает их (git submodule update --init --recursive, сначала только последний коммит), и каждый submodule получает ключ по своему адресу — токен или ключ ssh, разные у разных серверов; относительные адреса submodules (../lib.git) считаются от repo:. Самое длинное совпавшее начало адреса побеждает; основной ключ относится только к repo:. «Заменить» — новый ключ вместо прежнего, «Удалить» — убрать.
  • Реестры — список ключей registry: адрес (harbor.example.com, ghcr.io, registry.example.com:5000), логин, токен и при необходимости свой CA (PEM текстом или из файла) — для registry с сертификатом не от публичного центра. Ключей сколько угодно, у каждого образа стека свой: сухой прогон проверяет закрытый образ с хаба ключом его registry («есть в registry, проверено ключом конвейера»), выкладка скачивает образы на хосте с ключами всех registry стека — хост получает их только на время скачивания: Docker — во временном конфиге (docker --config), Podman — во временном --authfile; файл 0600 в каталоге данных nkt удаляется сразу после, свой docker login / podman login на хосте не нужен. Образы из открытых registry (postgres, redis) — без ключа. Тем же ключом хаб следит за тегами (registry:). Колонка «Вход» — проверка входа в каждый registry его ключом; «Изменить» — адрес, логин, CA (пустой токен — прежний), «Удалить» — убрать. CA хаб использует при проверке, а хост при выкладке кладёт его в certs.d своего движка — /etc/docker/certs.d/<registry>/ca.crt или /etc/containers/certs.d/<registry>/ca.crt (только если файл другой); системное доверие хоста не меняется. Прежний одиночный ключ (логин:токен без адреса) показан строкой «registry не указан» и относится, как раньше, к registry из registry:, а без него — к единственному registry стека, кроме Docker Hub; «Изменить» привяжет его к адресу.
  • .env — для action: compose: переменные окружения стека (KEY=значение по строке). Порядок строк важен, только если значение ссылается на другую переменную (FORGEJO_ROOT_URL=https://${FORGEJO_DOMAIN}/): docker compose подставляет в .env лишь то, что задано выше, поэтому такая переменная должна стоять ниже той, на которую ссылается, — иначе сухой прогон назовёт строку и скажет, что поднять.

Хранятся на хабе зашифрованными, в описание, журналы и командные строки не попадают (git получает их через окружение); показать нельзя — только заменить или убрать. Хабу нужна программа git (в образе хаба она есть): если её нет, на вкладке «Конвейеры» — плашка и кнопка «Установить git» (пакет из репозитория дистрибутива, фоновым заданием машины хаба).

.env стека. Каждая выкладка пишет его в /srv/compose/<стек>/.env (0600) и заменяет то, что на хосте; конвейер без .env файл на хосте не трогает. «Править .env» на вкладке «.env» открывает текущий .env в редакторе (значения — секреты: показ по подтверждению, с записью в журнал действий); перед записью — разница по ключам: добавлены, изменены, удалены (значения не показываются). Вставка нового .env целиком сначала показывает, какие имена переменных появятся и пропадут. «История .env» там же: каждая смена — версия (зашифрована); разница — только по именам (+ добавлена, − убрана, ~ изменено значение); «Показать значения» — администратору, с записью в журнал действий; «Вернуть» делает версию текущей (на хосты — со следующей выкладкой). Если .env на хосте правили вручную после прошлой выкладки, журнал выкладки и сухой прогон об этом скажут: хаб сравнивает файл с хэшем того, что записал сам.

История .env

История и откат ​

Каждая выкладка — задание хаба с журналом: что (коммит, ветка, тег), кто запустил (кнопка, вебхук, опрос, registry, откат), когда и чем кончилось. «Откатить» у прежней удачной выкладки выкладывает заново её коммит и тег — образ уже лежит в registry, пересборки нет. Галочка «вернуть и .env той выкладки» возвращает версию .env, с которой она шла; без неё .env остаётся текущим.

Удаление конвейера compose — задание хаба: сайт конвейера, затем на каждом хосте compose down (контейнеры и сеть) и каталог стека, затем запись на хабе с историей. Без галочки «тома» каталог стека не стирается, а переносится в /srv/compose/.nkt-removed/<стек>-<время> — данные bind-mount (./data) и тома docker сохраняются; с галочкой — down -v и каталог удаляется целиком. Образы и сертификат — тоже по галочкам.

Что и где убирается. Окно удаления показывает план по каждому хосту, где стек этого конвейера выложен на деле (по запомненному положению и оставшимся старым стекам; у конвейера, выложенного до v1.11.139, — по описанию):

  • «уберётся» — с галочкой; снять галочку — стек на этом хосте останется (окно предупредит, что им больше никто не будет управлять);
  • «оставлен — тот же стек у конвейера …» — стек с тем же именем на том же хосте числится и за другим конвейером: это один каталог /srv/compose/<имя>, и удаление одного конвейера не сносит выкладку другого;
  • «хост недоступен — останется» и «хоста нет на хабе».

Хосты из описания или группы, куда этот конвейер ни разу не выкладывался, не трогаются. Удаление без выбора (через API) ведёт себя как раньше: неработающий хост — ошибка, конвейер остаётся с пометкой «удаление не завершено» (и выключенным), повтор — «Повторить удаление» с теми же галочками.

«Только убрать с выбранных» — стек убирается с отмеченных хостов, а конвейер остаётся и работает на остальных: отмеченные хосты уходят из hosts: его описания (новая версия в истории). Недоступно, если стек выбирается группой (group:) или в описании не осталось бы ни одного хоста — тогда удаляется конвейер.

Удаление конвейера

Конвейеры manifest, helm и script удаляются только с хаба: выложенное в кластерах остаётся.

Пример ​

Готовый пример — examples/hello-app: приложение на Python, Dockerfile, CI для GitHub Actions, GitLab CI и Gitea Actions и три варианта выкладки — манифест, Helm, хост с Docker Compose. Разбор по шагам — Примеры CI/CD.

Хаб не виден из интернета ​

  • Включите опрос репозитория или registry — открывать ничего не нужно.
  • Или поставьте на VPS nkt-edge — маленькую программу с сертификатом Let's Encrypt, которая принимает только вебхуки и передаёт их хабу по туннелю, который хаб сам к ней держит.

Если не выкладывается ​

СимптомПричина и что делать
Вебхук 401Неверный секрет или подпись; повтор доставки («Redeliver» в GitHub шлёт тот же ID); у подписи nkt — часы CI и хаба расходятся больше чем на 5 минут. Причина — в журнале действий хаба (pipeline.hook.rejected)
200 ignored: другая веткаPush не в ref; тег — не по шаблону tags
ни один кластер не подходитИмена в clusters не совпадают с разделом «Кластеры», у группы нет кластеров или кластер не «готов»
unknown field "site_port" / «старая версия nkt не знает полей»nkt на хосте старее хаба: хаб повторяет запрос без новых полей (порт сайта не проверяется, ручная правка .env не отслеживается) — обновите nkt на хосте
описание конвейера, строка N «…»: …Ошибка YAML — строка описания, её текст и пояснение: сбит отступ, у ключа и значение, и блок (site: имя и строки ниже), неизвестный ключ (со списком допустимых), табуляция, не то значение
сайт — «чужой сертификат»Прокси отвечает сертификатом другого сайта: конфигурация этого не действует (не настроилась — см. журнал, или перекрыта другим server)
ufw … '/etc/ufw/user.rules' is not writableСлужба nkt под песочницей systemd, /etc/ufw не открыт: с 1.11.64 правило ufw добавляется в обход песочницы (а в службе новых установок /etc/ufw открыт). Сайт при этом не останавливается — порты можно открыть вручную: ufw allow 80,443/tcp
на хабе нет программы gitКнопка «Установить git» на вкладке «Конвейеры» или apt install git на машине хаба
в репозитории нет ветки или тегаОпечатка в ref, тег ещё не отправлен (git push origin v1.0.0)
git …: Authentication failed / Permission denied (publickey)Закрытый репозиторий без ключа в «Секретах», у токена нет права чтения, ключ не добавлен как deploy key
в репозитории нет файла …Путь в manifests, helm.values или script — от корня репозитория
Helm на хосте не установленПоставьте Helm на control plane: вкладка Kubernetes узла → Helm → «Установить Helm»
Поды ImagePullBackOffОбраз закрытый: сделайте пакет публичным или добавьте в кластер imagePullSecret; тег образа не совпадает с тем, что собрал CI
Опрос или registry молчатПервой выкладки кнопкой ещё не было; конвейер выключен; registry_tags не пропускает теги (по умолчанию — только версии вида 1.2.3)
выкладка этого конвейера уже идётДождитесь конца текущей — журнал в «Истории»