Со старта нашего проекта Polymatica EPM (бизнес‑платформа для автоматизации процессов стратегического планирования и бюджетирования) мы решили: код должен покрываться тестами. Проект построен на стеке FastAPI + Poetry + Pytest. Из‑за особенностей проекта тесты, в основном, функциональные. Все шло хорошо, команда росла, тесты писались, но запускались только на локальной машине перед коммитами. Наступил момент, когда нужно было внедрить автоматический прогон тестов на этапе Merge Request (MR).
На тот момент у нас был собственный GitLab и настроенный CI/CD, но ресурсы DevOps были ограничены. Поэтому задачу пришлось решать силами разработчиков. Меня зовут Дмитрий Богданов, я старший бэкенд‑разработчик, и в этой статье расскажу, как мы оптимизировали запуск тестов, с какими проблемами столкнулись и почему выбрали именно базовый образ для CI/CD.

Выбор подхода для запуска тестов
Каждый раз устанавливать зависимости на CI/CD‑воркере или собирать новый образ.
Минусы: долгое время сборки, загрязнение воркера лишними зависимостями.
Использовать тот же image, который выкатывается на продакшен‑стенд.
Минусы: возможные ошибки при сборке стенда, необходимость каждый раз доустанавливаем пакеты для тестирования, что опять же увеличивает время.
Использовать базовый образ со всеми зависимостями, включая тестовые.
Минусы: требует пересборки при изменении зависимостей, а после тестов нужно заново собирать продакшен‑образ.
Мы выбрали третий вариант, так как он обеспечивал баланс между скоростью тестирования и удобством управления зависимостями.
Особенности реализации
Начальная структура репозитория
monorep/ ├── service_1/ │ ├── app/ │ ├── Dockerfile │ ├── poetry.lock │ └── pyproject.toml ├── service_2/ │ ├── app/ │ ├── Dockerfile │ ├── poetry.lock │ └── pyproject.toml ├── service_3/ │ ├── app/ │ ├── Dockerfile │ ├── poetry.lock │ └── pyproject.toml ├── .gitlab_ci.yml
monorep/ — корневой каталог монорепозитория.
service_1/, service_2/, service_3/ — подкаталоги с сервисами, каждый из которых содержит:
Dockerfile — файл для сборки Docker‑образа.
pyproject.toml — файл конфигурации для Poetry.
poetry.lock — файл с зафиксированными зависимостями.
app/ — каталог с кодом приложения, тесты находятся тут же.
В корне монорепозитория находятся:
.gitlab_ci.yml — файл конфигурации для GitLab CI/CD.
Для реализации нашего варианта нам нужно собрать все зависимости из всех сервисов и собрать их вместе. Также нам потребуется Dockerfile для «базового образа».
Собираем зависимости
Начнем по порядку — соберем все зависимости. В наше проекте каждый сервис содержит свои зависимости в poetry, в целом используется одинаковый стек, однако бывают специфические библиотеки (например polars). Для экспорта requirements.txt используем команду:
poetry export --without-hashes ‑f requirements.txt ‑output requirements1.txt
Теперь у нас есть несколько requirements.txt, можно объединить их вручную в один файл, но мы написали скрипт на python:
import argparse from packaging.requirements import Requirement, InvalidRequirement def parse_requirements(file_path): dependencies = {} options = set() with open(file_path, 'r') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue if ' -- ' in line: dep_part, options_part = line.split(' -- ', 1) current_options = [' -- ' + opt.strip() for opt in options_part.split(' -- ')] options.update(current_options) else: dep_part = line current_options = [] dep_part = dep_part.split(';')[0].strip() if not dep_part: continue try: req = Requirement(dep_part) dep_name = req.name dependencies[dep_name] = dep_part except InvalidRequirement: print(f"⚠️ Ошибка парсинга: '{dep_part}' в файле {file_path} пропущена.") continue return dependencies, options def main(): parser = argparse.ArgumentParser(description='Объединяет несколько requirements.txt') parser.add_argument('files', nargs='+', help='Список файлов для объединения') parser.add_argument('-o', '--output', default='requirements_all.txt', help='Выходной файл') args = parser.parse_args() all_options = set() combined_deps = {} seen_files = set() for file_path in args.files: if file_path in seen_files: continue seen_files.add(file_path) deps, opts = parse_requirements(file_path) all_options.update(opts) for dep_name, dep_spec in deps.items(): if dep_name in combined_deps: print(f"⚠️ Конфликт: {dep_name} заменен на версию из {file_path} ({dep_spec})") combined_deps[dep_name] = dep_spec sorted_options = sorted(all_options) sorted_deps = sorted(combined_deps.items(), key=lambda x: x[0].lower()) with open(args.output, 'w') as f: if sorted_options: f.write('\n'.join(sorted_options) + '\n\n') for dep_name, dep_spec in sorted_deps: f.write(f"{dep_spec}\n") print(f"✅ Файл {args.output} успешно создан!") if __name__ == '__main__': main()
Для использования:
pip install packaging python merge_requirements.py requirements1.txt requirements2.txt requirements3.txt -o requirements_all.txt
Пишем Dockerfile для базового образа
FROM python:3.10.12-slim RUN pip install --no-cache-dir --upgrade pip COPY ./requirements_all.txt requirements_all.txt RUN pip install -r requirements_all.txt
Итоговая структура репозитория
monorep/ ├── service_1/ │ ├── app/ │ ├── Dockerfile │ ├── poetry.lock │ └── pyproject.toml ├── service_2/ │ ├── app/ │ ├── Dockerfile │ ├── poetry.lock │ └── pyproject.toml ├── service_3/ │ ├── app/ │ ├── Dockerfile │ ├── poetry.lock │ └── pyproject.toml ├── .gitlab_ci.yml ├── Dockerfile_gitlab └── requirements_all.txt
CI и настройка Gitlab
Сборку базового образа вынесем в отдельный шаг. Его пересборка будет достаточно редкой, так как будет нужна только при добавлении/удалении библиотеки. Также нам нужно будет создать шаги для запуска тестов на этапе MR и при сборке образа для деплоя.
image: alpine variables: PRETEST: pretest stages: - pretest - test - dockerize pretest: stage: pretest image: alpine:latest only: changes: - requirements_all.txt - Dockerfile_gitlab script: - apk add --no-cache bash docker - IMAGE=${CI_REGISTRY_IMAGE}/${PRETEST} - DOCKERFILE="-f Dockerfile_gitlab" - docker build $DOCKERFILE -t ${IMAGE}:$IMAGE_VERSION . - docker push ${IMAGE}:$IMAGE_VERSION - docker tag ${IMAGE}:$IMAGE_VERSION ${IMAGE}:latest - docker push ${IMAGE}:latest - echo $IMAGE:$IMAGE_VERSION > IMAGE_${PRETEST} artifacts: paths: - IMAGE_/c{PRETEST} when: manual allow_failure: true ### Test ### .test_template: &test_template stage: test image: ${CI_REGISTRY_IMAGE}/${PRETEST}:latest allow_failure: true rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_JOB_NAME == "service1:test" changes: - service1/**/* when: always - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_JOB_NAME == "service2:test" changes: - service2/**/* when: always - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_JOB_NAME == "service3:test" changes: - service3/**/* when: always - if: $CI_COMMIT_REF_NAME =~ /^(dev|stage)$/ && $CI_JOB_NAME == "service1:test" changes: - service1/**/* when: always - if: $CI_COMMIT_REF_NAME =~ /^(dev|stage)$/ && $CI_JOB_NAME == "service2:test" changes: - service2/**/* when: always - if: $CI_COMMIT_REF_NAME =~ /^(dev|stage)$/ && $CI_JOB_NAME == "service3:test" changes: - service3/**/* when: always - when: never script: - cd ${SERVICE}_service - python --version - | if [[ -f "migrate.py" ]]; then python migrate.py fi - pytest tests -vv --color yes --cov --cov-report term --cov-report xml:coverage.xml --junitxml=report.xml - cd .. coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/' artifacts: reports: coverage_report: coverage_format: cobertura path: ${SERVICE}_service/coverage.xml junit: ${SERVICE}_service/report.xml service1:test: variables: SERVICE: service1 <<: *test_template service2:test: variables: SERVICE: service2 <<: *test_template service3:test: variables: SERVICE: service3 <<: *test_template ### Dockerize ### .dockerize_template: &dockerize_template stage: dockerize image: alpine:latest rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" when: never - if: $CI_COMMIT_REF_NAME =~ /^(dev|stage)$/ && $CI_JOB_NAME == "service1:dockerize" changes: - service1/**/* when: on_success allow_failure: true - if: $CI_COMMIT_REF_NAME =~ /^(dev|stage)$/ && $CI_JOB_NAME == "service2:dockerize" changes: - service2/**/* when: on_success allow_failure: true - if: $CI_COMMIT_REF_NAME =~ /^(dev|stage)$/ && $CI_JOB_NAME == "service3:dockerize" changes: - service3/**/* when: on_success allow_failure: true - when: never script: - apk add --no-cache bash docker - IMAGE=${CI_REGISTRY_IMAGE}/${SERVICE} - DOCKERFILE="-f ${SERVICE}_service/Dockerfile" - docker build $DOCKERFILE -t ${IMAGE}:$IMAGE_VERSION . - docker push ${IMAGE}:$IMAGE_VERSION - echo $IMAGE:$IMAGE_VERSION > IMAGE_${SERVICE} artifacts: paths: - IMAGE_${SERVICE} service1:dockerize: needs: - job: "service1:test" optional: true variables: SERVICE: service1 <<: *dockerize_template service2:dockerize: needs: - job: "service2:test" optional: true variables: SERVICE: service2 <<: *dockerize_template service3:dockerize: needs: - job: "service3:test" optional: true variables: SERVICE: service3 <<: *dockerize_template
О том, как посмотреть результаты тестов, хорошо описано в официальной документации Gitlab.
Итоги
Мы используем этот подход уже более года, и он доказал свою эффективность:
среднее время прохождения тестов — 2–3 минуты на сервис,
тесты выполняются автоматически при MR, избавляя от ручного запуска,
базовый образ минимизировал время установки зависимостей.
Сейчас мы прорабатываем новую стратегию, так как часть сервисов выносятся из монорепозитория. Но наш опыт показывает, что базовый образ — отличное решение для ускорения тестов в CI/CD.
