devops-курс Все курсы

✻ Урок 3.6 · Тема 3: Git и CI

GitLab CI и Jenkins: тот же конвейер на других платформах

⏱ 4 ч

Зачем это нужно

В уроке 3.3 ты настроил на GitHub Actions конвейер (pipeline): цепочку автоматических проверок, которая запускается при каждом изменении кода. Называют его так потому, что изменение едет по ленте, как деталь на заводе, и на каждой остановке его проверяет своя «станция». Но GitHub Actions есть не везде. Многие компании держат код на собственном сервере (не на сайте github.com, а на своей машине, где хозяин сам отвечает за работу и безопасность), и там работают GitLab CI (GitLab это такой же, как GitHub, сервис хранения кода, только его можно поставить к себе; у него свои встроенные конвейеры) или Jenkins (отдельная программа-сервер автоматизации, которой больше двадцати лет: её ставят рядом с кодом, и она запускает любые скрипты по событиям). В описаниях вакансий DevOps их называют чаще, чем Actions, а на собеседовании просят «перенеси конвейер с Jenkins на GitLab».

Хорошая новость: менять платформу для инженера не значит учить всё заново. Идея одна и та же: по событию взять чистую машину, скачать код, выполнить команды и показать зелёный или красный результат. Меняются только синтаксис файла и названия понятий. В этом уроке ты перенесёшь тот же конвейер «Заметок» (проверка стиля и тесты) на GitLab CI, поднимешь собственный исполнитель (runner: программу на машине, которая забирает задания у сервера и выполняет их), потом запустишь Jenkins в Docker (инструмент, который запускает программы в изолированных «коробках»-контейнерах; разберём в следующей теме) и опишешь тот же конвейер в Jenkinsfile (файл-чертёж конвейера для Jenkins). Основным местом жизни проекта останется GitHub.

Почему компании десятилетиями сидят на Jenkins, если есть GitHub Actions и GitLab CI? Jenkins появился раньше и умеет брать код откуда угодно и запускать что угодно. За годы вокруг него накопились сотни сборок и плагинов, написанных под конкретную инфраструктуру, а переписать их все на новую платформу дорого и рискованно: работающая сборка никому не мешает, пока её не трогают. Поэтому читать чужой Jenkinsfile инженеру нужно, даже если новые проекты начинают на другой платформе.

Шаг проекта: в ~/notes появятся .gitlab-ci.yml и Jenkinsfile. Оба вызывают те же цели make lint и make test из Makefile (урок 1.6: файл, где длинные команды получили короткие имена), что и обычные проверки. Идея в том, чтобы логика проверок жила в одном месте, а платформы только звали её. Код app.py (версия v3) не меняется.

Что нужно знать

Docker в этом уроке нужен для практики. Подробно он разбирается в уроке 4.1; если Docker Engine (сама программа Docker, которая запускает контейнеры) ещё не стоит, поставь его по официальной инструкции для Ubuntu на docs.docker.com/engine/install/ubuntu (через apt-репозиторий, без curl | bash). Что такое образ и контейнер, объясняется прямо ниже, в теории.

Картина целиком

Представь фабрику по сборке мебели. Есть чертёж: в нём написано, из каких деталей, в каком порядке и какими инструментами собирать. Есть диспетчер: он получает заказ, читает чертёж и раздаёт задания. И есть рабочие со своими станками: каждый берёт задание, идёт в чистый цех, делает работу и докладывает «готово» или «брак».

Конвейер CI устроен так же. Чертёж это файл в репозитории (ci.yml, .gitlab-ci.yml или Jenkinsfile). Диспетчер это сервер платформы (GitHub, GitLab или Jenkins). Рабочий это исполнитель (runner или agent: программа на машине, которая забирает у сервера задание и выполняет его), а «чистый цех» это контейнер (изолированная мини-система, запущенная из готового образа; в Docker так называют «коробку» с программой), который создаётся на время задания и потом выбрасывается. Оговорка: на фабрике цех один и тот же, а в CI он каждый раз новый, поэтому «у меня на машине работает» здесь не работает.

flowchart LR
    D["разработчик<br>git push: код и файл конвейера"] --> S["сервер платформы<br>GitLab, Jenkins или GitHub<br>1. видит событие<br>2. читает файл конвейера<br>3. режет на job<br>4. ставит job в очередь"]
    S --> R["исполнитель<br>runner или agent<br>1. берёт job<br>2. создаёт чистый контейнер<br>3. скачивает код<br>4. выполняет make lint, make test<br>5. сообщает код выхода"]
    R --> O["результат<br>зелёный или красный статус<br>на коммите или merge request"]

За урок ты разберёшь каждый кусок схемы: как читать формат YAML (формат настроек, где структуру задают отступами), в котором пишут конвейеры, как устроен GitLab CI (этапы: группы заданий, идущие друг за другом; jobs: сами задания; правила запуска: когда job нужна), кто такой runner и почему job может висеть в очереди вечно (очередь это список заданий, которые ждут свободного исполнителя, как люди у кассы), как устроен Jenkins (controller: главный сервер, который хранит настройки и раздаёт работу; agent: исполнитель, который её делает; Jenkinsfile: чертёж на языке Groovy: это язык программирования для Java, в нём есть переменные и условия, а не только перечень настроек) и почему все три платформы должны звать одни и те же команды make. Потом сделаешь всё руками. Слово «merge request» на схеме выше это название запроса на слияние в GitLab, тот же Pull Request из урока 3.2.

Теория

Что общего у любой CI-платформы

Без CI проверка кода зависит от человека: разработчик должен вспомнить прогнать тесты, а его машина отличается от машины коллеги. Когда проверки исполняет робот на чистой машине при каждом изменении, «работает у меня» превращается в проверяемый факт: работает или нет там, где никто ничего не подправлял руками.

Строгий приёмщик на складе: любая партия, даже от лучшего поставщика, проходит одну и ту же проверку по одному чек-листу. Оговорка: приёмщик проверяет только то, что в чек-листе, остальное пропустит.

Три платформы этого урока делают одну и ту же последовательность:

  1. Что-то происходит с репозиторием: пришёл push (отправка коммитов), открыт merge request (в GitLab так называется запрос на слияние ветки, то же, что Pull Request на GitHub) или поставлен тег.
  2. Сервер платформы находит в репозитории файл конвейера и читает его.
  3. Файл описывает jobs (задания): «в такой среде выполни такие команды». Сервер ставит их в очередь.
  4. Свободный исполнитель забирает job. Он создаёт чистую среду, скачивает код репозитория и по очереди выполняет команды.
  5. Если каждая команда вернула код выхода 0 (урок 1.6), job зелёная. Любой ненулевой код останавливает job, и она красная. Платформа показывает результат рядом с коммитом.

Чистую среду почти всегда делают из образа (image) Docker. Образ это готовый набор файлов: система, Python, нужные программы, упакованные в один архив. Контейнер (container) это запущенная из образа изолированная мини-система: у неё свои файлы и процессы, она не видит остальную машину и исчезает после работы. Образ можно сравнить с формой для печенья, а контейнер с конкретным печеньем: из одной формы получается сколько угодно одинаковых. Сама тема образов и контейнеров разобрана в теме 4, сейчас нужны только эти два слова.

Возьмём наши две проверки. Что бы ни была за платформа, работа одинакова:

flowchart TD
    I["образ python:3.13<br>чистая среда: Python 3.13.15, make 4.4.1"] --> C["скачать код репозитория<br>файлы из git на конкретном коммите"]
    C --> P["pip install -r requirements-dev.txt<br>поставить ruff"]
    P --> L["make lint"]
    L -->|"код выхода 0"| R["ruff check ."]
    R -->|"код выхода 0"| T["make test"]
    T -->|"код выхода 0"| G["job зелёная"]
    T -->|"код выхода 1"| X["job красная,<br>остальные команды не запускаются"]

Прикинь сам: GitLab CI, Jenkins и GitHub Actions выглядят по-разному. Что у них одинаково?

Схема: событие, чтение файла конвейера, раздача job исполнителям, чистая среда, команды, код выхода и статус. Различаются слова и синтаксис.

Главное: любая CI-платформа делает одно: по событию читает файл конвейера, раздаёт job исполнителям и показывает результат по коду выхода.

Проверь понимание: конвейер состоит из одной job с командой echo "проверка". Каким будет результат и о чём это говорит?

Ответ

Зелёным: echo всегда возвращает код 0. Это показывает главное свойство CI: он верит кодам выхода, а не смыслу. Конвейер, в котором нет настоящих проверок, всегда зелёный и бесполезен.

Принцип общий, а файлы у всех на YAML (кроме Jenkins). Разберём язык.

YAML: на каком языке пишут конвейеры

Конвейер должен быть файлом в репозитории, который читают и человек, и программа. Обычный текст читает только человек, а форматы вроде JSON неудобны для глаз (много скобок и кавычек). YAML это формат для настроек: структура задаётся не скобками, а отступами, поэтому файл выглядит почти как список дел. В нём написаны ci.yml (урок 3.3) и .gitlab-ci.yml.

Оглавление книги: пункт, внутри него подпункты, отступ показывает, что чему принадлежит. Оговорка: в оглавлении лишний пробел ничего не ломает, а в YAML он меняет смысл.

Всего три конструкции:

  1. Ключ и значение (словарь, mapping): stage: lint. Двоеточие и пробел после него обязательны.
  2. Список (list): строки с дефисом. Каждый пункт это отдельный элемент.
  3. Вложенность: если строки сдвинуты вправо на два пробела относительно ключа, они принадлежат этому ключу. Табуляции запрещены, только пробелы.

Комментарий начинается с #. Список можно записать в одну строку в квадратных скобках: tags: [docker] это то же самое, что tags: и на следующей строке ` - docker`.

Кусок из нашего конвейера и его перевод на человеческий язык:

lint:                      # ключ верхнего уровня: имя job
  stage: lint              # у job есть свойство stage со значением lint
  script:                  # у job есть свойство script, значение это список
    - pip install -r requirements-dev.txt   # первый элемент списка
    - ruff check .                           # второй элемент

Читается так: «существует job lint; она относится к этапу lint; её команды: сначала pip install ..., потом ruff check .». Сдвинуть script влево на два пробела значит объявить его как отдельную job, и GitLab ответит ошибкой, что у job нет команд. Все отступы в курсе по два пробела.

Прикинь сам: в .gitlab-ci.yml перепутаны отступы у script:. Чем это грозит?

Файл прочтётся иначе или вообще не разберётся: вложенность в YAML задают отступы, и команды окажутся не там, где нужно.

Тут часто путают так. Двоеточие внутри значения. Строка - echo "итог: ok" сломает разбор, если её не взять в кавычки целиком: YAML увидит итог: с пробелом и решит, что это новый ключ. Правильно - 'echo "итог: ok"'. Второй случай: многострочные команды. Знак | после ключа говорит «дальше идёт текст как есть, со всеми переводами строк», так делают, когда нужно несколько команд в один шаг. Ты встретишь это позже в Jenkinsfile, где у того же приёма другой синтаксис (тройные кавычки).

Главное: в YAML смысл задают отступы, и один и тот же синтаксис используют GitLab CI и GitHub Actions.

Проверь понимание: чем отличаются tags: [docker] и tags: docker?

Ответ

Первое это список из одного элемента docker, второе просто строка. GitLab ждёт для tags именно список, поэтому вторая запись даст ошибку валидации (проверки формата). Общее правило: если в документации ключ принимает несколько значений, пиши список.

Язык общий, а слова разные. Сравним три платформы.

Три платформы, три словаря

Одни и те же вещи на трёх платформах названы по-разному. Без таблицы соответствий кажется, что это три разных мира. Таблица нужна для одного: чтобы переводить знакомое с одной платформы на другую. Со словарём в руках перенос конвейера сводится к переводу.

Один и тот же рецепт на русском, английском и китайском: блюдо одно, слова разные. Оговорка: перевод не дословный, у платформ есть особенности (например, в Jenkins нет YAML вообще).

Соответствие понятий:

Идея GitHub Actions GitLab CI Jenkins
Файл конвейера .github/workflows/ci.yml (файлов может быть много) .gitlab-ci.yml (один, в корне) Jenkinsfile (в корне, путь настраивается)
Весь конвейер workflow pipeline Pipeline
Единица работы job job stage (внутри неё steps)
Порядок needs: между jobs stages: (jobs одного этапа идут параллельно) stages идут по порядку, параллель через parallel
Кто исполняет runner (GitHub-hosted или свой) runner (общий или свой) agent (узел, подключённый к controller)
Среда runs-on: image: (для docker executor) agent { docker { image '...' } }
Когда запускать on: rules: when {} и триггеры в настройках job
Файлы между jobs artifacts artifacts: archiveArtifacts
Кэш actions/cache cache: кэш на агенте или том Docker
Секреты Secrets CI/CD variables (masked, protected) Credentials

Слово executor в таблице значит «способ запуска команд на раннере» (например, в контейнере docker). Остальные незнакомые слова из таблицы (controller, masked, stage) разбираются в разделах ниже, по одному.

Переведём кусок из ci.yml урока 3.3 на язык GitLab. Там было: job lint с runs-on: ubuntu-24.04 и шагом run: ruff check .. В GitLab «шаг run» превращается в элемент списка script, а runs-on (какая машина) заменяется на image (в каком образе). Получается job с image: python:3.13 и script: [ruff check .]. Всю разницу составляют названия ключей.

Прикинь сам: как называются у GitLab CI и Jenkins то, что GitHub Actions называет job и runner?

У GitLab job остаётся job, а runner тот же. У Jenkins этап это stage, а исполнитель agent. Понятия те же, слова другие.

Осторожно: Слово job в Jenkins. В Jenkins «job» (или «item») это вся настроенная сборка целиком, а то, что в GitHub и GitLab называется job, там называется stage. Читая чужую документацию, всегда проверяй, о какой платформе речь.

Главное: слова разные, а понятия те же: событие, файл конвейера, job или stage, исполнитель, код выхода.

Проверь понимание: как в GitLab CI выразить то, что в Actions делает needs: lint?

Ответ

Два способа. Обычный: положить jobs в разные stages, тогда этап test начнётся только после успеха всех jobs этапа lint. Точный: ключ needs: [lint] в job, тогда она стартует сразу после lint, не дожидаясь остальных jobs этапа.

Начнём с GitLab CI: stages, jobs и правила.

GitLab CI: stages, jobs, порядок и правила

Проверки нужно упорядочить: нет смысла гонять тесты, если код даже не разбирается интерпретатором, а дешёвые проверки должны идти раньше дорогих. Ещё нужно управлять запуском: не тратить машины на каждую ветку-черновик.

Приёмка квартиры: сначала общий осмотр (дёшево, минута), потом проверка электрики (дороже), потом сантехника. Если осмотр показал, что нет окон, электрику никто не проверяет. Оговорка: внутри одного этапа проверки могут идти одновременно, как бригады в разных комнатах.

Весь конвейер лежит в одном файле .gitlab-ci.yml в корне репозитория. Верхний уровень файла состоит из четырёх видов ключей:

  • stages (этапы): список названий в порядке выполнения;
  • общие настройки: default (значения для всех job, например image), variables (переменные), cache (что переиспользовать между запусками);
  • jobs: любой ключ верхнего уровня, внутри которого есть script. Имя job это просто твой ключ (lint, test), а не зарезервированное слово;
  • служебные ключи вроде workflow и include (подключить чужой файл), их мы сейчас не трогаем.

Правила порядка:

  1. Этапы идут строго друг за другом по списку stages.
  2. Все jobs одного этапа стартуют параллельно (если хватает исполнителей).
  3. Если хоть одна job этапа красная, следующие этапы не запускаются, а их jobs остаются серыми (skipped, «не запускалась»).
  4. Job без ключа stage попадает в этап test. Если stages не задан, набор по умолчанию такой: .pre, build, test, deploy, .post.

Главные ключи внутри job:

Ключ Что делает
stage в каком этапе идёт job
image образ Docker, в котором выполняется script
script список команд; ненулевой код выхода любой команды делает job красной
rules условия запуска (когда job создавать)
tags какой исполнитель может взять job (разобрано в следующем разделе)
needs зависимость от конкретных job, в обход этапов
artifacts какие файлы сохранить после job (отчёты, сборки) и expire_in, как долго хранить
cache что переиспользовать между запусками (папку с пакетами)
when: manual запуск только по кнопке

Правила запуска (rules). Без них GitLab создаёт конвейер на каждый push в любую ветку. С rules ты описываешь условия списком; GitLab читает их сверху вниз и берёт первое подошедшее. Условие пишется как if: с выражением на переменных GitLab. Нужные нам переменные GitLab подставляет сам (их называют предопределёнными):

Переменная Значение (пример)
CI_PIPELINE_SOURCE откуда взялся конвейер: push, merge_request_event, schedule…
CI_COMMIT_BRANCH имя ветки, для которой идёт конвейер: ci/gitlab
CI_DEFAULT_BRANCH ветка проекта по умолчанию: main
CI_PROJECT_DIR папка внутри контейнера, куда скачан код: /builds/<user>/notes

Если ни одно правило не подошло, job в конвейер не попадает; если не подошло ни одной job, конвейера нет вообще.

Правила из нашего файла:

rules:
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"   # (1)
  - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH        # (2)

Три ситуации:

Что произошло Условия Итог
push в ветку ci/gitlab, merge request ещё нет (1) source = push, не merge_request_event: не подошло. (2) ci/gitlab == main? нет job не создаются, конвейера нет (минуты исполнителей не тратятся)
тот же push, но merge request открыт (1) source = merge_request_event: подошло конвейер идёт
push в main (2) main == main: подошло конвейер идёт

Прикинь сам: push в ветку без открытого merge request при правилах merge_request_event и main. Создастся ли конвейер?

Нет: ни одно правило не подошло, job не создаются, минуты исполнителей не тратятся. После открытия merge request конвейер пойдёт.

Тут часто путают так. Что stages и needs это одно и то же. stages жёстко разделяет конвейер на слои: следующий слой ждёт всех из предыдущего. needs строит точечные зависимости: job стартует, как только готовы именно те, от кого она зависит. Второе: что rules заменяют старые ключи only и except. Они действительно устарели, в новых файлах используют rules.

Главное: stages задают порядок, jobs внутри одного этапа идут параллельно, а правила решают, создавать ли job.

Проверь понимание: в конвейере две job без stage:, и stages: не задан. Сколько этапов и как они пойдут?

Ответ

Обе job попадут в этап test по умолчанию и пойдут параллельно, если исполнителей хватает. Один этап, порядка между ними нет. Поэтому явное stages: в проекте лучше писать: читателю сразу видно порядок.

Конвейер идёт, но долго ставить пакеты. Как ускорить и что передать? Кэш и переменные.

Кэш и переменные в GitLab CI

Каждая job стартует в новом контейнере, где ничего нет. Если каждый раз скачивать из интернета все пакеты Python, конвейер будет медленным и будет зависеть от чужих серверов. Кэш (cache) это папка, которую GitLab сохраняет после job и подкладывает в следующую. Переменные (variables) нужны, чтобы настройки не копировать по файлу.

Кэш это кладовка, в которую повар кладёт заготовки: следующая смена не режет лук заново. Оговорка: кладовка может оказаться пустой (её почистили), поэтому рецепт обязан работать и без заготовок.

Программа pip (установщик библиотек Python) сама хранит скачанные пакеты в папке кэша. Через переменную PIP_CACHE_DIR можно указать, где эта папка. GitLab умеет кэшировать только пути внутри папки проекта ($CI_PROJECT_DIR), а домашний каталог пользователя внутри контейнера он не видит. Поэтому мы кладём кэш pip в .cache/pip внутри проекта, а в блоке cache: просим GitLab сохранить именно этот путь. key это имя кэша: пока ключ прежний, кэш подкладывается. Разные ключи дают разные кэши.

Кусок нашего файла:

variables:
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"    # (1) где pip хранит скачанное
cache:
  key: pip-py313                                 # (2) имя кэша
  paths:
    - .cache/pip                                 # (3) что сохранить и вернуть

Первый запуск: .cache/pip пуст, pip скачивает ruff из интернета (около 10 МБ) и пишет в кэш; после job GitLab упаковывает папку. Второй запуск: GitLab распаковывает кэш до начала script, pip находит ruff в кэше и не скачивает. Значение переменной $CI_PROJECT_DIR подставляется в строку автоматически, в контейнере это, например, /builds/anna/notes.

Прикинь сам: кэш пакетов не сработал. Сломается ли конвейер?

Нет: пакеты скачаются заново, конвейер станет дольше, но результат тот же.

Осторожно: Кэш и артефакт. Кэш это оптимизация: его можно потерять, и конвейер обязан пройти без него. Артефакт (artifacts) это результат job, который нужен людям или следующим job (отчёт, собранный архив). Если ты положишь в кэш то, без чего следующий этап не работает, однажды кэш пропадёт, и конвейер сломается.

Главное: кэш ускоряет, переменные задают настройки, а секреты хранят в защищённых переменных, а не в файле.

Проверь понимание: зачем PIP_CACHE_DIR внутри $CI_PROJECT_DIR, а не в ~/.cache/pip?

Ответ

GitLab сохраняет в кэш только пути внутри папки проекта. Стандартный ~/.cache/pip лежит вне проекта, поэтому он исчезал бы вместе с контейнером после каждой job, и кэш был бы бесполезен.

Кто выполняет job? Разберём runner и executor.

Runner и executor: кто на самом деле выполняет job

Сервер GitLab только хранит код и раздаёт задания, сам команды он не выполняет. Если бы выполнял, один тяжёлый конвейер положил бы весь сервер, а чужой код в конвейере получил бы доступ к серверу с кодом всей компании. Поэтому исполнителей выносят отдельно: это программа GitLab Runner (раннер), которая ставится на любую машину.

Диспетчер такси и водители. Диспетчер (GitLab) принимает заказы и не водит. Водитель (runner) сам заходит в приложение, берёт подходящий заказ и едет. Оговорка: если у водителя нет нужного детского кресла (тега), заказ с этим требованием он не возьмёт, и заказ висит.

Важная деталь: runner сам приходит к серверу, а не сервер к runner. Раннер каждые несколько секунд спрашивает у GitLab «есть работа для меня?». Поэтому раннер можно поставить за домашним роутером или файрволом компании, входящих подключений ему не нужно. Порядок:

  1. Ты создаёшь раннер в интерфейсе GitLab и получаешь токен (token) вида glrt-.... Токен это длинный секретный пароль: по нему сервер понимает, что это твой раннер. Показывается один раз.
  2. На своей машине ты запускаешь программу runner и регистрируешь её: сообщаешь адрес GitLab и токен. Настройки сохраняются в файле config.toml.
  3. Раннер опрашивает GitLab. Появилась подходящая job, раннер забирает её.
  4. Способ исполнения определяет executor (исполнитель в узком смысле): как именно запускать команды. Вариантов несколько: docker (на каждую job создаётся новый контейнер из image из файла, самый частый вариант), shell (команды выполняются прямо в системе раннера, без изоляции), kubernetes (под на job, тема 5).
  5. Команды выполнены, раннер отправляет в GitLab лог и статус.

Как раннер выбирает job: теги. Раннер может быть общим (shared: его предоставляет платформа, им пользуются все проекты) или своим (project или group runner: он привязан к твоему проекту). У раннера есть теги (tags): метки вроде docker, arm, gpu. У job тоже может быть tags:. Правило простое:

У job Что произойдёт
есть tags: [docker] job возьмёт только раннер, у которого есть тег docker
нет tags job возьмёт раннер с включённой опцией «Run untagged jobs» (выполнять job без тегов)
подходящего раннера нет job не падает и не ругается, а ждёт в статусе pending

Три раннера и три job:

flowchart LR
    A["раннер A<br>теги: docker<br>untagged: выкл"]
    B["раннер B<br>теги: arm<br>untagged: выкл"]
    C["раннер C<br>тегов нет<br>untagged: вкл"]
    L["job lint<br>tags: docker"] --> A
    T["job test<br>tags: gpu"] -.->|"никто не подходит"| P["pending"]
    BU["job build<br>тегов нет"] --> C

Через минуту в интерфейсе у test появится подпись This job is stuck because you don't have any active runners online with any of these tags assigned to them: gpu. Это не поломка кода, это отсутствие исполнителя.

Прикинь сам: у job tags: [gpu], раннеров с таким тегом нет. Что произойдёт?

job не упадёт, а будет ждать в статусе pending: подходящего исполнителя нет.

Тут часто путают так. Что pending означает «упал» или «идёт долго». Нет, pending это «никто не взялся». Вторая путаница: раннер и executor. Раннер это программа, executor это её режим работы (docker, shell, kubernetes). Третья: что docker executor и Docker на хосте это одно и то же. На машине раннера Docker должен быть, а executor docker использует его, чтобы создавать контейнеры для job.

Главное: runner это программа, берущая job, а executor способ запуска (например, docker): job находит раннер по тегам.

Проверь понимание: ты добавил в job tags: [docker], а раннер зарегистрирован без тегов и с выключенной опцией «Run untagged jobs». Что покажет pipeline?

Ответ

Pending. Job требует раннер с тегом docker, такого нет, а раннер без тегов job с тегами не берёт (он берёт только untagged, и то если опция включена, а у нас выключена). Ошибки нет, job просто ждёт.

Исполнитель получает чужой код и секреты. Что с безопасностью?

Безопасность CI: секреты, docker.sock и чужой код

CI выполняет команды из репозитория, значит любой, кто может изменить файл конвейера, может заставить твою машину выполнить что угодно. Кроме того, конвейеру часто нужны секреты для деплоя. Оба факта делают CI привлекательной мишенью.

Кухня ресторана, куда любой сотрудник может принести свой рецепт, и повар исполнит его дословно. Если у повара есть ключи от кассы, «рецепт» может начинаться со слов «открой кассу». Оговорка: у CI ключи от кассы это токены, и защищать надо именно их.

Три опасных места:

  1. Секреты. Их не кладут в репозиторий (урок 3.4). В GitLab они живут в Settings, CI/CD, Variables. Флаг masked прячет значение в логах (заменяет на [MASKED]). Флаг protected отдаёт переменную только защищённым веткам и тегам: конвейер обычной ветки её не увидит.
  2. Чужой код. Merge request из чужого форка (копии репозитория) содержит чужой .gitlab-ci.yml. Если такой конвейер идёт на твоём раннере с доступом к секретам и внутренней сети, автор может вывести секрет или сканировать сеть. Защита: protected-переменные недоступны форкам, форки идут на отдельные раннеры без доступа внутрь, деплой запускается только с main и по тегам.
  3. Сокет Docker. Файл /var/run/docker.sock это дверь, через которую программы командуют демоном Docker (фоновой службой, которая создаёт контейнеры). Кто получил эту дверь, может запустить контейнер с любой папкой хоста внутри, включая корень диска, и он фактически root на машине. Поэтому так делать нельзя: любая job (а значит, любой, кто может изменить конвейер) получает власть над всей машиной раннера и всем, что на ней лежит. Мы монтируем сокет в контейнеры раннера и Jenkins только на учебном стенде, где на машине нет ничего ценного; в боевой среде это допустимо лишь на отдельной изолированной машине.

Что делает владелец сокета, одной командой:

docker run --rm -v /:/host alpine cat /host/etc/shadow

Флаг -v /:/host показывает внутри контейнера весь диск хозяина в папке /host, и команда читает файл с хешами паролей пользователей. Это не взлом: Docker выполнил просьбу, ведь у просившего есть сокет. Именно поэтому доступ к сокету приравнивают к root.

Прикинь сам: в job подключён /var/run/docker.sock. Почему это опасно?

Тот, кто может запускать команды в job, управляет Docker хоста и фактически получает права над самим сервером.

Осторожно: masked это шифрование. Это только вырезание значения из логов; если команда сама выведет секрет в закодированном виде (например, через base64), маска его не узнает. Второе: что chmod 666 /var/run/docker.sock решает проблему прав. Он открывает root-дверь всем пользователям машины.

Главное: исполнитель запускает чужие команды, поэтому секреты выдают минимально, а доступ к docker.sock равен доступу к хосту.

Проверь понимание: почему нельзя отдавать раннеру с монтированием docker.sock пайплайны из форков?

Ответ

Автор форка пишет в конвейере любую команду, в том числе docker run -v /:/host .... Через сокет она выполнится с правами root на машине раннера: чужой человек прочитает секреты, диски и получит доступ во внутреннюю сеть.

Теперь Jenkins: у него другой словарь.

Jenkins: controller, agent и Jenkinsfile

Jenkins появился раньше GitHub Actions и GitLab CI и работает независимо от места хранения кода: он умеет брать код откуда угодно и запускать что угодно. Поэтому в компаниях с разнородной инфраструктурой он живёт десятилетиями. Платить за это приходится обслуживанием: Jenkins ты ставишь, обновляешь и бэкапишь сам.

Не готовая столовая, а пустое здание с кухней: оборудование (плагины) ты подбираешь и подключаешь сам. Оговорка: свобода означает ответственность, не обновлённый плагин может стать дверью для взлома.

Jenkins написан на Java (языке, для которого нужна среда JDK; в образе она уже есть). Две роли:

  • controller (контроллер, раньше «master»): главный сервер с веб-интерфейсом. Хранит настройки, историю сборок, раздаёт задания;
  • agent (агент, узел): машина или контейнер, который исполняет команды сборки. Аналог runner. Агентом может быть и сам controller (так делается в этом уроке), но в боевых установках controller не исполняет чужой код.

Всё состояние controller лежит в каталоге JENKINS_HOME (в Docker это том jenkins_home): настройки, пароли, история. Потеряешь без бэкапа, потеряешь всё. Возможности Jenkins дают плагины (Pipeline, Git, Docker Pipeline, Credentials). Они обновляются отдельно и через них чаще всего находят уязвимости.

Сборку описывает файл Jenkinsfile, лежащий в репозитории (подход «Pipeline as code», конвейер как код). У него два диалекта:

  • declarative (декларативный): жёсткая структура pipeline { agent, stages, steps }. Его проверяет парсер (программа-разборщик) до запуска, поэтому ошибки видны сразу. Мы пишем его;
  • scripted (скриптовый): сырой Groovy (язык программирования на базе Java), гибче, но легче наломать дров.

Внутри Jenkinsfile используются скобки { } для блоков (не отступы, как в YAML). Строки команд пишутся в sh '...' (одна команда в одинарных кавычках) или sh '''...''' (несколько команд, тройные кавычки).

Как работает agent { docker { image '...' } }. Эта строка просит Jenkins выполнять стадии внутри контейнера из образа. По шагам:

  1. Jenkins скачивает код репозитория в рабочую папку (workspace) на своей машине.
  2. Командой docker run запускает контейнер из образа и подключает к нему workspace как том (папку, общую с хостом).
  3. Каждый sh выполняется внутри этого контейнера командой docker exec.
  4. Сборка закончилась, контейнер останавливается и удаляется, а workspace остаётся.

Отсюда три требования: на агенте должен быть клиент docker (программа, которая отдаёт команды демону; это не то же самое, что сам демон), должен стоять плагин Docker Pipeline (docker-workflow), и у пользователя Jenkins должен быть доступ к сокету демона. Контейнер запускается с идентификатором пользователя Jenkins, а не root, поэтому писать в корень / и в домашнюю папку он не может. Отсюда python -m venv .venv в Jenkinsfile: виртуальное окружение (venv, отдельная папка с пакетами Python) создаётся в рабочем каталоге, куда писать можно.

Наш Jenkinsfile по частям (полный текст в практике):

pipeline {                              # весь конвейер
    agent { docker { image 'python:3.13' } }   # где выполнять: контейнер python:3.13
    options { timeout(time: 10, unit: 'MINUTES') }   # предел: 10 минут на всю сборку
    stages {                            # список этапов
        stage('Lint') { steps { sh '...' } }    # этап Lint: команды в sh
        stage('Test') { steps { sh 'make test' } }   # этап Test
    }
}

Порядок этапов в Jenkins всегда последовательный (сверху вниз), красный этап останавливает остальные. Это аналог stages в GitLab, только для параллельности нужен отдельный блок parallel.

Прикинь сам: что в Jenkins выполняет команды: controller или agent?

Agent: controller планирует и хранит настройки, а команды идут на agent.

Тут часто путают так. Что настройка «кликами» в веб-интерфейсе и Jenkinsfile равноценны. Клики живут только в JENKINS_HOME: при потере сервера конвейер пропадает, а ревью и история изменений отсутствуют. Файл же версионируется вместе с кодом (урок 3.1). Вторая путаница: что docker на агенте нужен внутри образа сборки. Нет, клиент нужен на агенте (в контейнере Jenkins), а образ python:3.13 может вообще не знать про Docker.

Главное: controller управляет, agent выполняет, а конвейер описывает Jenkinsfile.

Проверь понимание: почему Jenkinsfile в репозитории лучше, чем job, настроенная кликами в веб-интерфейсе?

Ответ

Файл версионируется вместе с кодом: видно, кто и когда менял конвейер, работают Pull Request и ревью, старую ветку можно собрать старым конвейером, при потере сервера конвейер не пропадает. Клики в интерфейсе живут только в JENKINS_HOME.

У трёх платформ одни и те же команды. Как не дублировать их? Через make.

Одни и те же команды в трёх местах: make как единый источник

Если правило «что значит проверка прошла» записано в трёх файлах отдельно (ci.yml, .gitlab-ci.yml, Jenkinsfile), рано или поздно они разойдутся: в одном линтер обновят, в другом забудут, и зелёный статус на одной платформе не будет значить того же, что на другой. Решение: конвейер только вызывает короткие имена целей из Makefile, а что они делают, записано в одном месте.

Пульт с подписанными кнопками из урока 1.6. Меняется устройство телевизора внутри, кнопка «громкость» у всех остаётся. Оговорка: пока в ci.yml из урока 3.3 команды написаны прямо (python -m unittest -v), он этого не делает; это можно поправить по желанию.

В Makefile из урока 1.6 есть цели lint (проверка синтаксиса py_compile), test (python3 -m unittest -v) и run (запуск сервиса). Конвейеры GitLab и Jenkins вызывают make lint и make test и отдельно ruff check . (ruff это линтер, он появился в уроке 3.3 и в Makefile пока не входит). Для этого в образе должен быть make. В маленьком образе python:3.13-slim его нет (sh: 1: make: not found), в полном python:3.13 есть (GNU Make 4.4.1). Мы берём полный образ; цена: он весит около 1 ГБ и тянется один раз, дальше лежит в кэше раннера.

Про переменную NOTES_DATA ?= /tmp/notes-dev.txt из Makefile. Она нужна цели make run: сервис по умолчанию пишет данные в /var/lib/notes/notes.txt, а такой папки на чужой машине нет. Цель make test эту переменную не использует: тест сам создаёт временную папку и передаёт её сервису. Поэтому в конвейере NOTES_DATA задавать не нужно, а make run в CI вообще не вызывают, это команда для разработчика.

Что случится, если запустить сервис в чистом контейнере без NOTES_DATA (реальный запуск в образе python:3.13):

$ python app.py            # без NOTES_DATA
... INFO started host=127.0.0.1 port=8080
$ curl -X POST -d '{"text":"x"}' http://127.0.0.1:8080/notes
{"error": "storage"}       # код ответа 500
... ERROR ошибка хранилища: [Errno 2] No such file or directory: '/var/lib/notes/notes.txt'

Сервис стартует, но не может записать заметку: папки /var/lib/notes в чистом контейнере нет, а readyz отвечает 503. Вот почему тесты сами задают NOTES_DATA, а в конвейере мы вызываем make test, а не запускаем app.py руками.

Прикинь сам: в .gitlab-ci.yml, Jenkinsfile и ci.yml по одной команде. Что случится, если поменять только одну?

Платформы разойдутся: одна проверит по-новому, две по-старому. Если команда одна (make test), правка в Makefile меняет всё сразу.

Осторожно: make в CI «необязателен». Он не обязателен технически, но без него команды дублируются. Ещё путают: что раз make lint проверяет синтаксис, то ruff не нужен. Это разные проверки: py_compile ловит синтаксические ошибки, ruff ловит стиль и неиспользуемые импорты.

Главное: команды проверки живут в Makefile, а файлы платформ только вызывают их, чтобы не расходиться.

Проверь понимание: завтра ты меняешь правила линтера. В скольких файлах это нужно править, если конвейеры вызывают make lint, а в нём ruff check .?

Ответ

В одном: в Makefile (плюс в ruff.toml для самих правил). GitLab и Jenkins продолжают вызывать make lint и получают новое поведение автоматически. Если бы команды были размножены по трём файлам, править пришлось бы в трёх, и о четвёртом легко забыть.

Команды едины. Какую платформу выбрать? Зависит от ситуации.

Когда что выбирают

Инженер должен уметь объяснить руководителю, почему для проекта выбрана та или иная платформа, и понимать, какая цена у выбора.

Такси, свой автомобиль и аренда: у каждого варианта своя стоимость и гибкость. Оговорка: выбор редко чисто технический, чаще он определяется тем, что уже есть в компании.

Решение складывается из четырёх факторов: где живёт код, кто обслуживает платформу, сколько исполнителей нужно и что уже накоплено.

  • GitHub Actions: код на GitHub, платформой занимается GitHub, исполнители его же или свои. Меньше всего обслуживания.
  • GitLab CI: встроен в GitLab. Репозиторий, реестр образов, окружения и конвейеры в одной системе, поэтому его выбирают компании со своим GitLab внутри контура (в закрытой сети). Раннеры обслуживаются отдельно.
  • Jenkins: не привязан к месту хранения кода, гибкий, но всё обслуживание на тебе: сервер, плагины, обновления, бэкапы.

Компания с сотней старых сборок на Jenkins и десятком нестандартных плагинов вряд ли перепишет всё за месяц. Разумный путь: новые проекты сразу на GitLab CI, старые переносить по одному, обе системы держать параллельно, пока не убедились, что результат совпадает (об этом вопрос 8 собеседования).

Прикинь сам: код лежит на GitHub, команда небольшая. Какую платформу взять первой?

GitHub Actions: он уже встроен в платформу, где лежит код, и не требует отдельного сервера. Jenkins берут, когда нужна полная свобода и свой сервер.

Тут часто путают так. Что Jenkins «устарел и не нужен». Он остаётся крайне распространённым в компаниях, и читать чужие Jenkinsfile ты должен уметь. Для нового проекта его обычно не выбирают из-за стоимости обслуживания, но это не значит, что он исчез.

Главное: платформу выбирают по тому, где лежит код, кто будет её обслуживать и какие требования у компании.

Проверь понимание: код проекта лежит на GitHub, обслуживать отдельные серверы некому. Что разумнее выбрать и почему?

Ответ

GitHub Actions: код уже на GitHub, платформа обслуживается провайдером, исполнители общие. GitLab потребовал бы переезда или зеркала репозитория, а Jenkins ещё и своего сервера с обслуживанием.

Платформа выбрана. Как читать результат её запуска?

Как читать запуск конвейера: статусы и где искать причину

На каждой платформе у запуска десяток статусов. Новичок видит красный или серый значок и не знает, сломался ли его код, упал сам сервер или задание просто ещё ждёт. От этого зависит, что делать дальше: чинить код, чинить раннер или подождать.

Статусы это табло на вокзале: «идёт посадка», «задержан», «отменён», «поезд не формируется». Из слов понятно, виноват ли пассажир (код) или железная дорога (инфраструктура). Оговорка: табло показывает статус, но не причину. Причина в логе, как в объяснении диспетчера.

У конвейера GitLab и у каждого job свой статус. Вот главные и что за ними стоит:

Статус Что значит Чья это проблема
pending (ожидает) job создана, но раннер её ещё не взял инфраструктура: нет подходящего раннера, он выключен или не совпали теги
running (идёт) раннер выполняет script ничья, просто подожди
passed (прошла) все команды вернули код 0 всё хорошо
failed (упала) какая-то команда вернула ненулевой код чаще твой код или команды, иногда сеть
skipped (пропущена) job не запускалась: этап перед ней упал или rules не подошли смотри, что упало раньше
canceled (отменена) её остановили руками или новый коммит заменил запуск обычно не проблема

Идти за причиной нужно по цепочке: конвейер, этап, job, лог. Открываешь конвейер, находишь красную job и читаешь конец её лога: команду, которая упала, и код выхода (в GitLab в конце строка вроде ERROR: Job failed: exit code 1). Всё, что выше, контекст: скачивание образа, код, команды до ошибки.

flowchart LR
    L["stage lint<br>passed"] --> T["stage test<br>failed<br>в конце лога FAIL: test_empty_note, exit code 1"]
    T --> D["stage deploy<br>skipped<br>не поломка: test упал, deploy не стартовал"]

Конвейер висит уже десять минут, job lint в статусе pending. Код ни при чём: job ещё даже не начала выполняться, значит, команды не запускались. Смотрим раннеры проекта: нужная job просит tags: [docker], а у единственного раннера тег shell. Подходящего раннера нет, job будет ждать вечно (до таймаута GitLab). Правим теги или добавляем раннер: код менять не нужно. Противоположный случай: job failed через 20 секунд, в конце лога ruff: command not found. Раннер работает, job выполнилась, но в образе нет нужной программы: причина в шаге pip install или в образе, а не в инфраструктуре.

Прикинь сам: в конвейере lint passed, test failed, deploy skipped. Где искать причину?

В test: открыть лог и найти первую ошибку. deploy пропущен, потому что упал test, это не поломка.

Осторожно: «skipped это тоже ошибка». Нет: серая job не упала, она не запускалась из-за того, что перед ней что-то упало. Ищи первую красную, а не серые. Второе: «failed всегда виноват мой код». Бывает, что упало скачивание пакетов из-за сбоя сети: лог покажет таймаут или connection refused. Тогда повторный запуск (Retry) обоснован, а вот повторять без чтения лога нельзя.

Главное: причина красного всегда в первой упавшей job, а skipped значит, что job не запускалась.

Проверь понимание: в конвейере lint красная, test серая. Где искать причину и что означает серый цвет?

Ответ

Причина в lint: открой её лог и прочитай конец. Серая test не запускалась, потому что этап lint упал, а следующие этапы стартуют только после успеха предыдущего. Чинишь lint, и test пойдёт сама.

Остался перенос конвейера между платформами.

Как переносить конвейер с одной платформы на другую

В реальной работе чаще приходится не писать конвейер с нуля, а переезжать: компания меняет Jenkins на GitLab или GitLab на GitHub. Если делать это «переписыванием всего подряд», половина проверок теряется по дороге, а красные результаты невозможно сравнить со старыми.

Переезд в другую квартиру. Мебель (команды проверок) переезжает целиком, а розетки, лампы и шторы (синтаксис платформы) придётся купить заново под новые стены. Умный переезд начинается с инвентаризации: что именно у тебя есть и что куда поставить. Оговорка: в квартире вещь либо переехала, либо нет, а в CI перенос можно сделать частично и запустить оба конвейера рядом, чтобы сравнить.

Надёжный порядок из пяти шагов:

  1. Инвентаризация. Выписываешь по старому конвейеру: какие проверки есть, в каком порядке, какие нужны образы, секреты, кэши, по каким событиям запуск (по push, по тегу, по расписанию).
  2. Вынос логики в скрипты. Команды проверок должны жить в репозитории (make lint, make test), а не в полях платформы. Тогда новая платформа лишь вызывает их. Именно поэтому в этом уроке всё сводится к make.
  3. Перевод по таблице соответствий. Берёшь таблицу «Три платформы, три словаря» и заменяешь ключи: job, образ, script, правила запуска, кэш.
  4. Параллельный запуск. Старый и новый конвейеры работают на одних и тех же коммитах. Результаты должны совпасть: если старый красный, а новый зелёный, значит, в переводе что-то потерялось.
  5. Переключение и удаление старого. Только после нескольких дней совпадающих результатов новый делают обязательным, а старый отключают.
flowchart LR
    O["старый конвейер<br>(Jenkins)"] --> Q{"результаты совпали<br>несколько дней?"}
    N["новый конвейер<br>(GitLab CI)"] --> Q
    Q -->|да| OFF["выключаем старый"]
    Q -->|нет| FIND["ищем, что потерялось<br>при переводе"]

В Jenkins есть stage Lint, внутри sh 'make lint', stage Test с sh 'make test' и тег v* как повод запуска. Переносим на GitLab. Инвентаризация даёт две проверки и одно правило запуска. Логика уже в make, поэтому переводить нечего, кроме оболочки: stage Lint превращается в job lint с script: [make lint], agent { docker { image ... } } в image: python:3.13, условие на тег в rules: - if: $CI_COMMIT_TAG. Секреты и права переносят отдельно и руками: значения из Credentials Jenkins автоматически не переедут, их заводят заново в CI/CD variables.

Прикинь сам: новый конвейер заработал. Можно ли сразу выключить старый?

Нет: сначала запускают оба и сравнивают результаты несколько дней. Выключают старый, когда они совпали.

Тут часто путают так. «Перенести значит скопировать файл и поправить ключи». Синтаксис это меньшая часть. Главные потери при переезде: секреты (забыли завести), права доступа (раннер без доступа к реестру), кэш (первый запуск в десять раз дольше), поведение при ошибках (там, где Jenkins продолжал, GitLab остановится). Поэтому параллельный запуск обязателен.

Главное: перенос идёт параллельно: старый и новый работают вместе, пока результаты не совпадут.

Проверь понимание: зачем перед переносом выносить логику проверок в Makefile?

Ответ

Тогда платформа только вызывает make lint и make test, и смена платформы не затрагивает сами проверки: они описаны в одном месте и работают одинаково у тебя на машине и в любом CI. Без этого команды пришлось бы переписывать внутри полей каждой платформы, и легко потерять шаг.

Теории хватит. Дальше практика: собираем конвейеры.

Практика

Понадобятся: репозиторий ~/notes с Makefile, test_app.py (урок 1.6), requirements-dev.txt и ruff.toml (урок 3.3), аккаунт на gitlab.com (бесплатный) и Docker. Проверь, что нужное на месте:

cd ~/notes
ls Makefile test_app.py requirements-dev.txt ruff.toml
docker --version

Разбор: ls со списком файлов печатает их же, если все существуют, и ругается на отсутствующие (No such file or directory, код 2). docker --version печатает версию Docker; если её нет, вернись к разделу «Что нужно знать».

Локально проверь то, что будет делать робот: те же цели в чистом контейнере. Команда запускает контейнер образа python:3.13, подключает текущую папку и выполняет три команды подряд:

docker run --rm -v "$PWD":/w -w /w python:3.13 \
  bash -euc 'pip install -q -r requirements-dev.txt && make lint && ruff check . && make test'

Разбор по частям: docker run создаёт и запускает контейнер; --rm удалит его после остановки; -v "$PWD":/w подключает текущую папку как /w внутри контейнера ($PWD это путь текущей папки); -w /w делает /w рабочей папкой; python:3.13 образ; bash -euc '...' запускает bash с флагами -e (остановиться при первой ошибке), -u (ошибка на несуществующую переменную) и -c (выполнить строку из кавычек); && выполняет следующую команду, только если предыдущая вернула 0.

Конец вывода:

python3 -m py_compile app.py test_app.py
lint: синтаксис в порядке
All checks passed!
python3 -m unittest -v
test_empty_body_is_400 (test_app.NotesTest.test_empty_body_is_400) ... ok
test_healthz (test_app.NotesTest.test_healthz) ... ok
test_method_not_allowed (test_app.NotesTest.test_method_not_allowed) ... ok
test_not_found (test_app.NotesTest.test_not_found) ... ok
test_post_and_get_note (test_app.NotesTest.test_post_and_get_note) ... ok
test_root (test_app.NotesTest.test_root) ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.658s

OK

Как читать вывод: python3 -m py_compile ... строка, которую make печатает перед выполнением команды цели; lint: синтаксис в порядке это echo из цели lint; All checks passed! говорит ruff; дальше unittest. Время (0.658s) у тебя будет другим, главное OK в конце. Если здесь красное, конвейер будет красным по той же причине, и чинить надо здесь, а не в конвейере.

Задание 1. Конвейер «Заметок» в GitLab CI

Цель: описать lint и test в .gitlab-ci.yml и получить зелёный pipeline на gitlab.com.

Предскажи: ты добавишь job lint в этап lint и test в этап test. Что произойдёт с test, если lint упадёт из-за замечания ruff? Запустится ли test вообще?

Ответ

Не запустится. Этап test стартует только после успеха всех jobs предыдущего этапа. Pipeline покажет lint красным, а test серым (не запускался).

Шаги:

  1. Создай пустой проект на gitlab.com: New project, Create blank project, имя notes, без README (иначе в проекте уже будет коммит, и push отклонят). Скопируй SSH-адрес проекта. Свой публичный ключ добавь в профиле (Preferences, SSH Keys), как в уроке 2.2, и проверь связь. Ожидаемый ответ: Welcome to GitLab, @<логин>!
ssh -T git@gitlab.com
  1. Добавь GitLab вторым remote (адрес удалённого репозитория; GitHub остаётся origin). Команда git remote add <имя> <адрес> запоминает адрес под коротким именем, git remote -v показывает все:
cd ~/notes
# замени <gitlab-user> на свой логин на gitlab.com
git remote add gitlab git@gitlab.com:<gitlab-user>/notes.git
git remote -v

Как читать вывод: каждый remote показан дважды, (fetch) (откуда забирать) и (push) (куда отправлять). Должны быть origin (GitHub) и gitlab.

  1. Создай ветку и файл конвейера. git switch -c создаёт ветку и переходит на неё. Файл записывается командой cat > файл <<'YAML' ... YAML (текст между метками попадает в файл как есть; кавычки вокруг YAML запрещают оболочке подставлять $-переменные):
git switch -c ci/gitlab
cat > .gitlab-ci.yml <<'YAML'
# Тот же конвейер, что и в .github/workflows/ci.yml, на языке GitLab CI
stages:                 # порядок этапов: сначала lint, потом test
  - lint
  - test

default:
  image: python:3.13   # образ, в котором исполняется каждый script (в нём есть make)

variables:
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"   # кэш pip внутри проекта, чтобы GitLab мог его сохранить
  PIP_DISABLE_PIP_VERSION_CHECK: "1"            # не проверять свежесть самого pip: лишний запрос в сеть
  PIP_ROOT_USER_ACTION: "ignore"                # в контейнере мы root, предупреждение pip не нужно

cache:
  key: pip-py313        # имя кэша
  paths:
    - .cache/pip        # что сохранять между запусками

lint:                   # имя job
  stage: lint           # этап
  script:               # команды: остановка на первой, вернувшей не 0
    - pip install -r requirements-dev.txt
    - make lint
    - ruff check .

test:
  stage: test
  script:
    - make test
YAML
git add .gitlab-ci.yml
git commit -m "ci: конвейер lint и test для GitLab CI"

Разбор файла: stages задаёт порядок; default.image даёт образ всем job сразу; variables создают переменные окружения внутри контейнера; cache описан в теории; в lint три команды идут по очереди, если pip install или make lint вернёт не 0, ruff уже не запустится; test вызывает make test, поэтому pip install здесь не нужен: у тестов нет внешних зависимостей, они используют только стандартную библиотеку Python. Для lint установка нужна, потому что ruff это внешний пакет, а каждая job стартует в новом контейнере, куда ничего не переносится из соседней.

  1. Отправь в GitLab (не в GitHub) сначала main, потом ветку. Первая отправленная в пустой проект ветка становится веткой по умолчанию, и нам нужно, чтобы это была main, потому что в задании 2 правила будут на неё опираться:
git push gitlab main
git push -u gitlab ci/gitlab

Разбор: git push gitlab main отправляет ветку main в remote gitlab; -u в следующей команде запоминает пару «локальная ветка и удалённая», дальше хватит git push. Открой в GitLab Build, Pipelines.

Если pipeline повисит в состоянии pending, у аккаунта нет доступа к общим раннерам (на бесплатном тарифе gitlab.com для них может потребоваться подтверждение аккаунта, проверь актуальные условия на docs.gitlab.com). Тогда переходи к заданию 2 и подними свой раннер.

Что должно получиться: в Pipelines строка со статусом passed и двумя зелёными кружками. В логе job test (вывод собран из прогона тех же команд в контейнере python:3.13, у тебя времена и порядок могут отличаться; сам GitLab добавляет шапку про раннер и образ):

$ make test
python3 -m unittest -v
test_empty_body_is_400 (test_app.NotesTest.test_empty_body_is_400) ... ok
test_healthz (test_app.NotesTest.test_healthz) ... ok
test_method_not_allowed (test_app.NotesTest.test_method_not_allowed) ... ok
test_not_found (test_app.NotesTest.test_not_found) ... ok
test_post_and_get_note (test_app.NotesTest.test_post_and_get_note) ... ok
test_root (test_app.NotesTest.test_root) ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.658s

OK
Job succeeded

Как читать вывод: строка с $ это команда из script, которую печатает GitLab; python3 -m unittest -v печатает уже make (он показывает команду цели); шесть строк ... ok это тесты из test_app.py (урок 1.6); Job succeeded итог, который бывает, только если последняя команда вернула 0. В логе lint найди строки Successfully installed ruff-... (версия у тебя будет своя), lint: синтаксис в порядке и All checks passed!.

Объясни себе:

  • Откуда в контейнере взялся код репозитория, если в script нет git clone?
  • Зачем PIP_CACHE_DIR внутри $CI_PROJECT_DIR, а не в ~/.cache/pip?
  • Почему в test нет pip install, а в lint есть?

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

  • ERROR: Job failed: failed to pull image "python:3.13m" with specified policies [always]: ... manifest unknown: опечатка в имени образа или тега; сверь с hub.docker.com/_/python и поправь image:.
  • fatal: Could not read from remote repository. Permission denied (publickey). при git push gitlab: ключ не добавлен в профиль GitLab; добавь публичный ключ и проверь ssh -T git@gitlab.com.
  • jobs:lint config should implement a script: or a trigger: keyword (вкладка Pipelines, yaml invalid): отступ или опечатка в ключе, script не на своём уровне; сверь отступы (два пробела, без табуляций).
  • Pipeline не создался вообще: файл называется не .gitlab-ci.yml (например .gitlab-ci.yaml или gitlab-ci.yml); переименуй.
  • ! [rejected] main -> main (fetch first) при git push gitlab main: в GitLab-проекте уже есть коммиты (например README при создании); создай проект пустым или сделай git pull --rebase gitlab main.

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

Задание 2. Свой раннер, теги и правила запуска

Цель: увидеть на живом примере связь job, tags и runner: зарегистрировать раннер в Docker, добавить теги и rules.

Предскажи: ты добавишь в job lint строку tags: [docker], а раннер зарегистрируешь без тегов и без «run untagged jobs». Что покажет pipeline: зелёный, красный или pending?

Ответ

Pending. Job требует раннер с тегом docker, такого нет, ошибки нет, job просто ждёт. В интерфейсе: This job is stuck because you don't have any active runners online with any of these tags assigned to them: docker.

Шаги:

  1. В GitLab: Settings, CI/CD, Runners, New project runner. Теги: docker. Создай раннер и скопируй токен glrt-... (показывается один раз, это секрет: не вставляй его в чат, тикет и историю команд).
  2. Запусти контейнер GitLab Runner. Он будет постоянно работать и опрашивать GitLab. Расшифровка флагов: -d в фоне; --name gitlab-runner имя контейнера; --restart unless-stopped поднимать после перезагрузки; первый -v подключает том gitlab-runner-config (сюда раннер пишет config.toml, том переживает удаление контейнера); второй -v даёт раннеру доступ к сокету Docker (см. раздел безопасности в теории: только для учебного стенда).
# тег образа проверь на hub.docker.com/r/gitlab/gitlab-runner, тег latest не используем
export RUNNER_TAG=ubuntu-v19.4.1
docker volume create gitlab-runner-config
docker run -d --name gitlab-runner --restart unless-stopped \
  -v gitlab-runner-config:/etc/gitlab-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  gitlab/gitlab-runner:"$RUNNER_TAG"

export RUNNER_TAG=... создаёт переменную окружения, "$RUNNER_TAG" подставляет её значение в имя образа. Версия в примере актуальна на 30.09.2026, если вышла новая, подставь её.

  1. Зарегистрируй раннер. Токен вводится через read -rs: read читает строку с клавиатуры в переменную, -s не показывает ввод, -r не трактует обратную косую черту особо. Так токен не остаётся ни на экране, ни в истории команд (history). В команде register: --non-interactive без вопросов, --url адрес GitLab, --token токен, --name имя раннера в интерфейсе, --executor docker режим работы, --docker-image образ по умолчанию, если у job нет своего image.
read -rs RUNNER_TOKEN   # вставь glrt-... и Enter, ввод не отображается
docker exec gitlab-runner gitlab-runner register --non-interactive \
  --url https://gitlab.com --token "$RUNNER_TOKEN" \
  --name notes-runner --executor docker --docker-image python:3.13
unset RUNNER_TOKEN
docker exec gitlab-runner gitlab-runner list

docker exec gitlab-runner ... выполняет команду внутри уже запущенного контейнера gitlab-runner. unset удаляет переменную из оболочки.

  1. Добавь в оба job тег и правило запуска: конвейер только для merge request и для ветки по умолчанию. Для lint:
lint:
  stage: lint
  tags: [docker]        # взять может только раннер с тегом docker
  rules:                # создавать job только если подошло одно из условий
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"   # запуск из-за merge request
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH        # или push в ветку по умолчанию
  script:
    - pip install -r requirements-dev.txt
    - make lint
    - ruff check .

Для test сделай то же самое: те же tags и rules между stage и script, а script остаётся - make test. Ключ rules заменяет старые only и except, пиши по-новому. Проверь файл на месте yq (если установлен) или просто глазами по отступам.

  1. Закоммить, отправь ветку и открой merge request одной командой (опция push merge_request.create просит GitLab создать MR сразу). Без MR конвейер по нашим правилам не появится: ветка ci/gitlab не является веткой по умолчанию:
git add .gitlab-ci.yml
git commit -m "ci: теги и rules"
git push -o merge_request.create gitlab ci/gitlab

Открой в GitLab страницу Build, Pipelines. Проверь, что job взял именно твой раннер: в начале лога строка Running with gitlab-runner ....

Что должно получиться. Команда list печатает (так выглядит формат, токен у тебя свой; проверено на версии раннера 19.4.1 с тестовым токеном):

Runtime platform                                    arch=arm64 os=linux pid=7 revision=3c39fceb version=19.4.1
Listing configured runners                          ConfigFile=/etc/gitlab-runner/config.toml
notes-runner                                        Executor=docker Token=glrt-... URL=https://gitlab.com

и pipeline passed. Push обычной ветки без merge request pipeline теперь не создаёт: так экономят минуты раннеров.

Как читать вывод: первая строка это платформа и версия раннера (pid и revision у тебя другие); Listing configured runners показывает файл настроек, значит раннер зарегистрирован; строка notes-runner ... это твой раннер с исполнителем docker. Внимание: list печатает токен целиком (вместо ... у тебя будет вся строка glrt-...). Не публикуй этот вывод и не вставляй в чаты.

Объясни себе:

  • Чем executor = docker безопаснее shell, если на раннере запускают чужие MR?
  • Что даёт монтирование /var/run/docker.sock в контейнер раннера и почему это почти root на хосте?
  • Зачем rules разрешает ветку по умолчанию, а не только MR?

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

  • This job is stuck because you don't have any active runners online with any of these tags assigned to them: docker: у раннера нет тега или он выключен; добавь тег в настройках раннера или включи «Run untagged jobs».
  • Verifying runner... is not valid и следом PANIC: Failed to verify the runner. при register: токен неверный, отозван или скопирован с обрезкой; создай раннер заново через New project runner и подставь новый glrt- токен. Текст получен на заведомо неверном токене.
  • Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?: сокет не смонтирован или демон остановлен; проверь -v /var/run/docker.sock:/var/run/docker.sock и systemctl status docker.

Задание 3. Jenkins в Docker и Jenkinsfile

Цель: поднять Jenkins, подключить ~/notes и получить зелёную сборку по Jenkinsfile с агентом docker.

Предскажи: ты запустишь стандартный образ Jenkins, смонтируешь сокет Docker и попробуешь agent { docker {...} }. Сработает ли?

Ответ

Нет. В образе Jenkins нет клиента docker: команда даёт docker: not found, а плагина Docker Pipeline может не быть. Поэтому делаем свой образ: Jenkins плюс клиент Docker плюс плагины.

Шаги:

  1. Собери образ Jenkins с клиентом Docker. Каталог вне ~/notes: эти файлы в проект не входят. Файл Dockerfile это рецепт сборки образа (подробно в теме 4); разбор ниже. Тег базового образа проверь на странице jenkins/jenkins, берём ветку LTS (долгой поддержки), тег закреплён на 30.09.2026:
mkdir -p ~/jenkins-lab && cd ~/jenkins-lab
cat > Dockerfile <<'EOF'
# Тег LTS проверь на hub.docker.com/r/jenkins/jenkins (на 30.09.2026: 2.580.1-lts-jdk21)
FROM jenkins/jenkins:2.580.1-lts-jdk21
USER root
# клиент Docker (пакет docker-cli) нужен, чтобы Jenkins мог запускать агентов в контейнерах
RUN apt-get update && apt-get install -y --no-install-recommends docker-cli \
    && rm -rf /var/lib/apt/lists/*
USER jenkins
# плагины: Pipeline, Git и Docker Pipeline
RUN jenkins-plugin-cli --plugins workflow-aggregator git docker-workflow
EOF
docker build -t jenkins-notes:lab .

Разбор Dockerfile: FROM берёт готовый образ Jenkins за основу; USER root временно даёт права администратора, чтобы ставить пакеты; RUN apt-get ... install docker-cli ставит только клиент Docker (--no-install-recommends не тянет лишнего; rm -rf /var/lib/apt/lists/* убирает служебный кэш, образ получается меньше); USER jenkins возвращает обычного пользователя; последняя RUN командой jenkins-plugin-cli ставит плагины. Пакет называется именно docker-cli: в Debian 13, на котором построен образ Jenkins, пакет docker.io клиента не содержит (проверено, там только демон), и сборка с ним кончилась бы тем же docker: not found.

docker build -t jenkins-notes:lab . собирает образ по Dockerfile из текущей папки (точка) и называет его jenkins-notes:lab. Сборка занимает около минуты. В конце должно быть без ошибок. Проверка результата:

docker run --rm --entrypoint sh jenkins-notes:lab -c 'which docker; docker --version'
/usr/bin/docker
Docker version 26.1.5+dfsg1, build a72d7cd

Разбор: --entrypoint sh подменяет стандартный запуск Jenkins на оболочку, -c '...' выполняет две команды; which docker печатает путь к программе. Версия у тебя может отличаться, важен сам ответ, а не not found.

  1. Запусти Jenkins. Порт 8081, потому что 8080 занят «Заметками». Порт публикуется флагом -p 8081:8080 (порт хоста, порт контейнера). --group-add "$(stat -c %g /var/run/docker.sock)" разбирается так: stat -c %g файл печатает номер группы, которой принадлежит файл (у сокета Docker это группа docker), а $(...) подставляет результат в команду; --group-add добавляет пользователя контейнера в эту группу, и он получает право говорить с демоном. sleep 45 ждёт, пока Jenkins стартует, последняя команда печатает начальный пароль администратора:
docker run -d --name jenkins -p 8081:8080 \
  -v jenkins_home:/var/jenkins_home \
  -v /var/run/docker.sock:/var/run/docker.sock \
  --group-add "$(stat -c %g /var/run/docker.sock)" \
  jenkins-notes:lab
sleep 45
docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword

Том jenkins_home хранит JENKINS_HOME: пересоздашь контейнер, настройки останутся. Вывод последней команды это строка из 32 шестнадцатеричных символов.

  1. Открой http://localhost:8081, введи пароль, создай администратора. На экране выбора плагинов возьми None (не ставить): нужные уже вшиты в образ.
  2. В ~/notes создай Jenkinsfile. Тот же конвейер, что в GitLab, и те же цели make:
cd ~/notes
cat > Jenkinsfile <<'EOF'
// Тот же конвейер: lint и test в контейнере python:3.13
pipeline {
    agent {
        docker { image 'python:3.13' }   // выполнять стадии внутри контейнера из этого образа
    }
    options {
        timeout(time: 10, unit: 'MINUTES')   // зависшая сборка не держит агента вечно
    }
    environment {
        PIP_CACHE_DIR = '.cache/pip'   // у пользователя контейнера нет домашней папки, кэш кладём в проект
    }
    stages {
        stage('Lint') {
            steps {
                // venv в рабочем каталоге: у пользователя контейнера нет прав писать в /
                sh '''
                    python -m venv .venv
                    . .venv/bin/activate
                    pip install -r requirements-dev.txt
                    make lint
                    ruff check .
                '''
            }
        }
        stage('Test') {
            steps {
                sh 'make test'
            }
        }
    }
}
EOF
git add Jenkinsfile && git commit -m "ci: Jenkinsfile для Jenkins (declarative, agent docker)"
git push -u origin ci/gitlab

Разбор: pipeline { ... } весь конвейер; agent { docker { image ... } } где выполнять; options общие настройки (timeout 10 минут); environment переменные окружения для всех стадий; в стадии Lint блок sh '''...''' выполняет пять команд по порядку: python -m venv .venv создаёт окружение, . .venv/bin/activate включает его (точка в начале это команда «выполни файл в текущей оболочке», после неё ruff находится в PATH), затем установка, make lint и ruff check .. Стадия Test вызывает make test, окружение ей не нужно: тесты используют только стандартную библиотеку, а python3 и make в образе есть.

  1. В Jenkins: New Item, имя notes, тип Pipeline, потом Definition: Pipeline script from SCM, SCM: Git, Repository URL: https://github.com/<github-user>/notes.git, Branch: */ci/gitlab, Script Path: Jenkinsfile. Save, Build Now. Репозиторий должен быть публичным, тогда ключи не нужны. Открой сборку и выбери Console Output.

Что должно получиться: в Console Output. Ниже команды в порядке их выполнения; так печатает sh (каждую команду с + Jenkins показывает сам), а выводом команд служит то, что мы получили при запуске тех же команд в python:3.13 под пользователем без прав root. Служебные строки Jenkins ([Pipeline] ..., Started by user ..., запуск контейнера) в твоём логе будут между ними:

[Pipeline] { (Lint)
+ python -m venv .venv
+ . .venv/bin/activate
+ pip install -r requirements-dev.txt
Successfully installed ruff-0.16.9
+ make lint
python3 -m py_compile app.py test_app.py
lint: синтаксис в порядке
+ ruff check .
All checks passed!
[Pipeline] { (Test)
+ make test
python3 -m unittest -v
...
Ran 6 tests in 0.665s

OK
Finished: SUCCESS

Как читать вывод: строки с + это выполняемые команды; [Pipeline] { (Lint) границы стадий; Finished: SUCCESS итог. Версия ruff и время тестов у тебя будут свои. Если стадии красные, читай лог вверх до первой строки с ошибкой: остальное каскад.

Объясни себе:

  • Кто в этой схеме controller, а кто agent? Где физически выполнился ruff?
  • Зачем в Jenkinsfile venv, а в .gitlab-ci.yml его нет?
  • Что сделал бы посторонний, получив доступ к Jenkins с примонтированным docker.sock?

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

  • docker: not found: в образе Jenkins нет клиента Docker; собери образ с docker-cli, как в шаге 1. Проверено на настоящем образе jenkins/jenkins:lts-jdk21: sh: 1: docker: not found.
  • permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: у пользователя jenkins нет группы сокета; добавь --group-add "$(stat -c %g /var/run/docker.sock)".
  • Missing required section "stages" или Expected a step: синтаксис declarative; сверь скобки и что steps лежат внутри stage.
  • No such DSL method 'docker' found among steps: не установлен плагин Docker Pipeline; пересобери образ с docker-workflow.

Если нейросеть написала Jenkinsfile, сверь конструкции с разделом про Jenkins и не копируй из ответа токены и пароли, подставляй их через учётные данные Jenkins.

Задание 4. Шаг проекта: два конвейера рядом с GitHub

Цель: оформить .gitlab-ci.yml и Jenkinsfile как обычное изменение через Pull Request в GitHub и убедиться, что основной ci.yml их не ломает.

Предскажи: в Pull Request добавляются только .gitlab-ci.yml и Jenkinsfile, код не меняется. Запустится ли GitHub Actions и каким будет результат?

Ответ

Запустится (триггер pull_request срабатывает на любой PR) и будет зелёным: ruff и unittest те же, а новые файлы Python не содержат. GitHub не читает .gitlab-ci.yml и Jenkinsfile, они просто лежат в репозитории.

Шаги:

  1. Убедись, что ветка ci/gitlab содержит оба файла, и открой Pull Request на GitHub. gh pr create это команда GitHub CLI (--base main куда вливать, --title заголовок, --body описание):
cd ~/notes
git status --short
git log --oneline -3
gh pr create --base main --title "ci: конвейер для GitLab CI и Jenkins" \
  --body "Тот же lint и test на других платформах. GitHub Actions остаётся основным."
  1. Дождись зелёных lint и test в Actions, влей PR (Squash and merge: все коммиты ветки в один), обнови локальную main:
git switch main
git pull
ls -a | grep -E 'gitlab-ci|Jenkinsfile'
  1. Убедись, что три конвейера делают одно и то же. grep -n ищет строки по образцу и печатает их номера; \| в образце означает «или»:
grep -n 'ruff check\|make lint\|make test\|unittest' .github/workflows/ci.yml .gitlab-ci.yml Jenkinsfile

Что должно получиться:

.gitlab-ci.yml
Jenkinsfile
.github/workflows/ci.yml:...:        run: ruff check .
.github/workflows/ci.yml:...:        run: python -m unittest -v
.gitlab-ci.yml:...:    - make lint
.gitlab-ci.yml:...:    - ruff check .
.gitlab-ci.yml:...:    - make test
Jenkinsfile:...:                    make lint
Jenkinsfile:...:                    ruff check .
Jenkinsfile:...:                sh 'make test'

Как читать вывод: первые две строки результат ls; остальные это файл:номер строки:строка. Номера у тебя другие. Обрати внимание, что у GitHub, GitLab и Jenkins команды не совпадают буквально: в ci.yml (урок 3.3) написан прямой вызов python -m unittest -v, а в двух новых файлах make test. Результат один, но правило теперь записано в двух местах: Makefile и ci.yml. Состояние проекта после урока: в ~/notes GitHub Actions, .gitlab-ci.yml и Jenkinsfile, app.py не менялся (v3), тег остаётся v0.2.0. Эталон: project/notes.

Объясни себе:

  • Где живёт «источник истины» про то, что значит «проверка прошла»: в каждом файле отдельно или в целях make lint и make test?
  • Что произойдёт, если завтра поменять правило линтера только в ci.yml?

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

  • fatal: 'gitlab' does not appear to be a git repository: нет remote gitlab; выполни git remote add gitlab ... из задания 1.
  • make: command not found в логе конвейера: образ без make (например python:3.13-slim); используй python:3.13 или добавь установку пакета make.

Сломай и почини

Сборка красная или не стартует. Выбери одну поломку из трёх командой shuf -i 1-3 -n 1 (она печатает случайное число от 1 до 3), внеси её скриптом и не открывай разбор. Скрипт скачивается и запускается одинаково в любом уроке, без sudo, потому что правит файлы твоего пользователя и контейнер jenkins:

curl -fsSL -o /tmp/break-3.6.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/3.6/break.sh
bash /tmp/break-3.6.sh 1     # вместо 1 подставь выпавший номер: 1, 2 или 3

Сценарии 1 и 2 создают в ~/notes ветку break/3.6 с коммитом-поломкой. Отправь её и открой merge request: git push -o merge_request.create -u gitlab break/3.6 (без MR конвейера не будет: так работают rules из задания 2). Для сценария 3 запусти в Jenkins сборку. Скрипт требует, чтобы задания 1-3 были выполнены и чтобы ты стоял на чистой ветке (git status пуст).

Симптом

В зависимости от номера ты увидишь одно из трёх:

  • pipeline на GitLab не стартует и висит в pending;
  • job на GitLab красная в первую секунду, лог короткий;
  • Jenkins падает сразу после Started by user, до стадии Lint.

Гипотезы

Запиши минимум три причины до проверки. Например: нет подходящего раннера; неверное имя образа; нет клиента Docker; отступ YAML; нет прав на сокет; кончились минуты. Отсортируй по вероятности и по цене проверки.

Проверки

Иди от дешёвого к дорогому: сначала текст ошибки в интерфейсе (он обычно точно называет причину), затем Settings, CI/CD, Runners (онлайн ли раннер, какие у него теги), затем docker exec gitlab-runner gitlab-runner list и docker logs gitlab-runner --tail 30. Для Jenkins: docker exec jenkins docker version и docker logs jenkins --tail 50. Дополнительно git diff main..break/3.6 покажет, что именно изменил скрипт, но так ты подглядишь ответ: сначала диагностика, потом diff.

Исправление

Разбор трёх сценариев

1. Тег, которого нет у раннера. Скрипт заменил в job tags: [docker] на tags: [docker-arm], а раннер зарегистрирован только с тегом docker. Симптом: This job is stuck because you don't have any active runners online with any of these tags assigned to them: docker-arm. Починка: вернуть тег в файле (или добавить docker-arm раннеру, если он и правда нужен). Урок: pending без ошибки означает «нет подходящего исполнителя», а не «сломан код»; про то же говорят настройки раннера (теги, «Run untagged jobs»).

2. Неверный образ в job. В image: опечатка python:3.13m. Симптом: ERROR: Job failed: failed to pull image "python:3.13m" with specified policies [always]: ... manifest unknown. Проверка до пуша: docker pull python:3.13m на своей машине даёт Error response from daemon: ... not found. Починка: исправить имя. Урок: сообщение точно называет причину, читай его до конца.

3. Jenkins без клиента docker. Скрипт переименовал /usr/bin/docker в /usr/bin/docker.off внутри контейнера jenkins: получилось то же, что в обычном образе jenkins/jenkins без docker-cli. Симптом: docker: not found в логе до первой стадии (не на Lint, а раньше, при запуске контейнера-агента). Проверка: docker exec jenkins docker version даёт ошибку. Починка: вернуть файл (bash /tmp/break-3.6.sh fix) или собрать образ из задания 3. Урок: agent { docker } требует клиента Docker на самом агенте, а не в целевом образе.

Вернуть всё как было: bash /tmp/break-3.6.sh fix. Команда безопасна при повторном запуске: удаляет ветку break/3.6 (возвращает на исходную ветку) и возвращает docker в контейнер jenkins. Если ветку отправляли в GitLab, удали и её: git push gitlab --delete break/3.6.

ИИ в помощь

Нейросеть быстро переведёт конвейер с одной платформы на другую и объяснит незнакомый синтаксис, но твоих раннеров и настроек не видит: файлы ты показываешь ей сам, без секретов. Общие правила: ИИ-помощник.

Задача: перевести workflow GitHub Actions в .gitlab-ci.yml.

Вот мой workflow GitHub Actions:
<вставь ci.yml>
Переведи его в .gitlab-ci.yml для GitLab CI с docker executor: stages, image, script, cache.
Составь таблицу соответствия: что в исходном файле, во что превратилось, чего в GitLab нет или оно работает иначе.

Проверь ответ: прогони результат в редакторе GitLab (CI Lint) и сверь таблицу с разделом «Три платформы, три словаря». Типичная ошибка нейросетей: перенести ключи один в один (runs-on, uses), которых в GitLab нет, и забыть про stages.

Задача: разобрать, почему job висит в pending.

Моя job в GitLab CI висит в статусе pending, раннер зарегистрирован.
Вот фрагмент .gitlab-ci.yml с tags:
<вставь job>
и вывод gitlab-runner list:
<вставь вывод, токены замени на ...>
Объясни, что может мешать job найти раннер, и в каком порядке это проверять.

Проверь ответ: проверь теги job и раннера и опцию «Run untagged jobs» по разделу про runner. Типичная ошибка: нейросеть советует перезапустить сервер или отключить теги, не заметив, что теги job и раннера просто не совпадают.

Задача: проверить конвейер на опасные места.

Вот мой .gitlab-ci.yml и описание раннера:
<вставь файл и вывод gitlab-runner list>
Найди места, где секреты могут попасть в лог, где подключён docker.sock и где конвейер запускается на чужом коде из форков.
Для каждой проблемы объясни риск и предложи минимальное исправление.

Проверь ответ: проверь каждое замечание по разделу «Безопасность CI»: масочные переменные, docker.sock, форки. Типичная ошибка: нейросеть советует chmod 666 /var/run/docker.sock, это открывает root-дверь всем пользователям машины.

Словарик урока

Термин Простыми словами
Pipeline (конвейер) цепочка автоматических проверок, запускаемая при событии в репозитории
Job одно задание конвейера: среда и список команд
Stage (этап) группа jobs; этапы идут друг за другом, jobs внутри параллельно (в Jenkins stage это и есть единица работы)
YAML формат настроек, где структуру задают отступы и дефисы
Runner (раннер) программа, которая забирает jobs у сервера GitLab и выполняет их
Executor режим работы раннера: docker, shell, kubernetes
Tags (теги) метки у job и раннера: job берёт только раннер с нужными тегами
Pending job ждёт исполнителя, никто ещё не взялся
Failed / skipped / canceled Упала (ненулевой код) / не запускалась (этап перед ней упал или не подошли rules) / остановлена руками или новым коммитом
Параллельный запуск при переезде Старый и новый конвейеры идут на одних коммитах, результаты сравнивают, потом старый отключают
Shared runner / свой runner Раннер платформы для всех проектов / раннер, который ты поставил сам для своего проекта
Token (glrt-...) секретный пароль раннера для связи с GitLab
Rules условия, при которых job создаётся (например, только для merge request)
Merge request (MR) запрос на слияние ветки в GitLab, то же, что Pull Request на GitHub
Cache (кэш) папка, которую платформа сохраняет между запусками для ускорения; может пропасть
Artifact (артефакт) файлы-результат job, которые сохраняют и передают дальше
Image (образ) готовый набор файлов и программ, из которого создают контейнер
Container (контейнер) запущенная из образа изолированная мини-система
Controller главный сервер Jenkins: настройки, история, раздача заданий
Agent узел Jenkins, который исполняет команды сборки
Jenkinsfile файл конвейера Jenkins в репозитории
Declarative pipeline строгий диалект Jenkinsfile со структурой pipeline { ... }
Groovy язык программирования, на котором написан Jenkinsfile
JENKINS_HOME каталог, где Jenkins хранит всё своё состояние
Plugin (плагин) расширение Jenkins; нуждается в обновлениях
Workspace рабочая папка сборки на агенте
docker.sock файл-сокет, через который управляют демоном Docker; доступ к нему почти root
masked / protected флаги переменных GitLab: спрятать в логах / отдавать только защищённым веткам

Вопросы с собеседований

Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.

1. [middle] [часто] Чем artifacts отличаются от cache в GitLab CI?

Ответ

artifacts - результат job: отчёт, бинарник, собранный каталог. GitLab сохраняет его, отдаёт следующим стадиям и даёт скачать, срок жизни задаю через expire_in. cache нужен для ускорения: например каталог с зависимостями, ключ строю от lock-файла. У кеша нет гарантий: на другом runner его может не оказаться, поэтому job должен работать и без него. Передаю данные между job артефактами, ускоряю повторные сборки кешем.

Что хотят услышать: artifacts для передачи результата между job и скачивания, cache для ускорения зависимостей, кеш не гарантирован, expire_in, ключ кеша от lock-файла.

Красный флаг: «Это одно и то же»; рассчитывает на кеш как на передачу данных между стадиями.

2. [junior] [часто] [на скорость] Чем GitLab CI отличается от GitHub Actions по устройству?

Ответ

В GitLab одна платформа держит код, реестр образов и конвейеры, а весь конвейер описан одним .gitlab-ci.yml вокруг stages и jobs; переиспользование делают через include (подключение чужого файла) и шаблоны. В Actions несколько workflow-файлов и готовые actions из каталога. Идея та же: событие, чистая среда, команды, статус.

Что хотят услышать: соответствие терминов (workflow и pipeline, secrets и variables), stages против needs, include.

Красный флаг: «GitLab лучше, Actions хуже» без аргументов; не знает, где хранится файл конвейера.

3. [junior] [часто] Что такое runner и executor? Чем docker executor отличается от shell?

Ответ

Runner это программа, которая берёт job у сервера и запускает её. Executor определяет способ: docker запускает каждую job в новом контейнере из образа (чистота, воспроизводимость), shell выполняет команды прямо на машине раннера (остаются следы прошлых сборок, всё зависит от установленного на хосте, чужая job получает доступ к машине).

Что хотят услышать: чистая среда на каждую job, воспроизводимость, риски shell, kubernetes executor как вариант.

Красный флаг: не различает сервер GitLab и раннер.

4. [junior] Pipeline в GitLab висит в pending и не стартует. Что делаешь?

Ответ

Открываю job: если написано stuck ... no active runners online with any of these tags, значит подходящего раннера нет. Смотрю Settings, CI/CD, Runners: есть ли раннеры онлайн, какие у них теги, включён ли запуск job без тегов, не выключен ли раннер. Сверяю tags: в job с тегами раннера. Если раннер свой, проверяю его процесс и логи (docker logs gitlab-runner).

Что хотят услышать: pending это про отсутствие исполнителя, а не про код; теги; общий (shared) и свой (project) раннер; проверка gitlab-runner list и логов.

Красный флаг: «перезапущу pipeline несколько раз» или «перепишу скрипт» без взгляда на раннеры.

5. [junior] Job упала на pip install в CI, а локально всё работает. Действия?

Ответ

Сначала читаю первую ошибку в логе. Затем воспроизвожу в той же среде: docker run --rm -it -v "$PWD":/w -w /w python:3.13 bash и повторяю команды внутри. Обычно причина в версии Python, недостающей системной библиотеке или файле, которого нет в репозитории (он в .gitignore).

Что хотят услышать: воспроизвести в том же образе, что и в CI; сравнить версии; «у меня работает» не аргумент.

Красный флаг: правит версию в конвейере наугад, пока не позеленеет.

6. [junior] Где хранить токен для деплоя в GitLab CI?

Ответ

В CI/CD Variables проекта или группы с флагами masked и protected: значение не попадёт в логи и будет доступно только защищённым веткам. Не в .gitlab-ci.yml и не в коде. Лучше короткоживущие токены и внешний менеджер секретов (например, Vault: хранилище секретов, тема 9).

Что хотят услышать: masked, protected, ограничение по окружениям, ротация (регулярная замена); секрет попал в историю git, значит его считают скомпрометированным.

Красный флаг: «положу в репозиторий, он же приватный».

7. [junior] Pipeline идёт 15 минут. Как ускорить, не теряя проверок?

Ответ

Сначала смотрю, что именно долго. Кэширую зависимости (cache: с ключом по файлу requirements*.txt), беру лёгкий образ, независимые job кладу в один этап, чтобы шли параллельно, добавляю needs, чтобы не ждать этап целиком, тяжёлое запускаю только по rules (MR, main).

Что хотят услышать: сначала измерить; кэш против artifacts; параллельность; rules и needs.

Красный флаг: «уберу тесты» или «добавлю раннеров», не выяснив узкое место.

8. [middle] Jenkins-сборка с agent { docker } падает: docker: not found или permission denied ... docker.sock. Разбор?

Ответ

Первое: на агенте нет клиента Docker или плагина Docker Pipeline. Второе: пользователь Jenkins не в группе, которой принадлежит сокет. Проверяю docker version от имени jenkins на агенте и чиню образом с клиентом и группой сокета. Отдельно поднимаю вопрос безопасности: доступ к docker.sock это фактически root на хосте, для боевого Jenkins нужны отдельные агенты или сборка без прав root (rootless: демон Docker работает от обычного пользователя, тогда даже вырвавшись из контейнера, злоумышленник не root).

Что хотят услышать: различие двух ошибок, проверка от нужного пользователя, риск docker.sock, отдельные агенты.

Красный флаг: chmod 666 /var/run/docker.sock как решение.

9. [middle] Сервер Jenkins умер, диск потерян. Что будет с конвейерами и как к этому готовиться?

Ответ

Если конвейеры лежат в Jenkinsfile в репозитории, они живы: нужно восстановить сервер и подключить репозитории. Потеряются история сборок, credentials (хранилище паролей Jenkins) и настройки из JENKINS_HOME. Готовиться: бэкап JENKINS_HOME (или тома), JCasC (Jenkins Configuration as Code: настройки Jenkins описаны YAML-файлом в репозитории и применяются автоматически), плагины списком в коде (в Dockerfile, как в задании 3), секреты в Vault, а не только в Jenkins.

Что хотят услышать: Pipeline as code, JCasC, бэкап, воспроизводимый образ, учёт плагинов.

Красный флаг: «настрою заново руками, это же быстро».

10. [middle] Тебя просят перевести конвейеры с Jenkins на GitLab CI. План?

Ответ

Инвентаризация jobs: что нужно, что мёртвое. Раскладываю по классам: сборка, тесты, деплой, ночные. Переношу по одной, простые первыми, запускаю обе системы параллельно и сравниваю результат. Секреты переношу в CI/CD Variables или Vault. Проверяю эквивалентность (те же команды и версии, лучше через общие цели make, как в этом уроке), выключаю Jenkins job после недели стабильной работы.

Что хотят услышать: параллельная работа, поэтапность, замена плагинов, секреты, откат, критерий «готово».

Красный флаг: «в выходные перепишу всё сразу».

11. [middle] Merge request из форка запускает конвейер, где есть секреты деплоя. Что не так?

Ответ

Чужой код в MR может вывести секреты в лог или отправить наружу. Защита: секреты только protected и только для защищённых веток и тегов; MR из форка не получает protected-переменные; конвейеры форков идут на отдельных раннерах без доступа во внутреннюю сеть; деплой только с main и по тегам через rules.

Что хотят услышать: protected variables, изоляция раннеров, rules для деплоя, ревью изменений в файле конвейера.

Красный флаг: «у нас все свои, доверяем».

12. [junior] [на скорость] Как выполняются stages и jobs в GitLab CI?

Ответ

Stages идут последовательно в порядке, заданном в stages:. Jobs одной stage запускаются параллельно, если хватает runner’ов. Если job упала, следующая stage по умолчанию не стартует. Ключ needs: позволяет job стартовать сразу после нужных ей jobs, не дожидаясь всей stage, получается граф зависимостей (DAG). Для некритичных проверок ставлю allow_failure: true, но не злоупотребляю.

Что хотят услышать: stages последовательно, jobs в stage параллельно, needs, allow_failure.

Красный флаг: Считать, что jobs в разных stages идут параллельно по умолчанию.

13. [junior] [на скорость] Чем rules отличается от only/except и как запустить job только для merge request и для main?

Ответ

only/except старый синтаксис, GitLab рекомендует rules, его и использую. Он задаёт список условий, первое подошедшее решает, запускать ли job. Для MR и ветки по умолчанию пишу: - if: $CI_PIPELINE_SOURCE == "merge_request_event" и - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH. Если ни одно условие не подошло, job не создаётся. Чтобы не получать дубли пайплайнов (для ветки и для MR), условия продумываю заранее.

Что хотят услышать: rules с if, переменные CI_PIPELINE_SOURCE и CI_COMMIT_BRANCH, only/except устарел.

Красный флаг: Копировать only/except из старых примеров, не зная порядка их работы.

14. [middle] Как сделать откат Helm-релиза ручным шагом в GitLab CI?

Ответ

Отдельная job в стадии deploy с when: manual: пайплайн её не запускает сам, человек нажимает кнопку. Команда: helm rollback notes $REVISION --wait --timeout 5m, а ревизию смотрят заранее через helm history notes. Номер ревизии можно задать переменной при запуске job вручную. Чтобы незапущенная job не блокировала пайплайн, нужен allow_failure: true: у job с when: manual на верхнем уровне это значение по умолчанию, а внутри rules по умолчанию false, и его задают явно. Доступ ограничивают защищённым окружением (environment: production) и защищёнными переменными с доступом к кластеру. Откат Helm возвращает манифесты, но не данные в БД и не миграции.

Что хотят услышать: when: manual, helm rollback с ревизией и --wait, ограничение доступа, откат не трогает данные.

Красный флаг: откат запускается автоматически на любой упавший пайплайн или делается руками kubectl edit с ноутбука.

Проверено на версиях

Проверено (Mac, Docker 29.6.2, образы под arm64; практика курса рассчитана на Ubuntu):

  • Python 3.13.15, GNU Make 4.4.1, ruff 0.16.9 (образ python:3.13): make lint, ruff check ., make test из заданий 1 и 3 выполнены в чистом контейнере, в том числе под пользователем без прав root с venv в рабочей папке (как в Jenkins); python:3.13-slim без make (sh: 1: make: not found).
  • app.py v3 и test_app.py из урока 1.6: 6 тестов, OK; запуск без NOTES_DATA в чистом контейнере даёт {"error": "storage"} (код 500).
  • GitLab Runner 19.4.1 (образ gitlab/gitlab-runner:ubuntu-v19.4.1): --version, флаги register, ответ на неверный токен (Verifying runner... is not valid), формат list на тестовой конфигурации.
  • Jenkins 2.580.1 LTS (jenkins/jenkins:2.580.1-lts-jdk21, Debian 13): образ из задания 3 собран, docker есть (docker-cli 26.1.5), плагины workflow-aggregator 608, git 5.10.1, docker-workflow 653; в обычном образе docker: not found; пакет docker.io в Debian 13 клиента не содержит.
  • .gitlab-ci.yml: yq v4.54.1 (разбор YAML) и JSON-схема GitLab (check-jsonschema): корректный файл проходит, опечатки в script и rules ловятся.
  • Скрипт поломок: shellcheck 0.11.0 без замечаний, сценарии 1-3 и fix (по два запуска) прогнаны на Ubuntu 24.04 в контейнере; сценарий 3 на образе из задания 3, запущенном с командой sleep вместо Jenkins.

Не проверялось: сам GitLab (pipeline, merge request, регистрация раннера с настоящим токеном, тексты в интерфейсе) и запуск Jenkins с прогоном Jenkinsfile не выполнялись (общая память Docker). Jenkinsfile сверен с документацией declarative pipeline, но парсером Jenkins не проверен; выводы GitLab и Jenkins в заданиях 1 и 3 собраны из прогона тех же команд в контейнере, оформление платформ приведено по документации. Тексты ошибок GitLab и Jenkins в типичных ошибках взяты из документации, кроме docker: not found и Verifying runner... is not valid, которые получены на практике. Точные версии актуальны на 30.09.2026.

Итог урока: ты умеешь

  • умею перенести конвейер lint и test с Actions на .gitlab-ci.yml и запустить его
  • умею объяснить связь job, tags и runner и найти, почему job висит в pending
  • умею зарегистрировать свой GitLab Runner с docker executor
  • умею ограничить запуск конвейера через rules
  • умею поднять Jenkins в Docker с клиентом Docker и плагинами
  • умею написать declarative Jenkinsfile с agent { docker }
  • умею вызывать в конвейерах одни и те же цели make, чтобы правила проверки жили в одном месте
  • умею сравнить GitHub Actions, GitLab CI и Jenkins по словарю и по цене обслуживания
  • умею назвать риск монтирования docker.sock и защитить секреты от MR из форков

Дальше: Тема 4: Docker и Compose

Проверь себя

Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.

Тест работает с включённым JavaScript.

тема 3 урок 3.6 4 ч курс 0/0 ← → уроки