# 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: ```yaml 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. Джоба выполняется только на основной ветке. ```{important} Справочник кладётся в `public/api-reference`, а не в `public/api`. Раздел документации `docs/api/` уже собирается в `public/api`, и `mv` положил бы `html2` **внутрь** существующего каталога, а не занял бы его имя — справочник оказался бы по адресу `/api/html2/`, а ссылки на него молча вели бы в никуда. ``` Результат доступен по адресу GitLab Pages проекта, а портал проксирует его на `sonik.space/docs/network/`. Сниппет nginx (живёт в конфигурации портала, **не** в этом репозитории): ```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: ```nginx 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`. ```{important} До 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=:<тег>` в `.env` и делает `pull`. ```{note} Развёрнутый артефакт — 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 секунд. ## Откат Откат — это возврат на предыдущий тег того же образа: ```bash 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`. Тогда путь один — восстановление базы из дампа на момент до выкатки (см. [](../operations.md)), и только затем старый образ. Такие миграции идут отдельным MR и помечаются в описании релиза — см. [](releasing.md). ## 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:///v2/…`, префикс пути не умеет. ```nginx # Проверка версии 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//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/` снаружи обязаны быть закрыты: иначе это оракул валидности токена. ```nginx 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; } ``` 3. Проверка — с машины снаружи, токеном владельца любой станции: `mosquitto_sub -h sonik.space -p 443 -L "wss://sonik.space/mqtt" -u station- -P <токен> -t stations//state` должен отдать retained-документ после сохранения формы; `curl -X POST https://sonik.space/api/v2/mqtt/auth/` обязан получить 404. Порядок — стенд, потом прод. 4. Подписка на данные спутника. Владелец станции в сети подписывается на спутник на его странице и берёт в профиле логин `subscriber-` и ключ. Клиент с этими данными (пример — [](../satellites.md), раздел «Подписка на данные») на `satellites//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` при сохранении картинки спутника. Проверка: ```bash 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` с трейсбеком, и на каждый новый путь уходит письмо админам. ```nginx 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:///` обрывается, `curl http://: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 его молча пропустит): ```text 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`.