Azure Functions: serverless

Serverless — это не «серверов нет», а «сервер не ваш»: вы пишете одну функцию, а платформа сама решает, когда и на скольких машинах её запустить.

Azure Functions — сервис для запуска небольших кусочков кода (функций) по событию: пришёл HTTP-запрос, сработал таймер, появилось сообщение в очереди. Вы платите не за арендованное время сервера, а за фактические вызовы и потреблённые ресурсы.

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

Зачем это на практике

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

Триггер — сердце функции

Триггер (trigger) — событие, которое запускает функцию. У функции ровно один триггер. Дополнительно можно объявить биндинги (bindings) — декларативные «провода» к данным: входной биндинг подтягивает данные до вызова, выходной сам записывает результат, и вам не нужен SDK внутри кода.

ТриггерКогда срабатываетТипичное применение
HTTPПришёл HTTP-запросAPI, вебхуки
TimerПо расписанию (NCRONTAB)Ночная уборка, отчёты
Queue / Service BusВ очереди появилось сообщениеАсинхронная обработка задач
BlobЗагружен файл в хранилищеПревью, антивирус, парсинг
Cosmos DBИзменилась запись в базеРеакция на изменения данных

Вот все три главных триггера в одном файле — это питоновская модель v2, где функции объявляются декораторами. Код использует пакет azure-functions, поэтому запустить его в браузере нельзя — он живёт в проекте функции:

import logging
import azure.functions as func

app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)


@app.route(route="hello")
def hello(req: func.HttpRequest) -> func.HttpResponse:
    name = req.params.get("name", "мир")
    return func.HttpResponse(f"Привет, {name}!", status_code=200)


# 6 полей! Первое — СЕКУНДЫ. Это «каждые 5 минут», а не «каждые 5 часов».
@app.timer_trigger(schedule="0 */5 * * * *", arg_name="timer", run_on_startup=False)
def cleanup(timer: func.TimerRequest) -> None:
    if timer.past_due:
        logging.warning("Таймер опоздал")
    logging.info("Чищу временные файлы")


@app.queue_trigger(arg_name="msg", queue_name="orders", connection="AzureWebJobsStorage")
def process_order(msg: func.QueueMessage) -> None:
    order_id = msg.get_body().decode("utf-8")
    logging.info("Обрабатываю заказ %s (попытка %s)", order_id, msg.dequeue_count)

Разберём по строкам. FunctionApp — контейнер, в котором регистрируются функции. http_auth_level=FUNCTION означает, что вызывать HTTP-функцию можно только с ключом в параметре ?code=...; поставьте ANONYMOUS, если эндпоинт должен быть публичным. msg.dequeue_count — счётчик попыток: если сообщение уже читали трижды, скорее всего, оно «ядовитое» и роняет обработчик.

Локальная разработка и деплой

Функции пишут и отлаживают локально через Azure Functions Core Tools — тот же самый хост, что и в облаке, только на вашей машине.

func init MyFuncProj --python
cd MyFuncProj
func start     # http://localhost:7071/api/hello

Функциям обязательно нужен storage account — там хост хранит служебное состояние: отметки таймеров, лизы для распределения нагрузки, очереди повторов. Без него приложение-функция просто не создастся.

az storage account create \
  --name stfndemo42 \
  --resource-group rg-demo \
  --location westeurope \
  --sku Standard_LRS

az functionapp create \
  --resource-group rg-demo \
  --name fn-demo-42 \
  --storage-account stfndemo42 \
  --consumption-plan-location westeurope \
  --runtime python \
  --runtime-version 3.11 \
  --functions-version 4 \
  --os-type Linux

func azure functionapp publish fn-demo-42

Сколько это стоит

В плане Consumption счёт складывается из двух частей: числа выполнений и «GB-секунд» — потреблённой памяти, умноженной на время работы. Плюс щедрый бесплатный грант каждый месяц (не только в trial-периоде): порядка 1 000 000 выполнений и 400 000 GB-секунд. Сверх гранта — центы: около 0,20 $ за миллион выполнений.

Прикинем: функция работает 200 мс и потребляет 128 МБ. Один вызов — это 0,2 с × 0,125 ГБ = 0,025 GB-с. Даже 100 000 вызовов в месяц дают 2 500 GB-с — это внутри бесплатного гранта. Для пет-проектов и небольших вебхуков serverless фактически бесплатен.

ПланОплатаХолодный старт
ConsumptionЗа вызовы и GB-с, простой бесплатенЕсть
Flex ConsumptionЗа вызовы + «всегда готовые» инстансыПочти нет
PremiumЗа прогретые инстансы (постоянно)Нет
Dedicated (App Service Plan)За план, как у обычного веб-приложенияНет (при Always On)

Как это работает

Когда функция не вызывается, ваш код нигде не запущен — инстансов ноль. За событиями следит служебный компонент платформы (scale controller): он смотрит на длину очереди, поток HTTP-запросов, время таймеров и решает, сколько инстансов поднять. В Consumption их может стать до двухсот, и всё это без единой строчки настройки с вашей стороны.

Отсюда и берётся холодный старт (cold start): первому запросу после простоя приходится ждать, пока платформа выделит worker, скачает пакет с вашим кодом, поднимет контейнер, запустит рантайм (Python, .NET, Node) и импортирует модули. Это от нескольких сотен миллисекунд до нескольких секунд — на Python и Java обычно дольше, чем на .NET. После простоя (порядка 20 минут без вызовов) инстанс гасится, и всё повторяется.

Что с этим делать:

  • Держать функцию лёгкой: тяжёлые импорты и создание клиентов (к БД, к HTTP) выносить на уровень модуля, чтобы они выполнялись один раз при инициализации, а не при каждом вызове.
  • Не тащить в зависимости всё подряд: чем толще пакет, тем дольше он разворачивается.
  • Если задержка критична (пользователь ждёт ответа) — переходить на Flex Consumption или Premium с прогретыми инстансами. Это уже фиксированная плата, но холодного старта нет.
  • Если задержка не критична (обработка очереди, ночной отчёт) — просто игнорировать её. Холодный старт страшен только там, где кто-то смотрит на спиннер.

Частые ошибки

  • Cron из Linux скопировали как есть. В NCRONTAB шесть полей, первое — секунды. Строка */5 * * * * (пять полей) означает совсем не то, что вы думаете, а 0 */5 * * * * — «каждые 5 минут».
  • Удалили storage account. Функции перестанут работать и исчезнут из портала: там жило служебное состояние хоста.
  • Функция обработала сообщение дважды. Очереди дают гарантию at-least-once, при таймауте сообщение вернётся и будет обработано снова. Пишите идемпотентный код (проверяйте, не обработан ли уже этот заказ).
  • Рекурсия, которая стоит денег. Классика жанра: blob-триггер на контейнер, и сама функция кладёт результат в тот же контейнер. Каждый файл порождает новый вызов, и так по кругу. Serverless послушно масштабируется — а счёт растёт. Пишите результат в другой контейнер.
  • Долгая задача в Consumption. Лимит выполнения по умолчанию 5 минут, максимум 10; а HTTP-ответа внешний шлюз ждёт около 230 секунд. Долгую работу дробите на шаги и складывайте в очередь (или берите Durable Functions).
  • Создание клиента внутри тела функции. Новый HTTP-клиент или подключение к БД на каждый вызов при масштабировании до сотни инстансов исчерпывает пул соединений. Создавайте один раз, на уровне модуля.
  • run_on_startup=True у таймера в проде. Функция будет срабатывать при каждой перезагрузке хоста — а он перезагружается чаще, чем вы думаете.

Итоги

  • Функция = один триггер + код. Никакого сервера в вашей зоне ответственности.
  • Главные триггеры: HTTP (API и вебхуки), Timer (расписание, NCRONTAB с шестью полями), Queue/Blob (события).
  • Consumption платит за вызовы и GB-секунды, простой бесплатен; месячный грант ~1 млн выполнений покрывает большинство пет-проектов.
  • Холодный старт — плата за то, что вы не платите за простой. Лечится Premium/Flex или просто не мешает фоновым задачам.
  • Storage account обязателен: там служебное состояние хоста.
  • Код должен быть идемпотентным, лёгким и коротким — платформа перезапустит его столько раз, сколько понадобится.
Проверьте себя
1. Что означает расписание таймера "0 */5 * * * *" в Azure Functions?
AКаждые 5 секунд
BКаждые 5 минут — в NCRONTAB шесть полей, и первое из них секунды
CКаждые 5 часов
DПять раз в сутки в случайные моменты
2. Почему в плане Consumption возникает холодный старт?
AAzure специально замедляет бесплатные вызовы
BКод каждый раз компилируется заново из исходников
CВ простое ваших инстансов нет вообще: первому запросу приходится ждать, пока платформа выделит worker, поднимет контейнер и рантайм
DФункция ждёт освобождения storage account