Как я строю и строю и строю свой продукт. И что уже вышло
TL;DR
Если честно, то не ожидал, что просто обзорная статья выйдет такой огромной, самому было лень ее перечитывать, чтобы отредактировать.
Кому интересно узнать, чем я занимаюст по вечерам, можете посмотреть в самом конце статьи. Получилось «водянисто», но хотелось описать, чем я занимаюсь, поделиться с такими же людьми со «стартаперством» головного мозга, найти единомыленников.
Присутпим
Есть разработчики, которые годами строят свой продукт, постоянно что-то переписывают, добавляют новые возможности и обещают себе: «Вот закончу эту часть, и тогда уже займусь продажами». Я один из них.
Когда разработчик решает запустить собственный продукт, ему обычно советуют начать с чего-нибудь небольшого: фитнес-приложения, менеджера задач, сервиса заметок, приложения для изучения языков или очередного календаря. В общем, с чего-то, что реально сделать одному или вместе с другом.
Совет разумный. Поэтому я решил его проигнорировать.
Почти три года назад я начал разрабатывать Inquir. Сначала это был относительно небольшой поисковый сервис, затем у него появились собственные индексы, сборщики данных, аналитика, RAG, ingestion-пайплайны и возможность выполнять пользовательские преобразования.
В какой-то момент я понял, что больше не занимаюсь только поиском. Вокруг него постепенно появилась отдельная инфраструктура для загрузки данных, их обработки, запуска пользовательского кода и выполнения продолжительных фоновых процессов.
Позже, уже после pivot-а, эта часть проекта начала развиваться самостоятельно. Так появился Inquir Compute, платформа для изолированного выполнения кода с упором на AI-приложения и длительные ingestion-пайплайны.
Всё началось с RAG
Всё началось в то время, когда генеративный AI ещё не обсуждали в каждом втором подкасте, а GPT-3 казалась скорее впечатляющим экспериментом, чем основой для новой индустрии.
Одной из главных проблем языковых моделей тогда был ограниченный размер контекста. В научных статьях и первых фреймворках появлялись разные способы обойти это ограничение: сжатие текста, MapReduce-цепочки, LexRank и другие подходы, многие из которых позднее можно было встретить, например, в LangChain.
Примерно тогда же начала активно развиваться идея RAG, Retrieval-Augmented Generation.
На первый взгляд всё выглядело довольно просто:
-
пользователь задаёт вопрос;
-
поисковая система находит подходящие фрагменты документов;
-
найденный контекст передаётся языковой модели;
-
модель формирует ответ на основе этих данных.
Казалось, что нужно просто соединить поисковый движок с нейросетью, после чего система будет готова. Разумеется, всё оказалось сложнее.
Очень быстро выяснилось, что единственного правильного варианта RAG не существует. Появились RAG Fusion, Agentic RAG, MemoRAG и множество других подходов. Каждый из них по-своему решал проблемы поиска, построения контекста, декомпозиции запросов и работы с памятью, но у каждого были свои ограничения.
Это была первая большая проблема, в которую я погрузился. Однако найти подходящие документы оказалось только половиной задачи, потому что сначала эти документы нужно получить, обработать и проиндексировать.
Поисковая система начинается не с поиска
Так появилась следующая проблема, ingestion-пайплайны.
Данные могли приходить с сайтов, из sitemap, REST API, баз данных, внутренних документов и других источников. Для каждого источника требовалась своя логика загрузки, после чего данные нужно было очистить, преобразовать, разбить на фрагменты, дополнить метаданными и отправить в поисковый индекс.
Жёстко заданного пайплайна для этого было недостаточно. Хотелось дать пользователю возможность самостоятельно определять этапы обработки и собирать гибкие ETL-процессы: получать данные из разных источников, трансформировать их и загружать в нужное хранилище.
Следом возникли вопросы авторизации и разграничения доступа. Недостаточно просто проиндексировать корпоративные документы, поисковая система не должна показать пользователю информацию, которую он не мог увидеть в исходной системе.
А затем появилась самая болезненная часть, инфраструктура.
OpenSearch, базы данных, фоновые обработчики, сервисы индексации и остальное окружение постепенно разрастались. В какой-то момент инфраструктура начала обходиться мне почти в 400 долларов в месяц.
Для коммерческого продукта с клиентами это, возможно, не самая страшная сумма, но для пет-проекта, который ещё ничего не зарабатывает, расходы уже выглядели чрезмерными. Особенно неприятно было платить за ресурсы, которые большую часть времени почти ничего не делали, но должны были оставаться доступными постоянно.
Однако стоимость была только частью проблемы. К этому моменту Inquir уже пытался одновременно быть поисковым сервисом, RAG-платформой, системой загрузки данных, ETL-инструментом и средой для выполнения пользовательского кода. Каждое из этих направлений постепенно превращалось в отдельный продукт.
В какой-то момент стало понятно, что задачу нужно сузить.
После pivot-а я решил сосредоточиться на самой универсальной части системы, изолированном выполнении пользовательского кода. Особенно интересными мне казались AI-задачи и продолжительные ingestion-процессы, которые плохо укладываются в обычную модель коротких serverless-функций.
Изначально идея выглядела почти примитивно: взять встроенный в Node.js модуль vm, добавить несколько ограничений и разрешить пользователям запускать внутри небольшие JavaScript-функции.
Я думал, что это задача на несколько вечеров, но разумеется, всё снова оказалось сложнее.
Казалось, что достаточно обернуть node:vm
Первоначальная идея была почти примитивной. Я хотел взять встроенный в Node.js модуль vm, немного ограничить доступное окружение и получить место, где можно запускать пользовательский JavaScript. Не передавать внутрь process, файловую систему и другие опасные вещи, оставить несколько разрешённых API, поставить тайм-аут и вернуть результат. На бумаге всё выглядело так, будто эту задачу можно закрыть за несколько вечеров.
Пользователь пишет небольшую функцию, Inquir запускает её во время обработки документа, а результат передаёт следующему этапу пайплайна:
export default async function transform(document) {
return {
...document,
title: document.title?.trim(),
indexedAt: new Date().toISOString()
};
}
Для обычного преобразования JSON этого казалось достаточно. В функцию приходит документ, внутри меняются нужные поля, наружу возвращается результат. Никаких отдельных серверов, сложных API и долгих настроек.
Проблема в том, что node:vm не создаёт полноценную границу безопасности. Это удобный способ выполнить код в отдельном контексте, но не волшебная коробка, внутрь которой можно положить что угодно и больше об этом не думать. В целом сейчас есть варианты как можно это сделать, например движок Ant, или другие микро компиляторы / интерпритаторы, но тх я пока не испытывал.
Пользовательский код мог уйти в бесконечный цикл, заблокировать поток тяжёлой синхронной операцией, создать огромное количество асинхронных задач или начать постепенно занимать всю доступную память. Даже нормально завершившаяся функция могла оставить после себя таймеры, открытые соединения или изменённое состояние, которое неожиданно проявлялось уже во время следующего запуска.
Потом появились более практические вопросы. Пользователям были нужны сетевые запросы, переменные окружения, секреты, зависимости и нормальные логи. Код нужно было запускать параллельно, уметь останавливать и не позволять одной неудачной функции ломать остальные.
В этот момент стало понятно, что речь уже не идёт о небольшой обёртке над node:vm. Функция execute постепенно начала превращаться в отдельный runtime, у которого есть собственный жизненный цикл, ограничения и способы восстановления после ошибок.
Сначала просто вынесем код в отдельный воркер
Запускать пользовательский код прямо внутри основного backend-процесса было слишком рискованно. Если функция зависает или начинает активно потреблять память, вместе с ней может перестать отвечать весь API. Для платформы, которая должна выполнять чужой код, это довольно плохое свойство.
Поэтому следующим шагом стали worker_threads. Основной процесс принимал запрос, находил свободный воркер и передавал ему задачу. Уже внутри воркера создавался ограниченный контекст, куда загружались пользовательский код и входные данные.
В упрощённом виде выполнение выглядело так:
-
пользователь отправляет функцию и входные данные;
-
задача попадает в очередь;
-
система находит свободный воркер;
-
воркер создаёт контекст и запускает код;
-
результат и логи возвращаются в основной процесс;
-
воркер подготавливается к следующей задаче или уничтожается.
Это дало важное преимущество. Если функция зависала, можно было завершить конкретный воркер, не останавливая всю платформу. Сначала я пытался прервать выполнение на уровне vm, а если этого было недостаточно, завершал сам воркер и создавал новый.
Довольно быстро над отдельными воркерами появился пул. Создавать новый поток для каждого запуска было невыгодно, особенно для коротких функций, поэтому часть воркеров оставалась готовой принимать следующие задачи. Если свободных исполнителей не хватало, пул мог создать дополнительные экземпляры до установленного лимита. Если воркер долго ничего не делал, его можно было остановить и освободить память.
Всё это уже начинало напоминать небольшой serverless runtime. Только тогда это ещё не было отдельным продуктом. Это была внутренняя часть поисковой платформы, которая должна была решить одну конкретную проблему: безопаснее выполнять пользовательские преобразования во время ingestion.
Почему произошёл pivot
Постепенно я заметил, что всё больше времени трачу не на поиск. Нужно было поддерживать загрузчики данных, обработку документов, фоновые задачи, очереди, RAG, выполнение пользовательского кода и инфраструктуру вокруг всего этого. Сам поисковый запрос на фоне остальных компонентов начинал выглядеть чуть ли не самой простой частью системы.
Одновременно росли расходы. OpenSearch, базы данных, обработчики и другие сервисы в какой-то момент стали обходиться почти в 400 долларов в месяц. Для работающего бизнеса это может быть обычным счётом за инфраструктуру, но для продукта, который пока не приносит денег, сумма ощущалась совсем иначе.
Впрочем, дело было не только в стоимости. Главная проблема заключалась в том, что Inquir пытался быть слишком многим одновременно. Это был Search-as-a-Service, RAG-платформа, ETL-система, набор загрузчиков данных и runtime для пользовательского кода. Каждое направление росло, требовало новых функций и постепенно начинало выглядеть как самостоятельный продукт.
В какой-то момент я решил сузить задачу и сосредоточиться на той части, которая могла быть полезна не только поисковой системе. Так после pivot-а начал формироваться Inquir Compute.
Его основой стало изолированное выполнение кода — принять пользовательскую программу, запустить её в контролируемом окружении, собрать результат и не позволить одному запуску повлиять на остальные.
Что такое Inquir Compute
После pivot-а идея уже не сводилась к тому, чтобы сделать более безопасную обертку над node:vm. К этому моменту стало понятно, что одного механизма выполнения кода недостаточно. Даже если функция запускается в изоляции и возвращает результат, вокруг нее все равно приходится отдельно настраивать HTTP endpoint, расписание, очередь, переменные окружения, логи и обработку ошибок.
Так Inquir Compute постепенно превратился в единую serverless-платформу для backend-задач. В ней можно написать функцию, опубликовать ее как HTTP endpoint, использовать для обработки webhook, запустить по расписанию или добавить как один из этапов пайплайна. При этом не нужно отдельно собирать API Gateway, cron-планировщик, очередь и систему наблюдаемости из нескольких сервисов.
Если сильно упростить, пользователь пишет обычный обработчик:
exports.handler = async (event) => {
return {
statusCode: 200,
body: JSON.stringify({
message: "Hello from Inquir Compute"
})
};
};
После деплоя функция может получить публичный HTTPS endpoint. Тот же код можно использовать в фоновой задаче, запускать по cron или вызывать из пайплайна. Для пользователя это один каталог функций и один способ деплоя, даже если сами сценарии запуска сильно отличаются.
Сейчас Compute поддерживает Node.js, Python и Go. Каждая функция запускается в отдельном контейнере со своими зависимостями, переменными окружения и ограничениями. Пользователю при этом не нужно писать Dockerfile или самостоятельно управлять контейнерами. Платформа собирает окружение, запускает его и направляет запрос к нужной функции.
Это было важным изменением относительно первоначальной идеи. Я больше не пытался сделать просто «безопасный eval». Я строил систему, в которой код можно один раз задеплоить, а затем использовать в нескольких режимах, не создавая для каждого из них отдельную инфраструктуру.
Один код, несколько способов запуска
Обычная serverless-функция чаще всего ассоциируется с HTTP-запросом. Пришел запрос, функция выполнилась, вернула ответ и завершилась. Для небольшого API этого достаточно, но реальные backend-задачи редко ограничиваются только таким сценарием.
Например, одна функция может принимать webhook от внешнего сервиса. Сам обработчик должен быстро проверить подпись, сохранить событие и вернуть ответ, чтобы отправитель не начал повторять запрос. Тяжелую обработку при этом лучше продолжить в фоне.
Другая функция может запускаться каждую ночь и синхронизировать данные. Третья используется как инструмент AI-агента. Четвертая вызывается внутри многошагового пайплайна после загрузки и очистки документов.
Технически это разные сценарии, но код во всех случаях выполняется в одном и том же runtime. Отличается только источник запуска. Это может быть маршрут API Gateway, cron-расписание, фоновая задача, ручной запуск или шаг пайплайна.
Именно поэтому Compute начал строиться вокруг функций и запусков, а не вокруг отдельных продуктов для API, cron и очередей. Пользователю не нужно переносить одну и ту же логику между несколькими системами только потому, что сегодня она вызывается по HTTP, а завтра должна запускаться по расписанию.
Контейнер как единица изоляции
Эксперименты с node:vm и worker_threads были полезными, но для полноценной платформы такой изоляции было недостаточно. В итоге единицей выполнения стала функция в собственном контейнере.
Это решало сразу несколько проблем. Функции могли иметь разные зависимости, версии библиотек и наборы переменных окружения. Ошибка или чрезмерное потребление ресурсов в одном запуске не должны были напрямую затрагивать остальные. Кроме того, такой подход позволил поддерживать не только Node.js, но и Python с Go.
По умолчанию контейнер не получает доступ к сети. Его можно разрешить для конкретной функции, если ей нужно обращаться к модели, базе данных или внешнему API. Секреты и переменные окружения также задаются на уровне функции, чтобы разные workload не использовали одно общее окружение.
У контейнерного подхода есть и очевидная проблема, холодный запуск. Если для каждого запроса создавать новое окружение, значительная часть времени будет уходить не на пользовательский код, а на запуск контейнера и загрузку runtime.
Поэтому в Compute появились горячие контейнеры. После деплоя или первого вызова окружение может оставаться запущенным и принимать следующие запросы без полной инициализации с нуля. Если функция долго не используется, контейнер останавливается и освобождает ресурсы.
Это не отменяет холодные старты полностью, но делает повторные вызовы заметно быстрее. Для AI-агентов и API инструментов это особенно важно, потому что задержка складывается не только из времени ответа модели, но и из подготовки всего окружения вокруг нее.
API Gateway как единая точка входа
После деплоя функцию можно подключить к API Gateway и назначить ей HTTP-маршрут. Маршрут содержит метод, путь, параметры авторизации и другие настройки, а gateway сам находит нужную функцию и направляет к ней запрос.
Таким образом, из нескольких небольших функций можно собрать обычный REST API. Одна функция обслуживает пользователей, другая принимает webhook, третья выполняет поиск, а четвертая запускает AI-агента. Снаружи все они доступны через единый gateway и выглядят как части одного backend-приложения.
При этом функции не обязаны использовать проприетарный клиент. Endpoint можно вызвать обычным fetch, curl или любым HTTP-клиентом. Для streaming-ответов поддерживается Server-Sent Events, поэтому функция может постепенно передавать в браузер токены модели или прогресс длительной операции.
Изначально я думал в основном о безопасном запуске кода. Но довольно быстро выяснилось, что пользователю мало просто выполнить функцию. Ему нужен стабильный способ вызвать ее из реального приложения. Так вокруг runtime появился собственный gateway.
Пайплайны вместо одного большого скрипта
Для ingestion-задач отдельной функции тоже оказалось недостаточно. Индексация документов обычно состоит из нескольких этапов, каждый из которых может завершиться ошибкой независимо от остальных.
Например, процесс может выглядеть так:
Загрузка документов
↓
Извлечение содержимого
↓
Очистка и нормализация
↓
Пользовательская трансформация
↓
Разбиение на фрагменты
↓
Вычисление embeddings
↓
Запись в индекс
Можно написать один большой скрипт, который последовательно выполняет все эти действия. Но тогда ошибка при записи в индекс заставит заново загружать и обрабатывать документы, хотя вся предыдущая работа уже была выполнена.
В Compute каждый этап можно представить отдельной функцией, а затем соединить функции в пайплайн. Пайплайн описывает порядок выполнения, зависимости между шагами, преобразование входных данных и правила повторных попыток. В более сложных сценариях в нем могут появляться ветвления и этапы, требующие участия человека.
Такой подход оказался полезен не только для ingestion. По той же модели можно собирать AI workflow, где один шаг выполняет поиск, другой вызывает модель, третий обращается к внешнему инструменту, а четвертый формирует итоговый ответ.
Вместо одного длинного процесса получается набор небольших функций, каждую из которых можно запустить отдельно, повторить после ошибки и отладить независимо от остальных.
Выполнение как отдельная сущность
Независимо от того, откуда была вызвана функция, через API Gateway, вручную, по расписанию или из пайплайна, в системе создается отдельный запуск, или run.
У запуска есть идентификатор, статус, время выполнения, логи и результат. Если включена наблюдаемость, к этому добавляется трасса с последовательностью внутренних операций и пользовательскими spans. Это позволяет открыть один конкретный запуск и посмотреть, что именно происходило от начала до конца.
Такой подход особенно важен для фоновых задач и пайплайнов. Пользователь может закрыть браузер, HTTP-соединение может оборваться, но само выполнение продолжит жить отдельно. Позже можно вернуться в историю, увидеть, завершилось ли оно, на каком шаге возникла ошибка и сколько времени заняла каждая часть процесса.
Изначально я воспринимал логи как обычный вывод console.log. Но для системы, где один запрос может пройти через gateway, очередь, несколько функций и внешний API, обычного текстового файла быстро становится недостаточно. Нужна связь между всеми событиями одного запуска.
Поэтому наблюдаемость стала частью самой модели выполнения, а не дополнительным сервисом, который можно подключить когда-нибудь потом.
Почему основной акцент оказался на AI
Технически на Compute можно запускать обычные API, cron-задачи, webhook-обработчики и фоновую обработку. Но AI-приложения особенно хорошо показывают, зачем все эти части нужны вместе.
AI-агенту часто требуются собственные инструменты в виде HTTP-функций. Эти инструменты должны обращаться к базам данных и внешним API, но секреты при этом не должны попадать в prompt. Ответ модели иногда нужно передавать пользователю потоком, а тяжелые операции лучше уносить в фон, чтобы не держать открытым исходный запрос.
Кроме того, выполнение AI-задачи редко ограничивается одним вызовом модели. Она может включать поиск, несколько инструментов, обработку документов и повторные попытки. Для отладки важно видеть не только финальную ошибку, но и всю последовательность действий, которая к ней привела.
В Inquir Compute эти части не являются отдельными продуктами. HTTP-функции, фоновые задачи, cron, пайплайны, секреты и история запусков работают вокруг одного каталога функций.
Именно это сейчас является основой продукта. Не просто «запустить код в контейнере», а дать ему несколько способов запуска и собрать вокруг выполнения все, что обычно приходится настраивать отдельно.
Что происходит после нажатия Deploy
Снаружи деплой функции должен выглядеть скучно. Пользователь выбирает runtime, добавляет код, указывает зависимости и нажимает Deploy. Через некоторое время он получает готовую функцию, которую можно вызвать напрямую или подключить к API Gateway.
Внутри в этот момент происходит уже не одна операция. Платформе нужно проверить конфигурацию, собрать окружение, подготовить зависимости, создать новую версию функции, запустить контейнер и убедиться, что handler действительно загружается. Только после этого функцию можно считать готовой к работе.
Первые версии этого процесса были довольно прямолинейными. Код передавался исполнителю, тот загружал его и пытался вызвать нужную функцию. Такой подход работал, пока пользовательский код состоял из одного файла и почти не имел зависимостей. Но довольно быстро выяснилось, что реальное приложение редко помещается в один небольшой handler.
Node.js-проекту нужны npm-пакеты. Python-функции могут использовать библиотеки с нативными расширениями. Go-код сначала нужно скомпилировать. Кроме того, у разных функций могут быть несовместимые версии одних и тех же зависимостей.
Так простая кнопка Deploy постепенно превратилась в небольшую систему сборки.
Платформа должна понимать, какой runtime выбран, как установить зависимости, какой файл является точкой входа и как проверить результат сборки. Если сборка завершилась с ошибкой, пользователю нужны нормальные логи, а не сообщение в духе “что-то пошло не так”.
При этом заставлять пользователя писать Dockerfile мне не хотелось. Иначе значительная часть смысла serverless-платформы теряется. Если разработчику все равно нужно вручную описывать образ, разбираться с базовыми контейнерами и настраивать команду запуска, он может просто арендовать VPS и использовать Docker напрямую.
Поэтому контейнер остается внутренней деталью Compute. Пользователь работает с функцией, runtime и зависимостями, а платформа уже превращает это в готовое окружение.
Зависимости тоже оказались отдельным продуктом внутри продукта
На первых этапах кажется, что зависимости можно просто устанавливать при каждом деплое. Для маленькой функции с парой npm-пакетов это действительно работает. Но когда в Python-проекте появляется несколько тяжелых библиотек, а сборка запускается после каждого изменения одной строки, такой подход быстро начинает раздражать.
Большая часть времени начинает уходить не на пользовательский код, а на повторную загрузку и установку одних и тех же пакетов. Кроме того, некоторые зависимости требуют системных библиотек или компиляции. Установить обычный пакет на чистом JavaScript и собрать Python-библиотеку с C-расширением, это довольно разные задачи.
Так в Compute появились слои зависимостей. Идея похожа на AWS Lambda Layers. Пользователь может отдельно собрать набор библиотек, подключить его к нескольким функциям и обновлять независимо от основного кода.
Например, в Python-слой можно вынести numpy, pandas и клиент для работы с моделью. После этого небольшие функции используют уже готовое окружение и не пересобирают тяжелые библиотеки при каждом деплое.
Для Node.js это может быть общий набор SDK и внутренних пакетов. Для Go слой может содержать системные зависимости, необходимые во время сборки.
На словах это просто “вынесем зависимости отдельно”. На практике пришлось решать вопросы версий, совместимости, кеширования и повторной сборки. Нужно понимать, когда старый слой еще можно использовать, а когда изменение runtime делает его несовместимым.
В итоге даже установка пакетов превратилась в отдельный жизненный цикл со своими состояниями, логами и ошибками.
Холодный запуск и цена изоляции
Контейнеры дают более понятную изоляцию, но за нее приходится платить временем запуска. Если поднимать новое окружение для каждого запроса, пользователь сначала будет ждать запуска контейнера, затем загрузки runtime и только потом выполнения своего кода.
Для фоновой задачи лишняя секунда иногда не имеет значения. Для API или инструмента AI-агента такая задержка уже хорошо заметна. Особенно если агент вызывает несколько функций последовательно и холодный старт добавляется к каждому шагу.
Поэтому в Compute появились горячие контейнеры. После первого вызова контейнер может оставаться запущенным и некоторое время ждать следующую задачу. Повторный запрос попадает в уже готовое окружение и начинает выполняться почти сразу.
Разумеется, это снова создало несколько новых вопросов. Сколько времени контейнер должен оставаться горячим? Сколько экземпляров одной функции нужно держать? Что делать, если одновременно пришло несколько запросов? Можно ли безопасно повторно использовать окружение после предыдущего запуска?
У контейнера постепенно появился собственный жизненный цикл. Он создается, запускается, проходит проверку готовности, принимает задачи, некоторое время простаивает и в конце останавливается. Если контейнер перестал отвечать или завершился с ошибкой, платформа должна убрать его из доступных исполнителей и подготовить замену.
При этом важно разделять контейнер и запуск. Один горячий контейнер может выполнить несколько запросов последовательно, но каждый из них должен оставаться отдельным run со своими логами, входными данными и результатом.
Иначе отладка быстро превращается в археологию, где события нескольких пользователей смешаны в одном потоке вывода.
Очередь появилась почти сама
Пока функций было немного, задачу можно было сразу отправлять свободному исполнителю. Но как только появились продолжительные процессы и параллельные запуски, стало понятно, что свободный runtime есть не всегда.
Можно, конечно, попытаться запускать новый контейнер под каждый запрос. Но сервер имеет вполне конкретный объем памяти и конечное количество процессорных ядер. Если позволить всем задачам стартовать одновременно, платформа очень быстро перестанет отвечать.
Поэтому между созданием запуска и его выполнением появилась очередь. Новый run сначала сохраняется, затем ожидает свободный runtime и только после этого начинает выполняться.
Очередь также позволила по-разному относиться к разным видам нагрузки. Короткий HTTP-запрос не должен ждать несколько минут только потому, что перед ним кто-то запустил большую индексацию. Фоновая задача может подождать, а запрос пользователя желательно обработать как можно быстрее.
Здесь возникает классическая проблема планирования. Нужно учитывать приоритеты, ограничения конкретной функции, доступные ресурсы и количество уже работающих контейнеров. Кроме того, один пользователь не должен занять все исполнители и заблокировать остальных.
Пока система маленькая, многие такие вопросы можно решить простыми лимитами. Но по мере роста даже обычная очередь постепенно начинает напоминать scheduler.
Снова получилось знакомо. Я хотел просто вызвать функцию, а в итоге начал писать планировщик вычислений.
Повторы не означают надежность сами по себе
Когда задача падает из-за временной сетевой ошибки, логично просто запустить ее еще раз. Так в системе появляются retries.
Но повторный запуск безопасен не всегда. Если функция отправляет письмо, списывает деньги или создает запись во внешней системе, повтор может выполнить действие дважды. Поэтому надежность нельзя получить одной настройкой retries: 3.
Функции и пайплайны должны учитывать идемпотентность. Один и тот же шаг может быть запущен повторно, поэтому разработчику нужен способ понять, выполнялась ли операция раньше. Для webhook это может быть идентификатор события, для импорта документов идентификатор файла, а для платежной операции отдельный idempotency key.
Compute может управлять очередью, повторами и историей запусков, но не способен автоматически сделать любой пользовательский код идемпотентным. Это один из моментов, где абстракция заканчивается и ответственность снова возвращается разработчику.
То же самое относится к обещанию “выполнить ровно один раз”. В распределенной системе такой контракт намного сложнее, чем кажется. Процесс может выполнить внешнюю операцию и завершиться до того, как сохранит успешный статус. После этого платформа увидит незавершенный запуск и повторит его.
Поэтому практичнее строить систему вокруг повторяемых операций и понятной истории выполнения, а не обещать магическое “exactly once”.
Логи быстро перестали быть просто console.log
В первой версии логирование было очевидным. Пользователь пишет console.log, строка сохраняется и показывается в интерфейсе. Для одной функции этого вполне достаточно.
Но затем один запрос начал проходить через API Gateway, очередь, runtime и несколько шагов пайплайна. Внутри функция могла вызвать внешний API, модель или другую функцию. В результате обычного списка строк стало недостаточно.
Нужно было понимать, к какому запуску относится сообщение, на каком шаге оно появилось и сколько времени прошло между операциями. Для AI-задач также важно видеть, где ожидался ответ модели, где запускался инструмент и на каком этапе выполнение начало замедляться.
Так вокруг логов появилась трассировка. Каждый запуск получает идентификатор, а связанные операции объединяются в одну историю. Пользователь может открыть конкретный run и посмотреть его путь от входного запроса до результата или ошибки.
Для активных запусков состояние и новые сообщения можно передавать в интерфейс через SSE. Благодаря этому не нужно постоянно обновлять страницу или отправлять запросы на проверку статуса. Логи и изменения состояния появляются по мере выполнения.
Наблюдаемость оказалась не декоративной функцией для красивого dashboard. Без нее невозможно нормально отлаживать продолжительные задачи. Если процесс работал сорок минут и упал на шестом шаге, пользователю нужно увидеть не только финальную ошибку, но и весь путь до нее.
Секреты нельзя передавать вместе с кодом
AI-агенты и backend-функции почти всегда обращаются к внешним сервисам. Им нужны ключи моделей, токены API, доступ к базе данных и другие секреты.
Самый простой способ, записать ключ прямо в код. Это одновременно и самый плохой способ. Такой секрет попадает в историю версий, сборочные артефакты и иногда даже в логи.
Поэтому переменные окружения и секреты стали отдельной частью конфигурации функции. Они передаются контейнеру только во время запуска и не должны храниться вместе с исходным кодом.
Для AI-сценариев это особенно важно. Секрет не должен попадать в prompt и становиться частью контекста модели. Агенту можно дать инструмент, который обращается к нужному API, но сам ключ остается внутри функции.
Получается довольно полезное разделение. Модель знает, что у нее есть инструмент “получить данные клиента”, но не получает прямой доступ к токену базы данных или внутреннему API.
При этом секреты создают собственный набор проблем. Их нужно безопасно хранить, обновлять, передавать только нужным функциям и не показывать в логах. В идеале также должна существовать история изменений и возможность быстро отозвать скомпрометированный ключ.
Очередная небольшая настройка снова превращается в отдельную подсистему.
Почему я не стал строить все вокруг Kubernetes
Когда речь заходит о контейнерах, очередях и планировщике, довольно быстро появляется очевидный вопрос: “Почему просто не использовать Kubernetes?”
Я экспериментировал с Kubernetes в других частях инфраструктуры, но текущий Inquir Compute не построен поверх него. Для моих задач он добавлял слишком много уровней, которые все равно пришлось бы скрывать от пользователя.
Пользователь Compute работает с функцией, маршрутом, пайплайном и запуском. Ему не нужны Pod, Deployment, Service и Ingress. Если вся внешняя модель продукта не совпадает с сущностями Kubernetes, поверх него все равно придется писать отдельный control plane.
Это не означает, что Kubernetes плохой. Он решает большой класс задач и хорошо подходит для систем, которым нужна его модель управления. Но в моем случае хотелось сохранить более прямой контроль над жизненным циклом контейнеров и не поддерживать дополнительный слой инфраструктуры раньше, чем он действительно понадобится.
Возможно, с ростом платформы это решение еще придется пересмотреть. Но пока собственная модель оказалась проще для тех сценариев, на которых сосредоточен Compute.
Где проходит граница продукта
В процессе разработки очень легко снова начать строить все подряд. Если функции могут обращаться к базе данных, почему бы не добавить собственную базу? Если пайплайнам нужно хранить файлы, почему бы не сделать объектное хранилище? Если есть API Gateway, почему бы не добавить полноценную систему управления пользователями?
Так можно довольно быстро вернуться к исходной проблеме и снова пытаться написать небольшой AWS.
Поэтому сейчас я стараюсь удерживать границу продукта вокруг выполнения кода. Compute дает runtime, маршрутизацию, расписания, пайплайны, секреты, историю запусков и наблюдаемость. Все остальное можно подключать как внешний сервис.
Конечно, эта граница не всегда очевидна. Пользователю хочется получить цельную систему, а не собирать продукт из десятка разных сервисов. Но каждая новая встроенная возможность увеличивает объем поддержки и отвлекает от основной задачи.
Для проекта, который долгое время развивал один человек, это особенно важно. Можно написать почти что угодно. Намного сложнее решить, чего писать не нужно.
Что пока остается сложным
Самая заметная проблема контейнерного serverless, это холодный запуск. Горячие контейнеры уменьшают задержку, но полностью ее не устраняют. Чем тяжелее runtime и зависимости, тем дольше поднимается новое окружение.
Вторая проблема связана с плотностью размещения. Изоляция требует ресурсов, а пустой контейнер тоже занимает память. Нужно искать баланс между скоростью повторного вызова и количеством простаивающих окружений.
Третья проблема, это продолжительные задачи. Чем дольше работает процесс, тем больше вероятность, что по дороге произойдет ошибка, перезапустится исполнитель или станет недоступен внешний сервис. Поэтому состояние нельзя хранить только в памяти конкретного контейнера.
Есть и более обычные вопросы: лимиты, безопасность сети, хранение логов, стоимость трассировки, сборка зависимостей и совместимость runtime. Большая часть этих проблем не видна пользователю, пока все работает. Но именно они определяют, насколько платформе можно доверять.
Это, пожалуй, главный урок всей разработки. “Запустить код” и “надежно выполнять пользовательский код в продакшене” являются двумя очень разными задачами.
Что получилось в итоге
Сейчас Inquir Compute объединяет несколько вещей, которые обычно приходится настраивать отдельно. Можно задеплоить функцию, назначить ей HTTP-маршрут, использовать ее в webhook, запустить по расписанию или добавить в пайплайн. Все эти сценарии используют общие runtime, секреты, историю запусков и наблюдаемость.
Поддержка Node.js, Python и Go позволяет не пытаться решать любую задачу одним языком. API и простые интеграции удобно писать на Node.js, обработку данных и ML-инструменты на Python, а более производительные обработчики на Go.
При этом основной объект платформы остается достаточно простым. Есть функция, есть способ ее вызвать и есть отдельный запуск с состоянием, логами и результатом.
Возможно, именно к этому я и пытался прийти все это время. Не построить еще одно универсальное облако, а собрать небольшой набор инфраструктурных примитивов, которых мне самому не хватало при разработке поисковой и RAG-платформы.
И я все еще не начал нормально это продавать
За время разработки я успел несколько раз изменить архитектуру, переписать runtime, построить API Gateway, добавить пайплайны, контейнеры, логи и трассировку. Параллельно я продолжал улучшать сайт, документацию, SEO и AEO, потому что это хотя бы та часть маркетинга, которую я могу воспринимать как инженерную задачу.
С продажами все оказалось сложнее.
Написать еще один scheduler психологически проще, чем написать незнакомому человеку и предложить попробовать продукт. Исправить холодный старт понятнее, чем решить, кому именно нужен твой сервис и почему он должен выбрать его среди десятков альтернатив.
Наверное, поэтому разработчики так любят бесконечно улучшать продукт. У технической задачи обычно есть понятный вход, ограничения и результат. У продаж нет даже гарантии, что человек ответит.
Но в какой-то момент продукт все же приходится показывать людям. Иначе можно еще три года строить идеальную платформу для пользователей, которых пока не существует.
Inquir Compute уже работает, на нем можно запускать функции, API, задачи по расписанию и пайплайны. Теперь мне остается решить, что сложнее: продолжать писать собственное облако или наконец научиться его продавать.
В завершении
Я все еще не бросаю затею с RAG и Search-as-a-Serive, постепенно оживляю тот проект, но он сместился в техническую сторону. Теперь он строится на базе своего собственного, написанного с нуля поискового движка. Если эта статья окажется хоть кому-то инетересна, то постараюсь описать некоторые технические моменты более глубоко, как Compute, так и Search, сейчас вышло весьма обзорно.
Ссылки на проекты:
1. Inquir Compute
2. Inquir Search
Еще на самом деле существует Inquir Id — SSO который по-быстрому сделал для общей аутентификации в рамках своего стэка (даже смешно это так называть XD)
Ну и теперь я упарываюсь еще больше делая различные демки, но уже используя свои приложения.
Handbook по git — сделал для коллег: https://aleksandr-kubarskii-0931e2-spam-c60e63.inquir.org/
Демка запуска ONNX моделек через Python окружение: https://aleksandr-kubarskii-0931e2-spam-c60e63.inquir.org/
Поисковик для Rick and Morty: https://aleksandr-kubarskii-0931e2-search-f15a70.inquir.org/
Поисковик для аниме: https://aleksandr-kubarskii-0931e2-anime-42854a.inquir.org/
Зачатки поисковика Hacker News: https://aleksandr-kubarskii-0931e2-hn-8439d2.inquir.org/
Эмулятор PSX: https://aleksandr-kubarskii-0931e2-psx-d82ded.inquir.org
Автор: alexander_kubarski

