2025, Nov 05 15:02

Почему FastAPI с Trino, PyArrow и Parquet в S3 «держит» память и как это исправить

Разбор роста RSS в долгоживущем FastAPI при работе с Trino, PyArrow и Parquet в S3. Показываем воспроизведение, причины и продакшн‑решение через подпроцесс.

Долгоживущий сервис FastAPI, который забирает данные из Trino, преобразует их с помощью PyArrow и Polars и пишет Parquet в S3, на первый взгляд кажется простым — пока после каждого запроса высокий уровень RSS‑памяти не остается «на месте». Удаление объектов, принудительный gc и даже попытки подтолкнуть аллокатор могут не вернуть потребление к исходному уровню. Ниже — краткий разбор характерного сценария сбоя и практичное, пригодное для продакшена решение.

Минимальное воспроизведение проблемы

Поток данных прост: пришел запрос, читаем из Trino, собираем таблицу PyArrow, отправляем в S3 через write_to_dataset, затем пытаемся освободить память. Но память процесса не возвращается к исходному уровню.

import pyarrow as pa
import pyarrow.parquet as pq
import pyarrow.fs as pafs
# Получаем и обрабатываем данные
rows_blob = grab_trino_rows()
tbl_arrow = pa.table(list(zip(*rows_blob)))
# Инициализируем файловую систему S3
s3fs = pafs.S3FileSystem()
# Загружаем в S3
pq.write_to_dataset(
    tbl_arrow,
    root_path=f"{RESULT_STORAGE_BUCKET}/{s3_storage_path}",
    partition_cols=["organisation"],
    filesystem=s3fs,
)
# Пытаемся освободить память
del rows_blob
del tbl_arrow

Эмпирические проверки показали: память достигает пика и остается рядом с ним даже после удаления объектов.

{
  "resource_stats": {
    "memory_mb": {
      "start": 140.30,
      "peak": 589.03,
      "end": 587.01
    }
  }
}

Что происходит на самом деле

В этом конвейере нагрузка на память связана не только с объектами Python. Задействуются нативные уровни — буферы Arrow, писатели Parquet, привязки к файловой системе и поведение аллокатора. Поэтому после удаления ссылок в Python процесс все еще может удерживать большие арены нативной памяти. Ручные вызовы gc не меняют исход. Даже более «глубокая» уборка — несколько проходов gc в сочетании с malloc_trim — лишь частично снижала RSS. Освобождение пула памяти PyArrow улучшало счетчики Arrow, но не возвращало процесс к базовой отметке, что соответствует наблюдению: фрагментация нативной памяти или выделения во внешних библиотеках могут держать высокий resident set.

Собранные наблюдения совпадали во всех подходах. Явные del и gc не давали заметного снижения. Продвинутая очистка с malloc_trim давала частичный эффект, но не возвращала к исходной точке. Периодическая процедура очистки уменьшала внутренние счетчики PyArrow, но RSS оставался повышенным.

Практичное решение: изолировать работу в подпроцессе

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

import pyarrow as pa
import pyarrow.parquet as pq
import pyarrow.fs as pafs
from multiprocessing import Process
import gc
import ctypes
def child_exec(payload, s3_key_prefix, bucket_uri):
    # Строим таблицу Arrow
    tbl_arrow = pa.table(list(zip(*payload)))
    # Пишем в S3 (набор Parquet)
    s3fs = pafs.S3FileSystem()
    pq.write_to_dataset(
        tbl_arrow,
        root_path=f"{bucket_uri}/{s3_key_prefix}",
        partition_cols=["organisation"],
        filesystem=s3fs,
    )
    # Необязательная очистка в процессе перед выходом
    gc.collect()
    try:
        ctypes.CDLL("libc.so.6").malloc_trim(0)
    except Exception:
        pass
# Подготавливаем входные данные
payload = grab_trino_rows()
s3_prefix = "output/"
bucket_uri = "s3://my-bucket"
# Выполняем тяжелый путь в отдельном процессе
proc = Process(target=child_exec, args=(payload, s3_prefix, bucket_uri))
proc.start()
proc.join()

Так основной процесс FastAPI остается «стройным» между запросами. Даже если нативные библиотеки или аллокатор удерживают арены во время работы подпроцесса, при его завершении все возвращается ОС — накопительного роста со временем не происходит.

Почему это важно

Сервисы, которые гоняют крупные таблицы Arrow и партиции Parquet, сталкиваются с высокими временными пиками. Если эти пики постепенно поднимают базовый уровень, в какой‑то момент вы упретесь в лимиты контейнера или получите непредсказуемые задержки из‑за нехватки памяти. Изоляция по процессам превращает «подтекующий» конвейер в ограниченный, с понятным верхним пределом на запрос.

Дополнительные заметки из практики

Вместе с изоляцией процессов полезными оказались две вещи. Во‑первых, измерять RSS сразу после del обманчиво: из‑за поведения аллокатора видимое снижение может занять немного времени, так что добавьте небольшую паузу перед замером. Во‑вторых, можно контролировать подпроцессы и забирать их результаты, при этом родительский процесс остается «плотным». В отдельных случаях разбиение выгрузки на батчи упрощало операционную часть, сохраняя границу подпроцесса.

Итог

Если маршрут FastAPI, который читает из Trino, обрабатывает с PyArrow и Polars и пишет Parquet в S3, не возвращает потребление памяти к базовой отметке, считайте, что задействованы нативные выделения. Удаление объектов Python, запуск gc, вызов malloc_trim и даже освобождение пула памяти PyArrow могут частично помочь, но не гарантируют возврата к стартовой точке. Выполнение тяжелого участка в короткоживущем подпроцессе — самый надежный способ обеспечить полное освобождение памяти между запросами. Замеряйте с небольшой задержкой, держите основной процесс «тонким» и доверьте финальную очистку ОС при завершении подпроцесса.

Статья основана на вопросе на StackOverflow от DonOfDen и ответе Aren.