Содержание
Свои скрипты на серверы я долго раскатывал руками: 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. Забыть его снова можно только коммитом, а коммит видно всем.