Бегунов ЕгорСистемный администратор, DevOps
  1. Главная
  2. Блог
  3. CI/CD

GitLab CI/CD: с чего начать

Содержание

Свои скрипты на серверы я долго раскатывал руками: scp, ssh, проверить, что работает. Однажды забыл про один сервер. Там несколько недель крутилась старая версия с уже исправленным багом.

Дальше — первый рабочий CI/CD глазами сисадмина. Свой GitLab Runner на Debian, .gitlab-ci.yml из трёх этапов, секреты, деплой по SSH и грабли, на которые наступают почти все. Сквозной пример — репозиторий admin-scripts. В нём мои Python-скрипты с тестами, которые надо проверить и выложить на два сервера.

Что понадобится для GitLab CI/CD

Требования и подготовка

GitLab у меня свой, Community Edition на отдельной виртуалке. На gitlab.com всё то же самое, только общие runner там уже есть. Установка самого GitLab — отдельная история, здесь считаем, что он работает.

Для CI понадобятся:

  • проект в GitLab с кодом и ролью Maintainer, иначе настройки CI/CD будут недоступны;
  • отдельная ВМ под runner: Debian 12 или 13, Ubuntu 24.04, для начала хватит 2 CPU и 4 ГБ памяти;
  • серверы, куда деплоим, и пользователь на них с доступом по SSH-ключу.

Runner я не ставлю на сервер с GitLab и тем более на прод. Он исполняет код из репозитория. Любой, у кого есть право push, по сути получает выполнение команд на этой машине.

Установка и подготовка среды

Установка пакетов / компонентов

Job будут выполняться в Docker-контейнерах, поэтому на ВМ под runner сначала ставим Docker:

apt install docker.io
systemctl enable --now docker

Сам runner ставлю из официального репозитория GitLab. Скрипт подключения репозитория сначала скачиваю и читаю, потом запускаю:

curl -fsSL -o runner-repo.sh https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh
less runner-repo.sh
bash runner-repo.sh
apt install gitlab-runner

Теперь регистрация. Раньше runner регистрировали общим registration token. Этот способ устарел и в свежих версиях GitLab по умолчанию выключен.

Сейчас runner создаётся в интерфейсе: Settings → CI/CD → Runners → New project runner. Там же задаются теги и описание. GitLab выдаст токен вида glrt-..., с ним и регистрируем:

gitlab-runner register --non-interactive \
  --url https://gitlab.example.local \
  --token glrt-XXXXXXXXXXXXXXXX \
  --executor docker \
  --docker-image alpine:3.22

Если у GitLab сертификат от внутреннего CA, runner ему не поверит. Корневой сертификат кладём в /etc/gitlab-runner/certs/gitlab.example.local.crt, имя файла совпадает с именем хоста. Второй вариант — ключ --tls-ca-file при регистрации.

Проверяем, что runner живой и GitLab его видит:

gitlab-runner verify
gitlab-runner list

В интерфейсе рядом с runner должен загореться зелёный индикатор.

Базовая настройка GitLab CI/CD

Основные параметры

Сначала четыре слова, без которых дальше никак. Pipeline — весь прогон после push. Job — одна задача в нём, например тесты. Stage — группа job, которые идут параллельно, а следующий stage ждёт предыдущий.

Runner — агент, который забирает job из GitLab и выполняет. Где именно выполняет, определяет executor. Для начала выбор из двух:

  • shell — команды идут прямо на хосте от пользователя gitlab-runner. Просто, но job видит всю машину и оставляет за собой мусор.
  • docker — каждая job в чистом контейнере из указанного образа. Изоляция и нужные версии без установки на хост.

Я везде использую docker. Shell оставляю для редких задач, которым действительно нужен хост.

Настройки runner лежат в /etc/gitlab-runner/config.toml. Из важного:

  • concurrent — сколько job runner выполняет одновременно;
  • image в секции [runners.docker] — образ по умолчанию, если в job не указан свой;
  • privileged — привилегированные контейнеры, по умолчанию выключены, и пусть так и остаётся;
  • volumes — что монтировать в контейнер, минимум /cache для кэша;
  • pull_policy — когда скачивать образ заново.

Со стороны проекта всё описывается в .gitlab-ci.yml в корне репозитория. Главные ключи там: stages, image, script, rules, cache, artifacts и variables.

Основная конфигурация

Пример конфигурации

Конфиг runner после регистрации я немного правлю:

# /etc/gitlab-runner/config.toml
concurrent = 2
check_interval = 0

[[runners]]
  name = "ci-runner-01"
  url = "https://gitlab.example.local"
  token = "glrt-XXXXXXXXXXXXXXXX"
  executor = "docker"
  [runners.docker]
    image = "alpine:3.22"
    privileged = false
    volumes = ["/cache"]
    pull_policy = ["if-not-present"]

Runner сам замечает изменения в этом файле, рестарт не нужен. if-not-present экономит время на скачивании. Но образ с тем же тегом тогда сам не обновится, поэтому теги в pipeline я фиксирую по версиям.

Теперь сам pipeline. В репозитории admin-scripts лежат скрипты, тесты в tests/ и requirements.txt:

# .gitlab-ci.yml
workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
      when: never
    - if: $CI_COMMIT_BRANCH

stages:
  - lint
  - test
  - deploy

default:
  image: python:3.12-slim

variables:
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"

cache:
  key:
    files:
      - requirements.txt
  paths:
    - .cache/pip

lint:
  stage: lint
  script:
    - pip install ruff
    - ruff check .

test:
  stage: test
  script:
    - pip install -r requirements.txt pytest
    - pytest -q --junitxml=report.xml
  artifacts:
    when: always
    reports:
      junit: report.xml

deploy:
  stage: deploy
  image: alpine:3.22
  cache: []
  resource_group: production
  environment:
    name: production
  variables:
    DEPLOY_HOSTS: "srv01 srv02"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  before_script:
    - apk add --no-cache openssh-client rsync
    - install -d -m 700 ~/.ssh
    - install -m 600 "$SSH_PRIVATE_KEY" ~/.ssh/id_ed25519
    - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
  script:
    - echo "$CI_COMMIT_SHORT_SHA" > VERSION
    - |
      for h in $DEPLOY_HOSTS; do
        rsync -az --delete --exclude .git --exclude venv --exclude .cache ./ deploy@$h:/opt/admin-scripts/
        ssh deploy@$h '/opt/admin-scripts/venv/bin/pip install -q -r /opt/admin-scripts/requirements.txt'
      done

Разберу по кускам. Блок workflow:rules оставляет один pipeline на событие. Без него push в ветку с открытым merge request запускает сразу два.

Кэш pip привязан к requirements.txt. Поменялись зависимости — кэш соберётся заново, не поменялись — pip ничего не качает. Отчёт junit GitLab покажет прямо в merge request, со списком упавших тестов.

Deploy запускается только из основной ветки и только кнопкой. resource_group не даст двум деплоям идти одновременно. environment ведёт историю выкладок в Operate → Environments.

--exclude venv защищает venv на серверах от --delete. rsync не удаляет на приёмнике то, что исключено из синхронизации. Файл VERSION с коротким хешем коммита кладу в каждую выкладку. Зачем, будет видно в проверке.

Секреты заводим в Settings → CI/CD → Variables:

  • SSH_PRIVATE_KEY — тип File, флаг Protected. Внутри job переменная содержит путь к временному файлу с ключом.
  • SSH_KNOWN_HOSTS — обычная переменная с выводом ssh-keyscan srv01 srv02. Отпечатки сверяю с самими серверами, а не верю сети на слово.

На серверах заведён пользователь deploy с правом записи только в /opt/admin-scripts. Его authorized_keys содержит только ключ для CI.

Проверка работоспособности

Проверка и тестирование

До коммита проверяю синтаксис. В GitLab для этого есть Build → Pipeline editor, он валидирует файл на лету. Из консоли то же самое делает glab:

glab ci lint .gitlab-ci.yml

Первый прогон делаю в отдельной ветке. Push, открываю Build → Pipelines и смотрю, что lint и test прошли, а deploy не появился. Так и задумано: он живёт только в основной ветке.

Потом merge. В pipeline появляется deploy с кнопкой запуска. Запускаю, смотрю лог job и сверяю версию на серверах:

for h in srv01 srv02; do echo -n "$h: "; ssh $h cat /opt/admin-scripts/VERSION; done

Хеш на обоих серверах должен совпасть с последним коммитом в GitLab. Ровно этой проверки мне не хватало, когда один сервер остался на старой версии.

Отдельно убеждаюсь, что тесты ловят поломку. В ветке намеренно ломаю ассерт и пушу. Pipeline должен покраснеть, а в merge request появиться упавший тест. Проверка, которая никогда не падает, ничего не проверяет.

Логи и диагностика

Лог job — первое место, куда смотреть. В начале лога видно, какой runner и какой образ взяли задачу. Если job висит в pending, дело не в скрипте, а в runner или тегах.

Лог самого runner лежит в журнале:

journalctl -u gitlab-runner -f

Подробнее он пишет с log_level = "debug" в глобальной секции config.toml. Включаю на время разбора и возвращаю обратно, иначе журнал быстро распухает.

Для отладки самой job есть переменная CI_DEBUG_TRACE: "true". Она включает трассировку shell, и видна каждая команда с подставленными значениями. Использую только в ветке и ненадолго. В лог попадут значения всех переменных, секреты тоже.

Безопасность и резервное копирование

Runner — самое чувствительное место в этой схеме. Он выполняет любой код из репозитория, поэтому у меня несколько правил:

  • project runner для проектов с секретами, а не общий на весь инстанс;
  • privileged = false, потому что привилегированный контейнер или проброшенный /var/run/docker.sock — это root на хосте runner;
  • shell executor не ставлю на машины, где есть что-то ценное.

Секреты живут только в CI/CD Variables, в .gitlab-ci.yml их нет. Флаг Protected отдаёт переменную только защищённым веткам. Основная ветка защищена, поэтому из feature-ветки ключ деплоя просто недоступен.

Masked прячет значение в логах, но работает только для однострочных значений. Приватный ключ так не скрыть, поэтому для него главная защита — Protected и роли в проекте.

StrictHostKeyChecking=no в CI я не пишу. Это популярный совет из интернета, который выключает проверку сервера. Правильный known_hosts в переменной — пара минут работы.

Ключ деплоя отдельный и нужен только для CI. Пользователь deploy на серверах без sudo. Утёк ключ — отзываю его и генерирую новый, остальной доступ не страдает.

Теперь резервное копирование. .gitlab-ci.yml лежит в репозитории, его бэкап — это бэкап GitLab. Переменные CI/CD хранятся в базе GitLab в зашифрованном виде.

Штатный gitlab-backup create базу сохранит, а ключ шифрования — нет. Ключ лежит в /etc/gitlab/gitlab-secrets.json, и копировать его надо отдельно вместе с gitlab.rb. Без него восстановленный GitLab не расшифрует ни одну переменную.

С runner бэкапить почти нечего. Перерегистрировать его быстрее, чем хранить токен из config.toml. Сам конфиг держу в git без токена, как шаблон.

Типичные ошибки

This job is stuck. Job висит в pending, GitLab пишет, что подходящих runner нет. Чаще всего не совпадают теги: они есть у job, но не у runner, или наоборот. Второй вариант — у runner выключен запуск job без тегов, а в job теги не указаны.

Load key: error in libcrypto. ssh не может прочитать ключ из переменной. Обычно в конце значения потерялся перевод строки. Добавьте пустую строку после -----END OPENSSH PRIVATE KEY----- и сохраните переменную заново.

Переменная пустая. В основной ветке всё работает, в feature-ветке $SSH_PRIVATE_KEY пустой. Переменная помечена Protected, а ветка не защищена. Это не баг, флаг ровно для этого и существует.

Host key verification failed. В контейнере пустой known_hosts, а ssh не знает сервер. Лечится переменной с ключами хостов, а не отключением проверки.

Два pipeline на один push. Ветка с открытым merge request запускает pipeline и для ветки, и для MR. Лечится блоком workflow:rules, как в примере выше.

rules вместе с only. GitLab ругается config key may not be used with rules: only. Старые примеры в интернете используют only и except, новые — rules. Смешивать их в одной job нельзя, переходите на rules целиком.

Cache вместо artifacts. Файл из одной job не виден в следующей. Cache — только ускорение, его может не оказаться, и передачу между stage он не гарантирует. Результаты между job передаются через artifacts.

Табы в YAML. .gitlab-ci.yml с табами вместо пробелов не парсится. Pipeline editor покажет проблемную строку ещё до push.

Практические рекомендации

Обновление и обслуживание

Runner обновляю через apt вместе с остальной системой. Версию держу рядом с версией GitLab: обновил GitLab до новой минорной — обновляю и runner. Сильно отставший runner рано или поздно перестаёт понимать новые ключи в .gitlab-ci.yml.

apt update && apt install --only-upgrade gitlab-runner
gitlab-runner --version

Docker executor оставляет за собой образы, контейнеры и тома кэша. За полгода они спокойно съедают весь диск. Раз в неделю по таймеру чищу всё, что не использовалось последние семь дней:

docker system prune -af --filter "until=168h"
docker system df

Тома кэша этой командой не удаляются. Их чищу отдельно, когда docker system df показывает, что они разрослись.

Ещё несколько привычек:

  • Образы с конкретными тегами: python:3.12-slim, а не latest. Иначе однажды pipeline сломается от обновления, которое никто не заказывал.
  • Прод выкладываю только кнопкой, через when: manual. Автоматический деплой — когда тестам доверяешь больше, чем себе.
  • Повторяющиеся куски выношу в default: или в общий файл через include:. Когда проектов со скриптами станет пять, это окупится.
  • Для Ansible подход тот же: на lint идёт ansible-lint, на deploy — playbook из образа с Ansible.
  • Pipeline должен быть быстрым. Если проверка идёт 15 минут, её начинают обходить.

Тот забытый сервер теперь прописан в DEPLOY_HOSTS. Забыть его снова можно только коммитом, а коммит видно всем.