Lambda вглубь: холодные старты, конкурентность и источники событий
Вызов Lambda — фаза INIT (холодный старт), затем INVOKE; переиспользование окружений выносит клиентов в module scope. Конкурентность аккаунта ограничена 1000; reserved и гарантирует, и ограничивает, а runaway-функция душит всех. Источник задаёт ретраи.
Твой checkout-API работает на Lambda и в демо выглядит идеально. Потом начинается флеш-распродажа: трафик за девяносто секунд вырастает в 20 раз, и дашборд загорается 429 TooManyRequestsException и всплесками p99-латентности, которые не сходятся ни с одним медленным запросом. Пока ты в это вглядываешься, дежурный отчётного сервиса пишет тебе — их ночной экспорт тоже троттлит, а они ничего не трогали. Оказывается, третья команда в 16:00 выкатила runaway-функцию, которая тихо съела почти весь пул из 1000 одновременных выполнений аккаунта, а у твоего checkout-пути не было зарезервированной ёмкости, поэтому он первым встал в очередь на голодание. Баг не в твоём коде. Он в том, что никто на аккаунте не понял модель выполнения и конкурентности Lambda — откуда берутся холодные старты, как делится пул из 1000 слотов и какие источники событий ретраят, а какие теряют. Ошибись в этих трёх вещах — и Lambda подведёт тебя ровно тогда, когда трафик докажет твою правоту.
Модель выполнения: INIT, INVOKE и почему DB-клиент живёт в module scope
Прежде чем рассуждать о холодных стартах, троттлинге шумного соседа или контрактах ретраев источников событий, нужна одна ментальная модель: что на самом деле происходит внутри Lambda между «событие пришло» и «твой код запустился». Всё остальное в этом уроке — следствие этой модели.
Вызов Lambda не стартует с нуля каждый раз. Lambda запускает твой код внутри окружения выполнения — микро-VM, изолированной Firecracker (опенсорсный гипервизор микро-VM от AWS), — и у этого окружения две фазы. Фаза INIT выполняется один раз при создании окружения: она скачивает деплой-пакет, поднимает рантайм языка и исполняет всё на уровне модуля — твои импорты, конструирование SDK-клиента, пул соединений с БД. Затем фаза INVOKE запускает функцию-обработчик против реального события. Первый запрос, попавший в свежее окружение, платит полную стоимость INIT от начала до конца; это и есть холодный старт. Каждый следующий запрос переиспользует то же тёплое окружение, полностью пропуская INIT и выполняя только обработчик.
Это важнейший факт о рантайме, потому что он диктует, куда класть дорогую инициализацию. Код на уровне модуля выполняется один раз на окружение; код внутри обработчика — один раз на запрос. Поэтому клиент базы данных, SDK-клиенты и пулы соединений ты конструируешь на уровне модуля — они переживают вызовы и переиспользуются бесплатно. Помести это конструирование внутрь обработчика — и будешь платить за открытие нового соединения на каждом запросе, что и жжёт латентность, и под нагрузкой исчерпывает лимит соединений базы.
// ✅ уровень модуля — выполняется ОДИН РАЗ на окружение при INIT, переиспользуется в каждом тёплом вызове
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME;
export const handler = async (event) => {
// ❌ НЕ строй клиент здесь — это выполнялось бы на каждый запрос
const { Item } = await ddb.send(
new GetCommand({ TableName: TABLE, Key: { id: event.id } })
);
return Item ?? null;
};Цифры задают ожидания. Тёплый вызов добавляет лишь однозначное число миллисекунд накладных расходов — по сути, только диспетчеризацию функции. Холодный старт — это стоимость INIT, обычно десятки–несколько сотен миллисекунд для лёгкой Node/Python-функции и существенно хуже для тяжёлых деревьев зависимостей или рантаймов JVM/.NET, которым нужно делать JIT и грузить большие фреймворки (сотни мс — пара секунд). Режим отказа — неверно прочитать график латентности: если твой p50 прекрасен, а p99 уродлив, ты почти наверняка смотришь на холодные старты на длинном хвосте — те запросы, которым не повезло попасть в свежеподнятое окружение, — а не на медленный downstream. Лечишь это атакой на INIT, а не на обработчик.
Убить (или спрятать) холодный старт: provisioned concurrency, SnapStart, размер пакета и штраф VPC
Сделать INIT мгновенным бесплатно нельзя; каждое смягчение торгует деньгами, сложностью сборки или поддержкой рантайма. Provisioned concurrency предварительно инициализирует фиксированное число окружений и держит их тёплыми и готовыми — INIT уже отработал, поэтому запросы, направленные на них, не имеют холодного старта. Трейдофф прямой: ты платишь почасовую ставку за эти окружения независимо от того, обслуживают они трафик или нет, поэтому это оправдано для чувствительных к латентности путей с предсказуемой базовой нагрузкой (checkout-API в известные рабочие часы) и расточительно для всплесковых, непредсказуемых или малотрафиковых функций. SnapStart идёт другим путём: Lambda выполняет INIT один раз, делает снимок всего инициализированного состояния памяти (снапшот микро-VM Firecracker) и восстанавливает его для каждого нового окружения вместо повторного INIT — резко срезая холодные старты на поддерживаемых рантаймах (Java, а теперь Python и .NET) без дополнительной почасовой платы, хотя инициализацию надо делать идемпотентной, потому что снапшоты переиспользуются.
Более дешёвые рычаги атакуют INIT напрямую. Урезание деплой-пакета и ленивая загрузка редко используемых модулей сокращают время скачивания и бутстрапа — функция в 5 МБ стартует холодным быстрее, чем в 250 МБ, тащащая весь AWS SDK и headless-браузер. И классическая ловушка: Lambda, подключённая к VPC. Чтобы достучаться до приватных ресурсов (инстанс RDS, внутренний сервис), Lambda присоединяет Hyperplane ENI к твоему VPC. Исторически это присоединение добавляло секунды к первому холодному старту; AWS переархитектурировала это (общие Hyperplane ENI, создаваемые на этапе конфигурации функции), так что поинвокационный штраф теперь мал, но VPC-функции всё ещё несут больше веса в инициализации, чем не-VPC, и тянуться к VPC стоит, только когда тебе действительно нужна приватная связность.
▸Почему это работает
Память и CPU связаны, и это переворачивает очевидную интуицию о стоимости. Память Lambda настраивается от 128 МБ до 10 240 МБ, но этот ползунок покупает не только RAM — CPU масштабируется линейно вместе с ним. Lambda выделяет пропорциональный vCPU, пересекая примерно одно полное vCPU около 1769 МБ и достигая нескольких vCPU на пределе. Ты платишь за ГБ-миллисекунду, поэтому удвоение памяти удваивает цену за миллисекунду — но для CPU-bound функции (ресайз картинки, сжатие, парсинг) больше CPU завершает работу быстрее, и задача, выполняющаяся вдвое быстрее при вдвое большей цене, может стоить столько же или меньше, вдвое срезав латентность. Режим отказа — рефлекс выставить память низкой «для экономии» на CPU-тяжёлой функции: ты морит её голодом по CPU, она работает медленно, и ты платишь за больше миллисекунд по низкой ставке, чем заплатил бы по высокой. Всегда профилируй стоимость по размерам памяти — самая дешёвая настройка редко самая маленькая.
Конкурентность: общий пул 1000, reserved против provisioned и троттл шумного соседа
Конкурентность — это число выполнений, идущих в один и тот же момент, а не запросов в секунду. Если каждый запрос занимает 100 мс, а к тебе приходит 100 запросов/с, нужно ~10 одновременных выполнений; те же RPS при односекундных запросах требуют ~100. Каждый регион в твоём аккаунте стартует с лимита конкурентности по умолчанию 1000 одновременных выполнений — мягкого лимита, который можно поднять заявкой в саппорт, но реального потолка, пока ты этого не сделал. Есть также burst-лимит, управляющий тем, как быстро может нарастать конкурентность (несколько тысяч мгновенно, затем ровная скорость масштабирования), поэтому вертикальная стена трафика может обогнать поднятие ёмкости даже ниже 1000.
Эта 1000 — общий пул на все функции аккаунта, и вот где живёт war-story. Reserved concurrency делает две вещи разом: она гарантирует функции выделенный кусок пула (так что она всегда может масштабироваться до этого числа) и она ограничивает эту функцию этим числом (она никогда не сможет его превысить). Функция без reserved-конкурентности черпает из оставшегося незарезервированного пула — и конкурирует за него с каждой другой незарезервированной функцией. Поэтому когда одна runaway-функция (шторм ретраев, случайная рекурсия, сорвавшийся fan-out) поглощает незарезервированный пул, каждая другая незарезервированная функция в аккаунте начинает троттлиться с 429 / TooManyRequestsException — ровно тот межкомандный радиус поражения из Hook. Фикс двойной: поставь reserved-конкурентность как кэп на плохую/недоверенную функцию, чтобы она не съела больше своей доли, и зарезервируй гарантированный пол для критичных путей вроде checkout, чтобы их никогда не морил голодом сосед.
# SAM/CloudFormation: зарезервировать гарантированный и ограниченный кусок для checkout,
# и прогреть часть из него provisioned concurrency, чтобы убрать холодные старты.
CheckoutFunction:
Type: AWS::Serverless::Function
Properties:
Handler: index.handler
MemorySize: 1769 # ~1 vCPU; профилируй стоимость по размерам
Timeout: 10
ReservedConcurrentExecutions: 200 # гарантирует 200 И ограничивает до 200
ProvisionedConcurrencyConfig:
ProvisionedConcurrentExecutions: 50 # 50 всегда тёплых, без холодного стартаЗаметь различие, которое кодирует этот сниппет. Reserved-конкурентность — про то, какой долей пула 1000 владеет эта функция — она ничего не стоит дополнительно, лишь разбивает общий лимит. Provisioned-конкурентность — про держание окружений тёплыми — она стоит денег и убирает холодные старты. Они ортогональны: можно резервировать без provisioning (гарантировать ёмкость, принять холодные старты) или зашить часть зарезервированной ёмкости (обычная продакшен-форма выше). Режим отказа — зарезервировать 100% пула одной функции и оставить всё остальное с нулевой незарезервированной ёмкостью — теперь каждая другая функция троттлится мгновенно. Всегда оставляй запас (AWS даже принудительно держит минимальный незарезервированный буфер).
Источники событий и их контракты ретраев: sync, async и poll-based
Способ, которым функция вызывается, решает, что произойдёт при её отказе — и команды рутинно теряют события, предположив неверный контракт.
Синхронные источники (API Gateway, ALB, прямой Invoke) блокируются на ответе: вызывающий ждёт, получает результат или ошибку, и Lambda не делает встроенных ретраев. Ретрай и таймаут — проблема вызывающего: твоего клиента или интеграционного таймаута API Gateway (29 с). Троттл здесь всплывает немедленно как 429 пользователю, поэтому именно чувствительные к латентности sync-пути ты защищаешь reserved + provisioned concurrency.
Асинхронные источники (события S3, SNS, EventBridge) отдают событие во внутреннюю очередь Lambda и возвращаются сразу; вызывающий никогда не видит твой результат. Затем Lambda ретраит упавший вызов — по умолчанию ещё два раза (всего три попытки) с бэкоффом — и если все попытки провалились, событие уходит в твою настроенную on-failure destination или dead-letter queue (DLQ). Нет DLQ и нет destination — значит, перманентно падающее async-событие молча отбрасывается после ретраев — классический инцидент «куда делся мой триггерящийся от S3 джоб?».
Poll-based источники (SQS, Kinesis, DynamoDB Streams) работают снова иначе: сам сервис Lambda опрашивает источник, собирает записи в батч и вызывает твою функцию с этим батчем. Вот где важны BatchSize, MaximumBatchingWindow, partial-batch-response (возвращай batchItemFailures, чтобы ретраились только упавшие записи, а не весь батч) и упорядоченность — Kinesis и DynamoDB Streams обрабатывают записи по шарду по порядку, и запись-«отравленная пилюля» может застопорить шард, пока она не устареет или ты её не обработаешь. У SQS standard упорядоченности нет, и он перенаправляет отказы через собственную redrive-политику очереди в SQS DLQ.
// Обработчик SQS poll-based с partial-batch-response: сообщаем только о
// упавших записях, чтобы SQS ретраил только их, а не весь батч.
export const handler = async (event) => {
const batchItemFailures = [];
for (const record of event.Records) {
try {
await process(JSON.parse(record.body));
} catch (err) {
// отправить эту запись в redrive; остальные подтверждены
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
return { batchItemFailures }; // требует ReportBatchItemFailures на маппинге
};Два финальных жёстких лимита ограничивают любой дизайн здесь: таймаут функции максимум 15 минут (900 с), а память до 10 240 МБ с CPU, масштабирующимся вместе с ней. Задача, которой нужно больше 15 минут, не место на Lambda — это та стена, что выталкивает долго работающую работу на Fargate.
Пользовательский checkout-API на Lambda имеет стабильную, предсказуемую базовую нагрузку в рабочие часы и строгий SLA по p99-латентности. Холодные старты на длинном хвосте срывают SLA. Какое смягчение подходит лучше всего?
Lambda, триггерящаяся от S3 и ресайзящая картинки, после плохого деплоя начинает падать на каждом вызове, и команда замечает, что ресайзнутые файлы просто… перестают появляться, без ошибок, всплывающих хоть какому-то вызывающему. Почему события исчезают?
- 01Пройди по двум фазам вызова Lambda и объясни, где именно должен конструироваться клиент базы данных и почему.
- 02Объясни, как взаимодействуют пул конкурентности аккаунта, reserved-конкурентность и provisioned-конкурентность, и как одна функция может затроттлить весь аккаунт.
Вызов Lambda выполняется внутри переиспользуемого окружения выполнения Firecracker с двумя фазами: фаза INIT выполняется раз на окружение — скачивая пакет, запуская рантайм и исполняя код уровня модуля — а фаза INVOKE запускает обработчик на каждом запросе. Первый запрос в свежем окружении платит полную стоимость INIT (холодный старт, десятки–несколько сотен мс, хуже для тяжёлых зависимостей или JVM/.NET), тогда как тёплые переиспользования пропускают INIT и добавляют лишь однозначные мс накладных расходов — вот почему DB- и SDK-клиенты живут на уровне модуля, а не внутри обработчика. Холодные старты прячут или сжимают через provisioned concurrency (прогретые окружения, оплата почасово, под предсказуемую чувствительную к латентности нагрузку), SnapStart (снимок-и-восстановление инициализированного состояния на поддерживаемых рантаймах, без почасовой платы), урезание пакета и ленивую загрузку, и избегая VPC, пока тебе по-настоящему не нужна приватная связность (штраф Hyperplane ENI, теперь малый, но ненулевой). Конкурентность — это одновременные выполнения, ограниченные на регион мягкой 1000 по умолчанию, общей на аккаунт: reserved-конкурентность и гарантирует, и ограничивает долю функции и ничего не стоит, provisioned-конкурентность держит окружения тёплыми и стоит денег, а одна runaway-функция без кэпов может осушить незарезервированный пул и затроттлить каждую другую функцию в аккаунте с 429. Наконец, источник события задаёт контракт отказа — синхронный (API Gateway/ALB) не имеет встроенных ретраев и всплывает ошибки вызывающему, асинхронный (S3/SNS/EventBridge) ретраит дважды, затем маршрутизирует в DLQ или отбрасывает событие, если ничего не настроено, а poll-based (SQS/Kinesis/DynamoDB Streams) — сервис Lambda батчит записи с partial-batch-response и последствиями упорядоченности по шарду — всё под жёсткими потолками таймаута в 15 минут, 10 ГБ памяти и CPU, линейно масштабирующегося с памятью, так что CPU-bound функция может подешеветь, став больше. Теперь, когда увидишь всплеск p99-латентности на Lambda, который не вяжется ни с одним медленным запросом к downstream, твоя первая гипотеза — холодные старты: смотри на хвост метрики длительности INIT, а не обработчика, прежде чем трогать что-либо ещё.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.