Два инцидента из-за ручного деплоя: строим GitLab CI/CD для финтех-команды

Задача клиента: два продакшн-инцидента за месяц

Осенью 2025 года к нам обратилась финтех-компания PayFlow из Новосибирска — разработчик платёжной платформы для малого бизнеса. Команда из 12 разработчиков, 3 QA-инженеров и 2 DevOps (один из которых уволился месяц назад) — обслуживала 6 микросервисов: API-шлюз, процессинг платежей, эквайринг, антифрод, личный кабинет мерчанта и бэк-офис.

Деплой выполнялся вручную: разработчик подключался по SSH к продакшн-серверу, делал git pull, собирал Docker-образ и перезапускал контейнер. Никакого CI/CD, никаких тестов перед деплоем, никакого rollback-плана.

За последний месяц произошли два серьёзных инцидента:

  1. Инцидент #1 — разработчик задеплоил на продакшн ветку feature/new-auth вместо main. Сервис аутентификации упал на 2 часа, 1 500 мерчантов не могли принимать платежи. Убыток — ~600 000 ₽
  2. Инцидент #2 — при ручном деплое забыли обновить переменную окружения DB_PASSWORD после ротации пароля БД. Процессинг платежей не мог подключиться к базе 40 минут. Убыток — ~200 000 ₽
«Мы буквально играем в русскую рулетку при каждом деплое. Нам нужна система, которая не даст человеку ошибиться» — CTO PayFlow.

Специалисты АйТи Фреш предложили внедрить полноценный CI/CD-пайплайн на базе GitLab, который компания уже использовала для хранения кода.

Аудит текущего процесса

Перед началом работы мы провели аудит и выявили критические проблемы:

  • Нет разделения окружений — staging-среда отсутствовала, тестирование выполнялось локально
  • Нет автотестов в пайплайне — unit-тесты были, но запускались только локально «когда не забудут»
  • Секреты в .env файлах на серверах — пароли, API-ключи хранились в открытом виде
  • Нет Docker Registry — образы собирались на продакшн-сервере
  • Нет rollback — при проблемах откатывались через git revert и повторный ручной деплой
  • Нет уведомлений — о результатах деплоя узнавали по жалобам пользователей

Установка и настройка GitLab Runner

GitLab Runner — это агент, который выполняет задачи CI/CD. Мы развернули два раннера: один для сборки и тестов, второй для деплоя на продакшн.

Установка GitLab Runner на выделенном сервере

# Добавляем репозиторий GitLab Runner
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt install -y gitlab-runner

# Регистрируем Runner для сборки и тестов
sudo gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.payflow.ru" \
  --registration-token "GR1348941_xxxxxxxxxxxxxx" \
  --executor "docker" \
  --docker-image "docker:24-dind" \
  --docker-privileged \
  --docker-volumes "/cache:/cache" \
  --docker-volumes "/var/run/docker.sock:/var/run/docker.sock" \
  --description "build-runner-01" \
  --tag-list "build,test,docker" \
  --run-untagged=false \
  --locked=false

# Регистрируем Runner для деплоя (на продакшн-сервере)
sudo gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.payflow.ru" \
  --registration-token "GR1348941_xxxxxxxxxxxxxx" \
  --executor "shell" \
  --description "deploy-runner-prod" \
  --tag-list "deploy,production" \
  --run-untagged=false \
  --locked=true  # только для назначенных проектов

Настройка Docker-in-Docker и кэширования

# /etc/gitlab-runner/config.toml (build-runner)
concurrent = 4
check_interval = 3

[[runners]]
  name = "build-runner-01"
  url = "https://gitlab.payflow.ru"
  token = "xxxxxxxx"
  executor = "docker"
  [runners.docker]
    tls_verify = false
    image = "docker:24-dind"
    privileged = true
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = [
      "/cache:/cache",
      "/var/run/docker.sock:/var/run/docker.sock"
    ]
    shm_size = 0
  [runners.cache]
    Type = "s3"
    Shared = true
    [runners.cache.s3]
      ServerAddress = "minio.payflow.ru:9000"
      AccessKey = "runner-cache"
      SecretKey = "CacheSecret2025!"
      BucketName = "gitlab-runner-cache"
      Insecure = true

Кэширование через S3 (MinIO) позволяло переиспользовать зависимости (node_modules, pip cache) между сборками, ускоряя пайплайн на 40–60%.

Структура .gitlab-ci.yml: пятиэтапный пайплайн

Мы разработали пайплайн из 5 стадий: build, test, security, staging, production. Каждая стадия — это гейт: если предыдущая не прошла, следующая не запускается.

Общая структура и переменные

# .gitlab-ci.yml

variables:
  DOCKER_REGISTRY: registry.payflow.ru
  IMAGE_NAME: $DOCKER_REGISTRY/$CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME
  IMAGE_TAG: $CI_COMMIT_SHORT_SHA
  DOCKER_TLS_CERTDIR: ""

stages:
  - build
  - test
  - security
  - staging
  - production

# === Шаблоны для переиспользования ===
.docker_login: &docker_login
  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $DOCKER_REGISTRY

.deploy_template: &deploy_template
  script:
    - echo "Deploying $IMAGE_NAME:$IMAGE_TAG to $DEPLOY_HOST"
    - docker pull $IMAGE_NAME:$IMAGE_TAG
    - docker stop $CI_PROJECT_NAME || true
    - docker rm $CI_PROJECT_NAME || true
    - docker run -d --name $CI_PROJECT_NAME
        --restart unless-stopped
        --network payflow-net
        --env-file /opt/payflow/envs/$CI_PROJECT_NAME.env
        -p $SERVICE_PORT:$SERVICE_PORT
        $IMAGE_NAME:$IMAGE_TAG
    - sleep 5
    - docker ps | grep $CI_PROJECT_NAME | grep -q "Up" || exit 1
    - echo "Health check..."
    - 'curl -sf http://localhost:$SERVICE_PORT/health || exit 1'
    - echo "Deploy successful"

Stage 1: Build — сборка Docker-образа

# === BUILD ===
build:
  stage: build
  tags: [build, docker]
  image: docker:24-dind
  <<: *docker_login
  script:
    # Сборка с кэшированием слоёв
    - docker build
        --cache-from $IMAGE_NAME:latest
        --build-arg BUILDKIT_INLINE_CACHE=1
        -t $IMAGE_NAME:$IMAGE_TAG
        -t $IMAGE_NAME:latest
        -t $IMAGE_NAME:$CI_COMMIT_REF_SLUG
        .
    # Push в реестр
    - docker push $IMAGE_NAME:$IMAGE_TAG
    - docker push $IMAGE_NAME:latest
    - docker push $IMAGE_NAME:$CI_COMMIT_REF_SLUG
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_MERGE_REQUEST_ID
    - if: $CI_COMMIT_TAG

Stage 2: Test — unit и integration тесты

# === TESTS ===
unit_tests:
  stage: test
  tags: [build, docker]
  image: $IMAGE_NAME:$IMAGE_TAG
  services:
    - name: postgres:15
      alias: test-db
    - name: redis:7
      alias: test-redis
  variables:
    POSTGRES_DB: payflow_test
    POSTGRES_USER: test
    POSTGRES_PASSWORD: test
    DATABASE_URL: "postgresql://test:test@test-db:5432/payflow_test"
    REDIS_URL: "redis://test-redis:6379"
  script:
    - cd /app
    - npm run test:unit -- --coverage --ci
    - npm run test:integration -- --ci
  coverage: '/All files[^|]*\|[^|]*\s+([\d.]+)/'
  artifacts:
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
    paths:
      - coverage/
    expire_in: 7 days
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_MERGE_REQUEST_ID

lint:
  stage: test
  tags: [build, docker]
  image: $IMAGE_NAME:$IMAGE_TAG
  script:
    - cd /app
    - npm run lint
    - npm run type-check
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_MERGE_REQUEST_ID

Stage 3: Security — сканирование уязвимостей

# === SECURITY ===
trivy_scan:
  stage: security
  tags: [build, docker]
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  script:
    - trivy image
        --exit-code 1
        --severity HIGH,CRITICAL
        --ignore-unfixed
        --format json
        --output trivy-report.json
        $IMAGE_NAME:$IMAGE_TAG
  artifacts:
    paths:
      - trivy-report.json
    expire_in: 7 days
    when: always
  allow_failure: false
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

secret_detection:
  stage: security
  tags: [build]
  image:
    name: trufflesecurity/trufflehog:latest
    entrypoint: [""]
  script:
    - trufflehog git
        --only-verified
        --fail
        --json
        file://.
        > trufflehog-report.json || true
    - '[ ! -s trufflehog-report.json ] || (echo "Secrets detected!" && exit 1)'
  artifacts:
    paths:
      - trufflehog-report.json
    expire_in: 7 days
    when: always
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_MERGE_REQUEST_ID

Деплой на staging и production

Мы реализовали двухэтапный деплой: автоматический на staging при каждом мёрже в main, и полуавтоматический (с ручным подтверждением) на production.

Деплой на staging

# === STAGING ===
deploy_staging:
  stage: staging
  tags: [deploy]
  environment:
    name: staging
    url: https://staging.payflow.ru
    on_stop: stop_staging
  variables:
    DEPLOY_HOST: staging.payflow.ru
    SERVICE_PORT: "3000"
  <<: *deploy_template
  after_script:
    # Smoke-тесты после деплоя
    - 'curl -sf https://staging.payflow.ru/api/health | jq .'
    - 'curl -sf https://staging.payflow.ru/api/version | jq .'
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

stop_staging:
  stage: staging
  tags: [deploy]
  environment:
    name: staging
    action: stop
  script:
    - docker stop $CI_PROJECT_NAME-staging || true
    - docker rm $CI_PROJECT_NAME-staging || true
  when: manual
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

Деплой на production с ручным подтверждением

# === PRODUCTION ===
deploy_production:
  stage: production
  tags: [deploy, production]
  environment:
    name: production
    url: https://api.payflow.ru
  variables:
    DEPLOY_HOST: prod.payflow.ru
    SERVICE_PORT: "3000"
  <<: *docker_login
  script:
    # Сохраняем текущую версию для rollback
    - CURRENT_IMAGE=$(docker inspect --format='{{.Config.Image}}' $CI_PROJECT_NAME 2>/dev/null || echo "none")
    - echo "$CURRENT_IMAGE" > /opt/payflow/rollback/$CI_PROJECT_NAME.previous
    # Деплой
    - docker pull $IMAGE_NAME:$IMAGE_TAG
    - docker stop $CI_PROJECT_NAME || true
    - docker rm $CI_PROJECT_NAME || true
    - docker run -d --name $CI_PROJECT_NAME
        --restart unless-stopped
        --network payflow-net
        --env-file /opt/payflow/envs/$CI_PROJECT_NAME.env
        -p $SERVICE_PORT:$SERVICE_PORT
        --health-cmd='curl -f http://localhost:$SERVICE_PORT/health || exit 1'
        --health-interval=10s
        --health-timeout=5s
        --health-retries=3
        $IMAGE_NAME:$IMAGE_TAG
    # Ожидаем прохождения health check
    - |
      for i in $(seq 1 30); do
        STATUS=$(docker inspect --format='{{.State.Health.Status}}' $CI_PROJECT_NAME 2>/dev/null)
        if [ "$STATUS" = "healthy" ]; then
          echo "Container is healthy!"
          exit 0
        fi
        echo "Waiting for health check... ($i/30)"
        sleep 2
      done
      echo "Health check failed! Rolling back..."
      PREV=$(cat /opt/payflow/rollback/$CI_PROJECT_NAME.previous)
      docker stop $CI_PROJECT_NAME && docker rm $CI_PROJECT_NAME
      docker run -d --name $CI_PROJECT_NAME --restart unless-stopped \
        --network payflow-net --env-file /opt/payflow/envs/$CI_PROJECT_NAME.env \
        -p $SERVICE_PORT:$SERVICE_PORT $PREV
      exit 1
  when: manual  # Ручное подтверждение!
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Ключевой элемент — when: manual. Деплой на продакшн требует нажатия кнопки в GitLab UI. Кнопку может нажать только пользователь с правами Maintainer — это защищает от случайного деплоя.

Автоматический rollback при ошибке

В скрипте деплоя мы реализовали автоматический rollback: если health check не проходит в течение 60 секунд, система откатывает контейнер на предыдущую версию. Дополнительно мы создали отдельную job для ручного отката:

# Ручной rollback
rollback_production:
  stage: production
  tags: [deploy, production]
  environment:
    name: production
  script:
    - PREV=$(cat /opt/payflow/rollback/$CI_PROJECT_NAME.previous)
    - echo "Rolling back to $PREV"
    - docker stop $CI_PROJECT_NAME && docker rm $CI_PROJECT_NAME
    - docker run -d --name $CI_PROJECT_NAME
        --restart unless-stopped
        --network payflow-net
        --env-file /opt/payflow/envs/$CI_PROJECT_NAME.env
        -p $SERVICE_PORT:$SERVICE_PORT
        $PREV
    - sleep 5
    - 'curl -sf http://localhost:$SERVICE_PORT/health || exit 1'
    - echo "Rollback successful to $PREV"
  when: manual
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Управление секретами: GitLab CI/CD Variables

Одна из причин инцидента #2 — секреты хранились в .env файлах, которые обновлялись вручную. Мы внедрили централизованное управление секретами через GitLab.

Настройка CI/CD Variables

Все секреты были перенесены в GitLab CI/CD Variables (Settings → CI/CD → Variables):

# Переменные, настроенные в GitLab UI:
# (Masked + Protected)
# DB_PASSWORD        → "Pr0d$ecret!2025"
# REDIS_PASSWORD     → "R3dis#Secur3"
# JWT_SECRET         → "eyJhbGciOiJIU..."
# PAYMENT_API_KEY    → "pk_live_xxxx"
# ANTIFRAUD_TOKEN    → "af_prod_yyyy"
# SENTRY_DSN         → "https://xxxx@sentry.payflow.ru/1"

# Переменные с Environment scope:
# DB_HOST (staging)  → "db-staging.payflow.ru"
# DB_HOST (production) → "db-prod.payflow.ru"

Критически важные параметры были помечены как:

  • Masked — не отображаются в логах пайплайна
  • Protected — доступны только в protected branches (main) и protected tags
  • Environment scope — разные значения для staging и production

Генерация .env файлов из CI/CD Variables

# Скрипт генерации .env перед деплоем
# Добавляем в deploy_template
before_script:
  - |
    cat > /opt/payflow/envs/$CI_PROJECT_NAME.env << ENVEOF
    NODE_ENV=production
    PORT=$SERVICE_PORT
    DB_HOST=$DB_HOST
    DB_PORT=5432
    DB_NAME=payflow_$CI_PROJECT_NAME
    DB_USER=payflow
    DB_PASSWORD=$DB_PASSWORD
    REDIS_URL=redis://:$REDIS_PASSWORD@redis.payflow.ru:6379
    JWT_SECRET=$JWT_SECRET
    SENTRY_DSN=$SENTRY_DSN
    LOG_LEVEL=info
    ENVEOF
  - chmod 600 /opt/payflow/envs/$CI_PROJECT_NAME.env

Теперь при ротации пароля БД достаточно обновить переменную в GitLab UI — при следующем деплое .env файл сгенерируется автоматически с новым паролем. Никакого ручного редактирования на серверах.

Уведомления: Telegram и Slack

Команда PayFlow использовала Telegram для коммуникаций и Slack для технических каналов. Мы настроили уведомления о событиях пайплайна в оба мессенджера.

Telegram-уведомления через webhook

# Добавляем job для уведомлений (в конец .gitlab-ci.yml)

.notify_telegram: ¬ify_telegram
  image: curlimages/curl:latest
  tags: [build]
  script:
    - |
      STATUS_EMOJI=""
      case "$CI_JOB_STATUS" in
        success) STATUS_EMOJI="✅" ;;
        failed)  STATUS_EMOJI="❌" ;;
        *)       STATUS_EMOJI="⚠️" ;;
      esac

      curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
        -d chat_id="${TELEGRAM_CHAT_ID}" \
        -d parse_mode="HTML" \
        -d text="${STATUS_EMOJI} ${CI_PROJECT_NAME}: ${NOTIFY_STAGE}
      Branch: ${CI_COMMIT_REF_NAME}
      Commit: ${CI_COMMIT_SHORT_SHA}
      Author: ${CI_COMMIT_AUTHOR}
      Pipeline: #${CI_PIPELINE_ID}
      Message: ${CI_COMMIT_MESSAGE}"

notify_deploy_staging:
  stage: staging
  <<: *notify_telegram
  variables:
    NOTIFY_STAGE: "Deployed to STAGING"
  needs: [deploy_staging]
  when: on_success
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

notify_deploy_production:
  stage: production
  <<: *notify_telegram
  variables:
    NOTIFY_STAGE: "Deployed to PRODUCTION"
  needs: [deploy_production]
  when: on_success
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

notify_failure:
  stage: production
  <<: *notify_telegram
  variables:
    NOTIFY_STAGE: "PIPELINE FAILED"
  when: on_failure
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

GitLab Webhook в Slack

Дополнительно мы настроили нативную интеграцию GitLab со Slack через Webhook:

# Settings → Integrations → Slack notifications
# Webhook URL: https://hooks.slack.com/services/T00000/B00000/XXXXX
# Channel: #deployments
# Events:
#   ✅ Pipeline events
#   ✅ Deployment events
#   ✅ Merge request events
#   ✅ Tag push events
# Branches: main, staging

Slack-канал #deployments стал единой точкой истины для всей команды — кто, что и когда задеплоил.

GitLab Container Registry и оптимизация образов

Для хранения Docker-образов мы настроили встроенный GitLab Container Registry, чтобы образы были привязаны к проектам и версиям кода.

Настройка Registry и политики очистки

# В /etc/gitlab/gitlab.rb (GitLab Omnibus)
registry_external_url 'https://registry.payflow.ru'
gitlab_rails['registry_enabled'] = true
registry['storage'] = {
  's3' => {
    'accesskey' => 'registry-s3-key',
    'secretkey' => 'registry-s3-secret',
    'bucket' => 'gitlab-registry',
    'regionendpoint' => 'https://minio.payflow.ru:9000',
    'region' => 'us-east-1',
    'path_style' => true
  }
}

# Политика очистки образов (Settings → Packages and registries → Clean up)
# Keep the most recent: 10 tags per image
# Remove tags older than: 30 days
# Remove tags matching: .*
# Do NOT remove tags matching: latest|stable|v\d+\.\d+\.\d+

Оптимизация Dockerfile: multi-stage build

# Dockerfile (оптимизированный для CI/CD)
# === Stage 1: Build ===
FROM node:20-alpine AS builder
WORKDIR /app

# Кэшируем зависимости отдельно от кода
COPY package*.json ./
RUN npm ci --only=production && \
    cp -R node_modules /production_modules && \
    npm ci

COPY . .
RUN npm run build

# === Stage 2: Production ===
FROM node:20-alpine AS production
WORKDIR /app

# Безопасность: не-root пользователь
RUN addgroup -g 1001 -S appgroup && \
    adduser -S appuser -u 1001 -G appgroup

COPY --from=builder /production_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json .

# Health check
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
  CMD curl -f http://localhost:${PORT:-3000}/health || exit 1

USER appuser
EXPOSE ${PORT:-3000}
CMD ["node", "dist/main.js"]

Multi-stage build уменьшил размер финального образа с 1.2 ГБ до 180 МБ — ускорив pull при деплое в 6 раз.

Результаты внедрения

Проект внедрения CI/CD занял 10 рабочих дней. Через два месяца работы команда PayFlow зафиксировала следующие результаты:

  • 0 инцидентов из-за деплоя за 2 месяца (было 2 за 1 месяц до внедрения)
  • Время от коммита до продакшна сократилось с 2–3 часов ручного процесса до 12 минут автоматического пайплайна
  • Частота деплоев выросла с 2 раз в неделю до 3–5 раз в день — команда перестала бояться деплоить
  • Покрытие тестами выросло с 34% до 78% — CI отказывал мёрж при падении покрытия ниже 70%
  • Автоматический rollback сработал 2 раза за 2 месяца, предотвратив простой
  • Время отката — 30 секунд (было 20–40 минут вручную)
  • Найдено 3 уязвимости в зависимостях через Trivy до попадания в продакшн
«Раньше деплой был как операция на открытом сердце — нервы, молитвы и ожидание. Теперь это кнопка, которая просто работает. За два месяца ни одного инцидента» — CTO PayFlow.

Специалисты АйТи Фреш помогают компаниям любого размера выстроить надёжные процессы CI/CD — от простого пайплайна для одного сервиса до сложной микросервисной архитектуры с blue-green деплоем и canary releases.

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

Да. GitLab Community Edition (CE) включает полноценный CI/CD-функционал: пайплайны, environments, Container Registry, variables. Для самохостинга ограничений нет. На gitlab.com бесплатный тариф даёт 400 минут CI/CD в месяц на shared runners.

GitLab CI/CD интегрирован в GitLab «из коробки» — код, issues, CI/CD, Registry, мониторинг в одном месте. GitHub Actions привязан к экосистеме GitHub. Jenkins — универсальный, но требует значительно больше настройки и обслуживания. Для команд, уже использующих GitLab, встроенный CI/CD — оптимальный выбор.

Несколько уровней защиты: 1) when: manual — деплой требует ручного подтверждения, 2) Protected environments — только Maintainer может деплоить, 3) Protected branches — мёрж в main только через Merge Request с code review, 4) Required approvals — минимум 2 апрувера для MR. Все эти механизмы встроены в GitLab.

Мы реализовали двухуровневый rollback: автоматический — если health check не проходит в течение 60 секунд после деплоя, контейнер автоматически откатывается на предыдущий образ. Ручной — кнопка Rollback в GitLab UI, которая запускает деплой предыдущего образа. Время отката — 30 секунд.

Для build runner рекомендуем 4 CPU и 8 ГБ RAM (для параллельной сборки 4 проектов). Deploy runner может работать на 1 CPU и 1 ГБ RAM (выполняет только docker pull/run). При использовании Docker executor накладные расходы минимальны — каждая job запускается в изолированном контейнере.

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

Специалисты АйТи Фреш помогут с внедрением и настройкой — 15+ лет опыта, обслуживание от 15 000 ₽/мес

📞 Связаться с нами
#GitLab CI/CD настройка#gitlab-ci.yml примеры#GitLab Runner установка#автоматический деплой GitLab#DevOps пайплайн финтех#Docker CI/CD pipeline#GitLab environments rollback#secrets management CI/CD