Cloud Functions: serverless-функции

Иногда не нужен ни сервер, ни контейнер — нужна одна функция, которая просыпается на событие. Разбираем триггеры, холодный старт и главный вопрос: когда брать функцию, а когда Cloud Run.

Cloud Functions — сервис, где единица развёртывания не сервер и не контейнер, а одна функция на вашем языке. Google сам заворачивает её в образ, сам запускает при наступлении события и сам гасит, когда событий нет.

Зачем: клей для облака

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

Ради каждой такой пятистрочной задачи держать сервер (или писать HTTP-приложение с роутингом и Dockerfile) — перебор. Cloud Functions даёт кратчайший путь: вы пишете функцию, указываете, на что она реагирует, и получаете работающий кусочек инфраструктуры. Функции — это клей, которым сервисы облака склеиваются друг с другом.

Функция-обработчик HTTP

Простейший случай — функция, которая отвечает на HTTP-запрос. Никакого Flask, никакого app.run(): вы пишете только тело обработчика, а HTTP-сервер вокруг него строит functions-framework.

import functions_framework

@functions_framework.http
def hello(request):
    name = request.args.get("name", "мир")
    return f"Привет, {name}!"

Рядом кладём requirements.txt с одной строкой functions-framework==3.* и деплоим. Ключ --gen2 обязателен: это второе поколение, о нём чуть ниже.

gcloud functions deploy hello \
  --gen2 \
  --runtime=python312 \
  --region=us-central1 \
  --source=. \
  --entry-point=hello \
  --trigger-http \
  --allow-unauthenticated

Результат:

Preparing function...done.
Deploying function...done.
url: https://us-central1-my-project.cloudfunctions.net/hello
state: ACTIVE

Триггеры: на что можно подписаться

Самое интересное начинается, когда функция реагирует не на HTTP, а на события внутри облака. Обработчик тогда принимает не request, а CloudEvent — стандартизированный конверт с данными события.

Pub/Sub: сообщение в очереди

import base64
import functions_framework

@functions_framework.cloud_event
def on_order(cloud_event):
    raw = cloud_event.data["message"]["data"]
    text = base64.b64decode(raw).decode("utf-8")
    print(f"Обрабатываю заказ: {text}")
gcloud functions deploy on-order \
  --gen2 --runtime=python312 --region=us-central1 \
  --source=. --entry-point=on_order \
  --trigger-topic=orders

Обратите внимание на base64: тело сообщения Pub/Sub всегда приходит закодированным. Забыть про это — классическая ошибка первого дня.

Cloud Storage: файл появился в бакете

import functions_framework

@functions_framework.cloud_event
def on_upload(cloud_event):
    data = cloud_event.data
    bucket = data["bucket"]
    name = data["name"]
    size = data.get("size")
    print(f"Загружен файл gs://{bucket}/{name}, размер {size} байт")
gcloud functions deploy on-upload \
  --gen2 --runtime=python312 --region=us-central1 \
  --source=. --entry-point=on_upload \
  --trigger-event-filters="type=google.cloud.storage.object.v1.finalized" \
  --trigger-event-filters="bucket=my-uploads"

Планировщик: по расписанию

Отдельного триггера «по времени» нет — вместо него связка Cloud Scheduler + Pub/Sub (или HTTP). Планировщик по cron-расписанию кидает сообщение в топик, функция на него просыпается:

gcloud scheduler jobs create pubsub nightly-report \
  --schedule="0 3 * * *" \
  --topic=reports \
  --message-body="run" \
  --time-zone="Europe/Moscow"

Холодный старт: что это и сколько стоит

Холодный старт — задержка первого запроса после простоя. Функций не крутится ни одной, поэтому платформе надо поднять инстанс, скачать образ, запустить рантайм, выполнить импорты и только потом позвать ваш код. Это добавляет от сотни миллисекунд до нескольких секунд.

Как с ним бороться:

  • Тяжёлые вещи — в глобальную область. Клиент базы, HTTP-сессия, загруженная модель создаются один раз при старте инстанса и переиспользуются всеми последующими вызовами. Создавать соединение внутри тела функции — значит платить за него каждый раз.
  • Меньше зависимостей. Каждая строчка в requirements.txt — это секунды импорта на холодном старте.
  • --min-instances=1 держит один инстанс прогретым. Работает, но вы платите за него постоянно — включайте только там, где задержка реально критична.
import functions_framework
from google.cloud import firestore

# создаётся ОДИН раз на инстанс, а не на каждый вызов
db = firestore.Client()

@functions_framework.http
def counter(request):
    ref = db.collection("stats").document("visits")
    ref.set({"count": firestore.Increment(1)}, merge=True)
    return "ok"

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

Главное, что стоит знать про второе поколение: Cloud Functions 2nd gen — это Cloud Run под капотом. При деплое ваш исходник уезжает в Cloud Build, там из него по Buildpacks собирается контейнер (тот самый functions-framework оборачивает функцию в HTTP-сервер), контейнер кладётся в Artifact Registry и разворачивается как сервис Cloud Run. События к нему доставляет Eventarc.

Отсюда следуют приятные вещи: функции gen2 наследуют concurrency (один инстанс может обрабатывать несколько событий), таймаут до 60 минут для HTTP-функций (до 9 минут для событийных), нормальные лимиты по памяти и CPU. И отсюда же следует главный вывод про выбор:

СитуацияЧто брать
Реакция на одно событие: файл в бакете, сообщение в топике, вебхукCloud Functions — меньше кода и никакого Dockerfile
API с несколькими маршрутами, middleware, авторизациейCloud Run
Нужны системные пакеты (ffmpeg, ImageMagick), свой рантаймCloud Run — вы контролируете образ
Хочется переносимости: тот же образ в K8s и локальноCloud Run
Пять строк кода, которые надо выкатить за минутуCloud Functions

Практическое правило: функция — для события, Cloud Run — для сервиса. Как только у функции появляется второй маршрут или Dockerfile, честнее переехать в Cloud Run.

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

  • Рекурсивный триггер — самая дорогая ошибка в этом уроке. Функция срабатывает на загрузку файла в бакет, делает превью и кладёт его в тот же бакет. Загрузка превью — тоже событие. Функция запускается снова. Получается бесконечный цикл, который крутится, пока вы не заметите счёт. Всегда пишите результат в другой бакет или проверяйте префикс имени файла в самом начале обработчика.
  • Нет идемпотентности. Pub/Sub гарантирует доставку as least once — «хотя бы один раз». Одно и то же сообщение может прийти дважды. Если функция списывает деньги или отправляет письмо, обязательно проверяйте eventId и отбрасывайте дубликаты.
  • Включённые ретраи + вечная ошибка. Для событийных функций можно включить --retry. Если функция падает всегда (например, из-за битого сообщения), платформа будет повторять её сутками. Ставьте предохранитель: если событию больше N минут — залогировать и вернуть успех, чтобы разорвать цикл.
  • Соединение с базой внутри тела функции. На каждый вызов новый коннект — база быстро упирается в лимит соединений. Клиенты создавайте в глобальной области.
  • Забыли про base64 в Pub/Sub. Печатаете «сообщение», а в логах абракадабра — просто не декодировали.
  • Функция как HTTP-сервер. Роутинг вручную по request.path внутри одной функции — верный признак того, что вам нужен Cloud Run, а не функция.
  • Первое поколение по привычке. Без флага --gen2 вы получите старую платформу с жёсткими лимитами (9 минут, один запрос на инстанс) и без concurrency. Новые функции пишите только на gen2.

Итоги

  • Cloud Functions — это функция-обработчик: вы пишете тело, платформа строит вокруг него сервер, контейнер и инфраструктуру.
  • Триггеры бывают HTTP, Pub/Sub, Cloud Storage и десятки других через Eventarc; расписание делается связкой Cloud Scheduler + Pub/Sub.
  • Событийные функции получают CloudEvent; в Pub/Sub тело сообщения приходит в base64.
  • Холодный старт лечится глобальными клиентами, лёгкими зависимостями и (в крайнем случае) --min-instances=1, за который придётся платить.
  • Cloud Functions 2nd gen — это надстройка над Cloud Run, отсюда concurrency, длинные таймауты и щедрый бесплатный лимит (порядка 2 млн вызовов в месяц).
  • Правило выбора: функция — для события, Cloud Run — для сервиса.
  • Самый опасный баг — рекурсивный триггер на бакете: он не роняет сервис, он просто печатает деньги.
Проверьте себя
1. Функция срабатывает на загрузку файла в бакет images, создаёт превью и сохраняет его в тот же бакет images. Что произойдёт?
AНичего особенного — Google автоматически игнорирует файлы, созданные функцией
BФункция запустится бесконечное число раз: каждое сохранение превью — новое событие
CФункция выполнится ровно два раза и остановится
DДеплой не пройдёт: GCP запрещает такой триггер
2. Что из перечисленного — верный аргумент в пользу Cloud Run вместо Cloud Functions?
AНужно обработать одно событие Pub/Sub пятью строчками кода
BНужен API с несколькими маршрутами и системным пакетом ffmpeg в образе
CНужно масштабирование до нуля при отсутствии трафика
DНужен бесплатный уровень для пет-проекта