Cloud Build: CI/CD

Настраиваем конвейер: разработчик делает git push — Google Cloud сам гоняет тесты, собирает образ и выкатывает новую версию в Cloud Run.

Cloud Build — управляемый сервис CI/CD в Google Cloud. Он берёт ваш исходный код, запускает над ним последовательность шагов (каждый шаг — контейнер) и складывает результат туда, куда вы скажете: в Artifact Registry, в Cloud Run, в бакет.

Зачем это на практике

Ручной деплой выглядит невинно: docker build, docker push, gcloud run deploy. Три команды, минута работы. Пока не выясняется, что:

  • Маша собрала образ на M1, и он не запустился на amd64;
  • Петя забыл прогнать тесты, потому что «правка на одну строчку»;
  • в проде крутится образ, собранный из незакоммиченного кода, и никто не знает, из какого именно;
  • деплоить умеет только тот, у кого настроен доступ, — а он в отпуске.

CI/CD убирает человека из повторяемой части. Сборка всегда в одном окружении, тесты пропустить нельзя, образ помечен хешем коммита — по нему всегда видно, какой код сейчас в проде.

Про деньги сразу: у Cloud Build есть бесплатный лимит (порядка 2500 build-минут в месяц на аккаунт), дальше — поминутная оплата, и она зависит от типа машины. Дефолтная машина дешёвая; если вы поставите machineType: E2_HIGHCPU_32 «чтобы быстрее», минута подорожает в разы. Актуальный прайс всегда проверяйте на странице Cloud Build Pricing.

cloudbuild.yaml: шаги — это контейнеры

Главная идея Cloud Build: каждый шаг сборки — это запуск Docker-образа. Нужен Node — берёте образ node. Нужен gcloud — берёте cloud-sdk. Нужен свой инструмент — берёте свой образ. Все шаги работают в общей папке /workspace, куда Cloud Build распаковал ваш код.

steps:
  # 1. Тесты. Если упадут — сборка остановится здесь.
  - name: 'python:3.12-slim'
    id: test
    entrypoint: bash
    args:
      - '-c'
      - |
        pip install --quiet -r requirements.txt
        pytest -q

  # 2. Сборка образа. Тег — короткий хеш коммита.
  - name: 'gcr.io/cloud-builders/docker'
    id: build
    args:
      - 'build'
      - '-t'
      - 'europe-west1-docker.pkg.dev/$PROJECT_ID/apps/shop-api:$SHORT_SHA'
      - '.'

  # 3. Публикация в Artifact Registry.
  - name: 'gcr.io/cloud-builders/docker'
    id: push
    args:
      - 'push'
      - 'europe-west1-docker.pkg.dev/$PROJECT_ID/apps/shop-api:$SHORT_SHA'

  # 4. Деплой новой ревизии в Cloud Run.
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    id: deploy
    entrypoint: gcloud
    args:
      - 'run'
      - 'deploy'
      - 'shop-api'
      - '--image=europe-west1-docker.pkg.dev/$PROJECT_ID/apps/shop-api:$SHORT_SHA'
      - '--region=europe-west1'

options:
  logging: CLOUD_LOGGING_ONLY

timeout: 900s

Разберём неочевидное.

$PROJECT_ID и $SHORT_SHAподстановки (substitutions). Cloud Build подставляет их сам: ID проекта и первые 7 символов хеша коммита. Есть ещё $COMMIT_SHA, $BRANCH_NAME, $TAG_NAME, $BUILD_ID. Свои переменные объявляют с подчёркиванием: _ENV, _REGION.

Тег :$SHORT_SHA вместо :latest — не эстетика, а необходимость. С latest вы не сможете ответить на вопрос «какая ревизия сейчас в проде и из какого она коммита», и не сможете откатиться на конкретную сборку.

entrypoint переопределяет команду образа. У cloud-sdk точка входа — не gcloud, поэтому её указывают явно.

timeout: 900s — общее время сборки. По умолчанию всего 10 минут, и сборка тяжёлого фронтенда в них не влезает; ошибка выглядит как загадочный обрыв на середине.

Запуск: вручную и по коммиту

Проверить конвейер локально можно одной командой — она заархивирует папку, зальёт в облако и запустит сборку:

gcloud builds submit --config cloudbuild.yaml .

# посмотреть историю сборок
gcloud builds list --limit=5

# почитать лог конкретной сборки
gcloud builds log BUILD_ID

Настоящий CI/CD начинается с триггера — правила «при таком-то событии в репозитории запусти такую-то сборку». Репозиторий подключают один раз через Cloud Build → Repositories (для GitHub это установка приложения Google Cloud Build), дальше:

gcloud builds triggers create github \
  --name=shop-api-main \
  --repo-name=shop-api \
  --repo-owner=my-org \
  --branch-pattern='^main$' \
  --build-config=cloudbuild.yaml

Типичная схема для команды: пуш в main — тесты, сборка, деплой в prod; пул-реквест — только тесты и сборка (--pull-request-pattern), без деплоя. Так ветка не может выкатиться в прод, минуя ревью.

Права: где спотыкаются все

Cloud Build работает не от вашего имени, а от сервисного аккаунта сборки. Ему нужно выдать ровно те роли, которые нужны шагам конвейера:

РольЗачем
roles/artifactregistry.writerчтобы docker push прошёл
roles/run.adminчтобы создать новую ревизию Cloud Run
roles/iam.serviceAccountUserчтобы «подставить» сервисный аккаунт, под которым бежит сам сервис
roles/logging.logWriterчтобы писать логи сборки
PROJECT_ID=$(gcloud config get-value project)
BUILD_SA="cloudbuild-runner@${PROJECT_ID}.iam.gserviceaccount.com"

gcloud projects add-iam-policy-binding "$PROJECT_ID" \
  --member="serviceAccount:${BUILD_SA}" \
  --role="roles/run.admin"

gcloud projects add-iam-policy-binding "$PROJECT_ID" \
  --member="serviceAccount:${BUILD_SA}" \
  --role="roles/iam.serviceAccountUser"

Если в логе сборки вы видите Permission 'iam.serviceaccounts.actAs' denied — забыли третью строку таблицы. Это ошибка номер один у всех, кто настраивает деплой в Cloud Run впервые.

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

Когда триггер срабатывает, Cloud Build выделяет одноразовую виртуальную машину (worker). На неё выкачивается ваш коммит, содержимое кладётся в /workspace. Дальше сервис по очереди запускает шаги: для каждого — docker run нужного образа с /workspace, примонтированным внутрь.

Отсюда следуют неочевидные, но важные вещи.

  • Состояние между шагами живёт только в /workspace. Установили пакеты в /usr/local на шаге 1 — на шаге 2 их нет: это другой контейнер. Всё, что должно пережить шаг, кладите в рабочую папку.
  • Шаги по умолчанию последовательны. Первый же ненулевой код возврата останавливает сборку — поэтому шаг с тестами и ставят первым.
  • Машина одноразовая. Кеша слоёв Docker между сборками нет по умолчанию, и «холодная» сборка каждый раз тянет зависимости заново. Отсюда — минуты и деньги. Лечится либо Kaniko-кешем, либо шагом docker pull ... :latest с флагом --cache-from, либо аккуратным Dockerfile, где слой с зависимостями идёт до слоя с кодом.
  • Всё, что лежит в папке, уезжает в облако. Файл .gcloudignore работает как .gitignore: без него node_modules и .venv будут заливаться при каждом builds submit.

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

  • Тег :latest в проде. Невозможно понять, что развёрнуто, и невозможно откатиться. Всегда $SHORT_SHA или семантическая версия.
  • Забытый iam.serviceAccountUser. Сборка проходит, деплой падает на actAs denied.
  • Дорогая машина «на всякий случай». machineType: E2_HIGHCPU_32 на сборке, которая упирается в скачивание npm-пакетов, ускорит её на 10%, а счёт умножит в разы. Сначала чините кеш, потом — железо.
  • Секреты в cloudbuild.yaml. Файл лежит в git. Пароли и токены — только через Secret Manager (availableSecrets), никогда в args или в substitutions.
  • Деплой с любой ветки. --branch-pattern='.*' означает, что эксперимент коллеги приедет в прод. Ограничивайте паттерн.
  • Нет timeout. Дефолтные 10 минут обрывают сборку на середине, и в логах это выглядит как случайный сбой.
  • Зависшие триггеры на удалённых ветках. Сборка, которая гоняется по кругу из-за ошибки в конфиге, тихо жжёт build-минуты. Держите бюджетный алерт на проекте.

Итоги

  • Cloud Build — CI/CD, где каждый шаг сборки является контейнером, а общая память шагов — папка /workspace.
  • cloudbuild.yaml описывает конвейер: тесты → docker buildpush в Artifact Registry → gcloud run deploy.
  • Подстановки $PROJECT_ID, $SHORT_SHA, $BRANCH_NAME дают воспроизводимые теги образов и возможность отката.
  • Триггер связывает пуш в ветку со сборкой; PR-триггер гоняет тесты без деплоя.
  • Сервисному аккаунту сборки нужны artifactregistry.writer, run.admin и iam.serviceAccountUser — последняя роль забывается чаще всего.
  • Бесплатный лимит есть, но кеш сборки и .gcloudignore экономят и минуты, и деньги.
Проверьте себя
1. Почему образ в Cloud Build принято тегировать $SHORT_SHA, а не latest?
Alatest запрещён в Artifact Registry
BSHORT_SHA собирается быстрее
CПо хешу коммита всегда видно, какой код в проде, и можно откатиться на конкретную сборку
Dlatest занимает больше места в реестре
2. Шаг 1 сборки установил пакеты через apt, шаг 2 их не находит. Почему?
AКаждый шаг — отдельный контейнер, между шагами сохраняется только папка /workspace
BCloud Build чистит систему после каждого шага командой apt clean
CНужно было указать options: logging
DПакеты установились, но шаг 2 запустился раньше шага 1