Антипаттерн: собирать образ прямо на боевом сервере
Классический сценарий небольшой команды: заходим по SSH на прод, делаем git pull, следом docker build -t myapp . && docker-compose up -d --build — и вроде бы всё работает, обновление выкатилось за одну команду. Проблема в том, что этот же сервер в этот момент обслуживает реальных пользователей, а сборка образа — тяжёлая, непредсказуемая по составу операция, которая никак не должна жить рядом с продакшен-трафиком. Разберём, что именно идёт не так, когда docker build выполняется прямо на боевой машине, и как перейти на схему, где на проде вообще нет инструментов сборки — только неизменяемые готовые образы.
Содержание
- Как это выглядит на практике
- Проблема 1: сборка отнимает ресурсы у реального трафика
- Проблема 2: несогласованный образ — сегодня одно, завтра другое
- Проблема 3: нет единого источника истины, что именно запущено
- Проблема 4: откат превращается в отдельную задачу
- Правильный подход: сборка в CI, публикация в registry, деплой неизменяемого образа
- Как перейти от сборки на проде к CI/CD пошагово
Как это выглядит на практике
Типичный деплой-скрипт, который встречается в проектах, выросших из «одного разработчика и одного сервера»:
#!/bin/bash
# deploy.sh — выполняется прямо на проде
cd /opt/myapp
git pull origin main
docker build -t myapp:latest .
docker-compose up -d --build
docker image prune -f
Его запускают вручную по SSH или вешают на git-хук после пуша. Работает это ровно до тех пор, пока сборка лёгкая, зависимостей мало, а нагрузка на сервер невысокая. Дальше начинают проявляться проблемы, которые по отдельности выглядят как случайные баги, а на самом деле — системное следствие одного архитектурного решения: сборка и эксплуатация делят один и тот же сервер.
Более скрытый вариант того же антипаттерна — CI-раннер, физически развёрнутый на проде «для простоты», чьи джобы собирают образ прямо там же, откуда потом этот образ и запускается. Формально это уже «CI», но по факту — та же сборка на боевой машине, просто обёрнутая в пайплайн.
Проблема 1: сборка отнимает ресурсы у реального трафика
docker build — это не лёгкая операция. Каждый шаг Dockerfile — это отдельный процесс: apt-get install разворачивает и распаковывает пакеты, npm install или pip install компилирует нативные расширения, копирование контекста сборки читает и хеширует файлы, финальная сборка слоёв пишет и сжимает данные на диск. Всё это конкурирует за CPU, оперативную память и дисковый I/O с процессами, которые в этот момент обслуживают запросы пользователей.
На практике это означает:
- Просадка отклика на время сборки. Если веб-сервер и база данных делят диск и CPU со сборочным процессом, время ответа на реальные запросы может заметно вырасти именно в момент деплоя — то есть ровно тогда, когда вы меньше всего хотите деградации.
- Риск нехватки памяти. Компиляция зависимостей (особенно нативных модулей Node.js или Python-пакетов с C-расширениями) может кратковременно потреблять существенно больше памяти, чем сам рантайм приложения. На сервере с плотной укладкой контейнеров это может довести до OOM-киллера, который остановит совсем не тот процесс, который вы собирали.
- Конкуренция за дисковый кеш. Docker кеширует слои сборки на диске, а СУБД держит свои страницы в page cache той же оперативной памяти. Сборка вытесняет горячие данные базы из кеша — после неё запросы к БД какое-то время идут мимо кеша и выполняются медленнее, даже если сама сборка уже завершилась.
Отдельная деталь: docker-compose up -d --build пересобирает образ до того, как остановит старый контейнер, — то есть окно нагрузки от сборки накладывается на окно, когда старая версия приложения ещё обслуживает трафик. Ресурсов из-под неё сборка тоже откусывает.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПроблема 2: несогласованный образ — сегодня одно, завтра другое
Ключевая иллюзия сборки на проде — что один и тот же Dockerfile и один и тот же коммит всегда дают один и тот же образ. Это не так, и причина — во внешних зависимостях, которые Dockerfile тянет из интернета в момент сборки, а не фиксирует раз и навсегда.
FROM node:20-bookworm-slim
RUN apt-get update && apt-get install -y curl git
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
Даже если package-lock.json честно зафиксирован, а базовый образ указан с версией (а не latest — этот отдельный антипаттерн разобран в статье про тег latest вместо версии образа), строка apt-get install -y curl git подтягивает текущие версии пакетов из репозитория Debian на момент сборки. Сегодня это одна минорная версия curl, через два месяца — другая, с другим набором зависимых библиотек. Если в проекте есть шаги вроде pip install -r requirements.txt без строгой фиксации версий (==, а не >=) или npm install вместо npm ci, разброс усиливается ещё сильнее — резолвер пакетов сам выбирает подходящую версию из диапазона, и эта версия зависит от того, что доступно в реестре именно сейчас.
Практическое следствие: два docker build из одного и того же коммита, выполненные в разное время на одном и том же сервере, могут дать физически разные образы — с разными версиями транзитивных зависимостей. Хуже того, сборка может упасть там, где вчера проходила успешно, просто потому что в апстрим-репозитории вышла новая версия пакета с изменённым поведением или удалённой зависимостью. На боевом сервере это означает, что деплой может провалиться в произвольный момент по причине, не имеющей отношения к вашему коду — и разбираться с этим приходится в реальном времени, под давлением того, что прод уже наполовину обновился.
Отдельно стоит кеш слоёв Docker: на сервере, где docker build гоняли месяцами, слой с apt-get install может быть закеширован и не пересобираться вообще — сборка тихо использует пакеты полугодовой давности, пока кто-то не почистит кеш (docker builder prune). После такой чистки следующая сборка внезапно подтягивает свежие версии, и образ начинает вести себя иначе, чем все предыдущие. Это тот же класс проблем, что и с тегом latest, только на уровне содержимого образа, а не самого тега.
Проблема 3: нет единого источника истины, что именно запущено
Когда образ собирается на самом проде, единственное место, где хранится информация «что сейчас реально работает» — это сам этот сервер, в единственном экземпляре. Если у вас несколько окружений (staging и prod) или несколько серверов за балансировщиком, каждый из них при сборке на месте может получить чуть-чуть разный образ по причинам из предыдущего раздела — и никто не сможет сказать точно, идентичны ли они на самом деле.
Проверить, что реально запущено, приходится археологическими методами вроде docker inspect --format='{{.Created}}' myapp или docker history myapp:latest — но и это не даёт полной картины: команды Dockerfile видны, а версии пакетов, которые эти команды подтянули в момент сборки, нет.
Это особенно больно проявляется при инцидентах. На вопрос «какая версия сейчас в проде?» реальный ответ — не «версия из git-тега», а «то, что получилось собрать в последний раз, чем бы это ни оказалось». Если на второй ноде за балансировщиком образ пересобирался в другой момент времени, две ноды могут отвечать на одинаковые запросы чуть по-разному — и такой баг крайне тяжело локализовать, потому что причина не в коде, а в дрейфе окружения сборки.
Проблема 4: откат превращается в отдельную задачу
Если что-то пошло не так после деплоя, естественная реакция — откатиться на предыдущую версию. При сборке на проде «предыдущая версия» — это не артефакт, который можно просто снова запустить, а состояние кода в git на какой-то более ранний коммит, которое нужно заново собрать. А значит:
- Откат занимает столько же времени, сколько сама сборка — а не секунды на переключение тега.
- Пересборка старого коммита сегодня использует сегодняшние внешние зависимости, а не те, что были доступны в момент исходного деплоя — то есть вы не гарантированно получаете тот же образ, что работал раньше (см. проблему 2). Есть реальный шанс, что откат «на старую версию» неожиданно тоже сломается, потому что мир вокруг успел измениться.
- Пока идёт пересборка, сервер продолжает терять ресурсы на билд — в момент инцидента, когда они особенно нужны для стабилизации сервиса.
Подробный разбор действий при откате и того, почему скорость отката определяется архитектурой деплоя, а не героизмом инженера, — в статье что делать, если обновление положило прод. Ключевой вывод оттуда прямо применим и здесь: если откат — это не одна команда, а расследование и повторная сборка, у вас нет отката, у вас есть надежда.
Правильный подход: сборка в CI, публикация в registry, деплой неизменяемого образа
Решение — разорвать связь между «где собирается образ» и «где он выполняется». Это и есть принцип immutable infrastructure применительно к контейнерам: образ собирается один раз, публикуется как versioned-артефакт, и дальше только копируется (пуллится) на нужные серверы — без единой пересборки после этого момента.
Схема из трёх шагов:
- Сборка в CI. Пуш в ветку (или тег, или merge request) запускает пайплайн на отдельном CI-раннере — не на проде. Там же гоняются тесты и линтеры, и если что-то падает, до registry дело просто не доходит.
- Публикация в registry с версионным тегом. Собранный образ пушится в Docker registry — свой приватный (можно поднять по инструкции как установить и настроить приватный Docker Registry на VPS) или облачный — с тегом, привязанным к конкретному коммиту или релизу, а не к
latest. - Деплой готового образа. На проде выполняется не
docker build, аdocker pull <тег> && docker-compose up -d— сервер только скачивает и запускает то, что уже прошло сборку и тесты в CI, битово идентичное тому, что тестировалось.
Пример .gitlab-ci.yml с таким разделением (аналогично настраивается и в GitHub Actions):
stages:
- build
- deploy
variables:
IMAGE: registry.example.com/myapp
build:
stage: build
script:
- docker build -t $IMAGE:$CI_COMMIT_SHORT_SHA -t $IMAGE:latest .
- docker push $IMAGE:$CI_COMMIT_SHORT_SHA
- docker push $IMAGE:latest
only:
- main
deploy:
stage: deploy
script:
- ssh deploy@prod "docker pull $IMAGE:$CI_COMMIT_SHORT_SHA && \
IMAGE_TAG=$CI_COMMIT_SHORT_SHA docker-compose up -d"
only:
- main
needs: ["build"]
Тег latest здесь опционально пушится для удобства ручных проверок, но на деплой в prod идёт именно $CI_COMMIT_SHORT_SHA — конкретный, воспроизводимый идентификатор. Compose-файл на сервере ссылается на переменную:
services:
app:
image: registry.example.com/myapp:${IMAGE_TAG}
restart: unless-stopped
Настройка CI-раннера для такого пайплайна на отдельной машине (или на том же VPS, но не совмещённого с продовыми контейнерами) разобрана в статье GitLab CI/CD на VPS: настройка — там же показано, как поднять раннер с executor'ом docker, изолированным от прода по ресурсам.
Что это даёт на практике:
| Сборка на проде | Сборка в CI + registry | |
|---|---|---|
| Ресурсы прод-сервера | Делит CPU/RAM/диск со сборкой | Только запуск контейнера |
| Согласованность образа | Может отличаться от сборки к сборке | Один и тот же артефакт везде |
| Источник истины | Нет — образ существует только локально | Registry с тегом = точка истины |
| Откат | Пересборка старого коммита | docker pull предыдущего тега |
| Инструменты сборки на проде | Нужны (компиляторы, apt, npm) | Не нужны вообще |
| Тестирование перед деплоем | Опционально, вручную | Встроено в пайплайн, блокирует деплой |
Последний пункт таблицы часто недооценивают: раз образ на проде не собирается, там не нужны ни компиляторы, ни менеджеры пакетов, ни доступ в интернет за зависимостями — только Docker-рантайм. Это заодно сокращает поверхность атаки: меньше установленного софта на боевой машине — меньше того, что может пойти не так вне контекста самого приложения.
Как перейти от сборки на проде к CI/CD пошагово
Переход не требует одномоментной перестройки всего процесса — его можно сделать поэтапно, не останавливая текущий деплой:
- Убедитесь, что Dockerfile воспроизводим локально. Зафиксируйте версию базового образа (не
latest), используйтеnpm ciвместоnpm install, зафиксированные версии вrequirements.txtилиpoetry.lock. Это нужно независимо от того, где потом будет собираться образ. - Поднимите registry. Приватный self-hosted (см. статью выше про установку Docker Registry на VPS) либо готовый облачный — GitHub Container Registry, GitLab Container Registry или аналог. Настройте
docker loginс отдельного технического пользователя для CI и для деплоя. - Перенесите текущую команду
docker buildв CI-джобу на отдельном раннере. На этом шаге логика сборки не меняется — меняется только машина, на которой она выполняется. - Добавьте пуш в registry с версионным тегом —
$CI_COMMIT_SHORT_SHA, git-тег релиза или дата сборки, но не одинокийlatest. - Замените деплой-скрипт на pull вместо build. Старый
docker build -t myapp . && docker-compose up -d --buildменяется наdocker pull registry/myapp:$TAG && docker-compose up -d, где$TAGпередаётся из CI. - Проверьте откат до того, как он понадобится в бою. Задеплойте предыдущий тег вручную и убедитесь, что
docker pull+ перезапуск возвращает рабочее состояние за секунды, а не минуты. - Уберите инструменты сборки с прод-сервера и проверьте, что образы не пересобираются случайно — например, старым cron-скриптом, который забыли отключить.
Если в проекте пока используется ручной SSH-деплой без CI вообще, начать стоит с более базового шага — избавления от ручных операций как таковых, это разобрано в статье про антипаттерн ручного деплоя по SSH. Сборка на проде и ручной SSH-деплой обычно идут рука об руку и решаются одним и тем же переходом на пайплайн.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
А если сервер один и трафик небольшой — так ли критична сборка на проде?
Проблема ресурсов менее заметна на слабонагруженном сервере, но несогласованность образа и отсутствие единого источника истины никуда не девается вне зависимости от трафика — она проявится в момент, когда понадобится точно воспроизвести или откатить версию.
Можно ли собирать образ в отдельном контейнере с лимитами по CPU и памяти, чтобы не мешать основным сервисам?
Это снижает остроту проблемы с ресурсами, но не решает несогласованность образа между сборками и отсутствие версионного источника истины — это полумера, всё ещё зависящая от состояния конкретного сервера в конкретный момент.
Нужен ли платный облачный CI, чтобы так делать?
Нет — раннер можно поднять на собственном VPS отдельно от прода, а registry — либо тоже self-hosted, либо бесплатный тир облачного варианта для небольших проектов.
Что делать, если сборка занимает много времени и это тормозит релизы?
Ускорять стоит саму сборку — многоэтапный docker build с кешированием зависимостей помогает независимо от того, где она происходит; смысл переноса в CI не в скорости, а в изоляции от продовой нагрузки и в воспроизводимости результата.
Если сборка на проде уже работает месяцами без видимых проблем, стоит ли что-то менять?
Отсутствие видимых проблем чаще означает, что несогласованность образов и хрупкость отката ещё не проявились в явном инциденте, а не что их нет — риск накапливается тихо и материализуется обычно в самый неудобный момент, например при попытке срочно откатиться под нагрузкой.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →