Реальный пайплайн: lint → test → build → deploy
Собираем всё изученное в один полноценный пайплайн веб-приложения.
Полный пайплайн — связная конфигурация от проверки кода до деплоя на production, использующая стадии, кеш, артефакты, переменные и rules.
До сих пор мы изучали кирпичики поодиночке: стадии, кеш, артефакты, переменные, rules, окружения. Это последний урок раздела, и его задача — собрать всё в единое работающее целое. Реальный пайплайн ценен не отдельными фичами, а тем, как они складываются в осмысленный конвейер, где каждое решение оправдано: почему деплой ручной, почему образ тегируется по SHA, почему сборка ограничена одной веткой. Именно эту связность — а не синтаксис отдельных ключей — и стоит унести из урока.
Договоримся о требованиях к конвейеру заранее, как это делают на реальном проекте. Любой код в любой ветке должен проходить быстрые проверки качества — иначе в основную ветку просочится мусор. Сборка артефакта и тем более деплой не должны запускаться из случайных feature-веток — это и трата ресурсов, и риск. Деплой на staging хочется автоматический, чтобы тестировщики всегда видели свежую версию, а вот выкат на production — только по осознанному нажатию кнопки человеком. И, наконец, между работающим контейнером и конкретным коммитом должна быть однозначная связь, чтобы откат был тривиальным. Держа эти требования в голове, читать конфигурацию станет гораздо осмысленнее.
Что мы строим
Возьмём типичное веб-приложение на Node.js. Конвейер: проверить стиль (lint), прогнать тесты с покрытием (test), собрать Docker-образ и запушить в реестр (build), задеплоить на staging автоматически и на production по кнопке (deploy). Это сборка из кирпичиков всего курса.
Полный .gitlab-ci.yml
stages:
- lint
- test
- build
- deploy
default:
image: node:20-alpine
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
before_script:
- npm ci
lint:
stage: lint
script:
- npm run lint
interruptible: true
test:
stage: test
needs: ["lint"]
script:
- npm test -- --coverage
coverage: '/All files.+?(\d+\.\d+)/'
artifacts:
when: always
reports:
junit: junit.xml
interruptible: true
build-image:
stage: build
needs: ["test"]
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
before_script: []
script:
- /kaniko/executor
--context "$CI_PROJECT_DIR"
--dockerfile "$CI_PROJECT_DIR/Dockerfile"
--destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
deploy-staging:
stage: deploy
needs: ["build-image"]
image: bitnami/kubectl:latest
before_script: []
script:
- kubectl set image deployment/web web=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -n staging
- kubectl rollout status deployment/web -n staging
environment:
name: staging
url: https://staging.example.com
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
deploy-production:
stage: deploy
needs: ["deploy-staging"]
image: bitnami/kubectl:latest
before_script: []
script:
- kubectl set image deployment/web web=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -n prod
- kubectl rollout status deployment/web -n prod
environment:
name: production
url: https://example.com
when: manual
rules:
- if: '$CI_COMMIT_BRANCH == "main"'Разбор по узлам
В default мы один раз задали образ, кеш по lock-файлу и установку зависимостей — джобы lint и test их наследуют. build-image и деплои переопределяют image и сбрасывают before_script: [], потому что им не нужен npm ci. Через needs выстроена цепочка: тест ждёт линт, сборка — тест, деплои — друг друга, образуя ясный DAG. Сборка и деплой ограничены rules на main, так что в feature-ветках идут только проверки. Production — ручной гейт (when: manual) с отслеживаемым окружением.
Почему сборку выполняет kaniko, а не docker build
Обратите внимание: образ собирает не привычный docker build, а kaniko. Это сознательный выбор. Классический docker build внутри CI требует доступа к демону Docker, а это либо привилегированный контейнер (Docker-in-Docker), либо проброс сокета хоста — оба варианта расширяют поверхность атаки: джоба фактически получает root на узле раннера. Kaniko собирает образ в пространстве пользователя, без демона и без привилегий, читая Dockerfile и пушя слои прямо в реестр. На общих раннерах, где ваши джобы соседствуют с чужими, это правильная модель безопасности по умолчанию.
Почему деплой через kubectl set image
Деплой здесь — это одна декларативная команда: kubectl set image меняет образ у Deployment, а kubectl rollout status ждёт, пока новые поды поднимутся и пройдут проверки готовности. Если новый образ не стартует, rollout status вернёт ошибку, и джоба покраснеет — то есть провальный деплой честно отражается статусом пайплайна, а не маскируется зелёным. Это и есть смысл связки: команда выката плюс команда ожидания результата, чтобы CI знал не «я отправил», а «оно действительно поднялось».
Соберём связи джоб в одну картину — так виден весь жизненный цикл от коммита до production.
lint ──> test ──> build-image ──> deploy-staging ──> deploy-production (любая (только (авто) (manual gate) ветка) main)
Эта схема читается как маршрут одного коммита: дешёвые проверки слева доступны всем веткам, а вся правая часть — сборка и выкаты — отпирается только для main, причём последний шаг ждёт человека. Слева направо растёт и цена ошибки, и степень контроля над запуском.
Тег образа = коммит
Образ тегируется $CI_COMMIT_SHORT_SHA, и тот же тег едет в деплой. Это даёт точную прослеживаемость: по работающему контейнеру можно однозначно вернуться к коммиту, а откат — это деплой образа предыдущего SHA.
Сравним это с распространённой, но вредной привычкой тегировать всё как latest. Тег latest — движущаяся мишень: он указывает то на один образ, то на другой, и по контейнеру в проде невозможно сказать, какой именно код в нём работает. Откат превращается в гадание, а две джобы могут случайно затереть образ друг друга. Тег по SHA, наоборот, неизменяем и уникален: каждый коммит порождает ровно один образ, который никогда не перепишется. Откат становится тривиальной операцией — задеплоить образ с известным предыдущим SHA, — и аудит «что сейчас в проде» сводится к чтению одного тега.
Как работает под капотом
На push в feature-ветку GitLab создаст пайплайн, но rules отсекут сборку и деплои — выполнятся только lint и test. На push в main пройдёт вся цепочка до deploy-staging автоматически, а deploy-production останется ждать кнопки. Каждая джоба — чистый контейнер; кеш ускоряет установку, артефакт-отчёт показывает тесты в MR, реестр хранит образ по SHA.
Проследим путь одного изменения целиком, чтобы фичи перестали быть абстракцией. Разработчик пушит в feature-ветку — поднимаются два чистых контейнера node:20-alpine, оба восстанавливают node_modules из кеша по lock-файлу, ставят зависимости через npm ci и гоняют линт и тесты; junit-отчёт прилетает прямо в Merge Request, и ревьюер видит результаты тестов, не открывая логи. После слияния в main те же проверки повторяются, затем kaniko собирает образ и пушит его в реестр под тегом текущего SHA, после чего deploy-staging автоматически выкатывает этот образ в namespace staging и ждёт готовности подов. Production остаётся жёлтой кнопкой: пайплайн зелёный, но финальный шаг исполнится только когда человек осознанно нажмёт «play». Так одна конфигурация обслуживает и быструю обратную связь на ветках, и контролируемый путь до прода.
Частые ошибки
- Забыть
before_script: []в джобах сборки/деплоя — унаследованныйnpm ciупадёт там, где нет package.json-окружения. - Не ограничить деплои
rules— и деплоить из любой ветки. - Тегировать образ
latestвместо SHA — теряется прослеживаемость и осмысленный откат. - Собирать образ через привилегированный Docker-in-Docker вместо безопасного kaniko на общих раннерах.
- Деплоить без
kubectl rollout status— провальный выкат покажется зелёным.
Итоги
- Полный пайплайн собирается из стадий,
default, кеша,needs,rulesи environment. - Проверки идут на любой ветке, сборка и деплой — только на
main; production — ручной гейт. - Тег образа по SHA даёт прослеживаемость и осмысленный откат.
- kaniko собирает образ без привилегий — безопасно для общих раннеров.
kubectl rollout statusделает провальный деплой красным, а не молча зелёным.