← CI/CD
12 мин · средне · Урок 6 из 7

Первый пайплайн в GitLab CI

Собираем простой .gitlab-ci.yml с нуля: hello-world, стадии, артефакты, тег по SHA.

Pipeline как код

В GitLab CI весь пайплайн описывается одним файлом .gitlab-ci.yml в корне репозитория. Отдельный CI-сервер поднимать не нужно: CI встроен в GitLab, а выполняет работу раннер (машина или контейнер).

Положил файл в репо, сделал push. GitLab сам находит его и запускает пайплайн. Никаких кнопок и веб-форм: конфиг живёт рядом с кодом и ревьюится вместе с ним.

Hello world

Самый маленький рабочий пайплайн:

hello:
  script:
    - echo "Hello, CI!"

Один job с именем hello, внутри одна команда. На каждый push раннер возьмёт чистый контейнер, выполнит echo и покажет результат в логе. Это уже полноценный пайплайн.

Стадии и порядок

Обычно работы больше одной, и часть из них должна идти по порядку. Для этого есть стадии:

stages:
  - build
  - test

build-job:
  stage: build
  script:
    - echo "Собираем приложение"
    - echo "v1" > app.txt

test-job:
  stage: test
  script:
    - echo "Проверяем"
    - test -f app.txt   # учебный пример: проверяем наличие файла, а не unit-тесты

Стадии идут последовательно: сначала весь build, потом весь test. Если в одной стадии несколько jobs, они бегут параллельно. Если build упал, test даже не стартует.

Передать файл между стадиями: artifacts

Каждый job стартует в чистом контейнере, поэтому файл из build сам по себе в test не попадёт. Чтобы передать, объявляем артефакт:

build-job:
  stage: build
  script:
    - echo "v1" > app.txt
  artifacts:
    paths:
      - app.txt

test-job:
  stage: test
  script:
    - cat app.txt

GitLab сохранит app.txt после build и распакует его в следующих стадиях. Так результат сборки доезжает до тестов и деплоя.

Запускать только где нужно: rules

Гонять весь пайплайн на каждой ветке не всегда нужно. rules ограничивают запуск:

build-job:
  stage: build
  script:
    - echo "Собираем"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

CI_COMMIT_BRANCH и CI_DEFAULT_BRANCH — встроенные переменные, их подставляет сам раннер. Тут job запускается только на дефолтной ветке (main).

Сборка образа и тег по SHA

Когда дело доходит до Docker-образа, важен тег. Распространённый антипаттерн — тегать всё как :latest:

# так делать не надо
- docker build -t myapp:latest .
- docker push myapp:latest

latest — это движущийся тег: сегодня под ним один образ, завтра другой. По нему не понять, что именно крутится в проде, и откат превращается в гадание. Тегай по SHA коммита:

build-image:
  stage: build
  image: docker:27
  services:
    - name: docker:27-dind
      alias: docker
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
  script:
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"

CI_COMMIT_SHORT_SHA — короткий хеш коммита. Тег registry/app:a1b2c3d жёстко связан с коммитом: всегда видно, из чего собран образ, а откат сводится к запуску предыдущего тега.

В этом куске две строки, без которых пример не поедет, и обе часто теряются в туториалах.

DOCKER_TLS_CERTDIR: "/certs" нужен потому, что начиная с Docker 19.03 сервис dind поднимает демон по TLS. Клиент и сервис должны договориться, где лежат сертификаты. Без этой переменной job падает на первой же docker-команде с «Cannot connect to the Docker daemon».

docker login нужен внутри самого job. Логин, сделанный в соседнем job или в шаблоне другой стадии, сюда не доедет: у каждого job своё чистое окружение со своим ~/.docker/config.json. Без логина build пройдёт, а push упадёт с 401.

Чем dind не нравится

Сервис docker:dind поднимает полноценный демон внутри job, а для этого раннер должен быть запущен в privileged-режиме. Раннер с privileged = true фактически даёт любому пайплайну права root на машине раннера. На своих раннерах это осознанный компромисс, а на общих или в чужом окружении так себе идея.

Альтернативы, которые собирают образ без привилегированного демона:

Начинать проще с dind: конфиг короче, а поведение один в один как локально. Переезжать имеет смысл, когда privileged-раннер становится проблемой.

Секреты — не в репозитории

Пароль от registry, токены, ключи в .gitlab-ci.yml писать нельзя: файл лежит в git и виден всем. Секреты живут в Settings -> CI/CD -> Variables как masked (скрыты в логах) и protected (доступны только на защищённых ветках). В пайплайне на них ссылаешься по имени:

  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"

Пароль идёт через --password-stdin, а не аргументом команды, чтобы не засветиться в выводе.

Для встроенного registry свои переменные заводить не нужно: GitLab сам подставляет CI_REGISTRY_USER и CI_REGISTRY_PASSWORD для текущего пайплайна. Свои REGISTRY_USER/REGISTRY_PASSWORD нужны только для внешнего реестра (Docker Hub, приватный).

Что важно запомнить

.gitlab-ci.yml в корне репо — весь пайплайн как код, отдельный сервер не нужен.

Job — это набор команд в script; стадии (stages) задают порядок, jobs внутри стадии бегут параллельно.

artifacts передают файлы между стадиями: чистый контейнер сам ничего не помнит.

Тег образа — по SHA коммита, а не :latest. latest движется, по нему не отследить прод и не откатиться.

Секреты — в CI-переменных (masked + protected), не в репозитории.

Сборка образа через dind требует DOCKER_TLS_CERTDIR: "/certs", docker login в самом job и privileged-раннера. Без привилегий берут kaniko, buildah или docker buildx с внешним билдером.

Дальше короткий квиз, и трек CI/CD закрыт.

Дрилл · вопросы как на собесе

Проверь себя без подсказок

Сформулируй ответ вслух или письменно, потом раскрой и честно отметь: знал или нет.

01

Почему тег :latest у Docker-образа - антипаттерн в CI?

CICD-052
02

Зачем тегать Docker-образ по SHA коммита?

CICD-051
03

Где описывается пайплайн в GitLab CI?

CICD-050

Прошли материал до конца? Отметьте урок пройденным.