Serverless ML-инференс на Python: ONNX на CPU, загрузка один раз

Обслуживайте обученную модель за HTTP-маршрутом без собственного сервера моделей. Паттерн «загрузить один раз» на уровне модуля, ONNX и scikit-learn как слои, бюджеты памяти и таймаута, тёплые пулы и честные ограничения: только CPU.

Serverless ML-инференс на Python: ONNX на CPU, загрузка один раз

Serverless ML-инференс без собственного сервера моделей

У вас есть обученная модель. Может быть, это классификатор из scikit-learn, небольшой градиентный бустинг для ранжирования или голова эмбеддингов, экспортированная в ONNX. Сам код инференса короткий: загрузить файл, вызвать session.run(), вернуть JSON-массив. Неудобно всё, что вокруг этого. Обычный способ обслуживать такую модель — держать контейнер или отдельный сервер моделей запущенным 24/7, рассчитывать его под пиковую нагрузку и платить за простой между запросами. Для инференса, управляемого запросами и работающего «всплесками», это слишком много постоянной инфраструктуры вокруг функции, которая большую часть времени просто ждёт.

Serverless ML-инференс переворачивает картину. Вы деплоите обработчик, привязываете его к аутентифицированному HTTP-маршруту, и платформа запускает его по требованию — не нужно вручную держать сервер тёплым и настраивать группу автоскейлинга. В Inquir Compute это работает на контейнерном Python 3.12, по одному контейнеру на функцию, с изоляцией зависимостей на уровне функции. Файл модели лежит внутри бандла функции, рантайм загружает его один раз, а тёплые контейнеры переиспользуют его между запросами.

Этот текст — честная версия истории: только инференс на CPU (GPU на платформе нет), паттерн «загрузить один раз на уровне модуля», который держит тёплые запросы быстрыми, ONNX и scikit-learn, подключённые как слои, а не вшитые в каждый деплой, и бюджеты памяти и таймаута, в которые нужно уложиться. Если ваша модель небольшая-средняя, а трафик неравномерный, инференс модели на serverless — действительно хороший выбор. Если же вам нужна пропускная способность GPU или хвостовые задержки в единицы миллисекунд при высоком QPS — нет, и я скажу об этом прямо в разделе про ограничения.

Почему для ONNX на serverless важен рантайм Python 3.12 на glibc

Причина, по которой любой разговор про serverless машинное обучение начинается с «зависит от рантайма», в том, что именно рантайм решает, загрузятся ли вообще ваши колёса (wheels). Две особенности Python-рантайма Inquir делают onnx serverless по-настоящему рабочим.

Во-первых, это настоящий процесс CPython, а не V8-изолят. Edge- и isolate-платформы вообще не запускают нативные ML-колёса: onnxruntime поставляет скомпилированный бинарник, и изолят просто не может его загрузить. Всё, что зависит от настоящего интерпретатора CPython с C-расширениями, на таких рантаймах невозможно — каким бы маленьким ни была модель. У контейнерного Python этой проблемы нет: обработчик выполняется в обычном Linux-процессе.

Во-вторых, базовый образ — это Python 3.12 на glibc (Debian slim), а не musl/Alpine. Это важнее, чем кажется. onnxruntime, onnx и scikit-learn публикуют предсобранные колёса manylinux. На базе glibc эти колёса устанавливаются и загружаются ровно так, как опубликованы, — так же, как на обычной Linux-машине или в CI-раннере. На образе на основе musl предсобранные manylinux-колёса не подходят, поэтому вы либо скатываетесь к медленной сборке из исходников, либо фиксируете более старые версии, у которых случайно есть musl-колёса. Именно рантайм на glibc позволяет сделать pip install onnxruntime и получить быстрый, протестированный, предкомпилированный бинарник без борьбы.

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

Паттерн «загрузить один раз»: создаём сессию на уровне модуля

Вот самая важная идея для быстрого инференса на CPU в serverless-рантайме: загрузите модель один раз, во время импорта, и переиспользуйте её.

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

Поэтому то, где вы создаёте InferenceSession, определяет профиль задержки. Если создавать её внутри обработчика, вы заново читаете файл .onnx и пересобираете сессию на каждый вызов — хоть холодный, хоть тёплый — и платите задержку загрузки модели на каждом запросе. Если же создать её на уровне модуля (в верхней части файла), она конструируется ровно один раз на контейнер, на холодном старте, а дальше каждый тёплый вызов переиспользует уже загруженную сессию. Загрузка модели исчезает из горячего пути.

Сам файл модели поставляется внутри бандла функции. Он деплоится рядом с обработчиком в /var/task, так что путь к нему можно построить относительно __file__ и загрузить прямо с диска. Никаких сетевых вызовов и обращений к объектному хранилищу — что хорошо согласуется с тем, что по умолчанию исходящая сеть отключена, а корневая файловая система смонтирована только на чтение (/tmp — доступный на запись tmpfs). Для чтения локального файла модели ничего из этого не нужно.

Вот реалистичный обработчик, который следует паттерну от начала до конца:

import os
import json
import time
import numpy as np
import onnxruntime as ort

# ---- Loaded ONCE on cold start, at module scope ----
# The .onnx model ships inside the function bundle and lives next to
# this handler in /var/task. Building the session here means it loads
# on the cold start of a fresh container, then every warm invocation
# in the pool reuses it — no per-request model load.
MODEL_PATH = os.path.join(os.path.dirname(__file__), "model.onnx")

_options = ort.SessionOptions()
# CPU inference: cap intra-op threads so one heavy request does not
# contend for every core in the container.
_options.intra_op_num_threads = int(os.environ.get("ORT_THREADS", "1"))

# CPU only — there is no GPU on the platform.
_session = ort.InferenceSession(
    MODEL_PATH,
    sess_options=_options,
    providers=["CPUExecutionProvider"],
)
_input_name = _session.get_inputs()[0].name
_loaded_at = time.time()  # constant across warm calls => proof of load-once


def handler(event, context):
    body = json.loads(event.get("body") or "{}")
    features = body.get("features")
    if features is None:
        return {
            "statusCode": 400,
            "body": json.dumps({"error": "features required"}),
        }

    # Accept a single row or a batch; onnxruntime wants a 2-D float32 array.
    x = np.asarray(features, dtype=np.float32)
    if x.ndim == 1:
        x = x.reshape(1, -1)  # single row -> batch of 1

    started = time.perf_counter()
    outputs = _session.run(None, {_input_name: x})  # warm: reuses _session
    elapsed_ms = round((time.perf_counter() - started) * 1000, 2)

    return {
        "statusCode": 200,
        "body": json.dumps(
            {
                "predictions": outputs[0].tolist(),
                "rows": int(x.shape[0]),
                "inference_ms": elapsed_ms,
                "session_loaded_at": _loaded_at,
            }
        ),
    }

Полезный приём — в последнем поле. session_loaded_at фиксируется один раз, в момент создания сессии. Если бить в маршрут многократно и метка времени не меняется, значит вы попадаете в тёплый контейнер, который ни разу не перезагружал модель. Если она «прыгнула» — это был холодный старт. Дешёвый и честный способ наблюдать, как паттерн «загрузить один раз» реально работает в проде.

Подключаем onnxruntime и scikit-learn как слои (layers)

Обработчик выше импортирует onnxruntime и numpy. Вшивать эти колёса в каждый деплой функции не стоит — они большие, и перезаливать их при каждом изменении кода расточительно. Правильный механизм — слой (layer).

Слои в Inquir — это общие бандлы зависимостей для Node, Python и Go, монтируемые в контейнер в момент вызова. Для Python-функции инференса вы объявляете ML-зависимости в requirements.txt слоя и подключаете этот слой к функции. Поскольку база — glibc, опубликованные manylinux-колёса устанавливаются в слой ровно так же, как локально:

# layer requirements.txt — attached to the function, not bundled per deploy
onnxruntime
numpy

Подключите onnxruntime (плюс numpy, а также onnx/scikit-learn, если нужны) как слой — и бандл функции останется крошечным: только обработчик и файл модели .onnx. Деплой быстрый, потому что вы отправляете лишь код, а тяжёлые скомпилированные зависимости живут в подключаемом слое. Считайте, что слой — это место для ML-библиотек, а бандл — место для обработчика и модели.

Две честные оговорки. Первая: я описываю механизм — вы подключаете эти библиотеки как слой (или объявляете их в requirements.txt слоя); не считайте, что какой-то конкретный пакет предустановлен в базовом образе по умолчанию. Фиксируйте версии в слое — и вы точно контролируете, что загружается. Вторая: как модель вообще попадает в ONNX. Для оценщика из scikit-learn экспортируйте его через skl2onnx, который превращает обученный пайплайн в граф .onnx, а дальше вы обслуживаете его через onnxruntime. Если хотите оставить оценщик нативным — подключите scikit-learn как слой и вызывайте predict() напрямую; но экспорт в ONNX даёт переносимый самодостаточный артефакт и обычно более быстрый CPU-инференс, поэтому именно этот путь я ставлю во главу для onnx serverless.

Планируем память и таймаут для инференса на CPU

Serverless-функции выполняются внутри реальных лимитов, и для инференса модели больнее всего бьют два: память и таймаут. Знайте цифры до деплоя.

Память по умолчанию — 256 МБ, настраивается от 64 МБ до 2 ГБ (2048 МБ) на функцию. Модель плюс рантайм плюс буферы numpy должны уместиться целиком. Небольшой классификатор scikit-learn или скромный градиентный бустинг, экспортированный в ONNX, комфортно влезают в значение по умолчанию. Модель побольше или пакетный инференс, выделяющий крупные промежуточные массивы, — это ровно тот случай, когда потолок памяти поднимают ближе к 2 ГБ. Если модели нужно больше 2 ГБ резидентной памяти, она не подходит для этой платформы — это жёсткая граница, а не ручка настройки.

Таймаут по умолчанию — 5 секунд, поднимается максимум до 15 минут (900 000 мс). Для одного предсказания на небольшой-средней CPU-модели 5 секунд более чем достаточно — вы отвечаете синхронно внутри запроса. Интереснее массовое скоринг-задание. Если вы прогоняете тысячи строк и работа честно идёт долго, не пытайтесь впихнуть её в один синхронный HTTP-вызов. Вместо этого примите запрос, верните 202 Accepted и запустите инференс как фоновый шаг пайплайна, у которого есть полный бюджет в 15 минут на шаг. Так маршрут, обращённый к клиенту, остаётся отзывчивым, пока за ним крутится тяжёлый скоринг.

Ещё два лимита важны именно для инференса. Тело запроса по умолчанию — 2 МБ, поэтому очень большой набор признаков придётся дробить. А сохраняемый JSON-результат ограничен 64 КБ символов — нормально для одного предсказания или небольшого пакета, но для огромного пакета разбивайте результаты на страницы или пишите их в собственное хранилище, а не возвращайте один гигантский JSON-блоб. Это тихие лимиты, которые бьют только если узнать о них на своём опыте, — заложите их заранее.

Тёплые пулы и холодные старты в serverless machine learning

Паттерн «загрузить один раз» окупается только потому, что тёплые контейнеры сохраняются. Стоит понять, как именно ведёт себя пул, чтобы ожидания по задержкам были откалиброваны.

По умолчанию платформа держит минимум 1 тёплый контейнер на функцию и масштабируется до максимум 8 при конкурентной нагрузке. Тёплый контейнер вытесняется примерно через 5 минут простоя сверх минимума, даже минимальный контейнер убирается примерно через 10 минут полного простоя, а контейнеры перерабатываются после 1000 вызовов. На практике: при стабильном трафике модель остаётся загруженной, и подавляющее большинство запросов — тёплые, идущие только по пути session.run(). После затишья или когда трафик превышает размер текущего пула и поднимаются новые контейнеры, вы платите холодный старт — загрузка интерпретатора плюс загрузка модели на уровне модуля — именно на этих запросах.

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

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

Честные ограничения: только CPU, размер модели и задержки

Любой честный текст про serverless машинное обучение должен содержать раздел о том, для чего это не нужно. Вот он, прямо.

GPU нет. Инференс идёт на CPU через CPUExecutionProvider, и точка. Ничто на этой платформе не ускоряет матричные вычисления на GPU, так что не планируйте нагрузку в расчёте на него. Это исключает крупные трансформерные модели, вижн-сети высокого разрешения и всё, чей бюджет задержек сходится только с пропускной способностью GPU. Это исключает и обучение — здесь речь про обслуживание инференса, а не про место для тренировки моделей.

Размер модели и память — жёсткая граница. Модель, рантайм и рабочие буферы должны уместиться в потолок памяти функции, который упирается в 2 ГБ. Небольшие-средние модели — оценщики scikit-learn, градиентные бустинги, компактные головы эмбеддингов, квантованные ONNX-графы — это золотая середина. Если ваш артефакт весит несколько гигабайт, это не тот инструмент.

Задержка — это задержка CPU, и холодные старты реальны. Для инференса, управляемого запросами и работающего всплесками, тёплая CPU-задержка на небольшой модели вполне разумна, а паттерн «загрузить один раз» держит её стабильной. Но если вам нужен p99 в единицы миллисекунд при высоком устойчивом QPS, выделенный постоянно работающий сервер, рассчитанный под эту нагрузку, обгонит функцию по требованию, которая изредка холодно стартует. Не обещайте себе цифр GPU или нулевых холодных стартов — ни того, ни другого здесь нет.

Никакой durable-оркестрации. Длинный или многоэтапный скоринг — это фоновые шаги пайплайна (каждый ограничен 15 минутами), а не движок долговременных воркфлоу; относитесь к инференсу как к вызову без состояния, делайте обработчики идемпотентными и связывайте шаги, когда работа перерастает один запрос.

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

Вывод

Serverless ML-инференс в Inquir намеренно не эффектный — и в этом суть. Вы запускаете обученную модель на контейнерном Python 3.12 на glibc, так что onnxruntime и scikit-learn ставятся из своих manylinux-колёс так же, как везде. Вы подключаете эти библиотеки как слой, а не вшиваете в каждый деплой, кладёте модель .onnx внутрь функции рядом с обработчиком и создаёте InferenceSession один раз на уровне модуля, чтобы холодные старты платили загрузку, а тёплые контейнеры её переиспользовали. Вы укладываетесь в 256 МБ (до 2 ГБ) памяти и таймаут от 5 секунд до 15 минут, выносите массовый скоринг в шаг пайплайна и смотрите задержку холодных и тёплых вызовов в истории запусков, а не гадаете.

И вы честны насчёт краёв: только инференс на CPU, без GPU, размер модели ограничен памятью, холодные старты реальны, но ограничены тёплыми пулами. Для небольшой-средней onnx serverless модели, обслуживающей неравномерный трафик за аутентифицированным маршрутом, эта сделка отличная — python serverless ml по требованию без сервера, за которым нужно ухаживать, и без платы, пока никто не обращается. Экспортируйте модель в ONNX, подключите onnxruntime как слой, загрузите её один раз и поставьте за маршрут.