В момент деплоя два экземпляра приложения не влезли в лимит контейнера
Сервис падает не как обычно — не под нагрузкой, не после обновления зависимостей, а ровно в тот момент, когда вы запускаете деплой. Причём деплой самый обычный, тот же самый скрипт, что и десятки раз до этого. Старая версия работала стабильно, новая версия, если запустить её отдельно с нуля, тоже работает стабильно — а вот сам переход с одной на другую валит сервис почти каждый раз. Это классический симптом того, что дело не в коде приложения, а в том, как устроено плавное обновление (rolling update) и как посчитан лимит ресурсов контейнера.
Содержание
Симптом: падает именно в момент деплоя, а не до и не после
Первое, что стоит зафиксировать в любом таком инциденте — это не общая фраза "деплой нестабилен", а точное наблюдение: сбой происходит в узком временном окне, привязанном к самой процедуре обновления, а не к качеству кода. Если бы проблема была в баге новой версии, она бы проявлялась и после деплоя, пока новая версия продолжает работать. Если бы проблема была в старой версии, она проявлялась бы и без всякого деплоя, просто со временем. А здесь картина другая:
- До деплоя — старая версия работает часами и днями без единого сбоя.
- Во время деплоя — сервис на несколько секунд или минут отдаёт ошибки, разрывает соединения, иногда полностью перезапускается.
- После деплоя — новая версия снова работает ровно, без нареканий, пока не начнётся следующее обновление.
Такая точная привязка к моменту обновления — это сильная подсказка. Она сразу сужает круг подозреваемых до всего, что происходит именно в процессе деплоя: механизма запуска новой версии, порядка остановки старой, проверок готовности (readiness/health checks), сетевых переключений — и ресурсов, которые в этот момент расходуются иначе, чем в стабильном режиме. Именно ресурсы, а конкретно память, и оказались в этой истории виновником.
Как устроено плавное обновление изнутри
Смысл rolling update (плавного, безостановочного обновления) в том, чтобы пользователь никогда не увидел простой сервиса из-за деплоя. Для этого оркестратор — будь то Kubernetes, Docker Swarm, systemd с шаблонными юнитами или любой самописный скрипт с blue-green-логикой — придерживается одного и того же принципа: сначала поднять новый экземпляр, дождаться, что он готов принимать трафик, и только потом остановить старый.
Это значит, что между двумя событиями — "новый экземпляр запущен" и "старый экземпляр остановлен" — существует переходный интервал, в течение которого оба экземпляра работают одновременно и оба реально потребляют ресурсы. Это не теоретическая возможность, а обязательное условие самой схемы: если остановить старый экземпляр раньше, чем новый готов принимать запросы, вы получите ровно тот простой, который rolling update и должен был исключить.
В Kubernetes это поведение управляется параметрами strategy.rollingUpdate в манифесте Deployment:
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # сколько ДОПОЛНИТЕЛЬНЫХ подов можно поднять сверх желаемого числа реплик
maxUnavailable: 0 # сколько подов может быть недоступно во время обновления
При maxSurge: 1 и одной реплике в Deployment ровно в этом и состоит суть: Kubernetes на время обновления поднимает второй под, и какое-то время работают оба — старый и новый. При maxUnavailable: 0 это условие становится обязательным: старый под не будет остановлен, пока новый не пройдёт readiness-проверку. То есть именно связка "не потерять доступность" и порождает временное удвоение работающих экземпляров.
В Docker Swarm та же логика зашита в параметр update_config сервиса:
deploy:
replicas: 1
update_config:
parallelism: 1
order: start-first # именно этот параметр критичен
failure_action: rollback
Значение order: start-first явно говорит Swarm: сначала запустить новую задачу, и только когда она поднимется — остановить старую. Это ровно то поведение, которое дало безостановочность ценой временного удвоения потребления. Значение по умолчанию для многих версий Swarm — stop-first (сначала остановить, потом запустить новую) — и оно не создаёт этой проблемы, зато допускает короткий простой.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверХронология расследования
Разбор инцидента начался с сопоставления времени сбоя с логами оркестратора деплоя — это первое, что стоит сделать, когда сбой воспроизводимо привязан к моменту обновления. В Kubernetes это kubectl rollout status deployment/имя вместе с kubectl describe deployment/имя и событиями kubectl get events --sort-by=.metadata.creationTimestamp. В Docker Swarm — docker service ps имя-сервиса --no-trunc, который показывает точные метки времени запуска нового таска и остановки старого.
Сопоставление временных меток дало точную картину: сбой не размазан по всему циклу деплоя, а сосредоточен в узком окне между событием "новый под перешёл в Ready" и событием "старый под получил SIGTERM". Это окно в разных запусках длилось от нескольких секунд до пары десятков секунд — ровно столько, сколько требовалось приложению на старте, чтобы пройти проверку готовности.
Дальше нужно было проверить не догадку, а факт: сколько памяти реально потребляют оба экземпляра одновременно именно в этом окне. Для этого использовалось прямое наблюдение за потреблением через docker stats (для контейнеров на хосте) или через метрики контроллера ресурсов в Kubernetes:
# на хосте с Docker — смотреть в реальном времени во время деплоя
docker stats --no-stream
# для группы cgroup контейнера напрямую
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.max
В момент, когда оба экземпляра были живы одновременно, суммарное потребление памяти по обоим процессам превышало лимит, выставленный на группу ресурсов деплоя — то есть теоретическая гипотеза подтвердилась прямым замером, а не осталась предположением.
Проверка гипотезы: считаем реальное потребление против лимита
Дальше важно было проверить не абстрактное "не хватает памяти", а конкретное соотношение цифр. Лимит ресурсов в docker-compose или Kubernetes обычно выставляется из расчёта на один работающий экземпляр — исходя из того, сколько приложение потребляет в стабильном режиме под обычной нагрузкой:
# docker-compose.yml — лимит рассчитан на ОДИН экземпляр
services:
app:
image: myapp:latest
deploy:
resources:
limits:
memory: 512M
Если приложение в стабильном режиме действительно укладывается, скажем, в 400–450 МБ (конкретные цифры зависят от вашего приложения и рантайма — это не универсальная константа, а то, что нужно замерить именно для своего сервиса), то лимит в 512 МБ выглядит разумным запасом для одного экземпляра. Но в момент rolling update работают не один, а два таких экземпляра одновременно, и суммарное потребление приближается к 800–900 МБ — что для группы ресурсов, ограниченной в 512 МБ (в Kubernetes — сумма resources.limits.memory по обоим подам считается независимо для каждого пода, а вот если лимит стоит на уровне namespace через ResourceQuota или на уровне ноды — суммарно), означает гарантированный выход за границу.
В Kubernetes лимит resources.limits.memory формально относится к одному поду, и превышение приводит к тому, что ядро (через OOM killer в cgroup этого пода) убивает именно контейнер, вышедший за свой лимит — обычно новый под, который только запустился и ещё не успел стабилизироваться, либо оба, если оба одновременно превысили свои индивидуальные лимиты из-за роста потребления при прогреве кэшей и пулов соединений. Но чаще узкое место — это лимит ресурсов ноды или лимит квоты namespace, рассчитанный из предположения "на этом узле одновременно работает N экземпляров", без учёта, что во время деплоя их временно N+1.
Корневая причина
Корневая причина этого инцидента — не баг в приложении и не нестабильность оркестратора, а несоответствие между тем, как рассчитывался лимит ресурсов, и тем, как в реальности работает механизм плавного обновления. Лимит был выставлен исходя из потребления одного работающего экземпляра приложения в стабильном режиме — это разумная и правильная отправная точка для расчёта ресурсов сервиса как такового. Но rolling update по своей природе на короткое время требует ресурсов на два экземпляра одновременно, и это требование никак не было заложено в конфигурацию лимитов.
Это не единичная ошибка конкретного человека, а типичная слепая зона при проектировании лимитов: инженер считает "сколько нужно приложению", а не "сколько нужно развёртыванию приложения с учётом процесса его обновления". Первое число — это установившееся потребление в работе. Второе — пиковое потребление в переходный момент, которое почти всегда выше первого, если используется стратегия обновления с параллельным запуском старой и новой версии.
Практические действия: как настроить лимиты и стратегию правильно
Первое и самое прямое решение — закладывать в лимит ресурсов запас именно на переходный период обновления, а не только на один работающий экземпляр. Если один экземпляр стабильно потребляет ~450 МБ, а группа ресурсов должна выдерживать одновременную работу старой и новой версии, лимит на уровне ноды/квоты должен допускать порядка 900 МБ–1 ГБ с запасом, а не 512 МБ, рассчитанные только на одну копию. В Kubernetes для расчёта нагрузки на ноду стоит учитывать не только requests/limits пода, но и на сколько подов одновременно рассчитан узел при maxSurge > 0:
resources:
requests:
memory: "512Mi"
limits:
memory: "640Mi" # запас над стабильным потреблением, а не впритык
и держать на ноде свободный запас памяти, равный как минимум одному дополнительному поду сверх суммы обычной нагрузки — иначе maxSurge попросту не сможет разместить новый под, и планировщик либо откажет в размещении, либо вытеснит что-то другое.
Второй вариант — не бороться с ресурсами, а сменить саму стратегию обновления на такую, которая не требует параллельной работы обеих версий. Если для конкретного сервиса допустима короткая недоступность (внутренний сервис, некритичный фоновый воркер, сервис с retry на стороне клиентов), можно явно выбрать стратегию "сначала остановить, потом запустить":
# Docker Swarm — сначала стоп, потом старт, без временного удвоения
deploy:
replicas: 1
update_config:
order: stop-first
# Kubernetes — Recreate вместо RollingUpdate: полная замена без параллельной работы версий
spec:
strategy:
type: Recreate
Это возвращает короткий простой (обычно секунды), но полностью убирает переходное удвоение потребления ресурсов — компромисс, который стоит явно проговорить с владельцем сервиса, а не выбирать по умолчанию без обсуждения.
Третий и, пожалуй, самый важный пункт — тестировать сам процесс деплоя, а не только состояние "до" и "после". Тестового стенда с одной работающей версией и продакшена с одной работающей версией недостаточно: инцидент возникает именно в переходном моменте, которого на большинстве обычных проверок просто не существует. На промежуточном окружении (staging) с теми же лимитами ресурсов, что и в продакшене, стоит явно прогонять полный цикл обновления и следить за потреблением ресурсов в реальном времени во время самого перехода:
# запустить деплой в фоне и параллельно следить за потреблением
kubectl rollout restart deployment/имя &
watch -n 1 'kubectl top pods -l app=имя'
или для Docker Swarm — docker stats в отдельном терминале во время docker service update. Если пиковое потребление во время обновления подбирается к лимиту ближе, чем на 20–30% запаса, лимит либо стратегию обновления пора пересматривать заранее, а не после первого падения в продакшене.
Отдельно стоит убедиться, что readiness-проверки (health checks) не затягивают переходное окно искусственно. Если проверка готовности слишком мягкая и считает под готовым раньше, чем приложение реально прогрелось (не заполнены кэши, не открыты пулы соединений), окно параллельной работы может быть короче номинального, но реальный пик памяти — позже, уже после того как "старый" экземпляр остановлен, а новый ещё не стабилизировался. И наоборот — слишком долгая или избыточно строгая проверка растягивает окно параллельной работы дольше необходимого, увеличивая время, в течение которого лимит рискует быть превышен.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему при первом же деплое проблема не проявилась, а стала заметна только через какое-то время?
Часто дело в том, что реальное потребление памяти приложением со временем растёт — из-за роста базы данных, кэшей, числа подключений или просто более активного трафика. Лимит, который с запасом покрывал сумму двух экземпляров при небольшой нагрузке, со временем перестаёт покрывать её при возросшей.
Можно ли просто увеличить лимит с большим запасом и не думать об этом?
Можно, и для многих сервисов это самый простой и правильный путь — но лимит нужно считать осознанно, под сценарий "два экземпляра одновременно", а не подбирать наугад методом проб и ошибок после очередного падения.
Как понять, использует ли мой оркестратор параллельный запуск при обновлении?
Проверьте параметр стратегии явно: в Kubernetes это strategy.type и maxSurge/maxUnavailable в манифесте Deployment, в Docker Swarm — update_config.order в docker-compose.yml или docker service inspect. Если maxSurge > 0 или order: start-first — да, в момент обновления обе версии работают параллельно.
Отличается ли эта проблема для CPU от проблемы для памяти?
Механизм тот же — оба экземпляра одновременно потребляют CPU, и суммарная нагрузка на переходный период выше обычной. Но последствия мягче: превышение лимита CPU в Linux cgroups обычно означает троттлинг (замедление), а не принудительное завершение процесса, как при превышении лимита памяти через OOM killer. Именно поэтому симптом чаще проявляется как падение, а не как временное замедление.
Нужно ли беспокоиться об этом, если у меня всего один инстанс приложения без оркестратора?
Да, если для обновлений вы используете любую схему с параллельным запуском новой версии до остановки старой — например systemd-юнит с шаблоном instance@.service или blue-green через смену порта в балансировщике. Принцип не привязан к конкретному инструменту, а к самой схеме безостановочного обновления.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →