Небольшому Python-приложению нужно от 100 до 300 МБ на один worker, а запускать вы будете от двух до четырёх workers. Поэтому для типичного API хватает 1 ГБ, а 2 ГБ - это размер, в который обычно комфортно помещаются Django или приложение с тяжёлыми данными. Это отправная прикидка, а не ответ. Значение имеет одно число: resident-память одного worker вашего приложения, измеренная после реального трафика, умноженная на число workers, плюс запас на пики. Эта статья показывает, как получить это число, как превратить его в количество workers для gunicorn или uvicorn и как понять, когда правильное решение - тариф побольше, а не ещё одна настройка.
Речь только о памяти. О том, как развернуть приложение, привязать порт и передать proxy-заголовки, читайте в развёртывании FastAPI и Flask и развёртывании Django в production. О фиксации зависимостей - в requirements и virtualenv в Python. Аналог этой статьи для Node - лимиты памяти Node.
Почему Python занимает больше памяти, чем вы ждёте#
Python хранит всё в виде объектов. Целое число, короткая строка, ключ словаря - у каждого есть заголовок, счётчик ссылок и указатель на тип, поэтому список из миллиона мелких значений обходится в десятки мегабайт, а не в те несколько, которые подсказывают сырые данные. Сам интерпретатор вместе с модулями стандартной библиотеки, которые импортирует ваш фреймворк, занимает примерно 10-20 МБ, пока ваш код ещё ничего не сделал.
Сверху добавляются три вещи, никак не связанные с вашей логикой:
- Импортируемые библиотеки. Голый Flask-приложение мало. Django с ORM, админкой и несколькими приложениями заметно больше. Всё, что импортирует NumPy, pandas, SQLAlchemy с множеством моделей, Pydantic с большим набором схем или библиотеку машинного обучения, добавляет 100 МБ и более уже при импорте.
- Аллокатор. CPython выдаёт мелкие объекты из собственных arena и редко возвращает их операционной системе. После запроса, который ненадолго построил большую структуру, процесс сохраняет память и использует её повторно. График поднимается и остаётся наверху - это нормально и само по себе не утечка.
- Рабочая память запроса. Разбор JSON-тела на 5 МБ, рендер шаблона, сборка ответа из запроса к базе на 20 000 строк - каждое из этих действий даёт кратковременный пик над базовым уровнем, и именно пик решает, помещаетесь ли вы.
Практический вывод: память Python-процесса растёт лесенкой. Она поднимается при прогреве, поднимается ещё раз, когда впервые вызван тяжёлый endpoint, и затем выходит на плато. Показание, снятое сразу после старта сервера, слишком оптимистично.
Примерные диапазоны, чтобы было с чем сверяться. Это типичные наблюдения, а не гарантии; ваши зависимости сильно их меняют.
| Тип приложения | RSS на worker, прогретый | Примечание |
|---|---|---|
| Flask, несколько route | 40-80 MB | В основном интерпретатор и фреймворк |
| FastAPI с моделями Pydantic | 60-120 MB | Растёт с числом моделей |
| Django с ORM и админкой | 90-180 MB | Больше при многих установленных приложениях |
| Любое из них плюс pandas | 150-350 MB | Доминируют NumPy и pandas |
| Всё, что загружает ML-модель | Размер модели плюс 150 MB | Часто сама занимает весь тариф |
RSS, VSZ и что именно измерять#
Linux показывает для процесса несколько значений памяти, и для подбора полезно только одно.
- VSZ (virtual size) считает все адреса, которые процесс отобразил, включая зарезервированные, но ни разу не тронутые. Для крошечного Python-процесса это может быть несколько гигабайт, и здесь это ничего не значит.
- RSS (resident set size) - физическая память, которую процесс сейчас держит в RAM. Читать нужно её, с одной оговоркой ниже.
- PSS (proportional set size) делит общие страницы между процессами, которые ими пользуются. Это честное число, когда несколько workers порождены fork от одного родителя.
Оговорка: RSS учитывает общие страницы полностью для каждого процесса. Четыре worker gunicorn, порождённых от одного master, показывают, скажем, по 120 МБ RSS, но значительная часть этого - один и тот же код и данные только для чтения, хранящиеся в физической памяти один раз. Сумма RSS по workers завышает итог. Сумма PSS даёт верный результат.
Начните с простого. В любой оболочке Linux, в том числе в SSH-сессии на VDS:
$ ps -eo pid,ppid,rss,vsz,cmd --sort=-rss | head -8rss указан в килобайтах. Вы увидите master gunicorn и каждый worker отдельной строкой. Для одного процесса собственный отчёт ядра выглядит так:
$ grep -E 'VmRSS|VmHWM' /proc/$(pgrep -o -f gunicorn)/statusVmRSS - текущий resident-размер, а VmHWM - самый высокий за всё время, то есть high-water mark; именно это число нужно, чтобы понять, во сколько обходится худший случай. Чтобы получить PSS по всей группе workers, арифметику делает smem:
$ smem -P gunicorn -k -c "pid pss rss command"Если его нет, установите из вашего дистрибутива. Если запустить это нельзя - на управляемом контейнере у вас только консоль, - воспользуйтесь самим приложением. Это маленький endpoint или строка в логе, которая сообщает собственные цифры:
import osimport resourcedef memory_report() -> dict: # ru_maxrss is kilobytes on Linux (bytes on macOS) peak_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 with open("/proc/self/status") as f: rss_kb = next(int(l.split()[1]) for l in f if l.startswith("VmRSS")) return {"pid": os.getpid(), "rss_mb": rss_kb // 1024, "peak_mb": round(peak_mb)}Откройте его на внутреннем route или пишите в лог раз в несколько минут. Поскольку каждый worker - отдельный процесс, pid в ответе показывает, какой worker ответил; обратитесь несколько раз, и вы увидите разные pid с разными цифрами, что само по себе показательно.
Что на самом деле ограничивает тариф#
Память тарифа - это лимит на весь контейнер, а не на один процесс. Всё внутри считается вместе: master gunicorn, каждый worker, любой фоновый поток или дочерний процесс и page cache файлов, которые читает ваше приложение. В контейнере с лимитами cgroup лимит и текущее потребление можно прочитать напрямую:
$ cat /sys/fs/cgroup/memory.max$ cat /sys/fs/cgroup/memory.current$ cat /sys/fs/cgroup/memory.peakЭто cgroup v2, сегодня самая распространённая схема; на старых хостах с cgroup v1 файлы называются /sys/fs/cgroup/memory/memory.limit_in_bytes и memory.usage_in_bytes. memory.max - потолок в байтах, memory.current - то, что группа использует сейчас, а memory.peak (на свежих ядрах) - самое высокое значение за всё время. memory.current включает файловый кеш, который ядро сбросит под давлением, поэтому на вполне здоровом сервере значение может выглядеть тревожно. Остановку по нехватке памяти предсказывает сумма анонимной памяти ваших процессов, близкая к сумме PSS.
От того, что происходит на лимите, зависит, сколько запаса оставлять. На RE:NODE, когда сервер доходит до лимита памяти, ядро останавливает контейнер, и он чисто перезапускается, а не уходит в swap; это задокументированное поведение платформы. Медленной деградации, за которой можно следить, нет - процесс просто исчезает, запросы в полёте теряются, и приложение стартует заново. Upstream Pterodactyl ведёт себя наоборот и позволяет контейнеру уходить в swap, что сохраняет жизнь и делает всё очень медленным. В любом случае вывод один: не планируйте работу на пределе. Механизм подробно объясняет swap в Linux и OOM killer.
Консоль панели показывает графики памяти, CPU и диска относительно лимитов тарифа. Этот график - самая быстрая проверка реальности: держите его открытым во время нагрузочного теста и смотрите, где линия находится относительно потолка. Что ещё есть на этом экране, рассказано в чтении консоли.
Workers, threads и формула#
«Worker» gunicorn или uvicorn - это процесс операционной системы с собственной копией памяти вашего приложения. Workers существуют по двум причинам: чтобы использовать больше одного ядра CPU (global interpreter lock в Python позволяет одному процессу исполнять байткод Python на одном ядре) и чтобы продолжать обслуживание, пока другой worker заблокирован или перезапускается. Один запрос они не ускоряют.
Отсюда формула подбора, и у неё два предела, а не один:
workers_by_memory = floor((plan_RAM - baseline - headroom) / RSS_per_worker)workers_by_cpu = vCPU, rounded up, times 1 to 2 for I/O-bound appsworkers = min(workers_by_memory, workers_by_cpu)baseline - то, что контейнер тратит не на workers: master gunicorn (примерно 30-60 МБ), любой планировщик и запас на оболочку и логирование. headroom - запас прочности; берите 20-25 процентов тарифа. RSS_per_worker - ваш измеренный high-water mark на worker, а не значение при старте.
Распространённый совет (2 x cores) + 1 workers - это правило для CPU из эпохи синхронных приложений с упором в I/O на больших машинах. На маленьком тарифе, где память - более жёсткий предел, оно регулярно даёт больше workers, чем помещается. Число vCPU тарифа к тому же - жёсткое ограничение до купленной доли: тарифу с 0.5 или 1 vCPU пять workers, упирающихся в CPU, ничего не дают, кроме пятикратного счёта за память.
Разберём пример. Сервис на FastAPI, измеренный в 110 МБ на worker на high-water mark, на тарифе 1 ГБ:
plan RAM 1024 MBbaseline 60 MB (master, logging)headroom 250 MB (about 25 percent)left for workers 714 MBworkers by memory = floor(714 / 110) = 6workers by CPU = 1 vCPU, async I/O-bound -> 2 to 3workers = 2 or 3Память позволила бы шесть; CPU больше трёх не нужно. Запустите три, и вы займёте около 390 МБ тарифа, оставив место для пиков. Это обычный итог на маленьких тарифах: ограничение по CPU срабатывает раньше, чем по памяти, пока приложение лёгкое.
Теперь та же арифметика для Django с pandas, по 280 МБ на worker, на тарифе 2 ГБ: (2048 - 60 - 500) / 280 даёт 5.3, то есть пять по памяти, а при 1 vCPU вы запустили бы два-три. Если затем добавить worker Celery или второй процесс, сначала вычтите его память.
Threads - более дешёвый рычаг. Worker gthread в gunicorn с несколькими потоками делит одну копию памяти приложения между всеми потоками, поэтому потоки умножают параллелизм, не умножая RSS. Для синхронного приложения Flask или Django, которое ждёт базу, --workers 2 --threads 4 обслуживает восемь запросов одновременно примерно за память двух процессов. Цена - GIL: потоки помогают при ожидании I/O, но не при вычислениях, а ваш код и библиотеки должны быть потокобезопасными.
Настройка gunicorn#
Держите параметры в файле, а не в длинной командной строке, чтобы их можно было ревьюить. gunicorn.conf.py читается автоматически из рабочего каталога:
import osbind = f"0.0.0.0:{os.environ.get('PORT', '8000')}"workers = int(os.environ.get("WEB_CONCURRENCY", "2"))threads = int(os.environ.get("GUNICORN_THREADS", "1"))worker_class = "gthread" if threads > 1 else "sync"timeout = 30graceful_timeout = 30keepalive = 5# Recycle workers to cap slow growth. Jitter staggers the restarts.max_requests = 1000max_requests_jitter = 100# Put the heartbeat file in RAM, not on disk.worker_tmp_dir = "/dev/shm"Параметры, важные для памяти:
| Параметр | По умолчанию | Влияние на память |
|---|---|---|
workers | 1 | Каждый - полная копия приложения |
threads | 1 | Параллелизм без лишних копий |
max_requests | 0 (выключено) | Перезапускает worker после N запросов |
max_requests_jitter | 0 | Случайно сдвигает это N, чтобы workers не перезапускались разом |
preload_app | False | Загружает приложение один раз в master, затем делает fork |
timeout | 30 | Убивает worker, молчащий столько секунд |
max_requests - это ремень безопасности, а не исправление. Если worker растёт на 1 МБ каждые сто запросов из-за кеша или утечки, перезапуск каждые тысячу запросов ограничивает ущерб, пока вы ищете причину. Но он же скрывает настоящую утечку с графика, так что включайте его осознанно и продолжайте поиск. Jitter добавляйте всегда; без него все workers достигают одного счётчика в один момент, и приложение ненадолго замирает, пока все они перезапускаются разом.
preload_app = True (или --preload) импортирует приложение в master до fork. Порождённые workers затем делят уже загруженные страницы, что снижает общую память и ускоряет запуск. Эффект реален, но есть два ограничения. Подсчёт ссылок в Python пишет в объекты по мере использования и по одной «пачкает» общие страницы, поэтому выигрыш со временем тает; ждите меньшей экономии, чем рисуют схемы. И всё, что создаётся при импорте, - соединения с базой, пулы потоков, открытые сокеты - теперь общее для всех порождённых workers, что всё ломает. Открывайте соединения в hook post_fork или лениво при первом использовании. Пулы соединений и лимиты объясняют, почему пул на каждый worker ещё и умножает число соединений с базой.
Строка worker_tmp_dir мала, но стоит того: gunicorn пишет heartbeat-файл для каждого worker, а на некоторых файловых системах контейнеров он попадает на медленный диск и вызывает ложные timeout workers. /dev/shm лежит в памяти, поэтому стоит несколько килобайт RAM и снимает проблему.
Настройка uvicorn#
Uvicorn запускает ASGI-приложения (FastAPI, Starlette, Django Channels). Сам по себе он умеет стартовать несколько процессов:
$ uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2 --limit-max-requests 5000--workers N порождает N процессов. --limit-max-requests заставляет процесс завершиться после стольких запросов, чтобы супервизор заменил его; при простом uvicorn --workers родитель перезапускает умерший worker, так что это тоже работает как перезапуск, хотя и менее аккуратно, чем у gunicorn. Если worker один, --workers вообще не указывайте - одиночный запуск проще и виден в ps одной строкой.
Для production распространена схема, где gunicorn выступает менеджером процессов, а работу делает uvicorn:
$ gunicorn main:app -k uvicorn.workers.UvicornWorker -w 2 -b 0.0.0.0:8000Вы получаете перезапуски gunicorn (--max-requests), его плавные перезапуски и timeout, а ASGI-сервер работает внутри. Одно проверьте на своих версиях: в свежих релизах uvicorn.workers объявлен устаревшим в пользу отдельного пакета uvicorn-worker, и тогда класс называется uvicorn_worker.UvicornWorker. Читайте заметки к той версии, которую фиксируете, а не копируйте команду из старого руководства.
Async меняет арифметику. Один async worker может держать тысячи простаивающих соединений и много запросов в полёте, потому что ожидание почти ничего не стоит. Поэтому async API обычно подбирают по CPU, а не по памяти: на маленьком тарифе часто верны один-два worker, а добавление новых даёт только параллелизм для работы, упирающейся в CPU. Исключение - когда блокируется event loop: вызов синхронной библиотеки или тяжёлое вычисление внутри async def останавливает все запросы этого worker, и ответ здесь - больше workers или вынос работы, а не тариф побольше.
Измерение под нагрузкой#
Число, снятое с простаивающего сервера, бесполезно; измеряйте, пока приложение работает. Повторяемая процедура:
- Запустите приложение с намеченным числом workers и запишите
psRSS каждого. Это нижняя граница. - Воспроизведите реалистичный трафик. Достаточно инструмента нагрузки вроде
heyилиwrkна самых медленных и тяжёлых endpoint:
``bash $ hey -z 120s -c 20 https://your-app.example/api/heavy-endpoint ``
- Пока он работает, каждые несколько секунд записывайте память в файл:
``bash $ while true; do date +%T; ps -C gunicorn -o rss= | awk '{s+=$1} END {print s/1024 " MB"}'; sleep 5; done ``
- Остановите нагрузку и продолжайте записывать ещё минуту. Память, которая не падает обратно, - это удержанный пул, что до известного предела ожидаемо.
- Запишите самый высокий
VmHWMлюбого worker и самую высокую сумму. Это и естьRSS_per_workerдля формулы выше.
Повторяйте после deploy, меняющего зависимости, и после всего, что затрагивает большой путь кода. Новая библиотека может добавить 80 МБ на worker, а в вашем коде не изменится ни строки.
Когда один worker намного больше остальных или график растёт без плато, у вас утечка или неограниченный кеш. Стандартный tracemalloc из Python показывает, откуда берутся аллокации:
import tracemalloctracemalloc.start(10)def top_allocations(limit: int = 10): snapshot = tracemalloc.take_snapshot() for stat in snapshot.statistics("lineno")[:limit]: print(stat)Сделайте два snapshot с интервалом в несколько минут и сравните их через snapshot2.compare_to(snapshot1, "lineno"), чтобы увидеть, что выросло. Сам tracemalloc тратит память и скорость, поэтому используйте его на staging-копии или недолго. Для аллокаций нативных расширений, которых tracemalloc не видит, есть более тяжёлый инструмент memray. Обычные виновники - кеш внутри процесса без ограничения размера (используйте functools.lru_cache(maxsize=...) или внешний кеш), глобальный список, в который что-то добавляется при каждом запросе, тела ответов, хранящиеся в структуре «последних запросов», и результаты ORM, которые удерживает ссылка. Общий метод отличать утечку от плато описан в утечках памяти игровых серверов.
Пики, загрузки и работа, которая не помещается в запрос#
Убивает не среднее значение на графике памяти, а пик. Большинство пиков в Python-приложениях вызывают три шаблона:
- Чтение файлов целиком в память.
await request.body()илиrequest.files["upload"].read()на загрузке в 200 МБ держит все 200 МБ в этом worker, а две одновременные загрузки - 400. Пишите на диск порциями и ставьте лимит размера тела на прокси или во фреймворке. - Неограниченные запросы.
Model.objects.all()илиSELECT *в список загружает каждую строку как объекты Python, что стоит во много раз больше размера данных в базе. Используйте постраничность, серверные курсоры или итераторы (.iterator()в Django) и выбирайте только нужные столбцы. - Endpoint отчётов и экспорта. Сборка CSV или PDF в веб-запросе - классический способ, которым один пользователь роняет приложение. Генерируйте их в фоновой задаче и отдавайте ссылку.
Последнее - структурное решение для тяжёлой работы: вынесите её из веб-workers. Отдельный процесс для задач держит веб-слой маленьким и предсказуемым и делает память задачи отдельным решением. Варианты описаны в фоновых задачах на небольшом сервере и очередях задач на Valkey. Помните, что исполнитель задач в том же контейнере добавляется к тому же лимиту памяти, так что учтите его в baseline.
Признаки, что вы переросли тариф#
Не всякая проблема с памятью решается деньгами, и не всякая - настройкой. Вот признаки, указывающие на каждый путь.
Сначала настройте (тариф побольше не поможет):
- Память растёт и никогда не выравнивается, сколько ни ждите. Это утечка; больше RAM лишь откладывает перезапуск.
- Каждый пик вызывает один endpoint. Чините endpoint.
- Число workers больше vCPU, умноженного на два, при малом трафике. Вы платите за простаивающие копии.
- Один запрос загружает неограниченный набор результатов.
Переходите на тариф выше:
- Даже при разумном числе workers и без утечек прогретая сумма на high-water держится выше примерно 75 процентов тарифа. Запаса на обычный пик нет.
- Контейнер останавливается и перезапускается на лимите памяти, а причина - законная работа: более крупная зависимость, модель, больший рабочий набор, - а не дефект.
- Вы опустились до одного worker, потому что больше не помещается, и запросы стоят в очереди. Задержка растёт с трафиком, а CPU не насыщен.
- График CPU упирается в лимит тарифа, а workers уже подобраны под него. CPU - жёсткое ограничение до купленной доли, поэтому упёршийся CPU означает медленные ответы, а не ошибки, и лекарство - больше vCPU. На линии Python тарифы идут так: 1 ГБ с 0.5 vCPU, 2 ГБ с 1 vCPU, 4 ГБ с 1.5 vCPU, 6 ГБ с 2 vCPU и 8 ГБ с 3 vCPU, так что тариф выше добавляет и то и другое.
На RE:NODE переход на тариф выше меняет лимиты на уже существующем сервере; сервер не пересоздаётся, поэтому файлы и окружение остаются на месте. Лестница из тарифов Python: 1 ГБ от $4 в месяц, 2 ГБ от $7, 4 ГБ от $12, 6 ГБ от $17, 8 ГБ от $23. Перед решением проверьте актуальные цены на странице, поскольку они могут меняться.
Вот как предыдущие примеры ложатся на тарифы:
| Нагрузка | Измерено | Тариф |
|---|---|---|
| Flask или FastAPI, 2-3 worker | 250-400 MB суммарно | 1 GB |
| Django с админкой, 3 worker | 500-700 MB суммарно | 2 GB |
| Django plus pandas, 3 worker | 1.2 GB суммарно | 2 GB впритык, 4 GB с запасом |
| Приложение с ML-моделью | размер модели + 400 MB | Считайте от размера модели |
База и кеш - отдельно. Python-приложение, которое в том же тарифе запускает ещё и PostgreSQL с Valkey, борется с ними за память, так что так не делайте; запускайте их отдельными серверами. Страница hosting bundles описывает покупку приложения, базы и кеша вместе, а bundle из Python, PostgreSQL и Valkey существует именно для такого стека.
Что идёт не так#
Workers умирают с `[CRITICAL] WORKER TIMEOUT`, затем в логе виден перезапуск. Либо запрос действительно длился дольше timeout, либо worker остановили извне. Если время совпадает с пиком памяти, контейнер, скорее всего, остановили на лимите, а сообщение о timeout - симптом. Прежде чем увеличивать timeout, проверьте memory.peak или график в консоли.
Приложение перезапускается раз в несколько часов без ошибки в логе. Остановка по нехватке памяти не оставляет приложению шанса что-либо записать. Ищите форму на графике: подъём, затем падение до дна. Исправьте рост или увеличьте лимит; больший timeout ничего не меняет.
Общая память намного больше `workers x RSS`. В контейнере есть что-то ещё: дочерний процесс, который запускает ваш код, пул потоков с буферами или импортированная библиотека, стартующая фоновый процесс. Полный список покажет ps -eo pid,ppid,rss,cmd --sort=-rss.
Локально память в порядке, а на сервере огромная. Другие версии зависимостей, другая сборка Python или оставленная отладочная настройка. Django с DEBUG = True хранит каждый SQL-запрос в памяти всё время жизни процесса, что для долгоживущего сервера является утечкой по замыслу. Поставьте False.
Preload всё ухудшил. Соединения, созданные при импорте и разделённые между порождёнными workers, под нагрузкой дают странные ошибки. Создавайте их после fork.
Всё хорошо до первого запроса за день. Ленивые импорты и холодный кеш делают первые запросы медленными, а их пик памяти - выше установившегося. Если это важно, после старта обратитесь к прогревающему route из health check.
FAQ#
Сколько workers gunicorn запускать?
Берите меньшее из двух чисел: сколько workers позволяет память (RAM тарифа минус baseline и 20-25 процентов запаса, делённое на измеренный RSS на worker) и примерно один-два на vCPU для приложения с упором в I/O. На тарифе 1 ГБ с 1 vCPU это обычно два или три. Если приложение больше ждёт базу, чем считает, используйте threads раньше, чем добавлять процессы.
Возвращается ли память процесса Python системе?
Отчасти. CPython возвращает операционной системе крупные аллокации, но arena мелких объектов часто сохраняет, а фрагментация может удерживать страницы. Процесс, обработавший тяжёлый запрос, обычно остаётся больше, чем был до него. Планируйте по high-water mark, а не по среднему, и используйте max_requests, если нужны периодические сбросы.
Нужен ли async, чтобы сэкономить память?
Async экономит память, когда у вас много одновременных соединений, которые в основном ждут, потому что один worker может держать их все вместо потока или процесса на каждое соединение. Базовый уровень самого приложения он не уменьшает и работе, упирающейся в CPU, не помогает. Блокирующий вызов внутри async-функции сводит выигрыш на нет.
Стоит ли включать --preload?
Иногда. Он делит между workers код и данные, загруженные при импорте, и сокращает запуск, но экономия тает по мере того, как подсчёт ссылок трогает общие страницы, а любое соединение, открытое при импорте, ошибочно становится общим. Прежде чем верить в выигрыш, измерьте PSS с ним и без него.
Сколько памяти использует master gunicorn?
Обычно 30-60 МБ сам по себе, и запросов он не обслуживает. С preload_app он ещё и держит загруженное приложение. Включите его в baseline, когда считаете.
Когда тариф 1 ГБ мал для Python?
Когда прогретая сумма нужных вам workers превышает примерно три четверти тарифа - например, три worker Django по 200 МБ уже занимают 600 МБ ещё до master и исполнителя задач. Тогда переходите на 2 ГБ, а не опускайтесь до одного worker, из-за чего запросы встают в очередь.


