REST API на serverless-функциях
Прагматичный гайд по serverless REST API на функциях за шлюзом: сопоставляем ресурсы с маршрутами, включаем авторизацию по API-ключу на API gateway, фиксируем форму запроса и ответа, добавляем курсорную пагинацию и единый контракт ошибок и честно разбираем лимиты.
REST API остаётся самым скучным и самым надёжным контрактом, который можно отдать другой команде: JSON на входе, JSON на выходе, предсказуемые коды ответа. Интересный вопрос сегодня уже не в том, строить ли его, а в том, где он должен работать. Этот пост — прагматичный разбор того, как собрать serverless REST API на функциях за шлюзом: одна функция на ресурс, авторизация на границе и знакомая форма запроса. И он честно говорит о тех немногих местах, где serverless заставляет думать иначе.
Почему serverless REST API лучше монолита-роутера
Первый инстинкт — взять фреймворк (Express, FastAPI, Gin) и спрятать все маршруты за одним долгоживущим процессом. Это работает ровно до тех пор, пока не перестаёт. Когда /users, /invoices и /reports живут в одном бинарнике, один плохой деплой задевает все три, а разбор инцидента превращается в чтение логов, где переплетены три несвязанные темы.
Со масштабированием та же беда. Если по /search бьют запросами, вы масштабируете весь монолит целиком — включая сонные /settings, которые видят десять запросов в день. Вы платите за память, которой не пользуетесь, потому что масштабирование связано на границе процесса, а не маршрута.
Serverless REST API переворачивает это. Каждый ресурс — отдельная функция со своим набором зависимостей, своим деплоем и своей кривой масштабирования. Горячий маршрут масштабируется сам по себе; рискованное изменение выкатывается отдельно; падающий эндпоинт виден в наблюдаемости привязанным к конкретной возможности, а не растворён в общем потоке логов.
Честный контраргумент — расползание микросервисов. Разнесите каждый путь в отдельный репозиторий и пайплайн — и вы обменяете одну проблему на худшую: десятки крошечных сервисов, которым всё равно нужны общие авторизация, CORS и лимиты. Ответ не в «многих сервисах», а в группах маршрутов за одним API gateway. Сгруппируйте те serverless api endpoints, которые деплоятся и падают вместе, в одну функцию, а сквозные заботы оставьте шлюзу.
Когда монолит всё же выигрывает? Когда команда крошечная, а приложение на фреймворке уже прекрасно работает. Не переписывайте здоровую систему ради архитектурной моды. Берите serverless, когда связанность деплоя, неравномерное масштабирование или радиус поражения реально начинают мешать.
Сопоставление REST-ресурсов и serverless API endpoints
Начните там, где велит REST: моделируйте ресурсы как существительные. users, orders, invoices. Каждое существительное становится группой маршрутов, а группа маршрутов — функцией. Глаголы — GET, POST, PUT, PATCH, DELETE — обрабатываются внутри функции переключением по HTTP-методу.
В Inquir Compute маршрут шлюза — это method плюс path. Методы покрывают весь набор: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS и всеохватный ANY. Пути поддерживают именованные параметры в виде :id или {id} и подстановки */+, так что один маршрут вроде ANY /v1/users* может обслуживать целый ресурс. Цель маршрута — функция (lambda) или, когда нужна многошаговая работа, пайплайн.
Вот что здесь на самом деле означает serverless-маршрутизация: шлюз сопоставляет запрос с маршрутом и передаёт функции событие; функция сама решает, что значит этот глагол. Публично маршруты доступны по адресу /gw/{tenant}/{route-path}, а именованные API можно поставить за подтверждённый кастомный домен, чтобы потребители видели api.yourcompany.com, а не платформенный URL.
Насколько дробить? Группируйте маршруты, которые меняются вместе. Разнести каждый путь в отдельную функцию на доске выглядит чисто, но взрывает операционный шум — больше деплоев, больше дашбордов, больше холодных путей. Хорошая отправная точка — одна функция на ресурс, а дробить дальше стоит лишь тогда, когда у маршрута действительно другие зависимости или другой профиль нагрузки. Цель — небольшое число serverless api endpoints, каждый из которых владеет цельным куском домена.
Авторизация на уровне маршрута с API-ключами
Аутентификация — первое, что нужно публичному API, и последнее, что хочется размазывать по хендлерам. На шлюзе авторизация настраивается на каждый маршрут в трёх режимах: none, api-key и bearer. Именно это решение — заданное на маршруте, а не в коде — определяет, кто вообще доберётся до хендлера.
Для трафика «сервер-сервер» и партнёрских интеграций рабочая лошадка — api-key. Вызывающий шлёт заголовок X-Api-Key, и шлюз проверяет его до запуска функции. Ключи — это значения base64url на 32 байта, хранятся только как SHA-256-хеш и показываются создателю ровно один раз, так что утёкшая база данных не отдаёт рабочих ключей. Маршрут можно ограничить конкретными ключами через allowedApiKeyIds — так каждому партнёру выдают учётные данные, открывающие лишь положенные ему эндпоинты.
Для клиентских приложений, которые уже носят токен, режим bearer проверяет заголовок Authorization: Bearer …. В любом случае ключевое архитектурное свойство одно: хендлер исходит из того, что вызывающий уже аутентифицирован. Никакого шаблонного кода авторизации в каждой функции, никакой забытой проверки, случайно оставившей эндпоинт открытым, и никакой логики авторизации внутри бизнес-кода. API gateway — единственная точка контроля, а хендлеры сосредоточены на ресурсе.
Форма запроса и ответа serverless-хендлера
Если вы когда-нибудь писали AWS Lambda за API Gateway, это покажется мгновенно знакомым. У хендлера сигнатура (event, context), и шлюз передаёт ему событие в стиле API Gateway с ожидаемыми полями:
httpMethod—"GET","POST"и так далее.path— совпавший путь запроса.headers— обычный объект заголовков запроса.queryStringParameters— разобранная строка запроса илиnull.pathParameters— именованные параметры пути, так что:idприходит какpathParameters.id.body— сырое тело запроса строкой (илиnull).
То, что тело приходит строкой, — сознательный выбор, а не неудобство. Так сохраняются ровно те байты, что прислал клиент, а это важно для проверки подписи и типов контента, которые лучше не давать фреймворку молча переразбирать. Для JSON вы сами вызываете JSON.parse(event.body || '{}') и остаётесь у руля.
На выходе возвращайте { statusCode, body }, где body — строка, почти всегда JSON.stringify(payload). Возврат «голого» объекта тоже допустим и трактуется как ответ 200 с JSON — удобно для быстрых внутренних эндпоинтов; но настоящий REST API на serverless-функциях должен быть явным насчёт кодов ответа, ведь 201 Created, 404 Not Found и 422 Unprocessable Entity — часть вашего контракта. Явное лучше неявного, когда от вас зависит чужой код.
Реалистичный хендлер: ресурс users на JavaScript
Вот одна функция, обслуживающая весь ресурс /v1/users. Она маршрутизирует по HTTP-методу, пагинирует список через курсор, валидирует ввод на границе и возвращает единый конверт ошибки. Привяжите её к маршруту шлюза вроде ANY /v1/users* с авторизацией api-key — и хендлер может считать, что любой вызывающий уже аутентифицирован.
// users.mjs — one function backing the /v1/users resource
// Gateway route: ANY /v1/users* · auth: api-key (enforced before this runs)
function json(statusCode, payload) {
return { statusCode, body: JSON.stringify(payload) };
}
function jsonError(statusCode, code, message) {
return json(statusCode, { error: { code, message } });
}
export async function handler(event) {
const { httpMethod, pathParameters, queryStringParameters } = event;
const id = pathParameters?.id;
try {
// GET /v1/users/:id — fetch one
if (httpMethod === 'GET' && id) {
const user = await db.users.findById(id);
if (!user) return jsonError(404, 'not_found', 'User not found');
return json(200, { data: user });
}
// GET /v1/users — list, cursor-paginated
if (httpMethod === 'GET') {
const limit = Math.min(Number(queryStringParameters?.limit) || 25, 100);
const cursor = queryStringParameters?.cursor ?? null;
const rows = await db.users.list({ limit: limit + 1, cursor });
const hasMore = rows.length > limit;
const data = hasMore ? rows.slice(0, limit) : rows;
return json(200, {
data,
next_cursor: hasMore ? data[data.length - 1].id : null,
});
}
// POST /v1/users — create
if (httpMethod === 'POST') {
const input = JSON.parse(event.body || '{}');
if (!input.email) return jsonError(422, 'invalid_body', 'email is required');
const user = await db.users.insert({ email: input.email, name: input.name ?? null });
return json(201, { data: user });
}
return jsonError(405, 'method_not_allowed', `${httpMethod} not supported`);
} catch (err) {
// Never leak internals: log the detail, return a stable envelope
console.error('users handler failed', err);
return jsonError(500, 'internal_error', 'Unexpected error');
}
}
Несколько моментов стоит подсветить — именно они отличают демку от API, на котором клиенты действительно строят.
Пагинация. Ветка списка читает limit и cursor из строки запроса, зажимает limit, чтобы клиент не запросил миллион строк, и берёт одну лишнюю строку, чтобы понять, есть ли следующая страница. Она возвращает next_cursor — id последнего элемента — или null в конце. Курсорная пагинация остаётся стабильной при вставках и удалениях так, как offset/page не умеет, и держит каждый ответ комфортно ниже лимита тела.
Контракты ошибок. Любой путь отказа возвращает один и тот же конверт: { "error": { "code": "...", "message": "..." } }. code — стабильная машинная строка, по которой клиент ветвится; message — человеческая подсказка, которую можно менять, ничего не ломая. Единая форма на всех маршрутах означает, что клиентский SDK пишет обработку ошибок один раз. Валидируйте на границе хендлера и падайте рано — отсутствующий email сразу становится 422, а не 500 тремя слоями глубже. (Если предпочитаете отсекать некорректные payload’ы ещё до запуска кода, шлюз также предлагает опциональную валидацию тела на маршруте.)
Защитные края. Неподдерживаемый глагол возвращает 405 вместо имитации успеха, а внешний try/catch гарантирует, что неожиданное исключение станет чистым 500 без утечки стектрейса вызывающему. Мелкие привычки — но именно они отделяют публичную поверхность от внутреннего скрипта.
CORS, версионирование и лимиты как паттерны шлюза
Три сквозные заботы всплывают на любом публичном API. Две — функции шлюза; одна — дисциплина, которую вы принимаете.
CORS обрабатывается на каждом маршруте шлюза и включён по умолчанию (corsEnabled: true). Шлюз отвечает на preflight-запросы OPTIONS и возвращает разрешённые методы (GET, POST, PUT, PATCH, DELETE, OPTIONS) и заголовки (Content-Type, Authorization, X-Api-Key). Поскольку это живёт на маршруте, браузерные клиенты работают без единой строки кода с заголовками в вашем хендлере — а список разрешений вы ужимаете централизованно, а не аудируя каждую функцию.
Версионирование — это паттерн, а не переключатель. Никакого волшебного тумблера «v2»; вы версионируете префиксом пути. Выпускайте /v1/users, а когда ломающее изменение схемы неизбежно — поднимайте /v2/users как новые маршруты (часто новые версии функций), пока префикс /v1 продолжает обслуживать существующих клиентов. Это ровно та дисциплина — версионировать до того, как сломать контракт, — и маршрутная модель шлюза делает одновременную работу обоих префиксов дешёвой.
Лимиты частоты применяются на каждом маршруте, и здесь важна честность: ограничитель работает на исходный IP, поминутно и в памяти — это не распределённая глобальная квота. Превысив лимит, клиент получает 429 с Retry-After: 60. Это ровно то, что нужно, чтобы прикрыть общую зависимость от разогнавшегося или злоупотребляющего клиента. Но раз это на IP и на инстанс, не считайте это точной квотой уровня аккаунта для биллинга; если нужны глобальные квоты на тенанта, применяйте их в хендлере против общего хранилища, а ограничитель шлюза пусть будет грубой первой линией обороны.
Честные лимиты и когда браться за фоновые задачи
Serverless меняет форму некоторых задач, и притворяться иначе — значит копить аварии на потом.
Размер и время запроса ограничены. Лимит тела запроса по умолчанию — около 2 МБ: щедро для JSON и неверно для приёма крупных файлов напрямую (используйте объектное хранилище и подписанный URL). У каждой функции таймаут 5 секунд по умолчанию, максимум 15 минут. Этот потолок — для длинного хвоста, а не для счастливого пути; синхронный REST-вызов должен отвечать заметно меньше чем за секунду.
Холодные старты сокращены, но не устранены. Пулы горячих/тёплых контейнеров держат инстансы наготове и поглощают большую часть задержки, но первый вызов или вызов после простоя всё ещё холодный. Мерьте свои p95/p99 под реалистичной нагрузкой, а не предполагайте ноль.
Медленной работе не место в потоке запроса. Это важнейший лимит для усвоения: внутри хендлера нет движка надёжной оркестрации, на который можно опереться. Когда запрос вышел бы за таймаут — перестройка индекса, генерация большого экспорта, вызов медленного стороннего API — не держите сокет открытым. Верните 202 Accepted с id задачи и продолжите в надёжной фоновой задаче на Postgres.
// export.mjs — hand slow work to a durable background job, return fast
export async function handler(event) {
const { datasetId } = JSON.parse(event.body || '{}');
if (!datasetId) return { statusCode: 400, body: JSON.stringify({ error: { code: 'invalid_body', message: 'datasetId required' } }) };
const { instanceId: jobId } = await global.durable.startNew('reindex-dataset', undefined, { datasetId });
// Client polls GET /v1/jobs/:jobId or waits for a webhook
return { statusCode: 202, body: JSON.stringify({ data: { jobId, status: 'queued' } }) };
}
Задача переживает перезапуски, доступны ретраи с backoff, а исчерпавшие попытки уходят в dead-letter. Чего она не даёт — доставки ровно один раз и гарантированного порядка, поэтому делайте хендлеры идемпотентными и ключуйте записи по стабильным id. Ещё два умолчания полезно знать: исходящая сеть выключена, пока вы её не включите, а корневая ФС только для чтения с записываемым /tmp. Считайте это настройками безопасности, а не препятствиями.
Вывод
Собрать serverless REST API — не экзотика. Моделируйте ресурсы как существительные, подкладывайте под каждый функцию и отдайте api gateway маршрутизацию, авторизацию по API-ключу, CORS и первую линию лимитов. Держите форму запроса и ответа явной — событие в стиле API Gateway на входе, { statusCode, body } на выходе — и стандартизируйте один конверт ошибки и курсорную пагинацию, чтобы клиенты писались дёшево. Версионируйте префиксом пути до того, как сломаете контракт, уважайте потолки ~2 МБ тела и 5 с / 15 мин таймаута и отдавайте всё медленное в надёжную фоновую задачу с 202. Сделайте так — и получите изоляцию деплоя и помаршрутное масштабирование множества сервисов при общей гигиене шлюза одного продукта, без монолита-роутера и без расползания микросервисов. Вынесите сперва один читающий маршрут, освойте цикл деплоя и наращивайте поверхность оттуда.