Обычный веб-сервис живёт на машине, которая работает круглые сутки. Ночью к нему никто не ходит, а счёт идёт; днём приходит пик, и копии нужно добавлять руками или держать с запасом. Хочется отдать облаку код и не думать дальше: пусть само поднимает столько копий, сколько нужно, и гасит до нуля, когда запросов нет. Это и есть serverless: серверы существуют, но машину не выбирают, за загрузкой не следят и за простой не платят. В GCP так работают два сервиса, Cloud Run для контейнеров и Cloud Functions для отдельных обработчиков, и обвязка вокруг них: триггеры, Eventarc, API Gateway.
Платят за удобство не деньгами, а новыми режимами отказа: сообщение приходит дважды, фоновый поток замирает после ответа, первый запрос после ночи ждёт несколько секунд. Если ещё не выбирали, где вообще запускать сервис, начните с обзора вариантов; здесь только бессерверные.
Cloud Run: обычный сервис без сервера
Сервис уже написан: Spring Boot, слушает порт 8080, настройки читает из переменных окружения. Переписывать его в «функции» ради облака никто не хочет. Cloud Run сделан под этот случай: ему отдают контейнер с обычным веб-приложением, а он выдаёт HTTPS-адрес, поднимает копии под нагрузку и гасит их до нуля, когда запросов нет. Ни кластера, ни машины. Если образа нет, Cloud Run соберёт его сам из исходников: в каталоге с pom.xml или package.json хватает одной команды.
gcloud run deploy orders --source . --region europe-west1 \
--allow-unauthenticated \
--memory 1Gi --concurrency 80 --min-instances 1 --cpu-boost
Флаги последней строки и есть ручки, вокруг которых крутится остальная статья: сколько запросов пускать в один экземпляр, сколько экземпляров держать всегда и ускорять ли старт. Первый из них меняет и код, и счёт. Один экземпляр Cloud Run обслуживает много запросов одновременно: по умолчанию до 80, --concurrency поднимает до 1000. У AWS Lambda и у функций первого поколения экземпляр занят ровно одним вызовом, и восемь параллельных запросов означают восемь экземпляров. Здесь один.
Восемь одновременных запросов слева попадают в один экземпляр и делят его процессор, память и пул соединений; справа каждому нужен свой экземпляр, а значит свой холодный старт, своя память и свои соединения к базе. Поэтому же код слева обязан быть потокобезопасным: запросы друг от друга не изолированы.
Следствий три. Код обязан быть потокобезопасным: внутри экземпляра запросы друг от друга не изолированы, и статическое поле, куда пишет один запрос, читает другой. Пул соединений к базе считают на экземпляр и умножают на их число: пул в десять соединений при сотне экземпляров даёт базе тысячу подключений, а у Cloud SQL лимит кончается раньше. И экземпляров при той же нагрузке нужно в десятки раз меньше, чем функций, а платят за экземпляры, не за запросы; к этому вернёмся в конце.
Процессор дают на время запроса
Сервис на Cloud Run отвечает 200, а письмо клиенту отправляет уже после ответа, в отдельном потоке через @Async. На машине это работало; здесь письма приходят через несколько минут или не приходят вовсе, а в логах поток будто замер. Это не ошибка кода. По умолчанию Cloud Run считает деньги по запросам (request-based billing) и выделяет экземпляру процессор только пока внутри есть хотя бы один незакрытый запрос. Ушёл последний ответ, и процессор урезают почти до нуля: поток жив, но не исполняется. Он доработает, когда придёт следующий запрос, если экземпляр к тому времени не погасят.
Выходов два. Первый честный: всё, что должно случиться, делают до ответа или в другом запросе. Письмо превращается в сообщение в Pub/Sub или задачу Cloud Tasks, а отправляет его отдельный обработчик. Второй, если фон нужен по-настоящему (метрики, прогрев кэша, потребитель очереди внутри сервиса): --no-cpu-throttling. Экземпляр получает процессор на всё время жизни, но и счёт идёт за всё время жизни, а не за секунды под запросами; секунда стоит примерно на четверть дешевле, а секунд становится много больше. Это переключает сервис на оплату по экземплярам (instance-based billing).
Cloud Functions: когда сервис не нужен
Задача другого масштаба: в бакет положили картинку, нужно сделать превью. Ради этого писать веб-сервер, Dockerfile и маршруты много. Cloud Functions: вы пишете одну функцию-обработчик, указываете, на какое событие её звать, и загружаете исходники; сборку и запуск берёт на себя облако. Ниша та же, что у AWS Lambda.
Первое, во что упирается человек при создании функции, выбор поколения. Первое поколение это отдельная платформа со своими потолками: один запрос на экземпляр, до 9 минут на вызов, до 8 ГБ памяти. Второе разворачивается как сервис Cloud Run и живёт по его правилам: те же экземпляры, та же конкурентность, те же лимиты. С 2024 года оно так и называется, Cloud Run functions, в консоли его ищут в разделе Cloud Run, а команда выката та же, только с флагом --function, который говорит, какой метод считать обработчиком.
Функция или сервис
Раз второе поколение и есть Cloud Run, выбор не между платформами, а между двумя способами выкатить код на одну и ту же платформу. Что отличается на самом деле:
| функция 1-го поколения | функция 2-го поколения | сервис Cloud Run | |
|---|---|---|---|
| что отдаёте | код | код, контейнер соберут | код или свой контейнер |
| запросов на экземпляр | 1 | до 1000 | до 1000 |
| время на вызов | до 9 мин | до 60 мин по HTTP, до 9 мин по событию | до 60 мин, по умолчанию 5 |
| память | до 8 ГБ | до 16 ГиБ | до 16 ГиБ |
| точка входа | функция-обработчик | функция-обработчик | ваш HTTP-сервер |
Функцию берут, когда обработчик маленький и событийный: вебхук от платёжной системы, превью по загрузке, задача по расписанию. Как только появляются свой Dockerfile (нестандартный базовый образ, системные библиотеки, свой веб-сервер), несколько маршрутов с общим состоянием или пул соединений, который хочется настраивать, это уже сервис. Переезд не больно: тот же gcloud run deploy, без --function и со своим образом. Поэтому на практике многие делают всё на Cloud Run сразу, а сомнение решают в пользу сервиса: он умеет всё, что умеет функция, обратное неверно.
Триггеры: кто дёргает код
Ни сервис, ни функция сами не запускаются, их вызывает триггер: HTTP-запрос напрямую или через API Gateway, сообщение в теме Pub/Sub, новый объект в Cloud Storage, расписание Cloud Scheduler. С HTTP клиент ждёт ответа и сам решает, повторять ли; остальные три доставляют событие сами, и повторяют тоже сами.
Обработчик по сообщению из Pub/Sub отправляет клиенту письмо. Часть клиентов получает его дважды, хотя сообщение было одно. Так и должно быть: Pub/Sub гарантирует доставку не менее одного раза, а не ровно одного. Механика простая. Push-подписка делает POST на адрес сервиса и ждёт ответа в пределах срока подтверждения (ack deadline), по умолчанию 10 секунд, максимум 600. Ответ 200, 201, 202 или 204 и есть подтверждение; любой другой код, обрыв соединения или молчание дольше срока Pub/Sub считает неудачей и шлёт сообщение снова, выждав от 100 миллисекунд до минуты. Обработчик, который работает 20 секунд при сроке в 10, получает повтор ещё до того, как закончил первый раз, и письмо уходит дважды без единой ошибки в логах.
Отсюда три правила. Срок подтверждения ставят не меньше реального времени обработки, а обработку держат короткой: долгое переносят в отдельный шаг. Обработчик делают идемпотентным: у каждого сообщения есть messageId, и его или бизнес-ключ (номер заказа) записывают в базу до побочного действия, а на повторе ничего не делают. И заводят тему для недоставленных (dead-letter topic): после заданного числа попыток, от 5 до 100, по умолчанию 5, сообщение уходит туда, а не крутится в повторах; на эту тему вешают оповещение, потому что одно сообщение, которое всегда падает, иначе будет бить в обработчик до конца срока хранения.
Eventarc: события облака в одном формате
Своих событий мало: хочется реагировать на то, что делает облако. Завершилась пакетная задача, создали пользователя в Cloud SQL, положили таблицу в BigQuery. Без Eventarc это опрос API по таймеру. С ним вы описываете триггер «событие такого-то типа от такого-то сервиса», а Eventarc доставляет его в сервис Cloud Run или функцию в формате CloudEvents: стандартный конверт с полями id, source, type, time и телом data. Обработчик разбирает конверт одинаково, от какого бы сервиса событие ни пришло.
Источников два вида, и разница бьёт по первой же настройке. Часть сервисов публикует события напрямую: Cloud Storage, Pub/Sub, Firebase. Остальные, включая Cloud SQL и BigQuery, Eventarc видит только через журнал аудита: триггер это фильтр по serviceName и methodName записи в Cloud Audit Logs. А записи о доступе к данным (Data Access) по умолчанию не пишутся ни для одного сервиса, кроме BigQuery. Триггер создаётся без ошибок, события не приходят, и виноваты выключенные журналы, а не Eventarc. Их включают в настройках аудита проекта для нужного сервиса, помня, что они платные по объёму; подробнее в статье про безопасность и наблюдаемость.
Под капотом Eventarc доставляет через те же подписки Pub/Sub, так что правила прежние: не менее одного раза, повторы с нарастающей паузой в течение суток, тема для недоставленных. Обработчик события Eventarc идемпотентен по той же причине, что и обработчик Pub/Sub.
API Gateway и связка целиком
Когда сервисов и функций несколько, перед ними ставят API Gateway: единую HTTPS-точку входа, которая по описанию OpenAPI направляет путь и метод в нужный сервис, проверяет ключ API или JWT и держит версии API. Ограничить частоту он тоже умеет, но грубее, чем привыкли в AWS: лимит задают квотой в запросах в минуту на проект-потребитель, и работает она только для клиентов с ключом API. Настройки вроде «столько запросов в секунду плюс всплеск» здесь нет.
Два пути через одну связку. Верхний — синхронный: клиент ждёт, пока шлюз проверит ключ и сервис ответит. Нижний — фоновый: сервис лишь публикует событие и отвечает, а работу делает функция, которую Pub/Sub дёргает сам, и при ошибке дёрнет ещё раз.
Граница между путями проходит по владельцу повтора: на верхнем ошибку получает клиент и повторяет сам, на нижнем повторяет Pub/Sub, поэтому всё, что стоит после него, обязано переживать дубли.
Холодный старт и жизнь экземпляра
API на Spring Boot в Cloud Run. Днём всё быстро, а первый запрос утром висит несколько секунд, и мобильный клиент успевает отвалиться по таймауту. Это холодный старт. Экземпляр, к которому 15 минут никто не обращался, Cloud Run гасит; следующий запрос ждёт, пока образ скачают, контейнер запустят, JVM поднимется и Spring соберёт контекст. Для обработчика на Node.js или Python это сотни миллисекунд, для Java на одном vCPU секунды.
Полоски в одном масштабе, цифры типичны для Spring Boot на одном vCPU: тёмная часть — время, пока поднимается экземпляр, светлая — сама обработка. Тёплый ответ на этом фоне почти не виден: 40 мс против 3,5 с. Последняя строка — та же пауза, но с одним экземпляром, который не гасят: старт не повторяется, а за простой идёт счёт.
Бороться можно тремя способами, и у каждого своя цена. --min-instances=1 держит один экземпляр всегда живым: старт не повторяется, а за простой идёт счёт по сниженному тарифу, но круглосуточно, то есть часть выгоды serverless вы отдаёте обратно. --cpu-boost временно даёт экземпляру больше процессора на время запуска, и старт Spring на одном vCPU укорачивается ощутимо, но не исчезает. Нативная сборка (GraalVM) убирает старт JVM и контекста, оставляя десятки миллисекунд, ценой долгой сборки и настройки рефлексии. У сервиса на Go тот же экземпляр поднимается за десятки миллисекунд без всяких ухищрений, у Node и Python за сотни, и холодный старт там почти не виден; поэтому цифры на схеме — это цена именно тяжёлой среды выполнения. Для фоновой задачи не нужно ничего из этого: лишние секунды перед разбором файла никто не заметит.
Раз экземпляр могут погасить в любой момент и поднять заново, в его памяти ничего не живёт дольше одного запроса. Счётчик в статическом поле у двух экземпляров показывает разные числа, а после паузы обнуляется; сессия пользователя пропадает вместе с экземпляром. Файловая система контейнера тоже в памяти: временные файлы съедают лимит памяти экземпляра и исчезают с ним. Состояние держат снаружи: в Firestore, Cloud SQL или Cloud Storage.
Сколько это стоит
При оплате по запросам счёт складывается из трёх частей: за секунды процессора экземпляра, за секунды памяти и за сами запросы, 0,40 доллара за миллион. Процессор и память считают по экземпляру, пока он обрабатывает хотя бы один запрос, с округлением до 100 миллисекунд. Отсюда главное следствие конкурентности: экземпляр, который держит 80 запросов сразу, стоит как один, а не как 80. У платформы с одним запросом на экземпляр те же 80 запросов оплачиваются восемью десятками экземпляров, и потому Cloud Run на ровной нагрузке выходит дешевле функций первого поколения в разы.
Порядок цифр: экземпляр с одним vCPU и 512 МиБ, пока обрабатывает запросы, стоит около 9 центов в час; при ста миллисекундах на запрос и конкурентности 80 он в пределе обслужит за этот час около трёх миллионов запросов, которые добавят ещё доллар с небольшим. В бесплатную квоту каждый месяц входят 2 миллиона запросов, 180 тысяч vCPU-секунд (50 часов работы одного vCPU) и 360 тысяч ГиБ-секунд (100 часов одного гигабайта), так что небольшой сервис с масштабированием до нуля часто не стоит ничего.
Дороже становится, когда от масштабирования до нуля отказываются. Минимальные экземпляры простаивают по сниженному тарифу, но 720 часов в месяц; оплата по экземплярам (--no-cpu-throttling) считает все секунды жизни. Сервис под ровной высокой нагрузкой при этом теряет главную выгоду, и его считают против обычной машины или GKE, как в статье про оптимизацию затрат.
Глубже: ночной пересчёт: Cloud Run jobs и Cloud Batchрасширенное
Всё выше про код, который отвечает на запрос или событие. Ночной пересчёт витрины, выгрузка отчёта, миграция данных это работа без запроса, которая должна начаться по расписанию, отработать час и завершиться, и втискивать её в HTTP-сервис с таймаутом на запрос неправильно.
Cloud Run jobs. Тот же контейнер, что и у сервиса, но без HTTP-сервера: контейнер запускается, делает работу, выходит с кодом ноль, и это считается успехом. Задание описывают один раз (gcloud run jobs create nightly-recalc --image ... --tasks 10 --max-retries 2 --task-timeout 2h), запускают вручную, из Cloud Scheduler по расписанию или из конвейера. --tasks разбивает работу на параллельные части: каждая получает свой номер в переменной окружения и берёт свою долю данных, так десять задач считают отчёт в десять раз быстрее; предел на задачу до 24 часов, память и процессор те же, что у сервиса. Неудача задачи повторяется указанное число раз, и повтор обязан быть идемпотентным, о чём вся статья: пересчёт витрины за день заменяет секцию целиком, а не дописывает. Для Spring Boot это тот же образ с --spring.main.web-application-type=none и профилем задания, как у миграций в Kubernetes.
Cloud Batch. Когда работа не помещается в контейнер Cloud Run: нужны десятки машин, видеокарты, сотни гигабайт памяти или прерываемые машины ради цены. Batch поднимает виртуальные машины под задание, раскладывает по ним задачи, дожидается и гасит машины; платят за машины на время работы, и на spot это в разы дешевле. Это уровень ниже по лестнице вычислений, со всеми его настройками, и берут его для расчётов, а не для пересчёта витрины.
Выбор простой: задача укладывается в часы, один контейнер и до 16 ГиБ памяти на задачу, значит Cloud Run jobs; нужны машины, видеокарты или тысячи задач, значит Batch. Функция по расписанию годится для минуты работы, но не для часа: у неё предел времени и один запрос на экземпляр.
Коротко
- Cloud Run запускает обычный контейнер с HTTP-сервером; функция второго поколения это тот же Cloud Run с готовой сборкой и подпиской на событие. Свой Dockerfile или несколько маршрутов значат сервис, сомнение тоже.
- Один экземпляр держит до 80 запросов (до 1000). Код потокобезопасен, пул к базе умножают на число экземпляров, а счёт идёт за экземпляры, не за запросы.
- По умолчанию процессор выделяют только пока открыт запрос: фоновая работа после ответа замирает. Либо уносить её в Pub/Sub и Cloud Tasks, либо
--no-cpu-throttlingи платить за всё время жизни. - Pub/Sub доставляет не менее одного раза: ответ не-2xx или молчание дольше срока подтверждения (10 с по умолчанию, до 600) даёт повтор. Обработчик идемпотентен по
messageId, срок не меньше времени работы, тема для недоставленных с оповещением. - Eventarc доставляет CloudEvents; для Cloud SQL, BigQuery и большинства сервисов источник это журнал аудита, и без включённых журналов доступа к данным события не приходят. Доставка та же, что у Pub/Sub.
- Экземпляр гасят после 15 минут простоя; холодный старт Spring на одном vCPU это секунды, у Go десятки миллисекунд. Лечат
--min-instances,--cpu-boostи нативной сборкой, каждое со своей ценой. В памяти и на диске экземпляра ничего не живёт дольше запроса. - Счёт: vCPU-секунды и ГиБ-секунды экземпляра под запросами плюс 0,40 доллара за миллион запросов; 2 миллиона запросов и 50 часов vCPU в месяц бесплатно. Минимум экземпляров и оплата по экземплярам отдают часть выгоды обратно.
- Работу без запроса запускают заданиями: Cloud Run jobs тем же контейнером с параллельными задачами до 24 часов и повторами, Cloud Batch когда нужны машины, видеокарты или spot; функция по расписанию только для минут.
Что почитать дальше
- Где запускать сервис в GCP: Cloud Run рядом с Compute Engine и GKE, чтобы видеть, от чего отказываетесь и что получаете.
- Управляемые данные: Pub/Sub как очередь и коннектор Cloud SQL, через который экземпляры ходят в базу без внешнего IP.
- Безопасность и наблюдаемость: журналы аудита, от которых зависят триггеры Eventarc, и трассировка запроса через шлюз и сервис.
- Serverless в AWS: та же модель с другими ручками, Lambda с provisioned concurrency и SnapStart вместо min-instances и cpu-boost.