Назад к блогу

Как перейти от локальных развертываний к быстрому CI/CD с помощью GitHub Actions

2026-01-198 min read

Я создавал веб-приложение для тренировки произношения, чтобы улучшить свою английскую просодию. Я носитель испанского языка и решил открыть его, вдруг оно поможет другим изучающим английский как второй язык. Бэкенд написан на Python с использованием FastAPI, для анализа аудио используется Parselmouth (обертка Python для Praat), а для генерации рекомендаций по обучению — Google Gemini. Фронтенд — это React с Vite и TypeScript.

Для инфраструктуры я выбрал Google Cloud. Бэкенд работает на Cloud Run, фронтенд размещен на Firebase Hosting, а данные пользователей хранятся в Firestore. Docker-образы отправляются в Artifact Registry. Код находится на GitHub. Это довольно стандартная настройка для личного проекта, который я хотел удержать в рамках бесплатного лимита.

Проблема

Я развертывал и фронтенд, и бэкенд с локальной машины. Фронтенд собирался достаточно быстро, но бэкенд был мучением. Каждый деплой требовал сборки Docker-образа с нуля, что занимало 5–10 минут, пока pip скачивал все Python-зависимости.

Я хотел перейти на CI/CD, чтобы развертывания происходили автоматически при пуше в main. Но я также не хотел ждать 10 минут при каждом пуше. Поэтому я решил сделать GitHub Actions «умным» в плане определения того, что именно изменилось.

Структура монорепозитория

Все находится в одном репозитории с папками frontend/ и backend/. Воркфлоу запускается при пуше в main, и я хотел, чтобы он определял, что именно изменилось, и развертывал только эту часть. Изменились файлы фронтенда? Развертываем только в Firebase. Изменился код бэкенда? Развертываем только в Cloud Run. А если изменился requirements.txt, пересобираем базовый Docker-образ. Но только в этом случае.

Поиск способа обнаружения изменений

Я поискал и нашел экшн под названием dorny/paths-filter. Он проверяет, какие файлы изменились при пуше, и выводит булевы значения, которые могут использовать другие джобы. Я настроил его как первую задачу в своем воркфлоу:

jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
requirements: ${{ steps.filter.outputs.requirements }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
requirements:
- 'backend/requirements.txt'
- 'backend/Dockerfile.base'

Это дало мне три выходных значения, на которые я мог ссылаться в других джобах, чтобы решить, должны ли они выполняться.

Борьба с медленной сборкой Docker

Даже при работающем обнаружении изменений деплои бэкенда все равно были медленными. Каждая сборка запускала pip install с нуля. В моем requirements.txt есть ML-библиотеки, инструменты для обработки аудио и прочее. Это много скачиваний.

Я понял, что зависимости меняю, возможно, раз в месяц. Все остальное время я меняю только код приложения. Поэтому я решил разделить Dockerfile на два.

Dockerfile.base

Сюда я поместил все медленное: системные пакеты, зависимости pip. Идея состояла в том, чтобы собрать это один раз, отправить в Artifact Registry, а затем использовать повторно.

FROM python:3.11-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
espeak-ng \
libsndfile1 \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

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

Dockerfile.fast

Этот образ начинается с базового и просто копирует код. Никакого apt-get. Никакого pip. Только команда COPY.

FROM us-central1-docker.pkg.dev/lescoach/prosody-repo/prosody-api-base:latest

WORKDIR /app
COPY app/ ./app/

ENV PYTHONUNBUFFERED=1
ENV PORT=8080
EXPOSE 8080

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

Это сократило время сборки с 5–10 минут до примерно 30 секунд, что стало хорошим улучшением.

Сборка воркфлоу

Я настроил джоб сборки базового образа так, чтобы он запускался только при изменении зависимостей:

build-base-image:
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.requirements == 'true'
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
- run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
- name: Build and push base image
working-directory: backend
run: |
docker build -f Dockerfile.base -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest

Джоб развертывания бэкенда потребовал проб и ошибок. Он должен был ждать сборки базового образа, если она выполнялась, но также работать, если она пропускалась. В итоге я получил следующее:

deploy-backend:
runs-on: ubuntu-latest
needs: [detect-changes, build-base-image]
if: |
always() &&
needs.detect-changes.outputs.backend == 'true' &&
(needs.build-base-image.result == 'success' || needs.build-base-image.result == 'skipped')
steps:
# auth steps...

- name: Check if base image exists
id: check-base
run: |
if docker manifest inspect ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest > /dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi

- name: Build base image if missing
if: steps.check-base.outputs.exists == 'false'
working-directory: backend
run: |
docker build -f Dockerfile.base -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest

- name: Build and push app image
working-directory: backend
run: |
docker build -f Dockerfile.fast -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }} .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }}

- uses: google-github-actions/deploy-cloudrun@v2
with:
service: prosody-api
region: us-central1
image: ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }}
flags: --allow-unauthenticated

Я узнал, что always() там необходим. Без него джоб даже не будет оценивать свое условие, если вышестоящий джоб был пропущен. Я также добавил проверку docker manifest inspect для обработки случая, когда базовый образ еще не существует, например, при первом развертывании или если кто-то случайно его удалил.

Часть с фронтендом

Джоб фронтенда оказался проще, так как он вообще не зависит от бэкенда:

deploy-frontend:
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- run: npm run build
working-directory: frontend
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: ${{ secrets.GITHUB_TOKEN }}
firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_LESCOACH }}
channelId: live
projectId: lescoach
entryPoint: frontend

Если изменился только фронтенд, выполняется только этот джоб.

Настройка аутентификации

Я выбрал Workload Identity Federation вместо ключей сервисного аккаунта. Это потребовало больше первоначальной настройки, но мне понравилось не иметь дела с ротацией ключей или беспокоиться о JSON-файлах в секретах. GitHub получает краткосрочные токены через OIDC, что показалось мне более чистым решением.

К чему я пришел

Изменения только во фронтенде теперь развертываются менее чем за минуту. Изменения в коде бэкенда занимают около 90 секунд. Изменения в зависимостях по-прежнему занимают около 4 минут, но для меня они случаются редко.

GitHub Actions workflow showing detect-changes, build-base-image, deploy-frontend, and deploy-backend jobs completing in 1m 33s
Фронтенд и бэкенд развернуты вместе за 1 минуту 33 секунды.

В большинстве дней я настраиваю элементы UI, и теперь это развертывается быстро. Весь воркфлоу занял около 130 строк YAML. Экшн paths-filter выполняет большую часть тяжелой работы по определению того, что изменилось.

Оставайтесь в курсе

Получайте последние статьи и идеи в свой почтовый ящик.

Unsubscribe anytime. No spam, ever.