Как упростить GitLab CI: семь приёмов для настройки пайплайна

Разберём семь возможностей GitLab CI, доступных без платной подписки, от простого к сложному: начнём с того, как сделать граф пайплайна компактнее, и закончим распределением тестов между параллельными задачами.

Обложка: Как упростить GitLab CI: семь приёмов для настройки пайплайна

Пайплайн успешно завершился, несмотря на найденные линтером ошибки. Тесты прошли дважды: для процента покрытия и для XML-отчёта завели разные задачи, и каждая заново прогоняет весь набор тестов. А два соседних пайплайна одновременно начали обновлять одно окружение. Такие ситуации требуют разных исправлений: настроить статусы проверок, убрать повторную работу и ограничить одновременный доступ к ресурсу.

Примеры в статье независимы: их можно адаптировать по отдельности. Для Python-задач ниже используются каталоги my_app и tests, а команды установки зависимостей показаны прямо в YAML.

Как свернуть похожие задачи в графе пайплайна

Когда в пайплайне становится много похожих задач — например, из-за ручного дублирования или распараллеливания, — граф может разрастись в длинный список строк. Если несколько задач выполняют похожие проверки, GitLab умеет объединять их в визуальную группу по именам. Для этого подойдёт общий префикс и нумерация X/Y:

			checks 1/3:
  image: python:3.12-slim
  script:
    - python -m compileall -q my_app
    
checks 2/3:
  image: python:3.12-slim
  script:
    - python -m pip install ruff
    - ruff check my_app tests
    
checks 3/3:
  image: python:3.12-slim
  script:
    - python -m pip install pytest
    - python -m pytest tests/
		

В обычном графе три задачи свернутся в группу checks, которую можно раскрыть для просмотра отдельных результатов. В мини-графах GitLab такую группировку не применяет.

Этот приём меняет только представление. Задачи сохраняют свои команды, статусы и требования к раннерам. Они не начинают выполняться быстрее и не получают общую блокировку из-за совпадения префикса. Для экземпляров, созданных через числовой parallel, GitLab сам добавляет нумерацию к именам.

Как поставить изменения общего ресурса в очередь

Несколько пайплайнов могут выполняться одновременно — например, из разных веток или мёрж-реквестов (Merge Request). Если они меняют одно и то же окружение, это может привести к конфликту: пока первый пайплайн применяет инфраструктурную конфигурацию, второй пытается сделать то же самое.

Resource group позволяет выполнять задачи с одним ключом по очереди в пределах проекта. Например, для проекта с Terraform-кодом в каталоге infra можно настроить ручной запуск:

			apply_infrastructure:
  stage: deploy
  when: manual
  resource_group: terraform-production
  script:
    - terraform -chdir=infra init -input=false
    - terraform -chdir=infra apply -input=false -auto-approve
		

Этот фрагмент рассчитан на раннер с установленным Terraform и уже настроенным доступом к инфраструктуре и её удалённому бэкенду. Оператор запускает задачу вручную; параметр auto-approve позволяет команде apply завершиться без интерактивного подтверждения внутри CI.

Если задача с ключом terraform-production уже выполняется, другая задача с таким же ключом в этом проекте будет ждать освобождения ресурса. Однако очередь не защищает от terraform apply, запущенного с ноутбука, из другого проекта или из задачи без нужного resource_group. Блокировка состояния на стороне Terraform по-прежнему нужна.

Порядок очереди тоже настраивается отдельно. Режим по умолчанию unordered не гарантирует, что первым пойдёт старейший пайплайн. Для обработки по возрастанию идентификаторов пайплайнов выберите oldest_first через API ресурсных групп. Это порядок пайплайнов, который не следует путать с датами коммитов.

Как разрешить ожидаемый код ошибки и сохранить остальные проверки

На этапе внедрения линтера команда может решить, что найденные нарушения пока не должны блокировать пайплайн. Если добавить к команде конструкцию || true, оболочка скроет любой ненулевой код завершения, включая ошибку запуска инструмента.

Более точное правило задаёт allow_failure:exit_codes. Например, Ruff возвращает код 1 при найденных нарушениях, а код 2 при нештатном завершении, в том числе из-за неверной конфигурации:

			lint:
  stage: test
  image: python:3.12-slim
  before_script:
    - python -m pip install ruff
  script:
    - ruff check my_app tests
  allow_failure:
    exit_codes: [1]
		

При коде 1 GitLab отметит задачу предупреждением и позволит пайплайну продолжиться. Код 2 останется обычной ошибкой и повлияет на итоговый статус. Успех всего пайплайна при этом зависит и от остальных задач.

Разрешайте только те коды, значение которых известно для выбранного инструмента. У другого линтера код 1 может означать совсем другую проблему. Когда команда сделает проверку обязательной, настройку allow_failure можно удалить, сохранив ту же команду запуска.

Как добавить выбор параметра при ручном запуске

В существующем пайплайне переменная может управлять режимом проверки: запустить тесты или дополнительно вывести покрытие. Чтобы человек не вводил значение по памяти, задайте список options для переменной:

			variables:
  TEST_MODE:
    value: "tests"
    options:
      - "tests"
      - "coverage"
    description: "Запустить тесты или тесты с отчётом о покрытии."
    
checks:
  stage: test
  image: python:3.12-slim
  before_script:
    - python -m pip install pytest pytest-cov
  script:
    - |
      case "$TEST_MODE" in
        tests)
          python -m pytest tests/
          ;;
        coverage)
          python -m pytest --cov=my_app --cov-report=term tests/
          ;;
        *)
          printf 'Unknown TEST_MODE: %s\n' "$TEST_MODE" >&2
          exit 2
          ;;
      esac
		

На странице ручного запуска GitLab покажет список с описанием. Значение value должно входить в options. Оно задаёт значение по умолчанию, но переменную могут переопределить другие источники с более высоким приоритетом. Поэтому скрипт отдельно отклоняет неизвестный режим.

Options полезен для интерфейса запуска пайплайна, который уже использует переменные. Для новых параметров рассмотрите CI/CD inputs: они задают типы и проверяют ограничения при создании пайплайна. Выбор между этими механизмами зависит от того, как параметр должен проверяться и использоваться, а не от размера YAML-файла.

Как переиспользовать часть конфигурации из другого файла

После настройки выполнения часто обнаруживается другой источник работы: одинаковые команды установки зависимостей скопированы в несколько задач. Изменение приходится повторять в каждом месте, и конфигурации постепенно расходятся.

Тег !reference позволяет взять выбранный блок из другой задачи или скрытого шаблона, в том числе из файла, подключённого через include. Например, вынесите подготовку Python-окружения в .ci/python.yml:

			.python_setup:
  before_script:
    - python -m pip install pytest pytest-cov
		

В основном .gitlab-ci.yml подключите файл и возьмите из шаблона только before_script:

			include:
  - local: .ci/python.yml
  
unit_tests:
  stage: test
  image: python:3.12-slim
  before_script: !reference [.python_setup, before_script]
  script:
    - python -m pytest tests/
		

Начальная точка в имени .python_setup делает его скрытым шаблоном: отдельную задачу GitLab для него не запускает. Остальные параметры unit_tests остаются рядом с самой задачей.

YAML-якоря действуют только внутри файла, где объявлены. При этом extends тоже умеет работать с подключёнными файлами: он подходит для наследования общей конфигурации задачи. !reference удобен, когда нужен конкретный раздел. Если для понимания одной команды приходится переходить через несколько вложенных ссылок, такая экономия строк уже затрудняет сопровождение.

Как получать два отчёта о покрытии тестами за один прогон

GitLab поддерживает два независимых механизма отображения покрытия кода тестами. Ключ coverage извлекает общий процент из лога задачи. Настройка artifacts:reports:coverage_report передаёт XML-отчёт, по которому GitLab отмечает покрытые и непокрытые строки в изменениях мёрж-реквеста. Эти механизмы можно подключить к одной задаче.

Для проверки создайте небольшой модуль my_app/calculator.py:

			def add(left, right):
    return left + right
    
    
def divide(left, right):
    if right == 0:
        raise ValueError("Division by zero")
    return left / right
    
    
def unused_function():
    return "This branch has no test"
		

В tests/test_calculator.py добавьте проверки сложения и деления:

			import pytest

from my_app.calculator import add, divide


@pytest.mark.parametrize("left,right,result", [(2, 3, 5), (-2, 2, 0)])
def test_add(left, right, result):
    assert add(left, right) == result
    
    
def test_divide():
    assert divide(9, 3) == 3
    
    
def test_divide_by_zero():
    with pytest.raises(ValueError):
        divide(1, 0)
		

Теперь настройте задачу в .gitlab-ci.yml. Плагин pytest-cov умеет формировать несколько отчётов за один запуск:

			unit_tests:
  stage: test
  image: python:3.12-slim
  before_script:
    - python -m pip install pytest pytest-cov
  script:
    - python -m pytest --cov=my_app --cov-report=term-missing --cov-report=xml:coverage.xml tests/
  coverage: '/TOTAL.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
		

Команда python -m pytest --cov=my_app --cov-report=term-missing --cov-report=xml:coverage.xml tests/ выводит покрытие в терминал и сохраняет тот же результат в coverage.xml. Регулярное выражение для pytest-cov забирает процент из строки TOTAL. В рабочем проекте закрепите версии зависимостей в принятом у вас файле требований: изменение формата вывода может потребовать правки регулярного выражения.

Чтобы увидеть построчную разметку, добавьте эти файлы в отдельной ветке и откройте мёрж-реквест. После завершения пайплайна GitLab обработает отчёт и покажет аннотации в изменённых файлах. В примере непокрытой останется строка return внутри unused_function. Само объявление функции выполняется при импорте модуля, поэтому считать строку def заведомо непокрытой было бы ошибкой.

Процент помогает заметить изменение объёма проверяемого кода, а построчная разметка указывает, где искать пробелы. Ни один из этих показателей не проверяет, насколько содержательны утверждения assert в тестах.

Как распределить тесты между параллельными задачами

Когда отчёты, статусы и конфигурация задач настроены, можно разбирать время выполнения. Ключ parallel создаёт несколько экземпляров задачи. Для каждого GitLab задаёт номер CI_NODE_INDEX и общее количество CI_NODE_TOTAL. Распределение самих тестов должен выполнить тестовый инструмент: обычная команда pytest в каждом экземпляре повторит весь набор.

В Python эту работу можно поручить плагину pytest-split. Сначала локально выполните полный прогон и сохраните длительности тестов:

			python -m pip install pytest pytest-split
python -m pytest --store-durations tests/
		

Плагин создаст файл .test_durations. Добавьте его в репозиторий, чтобы все экземпляры CI-задачи использовали одинаковые данные. Затем задайте три группы:

			split_tests:
  stage: test
  image: python:3.12-slim
  before_script:
    - python -m pip install pytest pytest-split
  script:
    - python -m pytest --splits "$CI_NODE_TOTAL" --group "$CI_NODE_INDEX" tests/
  parallel: 3
		

GitLab создаст три задачи, а pytest-split выберет для каждой её часть набора. Когда состав и длительности тестов заметно изменятся, обновите .test_durations. Наборы с зависимостью от порядка запуска сначала нужно подготовить к разделению.

Для одновременного выполнения нужны свободные раннеры или несколько доступных слотов на одном раннере. Если мощности не хватает, часть задач будет ждать. Время также зависит от самой долгой группы, подготовки окружения и установки зависимостей, поэтому трёхкратное увеличение числа задач не обещает трёхкратного ускорения.

Если нужно проверить один набор тестов на разных версиях Python, используйте parallel:matrix:

			python_versions:
  stage: test
  image: "python:${PYTHON_VERSION}-slim"
  before_script:
    - python -m pip install pytest
  script:
    - python -m pytest tests/
  parallel:
    matrix:
      - PYTHON_VERSION: ["3.11", "3.12", "3.13"]
		

Здесь версия меняется: её значение подставляется в имя образа, и GitLab запускает каждую задачу в своём контейнере с нужным Python. Если добавить переменную OS, она сама по себе не переключит операционную систему. Для Windows или macOS нужны подходящие раннеры и их выбор, например через теги, заданные переменными матрицы. Команды задачи тоже должны работать в выбранной среде.

Что проверить после изменения конфигурации

Перед переносом настроек в основной пайплайн:

  • Прогоните конфигурацию через CI Lint с подключаемыми файлами. Проверьте правила создания задач для push, мёрж-реквеста и ручного запуска.
  • Убедитесь, что каждый тест попадает в нужную группу, а самая долгая группа не сводит выигрыш от параллельности к минимуму. Сравните полное время пайплайна с исходным.
  • Проверьте логи и XML-отчёт покрытия на заведомо непокрытой строке. Найденный процент и построчная разметка должны описывать один прогон.
  • Вызовите разрешённую ошибку линтера и отдельно ошибку его конфигурации. Убедитесь, что пайплайн различает эти случаи.
  • На тестовом окружении запустите два пайплайна с общей ресурсной группой. Проверьте ожидание ресурса и выбранный порядок обработки.
  • Передайте неподдерживаемое значение TEST_MODE при запуске. Задача должна явно отклонить его, не переходя к тестам или другим действиям.