CI/CD и деплой

Стадии пайплайна

.gitlab-ci.yml разбит на девять стадий:

Стадия

Джобы

Что делает

schema

schema

./manage.py spectacular --validate → openapi.yml

api

api

openapi-generator: Python-клиент и HTML-справочник (html2)

static

js_css_linting, python_linting

npm run assets, eslint, stylelint, jest; tox -e ruff

build

docs, build, build_api

tox -e docs, сборка пакета, сборка клиента

test

test

tox -e deps,pytest

publish

publish, pages

Сборка и публикация Docker-образа; публикация документации

security

container_scanning, dependency_scanning, sast, secret_detection

Сканеры GitLab; отчёты готовы до выкатки на стенд, но деплой не блокируют

deploy_stand

deploy_stand

Выкатка на стенд dev.sonik.space: пуш в soniks и теги; пуш в другие ветки стенд не трогает

deploy_prod

deploy_prod

Выкатка в прод по тегу, ручной запуск

Образы задаются переменными: 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:

  1. docker login в registry.

  2. Удаление рабочего каталога, свежий git clone, git reset --hard $CI_COMMIT_SHORT_SHA.

  3. Копирование env-dist из $FILES_FOLDER в .env.

  4. Подстановка учётных данных Space-Track из CI/CD-переменных (SPACE_TRACK_USERNAME_DEV / SPACE_TRACK_PASSWORD_DEV) — в env-dist на сервере их искать бесполезно.

  5. 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 это сказано), порядок такой:

  1. До кнопки: дамп, предпроверки данных из CHANGELOG.md, остановить celery работающей версии.

  2. Кнопка deploy_prod; сразу после неё — docker-compose stop celery celery-beat.

  3. Дождаться 200 от /ready/: он отвечает 503, пока план миграций не пуст.

  4. Если миграция переписывала большую таблицу целиком — VACUUM FULL <таблица>, затем обычный VACUUM ANALYZE <таблица>: после FULL карта видимости пуста, и без второго прохода подсчёты по индексу читают саму таблицу.

  5. 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 клиента.

Включение на сервере — всё руками, репозиторий этого не делает:

  1. Проверить, что 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 ниже.

  2. mkdir -p /opt/storage/network_volumes/registry — bind в длинной форме каталог не создаёт.

  3. В $FILES_FOLDER/env-dist: COMPOSE_PROFILES=registry и read-only токен Docker Hub в REGISTRY_PROXY_USERNAME / REGISTRY_PROXY_PASSWORD. Без токена прокси работает анонимно, но промахи упираются в лимит Docker Hub.

  4. Сниппет в 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:

  1. В $FILES_FOLDER/env-dist: COMPOSE_PROFILES=registry,mqtt, MQTT_BROKER_HOST=mosquitto, MQTT_PORTAL_USERNAME=portal и любой MQTT_PORTAL_PASSWORD — под ним ходят публикация и мост.

  2. Сниппет в 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;
}
  1. Проверка — с машины снаружи, токеном владельца любой станции: 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. Порядок — стенд, потом прод.

  2. Подписка на данные спутника. Владелец станции в сети подписывается на спутник на его странице и берёт в профиле логин subscriber-<id> и ключ. Клиент с этими данными (пример — Спутники и передатчики, раздел «Подписка на данные») на satellites/<sat_id>/frames получает кадр, отправленный через SiDS POST /api/demoddata/. Проверять с каждого языкового хоста, где nginx ведёт /mqtt: адрес брокера профиль строит из текущего хоста.

Что нужно на сервере

Переменная CI/CD

Назначение

ID_RSA, SERVER_USER, SERVER_IP, SONIKS_FOLDER

Доступ и путь на стенде

PROD_ID_RSA, PROD_SERVER_USER, PROD_SERVER_IP, PROD_WORKDIR, PROD_SONIKS_FOLDER

То же для прода

FILES_FOLDER

Каталог с env-dist на сервере

SPACE_TRACK_*_DEV, SPACE_TRACK_*_PROD

Учётки Space-Track

NODE_OPTIONS

Опции Node при сборке статики

CACHE_IMAGE

Образ для кэша сборки

Конфигурация 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.