Serverless (буквально «без серверов») — модель, где вы отдаёте облаку код, а оно само решает, где и когда его запустить. Серверы существуют, но вы про них не думаете: не выбираете машину, не следите за загрузкой, не платите за простой. Платите за фактические вызовы. Разберём, как это устроено в Azure. Если вы ещё не выбирали, где запускать сервис, — Azure Functions это один из вариантов оттуда, здесь мы копаем его глубже.

функция HTTP вызов и ответ очередь Service Bus сообщение файл в Blob Storage загрузка таймер расписание база или другая очередь выходная привязка

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

Обязательно

Azure Functions

Azure Functions — функция как единица развёртывания. Вы пишете обработчик (на C#, Java, Python, Node.js и др.), загружаете — и всё. Ни машины, ни контейнера: облако поднимает окружение под вызов и гасит, когда обращений нет. Одна инфраструктурная деталь всё же есть, и о ней обычно узнают неприятным способом: приложению функций обязательно нужна учётная запись хранилища (параметр AzureWebJobsStorage). В ней живут состояние триггеров, блокировки таймеров и служебные очереди — без неё приложение не стартует, а её удаление роняет боевой сервис.

Функция всегда устроена одинаково: пришло событие — функция отработала — вернула результат. Она не «крутится» постоянно, а оживает на каждый вызов. Аналог AWS Lambda.

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

Триггеры: что запускает функцию

Функцию нужно чем-то вызвать — это триггер. В Azure функцию можно повесить на разные события:

  • HTTP — вызов по сети (напрямую или через API Management);
  • Queue Storage / Service Bus — пришло сообщение в очередь — функция его обработала;
  • Blob Storage — в хранилище загрузили файл — функция сработала;
  • Timer — по расписанию (cron);
  • Event Grid / Event Hubs — реакция на события платформы и потоки данных.

Триггеры делают serverless удобным для событийной обработки: вы не пишете цикл «проверь, не появилось ли новое», а говорите «на такое событие запусти вот эту функцию». Рядом есть привязки (bindings) — декларативный способ получить вход и записать выход (например, «прочитать из очереди, записать в таблицу») без ручного кода подключения.

Durable Functions

Обычная функция коротка и без состояния. Когда нужен процесс из нескольких шагов с ожиданием и состоянием — оформление заказа, цепочка обработки, — есть Durable Functions: расширение, которое позволяет описывать оркестрацию (последовательности, ветвления, ожидания) в коде, а состояние между шагами Azure хранит за вас. Это ответ на задачи, где иначе пришлось бы вручную городить машину состояний.

Платят за это необычным устройством кода. Функцию-оркестратор Azure переигрывает с самого начала на каждом шаге, подставляя уже известные результаты, поэтому она обязана вести себя одинаково при каждом прогоне: никакого «взять текущее время», случайных чисел и прямых обращений по сети прямо в оркестраторе. Всё это выносят в обычные шаги (activity), которые вызываются из оркестратора. Забыли — процесс на повторе пойдёт другой дорогой, и поймать это тяжело.

API Management

Когда функций и сервисов много, каждый внешний потребитель должен знать десять адресов, а проверку ключей и лимиты приходится повторять в каждой функции. Поэтому перед ними ставят API Management — единый шлюз, который берёт на себя маршрутизацию, авторизацию, ограничение частоты запросов и версионирование API: снаружи одна точка, внутри сколько угодно функций. Типичная бессерверная связка: API Management принимает запросы и направляет их в Azure Functions или Container Apps, а те читают и пишут в Cosmos DB или Blob Storage, а фоновые задачи идут через Service Bus.

За что именно платят

«Платите за фактические вызовы» — половина правды, и вторая половина влияет на решения. В плане потребления счёт складывается из двух частей:

  • Время работы, посчитанное в гигабайт-секундах: выделенная (точнее, фактически использованная, округлённая вверх) память, умноженная на длительность выполнения. Это основная часть счёта.
  • Число вызовов — сама по себе цена вызова невелика.

Плюс постоянный бесплатный уровень (порядка миллиона вызовов и 400 тысяч гигабайт-секунд в месяц), в который мелкие фоновые функции укладываются целиком.

Главный практический вывод из формулы: медленная функция дорожает линейно. Функция, которая выполняется 2 секунды вместо 200 миллисекунд, стоит в десять раз дороже при том же числе вызовов. Отсюда и приоритеты оптимизации: сначала длительность (лишний сетевой вызов, отсутствие кэша, тяжёлая инициализация), потом память, потом всё остальное. И отсюда же неприятность: функция, которая ждёт ответа внешнего сервиса, платит за ожидание — то есть медленный сосед стоит вам денег, а не только задержки.

Второй вывод: при ровном постоянном трафике функции перестают быть дешёвыми. Прикидка простая: если функция занята больше половины времени, стоимость сравнивается с постоянно работающим контейнером, а потом обгоняет его. Поэтому «лёгкий API на функциях» — правильный ответ для редких и неровных обращений и спорный для сервиса с постоянным потоком запросов; там дешевле и предсказуемее Container Apps или план приложений.

Что происходит при ошибке

Для событийной обработки это половина темы, и правила зависят от триггера.

Триггер очереди (Storage Queue, Service Bus). Функция бросила исключение — сообщение не удаляется и вернётся снова после истечения времени видимости. Число попыток ограничено: по умолчанию около пяти, после чего сообщение уезжает в очередь недоставленных (у хранилища это отдельная очередь с суффиксом -poison, у служебной шины — встроенная очередь $deadletterqueue).

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

Триггер HTTP. Повторов нет: ошибка возвращается вызывающему, и что делать дальше — его забота.

Триггер по расписанию. Пропущенный запуск по умолчанию не догоняется; настройка «запустить при пропуске» существует, и включать её надо осознанно — иначе после простоя функция выполнится за все пропущенные интервалы.

Триггер потока событий (Event Hubs) работает иначе: смещение фиксируется по пачке, и ошибка означает повторную обработку всей пачки после перезапуска. Поэтому обработчик пачки пишут так, чтобы повтор был безопасен для каждого элемента.

И общая рекомендация: ошибки логируют с идентификатором сообщения и числом попыток. Без этого разбор очереди недоставленных превращается в угадывание, какая из попыток была последней.

Где это применяется

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

Типичные ошибки новичков:

  • тянут в функцию тяжёлую и долгую логику — упираются в лимиты;
  • не учитывают холодный старт в чувствительном к задержке API;
  • хранят состояние в памяти функции — оно теряется между вызовами;
  • делают из функций монолит — разросшуюся логику пора переносить в контейнер.
Дополнительно: при первом чтении можно пропустить

Глубже: как устроен параллелизмрасширенное

Одна особенность, которая отличает функции Azure от аналогов и объясняет часть неожиданных ошибок: один экземпляр обслуживает несколько вызовов одновременно.

Это не «один вызов — один процесс». Платформа поднимает экземпляр, и внутри него функция вызывается параллельно столько раз, сколько позволяют настройки (для очередей это число сообщений в пачке, для HTTP — свои пределы). Только когда экземпляр перестаёт справляться, добавляется следующий.

Отсюда три следствия, которые видно в коде.

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

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

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

Глубже: привязки: чем функции отличаются от обработчикарасширенное

Привязки (bindings) названы одной фразой, а это как раз то, что отличает функции от обычного веб-обработчика: входные и выходные данные описываются декларативно, а платформа берёт на себя работу с сервисом.

@FunctionName("processOrder")
@QueueOutput(name = "audit", queueName = "order-audit", connection = "STORAGE")
public String run(
        @QueueTrigger(name = "msg", queueName = "orders", connection = "STORAGE") String message,
        @BlobInput(name = "template", path = "templates/receipt.html", connection = "STORAGE") String template,
        final ExecutionContext context) {

    var order = parse(message);
    return render(template, order);      // возвращённое значение уедет в очередь order-audit
}

Родной среды выполнения для Go у Functions нет: бинарник запускают как пользовательский обработчик (custom handler). Привязки те же, только описаны в function.json рядом с бинарником (queueTrigger на orders, blob на templates/receipt.html, queue на order-audit), а функция получает их HTTP-запросом с полем Data и отвечает полем Outputs:

func processOrder(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Data struct{ Msg, Template string }
	}
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	order := parse(in.Data.Msg)
	json.NewEncoder(w).Encode(map[string]any{
		"Outputs": map[string]string{"audit": render(in.Data.Template, order)}, // уедет в очередь order-audit
	})
}

В модели программирования v4 привязки объявляют кодом, без function.json:

const { app, input, output } = require('@azure/functions');

const template = input.storageBlob({ path: 'templates/receipt.html', connection: 'STORAGE' });
const audit = output.storageQueue({ queueName: 'order-audit', connection: 'STORAGE' });

app.storageQueue('processOrder', {
  queueName: 'orders',
  connection: 'STORAGE',
  extraInputs: [template],
  return: audit,                                   // возвращённое значение уедет в очередь order-audit
  handler: (message, context) => render(context.extraInputs.get(template), parse(message)),
});

В модели v2 привязки объявляют декораторами:

import azure.functions as func

app = func.FunctionApp()


@app.queue_trigger(arg_name="msg", queue_name="orders", connection="STORAGE")
@app.blob_input(arg_name="template", path="templates/receipt.html", connection="STORAGE")
@app.queue_output(arg_name="audit", queue_name="order-audit", connection="STORAGE")
def process_order(msg: func.QueueMessage, template: str, audit: func.Out[str]) -> None:
    order = parse(msg.get_body().decode())
    audit.set(render(template, order))      # уедет в очередь order-audit

Что здесь происходит без вашего кода: сообщение прочитано из очереди и разобрано, файл шаблона прочитан из хранилища, а возвращённое значение записано в другую очередь. Ни одного клиента, ни одной строки про подключение — только имена и настройки соединения.

Когда это удобно: простые связки «взял оттуда — положил туда», которых в событийной обработке большинство. Когда мешает: нужна тонкая работа с сервисом (пакетные операции, повторы по своим правилам, транзакции) — тогда берут обычный клиент из библиотеки и пишут руками. Смешивать можно: триггер привязкой, а запись — клиентом.

Две практические детали. Строка соединения указывается именем настройки, а не значением — и это правильно: в приложении лежит имя, а значение приходит из настроек среды или из хранилища секретов. И привязки описываются либо аннотациями (как выше), либо файлом рядом с функцией — набор доступных привязок зависит от версии среды выполнения, и это стоит сверять с документацией, а не с примерами из интернета.

За удобство serverless платят двумя особенностями.

Холодный старт (cold start). Если функцию давно не вызывали (на тарифе с оплатой по потреблению), окружение погашено, и первый вызов ждёт, пока оно поднимется, — лишние сотни миллисекунд, для Go меньше, а для JVM с большим пакетом зависимостей и секунды: время зависит от языка, размера пакета и того, подключена ли функция к виртуальной сети. Последующие «тёплые» вызовы быстры. Для фоновых задач не важно, для чувствительного к задержке API — важно. Лечится планом, где часть экземпляров держат наготове: это Premium или Flex Consumption — сегодня рекомендованный бессерверный план, в котором масштабирование до нуля, всегда готовые экземпляры и работа во внутренней сети собраны вместе.

Лимиты. У функции ограничены время работы и память, и здесь важны конкретные числа. На плане с оплатой по потреблению вызов по умолчанию обрывается через 5 минут, и поднять этот предел (functionTimeout в host.json) можно максимум до 10 минут; на Premium по умолчанию 30 минут, и там предел снимается совсем. Отдельно живёт ограничение в 230 секунд: столько ждёт ответа балансировщик, стоящий перед приложением, и на HTTP-вызов оно действует независимо от того, что вы написали в настройках. Функция может ещё работать, а вызывающий уже получил ошибку — отсюда вечное «почему обрывается, не дотянув до четырёх минут». Вывод простой: долгую работу по HTTP не делают — запрос принимают, кладут задачу в очередь и отвечают сразу. Долгие тяжёлые вычисления — вообще не про Functions; для такого берут контейнеры или машины. И функция stateless: любое состояние держат во внешнем хранилище — Cosmos DB, управляемой базе или Blob Storage.

Глубже: потолок масштабирования и база за функциейрасширенное

Две связанные вещи, о которых узнают в первый серьёзный всплеск нагрузки.

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

Взрывное масштабирование убивает то, что стоит за функцией. Сто экземпляров, в каждом по десять параллельных вызовов, в каждом вызове соединение с базой — и база получает тысячу соединений вместо обычных двадцати. Итог: база отказывает всем, включая остальные сервисы, которые с ней работают. Функция при этом «работает правильно» — она честно масштабируется под нагрузку.

Что с этим делают, в порядке применения:

  1. Ограничить число экземпляров функции (настройка предела масштабирования). Это самый прямой и надёжный способ поставить потолок нагрузке на базу.
  2. Ограничить параллелизм внутри экземпляра — сколько сообщений обрабатывается одновременно (настройки триггера: размер пачки и число параллельных вызовов).
  3. Держать маленький пул — одно-два соединения на экземпляр, а не «двадцать, как в обычном приложении».
  4. Поставить пулер перед базой (у управляемого PostgreSQL в Azure есть встроенный) — он держит соединения к базе и отдаёт их функциям.
  5. Разорвать связь очередью. Если функция обрабатывает поток событий, правильный ответ часто не в настройках, а в архитектуре: функция складывает работу в очередь, а обработчик с ограниченным параллелизмом разгребает её в темпе, который база выдерживает.

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

Коротко

  • Azure Functions оживают на событие и гаснут: удобны для обработчиков загрузок, вебхуков, задач по расписанию и лёгких API, но не для того, что должно быть включено постоянно.
  • Триггеры: HTTP, очереди Storage и Service Bus, файл в Blob Storage, таймер, Event Grid и Event Hubs; привязки декларативно берут вход и пишут выход без клиентов в коде.
  • Durable Functions описывают многошаговый процесс с состоянием, а оркестратор переигрывается с начала на каждом шаге и обязан быть детерминированным; API Management ставят перед десятком функций как единый шлюз.
  • Платят за гигабайт-секунды и вызовы: медленная функция дорожает линейно, а при ровной нагрузке выше половины времени функции дороже контейнера.
  • Один экземпляр обслуживает несколько вызовов параллельно: клиенты и пулы создают один раз на класс, статические поля это общая память, пул считают на экземпляр.
  • Ошибка в триггере очереди возвращает сообщение до исчерпания попыток и очереди недоставленных, HTTP не повторяет, таймер по умолчанию не догоняет пропуски, Event Hubs повторяет всю пачку: обработчик идемпотентен, а логи содержат идентификатор и номер попытки.
  • Холодный старт в плане потребления лечат заранее прогретыми экземплярами или другим планом; вызов обрывается через пять минут по умолчанию.
  • У масштабирования есть потолок, и его уменьшают осознанно: предел экземпляров, параллелизм внутри экземпляра, маленький пул, пулер перед базой и очередь между функцией и обработчиком.

Что почитать дальше