Построение полного CI/CD-конвейера на GitLab: от коммита до продакшена

Исходная ситуация

Компания «СофтЛаб» — 15 разработчиков, SaaS-продукт для управления складами. До обращения к нам деплой выглядел так: разработчик собирал JAR-файл локально, заливал по SSH на сервер, перезапускал systemd-сервис. Три окружения (dev, staging, production) обновлялись вручную, конфиги правились прямо на серверах.

Проблемы:

  • Деплой занимал 2-3 часа — ручная сборка, копирование, проверка на каждом окружении
  • «Работает на моей машине» — различия в версиях Java, зависимостях и конфигах между машинами разработчиков и серверами
  • Нет автотестов в пайплайне — тесты запускались локально (если вообще запускались)
  • Даунтайм при деплое — 5-15 минут на каждый деплой, 2-3 раза в неделю
  • Нет отката — при ошибке разработчик откатывал код вручную и пересобирал
  • Дрейф конфигов — staging и production имели разные версии конфигов, что приводило к «сюрпризам» при деплое

Архитектура CI/CD-конвейера

Мы спроектировали конвейер из 6 стадий, каждая из которых обязательна для попадания кода в production:

  1. Lint — статический анализ кода (Checkstyle, SpotBugs)
  2. Test — юнит- и интеграционные тесты
  3. Build — сборка Docker-образа и push в GitLab Container Registry
  4. Deploy Dev — автоматический деплой в dev-окружение
  5. Deploy Staging — деплой в staging по кнопке (manual)
  6. Deploy Production — деплой в production с ревью и approve

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

stages:
  - lint
  - test
  - build
  - deploy-dev
  - deploy-staging
  - deploy-production

variables:
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - .m2/repository/

# Шаблон для деплоя (DRY)
.deploy_template: &deploy_template
  image: alpine/helm:3.14
  before_script:
    - apk add --no-cache kubectl
    - echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig
    - export KUBECONFIG=/tmp/kubeconfig

GitLab Runner: настройка и оптимизация

Мы развернули 3 GitLab Runner на отдельных серверах с Docker executor:

# Установка GitLab Runner
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | bash
apt install gitlab-runner

# Регистрация Runner с Docker executor
gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.softlab.ru" \
  --registration-token "$RUNNER_TOKEN" \
  --executor docker \
  --docker-image alpine:latest \
  --docker-privileged \
  --docker-volumes /var/run/docker.sock:/var/run/docker.sock \
  --tag-list "docker,build" \
  --run-untagged=true \
  --locked=false

# /etc/gitlab-runner/config.toml — оптимизация
[[runners]]
  name = "docker-runner-01"
  url = "https://gitlab.softlab.ru"
  executor = "docker"
  [runners.docker]
    tls_verify = false
    image = "alpine:latest"
    privileged = true
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
    shm_size = 0
  [runners.cache]
    Type = "s3"
    Path = "gitlab-runner-cache"
    Shared = true
    [runners.cache.s3]
      ServerAddress = "minio.softlab.ru:9000"
      AccessKey = "$MINIO_ACCESS_KEY"
      SecretKey = "$MINIO_SECRET_KEY"
      BucketName = "runner-cache"

Для кэширования Maven-зависимостей мы развернули MinIO (S3-совместимое хранилище). Без кэша сборка скачивала 800 MB зависимостей каждый раз — с кэшем этот этап занимает 3 секунды.

Стадии Lint и Test

Lint — первая линия защиты от плохого кода:

lint:
  stage: lint
  image: maven:3.9-eclipse-temurin-21
  script:
    - mvn checkstyle:check spotbugs:check -B
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  allow_failure: false

Тесты разделены на юнит и интеграционные. Интеграционные тесты используют testcontainers для поднятия PostgreSQL и Redis:

unit-tests:
  stage: test
  image: maven:3.9-eclipse-temurin-21
  script:
    - mvn test -B -Dtest.profile=unit
  artifacts:
    reports:
      junit: target/surefire-reports/*.xml
    paths:
      - target/site/jacoco/
    expire_in: 7 days
  coverage: '/Total.*?(\d+%)/'

integration-tests:
  stage: test
  image: maven:3.9-eclipse-temurin-21
  services:
    - name: postgres:16-alpine
      alias: test-db
      variables:
        POSTGRES_DB: testdb
        POSTGRES_USER: test
        POSTGRES_PASSWORD: test
    - name: redis:7-alpine
      alias: test-redis
  variables:
    SPRING_DATASOURCE_URL: "jdbc:postgresql://test-db:5432/testdb"
    SPRING_REDIS_HOST: "test-redis"
  script:
    - mvn verify -B -Dtest.profile=integration
  artifacts:
    reports:
      junit: target/failsafe-reports/*.xml
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Мы настроили правило в GitLab: merge request не может быть влит, если coverage ниже 70% или хотя бы один тест падает.

Сборка Docker-образа и Registry

Стадия Build создаёт Docker-образ и пушит его в GitLab Container Registry:

build:
  stage: build
  image: docker:24-dind
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: ""
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build
        --build-arg JAR_FILE=target/*.jar
        --cache-from $CI_REGISTRY_IMAGE:latest
        -t $DOCKER_IMAGE
        -t $CI_REGISTRY_IMAGE:latest
        .
    - docker push $DOCKER_IMAGE
    - docker push $CI_REGISTRY_IMAGE:latest
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Многослойный Dockerfile с разделением на build и runtime:

# Dockerfile
FROM maven:3.9-eclipse-temurin-21-alpine AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B

FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:+UseG1GC", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]

Размер итогового образа — 180 MB вместо 650 MB при использовании полного JDK.

Деплой в Kubernetes через Helm

Каждое окружение — отдельный namespace в Kubernetes. Деплой через Helm chart с values-файлами для каждого окружения:

# Структура Helm chart
helm/
├── Chart.yaml
├── values.yaml           # общие значения
├── values-dev.yaml       # переопределения для dev
├── values-staging.yaml   # переопределения для staging
├── values-prod.yaml      # переопределения для production
└── templates/
    ├── deployment.yaml
    ├── service.yaml
    ├── ingress.yaml
    ├── hpa.yaml
    └── configmap.yaml
# values-prod.yaml
replicaCount: 3

image:
  pullPolicy: Always

resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    cpu: 2000m
    memory: 1536Mi

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1
    maxUnavailable: 0  # zero-downtime

Стадии деплоя в .gitlab-ci.yml:

deploy-dev:
  stage: deploy-dev
  <<: *deploy_template
  script:
    - helm upgrade --install warehouse-app ./helm
        -f ./helm/values-dev.yaml
        --set image.tag=$CI_COMMIT_SHORT_SHA
        --namespace dev
        --wait --timeout 300s
  environment:
    name: dev
    url: https://dev.softlab.ru
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

deploy-staging:
  stage: deploy-staging
  <<: *deploy_template
  script:
    - helm upgrade --install warehouse-app ./helm
        -f ./helm/values-staging.yaml
        --set image.tag=$CI_COMMIT_SHORT_SHA
        --namespace staging
        --wait --timeout 300s
  environment:
    name: staging
    url: https://staging.softlab.ru
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual

deploy-production:
  stage: deploy-production
  <<: *deploy_template
  script:
    - helm upgrade --install warehouse-app ./helm
        -f ./helm/values-prod.yaml
        --set image.tag=$CI_COMMIT_SHORT_SHA
        --namespace production
        --wait --timeout 600s
  environment:
    name: production
    url: https://app.softlab.ru
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  needs: ["deploy-staging"]

Стратегия отката и Merge Request Pipelines

Откат в Helm — одна команда:

# Просмотр истории релизов
helm history warehouse-app -n production
# REVISION  STATUS      DESCRIPTION
# 14        superseded  Upgrade complete
# 15        deployed    Upgrade complete

# Откат на предыдущую ревизию
helm rollback warehouse-app 14 -n production --wait

# Или через GitLab — перезапуск пайплайна предыдущего успешного коммита

Мы также настроили автоматический откат при неудачном health check:

# В Helm values:
livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5
  failureThreshold: 3

Флаг --wait в helm upgrade означает: если новые поды не пройдут readiness probe за timeout, Helm откатит деплой автоматически.

Merge Request Pipelines:

Для каждого MR запускается отдельный пайплайн с lint и тестами. Настройки в GitLab:

  • MR не может быть влит без успешного пайплайна
  • Минимум 1 approve от Code Owner
  • Squash commits при мерже для чистой истории
  • Автоматическое удаление source branch после мержа

Результаты

Через месяц после внедрения CI/CD:

МетрикаДоПосле
Время деплоя2-3 часа12 минут (автоматически)
Частота деплоев2-3 раза/неделю5-8 раз/день
Даунтайм при деплое5-15 минут0 (rolling update)
Время отката30-60 минут2 минуты (helm rollback)
Покрытие тестами~20%78%
Баги в production8-12/месяц2-3/месяц
Lead time (идея → production)2 недели1-2 дня

Главный культурный сдвиг: разработчики «СофтЛаб» перестали бояться деплоя. Раньше деплой был стрессом с ручными проверками, теперь — рутинная операция, которая происходит несколько раз в день. Если вашей команде нужна настройка CI/CD — обращайтесь в ITFresh, настроим пайплайн от коммита до продакшена.

Часто задаваемые вопросы

Если вы уже используете GitLab для хранения кода — однозначно GitLab CI. Пайплайн описывается в .gitlab-ci.yml прямо в репозитории, интеграция с MR из коробки, Container Registry встроен. Jenkins имеет смысл, если у вас гетерогенная инфраструктура (GitLab + Bitbucket + SVN) или нужны сложные плагины.
Kubernetes не обязателен. GitLab CI отлично деплоит через SSH (rsync + systemd restart) или docker-compose. K8s оправдан при 3+ окружениях, автоскейлинге и команде от 10 разработчиков. Для 2-3 серверов docker-compose + Watchtower проще и дешевле.
Никогда не храните секреты в .gitlab-ci.yml или в коде. Используйте GitLab CI/CD Variables (Settings → CI/CD → Variables) с флагом Protected и Masked. Для production-grade решения — HashiCorp Vault с интеграцией через GitLab JWT auth.
Базовый пайплайн (lint + test + build + deploy) — 2-3 дня. С Kubernetes, Helm, автоскейлингом и мониторингом — 1-2 недели. Главная работа — не в YAML, а в написании Dockerfile, Helm chart и настройке тестового окружения.
Используйте blue-green деплой: два идентичных окружения за балансировщиком (Nginx/HAProxy). Деплоите на неактивное, проверяете health check, переключаете трафик. Откат — обратное переключение. Можно реализовать через docker-compose с Nginx upstream.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#cicd#gitlab#gitlab-ci#docker#helm#kubernetes#pipeline#devops
Комментарии 0

Оставить комментарий

загрузка...