Построение CI/CD конвейера с нуля для растущего стартапа

Стартовая точка

Процесс деплоя «МедТрека» выглядел так:

  1. Разработчик собирает приложение локально: ./gradlew build
  2. Тимлид запускает тесты (если не забудет): ./gradlew test
  3. Девопс подключается по SSH к серверу, останавливает приложение
  4. Копирует JAR через scp
  5. Запускает приложение, проверяет логи
  6. Если что-то пошло не так — откатывается к предыдущему JAR (если он ещё на диске)

Проблемы: «работает на моей машине», отсутствие тестов в CI, 15-минутный даунтайм при каждом деплое, ручной откат. За последний квартал было 4 инцидента из-за человеческих ошибок при деплое.

Шаг 1: контейнеризация

Первое, что мы сделали — упаковали приложение в Docker. Multi-stage build позволяет получить минимальный образ:

# Dockerfile
# --- Стадия сборки ---
FROM eclipse-temurin:17-jdk-alpine AS builder
WORKDIR /app

# Кэшируем зависимости отдельно от исходного кода
COPY build.gradle settings.gradle ./
COPY gradle/ gradle/
RUN ./gradlew dependencies --no-daemon || true

COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test

# --- Стадия выполнения ---
FROM eclipse-temurin:17-jre-alpine
RUN addgroup -S app && adduser -S app -G app

WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar

# Не запускаем от root
USER app

EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s \
  CMD wget -q --spider http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["java", \
  "-XX:+UseG1GC", \
  "-XX:MaxRAMPercentage=75.0", \
  "-Djava.security.egd=file:/dev/./urandom", \
  "-jar", "app.jar"]

Ключевые решения: отдельный слой для зависимостей (кэширование Docker layers), JRE вместо JDK в runtime (образ 180 MB вместо 420 MB), непривилегированный пользователь, встроенный healthcheck.

Шаг 2: GitLab CI pipeline

Мы выбрали GitLab CI, потому что «МедТрек» уже использовал GitLab для хранения кода. Вот полный пайплайн, который мы построили:

# .gitlab-ci.yml
stages:
  - test
  - security
  - build
  - deploy-staging
  - integration-tests
  - deploy-production

variables:
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  GRADLE_OPTS: "-Dorg.gradle.daemon=false"

# ============= ТЕСТИРОВАНИЕ =============
unit-tests:
  stage: test
  image: eclipse-temurin:17-jdk-alpine
  script:
    - ./gradlew test --no-daemon
  artifacts:
    when: always
    reports:
      junit: build/test-results/test/*.xml
    paths:
      - build/reports/tests/
    expire_in: 7 days
  coverage: '/Branch Coverage: (\d+\.\d+)%/'

lint:
  stage: test
  image: eclipse-temurin:17-jdk-alpine
  script:
    - ./gradlew checkstyleMain checkstyleTest spotbugsMain --no-daemon
  artifacts:
    paths:
      - build/reports/
    expire_in: 7 days

# ============= БЕЗОПАСНОСТЬ =============
sast:
  stage: security
  image: returntocorp/semgrep:latest
  script:
    - semgrep scan --config auto --json --output semgrep-results.json src/
  artifacts:
    paths:
      - semgrep-results.json
    expire_in: 30 days
  allow_failure: false

dependency-check:
  stage: security
  image: eclipse-temurin:17-jdk-alpine
  script:
    - ./gradlew dependencyCheckAnalyze --no-daemon
  artifacts:
    paths:
      - build/reports/dependency-check-report.html
    expire_in: 30 days
  allow_failure: true  # информируем, но не блокируем

container-scan:
  stage: security
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker build -t $DOCKER_IMAGE .
    - docker run --rm -v /var/run/docker.sock:/var/run/docker.sock
        aquasec/trivy:latest image --severity HIGH,CRITICAL
        --exit-code 1 $DOCKER_IMAGE
  needs:
    - unit-tests

# ============= СБОРКА =============
build-image:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build
        --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
        --build-arg VCS_REF=$CI_COMMIT_SHORT_SHA
        -t $DOCKER_IMAGE
        -t $CI_REGISTRY_IMAGE:latest .
    - docker push $DOCKER_IMAGE
    - docker push $CI_REGISTRY_IMAGE:latest
  only:
    - main
    - develop

# ============= STAGING =============
deploy-staging:
  stage: deploy-staging
  image: bitnami/kubectl:latest
  script:
    - kubectl set image deployment/medtrek-api
        medtrek-api=$DOCKER_IMAGE
        -n medtrek-staging
    - kubectl rollout status deployment/medtrek-api
        -n medtrek-staging --timeout=300s
  environment:
    name: staging
    url: https://staging.medtrek.ru
  only:
    - main

# ============= ИНТЕГРАЦИОННЫЕ ТЕСТЫ =============
integration-tests:
  stage: integration-tests
  image: eclipse-temurin:17-jdk-alpine
  script:
    - ./gradlew integrationTest --no-daemon
        -Dtest.base-url=https://staging.medtrek.ru
  artifacts:
    reports:
      junit: build/test-results/integrationTest/*.xml
  needs:
    - deploy-staging

# ============= PRODUCTION =============
deploy-production:
  stage: deploy-production
  image: bitnami/kubectl:latest
  script:
    - ./scripts/blue-green-deploy.sh $DOCKER_IMAGE
  environment:
    name: production
    url: https://medtrek.ru
  when: manual  # ручное подтверждение для прода
  only:
    - main
  needs:
    - integration-tests

Шаг 3: пирамида тестирования

Мы помогли «МедТреку» выстроить правильную тестовую пирамиду. До нас было 20 end-to-end тестов, которые шли 40 минут и падали через раз. Мы перераспределили усилия:

  • Unit-тесты (70%) — 480 тестов, выполнение за 45 секунд. Покрывают бизнес-логику: расчёт стоимости консультации, валидация рецептов, проверка расписания врачей.
  • Integration-тесты (20%) — 85 тестов, выполнение за 3 минуты. Тестируют взаимодействие с PostgreSQL (Testcontainers), Redis, внешними API через WireMock.
  • E2E-тесты (10%) — 15 критических сценариев, выполнение за 5 минут. Только happy path основных бизнес-процессов: запись на приём, проведение консультации, оплата.

Пример интеграционного теста с Testcontainers:

@SpringBootTest
@Testcontainers
class AppointmentRepositoryTest {

    @Container
    static PostgreSQLContainer postgres =
        new PostgreSQLContainer<>("postgres:15-alpine")
            .withDatabaseName("medtrek_test")
            .withInitScript("schema.sql");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private AppointmentRepository repository;

    @Test
    void shouldFindAvailableSlots() {
        // given
        var doctorId = UUID.fromString("550e8400-e29b-41d4-a716-446655440000");
        var date = LocalDate.of(2026, 1, 25);

        // when
        var slots = repository.findAvailableSlots(doctorId, date);

        // then
        assertThat(slots)
            .hasSize(8)
            .allMatch(slot -> slot.getStatus() == SlotStatus.AVAILABLE)
            .extracting(Slot::getStartTime)
            .isSorted();
    }
}

Шаг 4: security scanning

Для healthtech-стартапа безопасность критична — обрабатываются медицинские данные, подпадающие под 152-ФЗ. Мы встроили три уровня сканирования:

  • SAST (Semgrep) — статический анализ кода. Находит SQL-инъекции, XSS, небезопасное использование криптографии. За первый запуск нашли 3 SQL-инъекции и 7 мест с логированием персональных данных.
  • Dependency Check (OWASP) — сканирование зависимостей на известные CVE. Обнаружили 2 критичные уязвимости в транзитивных зависимостях Jackson.
  • Container Scan (Trivy) — проверка Docker-образа. Alpine с уязвимыми пакетами, устаревшие версии системных библиотек.

SAST блокирует пайплайн при находках. Dependency check пока информационный — слишком много false positives. Container scan блокирует при CRITICAL уязвимостях.

Шаг 5: blue-green деплой

Для zero-downtime деплоев мы реализовали blue-green стратегию. Скрипт деплоя:

#!/bin/bash
# scripts/blue-green-deploy.sh
set -euo pipefail

IMAGE=$1
NAMESPACE="medtrek-production"

# Определяем текущий активный деплоймент
CURRENT=$(kubectl get service medtrek-api -n $NAMESPACE \
  -o jsonpath='{.spec.selector.version}')

if [ "$CURRENT" = "blue" ]; then
  TARGET="green"
else
  TARGET="blue"
fi

echo "Current: $CURRENT, deploying to: $TARGET"

# Обновляем неактивный деплоймент
kubectl set image deployment/medtrek-api-$TARGET \
  medtrek-api=$IMAGE -n $NAMESPACE

# Ждём готовности
kubectl rollout status deployment/medtrek-api-$TARGET \
  -n $NAMESPACE --timeout=300s

# Проверяем health check нового деплоймента
TARGET_POD=$(kubectl get pods -n $NAMESPACE \
  -l app=medtrek-api,version=$TARGET \
  -o jsonpath='{.items[0].metadata.name}')

HEALTH=$(kubectl exec $TARGET_POD -n $NAMESPACE -- \
  wget -q -O- http://localhost:8080/actuator/health | jq -r '.status')

if [ "$HEALTH" != "UP" ]; then
  echo "ERROR: Health check failed for $TARGET"
  exit 1
fi

# Smoke test
SMOKE=$(kubectl exec $TARGET_POD -n $NAMESPACE -- \
  wget -q -O- http://localhost:8080/api/v1/health/smoke | jq -r '.ok')

if [ "$SMOKE" != "true" ]; then
  echo "ERROR: Smoke test failed for $TARGET"
  exit 1
fi

# Переключаем трафик
kubectl patch service medtrek-api -n $NAMESPACE \
  -p "{\"spec\":{\"selector\":{\"version\":\"$TARGET\"}}}"

echo "Traffic switched to $TARGET"
echo "Previous version ($CURRENT) is still running for rollback"

# Оставляем старую версию на 30 минут для возможного отката
echo "Old deployment will be scaled down after manual confirmation"

После переключения трафика старая версия продолжает работать. Если что-то пошло не так — откат занимает 5 секунд (патч Service обратно). Через 30 минут, после подтверждения, старый деплоймент масштабируется до 0.

Шаг 6: миграции базы данных

Автоматизация миграций — одна из самых рискованных частей пайплайна. Мы использовали Flyway с жёсткими правилами:

  • Миграции только forward — никаких откатов через down-миграции. Если нужно откатить, пишется новая миграция.
  • Все DDL-изменения обратно совместимы. Добавляем колонку с DEFAULT — сначала nullable, потом заполняем данные, потом ставим NOT NULL.
  • Миграции выполняются до деплоя приложения (в init container в Kubernetes).
-- V025__add_appointment_notes.sql
-- Добавляем колонку как nullable
ALTER TABLE appointments
  ADD COLUMN IF NOT EXISTS notes text;

-- Индекс для полнотекстового поиска по заметкам
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_appointments_notes_fts
  ON appointments USING gin(to_tsvector('russian', coalesce(notes, '')));

Шаг 7: feature flags

Чтобы разделить деплой и релиз фичи, мы внедрили простой сервис feature flags. Вместо тяжёлых решений типа LaunchDarkly — свой минимальный сервис на Go:

// Таблица в PostgreSQL
// CREATE TABLE feature_flags (
//   key        varchar(100) PRIMARY KEY,
//   enabled    boolean NOT NULL DEFAULT false,
//   rules      jsonb,
//   updated_at timestamptz DEFAULT now()
// );

type FlagService struct {
    db    *sql.DB
    cache *sync.Map  // локальный кэш, обновляется каждые 10 секунд
}

type Flag struct {
    Key     string          `json:"key"`
    Enabled bool            `json:"enabled"`
    Rules   json.RawMessage `json:"rules"`
}

func (s *FlagService) IsEnabled(ctx context.Context, key string, attrs map[string]string) bool {
    // Проверяем кэш
    if cached, ok := s.cache.Load(key); ok {
        flag := cached.(*Flag)
        if !flag.Enabled {
            return false
        }
        return s.evaluateRules(flag.Rules, attrs)
    }
    return false
}

// Использование в приложении:
func (h *AppointmentHandler) CreateAppointment(w http.ResponseWriter, r *http.Request) {
    // ...
    if h.flags.IsEnabled(r.Context(), "video-consultation", map[string]string{
        "user_id":  userID,
        "clinic_id": clinicID,
    }) {
        // Новая логика видеоконсультаций
        h.setupVideoRoom(ctx, appointment)
    }
    // ...
}

Это позволяет деплоить код с выключенной фичей, включать её для 5% пользователей, постепенно увеличивать до 100%, и мгновенно выключить, если что-то пойдёт не так — без деплоя.

Шаг 8: метрики и автоматический откат

В пайплайне мы настроили автоматический мониторинг после деплоя. Если error rate превышает порог в течение 5 минут — автоматический откат:

# В deploy-production job добавляем post-deploy проверку
deploy-production:
  script:
    - ./scripts/blue-green-deploy.sh $DOCKER_IMAGE
    # Ждём 5 минут и проверяем метрики
    - sleep 300
    - |
      ERROR_RATE=$(curl -s "http://prometheus:9090/api/v1/query" \
        --data-urlencode "query=rate(http_requests_total{status=~\"5..\",app=\"medtrek-api\"}[5m]) / rate(http_requests_total{app=\"medtrek-api\"}[5m]) * 100" \
        | jq -r '.data.result[0].value[1]')

      echo "Error rate after deploy: ${ERROR_RATE}%"

      if (( $(echo "$ERROR_RATE > 2.0" | bc -l) )); then
        echo "ERROR: Error rate ${ERROR_RATE}% exceeds threshold 2%"
        echo "Initiating automatic rollback..."
        ./scripts/rollback.sh
        exit 1
      fi
      echo "Deploy verified successfully"

Результаты

МетрикаДоПосле
Время от коммита до продакшена2 недели18 минут
Частота деплоев2 раза в месяц3-5 раз в день
Даунтайм при деплое15 минут0
Время отката30-60 минут5 секунд
Инциденты из-за деплоя4 за квартал0 за квартал
Покрытие тестами12%78%
Найдено уязвимостей при первом скане12 (3 критичных)

Но самый важный результат — команда «МедТрека» перестала бояться деплоев. Раньше деплой был стрессом и событием, теперь это рутина. Разработчик мёржит PR, пайплайн делает всё остальное. Если что-то падает, откат происходит автоматически.

Рекомендации

  • Начните с контейнеризации — это фундамент для всего остального.
  • Быстрые тесты важнее полных тестов. Пайплайн на 40 минут никто не будет ждать.
  • Security scanning — не опция, а обязательный этап. Особенно для healthtech.
  • Blue-green проще canary для старта. Canary имеет смысл при тысячах запросов в секунду.
  • Feature flags разделяют деплой и релиз. Это меняет культуру разработки.
  • Автоматический откат по метрикам — лучшая страховка от «мы задеплоили и ушли домой».

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

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

📞 Связаться с нами
#bluegreen#container scan (trivy)#dependency check (owasp)#devops#e2e-тесты (10%)#feature#flags#gitlab
Комментарии 0

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

загрузка...