|
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, а не использовать основной пароль от аккаунта).
 Теперь мы можем безопасно обращаться к этим данным в коде пайплайна через синтаксис ${{ 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/') работает правильно!
 Этап 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.-Источник
|