MAATRIX / Блог / Антипаттерн: состояние приложения внутри контейнера

Антипаттерн: состояние приложения внутри контейнера

MAATRIX

Приложение работает, пользователи логинятся, файлы загружаются — всё хорошо, пока вы не обновите образ или не добавите вторую реплику. После docker compose up -d все разлогинены, а загруженные вчера файлы пропали. Причина почти всегда одна: состояние хранилось внутри контейнера, а не снаружи. Разберём, почему это происходит и как построить архитектуру, где контейнер можно убить в любой момент без последствий.

Что не так с состоянием внутри контейнера

Контейнер — это процесс с изолированным окружением, а не виртуальная машина. Его файловая система — это read-only слои образа плюс тонкий writable-слой поверх (overlay2 в Docker). Всё, что процесс пишет во writable-слой без явного volume, живёт ровно до тех пор, пока жив конкретный контейнер. Это не баг, а прямое следствие модели: образ должен быть воспроизводим и одинаков на любом хосте, а контейнер — расходный материал, который можно пересоздать за секунду.

Антипаттерн — когда в это писательское поведение записывают то, что должно пережить сам контейнер:

  • сессии пользователей — в памяти процесса или в файле на локальном диске контейнера;
  • загруженные файлы — в /app/uploads без монтирования наружу;
  • кэш и временные артефакты сборки — туда же, где их удобно было положить разработчику.

Работает это ровно до первого события жизненного цикла контейнера — а таких событий много больше, чем кажется на старте проекта.

Как это выглядит на практике

Типичный код, который проходит код-ревью, потому что "работает на деле":

# Flask с сессиями в памяти процесса (или файловый SessionInterface по умолчанию)
app.config['SESSION_TYPE'] = 'filesystem'
app.config['SESSION_FILE_DIR'] = '/app/flask_session'
# docker-compose.yml — типичная ошибка: bind не наружу, а просто путь внутри
services:
  app:
    image: myapp:latest
    volumes:
      - ./uploads:/app/uploads   # локальная папка ХОСТА, но всё ещё привязана к ЭТОМУ хосту

Второй пример уже лучше первого — данные хотя бы переживают пересоздание контейнера, потому что реально лежат на хосте. Но это полумера: она решает проблему пересоздания контейнера и не решает проблему масштабирования и миграции — файлы всё ещё привязаны к конкретной машине.

Ещё чаще встречается совсем незаметный вариант — кэш прогретых данных или сгенерированных превью, который держат в /tmp контейнера, потому что "это же просто кэш, не страшно потерять". Страшно не то, что он пропадёт, а то, что при нескольких репликах каждая реплика будет прогревать свой кэш заново, и он никогда не станет общим.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Что теряется при пересоздании контейнера

Пересоздание — это не редкое аварийное событие, а рутинная операция, которая происходит почаще, чем думают:

  • docker compose up -d после изменения образа или конфигурации — Compose пересоздаёт контейнер, а не обновляет его "на месте";
  • docker service update в Swarm или rolling update в любом оркестраторе — старые контейнеры убиваются, поднимаются новые;
  • OOM-killer прибил процесс из-за лимита памяти — Docker поднимает контейнер заново (если задан restart: unless-stopped или always);
  • откат на предыдущую версию образа после неудачного деплоя;
  • банальный docker compose down && docker compose up -d при отладке.

Во всех этих случаях writable-слой контейнера уничтожается вместе с самим контейнером. Если сессии лежали в памяти процесса — все пользователи разлогинены разом, причём без предупреждения: с их стороны это выглядит как случайный сбой авторизации. Если файлы лежали внутри без volume — они удалены безвозвратно, и это не восстановить бэкапом контейнера, потому что бэкапить writable-слой контейнера в принципе не принято и не предусмотрено.

Отдельно упомянем ловушку с анонимными volume — она формально "как будто volume", но по факту не спасает: Docker создаёт анонимный volume с случайным именем при каждом создании нового контейнера, если в Dockerfile объявлен VOLUME без явного пути монтирования снаружи. При docker compose down без флага -v эти volume формально остаются на диске, но перестают быть связаны с новым контейнером — и выглядит это как "данные пропали", хотя технически они просто осиротели. Подробнее про этот конкретный случай — в разборе почему данные исчезают после compose down при анонимном volume.

Почему это ломает горизонтальное масштабирование

Если приложение хранит состояние локально, у вас физически не может быть двух одинаковых реплик — они будут расходиться в данных с первой же секунды совместной работы.

Представьте: балансировщик направляет запрос пользователя на реплику А, там создаётся сессия. Следующий запрос того же пользователя балансировщик отправляет на реплику Б — а там этой сессии не существует, пользователь видит "разлогинен". Частичное решение — sticky sessions на балансировщике (маршрутизация по cookie на одну и ту же реплику), но это костыль: он не решает проблему при падении конкретной реплики (сессия теряется вместе с ней), плохо распределяет нагрузку и полностью бесполезен при масштабировании через несколько дата-центров.

С загруженными файлами то же самое, только хуже: пользователь загружает аватар через реплику А, файл лежит на диске контейнера А. Позже он открывает профиль, запрос попадает на реплику Б — файла там нет, отдаётся 404. Ни sticky sessions, ни повторные попытки это не лечат, потому что дело не в маршрутизации запроса, а в том, что данные физически существуют только в одном месте.

Смысл горизонтального масштабирования — в том, что любая реплика может обработать любой запрос. Как только состояние привязано к конкретному контейнеру, это свойство исчезает, и вы либо остаётесь на одной реплике навсегда, либо получаете рассинхронизацию данных, с которой пользователи столкнутся сами.

Проблемы при миграции на другой хост

Миграция — второй момент, где локальное состояние бьёт больнее всего, потому что обнаруживается это обычно уже после переключения DNS, когда откатывать поздно.

Стандартный процесс миграции контейнеризованного приложения — docker save / docker load или пересборка образа на новом хосте плюс перенос volume. Если часть данных лежит не в volume, а в writable-слое конкретного контейнера, стандартные инструменты миграции её просто не увидят — они ориентированы на образы и именованные volume, а не на содержимое конкретного экземпляра контейнера. В результате на новом хосте поднимается визуально идентичное приложение, но без файлов пользователей и без активных сессий — и это выясняется по тикетам от пользователей, а не по логам миграции.

Есть смежная и более коварная проблема: DNS переключается не мгновенно у всех клиентов, и часть трафика какое-то время продолжает идти на старый сервер, пока часть — уже на новый. Если состояние не вынесено во внешнее хранилище, доступное с обеих сторон, у вас на какое-то время появляются два расходящихся набора данных, и после полного переключения часть изменений теряется безвозвратно. Про сам процесс миграции и типичные грабли — в статье как составить план миграции на новый сервер и в разборе как перенести Docker-проект на другой сервер.

Правильный подход: stateless-приложение и 12-factor

Принцип из методологии twelve-factor app звучит просто: процессы приложения должны быть stateless и делиться между собой только через backing services — внешние по отношению к самому процессу сервисы. Любые данные, которые должны пережить рестарт процесса или быть видны другой его реплике, не должны лежать в файловой системе контейнера.

На практике это раскладывается на три конкретных переноса состояния:

Сессии — в Redis. Вместо файлового или in-memory session store подключите Redis как отдельный сервис:

services:
  app:
    image: myapp:latest
    environment:
      - SESSION_BACKEND=redis
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      - redis

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes

volumes:
  redis_data:

Теперь любая реплика приложения при запросе сессии обращается к Redis, а не к своей локальной памяти — состояние стало общим. У самого Redis, кстати, тоже есть подводные камни с потерей данных при перезапуске без правильной настройки персистентности — это отдельная тема, разобранная в статье Redis теряет данные после перезапуска. Если у вас уже есть работающий Redis, установка и базовая настройка описаны в статье как установить и настроить Redis на VPS.

Загруженные файлы — в объектное хранилище. Вместо записи на локальный диск приложение отправляет файл в S3-совместимое хранилище через SDK (boto3, aws-sdk, любой S3-клиент вашего языка). Для самостоятельного хостинга подойдёт MinIO — он поднимается тем же docker compose и говорит на S3-протоколе:

services:
  minio:
    image: minio/minio:latest
    command: server /data --console-address ":9001"
    environment:
      - MINIO_ROOT_USER=admin
      - MINIO_ROOT_PASSWORD=changeme_use_real_secret
    volumes:
      - minio_data:/data
    ports:
      - "9000:9000"
      - "9001:9001"

volumes:
  minio_data:

Приложение обращается к MinIO по S3 API вместо записи в локальную папку — и не важно, на какой реплике обрабатывается запрос, файл физически лежит в одном месте, доступном всем. Готовый compose-файл и разбор нюансов — в статье MinIO в Docker Compose.

База данных — отдельным сервисом с собственным volume, а лучше вообще отдельно от stateless-реплик приложения. Это правило и так соблюдается в большинстве проектов интуитивно — БД почти никогда не держат в writable-слое контейнера приложения. Но важно перенести ту же логику на сессии и файлы: если для БД вы не задумываясь заводите отдельный сервис с volume, то же самое нужно сделать и для остального состояния.

Важная оговорка: volume у Redis, MinIO или БД в примерах выше — это не нарушение принципа, а его правильное применение. Stateless должно быть само приложение (веб-сервер, обработчик запросов), а не инфраструктура вокруг него. Backing services по определению stateful, но у них ровно одна задача — хранить состояние, и они спроектированы это делать надёжно, с репликацией и персистентностью, в отличие от контейнера приложения, который никогда для этого не предназначался. Разница между типами volume и когда какой уместен — в статье Docker volumes: типы и когда какой использовать.

Как переехать от антипаттерна к правильной архитектуре

Если состояние уже размазано по контейнерам, миграцию удобно делать поэтапно, не останавливая прод целиком:

  1. Инвентаризация. Пройдитесь по коду и Dockerfile — найдите все пути записи внутри контейнера: конфиги сессий, UPLOAD_DIR и подобные переменные, места вызова open()/fs.writeFile() без явного monut-пути наружу.
  2. Поднимите backing services рядом. Redis и MinIO (или готовый managed S3) можно развернуть параллельно с текущим приложением, не трогая прод-трафик — это отдельные сервисы, не требующие остановки основного.
  3. Переключите запись новых данных на новые сервисы, оставив чтение старых данных с локального диска как fallback на переходный период.
  4. Перенесите существующие данные: файлы — через mc mirror (клиент MinIO) или aws s3 cp --recursive, сессии переносить обычно не нужно — старые протухнут сами, пользователи перелогинятся один раз.
  5. Уберите fallback и volume с локальным состоянием из compose-файла — теперь контейнер приложения безопасно пересоздавать, скейлить и переносить.
  6. Проверьте это явно, а не полагайтесь на предположение: docker compose up -d --force-recreate app — приложение должно подняться без потери сессий и файлов. Если что-то пропало — значит, состояние ещё не полностью вынесено наружу.

После этого горизонтальное масштабирование становится обычной операцией: docker compose up -d --scale app=3 работает без перекосов, потому что все три реплики смотрят в один и тот же Redis и один и тот же MinIO.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

А если приложение маленькое и реплика всегда одна — обязательно ли выносить состояние?

Формально можно оставить как есть, если вы точно знаете, что горизонтального масштабирования и миграций не будет. Но потеря сессий и файлов при каждом обновлении образа — это боль независимо от количества реплик, так что для чего-то долгоживущего вынос состояния окупается быстро.

Чем плох bind-mount локальной папки хоста вместо S3?

Он решает проблему пересоздания контейнера, но не решает проблему масштабирования на несколько реплик и не решает миграцию — данные всё ещё привязаны к конкретному хосту. Это шаг в правильном направлении, но не конечная точка.

Обязательно ли использовать именно Redis для сессий?

Нет, это самый распространённый вариант благодаря скорости и простоте, но подойдёт любое внешнее хранилище ключ-значение или даже сама БД приложения — важен сам факт вынесения наружу, а не конкретный продукт.

Что делать с кэшем — его тоже обязательно выносить?

Зависит от цены промаха кэша. Если пересчитать/перезапросить данные дёшево, локальный in-memory кэш на реплике — это нормальная оптимизация, а не антипаттерн: он не хранит уникальные данные, только ускоряет то, что и так можно получить заново. Антипаттерном кэш становится, если без него приложение теряет данные, а не просто работает чуть медленнее.

Как понять, что в проекте уже есть эта проблема, не читая весь код?

Быстрая проверка — docker compose up -d --force-recreate на тестовом окружении и сравнение поведения до и после: если пользователи разлогинены или файлы пропали, состояние где-то осталось внутри контейнера.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →