Всем привет, меня зовут Сергей Прощаев и в этой статье расскажу про то, почему пайплайны в GitLab CI начинают ползти, хотя раннеры вроде бы настроены «по документации».
Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в ОТУС.
Ситуация, думаю, знакомая. Открываешь Merge Request, ставишь пайплайн, идёшь за кофе. Возвращаешься — а он всё ещё на стадии build, потому что раннер в третий раз за утро выкачивает node_modules с нуля. Разработчики ждут, деплой критичного хотфикса стоит в очереди за прогоном линтера, а в конце месяца приходит счёт за облако, где половина суммы — это простой инстансов, которые крутились вхолостую.

Разберём 6 ошибок, которые чаще всего прячутся в конфигурации раннеров и в.gitlab‑ci.yml. Не абстрактных «делайте хорошо», а тех, что я регулярно вижу в боевых проектах, включая свои собственные грабли. Для каждой — симптом, причина, во что обходится и как чинить. В конце сводная таблица и вывод о том, какой навык эти ошибки на самом деле проверяют.
Сразу про контекст: основной кейс здесь — self‑hosted раннеры с Docker executor на своих серверах, самая массовая история в средних командах. Где уместно, покажу, чем ситуация отличается на Kubernetes.
И сразу оговорю, о чём статья не будет. Здесь не будет советов уровня «добавьте ещё один раннер» или «купите инстанс помощнее». Речь про ситуации, когда инфраструктура уже есть, мощностей в целом хватает, а минуты всё равно утекают — из‑за того, как раннеры и пайплайн сконфигурированы.
Ошибка 1. Кэш есть в.gitlab‑ci.yml, но он ничего не кэширует
Симптом простой и очень обидный: в джобе честно прописан блок cache, но каждый прогон всё равно тянет зависимости заново. Достаточно открыть лог джобы, чтобы это увидеть:
(лог джобы GitLab CI) Checking cache for default-protected... No URL provided, cache will not be downloaded from shared cache server. Successfully extracted cache $ npm ci npm warn tar TAR_ENTRY_INFO ... added 1423 packages in 1m 48s
Строчка No URL provided, cache will not be downloaded — это и есть диагноз: раннер даже не пошёл за общим кэшем, а npm ci честно поставил 1423 пакета с нуля почти за две минуты. И так каждый прогон.
Однажды я потратил почти двадцать минут, пытаясь понять, почему кэш формально есть, а сборка ведёт себя так, будто его нет. Оказалось, что дело в комбинации из двух вещей: key у кэша был статический, а джобы разъезжались по нескольким раннерам, и локальный кэш одного раннера был не виден другим — общего хранилища между ними не было.
Причина в том, что кэш в GitLab CI — это не магия, а конкретный механизм со своими условиями. По умолчанию Docker executor использует локальное хранилище кэша, привязанное к конкретному раннеру: где именно оно лежит, зависит от настроек cache_dir и volumes в config.toml, но суть в том, что оно локальное. Каталоги, объявленные в volumes, сохраняются между сборками на этом раннере — и только на нём. Как только у вас больше одного раннера или вы работаете в облаке с эфемерными контейнерами, локального хранилища недостаточно, кэш нужно выносить в общее место. В Kubernetes это вообще единственный рабочий вариант: локальный кэш пода умирает вместе с подом.
Во что обходится: каждый лишний прогон — это минуты скачивания и, если вы в облаке, реальные деньги за трафик и за время инстанса. На пайплайне, который гоняется десятки, а то и сотни раз в день, набегают десятки часов в месяц впустую. А в неудачный день эти две минуты — ровно та задержка, из‑за которой срочный хотфикс уезжает в прод позже, чем мог бы.
Как чинить:
Первое — осмысленный key. Ключ должен меняться тогда, когда меняются зависимости, и переиспользоваться, когда они те же. Обычно это хэш lock‑файла:
# (YAML, .gitlab-ci.yml) build: cache: key: files: - package-lock.json # ключ пересчитается только при смене зависимостей paths: - .npm/ script: - npm ci --cache .npm --prefer-offline
-
Второе, и это главное для нескольких раннеров — распределённый кэш. Его backend выносится в объектное хранилище (S3, GCS, Azure Blob или собственный MinIO), чтобы все раннеры смотрели в одно место.
Тут важно не путать: распределённый кэш GitLab — это не отдельный сервис, который надо «поднять», а именно backend‑хранилище, которое вы указываете раннеру в конфиге. Для
self-hostedя обычно беру MinIO рядом с раннерами — так нет ни платы за egress, ни зависимости от внешнего S3. Проверить результат легко: в логах джобы после первого прогона появляется строка про загрузку кэша (Downloading cache...), а время стадии со скачиванием зависимостей падает. В том проекте, лог которого я показал выше, установка зависимостей после включения распределённого кэша сократилась с почти двух минут (1m 48s на npm ci с нуля) примерно до 15–20 секунд на восстановление кэша — то есть кратно. Если после включения распределённого кэша второй прогон всё ещё тянет всё заново, значит key или пути указаны неверно, и это тоже видно по логу.
Ошибка 2. Один shared‑раннер на всё, без тегов
Симптом: пайплайны выстраиваются в очередь, хотя сервер вроде бы не загружен. Тяжёлая сборка с интеграционными тестами занимает единственный раннер на десять минут, а за ней в очереди стоят три MR, которым нужно всего лишь прогнать линтер за пятнадцать секунд.
Это классика, которую я называю «CI как супермаркет с одной кассой». Причина — в отсутствии разделения раннеров по типу нагрузки. Все джобы идут в один общий пул, и лёгкие задачи не могут «обогнать» тяжёлые. GitLab решает это тегами: раннер регистрируется с определёнными тегами, а джоба выбирает раннер по тегу.
Механика простая. При регистрации раннеру задаётся набор тегов (например, docker, linux), а джоба в.gitlab‑ci.yml через поле tags выбирает нужный раннер. Дальше вы разводите потоки: лёгкие быстрые джобы — на маленькие раннеры, тяжёлые сборки — на мощные.
# (YAML, .gitlab-ci.yml) lint: tags: [linux, fast] # уходит на лёгкий раннер, не ждёт тяжёлых script: npm run lint integration-test: tags: [linux, heavy] # уходит на мощный раннер с большим лимитом script: ./run-integration.sh
Отдельно про параметр concurrent в config.toml и limit у раннера. Это два разных ограничителя: concurrent задаёт глобальный предел одновременных джоб по всем раннерам одного процесса, а limit — максимум для конкретного раннера.
Очень частая недонастройка: concurrent = 1 остался с момента установки, и весь параллелизм убит на корню, сколько бы ядер ни было на машине.
Есть и третий, менее очевидный параметр — request_concurrency. Тут важно не попасться на распространённое заблуждение: он не увеличивает число одновременно выполняемых джоб. По документации GitLab request_concurrency ограничивает количество одновременных HTTP‑запросов раннера к API GitLab за новыми джобами и в основном защищает сервер от лишней нагрузки в моменты простоя.
На максимальный параллелизм он не влияет — если вы упёрлись в потолок джоб, крутить нужно concurrent и limit, а не его. Знать про request_concurrency полезно, но лечит он другую боль.
Во что обходится: искусственная очередь на ровном месте. Разработчики ждут не потому, что не хватает мощностей, а потому, что мощности заняты не тем.
Как чинить: развести раннеры тегами по классам нагрузки и выставить вменяемый concurrent под реальное число ядер. Я обычно начинаю с числа, близкого к количеству vCPU на машине, и дальше корректирую по метрикам очереди. Проверка — время ожидания джобы в очереди (queue time) на дашборде GitLab: после разведения тегов лёгкие джобы перестают стоять за тяжёлыми.
Ошибка 3. Тяжёлый базовый образ там, где хватило бы Alpine
Симптом: львиную долю времени джобы занимает не сама работа, а строчка Pulling docker image.... Особенно больно, если образ — какой‑нибудь полный node или python, который после распаковки весит сотни мегабайт, а то и больше гигабайта, а джобе нужно только запустить линтер.
Причина в том, что каждый pull тяжёлого образа — это сеть, распаковка и время. Когда джоб много, это умножается на их количество. Логичный первый порыв — взять образ полегче, и чаще всего вспоминают про Alpine как самый маленький. Порыв правильный, но с образом всё чуть тоньше, чем «бери самый лёгкий», — к этому вернусь ниже.
Я как‑то от нечего делать в пятницу пересобрал набор CI‑образов в одном проекте, заменив дефолтные «жирные» образы на slim‑варианты и вычистив лишние слои. Экономия на каждом pull оказалась заметной, а на масштабе всех джоб за день — очень существенной.
Как чинить: подбирать образ под задачу, а не брать самый универсальный. Но здесь есть важный нюанс на 2026 год, из‑за которого «просто возьмите Alpine» уже не считается универсальным best practice. Alpine использует musl вместо glibc, и на реальных нагрузках это временами стреляет: приложения с нативными зависимостями (Java, Python‑пакеты с C‑расширениями, native‑модули Node) под musl либо работают медленнее, либо требуют пересборки из исходников, либо ведут себя не так, как на glibc. Отдельная ловушка — multi‑stage сборка: если собрать бинарник под glibc и скопировать его в Alpine, можно получить загадочные ошибки загрузчика или молча сломанное поведение, потому что libc разные.
Поэтому правило такое: если приложение зависит от glibc или тянет нативные библиотеки, безопаснее брать slim‑образ (Debian‑based, тот же glibc), а не Alpine. Alpine хорош для статически скомпилированных сервисов (Go, Rust) и там, где вы осознанно проверили совместимость. Для линтера, тестов на чистом языке, простых скриптов Alpine обычно подходит — но проверьте на своём стеке, прежде чем катить это на все джобы.
И ещё: фиксируйте версию образа, а не берите плавающий тег. node:22 завтра может стать другим, node:22.17-alpine3.22 — воспроизводим.
# (YAML, .gitlab-ci.yml) lint: image: node:22.17-alpine3.22 # для линтера musl не мешает; версия зафиксирована script: npm run lint build-native: image: node:22.17-slim # есть native-модули → slim на glibc, а не Alpine script: npm run build
Второй рычаг — политика скачивания образа. Если раннер каждый раз лезет за образом в реестр, даже когда он уже есть локально, это лишний сетевой поход. В config.toml за это отвечает pull_policy: значение if-not-present берёт локальный образ, если он уже скачан. В конфигурации Docker executor можно задать pull_policy = ["if-not-present"], чтобы не тянуть образ повторно, если он уже есть на хосте. Тут есть нюанс: для образов с плавающим тегом latest это может законсервировать устаревшую версию, поэтому для базовых образов лучше фиксировать конкретный тег и обновлять его осознанно.
Для команд, которые часто тянут образы из внешних реестров, ещё сильнее помогает локальный pull‑through кэш (прокси реестра) рядом с раннерами. В боевом кейсе, о котором расскажу ниже, именно локальный прокси реестра стал одним из рычагов ускорения.
Проверка: смотрим на длительность шага pull в логе. Если она упала с минуты до секунд — образ и политика подобраны верно.
Ошибка 4. Всё в одну стадию: нет параллелизма и нет DAG
Симптом: пайплайн вытянут в одну длинную колонну. Стадии идут строго друг за другом, test ждёт полного завершения build, хотя половина тестов не зависит от артефактов сборки. Пять минут там, три здесь, и суммарно MR проверяется двадцать минут вместо возможных семи.
Причина — недоиспользование параллелизма. GitLab по умолчанию гоняет стадии последовательно, но внутри стадии джобы идут параллельно, а зависимости между джобами можно описать явно через needs, построив граф (DAG). Тогда джоба стартует не тогда, когда закончилась вся предыдущая стадия, а как только готовы конкретно её зависимости.
Есть два независимых рычага.
Первый — распараллелить тяжёлый набор тестов через parallel. Тут важно понимать, что именно делает этот ключ, потому что на него часто возлагают лишние надежды.
parallel: 4 сам по себе не ускоряет тесты и не раскидывает их по раннерам магически — он просто запускает четыре одинаковых экземпляра джобы. Распределение тестов между ними вы пишете сами, ориентируясь на переменныеCI_NODE_INDEX и CI_NODE_TOTAL: каждый экземпляр берёт свою долю тестовых файлов. Без этой логики все четыре экземпляра честно прогонят весь набор целиком, и вы получите не ускорение, а учетверённую нагрузку.
# (YAML, .gitlab-ci.yml) test: stage: test parallel: 4 # 4 экземпляра; какие тесты кому — решает сам скрипт script: ./split-and-run-tests.sh # внутри: разбивка по CI_NODE_INDEX/CI_NODE_TOTAL
Второй рычаг —
needsдля построения DAG, чтобы независимые ветки пайплайна не ждали друг друга. Через needs можно не только выстроить порядок, но и передать артефакты между джобами: джоба заберёт результаты конкретной предыдущей, а не будет ждать всю стадию:
# (YAML, .gitlab-ci.yml) build: stage: build script: ./build.sh artifacts: paths: [dist/] deploy-docs: stage: deploy needs: - job: build artifacts: true # берём dist/ из build, стартуем сразу после него script: ./deploy-docs.sh
Порядок действий тут важен. Оптимизацию стоит начинать с изменений максимального эффекта — кэширования и параллелизации, — а затем постепенно вводить DAG‑зависимости, кэш слоёв Docker и остальное. То есть сначала кэш (ошибка 1) и параллелизм, и только потом тонкая настройка графа.
Во что обходится: время и контекст разработчика. Пока MR проверяется двадцать минут вместо семи, автор успевает переключиться на другую задачу, а на ревью возвращается уже с остывшей головой. Длинный пайплайн бьёт не только по счёту за облако, но и по скорости самой команды.
Как чинить: разнести независимые джобы по параллельным веткам через needs, а долгие тесты — через parallel. Проверка наглядная: во вкладке с визуализацией пайплайна видно, что джобы идут «веером», а не в одну колонну, и общее время (duration) пайплайна падает.
Ошибка 5. Docker‑in‑Docker с privileged ради сборки образов
Симптом: джоба сборки образа медленная, пугает безопасников словом privileged в конфиге, а половина слоёв пересобирается каждый раз с нуля, потому что кэш слоёв между прогонами не переживает.
Это, пожалуй, самая нагруженная легаси‑темами ошибка, и здесь важно свериться с актуальностью, потому что подходы за последние полтора года заметно поменялись. Классический способ собирать Docker‑образы внутри CI — это Docker‑in‑Docker (DinD), который в стандартной конфигурации требует —privileged.
Использование DinD чревато проблемами прежде всего с точки зрения безопасности, поскольку большинство DinD‑конфигураций требуют опции ‑privileged на родительском контейнере, что даёт джобе возможности, близкие к root на хосте — этого по возможности лучше избегать.
Оговорюсь честно, чтобы не упрощать: существует rootless‑вариант DinD. Но тут легко обмануться формулировкой — «rootless» здесь означает, что пользователь внутри контейнера не root.
На практике большинство существующих CI‑конфигураций с rootless DinD всё равно используют privileged, поэтому полностью избавиться от него обычно не удаётся. Полностью без привилегий собирают уже другими инструментами — например, Podman или Buildah с user namespaces. Так что если цель — уйти от privileged совсем, DinD (даже rootless) обычно не тот путь.
Долгое время каноничной заменой был Kaniko — он собирал образы без демона и без privileged. И вот тут ключевой момент на 2026 год: Kaniko больше не вариант по умолчанию. Начиная с января 2025 года Kaniko официально помещён в архив и больше не поддерживается Google Container Tools; GitLab также объявил о полном переходе с Kaniko. Репозиторий заморожен: GoogleContainerTools/kaniko был архивирован на GitHub, баннер сообщает, что репозиторий переведён в режим read‑only. На практике это означает, что новых патчей и исправлений CVE не будет.
Мне как‑то попалась на глаза свежая формулировка от команды, которая проходила ровно этот путь, и она хорошо ставит точку: Google архивировал Kaniko в июне 2025 года, GitLab уже удалил документацию по Kaniko и рекомендует вместо него Buildah или Podman.
Причина проблемы, если убрать историю с инструментами, в одном: сборка образов в CI без демона и без privileged — это отдельная инженерная задача, и выбор инструмента напрямую влияет и на скорость, и на безопасность.
Во что обходится: либо дыра в безопасности (privileged‑раннер даёт джобе возможности, близкие к root на хосте), либо технический долг на мёртвом инструменте, либо медленные сборки без нормального кэша слоёв.
Как чинить на июнь 2026. Если вы всё ещё на Kaniko — планируйте миграцию, варианты называет сам GitLab. Buildah умеет собирать по обычному Dockerfile без демона:
# (Bash, в джобе сборки образа) buildah bud -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" . buildah push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
Только сразу разведём три названия, которые постоянно валят в одну кучу, — это разные категории инструментов, а не прямые взаимозаменяемые аналоги.
BuildKit — это движок сборки образов (он же под капотом современного docker build), заточенный на скорость, параллелизм шагов и продвинутый кэш слоёв.
Buildah — это CLI для сборки OCI‑образов, в том числе по Dockerfile и без демона.
Podman — это контейнерный runtime, демонлесс‑замена самому Docker для запуска контейнеров; а собирать образы он умеет потому, что использует те же библиотеки, что и Buildah (общая экосистема containers/*), а не отдельный докер‑демон.
Грубо: BuildKit отвечает на вопрос «чем быстро собрать», Buildah — «чем собрать без демона из командной строки», Podman — «в чём вообще гонять контейнеры без docker‑демона». Их часто используют вместе, поэтому путать не стоит.
Я бы в новом проекте предпочёл смотреть в сторону BuildKit (ради скорости и кэша слоёв) или Buildah/Podman (ради rootless без privileged) и держать раннеры без привилегий. Отдельно стоит настроить кэш слоёв в реестре, чтобы неизменные слои не пересобирались.
Проверка: privileged из конфига раннера ушёл, а повторная сборка при неизменном Dockerfile переиспользует слои из кэша — это видно по логу, где шаги отмечаются как взятые из кэша.
Ошибка 6. docker+machine для автоскейла в 2026 году
Симптом: облачный автоскейл раннеров работает, но каждый новый инстанс поднимается мучительно долго. Джоба стоит и ждёт, пока провижнится виртуалка, и на пайплайне из многих джоб это накапливается в заметный простой.
Реальный масштаб проблемы хорошо виден в кейсе, который команда описала публично. У них исходно был как раз docker+machine, поднимавший EC2‑инстанс на каждую джобу. При переменной нагрузке CI и неидеальном автоскейле поднятие новой EC2-инстанции на джобу в среднем занимало около 3 минут, а долгое время старта на пайплайнах с большим числом джоб оборачивалось массой простоя, что совсем не радует, когда разработчики ждут критичных деплоев.
И вот здесь — вторая важная сверка с актуальностью. docker+machine — уходящая натура. Executor Docker Machine объявлен устаревшим в GitLab 17.5 и запланирован к удалению в GitLab 20.0 (май 2027); новых фич не добавляют, чинят только критичные баги. Причём поддержка форка гарантирована лишь до определённого горизонта: GitLab полностью поддерживает форк Docker Machine для автоскейла на основных публичных провайдерах как минимум до FY27 Q2 (май‑июнь 2026). То есть ровно на момент, когда пишется эта статья, мы стоим на границе этого срока — и закладываться на docker+machine в новой инфраструктуре уже нельзя.
Что пришло на замену. Для автоскейла на облачных виртуалках теперь используют executor Docker Autoscaler; Docker Machine устарел и запланирован к удалению в GitLab 20.0, поэтому для новых VM‑based автоскейл‑сценариев берут именно Docker Autoscaler. (Если вы гоняете CI в Kubernetes, у вас другая история — там за масштабирование отвечает сам кластер через Kubernetes executor, и docker+machine вам вообще не нужен.) Под капотом у Docker Autoscaler — плагины fleeting: это абстракция над группой автоскейлящихся инстансов с плагинами под облачных провайдеров, Google Cloud, AWS, Azure.
# (TOML, config.toml — фрагмент) [[runners]] executor = "docker-autoscaler" # вместо устаревшего docker+machine [runners.autoscaler] plugin = "aws" capacity_per_instance = 1 max_instances = 10
Как та команда решила проблему простоя — отдельно поучительно: они не просто сменили executor, а пересобрали подход целиком. Изменив несколько ключевых аспектов того, где и как выполняются пайплайны, и добавив распределённое кэширование, они сократили среднюю длительность пайплайна на 70%.
Обратите внимание: 70% дал не один рычаг, а связка — уход от медленного провижнинга инстансов, распределённый кэш и локальный прокси реестра.
Во что обходится ошибка: минуты простоя на холодном старте инстансов плюс риск однажды упереться в удаление устаревшего executor.
Как чинить: для нового автоскейла в облаке брать Docker Autoscaler на fleeting‑плагинах, а не docker+machine. Если вы уже на docker+machine — это не повод паниковать сегодня, но повод ставить миграцию в план на ближайшие кварталы. Проверка — время холодного старта нового раннера и общий queue time под пиковой нагрузкой.
Прежде чем оптимизировать: что измерять и когда остановиться
Тут стоит сделать шаг назад. Все шесть пунктов выше — это лечение, но назначать его вслепую нельзя. Оптимизация CI начинается не с правки конфига, а с измерения: пока вы не видите, на что уходит время, любые изменения — это гадание.
Вот минимальный набор метрик, за которыми я смотрю, прежде чем что‑то крутить, и по которым проверяю результат после:
Pipeline duration — общее время пайплайна от старта до финиша. Верхнеуровневый индикатор: стало лучше или хуже.
Queue time — сколько джоба ждёт свободного раннера. Растёт — проблема в ёмкости или распределении раннеров (ошибки 2 и 6), а не в самих джобах.
Job duration — длительность конкретных джоб. Показывает, какая именно джоба съедает время.
Cache hit rate — как часто кэш реально используется. Низкий — привет, ошибка 1.
Docker pull duration — сколько занимает скачивание образа (ошибка 3).
Registry latency — задержка до реестра, особенно если образы тяжёлые и тянутся извне.
Хорошая новость: queue time, job duration и длительность стадий видны прямо в интерфейсе GitLab, а для остального есть встроенный экспорт метрик раннера в Prometheus. Померили, нашли узкое место, поправили именно его, померили снова — вот и весь цикл.
И отдельно — то, о чём в статьях про оптимизацию почти не пишут. Иногда правильный ответ: не оптимизировать вообще. Если пайплайн проекта укладывается в пару минут и всех устраивает, городить распределённый MinIO, прокси реестра и автоскейлер — это не инженерная зрелость, а оверинжиниринг. Вы потратите неделю на инфраструктуру, которая сэкономит десять секунд и добавит вам же новую систему на поддержку. Сложная оптимизация окупается на больших монорепах, тяжёлых сборках и десятках прогонов в день. На маленьком проекте честнее оставить как есть. Умение не делать лишнего здесь такой же навык, как умение всё правильно настроить.
Сводная таблица
Перед тем как перейти к выводу, соберём всё в одну таблицу — по ней удобно за пять минут проверить свою конфигурацию. Она показана на Рис. 2.

Главная мысль этой схемы: почти все шесть проблем сводятся не к «плохому» GitLab, а к тому, что раннер выполняет лишнюю работу — качает то, что уже качал, ждёт того, что мог бы не ждать, и поднимает то, что можно переиспользовать. Оптимизация CI — это в первую очередь про устранение повторной работы.
Вывод: какой навык на самом деле проверяют эти ошибки
Если посмотреть на все шесть ошибок сверху, видно, что дело почти никогда не в самом GitLab Runner как инструменте. Он честно делает то, что ему сказали. Проблема в том, что ему сказали делать лишнее: скачивать заново, ждать без причины, поднимать тяжёлое ради лёгкого.
По сути, эта группа ошибок проверяет один навык — умение видеть в пайплайне поток работы и находить в нём повторяющиеся и блокирующие участки. Это то же инженерное мышление, что и в оптимизации кода: сначала измерь, где узкое место, потом убери повторную работу, потом распараллель независимое. Не «покрутить случайные галочки в конфиге», а понять, на что конкретно уходит время, и убрать именно это.
И ещё один момент, который для меня оказался важнее всех конкретных приёмов. CI живёт своей жизнью и деградирует со временем: добавили джобу, подросли зависимости, сменилась нагрузка — и вот пайплайн снова ползёт. Оптимизация пайплайнов GitLab CI — это не разовая задача, а постоянный процесс; производительность деградирует со временем, и ревью конфигурации стоит планировать регулярно. Так что настроить раннеры один раз и забыть не получится.
Но если у меня есть один критерий, по которому я пойму, что эта статья сработала, то он такой. Если после её прочтения вы открыли свой.gitlab‑ci.yml и первым делом пошли смотреть не на script, а на queue time, cache hit rate и граф зависимостей — значит, цель достигнута. Дальше — дело техники.
Хотите проверить, насколько уверенно вы ориентируетесь в DevOps-практиках и инструментах? Пройдите вступительный тест: он поможет оценить текущий уровень и увидеть темы, которые стоит разобрать глубже.
А если хотите продолжить разбираться в CI/CD и инфраструктуре, вот ещё несколько материалов по теме:
4 антипаттерна CI-автоматизации, из-за которых команда делает работу за ботов — о ручной работе и процессах, которые стоило автоматизировать.
Self-service деплой: как перестать ждать DevOps и ускорить команду — как убрать очереди и лишние согласования из доставки изменений.
Kubernetes: архитектура и абстракции — полный гайд — базовые механизмы Kubernetes, которые пригодятся при работе с Kubernetes executor и масштабированием раннеров.