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 каждого джоба.
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 в main | push в ветку | 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 | только модули |
|---|---|---|---|
deps | 7,5 с | 2,3 с | 2,3 с |
lint / vet | 37 / 37 с | 15 / 5,9 с | 39 / 39 с |
unit / integration | 46 / 34 с | 11 / 9,5 с | 45 / 33 с |
vulncheck / build | 32 / 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 загружаются при
любом исходе джоба. Кэш распаковывается раньше артефактов, и при общем пути артефакт его перезапишет.
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 Actions | GitLab CI |
|---|---|
workflow в .github/workflows/ | пайплайн из .gitlab-ci.yml и include |
on: push, pull_request, schedule | CI_PIPELINE_SOURCE, workflow:rules, rules; расписания в интерфейсе |
job, runs-on | джоб, tags |
step: run, uses | строки script; шаги run пока экспериментальные |
needs, стадий нет | needs поверх стадий |
actions/cache, restore-keys | cache, fallback_keys |
upload-artifact, download-artifact | artifacts, скачиваются по needs и стадиям |
strategy.matrix, до 256 джобов | parallel:matrix, до 200 |
| environments, protection rules | environment, защищённые окружения |
concurrency: по умолчанию один ожидающий, прежний отменяется; queue: max копит очередь | resource_group: ожидающие копятся |
reusable workflows, workflow_call | компоненты и include с spec:inputs |
Вопросы
4CI_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 и включают защиту от устаревших выкаток.
Что показал прогон
На 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 executor | seccomp пода обычно 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 ищет 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 в одном управляемом шаге. Нужны также правила повторного запуска, чтобы более старый артефакт не прошёл проверку случайно.