GitLab CI: lint, test и сборка образов

GitLab CI: lint, test и сборка образов

CI (Continuous Integration) - автоматизация рутины: тесты, линтинг, сборка образов, деплой. GitLab CI запускает пайплайн при каждом пуше - робот делает скучное за тебя.

Структура пайплайна от языка не зависит: stages, jobs, образ на джобу, артефакты между этапами. Меняются только команды внутри script и базовый образ - примеры для Go, PHP и Python ниже во вкладках.

Как работает GitLab CI

git push → парсинг .gitlab-ci.yml → pipeline разбрасывает stages lint/test/build веером

Пайплайн = набор stages (этапов), выполняемых последовательно. Внутри stage - jobs (задачи), которые могут выполняться параллельно.

GitLab CI pipeline: lint -> test -> build -> deploy, артефакты между stages, jobs внутри stage параллельно

.gitlab-ci.yml - базовая структура

Каркас одинаковый для всех: этапы, а внутри - джобы бэкенда и фронтенда.

stages:
 - lint
 - test
 - build

# ---------- Frontend: одинаково при любом бэкенде ----------
frontend:lint:
  image: node:20-alpine
  stage: lint
  script:
    - cd frontend
    - npm ci
    - npm run lint
  only:
    - merge_requests

frontend:test:
  image: node:20-alpine
  stage: test
  script:
    - cd frontend
    - npm ci
    - npm run test - --run
  only:
    - merge_requests

Джобы бэкенда отличаются образом и командами:

variables:
  GOFLAGS: "-count=1"  # без кэша тестов

backend:lint:
  image: golangci/golangci-lint:latest
  stage: lint
  script:
    - cd backend
    - golangci-lint run ./...
  only:
    - merge_requests

backend:test:
  image: golang:1.22-alpine
  stage: test
  script:
    - cd backend
    - go test ./...
  only:
    - merge_requests
backend:lint:
  image: php:8.3-cli-alpine
  stage: lint
  before_script:
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - cd backend && composer install --no-interaction --no-progress
  script:
    - vendor/bin/phpstan analyse
    - vendor/bin/php-cs-fixer fix --dry-run --diff
  only:
    - merge_requests

backend:test:
  image: php:8.3-cli-alpine
  stage: test
  before_script:
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - cd backend && composer install --no-interaction --no-progress
  script:
    - vendor/bin/phpunit
  only:
    - merge_requests
backend:lint:
  image: python:3.12-slim
  stage: lint
  before_script:
    - cd backend && pip install --no-cache-dir ruff mypy
  script:
    - ruff check .
    - mypy .
  only:
    - merge_requests

backend:test:
  image: python:3.12-slim
  stage: test
  before_script:
    - cd backend && pip install --no-cache-dir -r requirements.txt
  script:
    - pytest -q
  only:
    - merge_requests

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

Линтинг и тесты запускаются только на [Merge Request](../git/07-mr.md) - не тратим ресурсы на каждый коммит в feature-ветку. Для `main` запускается build + deploy.

npm ci и lockfile

npm ci работает только с package-lock.json. Без него - ошибка:

npm ERR! `npm ci` can only install packages when your package.json
npm ERR! and package-lock.json are in sync.

Решение:

cd frontend
npm install              # создаёт package-lock.json
git add package-lock.json
git commit -m "chore: add lockfile"
В `.gitignore` должен быть `node_modules/`. В CI зависимости ставятся с нуля через `npm ci`. Lock-файл гарантирует одинаковые версии.

Кэширование зависимостей

Без кэша каждый job скачивает зависимости заново. С кэшем - использует сохранённые:

backend:test:
  image: golang:1.22-alpine
  stage: test
  script:
    - cd backend
    - go test ./...
  cache:
    key: go-${CI_COMMIT_REF_SLUG}
    paths:
      - backend/.go/pkg/mod/
  variables:
    GOPATH: "${CI_PROJECT_DIR}/backend/.go"

frontend:test:
  image: node:20-alpine
  stage: test
  script:
    - cd frontend
    - npm ci
    - npm run test - --run
  cache:
    key: frontend-${CI_COMMIT_REF_SLUG}
    paths:
      - frontend/node_modules/

${CI_COMMIT_REF_SLUG} - имя ветки. Каждая ветка имеет свой кэш.

Сборка Docker-образов

Вариант 1: Docker-in-Docker (DinD)

build:docker:
  image: docker:24
  stage: build
  services:
    - docker:24-dind
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA ./backend
    - docker push $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA
  only:
    - main

DinD запускает Docker daemon внутри Docker-контейнера. Работает, но медленный (нет кэша слоёв между сборками).

Вариант 2: Kaniko (без Docker daemon)

build:kaniko:
  image:
    name: gcr.io/kaniko-project/executor:v1.21.0-debug
    entrypoint: [""]
  stage: build
  script:
    - /kaniko/executor
      --context ./backend
      --dockerfile ./backend/Dockerfile
      --destination $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA
      --cache=true
  only:
    - main

<ComparisonTable title="DinD vs Kaniko" headers={["", "Docker-in-Docker", "Kaniko"]} rows={[ ["Docker daemon", "Нужен (privileged)", "Не нужен"], ["Безопасность", "Требует privileged mode", "Работает без привилегий"], ["Кэш слоёв", "Нет (по умолчанию)", "Да (--cache=true)"], ["Скорость", "Медленнее", "Быстрее (с кэшем)"], ["Настройка", "Проще", "Сложнее первый раз"] ]} />

Встроенный registry для Docker-образов. Адрес: `registry.gitlab.com/username/project`. Авторизация через CI-переменные `$CI_REGISTRY_USER` и `$CI_REGISTRY_PASSWORD` - они автоматически доступны в пайплайне.

Переменные окружения в CI

variables:
  # Глобальные переменные (доступны во всех jobs)
  GOFLAGS: "-count=1"

backend:test:
  variables:
    # Переменные конкретного job
    DATABASE_URL: "postgres://postgres:postgres@postgres:5432/test?sslmode=disable"
  services:
    - postgres:16-alpine

Секреты (пароли, токены) - через Settings → CI/CD → Variables в GitLab. Они доступны как $VARIABLE_NAME, но не видны в логах (masked).

Артефакты

Артефакты - файлы, которые передаются между jobs или доступны для скачивания:

backend:test:
  script:
    - cd backend
    - go test -coverprofile=coverage.out ./...
    - go tool cover -html=coverage.out -o coverage.html
  artifacts:
    paths:
      - backend/coverage.html
    expire_in: 7 days

Полный production .gitlab-ci.yml

stages:
    - lint
    - test
    - build
    - deploy

backend:lint:
  image: golangci/golangci-lint:latest
  stage: lint
  script:
    - cd backend && golangci-lint run ./...
  only:
    - merge_requests

backend:test:
  image: golang:1.22-alpine
  stage: test
  script:
    - cd backend && go test ./...
  only:
    - merge_requests

build:
  image:
    name: gcr.io/kaniko-project/executor:v1.21.0-debug
    entrypoint: [""]
  stage: build
  script:
    - /kaniko/executor
      --context ./backend
      --dockerfile ./backend/Dockerfile
      --destination $CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA
      --cache=true
  only:
    - main

deploy:
  stage: deploy
  script:
    - ssh deploy@$SERVER "cd /opt/app && docker compose pull && docker compose up -d"
  only:
    - main
  when: manual   # ручное подтверждение деплоя

Мини-задание

  • Создай .gitlab-ci.yml с двумя stages: test и build
  • Добавь package-lock.json в репозиторий
  • Настрой кэширование node_modules и Go-модулей
  • Добавь only: merge_requests для тестов
  • Проверь пайплайн в GitLab → CI/CD → Pipelines

Итог

  • CI = автоматизация: lint → test → build → deploy при каждом пуше/MR. Как выглядит весь командный процесс вокруг этого pipeline (защита веток, MR, релизы по тегам) - в мини-проекте трека Git.
  • .gitlab-ci.yml - stages (последовательно), jobs (параллельно внутри stage).
  • npm ci + package-lock.json - детерминированные зависимости в CI.
  • Кэширование: node_modules и Go-модули - экономия минут на каждом запуске.
  • Docker-образы: DinD (просто) vs Kaniko (безопасно + быстро с кэшем).
  • Секреты - через GitLab CI Variables, не в .gitlab-ci.yml.

Типичная ошибка

Забыть package-lock.json в git - npm ci упадёт. Lock-файл должен быть в репозитории и обновляться через npm install локально.

Другая ошибка - хранить секреты (пароли, токены, SSH-ключи) в .gitlab-ci.yml. Даже если репозиторий приватный - secrets должны быть в GitLab CI Variables (Settings → CI/CD → Variables, с флагом masked).

Мини-практика (10-15 минут)

Создай минимальный .gitlab-ci.yml для Go-проекта и запусти локально через gitlab-runner:

# Установи gitlab-runner (если есть Docker)
# Или проверь на GitLab - создай тестовый репозиторий

# Минимальный .gitlab-ci.yml:
cat > .gitlab-ci.yml <<'EOF'
stages:
 - test

go:test:
  image: golang:1.22-alpine
  stage: test
  script:
 - go version
 - go test ./... -v
EOF

# Пуш в GitLab → смотри Pipelines
git add .gitlab-ci.yml
git commit -m "ci: add basic pipeline"
git push

Открой GitLab → CI/CD → Pipelines - увидишь свой пайплайн.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, вести прогресс.