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 — очистка в отдельной оболочке, выполняется всегда.
  • Разделение подготовка/цель/уборка делает логи читаемыми, а пайплайн — предсказуемым.
Проверьте себя
1. Что особенного у after_script?
AВыполняется только при успехе джобы
BВыполняется всегда (даже при падении script), но в отдельной оболочке без переменных script
CЗаменяет собой script
DВыполняется до before_script
2. Зачем нужен раздел default в .gitlab-ci.yml?
AЧтобы задать значения по умолчанию (image, before_script) для всех джоб
BЧтобы пометить дефолтную ветку
CЧтобы создать обязательную джобу
DЧтобы отключить кеш