Сквозной пример: контейнер в Cloud Run

Пройдём весь путь целиком: от файла на ноутбуке до публичного HTTPS-адреса, который сам поднимает копии под нагрузкой и сам гасит их до нуля в простое.

Cloud Run — сервис, который берёт готовый контейнер и превращает его в HTTPS-эндпоинт: масштабирование, TLS-сертификат и балансировка достаются вам бесплатно, а платите вы только за то время, пока контейнер реально обрабатывает запросы.

В предыдущих разделах мы разбирали сервисы по отдельности: проекты и биллинг, Compute Engine, Artifact Registry внутри Cloud Build, IAM. Теперь соберём из этих кубиков одно работающее приложение. Это тот самый сценарий, который чаще всего встречается на реальной работе: у команды есть Docker-образ, и его нужно выкатить так, чтобы он держал нагрузку, не требовал администрирования и не съедал бюджет по ночам, когда пользователей нет.

Зачем именно Cloud Run

Сравните два способа выкатить один и тот же контейнер. Виртуальная машина в Compute Engine: вы платите за неё круглосуточно, сами ставите Docker, сами настраиваете systemd, сами добываете TLS-сертификат, сами следите за автоскейлингом через managed instance group. Cloud Run: одна команда, через минуту у вас есть адрес вида https://hello-abc123-ew.a.run.app, а ночью, когда трафика нет, сервис масштабируется в ноль и счёт не растёт.

Плата в Cloud Run считается по vCPU-секундам и GiB-секундам памяти, потраченным на обработку запросов, плюс мелочь за сам факт запроса. Бесплатный лимит щедрый: порядка 2 миллионов запросов, 180 000 vCPU-секунд и 360 000 GiB-секунд в месяц. Пет-проект и учебный сервис в эти рамки укладываются целиком — то есть стоят ровно ноль.

Шаг 0. Подготовка проекта

Выбираем проект, регион и включаем нужные API. Без включённых API любая следующая команда честно ответит ошибкой API [run.googleapis.com] not enabled.

export PROJECT_ID=$(gcloud config get-value project)
export REGION=europe-west1

gcloud config set run/region $REGION

gcloud services enable \
  run.googleapis.com \
  artifactregistry.googleapis.com \
  cloudbuild.googleapis.com

Результат:

Operation "operations/acat.p2-..." finished successfully.

Шаг 1. Приложение

Возьмём минимальный веб-сервис на Flask. Важно: он читает порт из переменной окружения PORT и слушает 0.0.0.0, а не 127.0.0.1. Это контракт Cloud Run, и нарушение именно этого пункта — самая частая причина, по которой первый деплой не взлетает.

import os
from flask import Flask, jsonify

app = Flask(__name__)

@app.get("/")
def index():
    return jsonify(
        message="Привет из Cloud Run!",
        service=os.environ.get("K_SERVICE", "local"),
        revision=os.environ.get("K_REVISION", "local"),
    )

@app.get("/heavy")
def heavy():
    # намеренно тяжёлый обработчик — на нём будем смотреть автоскейлинг
    total = sum(i * i for i in range(3_000_000))
    return {"result": total}

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

Переменные K_SERVICE и K_REVISION Cloud Run подставляет сам — по ним удобно понимать, какая именно ревизия обслуживает запрос.

Файл зависимостей:

flask==3.0.3
gunicorn==22.0.0

Шаг 2. Dockerfile

Встроенный сервер Flask в проде не используют — он однопоточный и не рассчитан на нагрузку. Запускаем через gunicorn.

FROM python:3.12-slim

ENV PYTHONUNBUFFERED=1
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# Cloud Run сам подставит PORT; жёстко зашивать 8080 в код нельзя
CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 main:app

Почему --workers 1 --threads 8? Cloud Run сам решает, сколько копий контейнера запустить. Внутри одного контейнера нам нужна лишь конкурентность на уровне потоков — плодить процессы бессмысленно, они просто съедят память. А --timeout 0 отключает встроенный таймаут gunicorn: таймаутом запроса управляет сам Cloud Run.

Шаг 3. Репозиторий в Artifact Registry

Artifact Registry — это хранилище образов (наследник Container Registry). Cloud Run не умеет тянуть образы откуда угодно: они должны лежать в Artifact Registry, к которому у сервис-аккаунта есть доступ.

gcloud artifacts repositories create app-repo \
  --repository-format=docker \
  --location=$REGION \
  --description="Образы приложений"

gcloud artifacts repositories list

Полное имя образа складывается по схеме РЕГИОН-docker.pkg.dev/ПРОЕКТ/РЕПОЗИТОРИЙ/ИМЯ:ТЕГ. Запомните её — половина ошибок деплоя это опечатка именно здесь.

Шаг 4. Сборка и пуш образа

Есть два пути. Первый — отдать сборку в облако, локальный Docker при этом не нужен вовсе:

export IMAGE=$REGION-docker.pkg.dev/$PROJECT_ID/app-repo/hello:v1

gcloud builds submit --tag $IMAGE .

Cloud Build упакует папку, соберёт образ на своей машине и сам положит его в Artifact Registry. Первые 120 минут сборки в сутки бесплатны.

Второй путь — собрать локально. Здесь есть ловушка для владельцев Mac на Apple Silicon: по умолчанию Docker соберёт образ под arm64, а Cloud Run работает на amd64, и контейнер упадёт с exec format error. Поэтому платформу указываем явно.

gcloud auth configure-docker $REGION-docker.pkg.dev

docker build --platform linux/amd64 -t $IMAGE .
docker push $IMAGE

Шаг 5. Деплой

gcloud run deploy hello \
  --image=$IMAGE \
  --region=$REGION \
  --allow-unauthenticated \
  --memory=512Mi \
  --cpu=1 \
  --concurrency=80 \
  --min-instances=0 \
  --max-instances=10

Результат:

Deploying container to Cloud Run service [hello] in project [my-project] region [europe-west1]
  Building and deploying... Done.
  Creating Revision... Done.
  Routing traffic... Done.
Service [hello] revision [hello-00001-abc] has been deployed and is serving 100 percent of traffic.
Service URL: https://hello-abc123-ew.a.run.app

Проверяем:

SERVICE_URL=$(gcloud run services describe hello --region=$REGION --format="value(status.url)")
curl $SERVICE_URL

Результат:

{"message":"Привет из Cloud Run!","revision":"hello-00001-abc","service":"hello"}

Всё: у вас есть публичный HTTPS-эндпоинт с валидным сертификатом. Никакого nginx, никакого certbot, никакого сервера.

Шаг 6. Смотрим автоскейлинг вживую

Пока запросов нет, работает ноль инстансов. Дадим нагрузку на тяжёлый обработчик и посмотрим, что произойдёт. Утилита hey шлёт запросы в несколько потоков:

hey -z 30s -c 50 $SERVICE_URL/heavy

# логи сервиса
gcloud run services logs read hello --region=$REGION --limit=20

В метриках сервиса (Cloud Console → Cloud Run → hello → Metrics) вы увидите характерную картинку: график «Container instance count» прыгает с нуля до нескольких единиц, держится под нагрузкой и через несколько минут после её окончания снова уползает в ноль. Никто ничего не настраивал — это поведение по умолчанию.

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

Под капотом Cloud Run — это контейнерный контракт плюс автоскейлер.

  • Контракт контейнера. Платформа запускает образ, передаёт переменную PORT и ждёт, что процесс начнёт слушать HTTP на этом порту по адресу 0.0.0.0. Не дождался за отведённое время — деплой откатывается, ревизия помечается как нерабочая.
  • Конкурентность. Параметр --concurrency (по умолчанию 80) говорит, сколько запросов один инстанс обслуживает одновременно. Это ключевое отличие от «одна функция — один запрос» в классическом serverless: Cloud Run дешевле именно потому, что один контейнер тянет десятки одновременных запросов.
  • Автоскейлер смотрит на очередь входящих запросов и загрузку CPU. Не хватает мощности — поднимает новые инстансы вплоть до --max-instances. Нагрузка ушла — гасит их, вплоть до нуля при --min-instances=0.
  • Холодный старт. Первый запрос после простоя ждёт, пока контейнер поднимется: обычно сотни миллисекунд, но толстый образ с тяжёлым фреймворком легко превращает это в 3–5 секунд.
  • CPU выдаётся только на время запроса. Вне обработки запроса процессор у контейнера почти отбирают (CPU throttling). Фоновый поток, который вы запустили «в фоне после ответа», будет исполняться мучительно медленно или не исполнится вовсе. Для фоновой работы есть Cloud Tasks и Pub/Sub, а если задача действительно нужна внутри — флаг --no-cpu-throttling (и он же переводит биллинг в режим оплаты за всё время жизни инстанса).
  • Ревизии неизменяемы. Каждый gcloud run deploy создаёт новую ревизию, трафик переключается на неё. Откат — это просто перевод трафика на предыдущую ревизию, что удобно для канареечных выкаток: gcloud run services update-traffic hello --to-revisions=hello-00002-xyz=10.

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

  • «Container failed to start and listen on the port defined by the PORT variable». Классика номер один. Причины: порт зашит числом вместо чтения из PORT; приложение слушает 127.0.0.1 (внутрь контейнера снаружи не достучаться); процесс упал на старте — смотрите логи ревизии, там будет настоящий traceback.
  • exec format error. Образ собран под arm64 на Apple Silicon. Лечится флагом --platform linux/amd64 или сборкой через Cloud Build.
  • Регион Artifact Registry не совпадает с регионом сервиса. Работать будет, но каждый холодный старт тянет образ через полмира — медленнее и с платой за межрегиональный трафик. Держите репозиторий и сервис в одном регионе.
  • --min-instances=1 «чтобы не было холодных стартов». Это стоит денег: инстанс живёт 730 часов в месяц и тарифицируется даже в простое (по сниженной idle-ставке). Для учебного проекта это разница между «бесплатно» и «счёт каждый месяц». Включайте только там, где задержка реально критична.
  • Не выставлен --max-instances. Значение по умолчанию — сотня инстансов. Один бесконечный цикл ретраев у клиента, случайный трафик от ботов или ошибка в коде, замедлившая обработчик, — и вы масштабируетесь в сто контейнеров, каждый из которых тикает счётчиком. Ставьте осознанный потолок: это ваш предохранитель.
  • --allow-unauthenticated на внутреннем API. Флаг делает сервис публичным для всего интернета. Для внутреннего сервиса уберите его и выдайте роль roles/run.invoker конкретному сервис-аккаунту.
  • Забытые образы в Artifact Registry. Каждый деплой оставляет слой в хранилище. Полгода активной разработки — это десятки гигабайт, за которые вы платите примерно $0,10 за гигабайт в месяц сверх бесплатных 0,5 ГБ. Настройте cleanup policy на репозитории.

Итоги

  • Путь всегда один: код → Dockerfile → образ в Artifact Registry → gcloud run deploy → публичный URL.
  • Приложение обязано слушать 0.0.0.0 на порту из переменной PORT — это контракт платформы.
  • Собирать образ проще через gcloud builds submit: не нужен локальный Docker и не будет проблемы с arm64.
  • Автоскейлинг работает из коробки: от нуля до --max-instances, ориентируясь на очередь запросов и CPU.
  • Два флага решают судьбу вашего счёта: --min-instances (сколько платить в простое) и --max-instances (потолок в пике).
  • Бесплатного лимита Cloud Run — около 2 млн запросов в месяц — обычному пет-проекту хватает с запасом.
Проверьте себя
1. Что обязано сделать приложение, чтобы Cloud Run признал контейнер запустившимся?
AСлушать HTTP на 0.0.0.0 и на порту из переменной окружения PORT
BСлушать HTTP на 127.0.0.1:8080 — снаружи адрес всё равно проксируется
CОткрыть gRPC-порт 50051 и ответить на health-check
DСоздать pid-файл в /var/run и записать туда номер процесса
2. Вы задали --min-instances=1, чтобы избавиться от холодных стартов. Каково последствие?
AНичего не изменится: Cloud Run всё равно гасит инстансы до нуля
BПервый запрос будет быстрым, но один инстанс живёт круглосуточно и тарифицируется даже в простое
CСервис перестанет масштабироваться выше одного инстанса
DCloud Run удалит предыдущие ревизии, чтобы освободить квоту