Настраиваем CI/CD в GitHub для Python-проекта с нуля

Страницы:  1

Ответить
 

Professor Seleznov


1. Введение
CI/CD — это автоматизация процессов, которые разработчики обычно делают руками после написания кода.
Если без лишней теории, то:
  • CI (Continuous Integration / Непрерывная интеграция) означает, что каждый раз, когда вы отправляете код в репозиторий, запускается сервер. Он скачивает ваши изменения, устанавливает зависимости и прогоняет тесты с линтерами. Цель — убедиться, что новый код ничего не сломал.
  • CD (Continuous Deployment / Непрерывное развертывание) вступает в дело, когда CI отработал без ошибок. Код автоматически собирается и отправляется туда, где он должен работать — на сервер, в Docker Hub или публикуется как пакет в PyPI.
Зачем CI/CD нужен в Python-проектах
Python — интерпретируемый язык с динамической типизацией. Здесь нет строгого этапа компиляции, который поймал бы опечатку в переменной до запуска программы. Ошибка или съехавший отступ могут проявиться только в рантайме.
Внедрение CI/CD в Python-проекте решает три конкретные задачи:
  • Автоматизация рутины. Вам больше не нужно перед каждым коммитом локально запускать pytest, black, isort или flake8. Вы просто пишете код и делаете push. Остальное делает сервер.
  • Защита стандартов кода (PEP8). В главную ветку (main/master) физически не попадет код, который не проходит проверки на стиль и качество. Это экономит часы на код-ревью — люди обсуждают архитектуру, а не отступы и длину строк.
  • Устранение проблемы «А у меня на компьютере работает». Код тестируется в изолированном, чистом окружении. Если вы установили библиотеку локально, но забыли добавить ее в requirements.txt или pyproject.toml, пайплайн сразу упадет с ошибкой ModuleNotFoundError, и вы исправите это до того, как код попадет на продакшен.
Почему GitHub Actions
На рынке много CI/CD систем: GitLab CI, Jenkins, CircleCI. Но если ваш проект уже живет на GitHub, использование GitHub Actions — самый прагматичный выбор.
  • Работает «из коробки». Вам не нужно арендовать сервер, устанавливать раннеры и настраивать вебхуки (как в случае с Jenkins). Достаточно положить YAML-файл в директорию .github/workflows/ внутри вашего репозитория, и всё заработает само.
  • Щедрые лимиты. GitHub дает 2000 минут серверного времени в месяц для приватных репозиториев. Для публичных open-source проектов время вообще не ограничено. Для подавляющего большинства небольших и средних проектов это полностью бесплатно.
  • Экосистема и комьюнити. В GitHub Marketplace есть тысячи готовых экшенов (шагов). Вам не нужно писать bash-скрипты для установки Python, кэширования зависимостей или деплоя по SSH — для всего этого уже есть официальные и проверенные сообществом готовые блоки в 2-3 строчки кода.
2. Наш подопытный
Чтобы не разбирать CI/CD на абстрактных примерах, давайте автоматизируем реальный, хоть и крошечный проект.
Допустим, мы пишем микросервис на FastAPI, который рассчитывает индекс массы тела (BMI). Он принимает вес и рост, а возвращает рассчитанный индекс.
Что мы автоматизируем
Вот наш основной код приложения. Он занимает всего десяток строк и лежит в файле src/main.py:
from fastapi import FastAPI, HTTPException
app = FastAPI()
@app.get("/bmi")
def calculate_bmi(weight: float, height: float):
if height <= 0 or weight <= 0:
raise HTTPException(status_code=400, detail="Рост и вес должны быть больше нуля")
bmi = weight / (height ** 2)
return {"weight": weight, "height": height, "bmi": round(bmi, 2)}
Чтобы проверить, что наша формула работает правильно и приложение не падает, мы написали автотест в файле tests/test_main.py:
from fastapi.testclient import TestClient
from src.main import app
client = TestClient(app)
def test_calculate_bmi_success():
# Проверяем расчет для человека весом 70 кг и ростом 1.75 м
response = client.get("/bmi?weight=70&height=1.75")
assert response.status_code == 200
assert response.json()["bmi"] == 22.86
Структура файлов
Наш проект уже причесан и готов к автоматизации. Файлы аккуратно разложены по папкам, а зависимости зафиксированы. Выглядит это так:
bmi_project/
├── requirements.txt # Здесь записаны fastapi, uvicorn, pytest, httpx и ruff
├── src/
│ ├── __init__.py
│ └── main.py # Наш API
└── tests/
├── __init__.py
└── test_main.py # Наши тесты
Ручная рутина (от которой мы избавимся)
Представьте, что вы добавили новую фичу в этот код. Чтобы убедиться, что всё работает идеально перед отправкой кода в GitHub (командой git push), вы как ответственный разработчик открываете терминал и вводите три команды:
  • Обновляете зависимости (вдруг коллега добавил новую библиотеку):
pip install -r requirements.txt
  • Запускаете линтер (в нашем случае ruff), чтобы проверить код на чистоту и соответствие PEP8:
ruff check .
  • Прогоняете тесты, чтобы убедиться, что логика не сломалась:
pytest
И так — каждый раз. А если вы забудете это сделать? Или поленитесь? Плохой код улетит в главную ветку и может сломать приложение на продакшене.
Наша цель в следующих шагах — написать конфигурацию, которая заставит сервер GitHub автоматически выполнять эти три команды при каждом вашем коммите. Вы будете только писать код, а проверять его будет робот.
Ой, вижу на скриншоте, что разметка Markdown сломалась — я забыл закрыть блок кода `````, и весь остальной текст улетел внутрь. Спасибо, что поправили! Исправляюсь и перехожу сразу к делу.
3. Пишем первый пайплайн (Базовый CI)
Чтобы GitHub понял, что мы от него хотим, нужно создать конфигурационный файл. GitHub Actions ищет инструкции строго в одном месте: в скрытой папке .github/workflows/ в корне вашего репозитория.
Создадим там файл и назовем его ci.yml (имя может быть любым, главное — расширение .yml).
Вот как выглядит базовый скелет нашего пайплайна:
name: API CI/CD
# 1. Когда запускать (Triggers)
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
# 2. Что именно делать (Jobs)
jobs:
build-and-test:
runs-on: ubuntu-latest # Выделяем виртуальный сервер на Linux
# 3. Пошаговая инструкция (Steps)
steps:
- name: Скачиваем код репозитория
uses: actions/checkout@v4
- name: Устанавливаем Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Устанавливаем зависимости
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
Разбираем код по косточкам
  • name: Имя пайплайна. Оно будет красиво отображаться в интерфейсе GitHub во вкладке «Actions».
  • on: Это триггер. Мы говорим: «GitHub, запускай этот скрипт каждый раз, когда кто-то пушит код напрямую в ветку main или открывает в нее Pull Request».
  • jobs: Задачи. Пока у нас одна задача, мы назвали её build-and-test.
  • runs-on: ubuntu-latest: GitHub выделяет нам абсолютно чистую виртуальную машину на последней версии Ubuntu.
  • steps: Самое интересное. Это последовательность шагов, которые сервер выполнит друг за другом:
  • actions/checkout@v4 — готовый экшен (action). По умолчанию выделенный сервер абсолютно пустой. Этот шаг копирует ваш код из репозитория на виртуальную машину.
  • actions/setup-python@v5 — еще один экшен. Он устанавливает нужную версию Python (в нашем случае 3.11).
  • run — а это уже обычные консольные команды, которые мы запускаем внутри сервера. Ровно так же, как вы делали это локально: обновляем pip и устанавливаем fastapi, pytest и ruff из файла зависимостей.
4. Запускаем проверки: Тесты и Линтеры
Теперь, когда сервер готов, на нем лежит наш код и установлены все библиотеки, самое время добавить проверки. Продолжаем редактировать файл ci.yml. Добавим в конец блока steps те самые команды, которые раньше вводили руками:
- name: Проверка кода линтером (Ruff)
run: ruff check .
- name: Запуск автотестов (Pytest)
run: pytest
Всё, базовый CI готов. Как только вы сделаете git push, GitHub поднимет сервер и пойдет по этим шагам.
Разбор полетов: что, если мы ошиблись?
Допустим, вы случайно сломали формулу расчета BMI в файле main.py (например, забыли возвести рост в квадрат) и запушили этот код. Что произойдет?
  • GitHub скачает код и установит зависимости (шаги пройдут успешно).
  • Линтер ruff проверит синтаксис (тоже успешно, синтаксических ошибок в неверной формуле нет).
  • Очередь дойдет до pytest. Наш тест ожидает индекс 22.86, а получит 40.0. Тест упадет.
Что сделает GitHub: Он немедленно прервет выполнение пайплайна. Около вашего коммита появится красный крестик ❌. Если это был Pull Request, система покажет, что проверки провалены, и вы сразу увидите, что код не готов к слиянию.
Если же всё написано правильно, пайплайн загорится зеленой галочкой ✅, и вы будете на 100% уверены, что код работает.
5. Оптимизация: Ускоряем CI и расширяем покрытие
Наш базовый пайплайн отлично справляется со своей задачей, но у него есть два узких места: он проверяет код только на одной версии Python и каждый раз скачивает все библиотеки заново. В реальных проектах это съедает кучу времени. Давайте это исправим.
Матрица тестирования (Matrix Strategy)
Представьте, что ваше приложение будет запускаться на разных серверах, или вы пишете open-source пакет для других разработчиков. Проверять код только на Python 3.11 недостаточно — вдруг в 3.10 нет нужной функции, и всё сломается?
Вместо того чтобы писать отдельные пайплайны или дублировать шаги для каждой версии, в GitHub Actions есть элегантное решение — матрица.
Мы добавляем блок strategy с указанием нужных версий, а в шаге установки Python заменяем жестко прописанную версию на переменную ${{ matrix.python-version }}.
jobs:
build-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"] # Указываем нужные версии
Что произойдет: GitHub автоматически поднимет три параллельных сервера и запустит ваши тесты одновременно на Python 3.10, 3.11 и 3.12. Время выполнения пайплайна останется прежним (задачи идут параллельно), а уверенность в коде вырастет втрое.
Кэширование зависимостей (Ускорение в разы)
Каждый раз при запуске шага установки зависимостей сервер честно идет в интернет, скачивает архивы FastAPI, Pytest и распаковывает их. В нашем микро-проекте это занимает секунд 10-15. Но в проектах с Pandas, SQLAlchemy или машинным обучением установка может длиться несколько минут.
Чтобы не скачивать одно и то же каждый раз, нужно включить кэш. Писать сложные скрипты не придется — разработчики официального экшена setup-python уже встроили этот функционал.
Добавляем всего одну строчку cache: 'pip' в наш шаг настройки Python:
steps:
- name: Скачиваем код
uses: actions/checkout@v4
- name: Устанавливаем Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip' # <-- Включаем магию кэширования
Как это работает под капотом: При первом запуске GitHub Actions скачает все пакеты и сохранит их в скрытый архив у себя на серверах. В качестве уникального «ключа» от этого архива он возьмет хеш вашего файла requirements.txt.
При следующем коммите сервер проверит: изменился ли requirements.txt? Если нет (вы просто правили код в main.py), он мгновенно достанет готовые библиотеки из кэша. Шаг установки зависимостей сократится до 1-2 секунд.
Собираем обновленный Job
Вот как теперь выглядит наша задача build-and-test с учетом всех оптимизаций:
jobs:
build-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- name: Скачиваем код репозитория
uses: actions/checkout@v4
- name: Устанавливаем Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Устанавливаем зависимости
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Проверка кода линтером (Ruff)
run: ruff check .
- name: Запуск автотестов (Pytest)
run: pytest
6. Настраиваем CD (Непрерывное развертывание)
Тесты горят зеленым, линтеры довольны, код в ветке main гарантированно рабочий. Что дальше?
Дальше код должен попасть к пользователям. Исторически разработчики собирали релизы на своих ноутбуках и копировали файлы на сервер через FTP или SSH. Это долго, чревато ошибками и совершенно не масштабируется. Мы поручим эту работу GitHub.
В этом разделе мы настроим CD (Continuous Deployment): сделаем так, чтобы при создании новой версии (тега) GitHub сам упаковывал наше FastAPI-приложение в Docker-образ и отправлял его в хранилище (Docker Hub), откуда сервер сможет его скачать.
Базовое требование: Dockerfile
Чтобы собрать образ, в корне проекта (рядом с requirements.txt) должен лежать файл Dockerfile. Если вы никогда с ним не работали, для нашего проекта он будет выглядеть максимально просто (всего 5 строк):
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ./src ./src
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
Безопасность: Прячем ключи в GitHub Secrets
Для публикации образа в Docker Hub пайплайну нужен ваш логин и пароль. Никогда не пишите пароли, токены или SSH-ключи в файлах .yml или в коде. Репозиторий могут сделать публичным, или доступ к нему получит новый сотрудник — и ваши данные утекут.
Для этого в GitHub есть Secrets — встроенный защищенный сейф для переменных окружения.
  • Зайдите на страницу вашего репозитория в GitHub.
  • Перейдите во вкладку Settings -> слева в меню Secrets and variables -> Actions.
  • Нажмите зеленую кнопку New repository secret.
  • Создайте два секрета:
  • Name: DOCKER_USERNAME | Secret: ваш логин на Docker Hub.
  • Name: DOCKER_TOKEN | Secret: ваш Access Token (лучше выпустить токен в настройках Docker Hub, а не использовать основной пароль от аккаунта).
pic
Теперь мы можем безопасно обращаться к этим данным в коде пайплайна через синтаксис ${{ secrets.DOCKER_USERNAME }}. В логах GitHub эти значения будут автоматически скрыты звездочками ***.
Практика деплоя: Пишем CD-задачу
Мы не хотим выкатывать каждый мелкий коммит из ветки main. Правильная практика — делать деплой только тогда, когда мы выпускаем релиз (создаем Git-тег, например, v1.0.0).
Для начала обновим блок on в самом начале нашего файла ci.yml, чтобы он реагировал на теги:
on:
push:
branches: [ "main" ]
tags: [ "v*.*.*" ] # Запускать и при создании тегов вида v1.0.0
pull_request:
branches: [ "main" ]
Теперь добавим новую задачу (Job) в самый конец файла. Назовем её build-and-push.
Важнейший момент: CD не должен начинаться, если CI упал. Мы свяжем эти две задачи параметром needs.
# ... здесь заканчивается наша предыдущая задача build-and-test ...
build-and-push:
# Запускаем эту задачу ТОЛЬКО если build-and-test завершилась успешно
needs: build-and-test
# Запускаем ТОЛЬКО если пуш содержит тег (а не просто коммит в main)
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Скачиваем код
uses: actions/checkout@v4
# Авторизуемся в Docker Hub с помощью наших спрятанных секретов
- name: Логин в Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
# Вытаскиваем версию из Git-тега (например, 1.0.0), чтобы назвать так образ
- name: Получаем версию релиза (тег)
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
# Собираем и пушим Docker-образ
- name: Сборка и отправка Docker-образа
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/bmi-api:latest
${{ secrets.DOCKER_USERNAME }}/bmi-api:${{ env.VERSION }}
Как это выглядит на практике
Теперь ваш рабочий процесс выглядит как на конвейере:
  • Вы работаете локально, пишите код.
  • Делаете git push. GitHub запускает только первую часть (build-and-test). Линтеры проверили код, тесты прогнались. CD-задача игнорируется, так как это не релиз.
  • Вы понимаете, что накопили достаточно фич для новой версии. В терминале вы создаете тег: git tag v1.0.0 и отправляете его на сервер: git push origin v1.0.0.
  • GitHub снова запускает тесты. Как только они загораются зеленым, автоматически стартует задача build-and-push.
  • Сервер авторизуется в Docker Hub, собирает образ bmi-api и вешает на него сразу два ярлыка: latest (последняя версия) и 1.0.0 (жестко зафиксированная версия).
Всё. Ваш свежий, проверенный и упакованный код лежит в Docker Hub. Любой сервер в мире теперь может скачать его командой docker pull и запустить.
7. Проверка, все ли настроили?
Этап 1. Проверяем СI (Тесты и Линтеры)
Для начала просто отправим наш новый файл конфигурации и Dockerfile на GitHub.
Откройте терминал и введите:
git add .
git commit -m "Добавил полный CI/CD пайплайн и Dockerfile"
git push
Что должно произойти:
  • Зайдите на GitHub во вкладку Actions.
  • Вы увидите, что пайплайн запустился. Но запустится только задача build-and-test (сразу 3 параллельных сервера для разных версий Python).
  • Задача build-and-push (работа с Docker) будет пропущена. Сервер проигнорирует её, потому что мы сделали просто коммит, а не релиз. Это значит, что наше условие if: startsWith(github.ref, 'refs/tags/') работает правильно!
pic
Этап 2. Проверяем CD (Сборка Docker-образа)
Теперь сымитируем выпуск новой версии (релиза), чтобы заставить GitHub собрать Docker-образ.
В терминале создаем Git-тег и отправляем его на сервер:
# Создаем ярлык версии (тег)
git tag v1.0.0
# Отправляем именно этот тег на GitHub
git push origin v1.0.0
Что должно произойти сейчас:
  • Снова идите во вкладку Actions в GitHub.
  • Вы увидите новый запуск пайплайна (он будет называться по имени коммита, но рядом будет значок бирки v1.0.0).
  • Сначала снова пробегут тесты (build-and-test).
  • Как только тесты успешно завершатся, вы увидите, что запустилась вторая задачаbuild-and-push.
  • Кликните на нее, чтобы открыть логи. Вы увидите, как сервер логинится в Docker Hub, шаг за шагом выполняет инструкции из Dockerfile и отправляет слои образа в интернет.
Этап 3. Финальная проверка (Где мой образ?)
Когда задача загорится зеленой галочкой ✅, нужно убедиться, что образ действительно долетел до хранилища.
Способ 1: Через браузер Зайдите на hub.docker.com, авторизуйтесь и перейдите в свой профиль. Там должен появиться новый репозиторий bmi-api. Если зайти в него и открыть вкладку Tags, вы увидите два тега: latest и 1.0.0.
Способ 2: Запуск на вашем компьютере Откройте терминал и просто попробуйте скачать и запустить этот свежий образ (замените ВАШ_ЛОГИН на логин от Docker Hub):
docker run -p 8000:8000 ВАШ_ЛОГИН/bmi-api:1.0.0
Если Docker скачал образ, и у вас запустился сервер Uvicorn — поздравляю! Вы настроили полноценный, рабочий CI/CD пайплайн.
8. Заключение
Чтобы вся эта автоматизация имела реальный смысл, остается сделать одну вещь в настройках GitHub: зайти в Settings -> Branches -> Add branch protection rule, указать ветку master и включите галочку Require status checks to pass before merging для нашей задачи build-and-test.
Всё. Теперь залить сломанный код в главную ветку физически невозможно — GitHub заблокирует кнопку слияния (Merge), пока пайплайн не загорится зеленой галочкой.
Что мы получили в итоге: За пару десятков строк конфигурации мы полностью делегировали серверу всю рутину. Он сам следит за чистотой кода через ruff, параллельно гоняет тесты на трех версиях Python, кэширует зависимости для скорости и автоматически собирает Docker-образы при выпуске релизов. Вы просто пишете код, а робот делает всё остальное.
Анонсы новых статей, полезные материалы, а так же если в процессе у вас возникнут сложности, обсудить их или задать вопрос по этой статье можно в моём Telegram-сообществе. Смело заходите, если что-то пойдет не так, — постараемся разобраться вместе.
Посмотреть исходники, структуру папок и забрать себе готовый рабочий файл ci.yml можно в репозитории: github.com/Zaplavs/bmi_project.-Источник
 
Loading...
Error