script, before_script, after_script и image
Изучаем основные поля джобы, которые управляют тем, что и в каком окружении она выполняет.
image — Docker-образ, в чистом контейнере которого docker-executor выполнит команды джобы.
Джоба — это атом пайплайна, и почти всё, что она делает, описывается горсткой полей: script, before_script, after_script и image. На первый взгляд кажется, что это просто «список команд и образ», но за каждым полем стоит конкретная модель исполнения: где именно запускается команда, в какой оболочке, в каком порядке и что произойдёт, если что-то упадёт. Понимание этой модели отличает человека, который копирует чужой .gitlab-ci.yml и надеется, что заработает, от того, кто читает лог упавшей джобы и за десять секунд видит причину. Поэтому в этом уроке мы не просто перечислим поля, а разберём, зачем каждое из них устроено именно так.
script — сердце джобы
Минимальная джоба обязана содержать script — список команд оболочки. Они выполняются по порядку; если любая возвращает ненулевой код, джоба считается упавшей и оставшиеся команды не выполняются.
unit-tests:
image: python:3.12
script:
- pip install -r requirements.txt
- pytest -qКлючевая идея здесь — код возврата. CI устроен вокруг простого правила: команда вернула 0 — успех, вернула что угодно другое — провал. Именно поэтому в script опасно прятать длинные пайпы вроде cmd1 | cmd2: по умолчанию оболочка возьмёт код только последней команды конвейера, и падение cmd1 может остаться незамеченным. Профессионалы добавляют в before_script строку set -o pipefail (для bash), чтобы конвейер падал, если упало любое его звено. Точно так же стоит помнить: каждая строка script — это, как правило, отдельная команда, но многострочные конструкции (циклы, if) лучше оформлять YAML-блоком, иначе раннер разобьёт их по строкам и логика сломается.
image — выбор окружения
Поле image задаёт Docker-образ, внутри которого побежит джоба. python:3.12 даст готовый Python, node:20 — Node.js, golang:1.22 — Go. Это устраняет проблему «на машине разработчика стоит другая версия»: окружение фиксируется в файле. Можно задать image глобально (по умолчанию для всех джоб) и переопределить в отдельной джобе.
Почему это так важно? Контейнер даёт две вещи сразу: воспроизводимость и изоляцию. Воспроизводимость — потому что образ с фиксированным тегом сегодня и через полгода развернёт одну и ту же версию интерпретатора и системных библиотек, и «у меня локально работает» перестаёт быть аргументом. Изоляция — потому что каждая джоба стартует в свежем контейнере: то, что натворила предыдущая джоба (наставила пакетов, наследила в /tmp), не повлияет на текущую. Из этого вытекает практическое правило тегов: избегайте image: python или image: node:latest. Тег latest — плавающий, и однажды утром ваш пайплайн упадёт не потому, что вы что-то изменили, а потому, что вышел новый мажорный релиз. Фиксируйте версию: python:3.12-slim, node:20-alpine. Кстати, варианты -slim и -alpine весят в разы меньше и быстрее скачиваются раннером, но в них нет привычных утилит вроде git или curl — их придётся доставить в before_script.
before_script и after_script
Часто перед основными командами нужна подготовка: установить зависимости, залогиниться, выставить переменные. Для этого есть before_script — он выполняется перед script в том же окружении. after_script наоборот выполняется в конце, причём даже если script упал — идеально для очистки, выгрузки логов, отправки уведомлений.
deploy:
image: alpine:3.19
before_script:
- apk add --no-cache curl
script:
- curl -sf https://example.com/deploy
after_script:
- echo "Джоба завершена (успех или нет)"Важная тонкость: after_script выполняется в отдельной оболочке, поэтому переменные и текущая директория из script в нём не сохраняются. Не рассчитывайте на состояние предыдущего шага.
Полезно держать в голове правильное разделение ответственности между этими тремя полями. before_script — это подготовка: всё, что нужно, чтобы основная работа вообще могла начаться (установка пакетов, аутентификация в реестре, экспорт служебных переменных). script — это цель джобы и единственное, что определяет её успех или провал. after_script — это уборка и оповещение: то, что должно произойти при любом исходе. Классический пример — отправка статуса в Slack или сбор coverage-отчёта: вам нужно увидеть отчёт даже у упавшего билда, поэтому ему место именно в after_script, а не в конце script (откуда падение его просто не дотащит). Ещё одна тонкость: у after_script отдельный, более жёсткий таймаут, и его собственное падение по умолчанию не делает джобу красной — он «best effort».
Глобальные значения по умолчанию
Чтобы не повторять image и before_script в каждой джобе, их выносят в раздел default:
default:
image: node:20
before_script:
- npm ci
lint:
script:
- npm run lint
test:
script:
- npm testОбе джобы унаследуют node:20 и установку зависимостей. Отдельная джоба может переопределить эти значения у себя.
Параллель с GitHub Actions помогает уложить это в голове. Там нет понятия image на уровне джобы в том же виде: вы либо задаёте runs-on (тип виртуальной машины), либо указываете container:, если хотите выполнить шаги внутри образа. А роль before_script там играют отдельные steps — в Actions всё разбито на именованные шаги, тогда как в GitLab script — это просто список команд одной оболочки. У подхода GitLab плюс — меньше церемоний и ближе к привычному терминалу; минус — нет встроенного механизма переиспользуемых «экшенов» из маркетплейса, их роль берут на себя шаблоны include и якоря YAML.
Как работает под капотом
Раннер для джобы собирает единый скрипт: сначала клонирование репозитория, затем восстановление кеша, затем before_script, затем script. Всё это — одна непрерывная сессия оболочки в контейнере. after_script запускается отдельной сессией с таймаутом, поэтому он не «видит» переменных основной части и ограничен по времени. Код завершения основной сессии определяет успех джобы.
Если развернуть это в схему, последовательность одной джобы выглядит так:
контейнер из image | +-- clone репозитория +-- restore cache / artifacts +-- before_script ] +-- script ] одна общая оболочка -- общий код возврата | +-- after_script -- отдельная оболочка, отдельный таймаут
Из этой схемы видно, почему «состояние не переезжает» в after_script: это буквально другой процесс оболочки. И почему важно класть установку зависимостей в before_script, а не в script: формально работать будет и там, и там (это всё одна сессия), но семантически before_script отделяет подготовку от сути, и логи становятся читаемее.
Частые ошибки
- Рассчитывать, что переменные из
scriptдоступны вafter_script— они нет, это разные оболочки. - Класть установку зависимостей в
scriptи дублировать её в каждой джобе вместоdefault.before_script. - Забыть
imageу джобы, требующей особого окружения, и получить дефолтный alpine без нужных инструментов. - Использовать плавающий тег
latestи потом ловить внезапные падения после выхода нового релиза образа. - Прятать падение в середине пайпа без
set -o pipefail— джоба зеленеет, хотя по сути сломана.
Итоги
scriptобязателен; первая упавшая команда обрывает джобу, а успех определяется кодом возврата.imageфиксирует окружение; общее задают вdefault; теги фиксируйте, не полагайтесь наlatest.before_script— подготовка,after_script— очистка в отдельной оболочке, выполняется всегда.- Разделение подготовка/цель/уборка делает логи читаемыми, а пайплайн — предсказуемым.