CI/CD и деплой¶
Стадии пайплайна¶
.gitlab-ci.yml разбит на девять стадий:
Стадия |
Джобы |
Что делает |
|---|---|---|
|
|
|
|
|
openapi-generator: Python-клиент и HTML-справочник ( |
|
|
|
|
|
|
|
|
|
|
|
Сборка и публикация Docker-образа; публикация документации |
|
|
Сканеры GitLab; отчёты готовы до выкатки на стенд, но деплой не блокируют |
|
|
Выкатка на стенд |
|
|
Выкатка в прод по тегу, ручной запуск |
Образы задаются переменными: GITLAB_CI_IMAGE_PYTHON (python:3.12-bookworm),
GITLAB_CI_IMAGE_NODE (node:24-bookworm), GITLAB_CI_IMAGE_DOCKER,
GITLAB_CI_IMAGE_OPENAPI_GENERATOR_CLI, GITLAB_CI_IMAGE_ALPINE.
Все джобы по умолчанию идут на раннерах с тегом project_runners; сборка и деплой —
на dev-stand.
Публикация документации¶
Джоба docs собирает HTML в артефакт, джоба pages раскладывает его в каталог public
вместе со справочником API:
pages:
stage: publish
needs:
- job: docs
artifacts: true
- job: api
artifacts: true
image: ${GITLAB_CI_IMAGE_ALPINE}
script:
- mv docs/_build/html public
- mv soniks-network-api-client/html2 public/api-reference
- mv soniks-network-api-client/openapi.yml public/api-reference/openapi.yml
artifacts:
paths:
- public
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Имя джобы (pages) и каталог (public) фиксированы требованиями GitLab Pages.
Джоба выполняется только на основной ветке.
Важно
Справочник кладётся в public/api-reference, а не в public/api. Раздел документации
docs/api/ уже собирается в public/api, и mv положил бы html2 внутрь
существующего каталога, а не занял бы его имя — справочник оказался бы по адресу
/api/html2/, а ссылки на него молча вели бы в никуда.
Результат доступен по адресу GitLab Pages проекта, а портал проксирует его на
sonik.space/docs/network/. Сниппет nginx (живёт в конфигурации портала, не в этом
репозитории):
location /docs/network/ {
proxy_pass https://soniks-network-space-education-development-sonik-216484c6a7233c.gitlab.io/;
proxy_set_header Host soniks-network-space-education-development-sonik-216484c6a7233c.gitlab.io;
}
Внутренние ссылки Sphinx относительные, поэтому раздача из подпути работает без
дополнительной настройки; html_baseurl в docs/conf.py влияет только на канонические
ссылки.
Соседние проекты публикуются так же: клиент — на /docs/client/, флоуграфы — на
/docs/flowgraphs/. Сам /docs/ — хаб документации из репозитория soniks-docs;
проектные location длиннее и выигрывают у него по longest-prefix:
location /docs/ {
proxy_pass https://docs-7b1d8d.gitlab.io/;
proxy_set_header Host docs-7b1d8d.gitlab.io;
}
location /docs/client/ {
proxy_pass https://soniks-client-new-ea69a1.gitlab.io/;
proxy_set_header Host soniks-client-new-ea69a1.gitlab.io;
}
location /docs/flowgraphs/ {
proxy_pass https://soniks-flowgraphs-52b76e.gitlab.io/;
proxy_set_header Host soniks-flowgraphs-52b76e.gitlab.io;
}
Если nginx стенда /docs/ не перехватывает (так на dev), запрос доходит до Django:
/docs/network/<путь> отвечает редиректом на DOCS_URL из .env — по умолчанию это
Pages проекта (https://soniks-network-space-education-development-sonik-216484c6a7233c.gitlab.io/),
а любой другой /docs/<путь> — на DOCS_HUB_URL, Pages soniks-docs. Документации
клиента и флоуграфов этот запасной путь не знает: без nginx они откроются только по
адресам своих Pages.
Pages собираются только с ветки по умолчанию; raw-артефакты job docs браузер
скачивает, а не показывает, поэтому ссылаться на них как на сайт нельзя.
Docker-образ¶
Джоба publish собирает образ через docker compose build web и пушит в registry
проекта:
$CI_REGISTRY_IMAGE/soniks-network:$CI_COMMIT_REF_NAME— на каждый не-MR пайплайн в неймспейсеspace-education-development;дополнительно
:latestи тег коммита — при сборке по git-тегу.
Переменная CACHE_IMAGE включает сборку с кэшем через docker-compose.cache.yml.
Выкатка на стенд¶
deploy_stand не переносит артефакты — он ходит по ssh и пересобирает окружение
из git:
docker loginв registry.Удаление рабочего каталога, свежий
git clone,git reset --hard $CI_COMMIT_SHORT_SHA.Копирование
env-distиз$FILES_FOLDERв.env.Подстановка учётных данных Space-Track из CI/CD-переменных (
SPACE_TRACK_USERNAME_DEV/SPACE_TRACK_PASSWORD_DEV) — вenv-distна сервере их искать бесполезно.docker-compose pullи./soniks.sh update_dev.
Запускается на каждом не-MR пайплайне в неймспейсе space-education-development.
Важно
До 2026-08-26 здесь был ещё один шаг — копирование djangoctl.sh из
$FILES_FOLDER в bin/. Копия молча побеждала репозиторий (образ собирается на
сервере и запекает то, что лежит в bin/), и серверные версии разошлись между
собой: на стенде gunicorn шёл с восемью воркерами, на проде с шестнадцатью, а
worker и beat запускались фоном под tail -f /dev/null — при смерти любого из
них контейнер оставался Up. Шаг убран, репозиторный bin/djangoctl.sh снова
исполняется, а хостовые числа переехали в .env: GUNICORN_WORKERS,
GUNICORN_TIMEOUT.
Выкатка в прод¶
deploy_prod устроен так же, но:
срабатывает только при наличии git-тега;
клонирует именно тег (
git clone -b $CI_COMMIT_TAG);использует отдельные ключ и хост (
PROD_ID_RSA,PROD_SERVER_USER,PROD_SERVER_IP);подставляет
SPACE_TRACK_USERNAME_PROD/SPACE_TRACK_PASSWORD_PROD— прод и стенд сознательно работают под разными аккаунтами Space-Track, чтобы не делить лимиты сессий;умеет выполнить дополнительный шаг
$PROD_SCRIPT;заканчивается
./soniks.sh update_prod;запускается вручную (
when: manual): пайплайн по тегу собирает образ, выкатывает стенд и останавливается перед продом. Кнопка — в пайплайне или на странице Environments → production, там же история выкаток по тегам.
update_dev и update_prod в soniks.sh делают одно и то же: docker-compose up -d.
Ни npm, ни --build на сервере нет: образ собран джобой publish, деплой лишь
пишет SONIKS_IMAGE=<registry>:<тег> в .env и делает pull.
Примечание
Развёрнутый артефакт — Docker-образ из registry по тегу. Git-чекаут на сервере нужен
только ради docker-compose.yml, env-dist и soniks.sh.
Выкатка с долгими миграциями¶
update_prod поднимает все сервисы разом, а миграции применяет web при старте:
celery и celery-beat его не ждут и работают на схеме, которая ещё меняется. Когда
в релизе есть миграция на минуты (в «Требует действий» CHANGELOG.md это сказано),
порядок такой:
До кнопки: дамп, предпроверки данных из
CHANGELOG.md, остановитьceleryработающей версии.Кнопка
deploy_prod; сразу после неё —docker-compose stop celery celery-beat.Дождаться
200от/ready/: он отвечает503, пока план миграций не пуст.Если миграция переписывала большую таблицу целиком —
VACUUM FULL <таблица>, затем обычныйVACUUM ANALYZE <таблица>: послеFULLкарта видимости пуста, и без второго прохода подсчёты по индексу читают саму таблицу.docker-compose start celery celery-beat.
Замер на копии прода перед 1.10.0 (1,6 млн наблюдений, 54 млн кадров, сервер
стенда): миграции — 11 минут, индекс по таблице кадров — ещё полторы, старт web со
сборкой статики — 27 секунд, VACUUM FULL с ANALYZE таблицы наблюдений — 50 секунд.
Откат¶
Откат — это возврат на предыдущий тег того же образа:
cd /путь/к/soniks-network # чекаут, в котором лежит .env
./soniks.sh rollback ТЕГ
Прежде чем откатываться, прочитать в CHANGELOG.md раздел «Требует действий» каждой
версии между целевым тегом и текущим: с 1.10.0 на 1.9.2.1, например, откат
невозможен.
Команда переключает чекаут на тег, переписывает SONIKS_IMAGE в .env, делает
pull и up -d. То же можно сделать кнопкой Re-deploy у нужной выкатки на
странице Environments → production — она заново прогонит deploy_prod того тега.
Проверить: docker-compose ps — все сервисы Up, главная страница отвечает,
страница «Система» показывает ожидаемую версию и живые Celery-воркеры.
Когда откат возможен. Контейнер при старте применяет миграции только вперёд,
назад их никто не откатывает. Поэтому старый образ работает на новой схеме, пока
миграции между тегами лишь добавляют: новые таблицы, поля с null/default,
индексы. Старый код их не видит и не трогает.
Когда откат невозможен. Если между тегами есть миграция, которую старый код не
переживёт: RemoveField/DeleteModel (старый код запрашивает удалённый столбец),
переименование, NOT NULL без default, необратимый RunPython вроде
0043_drop_socialaccount_tables. Тогда путь один — восстановление базы из дампа
на момент до выкатки (см. Эксплуатация), и только затем старый образ.
Такие миграции идут отдельным MR и помечаются в описании релиза — см.
Выпуск версии.
Registry образов станций¶
Часть станций стоит в закрытых сетях, где доступен только sonik.space, а
образы клиента и hamlib лежат на Docker Hub. Сервис registry в
docker-compose.yml — pull-through cache Docker Hub: станция тянет
sonik.space/sonikspace/soniks-client:<тег>, registry при промахе берёт образ с
Docker Hub и кэширует. Push в него невозможен — образы по-прежнему публикуются
на Docker Hub (build.sh клиента), digest у копии тот же. Кэш живёт на
/opt/storage/network_volumes/registry, неиспользуемое вытесняется через
168 ч. Решения 34–36 в docs/roadmap-network.md клиента.
Включение на сервере — всё руками, репозиторий этого не делает:
Проверить, что
docker-compose versionне ниже 1.28: старше ключprofilesне знает, и выкатка упадёт на разбореdocker-compose.ymlцеликом. Проверить, что порт на loopback свободен (ss -ltn | grep :5000): на занятом контейнер не стартует. Порт задаётся переменнойREGISTRY_PORTв$FILES_FOLDER/env-dist(умолчание 5000; на проде 5000 занятsonik_globus) — тот же порт должен стоять вproxy_passсниппета nginx ниже.mkdir -p /opt/storage/network_volumes/registry— bind в длинной форме каталог не создаёт.В
$FILES_FOLDER/env-dist:COMPOSE_PROFILES=registryи read-only токен Docker Hub вREGISTRY_PROXY_USERNAME/REGISTRY_PROXY_PASSWORD. Без токена прокси работает анонимно, но промахи упираются в лимит Docker Hub.Сниппет в nginx
sonik.space. Registry обязан быть в корне хоста: Docker-клиент всегда ходит вhttps://<host>/v2/…, префикс пути не умеет.
# Проверка версии API — docker login и pull начинают с неё.
location = /v2/ {
proxy_pass http://127.0.0.1:5000;
}
# Только образы станций. Без белого списка это открытое зеркало всего
# Docker Hub за наш канал и наш лимит.
location ~ ^/v2/(sonikspace/[a-z0-9._-]+|librespace/hamlib)/(manifests|blobs|tags)/ {
limit_except GET HEAD { deny all; }
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 900;
proxy_buffering off;
}
location /v2/ {
return 404;
}
Проверка — с машины снаружи: docker pull sonik.space/sonikspace/soniks-client:latest-addons, затем то же для
sonik.space/librespace/hamlib:4.5.4; docker pull sonik.space/library/debian
обязан получить отказ. Порядок — стенд, потом прод.
MQTT-брокер¶
Живой канал со станциями: портал публикует конфигурацию станции в момент
сохранения формы и её расписание (stations/<id>/jobs, retained) при каждом
изменении, станция сразу применяет их и публикует статус, обрыв связи
виден по Last Will. Брокер — Mosquitto с mosquitto-go-auth (сервис mosquitto в
docker-compose.yml, конфиг contrib/mosquitto.conf), логин и права на топики
проверяются через портал (/api/v2/mqtt/auth/, /acl/). Рядом — сервис
mqtt-bridge (manage.py mqtt_bridge), который пишет статусы станций в БД.
REST остаётся источником истины: без брокера станция забирает то же самое раз
в минуту, с брокером — один раз при старте. Решение 47 в docs/roadmap-network.md клиента.
Включение на сервере — руками, как и registry:
В
$FILES_FOLDER/env-dist:COMPOSE_PROFILES=registry,mqtt,MQTT_BROKER_HOST=mosquitto,MQTT_PORTAL_USERNAME=portalи любойMQTT_PORTAL_PASSWORD— под ним ходят публикация и мост.Сниппет в nginx
sonik.space. Станции подключаются по WebSocket на 443 — закрытым сетям ничего открывать не нужно. Эндпоинты/api/v2/mqtt/снаружи обязаны быть закрыты: иначе это оракул валидности токена.
location = /mqtt {
proxy_pass http://127.0.0.1:9001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600;
}
location ^~ /api/v2/mqtt/ {
return 404;
}
Проверка — с машины снаружи, токеном владельца любой станции:
mosquitto_sub -h sonik.space -p 443 -L "wss://sonik.space/mqtt" -u station-<id> -P <токен> -t stations/<id>/stateдолжен отдать retained-документ после сохранения формы;curl -X POST https://sonik.space/api/v2/mqtt/auth/обязан получить 404. Порядок — стенд, потом прод.Подписка на данные спутника. Владелец станции в сети подписывается на спутник на его странице и берёт в профиле логин
subscriber-<id>и ключ. Клиент с этими данными (пример — Спутники и передатчики, раздел «Подписка на данные») наsatellites/<sat_id>/framesполучает кадр, отправленный через SiDSPOST /api/demoddata/. Проверять с каждого языкового хоста, где nginx ведёт/mqtt: адрес брокера профиль строит из текущего хоста.
Что нужно на сервере¶
Переменная CI/CD |
Назначение |
|---|---|
|
Доступ и путь на стенде |
|
То же для прода |
|
Каталог с |
|
Учётки Space-Track |
|
Опции Node при сборке статики |
|
Образ для кэша сборки |
Конфигурация nginx и env-dist на серверах лежат вне репозитория — в $FILES_FOLDER.
Каталог media (MEDIA_VOLUME_PATH, по умолчанию
/opt/storage/network_volumes/media_volume) монтируется с хоста, а контейнер
работает от soniks — каталог должен принадлежать 999:999. До 1.9.2.1
контейнер работал от root и писал туда от root, поэтому на серверах, переехавших
с той версии, права выдаются один раз: chown -R 999:999 $MEDIA_VOLUME_PATH.
Признак невыданных прав — Permission denied в логе celery при сохранении
картинки спутника. Проверка:
docker compose exec -T celery sh -c 'touch /var/lib/soniks-network/media/.w'
web слушает только 127.0.0.1:8000, nginx обязан проксировать туда. Кроме
сайта портала nginx нужен сервер по умолчанию, отбивающий чужой Host. Иначе
сайт с единственным listen 443 сам становится умолчанием, и сканеры, которые
ходят по голому IP, доходят до Django: в логе web на каждый их запрос
DisallowedHost с трейсбеком, и на каждый новый путь уходит письмо админам.
server {
listen 80 default_server;
listen [::]:80 default_server;
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
# На серверах nginx 1.18, ssl_reject_handshake (1.19.4+) нет: TLS
# завершается на сертификате сайта, затем соединение закрывается.
ssl_certificate /etc/letsencrypt/live/sonik.space/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sonik.space/privkey.pem;
return 444;
}
Если в sites-enabled остался default со своим default_server, nginx -t
сообщит о дубле — его убрать. С двумя серверами на порту nginx строит хеш имён, и
на стенде корзина по умолчанию 32 байта: could not build server_names_hash.
Лечится server_names_hash_bucket_size 64; на уровне http — в nginx.conf или
в начале файла сайта, но только в одном месте. Проверка: curl -k https://<IP>/ обрывается,
curl http://<IP>:8000/ снаружи не соединяется.
Уборка диска на сервере с раннером¶
publish собирает образ с --no-cache --pull на каждый пуш, поэтому слои не
переиспользуются: каждая сборка кладёт на диск новый образ и новый кэш. Ничто их
не удаляет — за пару недель это десятки гигабайт.
Кончившееся место видно не сразу и не как «нет места». Сборка падает там, куда
первым доехал обрезанный файл: 22.09.2026 это был apt в runtime-стадии,
сообщивший At least one invalid signature was encountered сразу по всем трём
репозиториям Debian. Подпись Debian при этом в порядке — тот же образ и та же
команда на другой машине отрабатывают.
Проверить: df -h /, df -i /, docker system df.
Уборка — contrib/docker-gc.sh из чекаута на сервере. Снимает контейнеры и кэш
сборки старше KEEP (по умолчанию неделя), образы, которые не держит ни один
контейнер, и анонимные тома, оставшиеся от сервисов dind. Именованные тома не
трогает — в них база. Откату не мешает: soniks.sh rollback тянет тег из
registry.
Образы чистятся без ограничения по возрасту сознательно: в docker 29.3 фильтр
--filter until= на docker image prune не отбирает ничего, неиспользуемый
образ переживает и относительную метку, и абсолютную. Проверять это на каждой
версии демона дороже, чем скачать образ заново.
Ставится в cron, /etc/cron.d/soniks-docker-gc (файл должен кончаться переводом
строки, иначе cron его молча пропустит):
15 4 * * * root cd /путь/к/soniks-network && ./contrib/docker-gc.sh 2>&1 | logger -t docker-gc
Проверить после установки: journalctl -t docker-gc --since yesterday.
Каталог сборок раннера (/home/gitlab-runner/builds) живёт отдельно и
чистится самим раннером — скрипт его не касается.
Сканеры безопасности¶
Подключены шаблоны GitLab: Container Scanning, Dependency Scanning, SAST и Secret
Detection. Container Scanning запускается только на основной ветке в неймспейсе
space-education-development.