voidlearn
 

4.2GitLab CI на практике

Прочитаешь GitLab CI как граф: правила выбирают pipeline и jobs, needs задаёт зависимости, runner исполняет их в чистом окружении. Разберёшь кэш, артефакты, сервисы и гонки между параллельными выкладками.

Что сможешь объяснить после этой главы
  • Как needs превращает стадии в граф и откуда два пайплайна на ветке с открытым merge request.
  • Почему GOCACHE кладут внутрь каталога проекта и почему он экономит больше, чем кэш модулей.
  • Почему go test -race падает на alpine и какое регулярное выражение покрытия врёт.
  • Откуда у сервиса Postgres имя хоста и как собрать образ без Docker-демона.
  • Почему resource_group не гарантирует, что последней выкатится свежая версия.

Примеры сняты на одноразовом репозитории shop/orders (chi, pgx v5.11.0, unit-тесты и интеграционный тест на Postgres, Go 1.27.1) через npx gitlab-ci-local@4.75.1, дальше просто gitlab-ci-local, с --no-color и без служебных строк, в Docker Desktop 29.8 на 4 CPU. Это не настоящий раннер: где он ведёт себя иначе, есть оговорка, остальное сверено с документацией GitLab 19.3.

Стадии и граф needs

Джобы одной стадии идут параллельно, следующая стадия ждёт, пока все джобы предыдущей закончатся успешно. Без stages GitLab берёт .pre, build, test, deploy, .post, джоб без stage попадает в test. Ключ needs перечисляет, кого ждать: джоб стартует, как только они закончились, и порядок стадий на него не действует. needs: [] запускает джоб сразу. В списке до 50 джобов, можно и из своей стадии.

stages: [build, test]

compile:
  stage: build
  image: alpine:3.24
  script: sleep 5

lint:
  stage: test
  image: alpine:3.24
  needs: []
  script: echo lint

unit:
  stage: test
  image: alpine:3.24
  script: echo unit
$ gitlab-ci-local --timestamps \
    --file .gitlab-ci-needs.yml
[04:58:21  756 μs] lint    starting alpine:3.24 (test)
[04:58:21 3.34 ms] compile starting alpine:3.24 (build)
…
[04:58:22  538 ms] compile $ sleep 5
[04:58:22  594 ms] lint    finished in 594 ms
[04:58:27  5.62 s] compile finished in 5.62 s
[04:58:27 1.06 ms] unit    starting alpine:3.24 (test)
…

lint закончился раньше, чем проснулся compile, а unit ждал его. В orders проверкам нужны только модули, у всех needs: [deps], а build ждёт все пять. Схема нарисована по gitlab-ci-local --list, который печатает стадию, when и needs каждого джоба.

Пайплайн push в main: стадии и needs .pre lint test build deploy deps модули, 2,3 с lint golangci-lint, 15 с vet go vet, 5,9 с needs: [deps]: стартуют вместе сразу после deps unit -race, junit, 11 с integration postgres, 9,5 с vulncheck govulncheck, 15 с needs: все пять build bin/orders, 3,3 с container BuildKit, 5,6 с deploy-staging resource_group deploy-production тег, when: manual compat: [1.26.8] schedule, needs: [] в пайплайн push в main не входит (rules); у джобов время с тёплым кэшем
Граф вместо конвейера. Стадии задают колонки, а порядок запуска задают needs: пять проверок стартуют сразу после deps, build ждёт их все, выкатка ждёт образ. Джобы, которые rules не пустили в этот пайплайн, остаются в файле, но не создаются.

rules, типы пайплайнов и workflow

Состав пайплайна решается при его создании. rules проверяются по порядку до первого совпадения, и оно добавляет джоб со своим when (по умолчанию on_success) или не добавляет при never; не совпало ничего, и джоба нет. if, changes и exists внутри правила складываются через «и». only и except устарели, мешать их с rules в одном пайплайне документация не советует.

CI_PIPELINE_SOURCE равна push для веток и тегов, в других случаях это merge_request_event, schedule, web, api, trigger, parent_pipeline и ещё несколько значений. CI_COMMIT_BRANCH нет у merge request и тегов, CI_COMMIT_TAG есть только у тегов. В orders compat (parallel:matrix по GO_VERSION) пускается правилом $CI_PIPELINE_SOURCE == "schedule", deploy-staging правилом $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH, deploy-production правилом $CI_COMMIT_TAG с when: manual. Таблица собрана из gitlab-ci-local --list-all, другие типы пайплайна заданы через --variable и --unset-variable CI_COMMIT_BRANCH:

Джобpush в mainpush в веткуmerge requestтеграсписание на main
deps … containerдадададада
compat ×2нетнетнетнетда
deploy-stagingданетнетнетда
deploy-productionнетнетнетmanual, allow_failure falseнет

У расписания на main есть CI_COMMIT_BRANCH, и ночной прогон совместимости заодно выкатит staging, если не добавить && $CI_PIPELINE_SOURCE == "push". rules:changes в merge request сравнивает с целевой веткой, в пайплайне ветки с предыдущим коммитом. У новой ветки, тега, расписания и ручного запуска совпадение есть всегда, если не задать базу в changes:compare_to. gitlab-ci-local сравнивает рабочее дерево с origin/main: после echo "# local edit" >> Dockerfile джоб с changes: [Dockerfile] сменил never на on_success без коммита.

Push в ветку с открытым merge request для GitLab два события: push ветки и обновление merge request. Если правила пускают оба, на коммит создаются два пайплайна. Обычно виновато - when: always в конце списка или джобы без rules рядом с джобами, у которых есть правило для merge request. workflow:rules решает, создавать ли пайплайн вообще; вот рецепт документации с правилом для тегов:

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
      when: never
    - if: $CI_COMMIT_BRANCH
    - if: $CI_COMMIT_TAG

Второе правило отбрасывает пайплайн ветки с открытым merge request: CI_OPEN_MERGE_REQUESTS хранит до четырёх ссылок вида shop/orders!7 и известна до создания пайплайна. Без четвёртого правила, которого в документации нет, тег пайплайн не создаёт. Merge train и пайплайны на результате слияния разобраны в разделе Git, глава 4.2. С GitLab 19.2 то же делает бета-настройка Skip branch pipelines for merge requests.

Этот рецепт локально не проверить

gitlab-ci-local 4.75.1 берёт из workflow:rules только variables. Файл с единственным правилом - when: never отработал целиком (hello > workflow did not stop me, PASS hello), а с --variable 'CI_OPEN_MERGE_REQUESTS=shop/orders!7' список джобов не пустеет.

cache и artifacts

Кэш хранит у раннера или в S3 то, что можно скачать или пересобрать, и отдаёт любому джобу с тем же ключом, хоть из следующего пайплайна. Гарантий нет: раннер без общего хранилища его не увидит. Артефакт хранит результат джоба в GitLab до expire_in (по умолчанию 30 дней) и достаётся джобам этого пайплайна. Пути обоих считаются от CI_PROJECT_DIR и выйти за него не могут. В образе golang модули лежат в /go/pkg/mod, а проект в /builds/shop/orders:

$ gitlab-ci-local --file .gitlab-ci-cache-outside.yml
deps-outside $ go env GOMODCACHE
deps-outside > /go/pkg/mod
…
WARNING: processPath: artifact path is not a subpath of project directory: /go/pkg/mod
deps-outside cache created in '.gitlab-ci-local/cache/gomod-outside' in 256 ms
…
$ find .gitlab-ci-local/cache/gomod-outside -type f | wc -l
       0

Джоб зелёный, кэш «создан» и пуст; такое же предупреждение есть в исходниках GitLab Runner. Поэтому каталоги Go переносят внутрь проекта:

variables:
  GOPATH: $CI_PROJECT_DIR/.go
  GOCACHE: $CI_PROJECT_DIR/.go-build
  TOOLS: -modfile=tools/go.mod

.go-cache:
  cache:
    - key: gobuild-$CI_JOB_NAME_SLUG
      paths: [.go-build/]
    - &gomod-cache
      key:
        files: [go.sum, tools/go.sum]
        prefix: gomod
      paths: [.go/pkg/mod/]
      policy: pull

deps:
  stage: .pre
  cache:
    - <<: *gomod-cache
      policy: pull-push
  script:
    - go mod download
    - go mod download $TOOLS

Каталоги с точкой в начале шаблон ./... пропускает. Инструменты CI закреплены в отдельном tools/go.mod, чтобы их зависимости не поднимали версии у сервиса. cache:key:files берёт до двух файлов; с GitLab 18.4 ключ считается по их содержимому, а по коммитам его считает files_commits. Модули записывает один deps, остальные получают .go-cache через extends и только читают. Кэш компилятора у каждого джоба свой: unit собирает с -race, vet с тегом сборки, и общий ключ достался бы последнему записавшему. Три прогона одного коммита (кэш копируется docker cp, джобы делят 4 CPU, сравнивай столбцы):

Джоббез кэшамодули и GOCACHEтолько модули
deps7,5 с2,3 с2,3 с
lint / vet37 / 37 с15 / 5,9 с39 / 39 с
unit / integration46 / 34 с11 / 9,5 с45 / 33 с
vulncheck / build32 / 8,0 с15 / 3,3 с29 / 6,7 с
пайплайн1,18 мин32 с1,08 мин

Почти весь выигрыш дал GOCACHE, ценой места: кэш компилятора unit занял 284 МБ, модули 176 МБ. В GOCACHE лежат и результаты тестов (✓ internal/price (cached)), поэтому интеграционный тест идёт с -count=1. К ключам GitLab добавляет -protected или -non_protected, и обычная ветка кэш main не видит. cache:fallback_keys не спасёт, суффикс получают и они, а общий кэш через cache:unprotect: true даёт любой ветке подменить кэш main.

Артефакт build (paths: [bin/orders], expire_in: 1 day) забирает container. Джоб без needs получает артефакты всех ранних стадий (сужает dependencies), с needs только перечисленных. artifacts:reports загружаются при любом исходе джоба. Кэш распаковывается раньше артефактов, и при общем пути артефакт его перезапишет.

Кэш и артефакты между джобами и пайплайнами Пайплайн 1, холодный: 1,18 мин deps скачивает модули unit компилирует с нуля build go build container берёт bin/orders push gomod pull gomod push gobuild-unit bin/orders needs: [build] Кэш раннера или S3 gomod + SHA(go.sum, tools/go.sum) gobuild-unit, gobuild-build, … Артефакты в GitLab build: bin/orders, 1 day unit: report.xml, coverage.xml pull gomod pull gomod и gobuild-unit новый bin/orders needs: [build] deps модули уже есть unit тесты (cached) build 3,3 с вместо 8,0 container свой bin/orders Пайплайн 2, тот же go.sum: 32 с
Кэш и артефакты. Модули и кэш компилятора достаются следующему пайплайну с тем же ключом, а бинарь уходит в GitLab артефактом и достаётся джобу, который назвал build в needs.

Джобы Go: линтер, тесты, покрытие, govulncheck

golangci-lint берут из образа golangci/golangci-lint:v2.13.2-alpine. Конфиг v2 начинается с version: "2", набор по умолчанию: errcheck, govet, ineffassign, staticcheck, unused. Таймаута по умолчанию в v2 нет (в v1.64 была минута), и --timeout 5m ставит предел короче таймаута джоба. go test сам запускает лишь часть проверок vet, поэтому джоб vet гоняет полный go vet, заодно с -tags integration.

unit:
  stage: test
  extends: .go-cache
  needs: [deps]
  variables:
    CGO_ENABLED: "1"
  before_script:
    - apk add --no-cache gcc musl-dev
  script:
    - go tool $TOOLS gotestsum --junitfile report.xml -- -race -coverprofile=coverage.out ./...
    - go tool cover -func=coverage.out | tail -n 1
    - go tool $TOOLS gocover-cobertura < coverage.out > coverage.xml
  coverage: '/total:\s+\(statements\)\s+\d+.\d+%/'
  artifacts:
    when: always
    expire_in: 1 week
    reports:
      junit: report.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

Везде, кроме macOS, детектору гонок нужен cgo, а с ним и C-компилятор. В golang:1.27.1-alpine его нет, Go выключает cgo, и джоб без before_script и CGO_ENABLED падает с go: -race requires cgo; enable cgo by setting CGO_ENABLED=1. В debian-образе golang:1.27.1 gcc по его Dockerfile уже есть, но сжатым он весит 316 МБ против 75 МБ. На alpine установка отнимает около 3 секунд на джоб, а детектор с musl работает: тест с гонкой упал с WARNING: DATA RACE. С -race покрытие считается в режиме atomic, -covermode=count Go отвергает.

По JUnit XML от gotestsum --junitfile GitLab показывает упавшие тесты в merge request. Процент покрытия он ищет выражением coverage в логе и берёт последнее совпадение. Выражение из документации для одиночного go test -cover совпадает со строкой каждого пакета:

unit > ✓  internal/price (cached) (coverage: 100.0% of statements)
unit > ∅  cmd/orders (55ms) (coverage: 0.0% of statements)
unit > ∅  internal/store (53ms) (coverage: 0.0% of statements)
…
unit > total:						(statements)	14.8%
…
 PASS  unit 0.0% coverage

С выражением по строке total: тот же джоб даёт 14.8% coverage. Отчёт Cobertura от gocover-cobertura v1.5.0 подсвечивает покрытые строки в diff merge request. JUnit и Cobertura gitlab-ci-local не обрабатывает.

govulncheck v1.8.0 сверяет зависимости с vuln.go.dev и ищет по графу вызовов путь до уязвимой функции. 17 сентября 2026 на main он нашёл GO-2026-5970 в golang.org/x/text@v0.29.0, но джоб прошёл: Your code is affected by 0 vulnerabilities. This scan also found 1 vulnerability in packages you import. В ветке с pgx v5.5.3:

vulncheck > Vulnerability #2: GO-2024-2606
…
vulncheck >       #1: internal/store/store.go:47:86: store.Store.Total calls pgxpool.poolRow.Scan, which eventually calls pgconn.MultiResultReader.Close
…
vulncheck > Your code is affected by 2 vulnerabilities from 1 module.
…
vulncheck finished in 7.45 s  FAIL 3

«Affected» значит, что найден путь от твоего кода до уязвимой функции: на уровне -scan по умолчанию, symbol, код выхода 3 дают только такие находки. Уязвимость в импортированном, но не вызываемом пакете печатается справкой, а с -scan package тот же джоб упал бы. С -format json код всегда 0, тесты проверяются только с -test. База пополняется и без твоих коммитов, новое в ней ловит джоб по расписанию.

services: Postgres рядом с тестом

integration:
  stage: test
  extends: .go-cache
  needs: [deps]
  services:
    - postgres:18-alpine
  variables:
    POSTGRES_DB: orders
    POSTGRES_USER: orders
    POSTGRES_PASSWORD: orders
    DATABASE_URL: postgres://orders:orders@postgres:5432/orders?sslmode=disable
  script:
    - go test -tags integration -count=1 ./internal/store/...

Имя хоста сервиса раннер берёт из имени образа без тега, заменяя / на __ или -: postgres:18-alpine становится postgres, другое имя задаёт alias. До before_script раннер ждёт открытия портов из образа до 30 секунд (wait_for_services_timeout), а не дождавшись, пишет probably didn't start properly и идёт дальше. У Postgres порт не откроется раньше времени: на время инициализации entrypoint держит временный сервер с listen_addresses=''.

$ gitlab-ci-local --file .gitlab-ci-services.yml
db-check started service image: postgres:18-alpine with aliases: db, postgres in 143 ms
db-check service image: postgres:18-alpine healthcheck passed in 1.2 s
db-check $ getent hosts db
db-check > 172.24.0.2        db  db
db-check $ echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD POSTGRES_DB=$POSTGRES_DB"
db-check > POSTGRES_PASSWORD=orders POSTGRES_DB=
db-check $ PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U postgres -d orders -Atc 'select current_database()'
db-check > orders

Здесь у сервиса alias: db, пароль в variables джоба, POSTGRES_DB в services:variables. Переменные джоба достались и сервису, а services:variables только ему; переменные из настроек проекта, по документации, до сервиса не доходят. Друг друга сервисы видят только в сети на джоб, её включает флаг FF_NETWORK_PER_BUILD, а gitlab-ci-local создаёт её всегда.

Переменные

vars > CI_COMMIT_SHA=dff08a8ce4cd87b3bfeffc557058ea6661cd62dc
vars > CI_COMMIT_SHORT_SHA=dff08a8c
vars > CI_COMMIT_REF_NAME=Feature/ORD-42_discount
vars > CI_COMMIT_REF_SLUG=feature-ord-42-discount
vars > CI_DEFAULT_BRANCH=main
vars > CI_PIPELINE_SOURCE=push
vars > CI_PROJECT_DIR=/builds/shop/orders

CI_COMMIT_REF_SLUG делается из имени ветки: нижний регистр, всё кроме a-z и 0-9 заменено на -, до 63 байт; её ставят в имена хостов и ключи кэша. Одноимённые переменные перекрываются от старших к младшим: политики, переменные пайплайна (ручной запуск пайплайна или джоба, расписание, API, trigger), проекта, группы, инстанса, dotenv, variables джоба, variables файла, выкатки, предопределённые. Переменная проекта перекрывает одноимённую из YAML, переменная пайплайна даже предопределённую, поэтому с GitLab 17.7 вместо них советуют inputs. В YAML значения берут в кавычки: VAR1: 012345 прочитается как восьмеричное 5349. Секреты задают в настройках:

СвойствоЧто делаетОграничение
Protectedтолько пайплайны защищённых веток и тегов (merge request по настройке с 18.1)кто пушит в такую ветку, получит значение
Masked[MASKED] в логе; видимость по умолчанию с 18.3одна строка, от 8 символов; base64 маску обходит
Masked and hiddenзначение скрыто и в настройках (GA в 17.6)только при создании переменной
Type: Fileзначение во временном файле, в переменной путьдля переменных из YAML недоступно

С GitLab 18.6 Expand variable reference по умолчанию выключен, $ в значении остаётся символом. OIDC-токены id_tokens разобраны в главе 4.3. gitlab-ci-local берёт секреты из .gitlab-ci-local-variables.yml, защищённость и маскирование не воспроизводит.

include, extends, !reference и компоненты

include берёт файл из того же репозитория (local), другого проекта с ref (project), по HTTPS (remote, с 17.9 сверка integrity), из шаблонов GitLab (template) или компонент (component). Включённое разбирается раньше основного файла и сливается с ним, одноимённые ключи основного главнее. Параметры передаются через spec:inputs с типом, умолчанием, вариантами и регулярным выражением; $[[ inputs.… ]] подставляется при создании пайплайна.

# ci/lint.yml
spec:
  inputs:
    version:
      default: v2.13.2
    timeout:
      default: 5m
      regex: ^\d+m$
---
lint:
  stage: lint
  image: golangci/golangci-lint:$[[ inputs.version ]]-alpine
  script:
    - golangci-lint run --timeout $[[ inputs.timeout ]]

.gitlab-ci-include-inputs.yml подключает его пунктом - local: ci/lint.yml с inputs: timeout: 10m, а .gitlab-ci-include-bad-input.yml с timeout: 90s:

$ gitlab-ci-local --preview --file .gitlab-ci-include-inputs.yml
…
    - golangci-lint run --timeout 10m
$ gitlab-ci-local --list --file .gitlab-ci-include-bad-input.yml
This GitLab CI configuration is invalid: `ci/lint.yml`: `timeout` input: `90s` does not match required regex: ^\d+m$.

В templates/go-lint.yml тот же файл становится компонентом $CI_SERVER_FQDN/shop/orders/go-lint@1.0.0; версия задаётся тегом, SHA или веткой, а для опубликованных в CI/CD Catalog (GA с 17.0) ещё @1 и @~latest. По @$CI_COMMIT_SHA он сработал и в gitlab-ci-local.

extends сливает джоб с шаблоном: ключи джоба главнее, словари вроде variables объединяются, списки заменяются целиком. Для шаблона .go с variables: {GOFLAGS: -trimpath} и before_script: [go version] и джоба с extends: .go, своим CGO_ENABLED и своим before_script --preview показал обе переменные и один apk add, а go version пропал. Список собирают через !reference, который, в отличие от YAML-якорей, работает и между файлами:

unit-reference:
  extends: .go
  before_script:
    - !reference [.go, before_script]    # в --preview: go version, затем apk add
    - apk add --no-cache gcc musl-dev
  script: go test -race ./...

Раннеры, executors и теги

gitlab-runner сам ходит в GitLab за работой: в цикле шлёт POST /api/v4/jobs/request и выполняет полученный джоб через executor. Соединение открывает раннер, входящий доступ ему не нужен.

ExecutorГде идёт джобЧто учесть
dockerновый контейнер из image, сервисы рядомкэш у каждого раннера свой, пока нет S3
kubernetesпод на джоб: build, helper и по контейнеру на сервискэш в томе /cache пода или из хранилища
docker-autoscaler, instanceмашины, которые раннер создаёт и удаляет через fleetingзамена устаревшему Docker Machine
shellна хосте под пользователем раннераmaintenance mode, окружение не чистится

Джоб достаётся только раннеру, у которого есть все его tags; без тегов его возьмёт раннер с Run untagged jobs, иначе джоб висит в pending. interruptible: true разрешает отменить джоб при новом коммите в ветку; по умолчанию (auto_cancel:on_new_commit: conservative) пайплайн отменяется, пока не стартовал джоб с interruptible: false, поэтому у выкаток orders стоит false. timeout не превышает таймаут раннера. retry повторяет до двух раз, retry:when сужает повтор до отказов инфраструктуры (в 19.1 появились runner_external_dependency_failure и runner_interrupted), а повтор на script_failure прячет нестабильные тесты. Ничего из этого, как и resource_group, gitlab-ci-local не исполняет.

Сборка образа в CI

Джоб docker-executor сам живёт в контейнере, и сборщик ему нужно откуда-то взять. BuildKit, кэш слоёв и мультиплатформенную сборку разбирает статья «Docker», глава 2.2.

СпособКак устроенЦена
Docker-in-Dockerсервис docker:29.8.1-dind; с DOCKER_TLS_CERTDIR: "/certs" и томом /certs/client клиент ходит на tcp://docker:2376раннер с privileged = true
проброс docker.sockджоб говорит с демоном хостаroot на хосте раннера
BuildKit rootless, buildahдемон на время сборки или без демона, без privilegedнужны вызовы для user namespace
container:
  stage: build
  image:
    name: moby/buildkit:v0.33.0-rootless
    entrypoint: [""]
  needs: [build]
  services:
    - name: registry:3
      alias: registry
  variables:
    BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
  script:
    - buildctl-daemonless.sh build
        --frontend dockerfile.v0
        --local context=.
        --local dockerfile=.
        --output type=image,name=registry:5000/orders:$CI_COMMIT_SHORT_SHA,push=true,registry.insecure=true
    - wget -qO- http://registry:5000/v2/orders/tags/list

Без entrypoint: [""] образ запустит демон вместо скрипта. Dockerfile копирует артефакт bin/orders в distroless-образ. Вместо реестра GitLab здесь сервис registry:3; в GitLab образ назывался бы $CI_REGISTRY_IMAGE:…, а before_script писал бы ~/.docker/config.json. gitlab-ci-local по умолчанию запускает джобы от 0:0, раннер так не делает, отсюда --no-umask:

$ gitlab-ci-local --no-umask container
…
container > [rootlesskit:parent] error: failed to start the child: fork/exec /proc/self/exe: operation not permitted
…
container finished in 3.68 s  FAIL 1
$ gitlab-ci-local --no-umask --privileged container
…
container > #7 pushing manifest for registry:5000/orders:dff08a8c@sha256:84da9a4b305ba5d8e69a34375054dcb1d873ae948e9159f9800fd35e3c22bb02 done
…
container > {"name":"orders","tags":["dff08a8c"]}
container finished in 5.62 s

Rootless-демону нужны свой user namespace и монтирования, а встроенный в Docker Engine профиль seccomp пускает их только при CAP_SYS_ADMIN. Docker Desktop тут ни при чём: так будет на любом docker-раннере с настройками по умолчанию, и buildah 1.43.2 падает так же; что делать и что стало с kaniko, в вопросе 3. Подпись образа, SBOM и выкатка по дайджесту разобраны в главе 4.3.

environment, manual и resource_group

deploy-production:
  stage: deploy
  image: alpine:3.24
  needs: [container]
  rules:
    - if: $CI_COMMIT_TAG
      when: manual
  environment:
    name: production
    url: https://orders.example.com
  resource_group: production
  interruptible: false
  script:
    - echo "deploy orders:$CI_COMMIT_SHORT_SHA ($CI_COMMIT_TAG) to $CI_ENVIRONMENT_NAME"

environment создаёт окружение, если его нет, и пишет выкатку в его историю; джоб получает CI_ENVIRONMENT_NAME. Защищённые окружения и одобрения выкаток есть в Premium и Ultimate, сама выкатка в главе 4.3. У when: manual два умолчания. На уровне джоба это allow_failure: true: пайплайн зелёный без нажатия, а для следующих стадий незапущенный джоб считается успешным. Внутри rules это allow_failure: false, и пайплайн стоит в статусе blocked, пока джоб не запустят. resource_group держит один слот на все пайплайны проекта, два джоба с production разом не пойдут, но порядок в режиме по умолчанию unordered не обещан (вопрос 2). gitlab-ci-local группу не соблюдает, из окружения он только подставляет CI_ENVIRONMENT_NAME.

Соответствие с GitHub Actions

GitHub ActionsGitLab CI
workflow в .github/workflows/пайплайн из .gitlab-ci.yml и include
on: push, pull_request, scheduleCI_PIPELINE_SOURCE, workflow:rules, rules; расписания в интерфейсе
job, runs-onджоб, tags
step: run, usesстроки script; шаги run пока экспериментальные
needs, стадий нетneeds поверх стадий
actions/cache, restore-keyscache, fallback_keys
upload-artifact, download-artifactartifacts, скачиваются по needs и стадиям
strategy.matrix, до 256 джобовparallel:matrix, до 200
environments, protection rulesenvironment, защищённые окружения
concurrency: по умолчанию один ожидающий, прежний отменяется; queue: max копит очередьresource_group: ожидающие копятся
reusable workflows, workflow_callкомпоненты и include с spec:inputs

Вопросы

4
Суть: кэш пустеет молча. Проверяют путь (внутри ли CI_PROJECT_DIR), ключ, политику, общее ли хранилище у раннеров и защищённость ветки. И кэшируют GOCACHE вместе с модулями.

Что читать в логе

ПризнакПричина
джоб зелёный, следующий снова go: downloadingпуть вне проекта: /go/pkg/mod, ~/.cache/go-build; в логе not a subpath of project directory
ключи в логах джобов разныеразные key:files или prefix; новый go.sum в ветке даёт новый ключ
у всех policy: pullкэш никто не записывает
в ветке пусто, в main работаетсуффиксы -protected и -non_protected, их получают и fallback_keys; общий кэш даёт unprotect: true

Какой кэш важнее

Пайплайн orders без кэша шёл 1,18 минуты, с кэшем модулей 1,08, с модулями и GOCACHE 32 секунды: модули приходят через прокси за секунды, а компиляция повторяется в каждом джобе. Ключ GOCACHE делают свой на джоб, иначе джобы с -race и без перезаписывают друг друга.

Бинарь через кэш

Бинарь, переданный через cache, джоб на другом раннере не найдёт, а джоб другого пайплайна с тем же ключом получит чужой. Результат сборки передают артефактом.

Суть: resource_group не пускает две выкатки одновременно, но порядок не задаёт: в режиме unordered выкатка первого пайплайна может пройти после второго и перезаписать её. Чинят режимом группы и настройкой Prevent outdated deployment jobs.

Как это выходит

Пайплайны A (коммит 1) и B (коммит 2) идут почти одновременно. У B тесты быстрее, его deploy первым занимает слот. deploy из A ждёт и получает слот следом: в unordered он достаётся любому готовому джобу. На проде коммит 1.

Что настроить

СредствоЭффект
process_mode: oldest_firstвыкатки по номеру пайплайна: A, потом B
newest_first, newest_ready_firstсначала свежая; выкатка должна быть идемпотентной
Prevent outdated deployment jobsвыкатка старше прошедшей падает с failed outdated deployment job
interruptible: falseавтоотмена не оборвёт выкатку на полпути

Режим меняют только через API: PUT /projects/:id/resource_groups/production с process_mode=oldest_first (по документации, здесь не запускалось). Prevent outdated deployment jobs считает возраст по времени старта джоба, а не по коммиту: если ручную выкатку из старого пайплайна нажали после создания нового, заблокированной окажется выкатка нового.

Ручной джоб не спасает

С when: manual на уровне джоба пайплайн зеленеет без выкатки, и через неделю кто-то жмёт кнопку в старом пайплайне. Ручные выкатки объявляют в rules и включают защиту от устаревших выкаток.

Суть: dind без privileged не работает. BuildKit rootless и buildah обходятся без Docker-демона и привилегий, но создают user namespace и монтируют, а это запрещают профиль seccomp Docker по умолчанию и AppArmor узла. Kaniko в архиве с июня 2025, GitLab предлагает вместо него BuildKit rootless.

Что показал прогон

На Docker Desktop 29.8 с профилем seccomp по умолчанию moby/buildkit:v0.33.0-rootless упал с fork/exec /proc/self/exe: operation not permitted, quay.io/buildah/stable:v1.43.2 с unshare(CLONE_NEWUSER): Operation not permitted. С --privileged BuildKit собрал и отправил образ за 5,6 секунды. С профилем из исходников Engine 29.8 BuildKit упал так же, как со встроенным. Без privileged оба сборщика прошли шаг RUN с seccomp=unconfined и с профилем по умолчанию, где разрешены ещё unshare, setns, keyctl, sethostname, pivot_root, семейство mount и clone без проверки флагов. В ВМ Docker Desktop нет AppArmor, а на Ubuntu и Debian docker-default ещё и сам запрещает mount.

Варианты

УсловияРешение
свой docker-раннерBuildKit rootless и свой профиль seccomp в security_opt только с нужными вызовами, при AppArmor и его профиль; seccomp:unconfined документация не советует
kubernetes executorseccomp пода обычно Unconfined, монтирование режет AppArmor узла: app_armor_profile в build_container_security_context
раннеры GitLab.comони privileged, BuildKit rootless работает
менять ничего нельзяотдельный раннер для сборки образов и джоб с его тегом

--oci-worker-no-process-sandbox не даёт шагам RUN своё пространство PID, и шаг сборки может убить любой процесс в контейнере демона, о чём BuildKit предупреждает в логе демона. Этот лог buildctl-daemonless.sh выводит, только если демон не поднялся.

Kaniko

GoogleContainerTools/kaniko в архиве, последний push 3 июня 2025, образы gcr.io/kaniko-project/executor не обновляются. Chainguard поддерживает форк chainguard-dev/kaniko (теперь chainguard-forks/kaniko, тег v1.25.19 от 27 августа 2026): исправления безопасности без крупных новых функций и без публичных образов. Документация GitLab отсылает с kaniko к Docker, buildah и Podman и описывает переход на BuildKit rootless.

Суть: через services:: Postgres стартует рядом с джобом под именем из образа или alias, раннер ждёт порт до 30 секунд. Нестабильность дают медленный старт, переменные, не дошедшие до сервиса, и кэш результатов тестов.

Конфигурация

integration:
  services:
    - name: postgres:18-alpine
      alias: db
  variables:
    POSTGRES_DB: orders
    POSTGRES_USER: orders
    POSTGRES_PASSWORD: orders
    DATABASE_URL: postgres://orders:orders@db:5432/orders?sslmode=disable
  script:
    - go test -tags integration -count=1 ./internal/store/...

С таким джобом gitlab-ci-local дождался порта за 1,21 с, и тест прошёл.

Где ломается

  • connection refused. Раннер ждёт только порт и не дольше wait_for_services_timeout, потом пишет предупреждение и продолжает. Тест подключается с повтором под context.WithTimeout.
  • Сервис умер на старте. Пароль задан переменной проекта в интерфейсе, а такие в сервис не передаются. Без POSTGRES_PASSWORD образ пишет You must specify POSTGRES_PASSWORD и выходит, а в gitlab-ci-local это выглядело так: healthcheck failed, затем pg_isready в джобе ответил postgres:5432 - no response.
  • Сервис не видит другой сервис. Раннер без FF_NETWORK_PER_BUILD.
  • Тест «прошёл» со сломанной схемой. Без -count=1 результат мог прийти из GOCACHE.
  • Чужие данные в базе. go test ./... гоняет пакеты параллельно на одной базе: схема на пакет или -p 1.
Testcontainers в том же джобе

Testcontainers ищет Docker-демон, а в docker executor он появляется только с dind или пробросом сокета, то есть с привилегиями. Сервисы GitLab обходятся без демона.

Что стоит запомнить

  • needs позволяет запускать job сразу после его зависимостей, не дожидаясь всей предыдущей стадии.
  • Кэш ускоряет повторную работу, а артефакт переносит результат конкретного job.
  • resource_group сериализует jobs группы, но сам по себе не гарантирует порядок по свежести коммита.

Частые ошибки

  • Кэш модулей и GOCACHE имеют разные ключи и разную пользу для Go pipeline.
  • Rules без согласованной workflow-политики могут создавать два pipeline для одного коммита.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Прочитай граф needs

Какие jobs могут стартовать сразу, а какие ждут compile? Есть ли зависимость package от lint?

stages: [build, test, deploy]
compile:
  stage: build
  script: ["go build ./..."]
lint:
  stage: test
  needs: []
  script: ["golangci-lint run"]
unit:
  stage: test
  needs: [compile]
  script: ["go test ./..."]
package:
  stage: deploy
  needs: [unit]
  script: ["make package"]
Ответ и разбор

lint может стартовать сразу благодаря пустому needs. unit ждёт только compile, а package ждёт unit и потому косвенно ждёт compile. Прямой или косвенной зависимости от lint нет. Jobs с needs не обязаны ждать завершения всей предыдущей стадии.

Код-ревью

2. Убери два pipeline на push

При открытом merge request на каждый новый commit появляются и branch pipeline, и merge request pipeline. Правила jobs одинаковы, но workflow не ограничивает тип запуска. Что исправить?

build:
  script: make build
  rules:
    - if: '$CI_COMMIT_BRANCH'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
Ответ и разбор

Правило по branch срабатывает и для ветки с открытым merge request, поэтому создаётся лишний pipeline. Задай workflow: rules, чтобы при открытом MR запускался MR pipeline, а branch pipeline для этой ветки подавлялся; отдельно сохрани нужные события вроде default branch и тегов. После правки проверь все источники запуска, а не только push.

Применение

3. Выбери кэш для Go

Каждый runner скачивает модули заново и компилирует все пакеты. В проекте нужны повторные unit-тесты и интеграционный job. Что кэшировать и что передавать как artifact?

Ответ и разбор

Кэшируй каталог модулей и GOCACHE внутри workspace с ключом, учитывающим Go version и lock-файлы. Кэширование лишь ускоряет и должно переживать промах без ошибки сборки. Бинарь или отчёт тестов передавай как artifact между jobs, если следующий job использует именно этот результат.

Применение

4. Разбери гонку выкладок

Коммит A прошёл проверки дольше коммита B. Оба deploy jobs ждут одну resource_group; B выкатился первым, затем A перезаписал его. Как закрыть риск старой версии поверх новой?

Ответ и разбор

resource_group не задаёт гарантированно порядок по возрасту коммитов. Перед выкладкой проверь, что revision всё ещё актуальна для целевой ветки, и отмени устаревший job; при необходимости сериализуй операцию и сравниваемую revision в одном управляемом шаге. Нужны также правила повторного запуска, чтобы более старый артефакт не прошёл проверку случайно.