Ветку закрыли неделю назад. Автор давно переключился на другую задачу, ревьюер забыл, как выглядела та кнопка, а на сервере по-прежнему крутятся три контейнера, занимают память и отвечают по старой ссылке. Если открыть её, окажется, что живёт не просто «страница», а целое приложение со своей базой, со своими переменными и, возможно, с настоящим ключом от внешнего сервиса. Никто его не обслуживает, потому что формально его уже нет: pull request закрыт. Но физически оно есть.
Так выглядит типичная судьба preview-окружения, которое делали как удобную мелочь. Запуск написали за вечер, а о том, как оно будет умирать, не подумали. Эта статья посвящена обратной стороне: как давать каждому pull request живую демонстрацию и не получить сервер, который растёт, пока не упрётся в потолок.
Временный продукт, а не маленький production
Первое, что стоит поменять, это само представление о preview. Его легко воспринимать как уменьшенную копию боевой системы: те же сервисы, та же схема, только по другому адресу. Из такого взгляда вырастает неудобный набор требований: стабильность, мониторинг, обновления. Но у preview другая задача. Он нужен, чтобы ревьюер за пару минут увидел изменение в работе, а автор получил обратную связь. Через несколько дней после слияния ветки он не нужен никому.
Поэтому у такого окружения нет «жизни», есть срок. Хорошо, когда этот срок записан прямо в ресурсе, а не в чьей-то голове. Минимальный набор сведений о каждом preview таков:
- номер pull request, из которого оно выросло;
- владелец: автор изменений, а не абстрактная «команда»;
- время создания и время, после которого окружение считается просроченным;
- имя набора ресурсов, по которому его можно целиком найти и удалить.
Это не паспорт сервера с описанием обязанностей. Это бирка на вещи в гардеробе: номерок, чтобы потом можно было прийти и забрать, а если не забрали, понять, чьё это и почему висит.
В документации Docker Compose отмечено, что одну и ту же конфигурацию можно использовать в CI, staging и production с отличающимися настройками (Docker Compose: Production). Для preview это удобно: базовый файл описывает сервисы, а поверх него накладываются параметры конкретного окружения. Но тут же нужно помнить, что в production-настройках обычно убирают bind mount кода, меняют порты и окружение, добавляют restart policy. Для preview такой же вопрос стоит иначе: restart policy нужен осторожно, и ниже будет понятно почему.
Имя проекта как граница окружения
Самый практичный способ отделить окружения друг от друга при работе с Compose — давать каждому собственное имя проекта, производное от номера pull request, например pr-482. Контейнеры, сети и тома, созданные в рамках такого проекта, получают это имя в префиксе, и их можно найти по нему же. Два preview не наступают друг другу на ноги: у каждого свои контейнеры и своя внутренняя сеть.
Запуск выглядит примерно так:
docker compose -p pr-482 -f compose.yaml -f compose.preview.yaml up -d --buildФайл compose.preview.yaml содержит то, что отличает демонстрацию от боевой схемы: убранные внешние порты, подключение к служебной сети маршрутизатора, метки со сроком жизни. Код приложения запекается в образ, а не монтируется с диска: документация Compose прямо говорит, что обновление кода внутри образа требует пересборки и пересоздания контейнера. Для preview это означает простое правило: новый коммит в ветке — новая сборка образа и пересоздание только того сервиса, который изменился. Зависимости, например базу, можно не трогать, потому что Compose умеет обновить отдельный сервис без пересоздания остальных.
Заодно решается вопрос, что делать с коммитами. Не нужно держать по окружению на каждый коммит. Окружение одно на pull request, и каждое новое изменение заменяет предыдущую версию приложения внутри него.
Данные, секреты и адрес: три вещи, которые нельзя делить
Три ресурса чаще всего «протекают» между окружениями, потому что их проще всего сделать общими. Каждый из них стоит разобрать отдельно.
Данные
Если все preview подключены к одной тестовой базе, то миграция из одной ветки ломает демонстрацию другой. Ревьюер открывает ветку Б и видит ошибку, потому что ветка А уже переименовала колонку. Поэтому у каждого окружения своя база, а точнее, свой том с данными, созданный внутри проекта pr-482. Начальное содержимое берётся из небольшого обезличенного набора, который хранится рядом с кодом: достаточно, чтобы интерфейс выглядел живым, но не настолько, чтобы там лежали настоящие адреса клиентов.
Копировать production-дамп в preview не стоит по простой причине: у каждого окружения круг людей, которые его видят, шире, чем у боевой базы. К тому же такая копия при уборке оказывается ещё одним местом, откуда персональные данные можно забыть удалить.
Секреты
Для preview нужны собственные, отдельно выпущенные ключи с ограниченными правами: например, ключ платёжной песочницы вместо боевого, тестовый почтовый ящик вместо рассылки клиентам. Секрет передаётся в окружение при запуске из хранилища CI и не записывается в образ. Образ может оказаться в реестре, где его увидит больше людей, чем рассчитывали.
Заранее полезно задать себе вопрос: что худшего сможет сделать человек, который получил доступ внутрь этого окружения? Если ответ «отправить письма настоящим клиентам», значит, ключ выбран неправильно.
Адрес
У каждого preview свой поддомен, построенный из номера: pr-482.preview.example.com. Он не должен меняться между коммитами, иначе ссылка в комментарии к pull request устареет после первого же пуша. Один wildcard-DNS на всю зону превращает создание адреса в чисто программную операцию: никаких правок в DNS на каждое окружение. Маршрутизация внутри VPS возлагается на обратный прокси, который находит нужный контейнер по имени, а не по порту на хосте.
Такое решение избавляет от одной частой неприятности: контейнеры preview вообще не публикуют порты наружу. Наружу смотрит только прокси, а всё остальное доступно лишь внутри служебной сети. Меньше открытых портов — меньше мест, где можно ошибиться и случайно выставить базу в интернет.
Кто может открыть демонстрацию
Ссылка на preview быстро расходится по рабочим чатам, а потом её находят и те, кому она не предназначалась. Обычно поисковые роботы приходят не сразу, но адреса бывают видны в публичных комментариях к pull request. Поэтому на входе нужна защита, не зависящая от самого приложения: если приложение ветки сломано или недописано, его собственная авторизация может не работать.
Простой вариант — базовая аутентификация на уровне прокси с общим логином для команды. Строже — проверка через единый вход компании или список разрешённых адресов. Какой бы способ ни выбрали, правило одно: проверка выполняется до того, как запрос дошёл до контейнера ветки.
Вторая сторона вопроса — что preview может сделать наружу. Приложение в окружении живое, значит, оно умеет отправлять письма, обращаться к платёжным и складским API, ставить задания во внешние очереди. Именно эти побочные действия опаснее самого просмотра страницы. Их нужно отключить или перенаправить на безопасную замену:
- исходящая почта уходит в ловушку (локальный сервис, собирающий письма без отправки);
- платёжные и складские интеграции подключены к песочницам или к заглушкам;
- фоновые задачи, которые трогают внешний мир, по умолчанию выключены и включаются явным флагом;
- вебхуки внешних сервисов не направляются на адреса preview, иначе чужие события придут в чужую ветку.
Самое неприятное в этом списке последнее. Если сторонний сервис однажды настроили слать события по маске адреса, то оставшееся окружение продолжает их принимать, даже когда о нём все забыли.
Граница доверия на самом хосте
Отдельный разговор — что именно запускает CI. В описании безопасности Docker Engine сказано, что доступ к rootful-демону Docker это существенная граница доверия: управление им может позволить смонтировать файловую систему хоста (Docker Engine security). Для preview отсюда следует практический вывод. Конвейер, который собирает и запускает ветку по команде из pull request, фактически выполняет код автора на хосте с правами демона. Если pull request приходит от внешнего участника, а сборка выполняется на том же VPS, где живут остальные окружения, граница доверия проходит через всех сразу.
Разумная осторожность выглядит так: автоматически поднимать preview только для веток людей из своей команды, а для чужих требовать ручного подтверждения. Не давать контейнерам ветки смонтированный сокет Docker и лишние привилегии: в документации подчёркнуто, что конфигурация контейнера и дополнительные возможности процесса влияют на изоляцию. Изоляцию в контейнерах обеспечивают namespaces и cgroups, но это инструменты, а не гарантия при любой конфигурации: контейнер, запущенный с избыточными правами, защищён заметно хуже.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Сколько окружений выдержит одна машина
Если неделя была оживлённой, одновременных pull request может оказаться десяток. Каждое preview состоит минимум из приложения и базы, часто ещё из кэша и фонового воркера. Сервер при этом один, и его память не растягивается.
Посчитаем на условных числах, показывающих логику, а не измерение. Пусть одно окружение в покое занимает около 400 МБ памяти, а во время сборки и прогона миграций пиково до 1 ГБ. На машине с 8 ГБ памяти, из которых около 2 ГБ нужно под систему, прокси и сам CI-агент, остаётся примерно 6 ГБ. Десять окружений в покое займут около 4 ГБ, и это кажется приемлемым. Но если три из них одновременно пересобираются после пушей, то к 4 ГБ прибавится ещё около 1,8 ГБ пикового запаса, и получается уже 5,8 ГБ при пределе в 6. Запас съеден, а на деле одного большого запроса уже хватит, чтобы ядро начало убивать процессы по нехватке памяти. Реальные цифры у каждого приложения свои, их придётся снять самому, но сама арифметика пикового запаса везде одинакова: считать нужно не среднее, а наихудший случай.
Квота и очередь вместо надежды
Из расчёта следует правило: максимальное число одновременно работающих окружений задаётся явно. Например, шесть. Когда приходит седьмой pull request, у системы есть три разумных варианта:
| Вариант | Что происходит | Когда подходит |
|---|---|---|
| Отказать с комментарием | В pull request появляется сообщение, что мест нет, а запуск возможен вручную | Редкие пиковые дни, небольшая команда |
| Вытеснить самое старое | Останавливается окружение, которое дольше всех не получало новых коммитов | Много параллельных веток, свежие изменения важнее |
| Поставить в очередь | Окружение создаётся, когда освободится место | Когда демонстрация нужна не срочно |
Для большинства команд лучше всего работает вытеснение по давности активности. Свежее изменение — это то, что сейчас смотрят. Ветка, где последний коммит был пять дней назад, по смыслу ближе к заброшенной. Вытесненное окружение не обязательно удалять сразу: достаточно остановить контейнеры и оставить том и запись в метках. Как только автор снова запушит, окружение поднимется заново. Память освобождается, а работа не пропадает.
Ограничить аппетиты каждого окружения тоже стоит: лимит памяти и процессора на контейнер приложения не даёт одной сломанной ветке с утечкой съесть весь хост. Тут и приходит на помощь механизм cgroups, о котором шла речь выше: именно через него Docker ограничивает ресурсы процесса.
Про restart policy нужно сказать отдельно. В production её добавляют, чтобы контейнеры поднимались после перезапуска машины. Для preview это поведение двоякое: оно спасает от случайного падения, но после перезагрузки VPS поднимет вообще все окружения, включая давно забытые. О том, к чему это приводит, речь пойдёт ниже.
Сиротское окружение: разбор одного случая
Разберём условный, но очень типичный сценарий. Разработчица Анна открывает pull request №482, система создаёт окружение pr-482. Метки на контейнерах говорят: владелец — anna, создано во вторник, истекает через семь дней. Анна пушит правки, потом сливает ветку и закрывает pull request в четверг.
Закрытие порождает событие, которое должен принять CI и выполнить команду удаления. Но именно в этот момент сервис CI был недоступен из-за обновления, и событие не дошло. Повторной доставки нет. Pull request закрыт, ветка удалена, а на VPS остались:
- три контейнера проекта
pr-482: приложение, база, воркер; - том с данными базы;
- собранный образ ветки;
- файл маршрута у обратного прокси, который по-прежнему направляет
pr-482.preview.example.comна контейнер.
Теперь окружение осиротело: у него нет pull request, который бы о нём напоминал, и никто не ждёт его уборки. На ссылке по-прежнему открывается приложение, а воркер продолжает работать с песочничными ключами.
Через неделю VPS перезагружают из-за обновления ядра. Если у контейнеров включена политика автоматического перезапуска, окружение поднимется само. Причём не как-нибудь, а целиком: с тем же томом, с теми же переменными и с тем же маршрутом. Для системы оно ничем не отличается от живого. Это и есть самое неприятное свойство сироты: перезапуск не просто сохраняет его, а подтверждает его существование как будто бы заново.
Из этой картины видно, на чём нельзя строить уборку. Если единственный механизм удаления это событие закрытия pull request, то его пропуск оставляет ресурсы навсегда. Нужен второй, независимый способ узнать, что окружение просрочено, и он не должен зависеть ни от CI, ни от состояния репозитория.
Поиск просроченного по сроку жизни
Идея проста: сам хост знает, какие окружения на нём запущены и когда они должны были закончиться. Для этого достаточно меток, поставленных при создании. В Compose метки задаются прямо в файле сервиса:
labels:
preview.pr: "482"
preview.owner: "anna"
preview.expires: "2026-11-12T18:00:00Z"Раз в час на VPS запускается небольшая задача, например через планировщик cron или systemd timer, и делает три вещи:
- перечисляет контейнеры, у которых есть метка
preview.expires, например черезdocker ps -a --filter label=preview.expires; - сравнивает значение метки с текущим временем;
- для каждого просроченного окружения выполняет то же удаление, что и обычная уборка при закрытии.
Принципиально важно, что эта задача не спрашивает у GitHub или другой системы, открыт ли ещё pull request. Она читает только срок на самом ресурсе. Если событие закрытия потерялось, срок всё равно наступит. Если VPS перезагрузили и окружение поднялось, срок по-прежнему написан в метке и будет проверен при ближайшем запуске.
Вторая, дополняющая проверка идёт в обратную сторону: список окружений на хосте сверяется со списком открытых pull request. Всё, чего нет среди открытых, помечается как кандидат на удаление, а если у него к тому же прошёл срок, удаляется сразу. Ресурсы без метки срока вообще не должны существовать: если задача находит том или сеть с префиксом pr-, но без привязанных контейнеров и меток, она сообщает об этом владельцу или хотя бы в журнал.
Срок жизни при этом можно продлевать. Если каждый новый коммит сдвигает preview.expires на семь дней вперёд, то активное окружение живёт, а брошенное через неделю исчезает само. Автор, которому нужно дольше, пишет явную команду продления, и это осознанный выбор, а не забытое состояние.
Нужно сказать честно: Compose сам не следит за закрытием pull request и сам ничего не удаляет по расписанию. Всё, что выше, это собственный процесс в CI и на хосте, и его надо написать и проверить. Это и хорошо: когда срок и владелец записаны в самом ресурсе, поиск сирот превращается в обычный запрос по меткам.
Закрытие pull request: что именно нужно удалить
Когда событие закрытия всё-таки приходит, уборка должна быть полной. Контейнеры остаются самой заметной частью, но далеко не единственной. По порядку:
- Остановить и удалить контейнеры и сеть проекта. Команда
docker compose -p pr-482 downубирает контейнеры и сети проекта. - Удалить тома. По умолчанию
downтома не трогает. Для preview с отдельной базой нужен ключ--volumes, иначе данные останутся на диске, и именно они обычно занимают больше всего места. - Удалить образ ветки. Собранные образы копятся быстрее, чем кажется: каждая пересборка оставляет слои. Образ с тегом
pr-482удаляется явно. - Убрать запись маршрутизации. Если маршрут создаётся файлом или меткой для прокси, он должен исчезнуть вместе с окружением. Иначе адрес продолжает числиться в конфигурации и в лучшем случае отвечает ошибкой шлюза, а в худшем когда-нибудь будет отдан другому окружению с тем же номером.
- Удалить рабочие файлы. Каталог со склонированным кодом, временные файлы сборки, выгруженные артефакты.
- Оставить в журнале короткую запись: что удалено, когда и по какой причине (событие закрытия или истёк срок).
Порядок важен. Если убрать маршрут последним, на несколько секунд адрес указывает на уже остановленные контейнеры. Если сначала убрать маршрут, то ссылка перестаёт открываться раньше, чем исчезают данные, и этот интервал безопаснее.
Удаление должно быть повторяемым: запуск уборки для уже убранного окружения не должен падать с ошибкой. Тогда и событие закрытия, и почасовая сверка могут безопасно вызывать одну и ту же функцию. Если одна из них отработала, вторая просто ничего не найдёт.
Какой VPS подходит под такую нагрузку
Preview-окружения хорошо ложатся на отдельный VPS с root-доступом, потому что им нужен собственный Docker, свой прокси и право ставить планировщик. Общий хостинг без доступа к демону для такой схемы не подойдёт. Отдельная машина ещё и отделяет демонстрации от боевых сервисов: даже если ветка с утечкой памяти съест всё, что ей позволено, production окажется на другом сервере.
Планы MAATRIX дают разные наборы ресурсов: Ba с 1 vCPU, 2 ГБ RAM и 40 ГБ NVMe, Uab с 2 vCPU, 4 ГБ и 80 ГБ, Heka с 4 vCPU, 8 ГБ и 160 ГБ, Maat с 8 vCPU, 16 ГБ и 240 ГБ, Djed с 16 vCPU, 32 ГБ и 320 ГБ. Эти цифры описывают ресурсы плана, а не ёмкость по числу окружений: сколько preview поместится, зависит от самого приложения, и это нужно мерить на своей сборке. Для небольшой команды с лёгким приложением разумной стартовой точкой может быть план среднего размера, а для тяжёлых сборок с несколькими сервисами и базами значения квоты стоит определять по пиковому потреблению, как в расчёте выше.
Диск здесь важнее, чем принято думать: тома баз и образы растут молча. NVMe помогает, когда сборки образов идут одна за другой, а квота по числу окружений защищает и память, и место на диске. Посмотреть актуальные локации, способы оплаты и условия можно на странице аренды VPS MAATRIX.
Если же preview нужен для сайта, а не для сервисного приложения, то часть вопросов про данные и доступ перекликается с настройкой постоянной копии: об этом подробнее в материале про настройку staging-копии сайта. Разница в том, что staging живёт долго и обновляется, а preview рождается и умирает вместе с pull request.
Ссылка, которая должна перестать открываться
Вернёмся к закрытой неделю назад ветке. В хорошо устроенной системе её ссылка в тот же день начинает отвечать «не найдено», а на сервере не остаётся ни контейнеров, ни тома, ни образа, ни строки в конфигурации прокси. Автор может не помнить, что у него когда-то было окружение, и это нормально: именно так и должно быть.
Если же ссылка всё ещё открывается, значит, процесс завершения не сработал, причём ни основной путь по событию, ни страховочный по сроку. Поэтому запуск и остановка пишутся как две половины одной функции. Создание окружения, которому не научили умирать, сделано только наполовину: оно работает, пока на него смотрят, и продолжает работать, когда смотреть уже некому.