Review apps: окружение на каждый MR

Разворачиваем временную копию приложения на каждый MR, чтобы ревьюер видел изменения вживую.

Review app — динамическое эфемерное окружение, разворачиваемое для конкретного Merge Request и автоматически удаляемое при его закрытии.

Зачем смотреть, а не воображать

Читать diff полезно, но «как это выглядит в работе» по коду не всегда понятно. Review apps решают это: для каждого MR поднимается живая копия приложения с изменениями этой ветки. Ревьюер открывает ссылку прямо из MR и щёлкает по интерфейсу. Это одна из визитных карточек GitLab.

Стоит проговорить, чего именно diff не показывает. Diff отвечает на вопрос «что изменилось в тексте», но молчит про то, как это ощущается: не съехала ли вёрстка на узком экране, не стало ли действие на два клика длиннее, читается ли новый текст ошибки. Особенно это касается фронтенда и UX-правок, где «правильный код» и «удобный продукт» — не одно и то же. Review app переносит проверку из плоскости «я прочитал и поверил» в плоскость «я открыл и убедился». Это снимает с ревьюера необходимость держать в голове виртуальную модель приложения и мысленно применять к ней патч.

Есть и неочевидная выгода: review app полезен не только программисту-ревьюеру. Ссылку из MR можно дать дизайнеру, продакт-менеджеру или тестировщику, и они проверят фичу до мёржа, не разворачивая ничего у себя и не дожидаясь попадания кода на общий staging. Так обратная связь приходит раньше и дешевле: исправить замечание в открытом MR проще, чем после релиза. По сути review apps сдвигают тестирование и приёмку влево по пайплайну — к моменту, когда менять код ещё легко.

Динамическое окружение

Магия в том, что имя и URL окружения формируются из переменных, уникальных для ветки/MR:

deploy-review:
  stage: deploy
  script:
    - ./deploy.sh "review-$CI_COMMIT_REF_SLUG"
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: https://$CI_COMMIT_REF_SLUG.review.example.com
    on_stop: stop-review
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

Каждый MR получает своё окружение review/имя-ветки и свой поддомен. CI_COMMIT_REF_SLUG — «безопасная» для URL версия имени ветки.

Разберём, почему имя пишут с косой чертой — review/$CI_COMMIT_REF_SLUG. GitLab трактует часть до / как «папку» окружений и группирует все review-окружения вместе на странице Environments. Вместо плоской свалки из сотни записей вы получаете аккуратную ветку review/*, которую можно свернуть и которая не мешает видеть staging и production. Это не требование синтаксиса, а соглашение, заметно улучшающее навигацию, когда открытых MR много.

Отдельно про CI_COMMIT_REF_SLUG. Имена веток в Git могут содержать символы, недопустимые в DNS и URL: слэши (feature/login), верхний регистр, подчёркивания, длинные хвосты. Переменная-slug приводит имя к нижнему регистру, заменяет небезопасные символы на дефис и обрезает длину так, чтобы получился валидный поддомен. Использовать сырое CI_COMMIT_REF_NAME в URL — почти гарантированно нарваться на сломанный адрес на первой же ветке с нестандартным именем.

Автоматическая уборка

Эфемерные окружения нельзя оставлять навсегда — иначе их накопятся сотни. Поэтому объявляют джобу остановки, ссылаясь на неё в on_stop:

stop-review:
  stage: deploy
  script:
    - ./teardown.sh "review-$CI_COMMIT_REF_SLUG"
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop
  when: manual
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

action: stop помечает джобу как останавливающую окружение. GitLab вызовет её при закрытии/слиянии MR (или по кнопке), и review app исчезнет.

Тут важна экономика, а не только чистота. Каждый поднятый review app потребляет ресурсы: память, CPU, иногда отдельный namespace в Kubernetes, запись в реестре образов, строку в балансировщике. Десять открытых MR — это десять параллельно работающих копий приложения. Если уборку не автоматизировать, окружения переживают свои MR, и через месяц инфраструктура забита «зомби-стендами», за которые приходят счета, а никто уже не помнит, к какой ветке они относились. Связка on_stop + action: stop делает жизненный цикл окружения симметричным жизненному циклу MR: открылся — поднялось, закрылся — погасло.

Для подстраховки на случай «забытых» MR у окружений есть и автоудаление по неактивности (auto-stop через срок жизни): можно задать, что review app сам гасится, скажем, через несколько дней без новых деплоев. Это страховочная сетка под основной механизм on_stop, а не замена ему.

Сравнение с GitHub Actions

В GitHub похожего эффекта добиваются через GitHub Environments и сторонние сервисы (Vercel/Netlify preview deployments). В GitLab review apps — встроенная концепция динамических окружений, тесно интегрированная с MR и историей деплоев.

Разница в том, откуда берётся фича. В экосистеме GitHub превью-деплои чаще всего приносит платформа хостинга: Vercel или Netlify сами слушают пуши, поднимают preview-URL и постят его в Pull Request. Это удобно, но привязывает вас к конкретному провайдеру и хорошо работает в основном для фронтенда и статики. В GitLab review app — это ваш деплой-скрипт плюс механизм динамических окружений: вы сами решаете, что и куда катить (Kubernetes, своя VM, что угодно), а GitLab берёт на себя связывание с MR, ссылку в виджете и автоуборку. Гибкости больше, но и собрать конвейер придётся самому — это не «галочка», а несколько джоб в .gitlab-ci.yml.

Как работает под капотом

Поскольку name окружения содержит переменную, GitLab создаёт динамическое окружение на лету для каждого значения. В виджете MR появляется ссылка на URL этого окружения. При событии закрытия MR GitLab находит окружение с тем же именем и запускает связанную через on_stop джобу с action: stop, после чего помечает окружение остановленным.

Ключевой момент — как stop-джоба вообще может запуститься после закрытия MR, когда нового пайплайна уже нет. GitLab при первом деплое запоминает stop-джобу как «отложенную»: её определение сохраняется в привязке к окружению. Когда наступает событие остановки, движок не генерирует новый пайплайн с нуля, а исполняет именно ту запомненную джобу в том же контексте переменных, что и при создании окружения. Поэтому так важно, чтобы name у deploy- и stop-джобы совпадал символ в символ: по нему GitLab и находит, что именно гасить.

Поток событий целиком:

MR открыт / push -> deploy-review -> окружение review/branch (live)
                                        |
                                        v
                         ссылка в виджете MR -> ревьюер кликает

MR закрыт / смёржен -> GitLab находит окружение по name
                    -> запускает stop-review (action: stop)
                    -> окружение помечено stopped, ресурсы освобождены

Частые ошибки

  • Забыть on_stop — review apps не убираются, инфраструктура засоряется.
  • Использовать в URL сырое имя ветки вместо CI_COMMIT_REF_SLUG — символы вроде / ломают поддомен.
  • Не ограничить джобу rules на MR — review-окружения начнут плодиться и на обычных push.
  • Дать stop-джобе имя окружения, отличное от deploy-джобы — GitLab не свяжет их и не сможет погасить окружение.
  • Полагаться только на ручную кнопку остановки без auto-stop — забытые MR оставят зомби-стенды.

Итоги

  • Review app — живая копия приложения на каждый MR для визуальной проверки.
  • Динамическое окружение с именем/URL из CI_COMMIT_REF_SLUG.
  • on_stop + action: stop автоматически гасят окружение при закрытии MR.
  • Совпадение name у deploy- и stop-джобы — обязательное условие автоуборки.
  • В отличие от GitHub-экосистемы, превью в GitLab — ваш деплой-скрипт, а не услуга провайдера хостинга.
Проверьте себя
1. Что делает review app в GitLab?
AЛинтит YAML
BРазворачивает живую копию приложения для конкретного MR, чтобы ревьюер видел изменения
CШардирует тесты
DКеширует зависимости
2. Зачем review-окружению нужна джоба on_stop с action: stop?
AЧтобы ускорить деплой
BЧтобы автоматически гасить эфемерное окружение при закрытии MR и не копить мусор
CЧтобы зашифровать URL
DЭто обязательно для всех джоб