В проектах «Экспресс 42» — подразделения «Фланта», которое занимается DevOps-консалтингом и внедрением, — мы каждый день работаем с GitLab. И нередко встречаем у клиентов сложные пайплайны, которые можно сделать проще и эффективнее, используя встроенные фичи самого инструмента. 

Меня зовут Андрей Шилов, я инженер по DevOps-практикам и в этой статье поделюсь несколькими «неочевидными» ключевыми словами из .gitlab-ci.yml, которые сэкономят вам время и нервы. Возможно, какие-то из них вы ещё не используете и сможете с их помощью решить типичные проблемы поддержки CI/CD.

Буду рассматривать только фичи, доступные в опенсорсном GitLab Community Edition. Коммерческие версии платформы останутся за рамками статьи.

group of jobs: как сократить длинный список однотипных задач

Когда пайплайн запускает десятки похожих задач (Jobs), например линтинг для каждого микросервиса, его граф растягивается в нечитаемую простыню. Найти в ней упавшую задачу или оценить общий прогресс становится сложно. К счастью, GitLab умеет автоматически группировать такие задачи, если их названия следуют простому шаблону базовое_имя X/Y.

Например, так можно удобно сгруппировать задачи линтинга:

lint service 1/3:
  stage: test
  script:
    - cd service-auth && npm run lint

lint service 2/3:
  stage: test
  script:
    - cd service-payment && bundle exec rubocop

lint service 3/3:
  stage: test
  script:
    - cd service-notification && gofmt -d .

В результате вместо двух отдельных строк вы увидите одну компактную группу lint service (3). Её цвет сразу покажет общий статус — зелёный, если всё прошло хорошо, или красный, если хоть одна задача провалилась, а клик развернёт детали.

resource group: блокировка одновременного запуска

Когда несколько разработчиков одновременно пушат изменения в проекте с Terraform, их пайплайны пытаются параллельно запустить terraform apply. Это вызывает конфликт блокировок state-файла в удалённом бэкенде, и в результате большинство задач завершаются с ошибкой Error acquiring the state lock. Аналогичная проблема возникает с деплоем на окружения: параллельный запуск скриптов развёртывания ломает состояние инфраструктуры.

Ключевое слово resource_group решает эту проблему, создавая очередь выполнения для задач. Все задачи с одинаковым названием ресурсной группы во всех пайплайнах проекта выстраиваются в очередь и выполняются строго по одному, а не запускаются параллельно:

# Пример для работы с Terraform state.
tf apply:
  stage: apply
  script: terraform apply -auto-approve
  resource_group: terraform_lock # State-файл будет меняться только из одной задачи.

С такой конфигурацией, если три пайплайна одновременно запустят tf apply, первая задача начнёт выполнение, а две остальные перейдут в состояние Waiting for resource и будут ждать своей очереди. Это полностью исключает конфликты блокировок и race condition, выстраивая очередь выполнения в предсказуемую последовательность.

Для управления порядком (например, чтобы деплои шли от самого старого коммита) в настройках ресурсной группы можно через API выбрать режим oldest_first. Если вам нужна более тонкая настройка порядка ресурсных групп, обратитесь к документации.

allow_failure:exit_codes: обработка exit-кодов

Классическая ситуация: линтер в пайплайне завершается с кодом 1, чтобы указать на проблемы в коде. Но из-за этого падает весь пайплайн, хотя сборка и тесты прошли отлично. Чтобы обойти проблему, в скрипты нередко добавляют || true, что заставляет задачу всегда завершаться «успешно». И это плохая практика: она маскирует реальное состояние проверок, а вы можете пропустить важные алерты.

В GitLab есть готовое решение — allow_failure:exit_codes. Вместо того чтобы грубо игнорировать все ошибки, вы можете явно указать, какие коды завершения стоит считать некритичными. Задача с такими кодами будет помечена в интерфейсе как failed, но сам пайплайн продолжит работу и завершится успешно, хоть и со статусом Warning.

lint:
  script: ./run-linter.sh
  allow_failure:
    exit_codes: [1, 2]  

Этот подход полностью заменяет костыли вроде || true. Конфигурация становится прозрачной: состояние пайплайна точно отражает, что прошло, а что нет, а сам пайплайн не блокируется из-за некритичных сбоев. Так вы всегда увидите полную картину.

variables с options: выпадающий список для ручного запуска пайплайна

Фича inputs — мощный инструмент для параметризации пайплайнов, но её внедрение порой требует их переписывания. Если у вас большая устоявшаяся библиотека пайплайнов, это может стать breaking change.

Для более лёгкой кастомизации в GitLab есть альтернатива: расширенный синтаксис определения variables. Он позволяет задать для переменной список допустимых значений (options) и описание (description). При ручном запуске пайплайна GitLab отобразит переменную как выпадающий список с подсказкой, но для автоматических запусков будет использовано значение по умолчанию (value).

variables:
  DEPLOY_ENVIRONMENT:
    value: "staging"
    options:
      - "production"
      - "staging"
      - "canary"
    description: "Окружение для деплоя. По умолчанию: 'staging'."

deploy:
  stage: deploy
  script: ./deploy.sh --env $DEPLOY_ENVIRONMENT
  environment: $DEPLOY_ENVIRONMENT

Чем это решение отличается от inputs и когда стоит его использовать?

Если нужно быстро добавить выбор параметров для запуска в старый пайплайн, не перелопачивая всю конфигурацию, — используйте variables с options. Если вы закладываете основу для переиспользуемых шаблонов будущего — выбирайте inputs.

!reference: переиспользование атомарных конфигураций между файлами

Многие привыкли к YAML-якорям (&, *) и extends для повторного использования конфигурации внутри одного файла. Но из-за них зачастую возникают проблемы, когда шаблоны подключаются через include. Якоря просто не работают между файлами, а extends копирует шаблон целиком, не давая взять только кусочек.

Здесь выручает тег !reference. Он позволяет точечно ссылаться на конкретный блок конфигурации — например, только на rules или script — из другой задачи, даже если она описана в отдельном подключаемом файле шаблона. В итоге вы можете собирать пайплайн из готовых деталей конструктора.

Допустим, у вас в одном шаблоне лежат общие rules для production, а в другом — стандартный скрипт настройки окружения. С !reference вы можете собрать их в одной задаче, не дублируя код:

# configs.yml
.prod_deploy_rules:
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
---
# .gitlab-ci.yml
include:
  - local: 'configs.yml'

.deploy_setup:
  script:
    - echo "Настраиваем окружение"

deploy_app:
  stage: deploy
  script:
    - !reference [.deploy_setup, script]  # Берём скрипт из локального шаблона.
    - ./deploy.sh
  rules: !reference [.prod_deploy_rules, rules] # А rules — из внешнего файла.

Если ваш пайплайн перерос единственный YAML-файл и вы хотите собирать задачи из готовых «кирпичиков» конфигурации, лежащих в разных местах, то !reference — именно тот инструмент, который делает эту историю элегантной и обеспечивает соответствие принципу DRY.

Однако будьте внимательны. Злоупотребление тегом !reference может сильно ударить по читаемости пайплайна, поэтому использовать его стоит только при явной необходимости.

сoverage: встроенная визуализация покрытия тестами прямо в merge request

Многие команды генерируют отчёты о покрытии тестами, но эти данные часто остаются за пределами code review — в отдельных файлах или CI-логах. Чтобы оценить, как новый код влияет на покрытие, приходится открывать сторонние отчёты и сравнивать цифры вручную.

GitLab решает это нативно, встраивая метрики покрытия прямо в интерфейс Merge Request. Ключевые фичи этого функционала доступны в CE-редакции. Давайте посмотрим, как он работает, на примере.

Представим, что нам нужно настроить отчёт о покрытии тестами для Python-проекта. Для демонстрации создадим простой проект с двумя ветками.

Шаг 1. Готовим целевую ветку (coverage).

Создадим в репозитории ветку с именем coverage. В ней должны быть три файла: my_app/calculator.py, tests/test_calculator.py и .gitlab-ci.yml.

my_app/calculator.py — это наш тестируемый модуль:

def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

def multiply(a, b):
    return a * b

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero!")
    return a / b

# def unused_function():
#     """Эта функция не покрыта тестами"""

#     """Написана для демонстрации функционала"""    

#     return "no tests"

Обратите внимание: функция unused_function закомментирована.

tests/test_calculator.py — это наши тесты:

import pytest
from my_app.calculator import add, subtract, multiply, divide

def test_add():
    assert add(2, 3) == 5

def test_subtract():
    assert subtract(5, 3) == 2

def test_multiply():
    assert multiply(2, 3) == 6

def test_divide():
    assert divide(6, 3) == 2

def test_divide_by_zero():
    with pytest.raises(ValueError):
        divide(6, 0)

А .gitlab-ci.yml — конфигурация пайплайна:

stages:
  - test

.test-template: &test-template
  stage: test
  image: python:3.11-slim
  before_script:
    - pip install pytest pytest-cov
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "coverage"

unit-test-coverage-percent:
  <<: *test-template
  script:
    - python -m pytest --cov=my_app --cov-report=term tests/
  coverage: '/TOTAL.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'

unit-test-coverage-visualization:
  <<: *test-template
  script:
    - python -m pytest --cov=my_app --cov-report=xml:coverage.xml tests/
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

Здесь две ключевые настройки:

  1. coverage — регулярное выражение, которое извлекает общий процент из вывода pytest-cov.

  2. artifacts:reports:coverage_report — загружает детальный XML-отчёт в формате Cobertura для визуализации.

Запушим все три файла в ветку coverage. Пайплайн запустится автоматически.

Шаг 2. Создаём feature-ветку и Merge Request.

Теперь создадим новую ветку от coverage, например feature/coverage. В ней раскомментируем последнюю функцию в calculator.py:

def unused_function():
    """Эта функция не покрыта тестами"""

    """Написана для демонстрации функционала"""    

    return "no tests"

Это действие добавит непокрытую тестами строку кода. Создадим Merge Request из feature/coverage в coverage.

Шаг 3. Смотрим результаты в интерфейсе Merge Request.

Как только пайплайн в MR завершится, мы увидим результат работы двух фич.

1. Общий процент покрытия тестами (coverage). В виджете пайплайна появится бейдж, например, 85 %. GitLab также начнёт строить график тренда покрытия на вкладке Analyze → Repository analytics.

2. Построчная визуализация (coverage_report). Это самое наглядное. В самой вкладке «Changes» вашего MR, в diff файла calculator.py, мы увидим цветовую маркировку:

  • Зелёная полоса слева у строк add, subtract и других — они покрыты тестами.

  • Оранжевая полоса у строки с def unused_function(): — GitLab прямо в интерфейсе ревью показывает, что эта новая строка не покрыта тестами.

Фича coverage превращает абстрактную метрику в практический инструмент код-ревью. Вместо того чтобы гадать о качестве тестов, вы сразу видите конкретные непокрытые строки в контексте изменений. Настройка для Python займёт лишние 10 минут, но взамен мы получим долгосрочный эффект для культуры качества в команде. 

Наш пример с Python — отправная точка. Для других языков и фреймворков (Ruby, Java, Go, JS) логика идентична: нужны вывод процента для регулярки и отчёт в Cobertura/JaCoCo. Готовые примеры конфигураций для десятков инструментов можно найти в документации GitLab.

parallel: многопоточные задачи для ускорения пайплайна

Когда в пайплайне появляется долгая задача, например прогон сотен тестов, она становится узким местом. Выполнение на одном раннере может занимать десятки минут. 

Ключевое слово parallel решает проблему «бутылочного горлышка». Оно позволяет разбить одну задачу на N одинаковых частей, которые выполняются одновременно на разных раннерах или на одном и том же со сконфигурированным concurrency. Это даёт почти линейный прирост скорости.

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

run-all-tests:
  stage: test
  script:
    - pytest --test-group-count $CI_NODE_TOTAL --test-group=$CI_NODE_INDEX
  parallel: 5

С такой конфигурацией GitLab создаст 5 задач с именами run-all-tests 1/5, run-all-tests 2/5 и так далее. В каждой задаче будут доступны предопределённые переменные CI_NODE_TOTAL (общее количество — 5) и CI_NODE_INDEX (порядковый номер текущего экземпляра задачи в группе параллельных задач — от 1 до 5). Эти переменные и используются скриптом, чтобы разделить работу. Вместо 5 используйте нужное вам число.

Но что, если нужно проверить не одну задачу в параллели, а одну логику в разных окружениях? Например, протестировать сборку на нескольких версиях Python и в разных операционных системах. Здесь на помощь приходит parallel:matrix.

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

test:
  stage: test
  script: ./test.sh
  parallel:
    matrix:
      - PYTHON_VERSION: ["3.9", "3.10", "3.11"]
        OS: ["ubuntu-22.04"]
      - PYTHON_VERSION: ["3.11"]
        OS: ["windows-2022", "macos-13"]

Этот конфиг создаст 5 параллельных задач: по одной для Python 3.9, 3.10, 3.11 на Ubuntu и дополнительно по одной для Python 3.11 на Windows и MacOS. Внутри каждой задачи будут доступны свои переменные PYTHON_VERSION и OS.

Когда использовать parallel, а когда — parallel:matrix?

parallel: 10 — представим, что ваш тестовый прогон занимает 50 минут. Разделите его на 10 частей и получите результат за ~5–7 минут, не меняя логики тестов.

parallel:matrix — вам нужно убедиться, что код или контейнер корректно работает на нескольких версиях языка, в разных ОС или с разными базами данных. Матрица запустит все комбинации одновременно.

В GitLab есть ограничение на 200 паралл��льных задач. На практике оно скорее архитектурное, но если вы приближаетесь к этому лимиту, то, возможно, пайплайн пора пересматривать.

Заключение

В этой статье я сознательно фокусировался на точечных, но мощных возможностях GitLab CI — тех, что не требуют перелопачивания архитектуры, зато сразу дают заметный эффект. 

Если текст был вам полезен, дайте знать в комментариях или плюсом. Тогда я напишу вторую часть, в которой разберу инструменты для построения сложных CI/CD-ландшафтов: каскадные пайплайны (downstream, child pipelines), гибкое управление окружениями, глубокую параметризацию через inputs и создание собственной библиотеки шаблонов с помощью gitlab components. 

И рекламная пауза напоследок:

  • Если вы ищете достойную замену GitLab, присмотритесь к Deckhouse Code. Он полностью совместим с GitLab CE и значительно расширяет его — многие фичи аналогичны GitLab EE. А ещё он подойдёт тем, кому важна запись в реестре российского ПО.

  • Хотите улучшить свой SDLC? Приходите на диагностику в «Экспресс 42» — проанализируем ваши процессы и предложим индивидуальное решение.