Первый запрос к свежевыложенной функции зависает на три секунды, пятый и все последующие отрабатывают за тридцать миллисекунд. Через час тишины картина повторяется: снова секунда с лишним на первом обращении. Команда списывает это на особенности облака и живёт дальше, пока медленный старт не съест бюджет ошибок пользовательского API. Между тем холодный старт serverless-функций не природная неизбежность, а составная задача: часть секунд уходит на запуск микровиртуальной машины, часть на инициализацию кода, и каждую часть можно сократить. Ниже разобраны рабочие приёмы, от лёгкого пакета и снимков памяти до провижонированных сред и кэширования, а заодно граница, за которой стандартных настроек действительно достаточно.

Из каких секунд складывается холодный запуск функции

При холодном старте платформа делает три вещи подряд. Выделяет и запускает микровиртуальную машину, у ведущих облаков это легковесный Firecracker, затем скачивает и распаковывает код функции, затем поднимает среду исполнения и выполняет фазу инициализации: импорты модулей, создание клиентов, соединения. Лишь после этого запускается обработчик. Для компактной функции на Node.js или Python вся цепочка занимает десятки или сотни миллисекунд, для Java-функции с тяжёлым фреймворком счёт идёт на секунды, и именно Java-разработчики первыми почувствовали боль.

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

Платформа честно показывает, куда уходят секунды, в журнальной строке отчёта:

REPORT RequestId: 8f5e4a2b Duration: 47.31 ms Billed Duration: 48 ms
Memory Size: 512 MB Max Memory Used: 158 MB Init Duration: 1268.44 ms

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

Лёгкий пакет и ленивые импорты дают самый дешёвый выигрыш

Размер архива с кодом ограничен пятьюдесятью мегабайтами в сжатом виде и двумястами пятидесятью в развёрнутом, контейнерный образ можно раздуть до десяти гигабайт, но платят не мегабайты, а то, что инициализация реально загружает. Тяжёлый ORM, монструозный SDK, фреймворк с автопоиском модулей, всё это исполняется в фазе инициализации на каждом холодном старте. Аудит зависимостей регулярно удивляет: в функцию, обрабатывающую вебхуки оплаты, случайно притащили библиотеку визуализации и половину тестового набора.

Приёмы элементарны. Лишние зависимости вычищаются, сборка JS-проекта прогоняется через tree shaking, общие библиотеки выносятся в слои. Редко используемые тяжёлые импорты переносятся внутрь обработчика:

let heavy = null;

export async function handler(event) {
  heavy = heavy || (await import("heavy-lib"));
  return heavy.transform(event.body);
}

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

Снимок памяти SnapStart превращает долгий запуск в быстрое восстановление

Идея снимка проста: платформа дожидается завершения инициализации, фотографирует состояние памяти и диска и дальше разворачивает новые среды не с нуля, а из готового снимка. Документация Lambda обещает сокращение запуска с нескольких секунд до долей секунды в удачных сценариях. Поддерживаются управляемые среды Java 11 и новее, Python 3.12 и новее, .NET 8 и новее, для Node.js и Ruby механизм пока недоступен. Включается на опубликованных версиях функции, на черновой версии $LATEST работать отказывается.

Включение занимает одну команду:

aws lambda update-function-configuration --function-name orders-api \
  --snap-start ApplyOn=PublishedVersions

Подводные камни вытекают из самой механики. Один снимок обслуживает множество сред, поэтому всё уникальное, созданное в фазе инициализации, идентификаторы, секреты, энтропия генератора случайных чисел, окажется одинаковым во всех копиях. Уникальные значения нужно создавать после восстановления, и для этого предусмотрены хуки перехвата восстановления. Сетевые соединения, открытые в инициализации, состояние не гарантируют: соединения официальных SDK обычно восстанавливаются сами, самодельные TCP-соединения требуют проверки и повторного подключения.

Оплата несимметрична по языкам. Для Java-сред снимок бесплатен, для Python и .NET списываются деньги за хранение кэша снимка, минимум за три часа, и за каждое восстановление среды. Нюанс, который бьёт по бюджету внезапно: у функций со снимком код инициализации вне обработчика тоже входит в оплачиваемую длительность, хотя у обычных функций он бесплатен. Для редко вызываемых функций выгода может не покрыть плату за кэш, документация честно предупреждает, что механизм создан для масштабных вызовов.

Провижонированные среды держат функции тёплыми за постоянную плату

Механизм provisioned concurrency держит заданное число сред заранее инициализированными и готовыми отвечать за десятки миллисекунд, без ожидания даже первого запроса. Настройка отправляется одной командой:

aws lambda put-provisioned-concurrency-config --function-name orders-api \
  --qualifier prod --provisioned-concurrent-executions 5

Плата течёт постоянно: и за удержание прогретых сред, и за каждый вызов. Ради справедливости платят за реальное время работы вызова, а не за минуту простоя, но удержание пяти прогретых сред круглые сутки заметно дороже функций, которые просыпаются по запросу. Поэтому механизм включают адресно: на пиковые часы через автоскейлинг с расписанием, на критичные для бизнеса эндпоинты с жёсткими требованиями к задержке. Документация прямо советует этот путь, когда требования к холодному старту не закрываются снимками. Несовместимость стоит помнить заранее: SnapStart и provisioned concurrency не работают на одной функции одновременно, выбирать придётся. Отдельный нюанс выката: провижонирование привязано к конкретной версии или псевдониму, и после публикации новой версии конфигурацию приходится переносить заново. Если перенос не вписан в пайплайн деплоя, первый же релиз оставит прод без прогретых сред, и эффект исчезнет в самый ответственный момент.

Самодельный прогрев, кронтаб, раз в пять минут дёргающий функцию, встречается до сих пор. Он дешевле, но хрупок: платформа вправе переработать среды при простое между пинками, а прогрев одной версии не прогревает псевдонимы и новые версии после релиза. Такое решение годится для внутреннего инструмента, но не для пользовательского API.

Кэширование внутри функции и рядом с ней экономит и старт, и вызовы

Среда исполнения переживает вызовы, и этим стоит пользоваться. Глобальная область видимости живёт между запросами одной среды: клиенты SDK, загруженная конфигурация, откомпилированные шаблоны, всё, что создано вне обработчика, переиспользуется бесплатно:

import boto3

s3 = boto3.client("s3")          # создаётся один раз на среду
_cache = {}

def handler(event, context):
    key = event["key"]
    if key not in _cache:
        _cache[key] = s3.get_object(Bucket="reports", Key=key)["Body"].read()
    return _cache[key]

Локальный каталог /tmp сохраняется между вызовами той же среды и даёт до десяти гигабайт: туда кэшируют скачанные модели, словари, переводы, тяжёлые справочники. Опора на него требует аккуратности, содержимое ненадёжно, среда может быть пересоздана в любой момент, поэтому алгоритм обязан корректно работать и с пустым каталогом.

Рядом с функцией живут и внешние кэши: управляемый Redis или Memcached для горячих вычислений, корзина S3 для собранных один раз артефактов. Отдельного внимания заслуживают соединения с базой: каждая новая среда открывает собственное соединение, и при всплеске одновременных холодных стартов пул на стороне СУБД вылетает за лимиты. Пуллер вроде PgBouncer в транзакционном режиме или управляемый пул соединений превращают эту проблему в несобытие, а клиент базы, созданный вне обработчика, экономит ещё и рукопожатия на каждый вызов. С плотным кэшированием приходит и новая беда, лавина одновременных промахов: сотня сред срывается в базу за одним ключом сразу после его истечения. Короткое время жизни записи и случайная задержка обновления размывают пик.

Память, время выполнения и язык влияют на старт сильнее, чем кажется

Ползунок памяти в конфигурации функции задаёт не только память, но и долю процессора. Примерно при полутора тысячах мегабайт функция получает целое виртуальное ядро, и дальнейшее увеличение памяти продолжает добавлять вычислительную мощность. Для стартов, упирающихся в процессор, это означает прямое ускорение инициализации, а billed duration сокращается настолько, что суммарный счёт нередко падает вместе с задержкой. Проверка занимает пять минут: поднять память, прогнать нагрузочный тест, сравнить журнал отчётов.

Выбор среды исполнения остаётся самым сильным рычагом, который принимают на старте проекта. Node.js и Python стартуют быстро от природы и обходятся без спецсредств в большинстве сценариев. Java платит за богатый фреймворк долгой инициализацией, зато получает максимальную выгоду от снимков памяти, именно под Java SnapStart и появился в конце 2022 года, а позже добрался до Python и .NET. Компиляция в нативный образ через GraalVM сокращает старт Java-функции радикально, но усложняет сборку и отладку, и подходит не каждому проекту. Разброс внутри одного языка тоже велик: компактная функция на Node.js стартует за десятки миллисекунд, а приложение с десятком тяжёлых зависимостей легко уходит за сотни. Перед включением спецсредств имеет смысл просто перепроверить, не тащит ли код лишнее.

Когда стандартных настроек среды действительно достаточно

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

Разумный порядок действий при работе с холодным стартом выглядит так:

  1. Измерить реальную длительность инициализации по журнальной строке отчёта, а не по ощущениям;
  2. Проверить вес пакета и вычистить случайные зависимости, перенеся тяжёлые импорты в обработчик;
  3. Вынести создание клиентов SDK, конфигурацию и соединения из обработчика в глобальную область;
  4. Для Java, Python 3.12 или .NET при задержках в секунды включить снимки памяти и обработать ловушки уникальности и соединений;
  5. Для эндпоинтов с жёсткими требованиями к задержке выделить провижонированные среды с автоскейлингом по расписанию;
  6. Для редко вызываемых функций оставить стандартные настройки и не платить за тепло, которое никому не нужно.

Холодный старт перестаёт быть страшилкой, когда разобран по составляющим. Дешёвые приёмы, лёгкий пакет, ленивые импорты, переиспользование клиентов, закрывают большую часть боли бесплатно, снимки памяти и провижонированные среды добивают остаток там, где задержка действительно критична. Авторская позиция проста: сначала измерение, потом самый дешёвый приём, и только затем дорогой; стандартные настройки среды не приговор, но и не оправдание, чтобы игнорировать секунду ожидания на каждом первом запросе.