Предисловие
Статья получилась большой: практик много, и каждая из них важна по-своему. Я собрал материал как набор best practices: не все пункты нужны каждому проекту, но почти каждый пункт однажды всплывает на ревью, при оптимизации медленного пайплайна, при разборе утечки секрета или после тяжелого инцидента.
Я старался писать для разных грейдов: от базовой гигиены вроде workflow:rules, cache, artifacts и needs до более продакшеновых тем вроде OIDC, Vault, CI_JOB_TOKEN, защищённых окружений, ревью-окружений, очередей слияния, BuildKit без root-прав, CI/CD-компонентов и усиления защиты раннеров.
Поэтому язык подачи здесь намеренно сухой, прямой и инженерный: без долгих заходов, без воды и без пересказа документации ради пересказа. Я хотел сделать не обзорную статью, а рабочую памятку, к которой можно вернуться при написании нового пайплайна, ревью .gitlab-ci.yml, переносе проекта в GitLab или наведении порядка в уже существующей CI/CD-платформе.
Чтобы в статье было легче ориентироваться, я разбил её на смысловые блоки. Ниже оглавление: нажали на нужный пункт — сразу перешли к соответствующему разделу.
Оглавление:
-
Переиспользование:
extends, шаблоны, компоненты и входные параметры -
Сборки Docker-образов, BuildKit, Dependency Proxy и кеш в реестре
Другие статьи из этой серии:
Отдельно буду рад вашим дополнениям в комментариях: практическим кейсам, спорным моментам, личному опыту, ошибкам, которые всплывали в реальной эксплуатации, и альтернативным подходам. Я читаю обратную связь и при необходимости обновляю материал: уточняю формулировки, исправляю неточности и добавляю полезные замечания, если они делают статью сильнее и точнее.
1. Зачем вообще думать о GitLab CI/CD
GitLab CI/CD часто начинается с простого файла:
stages: - build - test - deploy
На первых этапах этого хватает. Есть сборка, есть тесты, есть деплой. Но по мере роста проекта этот файл почти всегда превращается в отдельную инженерную систему. В нём появляются условия запуска, разные типы пайплайнов, кеши, артефакты, секреты, ревью-окружения, child-пайплайны, контейнерные сборки, блокировки деплоя, согласования, проверки безопасности, release-джобы и общие шаблоны для десятков репозиториев.
Плохой GitLab CI/CD обычно ломается не сразу. Он сначала просто становится медленным, потом непонятным, потом дорогим, потом опасным. Сначала команда ждёт пайплайн 40 минут. Потом появляются дублирующиеся пайплайны веток и MR-пайплайнов. Потом один джоб случайно получает продакшен-секрет. Затем два деплоя одновременно пишут в одно окружение. Потом оказывается, что общий шаблон подключался по main, вчера изменился и сломал все проекты.
Хороший GitLab CI/CD решает несколько задач одновременно:
-
Воспроизводимость. Один и тот же коммит должен проходить через понятный, предсказуемый пайплайн. Если поведение зависит от случайного состояния кеша, плавающего шаблона или неявной переменной, это уже риск.
-
Быстрая обратная связь. Разработчик должен быстро понять, сломал ли он код, стиль, тесты, сборку или деплой. Чем позже падает очевидная ошибка, тем дороже она стоит.
-
Безопасность. Пайплайн имеет доступ к исходникам, токенам, артефактам, реестру, облакам и окружениям. Его нужно защищать как часть цепочки поставки ПО, это не «просто автоматизация».
-
Управляемая доставка. Деплой должен быть отслеживаемым, сериализованным, ограниченным по правам и понятным в интерфейсе. Особенно если речь про стейджинг, продакшен и Kubernetes.
-
Сопровождаемость.
.gitlab-ci.ymlдолжен читать не только автор. Его должны понимать разработчики, DevOps, SRE, специалисты по безопасности и новый человек в команде.
Правильная мысль тут такая: CI/CD — это не место, куда надо быстро накидать bash-команды. Это слой инженерной архитектуры проекта.
2. Архитектура пайплайна и базовая YAML-гигиена
2.1. Держите корневой .gitlab-ci.yml коротким и декларативным
Корневой .gitlab-ci.yml — это точка входа в CI/CD-конфигурацию. Через него мы должны быстро понять:
-
какие стадии есть в пайплайне;
-
какие типы пайплайнов вообще создаются;
-
где находятся общие шаблоны;
-
какие джобы относятся к lint/test/сборка/деплой;
-
какие зависимости между джобами критичны;
-
какие правила действуют для веток, MR, тегов и расписаний.
Плохой вариант — держать в корневом файле огромную простыню из сотен строк bash, десятков почти одинаковых джобов и случайных условий.
Лучше:
include: - local: .gitlab/ci/lint.yml - local: .gitlab/ci/test.yml - local: .gitlab/ci/build.yml - local: .gitlab/ci/deploy.ymlstages: - lint - test - build - deploy
Корневой файл должен быть картой, а не свалкой. Детали можно выносить в локальные подключаемые файлы, компоненты или шаблоны, но сама верхнеуровневая логика должна оставаться читаемой.
2.2. Общие настройки выносите в default, но не превращайте его в мусорную корзину
default нужен для значений, которые действительно общие для большинства джобов: базовый образ, политика повторных запусков, кеш, теги, before_script, timeout и так далее.
Пример:
default: image: node:22-bookworm-slim interruptible: true before_script: - node --version - npm --version
Это лучше, чем копировать один и тот же image и before_script в каждый джоб. Но есть обратная крайность: складывать в default всё подряд. Чем больше глобальных настроек, тем выше шанс, что отдельный джоб начнёт наследовать то, что ему не нужно.
Если джоб не должен наследовать общий default, используйте явное отключение:
release: inherit: default: false image: alpine:3.20 script: - ./release.sh
То же самое касается переменных:
sensitive-check: inherit: variables: false script: - ./run-isolated-check.sh
Хорошая практика — не бороться с наследованием, а явно показывать, где джоб живёт отдельно от общего поведения.
2.3. Не хардкодьте main, master и внутренние договорённости проекта
Если шаблон или компонент должен переиспользоваться, не зашивайте в него main, master, конкретные имена групп, окружений и реестр без необходимости.
Плохо:
rules: - if: $CI_COMMIT_BRANCH == "main"
Лучше:
rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
$CI_DEFAULT_BRANCH делает конфигурацию переносимой. Это особенно важно для общих шаблонов и компонентов, которые могут использоваться в разных проектах с разными ветками по умолчанию.
Если шаблон всё-таки зависит от конкретной веточной модели, это надо явно документировать. Иначе он будет выглядеть универсальным, но ломаться в проектах с другой организацией веток.
2.4. Пишите shell-блоки так, чтобы их можно было ревьюить
CI/CD часто превращается в bash-программу, спрятанную внутри YAML. Поэтому shell-часть надо писать так же аккуратно, как обычный код.
Плохо:
script: - apk add curl jq bash && curl -sSL https://example.com/script.sh | bash && ./deploy.sh prod
Лучше:
script: - | set -euo pipefail apk add --no-cache curl jq bash curl -fsSLo /tmp/script.sh https://example.com/script.sh chmod +x /tmp/script.sh /tmp/script.sh ./deploy.sh prod
Такой джоб проще читать, проще ревьюить и проще отлаживать. Особенно когда речь про деплой, работу с секретами, внешние API или автоматизацию релизов.
2.5. Проверяйте CI-конфигурацию до merge
Ошибки в .gitlab-ci.yml неприятны тем, что они ломают не приложение, а сам механизм проверки приложения. Поэтому CI Lint, проверка expressions и ревью изменений в CI-файлах должны быть частью процесса.
Отдельно стоит защищать изменения в CI-конфигурации через CODEOWNERS и политику защищённых веток. Если человек может поменять .gitlab-ci.yml, он фактически может поменять, какие команды будут выполнены и какие секреты увидят джобы.
Пример CODEOWNERS:
.gitlab-ci.yml @platform-team @security-team.gitlab/ci/** @platform-team @security-team
Для маленького проекта это может казаться избыточным. Для продакшен-системы это нормальная гигиена.
3. rules, workflow:rules и управление созданием пайплайна
3.1. Управляйте созданием пайплайна через workflow:rules
Тут важно различать две вещи:
-
workflow:rulesрешает, будет ли создан пайплайн вообще; -
rulesна уровне джоба решают, попадёт ли конкретный джоб в уже созданный пайплайн.
Если вы управляете только rules на уровне джоба, пайплайн всё равно может создаваться в ненужных ситуациях. Отсюда появляются дубли пайплайнов веток и MR, лишний расход раннеров и непонятная история проверок.
Базовый зрелый паттерн для ветки и процесса работы с MR:
workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push" when: never - if: $CI_COMMIT_BRANCH
Что здесь происходит:
-
если это MR-пайплайн — запускаем;
-
если это push-based пайплайн ветки, но по ветке уже есть открытый MR — не запускаем;
-
если это обычная ветка без MR — запускаем пайплайн ветки.
Условие && $CI_PIPELINE_SOURCE == "push" здесь важно, потому что пайплайны, запущенные триггером, API, расписанием и downstream-процессами тоже могут иметь $CI_COMMIT_BRANCH, и без проверки источника их можно случайно заблокировать. Так GitLab не гоняет два пайплайна на один и тот же коммит: пайплайн ветки и MR-пайплайн одновременно, но не ломает остальные типы пайплайнов.
3.2. Используйте CI_PIPELINE_SOURCE как главный переключатель логики
CI_PIPELINE_SOURCE показывает, откуда пришёл пайплайн: push, MR, schedule, API, trigger, parent-пайплайн и так далее.
В этой статье web-пайплайны для веток и тегов покрываются не отдельным правилом, а общими условиями по $CI_COMMIT_BRANCH и $CI_COMMIT_TAG. Источники external и external_pull_request_event намеренно не рассматриваются: если у вас есть интеграция с внешними pull request, их нужно разрешать отдельными правилами по CI_PIPELINE_SOURCE.
Отдельно стоит понимать, что есть и более редкие источники вроде chat, webide и security_orchestration_policy. В статье они не разбираются намеренно: это уже специализированные сценарии, и для них лучше писать отдельные правила под конкретный процесс.
Это лучше, чем строить сложные догадки только по ветке или тегу.
Пример:
nightly-tests: rules: - if: $CI_PIPELINE_SOURCE == "schedule" script: - ./run-nightly-tests.shmr-checks: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" script: - ./run-mr-checks.sh
Так пайплайн становится предсказуемым: джоб запускается не потому, что «так случайно совпали переменные», а потому что источник пайплайна явно подходит.
3.3. Не заканчивайте rules широким when: always без защитного workflow
Одна из частых причин дублей — rules на уровне джоба, которые заканчиваются слишком широким правилом.
Плохо:
test: rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - when: always script: - npm test
Такой джоб может попасть в разные типы пайплайнов, если вы не ограничили создание пайплайна на уровне workflow. В итоге push в ветку с открытым MR может породить и пайплайн ветки, и MR-пайплайн.
Лучше сначала ограничить создание пайплайна через workflow:rules, а потом уже настраивать джобы.
3.4. Не смешивайте rules и only/except
only/except — старый синтаксис. В старых проектах он встречается часто, но в новых конфигурациях лучше использовать rules и workflow:rules.
Проблема не только в том, что rules гибче. Проблема в смешении моделей. Джобы с rules и джобы с only/except могут вести себя по-разному по умолчанию. Когда таких джобов много, становится трудно понять, почему один джоб появился в пайплайне, а другой — нет.
Ещё один неочевидный нюанс: джобы без rules по умолчанию ведут себя как except: merge_requests. Поэтому если рядом есть джобы с rules, ориентированные на MR-пайплайны, один push в ветку с открытым MR легко порождает два пайплайна: пайплайн ветки для джобов без rules и MR-пайплайн для джобов с rules.
Целевое состояние:
workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push" when: never - if: $CI_COMMIT_BRANCHlint: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH script: - npm run lint
Если проект большой и унаследованный, мигрировать можно постепенно. Но лучше не оставлять гибрид навсегда.
3.5. Используйте rules:changes, compare_to и exists
Не каждый джоб должен запускаться на каждое изменение. Если поменялась документация, не всегда нужно гонять backend tests. Если поменялся frontend, не всегда нужно собирать Go-сервис.
Пример:
backend-tests: rules: - changes: - backend/**/* - go.mod - go.sum script: - go test ./...frontend-tests: rules: - changes: - frontend/**/* - package-lock.json script: - npm test
Для монорепозиториев это особенно важно. rules:changes позволяет не запускать половину пайплайна без причины.
Для больших монорепозиториев полезно помнить про лимиты: GitLab делает ограниченное число проверок по rules:changes, а на один блок rules:changes действует лимит по количеству путей и паттернов. На очень больших наборах изменений это может влиять на поведение правил, поэтому сложную логику лучше проверять отдельно и не превращать changes в огромный список из десятков разрозненных условий.
Если нужно сравнивать изменения с конкретной базой, используйте compare_to. Но учитывайте нюанс пайплайнов с результатом слияния: там база сравнения может быть временным merge-коммитом, и правила могут срабатывать шире, чем в обычном пайплайне ветки.
exists полезен для универсальных шаблонов:
node-tests: rules: - exists: - package.json script: - npm test
Так один и тот же шаблон можно подключать в разные проекты, и джоб появится только там, где он имеет смысл.
3.6. Отдельно обрабатывайте черновые MR
Для больших проектов полезно не тратить тяжёлые проверки на черновой MR.
Пример:
workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_DRAFT == "true" when: never - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push" when: never - if: $CI_COMMIT_BRANCH
Это экономит минуты раннеров и уменьшает шум. Главное — договориться в команде, что черновой MR действительно означает что пока не надо гонять полноценный CI.
Нюанс по версиям: $CI_MERGE_REQUEST_DRAFT появился в GitLab 17.10. Если у вас старая самостоятельно развёрнутая версия GitLab, используйте резервный вариант через $CI_MERGE_REQUEST_TITLE и регулярное выражение по заголовку MR.
3.7. Для ручных запусков используйте типизированные входные параметры вместо свободных переменных
Для ручных пайплайнов, переиспользуемых шаблонов и компонентов лучше использовать входные параметры CI/CD, а не набор произвольных переменных пайплайна без валидации.
Переменные удобны, когда значение должно быть доступно внутри джоба во время выполнения. Но если параметр влияет на структуру пайплайна или выбирается пользователем при запуске, входные параметры дают более строгий контракт:
Для новых конфигураций это особенно важно: GitLab постепенно смещает ручные запуски в сторону pipeline inputs, а не произвольных pipeline variables. Если вы строите контракт запуска для деплоя, отката или maintenance-процесса, inputs обычно безопаснее и предсказуемее, потому что их можно типизировать, ограничить и описать прямо в конфигурации.
-
тип значения;
-
значение по умолчанию;
-
список допустимых вариантов;
-
валидацию регулярным выражением;
-
описание для пользователя;
-
проверку на этапе создания пайплайна.
Пример:
spec: inputs: target_environment: default: staging options: - staging - production description: "Куда деплоить" run_migrations: type: boolean default: false description: "Запускать ли миграции"---deploy: script: - ./deploy.sh "$[[ inputs.target_environment ]]" - | if [ "$[[ inputs.run_migrations ]]" = "true" ]; then ./migrate.sh "$[[ inputs.target_environment ]]" fi
Такой пайплайн сложнее запустить с неправильными параметрами. Это особенно полезно для ручных деплоев, релизов, откатов и maintenance-джобов.
Важный момент по версиям: входные параметры CI/CD стали общедоступными в GitLab 17.0. Отдельные возможности вроде spec:inputs:rules, варианты для массивов и доступа к элементам массивов появились позже, поэтому для самостоятельно развёрнутого GitLab лучше проверять версию перед использованием свежих возможностей входных параметров.
4. DAG, needs, параллелизм, матрицы и быстрые проверки
4.1. Не стройте весь пайплайн только на стадиях
Классическая модель стадий простая:
stages: - lint - test - build - deploy
Но у неё есть ограничение: джобы следующей стадии ждут завершения всей предыдущей стадии. Если один долгий джоб в test не нужен для сборки документации, он всё равно может задержать весь пайплайн.
needs позволяет описывать реальные зависимости между джобами, а не только грубый порядок стадий.
lint: stage: lint needs: [] script: - npm run lintunit-tests: stage: test needs: [] script: - npm testbuild: stage: build needs: - unit-tests script: - npm run build
needs: [] говорит GitLab: джоб не ждёт предыдущих стадий и может стартовать сразу. Это удобно для быстрых проверок с ранним падением.
4.2. Ставьте проверки с ранним падением максимально рано
Ошибки синтаксиса, форматирования, схемы, сообщения коммита и базовые smoke-проверки должны падать как можно раньше.
Плохо, когда пайплайн 30 минут собирает образ, запускает интеграционные тесты, а потом падает на prettier или YAML-lint.
Лучше:
stages: - validate - test - build - deploylint-yaml: stage: validate needs: [] script: - yamllint .lint-code: stage: validate needs: [] script: - npm run lint
Раннее падение — это не только скорость. Это ещё и качество обратной связи: разработчик быстрее понимает, что именно сломал.
4.3. Анализируйте критический путь, а не «среднее время джобов»
Если пайплайн длится 40 минут, не обязательно оптимизировать все джобы. Нужно найти критический путь — цепочку зависимостей, которая определяет минимальное время до результата.
Иногда джоб длится 15 минут, но не блокирует ничего важного. А другой джоб длится 5 минут, но стоит в начале цепочки и задерживает весь деплой.
Смотрите:
-
граф пайплайна;
-
граф
needs; -
длительность джобов и стадий;
-
время ожидания в очереди;
-
нестабильные джобы;
-
время скачивания Docker-образов;
-
время установки зависимостей;
-
хранилище и загрузку/скачивание артефактов.
Оптимизация без измерений часто превращается в угадайку.
4.4. Если используете needs, отдельно продумывайте артефакты
Когда пайплайн построен только на стадиях, GitLab может автоматически передавать артефакты из предыдущих стадий. Но с DAG через needs поведение надо задавать явнее.
Пример:
build: stage: build script: - npm run build artifacts: paths: - dist/deploy: stage: deploy needs: - job: build artifacts: true script: - ./deploy.sh dist/
Если артефакты не нужны, не скачивайте их:
lint: stage: test dependencies: [] script: - npm run lint
Лишние артефакты — это сетевой шум, стоимость хранения и задержки в каждом джобе.
4.5. Используйте needs:optional, если зависимое джоб появляется не всегда
Если джоб зависит от другого джоба, который иногда не создаётся из-за rules, пайплайн может не стартовать.
Пример проблемы:
build-docs: rules: - changes: - docs/**/* script: - ./build-docs.shpublish-docs: needs: - build-docs script: - ./publish-docs.sh
Если build-docs не попал в пайплайн, зависимость может стать проблемой. В таких случаях используйте опциональную зависимость:
publish-docs: needs: - job: build-docs optional: true script: - ./publish-docs.sh
Это особенно актуально для больших конфигураций с rules:changes.
4.6. Параллельте только то, что реально можно выполнить параллельно
parallel и parallel:matrix помогают ускорить тесты, сборки и проверки под разные версии среды выполнения.
Пример:
test: stage: test parallel: matrix: - NODE_VERSION: ["20", "22"] OS: ["debian", "alpine"] image: node:${NODE_VERSION} script: - npm test
Но параллелизм не бесплатный. Если у вас два раннера, а вы создаёте 40 джобов, большая часть будет просто ждать в очереди. В таком случае вы увеличите сложность, но не сократите длительность.
Параллелизм должен соответствовать ёмкости раннеров.
4.7. Для матриц используйте needs:parallel:matrix, когда нужна точная зависимость
Иногда downstream-джоб должен ждать не всю матрицу, а конкретную комбинацию.
Например, сборка образа для linux/amd64 должна ждать только тесты для linux/amd64, а не всю матрицу тестов.
В таких случаях используйте needs:parallel:matrix. Это делает DAG точнее и уменьшает задержки.
4.8. retry — только для инфраструктурных сбоев, а не для логических ошибок
retry полезен, если падает раннер, реестр, сеть или другой нестабильный инфраструктурный слой.
Плохая практика:
test: retry: 2 script: - npm test
Так можно замаскировать реальные проблемы в тестах.
Лучше задавать повторный запуск адресно:
test: retry: max: 2 when: - runner_system_failure - stuck_or_timeout_failure script: - npm test
Если тесты нестабильны, их надо чинить, а не бесконечно перезапускать.
5. Переиспользование: extends, шаблоны, компоненты и входные параметры
5.1. Не копируйте одинаковый YAML по джобам
Если несколько джобов используют одинаковую структуру, выносите её через скрытый джоб и extends.
.node-job: image: node:22 before_script: - npm cilint: extends: .node-job script: - npm run linttest: extends: .node-job script: - npm test
Это лучше копипаста. Когда нужно обновить Node.js или способ установки зависимостей, вы меняете одно место.
Для более точечного переиспользования можно использовать !reference, но не стоит строить на нём нечитаемые YAML-фокусы. Правило простое: переиспользование должно уменьшать сложность, а не прятать её.
5.2. Разделяйте шаблоны пайплайнов и job-шаблоны
Шаблон пайплайна может задавать глобальные вещи: stages, workflow, default, общую архитектуру.
Job-шаблон должен быть максимально аккуратным: он встраивается в чужой пайплайн и не должен неожиданно менять глобальное поведение проекта.
Плохой job-шаблон:
stages: - testdefault: image: node:22security-scan: stage: test script: - ./scan.sh
Такой шаблон может конфликтовать с уже существующими stages и default в проекте.
Лучше:
.security-scan-base: image: alpine:3.20 script: - ./scan.sh
Или ещё лучше — оформить это как CI/CD-компонент с входными параметрами.
5.3. Для массового переиспользования переходите к CI/CD-компонентам
Старый путь — общий репозиторий с подключаемыми файлами:
include: - project: platform/ci-templates ref: main file: node.yml
Он работает, но у него есть проблемы:
-
сложно понять, какие шаблоны вообще есть;
-
сложно версионировать;
-
легко подключить плавающий
main; -
документация часто живёт отдельно или не живёт вообще;
-
изменение общего шаблона может внезапно сломать десятки проектов.
CI/CD-компоненты лучше подходят для платформенного переиспользования: у них есть каталог, версионирование, spec:inputs, документация и более явный контракт.
Пример компонента:
spec: inputs: stage: default: test description: "Стадия для unit tests" job-prefix: default: app description: "Префикс имени job, чтобы избежать конфликтов"---"$[[ inputs.job-prefix ]]-unit-tests": stage: $[[ inputs.stage ]] script: - npm test
Подключение:
include: - component: $CI_SERVER_FQDN/platform/ci-components/unit-tests@1.2.0 inputs: stage: verify job-prefix: backend
5.4. Для inputs задавайте значения по умолчанию
Если компонент или шаблон пайплайна использует входные параметры, задавайте значения по умолчанию там, где это возможно.
Плохо:
spec: inputs: environment: description: "Target environment"---deploy: environment: $[[ inputs.environment ]] script: - ./deploy.sh
Если такой пайплайн запускается автоматически, а входной параметр не передали, он может упасть.
Лучше:
spec: inputs: environment: default: staging description: "Target environment"---deploy: environment: $[[ inputs.environment ]] script: - ./deploy.sh $[[ inputs.environment ]]
Входные параметры лучше произвольных переменных пайплайна, потому что они описывают контракт: какие параметры есть, какие значения ожидаются и что будет по умолчанию.
5.5. Компоненты пиньте на тег или SHA
Плавающие ссылки — враг воспроизводимости.
Плохо:
include: - component: $CI_SERVER_FQDN/platform/ci-components/build@~latest
Лучше:
include: - component: $CI_SERVER_FQDN/platform/ci-components/build@1.4.2
Ещё строже:
include: - component: $CI_SERVER_FQDN/platform/ci-components/build@e3262fdd0914fa823210cdb79a8c421e2cef79d8
~latest и частичное семантическое версионирование вроде 1 или 1.2 допустимы, если вы сознательно хотите получать автоматические обновления из каталога CI/CD. Но это всё равно движущаяся цель: ~latest может принести самый свежий релиз, включая ломающие изменения. Для критически важной для продакшена автоматизации лучше фиксированный релизный тег или SHA.
5.6. Используйте include:local, когда связанные файлы должны разрешаться на одном коммите
Если компонент внутри своего проекта включает дополнительные локальные файлы, лучше использовать include:local. Тогда связанные части конфигурации берутся из одного Git SHA.
Это уменьшает риск ситуации, когда один файл взяли из одной версии, а зависимый — из другой.
5.7. Избегайте конфликтов имён в компонентах
Пайплайн и компонент объединяются в итоговую конфигурацию. Если джоб компонента называется test, а в проекте уже есть джоб test, результат может быть неожиданным.
Плохо:
test: script: - npm test
Лучше:
spec: inputs: job-name: default: component-test---"$[[ inputs.job-name ]]": script: - npm test
Если компонент можно подключить несколько раз, динамические имена через входные параметры почти обязательны.
5.8. Тестируйте компоненты до публикации версии
Компонент — это код. Его надо тестировать.
Хороший паттерн: в CI самого компонентного проекта подключать компонент по текущему SHA коммита.
include: - component: $CI_SERVER_FQDN/platform/ci-components/my-component@$CI_COMMIT_SHA inputs: job-name: test-current-component
Так вы проверяете именно тот коммит, который собираетесь релизить.
Если компоненту нужны примерные файлы, тестовая нагрузка или тестовый Dockerfile, храните их рядом с компонентом. Тестируйте не только «джоб стартует», но и реальные побочные эффекты: создаётся ли артефакт, корректно ли публикуется отчёт, не ломается ли цепочка подключений.
5.9. Публикуйте журнал изменений и заметки по миграции
Если общий компонент используется десятками проектов, ломающее изменение в нём — это не локальное изменение, а потенциальный инцидент.
Минимум:
-
README с примерами;
-
описание входных параметров;
-
журнал изменений;
-
заметки по миграции для ломающих изменений;
-
подход к семантическому версионированию;
-
тесты компонента;
-
понятная политика поддержки старых версий.
CI/CD-платформа должна сопровождаться так же серьёзно, как библиотека или внутренний SDK.
5.10. Любой сторонний компонент считайте частью цепочки поставки ПО
Если вы подключаете чужой компонент, вы фактически разрешаете чужой YAML выполнить команды в вашем пайплайне.
Перед использованием смотрите:
-
какие команды выполняются;
-
какие секреты может увидеть джоб;
-
какие кеши и артефакты используются;
-
какие теги раннеров нужны;
-
нужен ли привилегированный Docker;
-
какие токены и права доступа запрашиваются;
-
как компонент версионируется;
-
есть ли тесты и документация.
Удобный компонент, который требует привилегированного раннера и широких токенов, может быть хуже простого локального джоба.
Для include:project и похожих механизмов переиспользования используйте полный 40-символьный SHA или релизный тег. Ветку main, ~latest и другие движущиеся ссылки стоит рассматривать как supply-chain-компромисс, а не как нейтральный дефолт.
5.11. Для include:remote используйте integrity
include:remote удобен, когда CI-конфигурация лежит по внешнему URL. Но с точки зрения цепочки поставки ПО это самый рискованный вариант подключения: вы подтягиваете YAML снаружи и разрешаете ему влиять на пайплайн.
Отдельный нюанс безопасности: include:remote поддерживает только публичный HTTP/HTTPS GET без аутентификации. Вложенные include выполняются без контекста как public user, поэтому из них доступны только публичные проекты и шаблоны, а переменные в секции nested include недоступны. Это ещё один аргумент в пользу integrity и минимизации внешних include-зависимостей.
Если без include:remote нельзя, фиксируйте содержимое через integrity:
include: - remote: 'https://gitlab.com/example-project/-/raw/main/.gitlab-ci.yml' integrity: 'sha256-L3/GAoKaw0Arw6hDCKeKQlV1QPEgHYxGBHsH4zG1IY8='
Если содержимое внешнего файла изменится, а hash не совпадёт, пайплайн должен упасть, а не молча выполнить новый чужой YAML. Это не отменяет ревью внешнего шаблона, но хотя бы фиксирует то, что вы реально подключаете.
Нюанс по версиям: include:integrity появился в GitLab 17.9. Если у вас более старая самостоятельно развёрнутая версия GitLab, этот ключ не сработает.
Дополнительно для include:remote можно смотреть в сторону include:cache. Он кэширует содержимое внешнего подключения на заданный TTL и снижает количество HTTP-запросов к внешнему YAML. Но это компромисс между скоростью и свежестью: чем дольше кеш, тем выше шанс временно использовать устаревшую внешнюю конфигурацию.
Нюанс по версиям: include:cache появился в GitLab 18.9 как экспериментальная возможность, а общедоступной стала в GitLab 19.0.
6. Parent/child и multi-project пайплайны
6.1. Начинайте с простой архитектуры
Не нужно начинать новый проект сразу с parent/child-пайплайнов, то есть родительских и дочерних пайплайнов, multi-project пайплайнов, динамических child-пайплайнов и сложной матрицы подключаемых файлов.
Сначала простой пайплайн:
stages: - lint - test - build - deploy
Потом workflow:rules, needs, кеши, отчёты. И только когда конфигурация реально стала большой, переходите к декомпозиции.
Сложная архитектура CI/CD оправдана, когда она решает реальную проблему: монорепозиторий, десятки сервисов, разные релизные циклы, отдельные команды, отдельные границы доверия.
6.2. Parent/child-пайплайны — для декомпозиции монорепозитория
Родительский/child-пайплайн хорошо подходит, когда компоненты живут в одном репозитории, но имеют разные CI-файлы.
Пример:
backend: trigger: include: .gitlab/ci/backend.yml strategy: mirror rules: - changes: - backend/**/*frontend: trigger: include: .gitlab/ci/frontend.yml strategy: mirror rules: - changes: - frontend/**/*
Так backend-пайплайн запускается только при изменениях backend, а frontend-пайплайн — только при изменениях frontend.
strategy: mirror делает статус trigger-джоба зеркалом downstream-пайплайна. В старых конфигурациях ещё часто встречается strategy: depend, но для новых пайплайнов лучше показывать именно mirror: так поведение trigger-джоба понятнее и ближе к реальному статусу downstream-процесса.
По версиям: strategy: mirror появился в GitLab 18.2. Если проект живёт на более старом самостоятельно развёрнутом GitLab, придётся временно использовать strategy: depend или сначала обновить GitLab.
Если child-пайплайн генерирует JUnit, качество кода, Terraform, метрики или отчёты по безопасности, отдельно проверьте, что эти отчёты реально видны в виджете MR. Для этого trigger-джоб должен ждать child-пайплайн через strategy: mirror или strategy: depend, иначе parent-пайплайн может завершиться раньше, а отчёты останутся спрятанными в child-пайплайне.
Так же по версиям: отчёты из child-пайплайнов в виджеты MR появились в GitLab 18.6, а отчёты по безопасности из child-пайплайнов — в GitLab 18.9.
Отдельный момент для покрытия: coverage_report из child-пайплайнов даёт аннотации в diff MR, но не становится обычным отчётом parent-пайплайна. Поэтому для child-пайплайнов полезно различать «виджет MR/дифф» и «отчёт, доступный на уровне parent-пайплайна».
Это намного лучше, чем один огромный .gitlab-ci.yml, где все джобы перемешаны.
6.3. Помните про ограничения глубины child-пайплайнов
Child-пайплайны не нужно использовать как бесконечное дерево. Если вы дошли до нескольких уровней вложенности и уже трудно понять, откуда что запускается, возможно, вы решаете не ту проблему.
Если граница стала межпроектной или организационной, чаще логичнее перейти на multi-project пайплайн.
6.4. Межпроектные пайплайны — для multi-repo систем
Межпроектный пайплайн нужен, когда вышестоящий проект должен запускать пайплайн в другом проекте: например, библиотека запускает проверки потребителей, infra-репозиторий запускает деплой application-репозитория, релизная оркестрация связывает несколько сервисов.
Пример:
trigger-service-b: trigger: project: platform/service-b branch: $CI_DEFAULT_BRANCH strategy: mirror
strategy: mirror здесь нужен по той же причине: upstream-trigger-джоб должен отражать реальный результат downstream-пайплайна, а не просто факт, что downstream-пайплайн удалось создать.
Если downstream-пайплайн должен забрать артефакты именно из MR-пайплайна, не подставляйте в needs:project обычное имя ветки. Для MR-пайплайнов нужно передавать CI_MERGE_REQUEST_REF_PATH, иначе легко случайно получить артефакты из последнего branch-пайплайна, а не из нужного MR.
Но здесь важно помнить про границу доверия. Downstream-пайплайн запускается в другом проекте, с его настройками, правами и правилами. Нужно понимать:
-
кто может запускать upstream-пайплайн;
-
с какими правами стартует downstream;
-
какие токены и артефакты передаются;
-
можно ли доверять downstream-коду;
-
как трассировать сбои между проектами.
Межпроектный пайплайн — это уже не просто YAML-удобство, а часть организационной модели доступа.
7. Производительность, кеш, артефакты и стоимость
7.1. Начинайте оптимизацию с измерений
Фраза «CI медленный» ничего не объясняет. Нужно понять, где именно медленно:
-
получение исходников репозитория;
-
скачивание Docker-образа;
-
установка зависимостей;
-
тесты;
-
сборка образа;
-
загрузка и скачивание артефактов;
-
очередь на раннеры;
-
медленный реестр;
-
сетевая задержка;
-
нестабильные джобы и retries.
Смотрите длительность джобов и стадий, аналитику пайплайнов, время ожидания в очереди, метрики раннеров, использование хранилища и критический путь.
Без измерений легко оптимизировать не то.
7.2. Используйте auto-cancel и interruptible
В активной ветке старые пайплайны часто теряют смысл после нового коммита. Но если их не отменять, они продолжают занимать раннеры.
Для джобов, которые можно безопасно оборвать, ставьте:
lint: interruptible: true script: - npm run lint
Для деплоя и необратимых операций обычно наоборот:
deploy-production: interruptible: false script: - ./deploy.sh production
Можно управлять автоотменой на уровне workflow:
workflow: auto_cancel: on_new_commit: interruptible on_job_failure: all rules: - if: $CI_COMMIT_REF_PROTECTED == "true" auto_cancel: on_new_commit: none on_job_failure: none - when: always
Для защищённых веток часто нужна более консервативная политика: релизный пайплайн может быть важен для журнала аудита, даже если пришёл новый коммит.
7.3. Настройте получение исходников Git под задачу
Большие репозитории могут терять минуты просто на получение исходников.
В большинстве случаев лучше использовать fetch, а не полный clone. Shallow clone тоже помогает.
variables: GIT_STRATEGY: fetch GIT_DEPTH: "20"
Но есть нюансы:
-
слишком маленький
GIT_DEPTHможет сломать генерацию журнала изменений, семантическое версионирование и инструменты, завязанные на Git; -
GIT_STRATEGY: fetchв общем окружении безопасен только если вы доверяете всем пользователям этой среды; -
для очистки/stop-джобов после удаления ветки можно использовать
GIT_STRATEGY: noneилиempty, если исходники не нужны.
Пример stop-джоба, где очистка-команда доступна без получения исходников репозитория:
stop-review: image: registry.example.com/platform/helm-kubectl:1.30.2 variables: GIT_STRATEGY: none script: - helm uninstall "app-$CI_COMMIT_REF_SLUG" --namespace review || true
Если очистка-логика лежит в скрипте из репозитория вроде ./destroy-review.sh, не ставьте GIT_STRATEGY: none: без получения исходников этого файла может просто не быть в рабочей директории.
7.4. Разделяйте кеш и артефакты
Кеш и артефакты решают разные задачи.
Кеш — для зависимостей и повторно используемых данных: npm кеш, bundler кеш, Maven repository, Gradle кеш и т.д.
Артефакты — для результатов конкретного джоба: результат сборки, JUnit-отчёт, отчёт о покрытии, бинарный файл, Terraform plan, пакет.
Плохо использовать кеш для передачи результата сборки между джобами. Кеш может быть перезаписан, устареть или приехать с другой ветки.
Лучше:
build: script: - npm run build artifacts: paths: - dist/ expire_in: 7 daysdeploy: needs: - job: build artifacts: true script: - ./deploy.sh dist/
7.5. Стройте ключ кеша от lockfile
Для кеша зависимостей часто лучше ключ от lockfile, а не только от ветки.
cache: key: files: - package-lock.json paths: - .npm/
Если lockfile не изменился, зависимости те же. Значит, кеш можно переиспользовать.
Для данных, специфичных для ветки, можно использовать ключ ветки:
cache: key: cache-$CI_COMMIT_REF_SLUG paths: - .cache/
Главное — не складывать разные типы данных под один и тот же key.
7.6. Используйте резервные ключи кеша
Первый пайплайн новой ветки часто медленный, потому что кеш ветки ещё нет. Резервные ключи помогают использовать кеш ветки по умолчанию.
cache: - key: cache-$CI_COMMIT_REF_SLUG fallback_keys: - cache-$CI_DEFAULT_BRANCH - cache-default paths: - vendor/ruby
Так джоб сначала ищет кеш текущей ветки, потом ветку по умолчанию, потом общий fallback.
Это хороший баланс между изоляцией и быстрым стартом с прогретым кешем. Но глобальный резервный кеш нужно чистить и контролировать, иначе он станет помойкой.
7.7. Не используйте один ключ кеша для разных путей
Плохой пример:
cache: key: deps paths: - node_modules/
А в другом джобе:
cache: key: deps paths: - vendor/bundle/
Оба джоба пишут в один и тот же архив кеша с разным содержимым. Потом начинаются странные misses, перезаписи и нестабильное поведение.
Лучше разделять:
cache: key: npm-$CI_COMMIT_REF_SLUG paths: - .npm/
cache: key: ruby-$CI_COMMIT_REF_SLUG paths: - vendor/bundle/
7.8. Для нескольких раннеров нужен распределённый кеш
Если джоб A создал кеш на одном хосте раннера, а джоб B ушёл на другой хост, локальный кеш может быть бесполезен.
Нормальные варианты:
-
один раннер для связанных джобов;
-
несколько раннеров с распределённым кешем через объектное хранилище;
-
общий сетевой кеш для одинаковых раннеров;
-
автомасштабируемые раннеры с корректным бекендом кеша.
Для объектного хранилища обязательно нужна политика жизненного цикла. Иначе хранилище кеша будет бесконтрольно расти.
7.9. Разделяйте кеши защищённых и незащищённых refs
Кеш тоже может быть источником риска для цепочки поставки ПО. Если недоверенная ветка может подготовить кеш, который потом использует защищённая ветка, это опасно.
Раздельные кеши для защищённых и незащищённых refs уменьшают риск. Да, доля попаданий в кеш может стать ниже. Но для пути в продакшен безопасность важнее.
7.10. Управляйте временем жизни артефактов
Артефакты не должны жить вечно «на всякий случай».
build: artifacts: paths: - dist/ expire_in: 7 days
Для тестовых отчётов часто нужен when: always, чтобы отчёт был доступен даже при падении джоба:
test: script: - npm test -- --reporter=junit artifacts: when: always reports: junit: junit.xml paths: - junit.xml expire_in: 14 days
Слишком короткий срок хранения ломает отладку и интерфейс MR. Слишком длинный — раздувает хранилище. Нужен баланс.
7.11. Используйте artifacts:expose_as для полезных файлов ревьюера
Если джоб создаёт HTML-отчёт, Terraform plan, preview summary или другой файл, который должен открыть ревьюер, не заставляйте его искать файл в архиве артефактов.
terraform-plan: script: - terraform plan -out=tfplan - terraform show -no-color tfplan > plan.txt artifacts: expose_as: "Terraform plan" paths: - plan.txt
Так результат становится частью цикла ревью, а не спрятанным файлом в выводе джоба.
8. Сборки Docker-образов, BuildKit, Dependency Proxy и кеш в реестре
8.1. Не собирайте Docker-образы через небезопасный привилегированный процесс без причины
Классический Docker-in-Docker удобен, но часто требует привилегированного контейнера и Docker-демона. Это повышает риск, особенно на общих или переиспользуемых раннерах.
Если проект чувствительный к безопасности, лучше смотреть в сторону BuildKit без root-прав, Buildah, Podman или другой модели без лишних привилегий.
Это не значит, что docker buildx всегда плох. Это значит, что модель раннеров должна соответствовать риску.
8.2. Для безопасной сборки, нативной для GitLab смотрите на BuildKit без root-прав
Пример BuildKit без root-прав:
build-image: image: name: moby/buildkit:rootless entrypoint: [""] stage: build variables: BUILDKITD_FLAGS: --oci-worker-no-process-sandbox CACHE_IMAGE: $CI_REGISTRY_IMAGE:cache before_script: - mkdir -p ~/.docker - | cat > ~/.docker/config.json <<EOF { "auths": { "$CI_REGISTRY": { "username": "$CI_REGISTRY_USER", "password": "$CI_REGISTRY_PASSWORD" } } } EOF script: - | buildctl-daemonless.sh build \ --frontend dockerfile.v0 \ --local context=. \ --local dockerfile=. \ --import-cache type=registry,ref=$CACHE_IMAGE \ --export-cache type=registry,ref=$CACHE_IMAGE \ --output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA,push=true
Плюсы:
-
не нужен привилегированный Docker-демон;
-
можно использовать кеш в реестре;
-
хорошо ложится в одноразовую CI-среду;
-
меньший радиус поражения по сравнению с классическим DinD на общем раннере.
Минусы:
-
настройка auth менее привычная;
-
команде нужно понимать BuildKit;
-
не все устаревшие Docker-процессы переносятся один в один.
8.3. Различайте встроенный кеш и кеш в реестре
Встроенный кеш проще:
docker build \ --build-arg BUILDKIT_INLINE_CACHE=1 \ --cache-from "$CI_REGISTRY_IMAGE:latest" \ -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
Но для сложных многоэтапных сборок часто лучше кеш в реестре:
docker buildx build \ --cache-from type=registry,ref=$CI_REGISTRY_IMAGE:buildcache \ --cache-to type=registry,ref=$CI_REGISTRY_IMAGE:buildcache,mode=max \ -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \ --push .
Встроенный кеш хорош как быстрый старт. Кеш в реестре лучше масштабируется для зрелых пайплайнов, где кеш сборки должен жить отдельно от итогового образа.
8.4. Не передавайте секреты в Docker-сборку через ARG, ENV или COPY
Плохой пример:
ARG NPM_TOKENRUN npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN
Секрет может попасть в историю слоёв, логи сборки или кеш.
Лучше использовать секреты BuildKit:
# syntax=docker/dockerfile:1RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ npm ci
В CI:
docker buildx build \ --secret id=npmrc,src=.npmrc \ -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
Секрет должен существовать только во время конкретного шага сборки, а не попадать в итоговый образ.
8.5. Используйте Dependency Proxy для базовых образов
Базовые образы часто тянутся из Docker Hub или внешних реестров. Это даёт:
-
лимиты запросов;
-
сетевые задержки;
-
зависимость от внешней доступности;
-
лишнее время скачивания.
GitLab Dependency Proxy помогает кэшировать upstream-образы на уровне группы.
Пример:
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:22-bookworm-slim
Для Dockerfile:
ARG CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIXFROM ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:22-bookworm-slim
Это особенно полезно, когда много проектов тянут одни и те же базовые образы.
8.6. Тегируйте образы воспроизводимо
Не делайте продакшен-деплой из latest.
Плохо:
docker build -t $CI_REGISTRY_IMAGE:latest .docker push $CI_REGISTRY_IMAGE:latest
Лучше:
docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
Можно дополнительно пушить удобные теги:
docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUGdocker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
Но деплой должен ссылаться на неизменяемый тег или digest, а не на плавающий latest.
8.7. Kaniko — это уже не best practice для нового GitLab CI/CD
Kaniko долго был популярным способом собирать образы без Docker-демона. Но сейчас его лучше рассматривать как устаревший элемент для миграции, а не как стандарт для новых пайплайнов.
Если у вас уже есть Kaniko-джобы и они работают, не обязательно ломать всё в один день. Но при плановом обновлении CI/CD-платформы стоит смотреть на BuildKit без root-прав, Buildah, Podman или другой поддерживаемый инструмент.
Главный принцип: не строить новую платформу на инструменте, который уже не является актуальной рекомендацией.
8.8. После сборки образа добавляйте SBOM, подпись и проверку политик
Сборка образа — это не конец цепочки поставки ПО. Если пайплайн просто собрал образ и сразу задеплоил его в продакшен, вы не отвечаете на несколько важных вопросов:
-
какие зависимости попали в образ;
-
какие CVE есть в базовом образе и пакетах;
-
кто и в каком пайплайне собрал образ;
-
можно ли доказать происхождение артефакта;
-
разрешён ли этот образ к деплою по внутренней политике.
Минимальный зрелый процесс:
-
Собрать образ с неизменяемым тегом или digest.
-
Сгенерировать SBOM, например в CycloneDX.
-
Прогнать сканирование контейнеров.
-
Подписать образ через Cosign, Notation или другой принятый в компании инструмент.
-
Сохранить provenance/attestation, если это требуется для цепочки поставки ПО.
-
Перед продакшен-деплоем поставить проверку политик: не деплоить образ, который не прошёл минимальные проверки.
Пример с CycloneDX-отчётом:
sbom: stage: test script: - ./generate-sbom.sh > gl-sbom.cdx.json artifacts: reports: cyclonedx: - gl-sbom.cdx.json paths: - gl-sbom.cdx.json
Нюанс по тарифному уровню: GitLab-интеграция artifacts:reports:cyclonedx относится к Ultimate. Если нужного тарифного уровня нет, SBOM всё равно можно сохранять как обычный артефакт и использовать во внешней проверке политик, но интерфейс-интеграция GitLab может быть недоступна.
Конкретный набор инструментов зависит от компании. Важен принцип: продакшен-деплой должен опираться не только на факт успешной сборки, но и на проверяемую информацию об образе.
8.9. Не забывайте про политику очистки для реестра и кеша сборки
Dependency Proxy, кеш в реестре и теги образов ускоряют CI/CD, но без политики очистки они быстро превращаются в проблему хранилища.
Что стоит контролировать:
-
сколько живут временные и ревью-теги образов;
-
как часто чистится кеш сборки;
-
сколько хранится кеш-образ вроде
$CI_REGISTRY_IMAGE:buildcache; -
удаляются ли старые теги веток после закрытия веток;
-
есть ли отдельная политика для релизных тегов и продакшен-образов;
-
не растёт ли реестр контейнеров быстрее, чем команда это замечает.
Хорошая модель простая: продакшен/релизные артефакты живут долго и управляются осознанно, а ревью-, временные и кеш-артефакты сборки имеют понятный TTL и автоматическую очистку.
9. Окружения, ревью-окружения и управляемые деплои
9.1. Делайте окружения полноценным объектом
Деплой-джоб без environment — это просто команда в логах. Деплой-джоб с environment — это часть модели доставки GitLab.
Пример:
deploy-staging: stage: deploy script: - ./deploy.sh staging environment: name: staging url: https://staging.example.com deployment_tier: staging
Так GitLab понимает, что и куда задеплоено. Это даёт:
-
историю деплоев;
-
ссылки на окружения;
-
панель окружений;
-
защищённые окружения;
-
согласования;
-
очистку;
-
визуализацию процесса доставки.
9.2. Задавайте deployment_tier явно
Не стоит полагаться только на имя окружения. Лучше явно писать уровень:
environment: name: production deployment_tier: production
Поддерживаемые значения:
-
production; -
staging; -
testing; -
development; -
other.
Это полезно для аналитики и контроля, особенно в больших организациях, где названия окружений могут отличаться.
9.3. Для динамических окружений возвращайте фактический URL через dotenv
Иногда URL окружения создаётся после деплоя: например, PaaS или Kubernetes ingress генерирует адрес динамически.
В таком случае джоб может записать URL в dotenv-отчёт:
deploy-review: script: - ./deploy-review.sh - echo "DYNAMIC_ENVIRONMENT_URL=https://$CI\_COMMIT\_REF\_SLUG.review.example.com" >> deploy.env artifacts: reports: dotenv: deploy.env environment: name: review/$CI_COMMIT_REF_SLUG url: $DYNAMIC_ENVIRONMENT_URL
Так ревью-окружение становится кликабельным в интерфейсе.
9.4. Ревью-окружения должны иметь on_stop и auto_stop_in
Ревью-окружение без очистки — это будущая свалка ресурсов.
Хороший паттерн:
deploy-review: stage: deploy script: - ./deploy-review.sh environment: name: review/$CI_COMMIT_REF_SLUG url: https://$CI\_ENVIRONMENT\_SLUG.example.com on_stop: stop-review auto_stop_in: 1 week rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event"stop-review: stage: deploy script: - ./destroy-review.sh environment: name: review/$CI_COMMIT_REF_SLUG action: stop when: manual allow_failure: true rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event"
В примере stop-review оставлен необязательным через allow_failure: true: это удобно для UI-очистки окружения и не блокирует пайплайн. Если же ваша политика требует обязательной очистки перед продолжением процесса, используйте блокирующий manual-джоб и не оставляйте его необязательным по умолчанию.
Важно: деплой и stop-джобы должны иметь согласованные rules. Если stop-джоб не попал в пайплайн, GitLab не сможет нормально остановить окружение.
9.5. Для остановки окружения из интерфейса держите деплой- и stop-джобы в одном resource_group
Если вы хотите, чтобы остановка из интерфейса работала корректно с on_stop, деплой-джоб и stop-джоб должны быть согласованы, в том числе по параллельному выполнению.
deploy-review: resource_group: review/$CI_COMMIT_REF_SLUG environment: name: review/$CI_COMMIT_REF_SLUG on_stop: stop-review script: - ./deploy-review.shstop-review: resource_group: review/$CI_COMMIT_REF_SLUG environment: name: review/$CI_COMMIT_REF_SLUG action: stop script: - ./destroy-review.sh
Это защищает от странных гонок между деплоем и очисткой.
9.6. Настраивайте карты маршрутов для ревью-окружений
Для frontend, документации и статических сайтов ревью-окружение полезнее, если из MR можно перейти не только на корень приложения, но и на страницу, соответствующую изменённому файлу.
Пример .gitlab/route-map.yml:
- source: /docs\/(.*)\.md/ public: '/docs/\1/'
Так ревьюер быстрее проверяет изменения в живом окружении.
9.7. Защищайте продакшен и стейджинг через защищённые окружения
Защищённая ветка защищает код. Защищённое окружение защищает деплой.
Для продакшена обычно нужно ограничить:
-
кто может деплоить;
-
кто может согласовывать деплой;
-
какие ветки/теги имеют доступ к продакшен-секретам;
-
какие раннеры могут выполнять деплой-джоб.
Пример:
deploy-production: stage: deploy environment: name: production deployment_tier: production rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: manual script: - ./deploy.sh production
А права на само окружение настраиваются в интерфейсе GitLab.
9.8. Сериализуйте деплои через resource_group
Два продакшен-деплоя одновременно — плохая идея.
deploy-production: stage: deploy resource_group: production environment: name: production script: - ./deploy.sh production
resource_group гарантирует, что джобы с одной resource_group не будут выполняться параллельно.
Это особенно важно для:
-
деплоя в Kubernetes;
-
Terraform apply;
-
миграций базы данных;
-
публикации релизов;
-
изменяемых окружений.
9.9. Подбирайте process_mode под релизную политику
У resource_group есть разные режимы обработки:
-
oldest_first— безопаснее для последовательного непрерывного деплоя; -
newest_first— быстрее выбрасывает старые деплой-джобы, но требует идемпотентности; -
newest_ready_first— компромиссный вариант для готовых джобов.
Если деплой не идемпотентный, агрессивные режимы могут привести к непредсказуемому состоянию.
9.10. Для downstream-процесса деплоя держите блокировку на trigger-джобе
Если деплой выполняется в downstream-пайплайне, блокировка должна жить до конца downstream-процесса.
Иначе trigger-джоб в parent-пайплайне быстро завершится, resource_group освободится, и следующий деплой стартует, пока downstream ещё работает.
Используйте trigger:strategy: mirror или подход, при котором parent-пайплайн ожидает downstream-пайплайн.
9.11. Избегайте deadlock’ов между родительским и дочерним пайплайнами
Опасный паттерн: parent/child-пайплайны конкурируют за один и тот же resource_group, особенно при oldest_first. Можно получить ситуацию, когда parent-пайплайн ждёт дочерний, а child-пайплайн ждёт ресурс, который удерживает parent-пайплайн.
Правило простое: если используете блокировки через родительские и child-пайплайны, проектируйте их явно. Не копируйте resource_group на разные уровни пайплайна без понимания очереди.
9.12. Включайте защиту от устаревших деплой-джобов
Старый деплой не должен приезжать в продакшен после нового.
Типичный сценарий:
-
Пайплайн A начал деплой.
-
Пайплайн B собрался быстрее и задеплоил новый коммит.
-
Пайплайн A внезапно продолжил работу и откатил продакшен на старый коммит.
Защита от устаревших деплой-джобов уменьшает риск такого поведения. Но если вы используете ручной откат через повторный запуск старых джобов, нужно отдельно продумать политику отката.
9.13. Не используйте environment-scoped переменные в rules и include
Environment-scoped переменные удобны для разделения секретов по окружениям:
-
продакшен-секреты — только для
production; -
секреты ревью-окружений — только для
review/*; -
стейджинг-секреты — только для
staging.
Но не стоит опираться на них в rules или include. На этапе валидации пайплайна они могут быть ещё не определены.
Если нужен доступ к environment-scoped переменным до полноценного деплоя, используйте environment actions:
-
prepare; -
verify; -
access.
Это лучше, чем пытаться заставить переменные работать там, где GitLab ещё не знает контекст окружения.
9.14. Для опасных manual-джобов добавляйте manual_confirmation
Manual-джоб сам по себе не всегда достаточная защита. Человек может нажать кнопку случайно, особенно если рядом несколько похожих деплой- и stop-джобов.
Для опасных действий добавляйте явное подтверждение:
deploy-production: stage: deploy script: - ./deploy.sh production environment: name: production when: manual manual_confirmation: "Deploy to production?"
Нюанс, который часто путают: when: manual вне rules по умолчанию создаёт необязательный manual-джоб (allow_failure: true), а when: manual внутри rules — блокирующее (allow_failure: false). Если нужен «необязательный manual» внутри rules, указывайте allow_failure: true явно.
Это особенно полезно для:
-
продакшен-деплоя;
-
остановки продакшена;
-
удаления ревью-окружения;
-
Terraform apply;
-
миграции базы данных;
-
публикации релизов.
manual_confirmation не заменяет защищённые окружения и согласования. Это просто дополнительная защита от случайного действия в интерфейсе.
Нюанс по версиям: manual_confirmation появился в GitLab 17.1, а поддержка stop-джобов окружений появилась в GitLab 18.3. Если у вас более старая самостоятельно развёрнутая версия GitLab, проверяйте поддержку перед тем, как добавлять этот ключ в общие шаблоны.
9.15. Продумайте заморозку деплоев и политику отката
Для продакшена важен не только сам деплой, но и правила, когда деплоить нельзя и как откатываться.
Заморозка деплоев нужна, если в проекте есть периоды, когда деплой запрещён: праздники, релизные окна, конец отчётного периода, миграции, внешние зависимости или freeze по договорённости с бизнесом.
Откат тоже нужно описать явно. Варианты бывают разные:
-
повторный запуск старого деплой-джоба;
-
новый пайплайн со старым SHA коммита;
-
деплой предыдущего неизменяемого тега образа;
-
отдельный джоб отката;
-
откат релиза через Git-тег или метаданные релиза.
Для чувствительных проектов опасно разрешать любой повторный запуск старого деплой-джоба без контроля: так можно случайно откатить продакшен на неподходящее состояние. Лучше заранее договориться, какой процесс отката считается нормальным, кто его запускает и какие согласования нужны.
9.16. Для Kubernetes-деплоя рассмотрите GitLab Agent for Kubernetes
Если GitLab CI/CD деплоит в Kubernetes, не обязательно хранить долгоживущий kubeconfig как обычный CI/CD-секрет. Для многих проектов более правильная модель — GitLab Agent for Kubernetes.
Agent даёт пайплайн Kubernetes-контекст, а доступ можно ограничивать через ci_access, проекты, группы, RBAC и имперсонацию. В джобе появляется $KUBECONFIG, после чего можно выбрать нужный контекст и выполнить kubectl.
Пример идеи:
deploy-kubernetes: image: registry.example.com/platform/kubectl:1.30.2 script: - kubectl config use-context path/to/agent/project:agent-name - kubectl apply -f k8s/
Это не отменяет защищённые окружения, согласования, отдельные продакшен-раннеры и ограниченный RBAC. Но это лучше, чем бесконтрольно раздавать один общий kubeconfig всем джобам деплоя.
В примере образ намеренно указан с фиксированной версией. Для инструментов деплоя вроде kubectl, helm и kustomize лучше не использовать latest: иначе один и тот же деплой-джоб может начать вести себя по-разному после обновления образа.
10. MR, merged results pipelines и merge trains
10.1. Для активной разработки переносите основную проверку в MR-пайплайны
Пайплайн ветки показывает состояние ветки. MR-пайплайн показывает состояние изменения в контексте MR.
Для команд с активным процессом ревью MR-пайплайн обычно важнее: именно он даёт интеграции с интерфейсом, виджеты, согласования и нормальную обратную связь ревьюеру.
Пример:
workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push" when: never - if: $CI_COMMIT_BRANCH
Так пайплайн ветки остаётся для веток без MR, а после открытия MR основной проверкой становится MR-пайплайн.
10.2. Merged results pipelines проверяют MR так, как будто он уже влит
Обычный MR-пайплайн проверяет исходную ветку. Но целевая ветка может измениться. В итоге MR зелёный, а после merge ветка по умолчанию становится красной.
Пайплайн с результатом слияния создаёт временный merge-коммит между исходной и целевой ветками и проверяет именно его.
Это полезно, если:
-
много параллельных MR;
-
ветка по умолчанию часто меняется;
-
интеграционные конфликты всплывают поздно;
-
важно ловить проблемы до merge.
Но есть нюанс: rules:changes:compare_to в merged results pipeline может работать иначе, потому что база сравнения — временный merge-коммит.
Если нужно различать обычный detached MR-пайплайн, merged results pipeline и merge train pipeline, смотрите на CI_MERGE_REQUEST_EVENT_TYPE: он принимает значения detached, merged_result и merge_train. Это удобно, когда тяжёлые проверки или публикация отчётов должны запускаться только в определённом типе MR-пайплайна.
10.3. Merge trains нужны для процесса с большим количеством слияний
Если в ветку по умолчанию часто вливаются изменения, даже merged results pipeline может быть недостаточно. Пока один MR проверяется, перед ним могут влиться другие MR.
Очередь слияния выстраивает MR в очередь и проверяет каждый MR в контексте тех изменений, которые уже стоят перед ним в очереди.
Это уменьшает риск ситуации «каждый MR отдельно зелёный, но main после merge красный».
10.4. Merge trains включайте вместе с MR-пайплайнами и пайплайнами с результатом слияния
Merge trains не стоит включать как отдельную магическую кнопку. Они работают нормально в связке с MR-пайплайнами и пайплайнами с результатом слияния.
Иначе MR могут вести себя неожиданно: застревать, проверяться не в том контексте или требовать ручных действий.
10.5. Используйте автослияние, если работаете с merge trains
В процессе с большим количеством слияний удобно использовать Set to auto-merge. Тогда MR корректно попадает в очередь и GitLab сам выполнит merge, когда проверки пройдут.
Без этого люди начинают вручную ловить момент, когда пайплайн зелёный, а это возвращает хаос.
10.6. Release-джобы, теги и версионирование держите отдельным процессом
Release-джоб не должен быть случайным продолжением обычного деплой-джоба. У релиза отдельная ответственность: создать тег, метаданные релиза, журнал изменений, файлы релиза, package, тег образа или другую точку фиксации версии.
Частые варианты:
-
релиз создаётся при push Git-тега;
-
релиз создаётся после merge в ветку по умолчанию;
-
отдельный prepare-джоб собирает метаданные, а release-джоб публикует релиз;
-
release-джоб создаёт файлы релиза и ссылки на артефакты;
-
повышение версии и журнал изменений делаются отдельным контролируемым шагом.
Пример релиза по тегу:
release-job: stage: release image: registry.gitlab.com/gitlab-org/cli:latest rules: - if: $CI_COMMIT_TAG script: - echo "Create release for $CI_COMMIT_TAG" release: tag_name: '$CI_COMMIT_TAG' description: '$CI_COMMIT_TAG'
Отдельно проверьте верхнеуровневый workflow:rules: если он не разрешает tag pipelines, условие if: $CI_COMMIT_TAG на уровне джоба само по себе не поможет — теговый пайплайн просто не будет создан.
Если релиз создаёт тег сам, следите, чтобы не получить два пайплайна: один пайплайн на ветке по умолчанию создаёт релиз и тег, а тег потом запускает ещё один теговый пайплайн. Для такого процесса явно запрещайте release-джоб в теговом пайплайне:
release-job: rules: - if: $CI_COMMIT_TAG when: never - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH script: - echo "Create release"
В официальных примерах GitLab часто встречается registry.gitlab.com/gitlab-org/cli:latest, но для критически важной для продакшена автоматизации релизов лучше зафиксировать конкретную версию образа или использовать внутренний зафиксированный образ с инструментами релиза.
Для продакшена лучше, чтобы релиз ссылался на неизменяемый тег образа или digest, а не на latest или плавающий тег ветки.
11. Безопасность, секреты, OIDC, Vault и CI_JOB_TOKEN
11.1. Считайте пайплайн частью цепочки поставки ПО
Пайплайн может:
-
читать исходный код;
-
собирать артефакты;
-
публиковать Docker-образы;
-
получать секреты;
-
ходить в облачные API;
-
деплоить в Kubernetes;
-
пушить теги;
-
создавать релизы;
-
запускать downstream-пайплайны.
Это не вспомогательный скрипт. Это часть цепочки поставки ПО.
Отсюда правила:
-
не доверять произвольным include и компонентам без ревью;
-
ограничивать токены;
-
защищать раннеры;
-
отделять защищённые и незащищённые процессы;
-
не давать продакшен-секреты обычным test-джобам;
-
не запускать недоверенный код из форка с доступом к секретам родительского проекта.
11.2. Секреты высокой чувствительности держите во внешнем менеджере секретов
CI/CD-переменные удобны, но для важных секретов лучше использовать внешний менеджер секретов: Vault, облачный менеджер секретов или другой провайдер.
Базовый минимум для переменных GitLab:
-
замаскированные;
-
скрытые;
-
защищённые;
-
с ограничением по окружению;
-
минимальная область действия;
-
регулярная ротация.
Но целевое состояние для облачных и продакшен-секретов — краткоживущие учётные данные через OIDC или выдача секрета джобу по запросу.
11.3. Понимайте разницу между переменными и secrets:
Обычные переменные часто доступны джобу по умолчанию. А secrets: запрашиваются джобом явно.
Это важная разница. Чем явнее джоб просит секрет, тем проще ревьюить доступ.
Плохой подход:
variables: AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY
Лучше не держать долгоживущий облачный ключ в GitLab вообще, а получать временный доступ через OIDC.
11.4. Используйте OIDC ID-токены вместо статических облачных ключей
Статический облачный ключ в CI/CD — это долгий радиус поражения. Его надо хранить, ротировать, ограничивать и надеяться, что он не утечёт.
OIDC-подход лучше: джоб получает ID-токен, облачный провайдер или Vault проверяет claims и выдаёт временные учётные данные.
Пример для Vault:
deploy: id_tokens: VAULT_ID_TOKEN: aud: https://vault.example.com script: - vault write auth/jwt/login role=gitlab-ci jwt="$VAULT_ID_TOKEN" - ./deploy.sh
В политике доверия лучше использовать стабильные claims вроде project_id и namespace_id, а не только path-based значения. Проект или группа могут переименоваться, а ID останется стабильнее.
OIDC полезен не только для Vault. Тот же принцип применим к облачной федерации: AWS, GCP, Azure, Yandex Cloud или другой провайдер проверяет claims токена и выдаёт временные учётные данные только конкретному проекту, ref или окружению. Это лучше, чем хранить в GitLab долгоживущий облачный ключ доступа.
11.5. Для Vault ограничивайте роли, bound claims, TTL и политики доступа
Роль Vault не должна быть «всё всем».
Ограничивайте:
-
project/namespace;
-
защищённые refs;
-
ref pattern;
-
audience;
-
TTL;
-
политику доступа только на нужное чтение.
Если джоб должен только прочитать один секрет, не выдавайте ему write-доступ или wildcard-доступ.
11.6. Если инструменту нужен файл, используйте переменные файлового типа или file-backed секреты
Некоторые инструменты ожидают файл: kubeconfig, учётные данные Google, сертификат подписи, npmrc, SSH-ключ.
Не всегда удобно и безопасно передавать это строкой в переменную окружения. Используйте переменные файлового типа или секрет, который материализуется как файл.
Пример:
deploy: script: - kubectl --kubeconfig "$KUBECONFIG_FILE" apply -f k8s/
11.7. Ограничивайте CI_JOB_TOKEN
CI_JOB_TOKEN удобен для автоматизации, но его нельзя считать безобидным.
Что делать:
-
включать список разрешений;
-
добавлять только нужные проекты/группы;
-
использовать точечные права доступа там, где доступны;
-
не отключать список разрешений «навсегда ради удобства»;
-
пересматривать межпроектные доступы;
-
не использовать job token как замену нормальной модели прав.
Если пайплайн одного проекта может ходить в другой проект, это уже межпроектная граница доверия.
11.8. Не включайте Git push через CI_JOB_TOKEN без явной причины
Push из пайплайна удобен для автоматизации релизов, ведения журнала изменений, повышения версии и GitOps-сценариев. Но это также расширяет поверхность атаки.
Если включаете:
-
делайте это только для конкретного проекта;
-
ограничивайте защищённые ветки;
-
учитывайте, что push через job token может не запускать новый пайплайн так, как вы ожидаете;
-
документируйте релизный процесс.
11.9. Не показывайте пайплайны публично без причины
В публичных и внутренних проектах логи и артефакты могут содержать чувствительные детали: пути, версии, ошибки, имена хостов, куски конфигов, имена пакетов, внутренние URLs.
Если нет причины показывать детали наружу, ограничьте публичную видимость пайплайнов.
11.10. Для бинарных секретов используйте Secure Files
Keystore, provisioning profile, сертификат подписи и похожие бинарные секреты не должны лежать в репозитории.
Для таких файлов используйте Secure Files или внешнее хранилище секретов.
11.11. Применяйте требования безопасности централизованно, где это нужно
Для проектов с требованиями безопасности не стоит надеяться, что каждая команда сама правильно подключит SAST, сканирование зависимостей, сканирование контейнеров или проверки политик.
Лучше использовать:
-
SAST;
-
сканирование зависимостей;
-
сканирование контейнеров;
-
поиск секретов;
-
политики выполнения пайплайнов;
-
compliance-пайплайны;
-
CODEOWNERS для чувствительных к безопасности файлов.
Чем критичнее проект, тем меньше безопасность должна быть добровольной настройкой.
11.12. Fork MR считайте потенциально враждебными
MR из форка может содержать код, который пытается прочитать переменные, дёрнуть внутренний endpoint, украсть артефакт или использовать раннер родительского проекта.
Если запускаете MR-пайплайн из форка в родительском проекте, сначала ревьюйте код и понимайте, какие ресурсы он получит.
Защищённые переменные и защищённые раннеры для MR-пайплайнов разрешайте только там, где это действительно нужно. Обычно доступ к ним должен быть ограничен пайплайнами внутри того же проекта, защищёнными refs и пользователями с нужными правами.
12. Раннеры, изоляция и усиление защиты инфраструктуры
12.1. Регистрируйте раннеры с минимальным радиусом поражения
Раннер можно зарегистрировать на уровне instance, group или project. Чем шире уровень, тем больше радиус поражения.
Общее правило:
-
общие раннеры — только для доверенных и хорошо изолированных нагрузок;
-
групповые раннеры — для понятной группы проектов;
-
проектные раннеры — для чувствительных проектов;
-
выделенные защищённые раннеры — для продакшен-деплоев и чувствительных к безопасности джобов.
Используйте теги, чтобы джоб попадал только на подходящий раннер:
deploy-production: tags: - prod-deploy script: - ./deploy.sh production
12.2. Защищённые ветки, защищённые переменные и защищённые раннеры должны работать вместе
Защитить только ветку недостаточно. Защитить только переменную недостаточно. Защитить только раннер тоже недостаточно.
Нормальная модель:
-
продакшен-деплой запускается только из защищённой ветки или тега;
-
продакшен-секреты доступны только на защищённых refs;
-
джоб выполняется на защищённом раннере;
-
окружение защищено;
-
деплой требует ручного согласования или участия разрешённых деплоеров.
Если один слой открыт, вся цепочка становится слабее.
12.3. Используйте одноразовые и изолированные окружения раннеров
Идеальная модель для рискованных нагрузок — одноразовая среда: джоб выполнился, VM или контейнер раннера уничтожается.
Это снижает риск:
-
кражи данных между джобами;
-
оставшихся секретов;
-
закрепления злоумышленника;
-
латерального перемещения;
-
загрязнения рабочей директории.
Для исполнителя Docker Machine с рискованными нагрузками используйте подход вроде MaxBuilds = 1, чтобы одна машина не обслуживала много джобов подряд.
12.4. Shell-исполнитель — только для доверенных сборок
Shell-исполнитель выполняет команды прямо на хост раннера. Это мощно, но опасно.
Если джоб может выполнить произвольный shell на хосте, он может:
-
читать остатки файлов;
-
смотреть процессы;
-
трогать сеть хоста;
-
пытаться украсть учётные данные;
-
влиять на другие джобы.
Shell-исполнитель допустим для полностью доверенных нагрузок на выделенных машинах. Для общих раннеров это плохая идея.
12.5. Docker-исполнитель безопаснее без привилегированного режима
Docker-исполнитель в непривилегированном режиме обычно безопаснее Shell-исполнителя. Но привилегированные контейнеры резко меняют модель риска.
Дополнительно:
-
запускайте джоб от non-root-пользователя, где возможно;
-
убирайте
sudo, если он не нужен; -
избегайте SETUID/SETGID внутри образа джоба;
-
не включайте host PID namespace;
-
не монтируйте Docker socket без крайней необходимости.
Монтирование Docker socket (/var/run/docker.sock) часто фактически равно root-доступу к хосту. Не используйте его как «простую замену DinD», если не понимаете последствия.
12.6. Привилегированные контейнеры — только на изолированных одноразовых раннерах
Если привилегированный режим действительно нужен:
-
отдельные раннеры;
-
отдельные хосты или VM;
-
запуск только на защищённых refs;
-
минимум доступов;
-
короткая жизнь окружения раннера;
-
мониторинг;
-
понятный владелец.
Не смешивайте привилегированные джобы и обычные недоверенные джобы на одной переиспользуемой машине.
12.7. На общих раннерах не используйте pull_policy: if-not-present для приватных образов
if-not-present может быть опасен на общем раннере: приватный образ, скачанный одним проектом, теоретически может быть переиспользован другим джобом на той же машине.
Для общих раннеров безопаснее не полагаться на уже скачанные приватные образы.
12.8. Осторожно с GIT_STRATEGY: fetch в общем окружении
fetch быстрее, потому что переиспользует рабочую директорию. Но в общей среде со смешанным уровнем доверия переиспользование локальной рабочей копии может быть риском.
Если раннер используется недоверенными проектами, лучше предпочесть более изолированную модель, даже если она медленнее.
12.9. Сегментируйте сеть раннеров
Раннер не должен иметь лишний доступ ко всей внутренней сети.
Хорошие меры:
-
отдельные сети/подсети;
-
запрет внешнего SSH;
-
фильтрация доступа к metadata endpoints;
-
ограничение east-west трафика;
-
отдельные раннеры для продакшен-деплоя;
-
egress-правила;
-
аудит сетевых доступов.
Особенно это важно в облаках, где metadata endpoint может выдавать учётные данные.
12.10. Усиливайте защиту хоста раннера
На хосте раннера не должно быть лишних постоянных чувствительных данных:
-
отладочные учётные данные;
-
старые SSH-ключи;
-
временные токены;
-
артефакты прошлых джобов;
-
лишние правила sudoers;
-
ненужные сервисы;
-
открытые порты.
Хост раннера — часть границы безопасности CI/CD. Его нельзя администрировать как случайную тестовую VM.
12.11. Разделяйте защищённые и незащищённые джобы по разным раннерам
Если один раннер обслуживает и джобы из недоверенных веток, и джобы продакшен-деплоя, вы смешиваете разные уровни доверия.
Лучше:
-
незащищённые джобы — отдельные обычные раннеры;
-
защищённые джобы — отдельные защищённые раннеры;
-
продакшен-деплой — отдельный выделенный раннер;
-
проверки безопасности с привилегированными требованиями — отдельный изолированный пул раннеров.
Это снижает риск, что недоверенная нагрузка повлияет на путь в продакшен.
13. Отчёты, качество, наблюдаемость и контроль
13.1. Публикуйте JUnit-отчёты
Не заставляйте разработчика читать сырые логи тестов. GitLab умеет показывать тестовые отчёты прямо в интерфейсе MR и пайплайна.
test: script: - npm test -- --reporters=default --reporters=jest-junit artifacts: when: always reports: junit: junit.xml paths: - junit.xml
artifacts:when: always важен: если тесты упали, отчёт всё равно должен быть доступен.
13.2. Не путайте coverage и coverage_report
coverage: вытаскивает процент покрытия из лога джоба.
test: coverage: '/All files[^|]*\|[^|]*\s+\d+(?:\.\d+)?/'
coverage_report даёт построчные аннотации в diff MR.
Нюанс: GitLab использует RE2 для coverage, и группы в регулярном выражении должны быть незахватывающими. Поэтому используйте (?:...), а не обычные захватывающие группы вроде (...).
test: artifacts: reports: coverage_report: coverage_format: cobertura path: coverage/cobertura-coverage.xml
Для нормального пользовательского опыта часто нужны оба механизма.
13.3. Code Quality подключайте через реальные инструменты команды
Не надо строить качество только вокруг устаревших универсальных шаблонов. Лучше использовать реальные линтеры и статический анализ, которые команда уже понимает, и публиковать результат в формате отчёта Code Quality.
Идея простая: замечания должны появляться в MR-виджете, а не теряться в stdout.
13.4. Для UI/E2E-тестов прикладывайте артефакты
Если падает браузерный тест, логов часто мало. Нужны:
-
скриншот;
-
видео;
-
trace;
-
HTML-отчёт;
-
сетевые логи.
Пример:
e2e: script: - npm run e2e artifacts: when: always paths: - playwright-report/ - test-results/ expire_in: 7 days
Но помните про хранилище. Видео и скриншоты быстро раздувают артефакты.
13.5. Делайте MR-виджеты основным интерфейсом обратной связи
Плохой пайплайн заставляет разработчика открыть джоб, проскроллить лог и вручную искать ошибку.
Хороший пайплайн показывает результат в MR:
-
тесты;
-
покрытие;
-
качество кода;
-
замечания по безопасности;
-
выставленные артефакты;
-
ссылка на ревью-окружение;
-
статус деплоя.
Цель CI/CD — дать быстрый и понятный сигнал, а не просто выполнить команды.
13.6. Наблюдайте за пайплайном как за системой
Наблюдаемость пайплайна — это не только «зелёный/красный».
Смотрите:
-
долю успешных запусков;
-
среднюю и p95 длительность;
-
время ожидания в очереди;
-
длительность по стадиям и джобам;
-
нестабильные джобы;
-
долю повторных запусков;
-
утилизацию раннеров;
-
рост хранилища;
-
рост реестра;
-
частоту отменённых пайплайнов;
-
инциденты, связанные с CI/CD.
Для долгосрочного контроля можно использовать GitLab Analytics, API, exporter-подходы, Prometheus/Grafana и внутренние дашборды.
13.7. Документируйте архитектуру пайплайна в репозитории
CI/CD не должен жить только в голове платформенной команды.
Полезно держать в репозитории:
-
схему пайплайна;
-
описание окружения;
-
правила деплоя;
-
описание секретов и источников учётных данных;
-
разбор типовых проблем;
-
известные проблемы;
-
журнал изменений CI/CD-платформы.
13.8. Улучшайте пайплайн маленькими итерациями
Не надо переписывать весь CI/CD за один MR.
Хороший порядок:
-
Измерить.
-
Найти узкое место.
-
Внести маленькое изменение.
-
Сравнить эффект.
-
Повторить.
Так проще не сломать процесс доставки и показать пользу изменений.
13.9. Начинайте с официальных примеров, но адаптируйте под свою модель риска
Официальные примеры, компоненты и паттерны каталога полезны как старт. Но их нельзя бездумно копировать.
Проверяйте:
-
вашу версию GitLab;
-
GitLab.com или самостоятельно развёрнутую инсталляцию;
-
доступный уровень;
-
модель раннеров;
-
требования безопасности;
-
стратегия ветвления;
-
монорепозиторий или несколько репозиториев;
-
цель деплоя;
-
требования к аудиту и согласования.
Если статья сообщества или старый лабораторный пример конфликтует с актуальной документацией, приоритет лучше отдавать актуальной документации.
14. Финальный пример .gitlab-ci.yml
Ниже не идеальный пайплайн для все, а нормальный стартовый шаблон для сервисного репозитория. Его нужно адаптировать под язык, тесты, реестр, окружения и модель безопасности.
Нюанс по совместимости: финальный пример ниже ориентирован на относительно свежий GitLab, примерно 18.3+. Если у вас self-managed-инсталляция старее, отдельно проверьте поддержку workflow:auto_cancel:on_job_failure, переменной $CI_MERGE_REQUEST_DRAFT и manual_confirmation для stop-джобов. На старых версиях эти части примера нужно будет упростить или адаптировать под вашу версию GitLab.
workflow: auto_cancel: on_new_commit: interruptible on_job_failure: all rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_DRAFT == "true" when: never - if: $CI_COMMIT_TAG - if: $CI_PIPELINE_SOURCE == "schedule" - if: $CI_PIPELINE_SOURCE == "api" - if: $CI_PIPELINE_SOURCE == "trigger" - if: $CI_PIPELINE_SOURCE == "pipeline" - if: $CI_PIPELINE_SOURCE == "parent_pipeline" - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push" when: never - if: $CI_COMMIT_BRANCHstages: - validate - test - build - deploydefault: interruptible: true.node-job: image: node:22-bookworm-slim cache: key: files: - package-lock.json paths: - .npm/ policy: pull before_script: - npm ci --cache .npm --prefer-offline.common-rules: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_TAG - if: $CI_PIPELINE_SOURCE == "schedule" - if: $CI_PIPELINE_SOURCE == "api" - if: $CI_PIPELINE_SOURCE == "trigger" - if: $CI_PIPELINE_SOURCE == "pipeline" - if: $CI_PIPELINE_SOURCE == "parent_pipeline" - if: $CI_COMMIT_BRANCHdeps: extends: .node-job stage: validate cache: key: files: - package-lock.json paths: - .npm/ policy: pull-push rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH script: - echo "Dependency cache updated"lint: extends: - .node-job - .common-rules stage: validate needs: [] script: - npm run linttest: extends: - .node-job - .common-rules stage: test needs: [] script: - npm test -- --ci --reporters=default --reporters=jest-junit --coverage coverage: '/All files[^|]*\|[^|]*\s+\d+(?:\.\d+)?/' artifacts: when: always paths: - junit.xml - coverage/ reports: junit: junit.xml coverage_report: coverage_format: cobertura path: coverage/cobertura-coverage.xml expire_in: 14 daysbuild-app: extends: - .node-job - .common-rules stage: build needs: - job: test artifacts: false script: - npm run build artifacts: paths: - dist/ expire_in: 7 daysbuild-image-check: stage: build image: name: moby/buildkit:rootless entrypoint: [""] needs: - job: build-app artifacts: true variables: BUILDKITD_FLAGS: --oci-worker-no-process-sandbox script: - | buildctl-daemonless.sh build \ --frontend dockerfile.v0 \ --local context=. \ --local dockerfile=. \ --output type=oci,dest=/tmp/image.tar rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID != $CI_PROJECT_IDbuild-review-image: stage: build image: name: moby/buildkit:rootless entrypoint: [""] needs: - job: build-app artifacts: true variables: BUILDKITD_FLAGS: --oci-worker-no-process-sandbox CACHE_IMAGE: $CI_REGISTRY_IMAGE:buildcache REVIEW_IMAGE: $CI_REGISTRY_IMAGE:review-$CI_COMMIT_SHA before_script: - mkdir -p ~/.docker - | cat > ~/.docker/config.json <<EOF { "auths": { "$CI_REGISTRY": { "username": "$CI_REGISTRY_USER", "password": "$CI_REGISTRY_PASSWORD" } } } EOF script: - | buildctl-daemonless.sh build \ --frontend dockerfile.v0 \ --local context=. \ --local dockerfile=. \ --import-cache type=registry,ref=$CACHE_IMAGE \ --export-cache type=registry,ref=$CACHE_IMAGE,mode=max \ --output type=image,name=$REVIEW_IMAGE,push=true rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID == $CI_PROJECT_IDbuild-image: stage: build image: name: moby/buildkit:rootless entrypoint: [""] needs: - job: build-app artifacts: true variables: BUILDKITD_FLAGS: --oci-worker-no-process-sandbox CACHE_IMAGE: $CI_REGISTRY_IMAGE:buildcache before_script: - mkdir -p ~/.docker - | cat > ~/.docker/config.json <<EOF { "auths": { "$CI_REGISTRY": { "username": "$CI_REGISTRY_USER", "password": "$CI_REGISTRY_PASSWORD" } } } EOF script: - | buildctl-daemonless.sh build \ --frontend dockerfile.v0 \ --local context=. \ --local dockerfile=. \ --import-cache type=registry,ref=$CACHE_IMAGE \ --export-cache type=registry,ref=$CACHE_IMAGE,mode=max \ --output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA,push=true rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHreview-app: stage: deploy image: registry.example.com/platform/deploy-tools:1.0.0 needs: - build-review-image script: - ./deploy-review.sh "$CI_REGISTRY_IMAGE:review-$CI_COMMIT_SHA" environment: name: review/$CI_COMMIT_REF_SLUG url: https://$CI\_ENVIRONMENT\_SLUG.example.com on_stop: stop-review auto_stop_in: 1 week deployment_tier: development resource_group: review/$CI_COMMIT_REF_SLUG rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID == $CI_PROJECT_IDstop-review: stage: deploy image: registry.example.com/platform/deploy-tools:1.0.0 script: - ./destroy-review.sh environment: name: review/$CI_COMMIT_REF_SLUG action: stop resource_group: review/$CI_COMMIT_REF_SLUG when: manual manual_confirmation: "Destroy review environment?" allow_failure: true rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID == $CI_PROJECT_IDdeploy-production: stage: deploy image: registry.example.com/platform/deploy-tools:1.0.0 interruptible: false needs: - build-image resource_group: production environment: name: production url: https://example.com deployment_tier: production rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: manual manual_confirmation: "Deploy to production?" script: - ./deploy.sh "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" production
Что здесь важно:
-
создание пайплайна управляется через
workflow:rules; -
черновой MR можно не гонять;
-
пайплайн ветки не дублирует MR-пайплайн;
-
теги, расписания, API, trigger, multi-project и parent-child downstream-процессы явно разрешены на уровне
workflow; -
пайплайны, запущенные триггером, API, parent-child и multi-project downstream-процессами не блокируются правилом для пайплайнов веток, запущенных push-событием;
-
быстрые проверки стартуют через
needs: []; -
настройки Node.js вынесены в
.node-job, поэтому деплой- и stop-джобы не наследуютnpm ci; -
деплой- и stop-джобы используют отдельный зафиксированный образ с инструментами деплоя, а не случайный базовый
alpine; -
кеш зависимостей обновляется отдельным джобом
depsчерезpolicy: pull-push; -
тесты публикуют JUnit и покрытие, а регулярное выражение покрытия использует незахватывающие группы;
-
результат сборки передаётся через артефакты;
-
build-appне скачивает тестовые артефакты без необходимости; -
MR из форка проверяет Docker-сборку через
build-image-check, но не пушит образ в реестр; -
MR внутри того же проекта может собрать отдельный образ для ревью-окружения;
-
продакшен-образ собирается через BuildKit без root-прав только из ветки по умолчанию;
-
продакшен-образ тегируется SHA коммита;
-
ревью-окружение имеет
on_stop,auto_stop_inиresource_group; -
stop-reviewне используетGIT_STRATEGY: none, потому что вызывает./destroy-review.shиз репозитория; -
опасные manual-джобы имеют
manual_confirmation; -
продакшен-деплой ручной, не прерываемый и сериализован через
resource_group.
15. План внедрения
Если проект уже живой, не нужно внедрять всё сразу. Хорошая последовательность такая.
Шаг 1. Навести порядок в создании пайплайнов
-
добавить
workflow:rules; -
убрать дубли пайплайнов веток и MR;
-
перестать смешивать
only/exceptиrules; -
явно разделить push, MR, расписания, теги и trigger-процессы.
Шаг 2. Ускорить цикл обратной связи
-
вынести линтинг/стиль/схему в ранние джобы;
-
добавить
needs: []для быстрых проверок; -
найти критический путь;
-
добавить
interruptibleтам, где джобы можно отменять; -
включить auto-cancel для лишних пайплайнов.
Шаг 3. Разобраться с кешем и артефактами
-
отделить кеш от артефактов;
-
построить ключи кеша от lock-файлов;
-
добавить резервные ключи;
-
включить распределённый кеш для нескольких раннеров;
-
задать
expire_inдля артефактов; -
убрать лишнее скачивание артефактов через
dependencies/needs:artifacts.
Шаг 4. Привести Docker-сборки к нормальной модели
-
перестать собирать через опасный привилегированный процесс без причины;
-
перейти на BuildKit без root-прав/Buildah/Podman или осознанный процесс на Docker Buildx;
-
добавить кеш в реестре;
-
не передавать секреты через
ARG/ENV/COPY; -
использовать Dependency Proxy;
-
тегировать образы по SHA коммита или digest.
Шаг 5. Защитить секреты и токены
-
убрать секреты из репозитория;
-
замаскированные/скрытые/защищённые переменные как минимум;
-
продакшен-секреты привязать к защищённым refs и окружениям;
-
перейти на OIDC/Vault для облаков и внешних секретов;
-
ограничить
CI_JOB_TOKENсписок разрешений; -
включить точечные права доступа, где это возможно.
Шаг 6. Привести процесс деплоя в порядок
-
описать окружения;
-
включить защищённые окружения;
-
добавить согласования;
-
сериализовать деплой через
resource_group; -
включить защиту от устаревших деплоев;
-
сделать ревью-окружения с очисткой;
-
добавить карты маршрутов, если это frontend/документация/статический сайт.
Шаг 7. Стандартизировать переиспользование
-
вынести повторяющийся YAML в
extends/!reference; -
разделить job-шаблоны и шаблоны пайплайнов;
-
перейти к CI/CD-компонентам для массового переиспользования;
-
добавить
spec:inputs; -
пинить версии;
-
тестировать компоненты по
$CI_COMMIT_SHA; -
вести журнал изменений и заметки по миграции.
Шаг 8. Добавить наблюдаемость и контроль
-
JUnit-отчёты;
-
отчёты о покрытии;
-
отчёт Code Quality;
-
артефакты через
expose_as; -
аналитика пайплайнов;
-
мониторинг раннеров;
-
документация архитектуры пайплайна;
-
CODEOWNERS для CI/CD-файлов;
-
политики безопасности для критичных проектов.
16. Заключение
GitLab CI/CD легко недооценить. Пока проект маленький, кажется, что достаточно пары джобов и трёх стадий. Но чем ближе пайплайн к продакшену, тем больше он становится похож на полноценную платформу доставки.
Хороший GitLab CI/CD — это не самый сложный YAML. Это понятная архитектура, где:
-
пайплайн создаётся только когда должен создаваться;
-
джобы запускаются только когда нужны;
-
быстрые ошибки падают рано;
-
кеш ускоряет, а не ломает воспроизводимость;
-
артефакты передают результат, а не заменяют хранилище;
-
Docker-образы собираются безопасно;
-
секреты не живут в коде и не раздаются всем джобам;
-
деплой сериализован и ограничен правами;
-
ревью-окружения сами очищаются;
-
отчёты видны в MR, а не спрятаны в логах;
-
переиспользуемая логика версионируется и тестируется;
-
раннеры изолированы и не смешивают доверенные/недоверенные нагрузки.
Главная мысль простая: CI/CD надо проектировать так же внимательно, как продакшен-инфраструктуру. Потому что для современного проекта CI/CD и есть одна из дорог к продакшену.
ссылка на оригинал статьи https://habr.com/ru/articles/1052024/