Cloud Run: контейнеры без серверов

Вы отдаёте Google контейнер — он сам поднимает его на HTTP-запрос, сам масштабирует под нагрузку и сам гасит до нуля, когда трафика нет. Разбираемся, почему Cloud Run стал главной вычислительной платформой GCP.

Cloud Run — управляемая платформа, которая запускает ваш контейнер как HTTP-сервис: сама создаёт инстансы под нагрузку, сама убивает их при простое и берёт деньги только за то время, когда контейнер реально что-то делал.

Какую боль он снимает

Представьте типичный путь небольшого API. Сначала вы поднимаете VM. Потом настраиваете systemd, nginx, сертификаты Let's Encrypt, автообновления. Потом приходит нагрузка — и вы городите группу инстансов с автоскейлером и балансировщиком. Потом нагрузка уходит, а счёт остаётся, потому что три машины крутятся вхолостую всю ночь.

Cloud Run убирает весь этот слой. Ваша ответственность заканчивается на контейнере. Всё остальное — HTTPS-домен с валидным сертификатом, балансировка, масштабирование, перезапуск упавших инстансов, деплой без даунтайма — берёт на себя платформа.

Почему именно контейнер, а не «функция»? Потому что контейнер — это честный универсальный интерфейс. Любой язык, любая версия рантайма, любые системные библиотеки, ImageMagick или ffmpeg внутри — Cloud Run всё равно, что там. Вы не привязаны к платформе: тот же образ запустится в Kubernetes, в Docker на ноутбуке или у другого провайдера. Именно за это Cloud Run и любят.

Контракт контейнера: всего два правила

Чтобы контейнер поехал в Cloud Run, он должен соблюдать ровно два условия:

  1. Слушать HTTP на порту из переменной окружения PORT (по умолчанию 8080).
  2. Слушать на 0.0.0.0, а не на 127.0.0.1 — иначе снаружи в контейнер никто не достучится.

Вот минимальное приложение на Python с Flask. Обратите внимание на чтение PORT — это не украшение, а часть контракта:

import os
from flask import Flask

app = Flask(__name__)

@app.get("/")
def index():
    return {"status": "ok", "revision": os.environ.get("K_REVISION", "local")}

if __name__ == "__main__":
    port = int(os.environ.get("PORT", 8080))
    app.run(host="0.0.0.0", port=port)

И Dockerfile к нему. В проде обычно запускают через gunicorn, а не через встроенный сервер Flask:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PORT=8080
CMD exec gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 main:app

Деплой: две команды на выбор

Если у вас уже есть собранный образ, вы указываете его напрямую. Образы Cloud Run берёт из Artifact Registry — реестра контейнеров внутри GCP:

gcloud run deploy api \
  --image=us-central1-docker.pkg.dev/my-project/apps/api:v1 \
  --region=us-central1 \
  --allow-unauthenticated \
  --memory=512Mi \
  --cpu=1

А если образа нет, Cloud Run соберёт его сам прямо из вашей папки с кодом — через Cloud Build. Есть Dockerfile — соберёт по нему; нет Dockerfile — определит язык и соберёт образ через Buildpacks:

gcloud run deploy api --source . --region=us-central1 --allow-unauthenticated

Результат:

Building using Dockerfile and deploying container to Cloud Run service [api]
✓ Building and deploying new service... Done.
Service [api] revision [api-00001-abc] has been deployed and is serving 100 percent of traffic.
Service URL: https://api-7x4k2mnq3a-uc.a.run.app

Вы получили работающий HTTPS-эндпоинт с валидным сертификатом. Никаких nginx, никакого certbot. Флаг --allow-unauthenticated означает «сервис публичный»; без него Cloud Run будет требовать IAM-токен и отвечать 403 всем чужим.

Масштабирование до нуля и concurrency

Вот здесь Cloud Run принципиально отличается от классического serverless вроде AWS Lambda. Один инстанс Cloud Run умеет обрабатывать несколько запросов одновременно — по умолчанию до 80.

Это огромная разница в деньгах. Если ваш сервис в основном ждёт ответа от базы (то есть простаивает на I/O), сотня одновременных пользователей может уместиться в два инстанса, а не в сотню. Формула простая:

нужно инстансов ≈ (запросов в секунду × среднее время ответа) / concurrency

500 запросов в секунду при ответе за 200 мс и concurrency 80 дают всего ≈ 1,25 → два инстанса. А когда трафик прекращается, Cloud Run гасит всё до нуля — и вы не платите вообще ничего.

ФлагЧто делает
--concurrency=80Сколько запросов один инстанс обрабатывает параллельно. Ставьте 1, если код не потокобезопасен или жрёт много CPU на запрос.
--min-instances=0Минимум «прогретых» инстансов. 0 — экономия, но первый запрос после простоя ловит холодный старт.
--max-instances=100Потолок масштабирования. Ваш главный предохранитель от разорительного счёта.
--timeout=300Максимальное время обработки запроса, секунды. По умолчанию 5 минут, максимум — 60.
--cpu, --memoryРесурсы одного инстанса. Их произведение на время и есть основа счёта.

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

Cloud Run считает деньги за vCPU-секунды и GiB-секунды, потраченные на обработку запросов, плюс копейки за миллион запросов. Бесплатный уровень щедрый и обновляется каждый месяц: порядка 2 миллионов запросов, 180 000 vCPU-секунд и 360 000 GiB-секунд. Небольшой пет-проект, личный API или бот у вас реально будут работать за 0 долларов.

Ключевой момент: при стандартной модели (CPU выделяется только на время запроса) простаивающий инстанс, который ещё не потушен, почти ничего не стоит. Платите вы за работу, а не за аренду.

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

Каждый инстанс запускается в песочнице gVisor — облегчённом слое, который перехватывает системные вызовы контейнера. Это даёт изоляцию уровня VM при скорости запуска контейнера, но и объясняет мелкие странности: некоторые низкоуровневые syscalls там не работают.

Дальше три факта, из которых растут почти все ошибки новичков:

  • CPU отбирают между запросами. Как только вы вернули ответ, процессор инстанса дросселируется почти до нуля. Фоновый поток, который вы запустили «доделать потом», просто зависнет — до следующего входящего запроса. Лечится флагом --no-cpu-throttling (тогда платите за всё время жизни инстанса) или, что правильнее, выносом фоновой работы в Cloud Tasks или Pub/Sub.
  • Файловая система — это память. Всё, что вы пишете в /tmp, лежит в RAM инстанса и учитывается в лимите памяти. Скачали 400-мегабайтный файл в контейнер с лимитом 512 Mi — получите OOM.
  • Инстансы смертны. Cloud Run в любой момент может погасить инстанс, прислав SIGTERM. Хранить сессии пользователей или кэш в локальной памяти нельзя — состояние живёт в Redis (Memorystore), базе или Cloud Storage.

Каждый деплой создаёт новую ревизию — неизменяемый снимок «образ + конфигурация». Трафик можно переключать между ревизиями плавно, что даёт бесплатные canary-выкатки:

# 10% трафика на новую ревизию, 90% на старую
gcloud run services update-traffic api \
  --to-revisions=api-00002-xyz=10,api-00001-abc=90 \
  --region=us-central1

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

  • «Container failed to start and listen on the port». Ошибка №1 всех новичков. Причина почти всегда одна: приложение слушает 127.0.0.1 вместо 0.0.0.0, либо порт захардкожен и не читается из PORT.
  • Фоновая работа после ответа. Отправили ответ клиенту и в фоне пишете отчёт? Он не допишется — CPU уже забрали. Ставьте --no-cpu-throttling или выносите задачу в очередь.
  • Не выставлен --max-instances. Это ошибка, которая стоит денег. Бот-атака, петля ретраев или кривой цикл в клиенте — и Cloud Run честно поднимет сотню инстансов, потому что вы его об этом попросили. Всегда ставьте вменяемый потолок.
  • --min-instances «чтобы не было холодного старта». Один прогретый инстанс работает 24/7 и оплачивается 24/7 — вы фактически вернулись к аренде сервера. Для внутреннего сервиса, к которому обращаются трижды в день, это выброшенные деньги.
  • Состояние в памяти. Кэш, счётчик, сессия в переменной приложения ведут себя непредсказуемо: инстансов несколько, и запрос попадает в случайный.
  • Тяжеленный образ. Базовый python:3.12 вместо python:3.12-slim — это лишние сотни мегабайт, которые платформа тянет на каждом холодном старте. Чем меньше образ, тем быстрее старт.
  • Забыли --allow-unauthenticated. Сервис задеплоился, URL есть, но браузер отдаёт 403. Это не баг — это включённая по умолчанию IAM-авторизация.

Итоги

  • Cloud Run запускает ваш контейнер как HTTPS-сервис: масштабирование, сертификаты, балансировка и перезапуски — на стороне Google.
  • Контракт контейнера прост: слушать 0.0.0.0 на порту из $PORT. Всё остальное — ваше дело.
  • gcloud run deploy --source . соберёт образ и выкатит сервис одной командой, даже без Dockerfile.
  • Один инстанс держит до 80 запросов одновременно — это резко дешевле «одна функция = один запрос» и главная причина, почему Cloud Run вытесняет функции.
  • Масштабирование до нуля означает нулевой счёт при отсутствии трафика; бесплатный лимит покрывает 2 млн запросов в месяц.
  • Между запросами CPU дросселируется, /tmp — это RAM, а инстанс могут убить в любой момент. Проектируйте сервис stateless.
  • Всегда задавайте --max-instances: это ваш предохранитель от счёта на четырёхзначную сумму.
Проверьте себя
1. Cloud Run не может запустить ваш контейнер и пишет «Container failed to start and listen on the port». Что проверить в первую очередь?
AЧто приложение слушает 0.0.0.0 на порту из переменной PORT
BЧто у сервиса выставлен --min-instances больше нуля
CЧто образ лежит в Docker Hub, а не в Artifact Registry
DЧто в Dockerfile указан ENTRYPOINT, а не CMD
2. Почему Cloud Run обычно дешевле классической модели «одна функция обрабатывает один запрос»?
AПотому что он не берёт денег за исходящий трафик
BПотому что один инстанс обрабатывает до 80 запросов параллельно
CПотому что контейнеры всегда быстрее функций на старте
DПотому что он не масштабируется до нуля и переиспользует машины