Реальный пайплайн: 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 делает провальный деплой красным, а не молча зелёным.
Проверьте себя
1. Почему в джобах build-image и деплоя стоит before_script: []?
AЧтобы ускорить git clone
BЧтобы отменить унаследованный из default npm ci, который этим джобам не нужен
CЧтобы отключить кеш
DЭто обязательно для всех джоб
2. Зачем тегировать образ значением CI_COMMIT_SHORT_SHA, а не latest?
Alatest быстрее
BТег по SHA даёт точную прослеживаемость и осмысленный откат к конкретному коммиту
CGitLab запрещает latest
DЧтобы образ не кешировался