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, HTTPExceptionapp = 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 TestClientfrom src.main import appclient = 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-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY ./src ./srcCMD ["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# Отправляем именно этот тег на GitHubgit 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.
ссылка на оригинал статьи https://habr.com/ru/articles/1037002/