Лаборатория на двух VPS: Semaphore и gitness

Кейсы
14 октября 2026 Время чтения 7 мин

Наша лаборатория — это два обычных VPS с Docker Compose, а вся инфраструктура описана в одном репозитории и разворачивается с чистого сервера несколькими нажатиями кнопок. Главный критерий простой: сервер можно стереть и получить то же самое состояние, ничего не вспоминая.

Лаборатория по-прежнему полигон: изменения сначала обкатываются у нас, а не в проекте клиента. Но на ней живут настоящие сервисы — этот сайт, CRM и ещё три продукта — со своими доменами, сертификатами и мониторингом.


Два сервера и три слоя

Серверов два, у каждого своя роль:

  • infra — всё, что обслуживает разработку: Traefik, Postgres, Valkey, gitness (git, реестры образов и крейтов, CI/CD), Kaneo для задач, AFFiNE для документации, ExcaliDash для досок Excalidraw и стек наблюдаемости (Prometheus, Loki, Tempo, Grafana, Alloy);
  • production — то, что видят пользователи: Traefik, SurrealDB, SeaweedFS как S3-хранилище и пять веб-сервисов на Rust.

Поверх них три слоя, и каждый описан кодом: управление (Semaphore UI и ansible), доставка (gitness с пайплайнами в .harness/) и наблюдаемость (OpenTelemetry в приложениях, Grafana на infra).


Semaphore UI: кнопки вместо ручных команд

Запускаем Semaphore одной командой docker compose up. Одноразовый сервис provision создаёт по JSON два проекта — infrastructure и production — с готовым набором задач. Они пронумерованы, потому что Semaphore сортирует их по имени:

0. Full cycle
1. Install Docker
2. Deploy stack
3. Status
...

Задача «Full cycle» проходит все шаги подряд, а отдельные задачи нужны, чтобы повторить упавший шаг. Под капотом — обычные ansible-плейбуки: deploy.yml рендерит шаблоны из templates/<infra|prod>/ в /opt/stack на сервере и запускает docker compose up. Конфигурация стека — это папка с шаблонами, а не набор команд, которые кто-то когда-то выполнил руками.


Секреты в git, но зашифрованные

Все пароли, ключи и токены лежат в одном файле vault.yml, зашифрованном ansible-vault. Файл коммитится в репозиторий, а пароль от него вводится один раз в хранилище ключей Semaphore:

cd bootstrap/ansible
ansible-vault edit inventory/vars/vault.yml

Шаблоны берут значения из vault, и плейбук перед запуском проверяет, что нужные переменные есть, — ошибка про пропущенный пароль приходит сразу, а не на третьем шаге. Так же и с сертификатами: чтобы при пересоздании сервера не упереться в лимит Let's Encrypt (пять одинаковых сертификатов в неделю), мы храним публичную часть acme.json в шаблоне, а закрытые ключи — в vault. Новый сервер стартует с уже выданными сертификатами.


gitness: git, реестры и CI в одном месте

gitness заменил у нас связку из git-хостинга, реестра образов и CI. В нём живут репозиторий, Docker-реестр для образов сервисов, Cargo-реестр для общих крейтов и пайплайны. Настраивает его тоже плейбук: создаёт пространство, реестры, пользователей с минимальными правами, кладёт код в репозиторий и регистрирует пайплайны.

Код — монорепозиторий с одним Cargo-воркспейсом и одним Cargo.lock. Общие библиотеки (авторизация, почта, системные задачи, наблюдаемость) лежат в packages/ и публикуются в наш Cargo-реестр, а сервисы подключают их как обычные зависимости. Чтобы сборка не компилировала весь граф зависимостей заново, мы собираем два базовых образа: в них уже скомпилированы все зависимости, а образ сервиса добавляет только свой код.


От push до продакшена

Пайплайнов три, и названия говорят сами за себя:

  • main — запускается на каждый push в main: публикует новые версии крейтов, пересобирает образы и выкатывает сервисы;
  • build — те же шаги сборки без деплоя, запускается вручную;
  • deploy — только выкладка, тоже вручную.
steps:
  - name: publish-packages   # новые версии общих крейтов в Cargo-реестр
  - name: build-base         # базовые образы: компилированные зависимости
  - name: build-services     # образы пяти сервисов
  - name: deploy             # ssh на production, по одному сервису

Деплой — это ssh на сервер продакшена с отдельным ключом, который умеет делать ровно одно: запускать скрипт deploy-service.sh <сервис>. В authorized_keys он выглядит так:

restrict,command="/opt/stack/deploy-service.sh" ssh-ed25519 AAAA…

Скрипт делает docker compose pull и up -d для названного сервиса; контейнер пересоздаётся, только если образ изменился. Если к ключу кто-то получит доступ, всё, что он сможет, — перезапустить наши же сервисы.

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


Логи, метрики и трейсы в одной Grafana

Приложения отдают метрики и трейсы по OpenTelemetry (OTLP по http), пишут логи в JSON с идентификатором трейса, а Alloy собирает логи контейнеров, на production — ещё и метрики хоста и контейнеров. Всё приходит на infra: Prometheus, Loki и Tempo сидят за общей basic-аутентификацией.

В Grafana у каждого сервиса свой дашборд, а между сигналами есть переходы: от запроса с 500-й ошибкой — к его трейсу, от трейса — к строкам лога, от лога с паникой — к бэктрейсу. Дашборды — обычные JSON-файлы в репозитории, и при пересоздании сервера они появляются сами.


Какие грабли мы собрали

Идея «стереть и повторить» хороша тем, что вытаскивает на поверхность всё, что раньше держалось на ручной памяти:

  • Лимит Let's Encrypt. Несколько тестовых пересозданий подряд упираются в пять одинаковых сертификатов в неделю — отсюда схема с сертификатами в шаблоне и ключами в vault.
  • Контрольные суммы крейтов. Новый реестр публикует те же версии с другими checksum, и закоммиченный Cargo.lock перестаёт собираться. Теперь пайплайн сверяет lock с реестром и сам обновляет его отдельным коммитом.
  • Импорт репозитория. Фоновая задача импорта в gitness на свежем сервере стартовала только на следующей 20-минутной проверке планировщика. Мы заменили её на обычный clone и push — и ждать перестали.
  • Таймаут Traefik. Тяжёлые push-ы образов обрывались на 60 секундах чтения — таймаут пришлось отключить.

Итог

Два сервера, один репозиторий, одна кнопка Full cycle.

Тот же принцип «просто и в одном месте», за который мы любим чистый монолит вместо микросервисов, мы применили к инфраструктуре: никакой магии на серверах, всё лежит в git, а сервер можно пересоздать, не боясь забыть, как он был настроен.

О том, почему собственные сервисы мы пишем на Rust, — в статье Rust и Zig в привычных приложениях.

Обсудим ваш проект?

Расскажите о вашей задаче — вернёмся с оценкой сроков и стоимости в течение двух рабочих дней.

Обсудить проект