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 — для сервиса.
- Самый опасный баг — рекурсивный триггер на бакете: он не роняет сервис, он просто печатает деньги.