Микросервисы на Java: от Spring Boot до мониторинга в Grafana через Docker и Kubernetes

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

Страховая компания «СтрахТех» запускала новую платформу онлайн-страхования. Монолитное Java-приложение на Spring Boot 2.x обслуживало все процессы: от расчёта тарифов до выпуска полисов. При росте до 50 000 пользователей в день монолит перестал справляться: время расчёта тарифа увеличилось с 200 мс до 3 секунд, деплой занимал 2 часа с даунтаймом, а команда из 15 разработчиков мешала друг другу в одном репозитории.

Мы декомпозировали монолит на 6 микросервисов:

  • customer-service — управление профилями клиентов (MongoDB)
  • tariff-service — расчёт страховых тарифов (PostgreSQL)
  • policy-service — выпуск и управление полисами (PostgreSQL)
  • payment-service — интеграция с платёжными системами (Redis + PostgreSQL)
  • notification-service — email, SMS, push (RabbitMQ)
  • gateway-service — API Gateway на Spring Cloud Gateway

Spring Boot: структура микросервиса

Каждый сервис построен на Spring Boot 3.2 с единообразной структурой. Пример конфигурации customer-service:

# application.yml
spring:
  application:
    name: customer-service
  data:
    mongodb:
      uri: ${MONGODB_URI:mongodb://localhost:27017}
      database: customers
  profiles:
    active: ${SPRING_PROFILES_ACTIVE:local}

server:
  port: 8080
  servlet:
    context-path: /customer

# Actuator для мониторинга
management:
  endpoints:
    web:
      exposure:
        include: health, prometheus, info, metrics
  metrics:
    tags:
      application: ${spring.application.name}
    distribution:
      percentiles-histogram:
        http.server.requests: true
  health:
    mongo:
      enabled: true

Ключевые зависимости в build.gradle:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'

    // Метрики для Prometheus
    implementation 'io.micrometer:micrometer-registry-prometheus'

    // Распределённые трейсы
    implementation 'io.micrometer:micrometer-tracing-bridge-otel'
    implementation 'io.opentelemetry:opentelemetry-exporter-otlp'

    // Межсервисное взаимодействие
    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

REST-контроллер с валидацией и обработкой ошибок:

@RestController
@RequestMapping("/api/v1/customers")
@RequiredArgsConstructor
public class CustomerController {

    private final CustomerService customerService;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public CustomerResponse create(@Valid @RequestBody CustomerRequest request) {
        return customerService.create(request);
    }

    @GetMapping("/{id}")
    public CustomerResponse getById(@PathVariable String id) {
        return customerService.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Customer", id));
    }

    @GetMapping("/search")
    public Page<CustomerResponse> search(
            @RequestParam(required = false) String email,
            @RequestParam(required = false) String phone,
            Pageable pageable) {
        return customerService.search(email, phone, pageable);
    }
}

Docker: контейнеризация сервисов

Мы используем два подхода к сборке Docker-образов: Cloud Native Buildpacks для простоты и multi-stage Dockerfile для контроля:

Вариант 1: Buildpacks (рекомендуем для большинства случаев)

# Сборка без Dockerfile — Spring Boot сам создаёт оптимальный образ
./gradlew bootBuildImage --imageName=strahteh/customer-service:1.0.0

# Или через Maven
./mvnw spring-boot:build-image \
    -Dspring-boot.build-image.imageName=strahteh/customer-service:1.0.0

Вариант 2: Multi-stage Dockerfile (для полного контроля)

# Dockerfile
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app

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

# Копируем исходники и собираем
COPY src ./src
RUN ./gradlew bootJar --no-daemon -x test

# Финальный образ — только JRE
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app

# Безопасность: не запускаем от root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

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

# Оптимизация JVM для контейнера
ENV JAVA_OPTS="-XX:+UseContainerSupport \
    -XX:MaxRAMPercentage=75.0 \
    -XX:+UseG1GC \
    -XX:+ExitOnOutOfMemoryError"

EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s \
    CMD wget -qO- http://localhost:8080/customer/actuator/health || exit 1

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Размер образа: 380 МБ с JDK → 185 МБ с JRE Alpine. Время сборки при изменении только Java-кода: 45 секунд вместо 3 минут благодаря кэшированию зависимостей.

Kubernetes: деплоймент и сервисы

Для оркестрации мы выбрали Amazon EKS. Создание кластера:

# Создание кластера EKS
eksctl create cluster \
    --name strahteh-prod \
    --region eu-central-1 \
    --node-type t3.medium \
    --nodes 3 \
    --nodes-min 2 \
    --nodes-max 6 \
    --managed

# Проверка
kubectl get nodes

Deployment для customer-service с readiness/liveness probes и ресурсными лимитами:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: customer-service
  namespace: strahteh
  labels:
    app: customer-service
spec:
  replicas: 2
  selector:
    matchLabels:
      app: customer-service
  template:
    metadata:
      labels:
        app: customer-service
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "8080"
        prometheus.io/path: "/customer/actuator/prometheus"
    spec:
      containers:
        - name: customer-service
          image: 123456789.dkr.ecr.eu-central-1.amazonaws.com/customer-service:1.0.0
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: "prod"
            - name: MONGODB_URI
              valueFrom:
                secretKeyRef:
                  name: customer-db-secret
                  key: uri
          resources:
            requests:
              memory: "512Mi"
              cpu: "250m"
            limits:
              memory: "1Gi"
              cpu: "500m"
          readinessProbe:
            httpGet:
              path: /customer/actuator/health/readiness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /customer/actuator/health/liveness
              port: 8080
            initialDelaySeconds: 60
            periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
  name: customer-service
  namespace: strahteh
spec:
  selector:
    app: customer-service
  ports:
    - port: 80
      targetPort: 8080
  type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: customer-service-hpa
  namespace: strahteh
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: customer-service
  minReplicas: 2
  maxReplicas: 8
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

Helm Charts: шаблонизация деплойментов

С 6 сервисами ручное управление YAML-файлами превращается в кошмар. Мы создали Helm chart с параметризацией:

# charts/microservice/values.yaml
replicaCount: 2

image:
  repository: 123456789.dkr.ecr.eu-central-1.amazonaws.com
  tag: "latest"
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 80
  targetPort: 8080

resources:
  requests:
    memory: "512Mi"
    cpu: "250m"
  limits:
    memory: "1Gi"
    cpu: "500m"

autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 8
  targetCPUUtilizationPercentage: 70

spring:
  profile: "prod"
  contextPath: "/customer"

monitoring:
  enabled: true
  path: "/actuator/prometheus"

healthCheck:
  readinessPath: "/actuator/health/readiness"
  livenessPath: "/actuator/health/liveness"

Деплоим каждый сервис одной командой:

# Деплой customer-service
helm upgrade --install customer-service ./charts/microservice \
    --namespace strahteh \
    --set image.repository=123456789.dkr.ecr.eu-central-1.amazonaws.com/customer-service \
    --set image.tag=1.2.0 \
    --set spring.contextPath=/customer \
    --set replicaCount=3

# Деплой tariff-service с другими параметрами
helm upgrade --install tariff-service ./charts/microservice \
    --namespace strahteh \
    --set image.repository=123456789.dkr.ecr.eu-central-1.amazonaws.com/tariff-service \
    --set image.tag=2.0.1 \
    --set spring.contextPath=/tariff \
    --set resources.limits.memory=2Gi \
    --set resources.limits.cpu=1000m

# Откат при проблемах
helm rollback customer-service 1

# История релизов
helm history customer-service -n strahteh

Мониторинг: Prometheus + Grafana

Стек мониторинга мы развернули через kube-prometheus-stack:

# Установка Prometheus и Grafana через Helm
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

helm install monitoring prometheus-community/kube-prometheus-stack \
    --namespace monitoring \
    --create-namespace \
    --set grafana.adminPassword='SecurePassword123' \
    --set prometheus.prometheusSpec.retention=30d \
    --set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.resources.requests.storage=50Gi

Spring Boot Actuator автоматически экспортирует метрики в формате Prometheus. Ключевые метрики, которые мы отслеживаем:

# Grafana Dashboard — PromQL запросы

# RED Metrics (Rate, Errors, Duration)

# Запросов в секунду по сервису
rate(http_server_requests_seconds_count{namespace="strahteh"}[5m])

# Процент ошибок (5xx)
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count[5m])) * 100

# 95-й перцентиль латентности
histogram_quantile(0.95,
    sum(rate(http_server_requests_seconds_bucket{namespace="strahteh"}[5m])) by (le, application)
)

# JVM метрики
jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} * 100
jvm_gc_pause_seconds_sum / jvm_gc_pause_seconds_count

# Кастомные бизнес-метрики
rate(policies_issued_total[1h])  # Полисов в час
histogram_quantile(0.99, rate(tariff_calculation_seconds_bucket[5m]))  # Время расчёта тарифа

Мы импортировали готовые дашборды из Grafana Labs: ID 12900 (Spring Boot APM), ID 6417 (Kubernetes Cluster), ID 14282 (JVM Micrometer). Дополнительно создали кастомный дашборд с бизнес-метриками: конверсия на каждом этапе воронки, среднее время выпуска полиса, распределение по типам страхования.

Распределённые трейсы с Jaeger

В микросервисной архитектуре один пользовательский запрос проходит через 3-5 сервисов. Без трейсинга найти причину медленного ответа невозможно. Мы развернули Jaeger:

# Деплой Jaeger в Kubernetes
helm repo add jaegertracing https://jaegertracing.github.io/helm-charts
helm install jaeger jaegertracing/jaeger \
    --namespace monitoring \
    --set provisionDataStore.cassandra=false \
    --set storage.type=elasticsearch \
    --set storage.elasticsearch.host=elasticsearch.monitoring.svc

# application.yml — настройка трейсинга в Spring Boot
management:
  tracing:
    sampling:
      probability: 0.1  # Семплируем 10% запросов в проде

otel:
  exporter:
    otlp:
      endpoint: http://jaeger-collector.monitoring.svc:4317
  resource:
    attributes:
      service.name: ${spring.application.name}

Пример инструментации критичного метода:

@Service
@RequiredArgsConstructor
public class TariffService {

    private final TariffRepository tariffRepository;
    private final CustomerClient customerClient;
    private final ObservationRegistry observationRegistry;

    public TariffResponse calculate(TariffRequest request) {
        return Observation.createNotStarted("tariff.calculate", observationRegistry)
                .lowCardinalityKeyValue("insurance.type", request.getType().name())
                .observe(() -> {
                    // Получаем профиль клиента из customer-service
                    var customer = customerClient.getById(request.getCustomerId());

                    // Загружаем базовые тарифы
                    var baseTariff = tariffRepository.findByType(request.getType());

                    // Расчёт с учётом рисков
                    var riskCoeff = calculateRisk(customer, request);
                    var premium = baseTariff.getBaseRate()
                            .multiply(riskCoeff)
                            .multiply(request.getCoverage());

                    return new TariffResponse(premium, riskCoeff, baseTariff);
                });
    }
}

Jaeger визуализирует водопад вызовов: gateway-service → tariff-service → customer-service → MongoDB. Мы сразу видим, что 60% времени расчёта тарифа тратилось на вызов customer-service, и решили проблему добавлением Redis-кэша для профилей клиентов. Время расчёта сократилось с 3 секунд до 180 мс.

CI/CD Pipeline

Мы построили pipeline в GitHub Actions с автоматическим деплоем в Kubernetes:

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [main]
    paths:
      - 'services/customer-service/**'

env:
  ECR_REGISTRY: 123456789.dkr.ecr.eu-central-1.amazonaws.com
  SERVICE_NAME: customer-service

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'gradle'

      - name: Run tests
        run: |
          cd services/${{ env.SERVICE_NAME }}
          ./gradlew test jacocoTestReport

      - name: Check coverage
        run: |
          COVERAGE=$(cat services/${{ env.SERVICE_NAME }}/build/reports/jacoco/test/jacocoTestReport.csv | \
              awk -F, 'NR>1 {missed+=$4; covered+=$5} END {printf "%.0f", covered*100/(missed+covered)}')
          echo "Coverage: ${COVERAGE}%"
          if [ "$COVERAGE" -lt 80 ]; then
            echo "Coverage ${COVERAGE}% is below 80% threshold"
            exit 1
          fi

  build-and-deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: eu-central-1

      - name: Login to ECR
        run: aws ecr get-login-password | docker login --username AWS --password-stdin ${{ env.ECR_REGISTRY }}

      - name: Build and push
        run: |
          cd services/${{ env.SERVICE_NAME }}
          VERSION=$(date +%Y%m%d)-${GITHUB_SHA::7}
          docker build -t ${{ env.ECR_REGISTRY }}/${{ env.SERVICE_NAME }}:${VERSION} .
          docker push ${{ env.ECR_REGISTRY }}/${{ env.SERVICE_NAME }}:${VERSION}
          echo "VERSION=${VERSION}" >> $GITHUB_ENV

      - name: Deploy to EKS
        run: |
          aws eks update-kubeconfig --name strahteh-prod --region eu-central-1
          helm upgrade --install ${{ env.SERVICE_NAME }} ./charts/microservice \
              --namespace strahteh \
              --set image.repository=${{ env.ECR_REGISTRY }}/${{ env.SERVICE_NAME }} \
              --set image.tag=${{ env.VERSION }} \
              --wait --timeout 5m

      - name: Verify deployment
        run: |
          kubectl rollout status deployment/${{ env.SERVICE_NAME }} -n strahteh --timeout=300s
          # Проверяем, что поды здоровы
          READY=$(kubectl get deployment ${{ env.SERVICE_NAME }} -n strahteh -o jsonpath='{.status.readyReplicas}')
          if [ "$READY" -lt 2 ]; then
            echo "Only $READY replicas ready, rolling back"
            helm rollback ${{ env.SERVICE_NAME }} -n strahteh
            exit 1
          fi

Время от коммита до продакшена: 8 минут. Каждый сервис деплоится независимо — изменение в customer-service не затрагивает tariff-service.

Результаты

Через 4 месяца после миграции на микросервисы:

МетрикаМонолитМикросервисы
Время расчёта тарифа (P95)3 000 мс180 мс
Деплой2 часа с даунтаймом8 минут, zero downtime
Частота деплоев1 раз в 2 недели3-5 раз в день
Время обнаружения проблем30-60 минут2-3 минуты (алерты)
МасштабированиеВесь монолитТолько нужный сервис
Потребление ресурсов16 ГБ RAM (один процесс)6 ГБ RAM (6 сервисов)

Ключевые уроки:

  • Cloud Native Buildpacks экономят время на написание и поддержку Dockerfile
  • Helm charts обязательны при 3+ сервисах — ручное управление YAML не масштабируется
  • Tracing (Jaeger) дал больше пользы, чем метрики — он показал конкретные места задержек
  • HPA (автоскейлинг) по CPU работает для большинства сервисов, но tariff-service потребовал кастомных метрик (очередь расчётов)
  • 12-factor app принципы — логи в stdout, конфигурация через env variables — упростили контейнеризацию

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

Buildpacks автоматически создают оптимальный OCI-образ: правильные слои для кэширования, безопасный базовый образ, оптимальные JVM-параметры для контейнера. Не нужно поддерживать Dockerfile и следить за обновлениями базовых образов. Для 90% Spring Boot сервисов Buildpacks достаточно.
Managed Kubernetes (EKS/GKE) оправдан при команде до 3 DevOps-инженеров — обновления control plane, etcd-бэкапы и интеграция с облачными сервисами уже включены. Самоуправляемый Kubernetes дешевле на 30-40%, но требует выделенного инженера. Для команды до 50 разработчиков рекомендуем managed.
В продакшене 1-10% достаточно для большинства случаев. 100% семплирование создаёт заметную нагрузку и объём хранимых данных. Для отладки конкретной проблемы временно увеличьте до 100% для конкретного сервиса через переменную окружения. В staging всегда 100%.
Kubernetes Ingress (nginx-ingress, traefik) обеспечивает базовую маршрутизацию, TLS-терминацию и rate limiting. Отдельный API Gateway (Spring Cloud Gateway, Kong) добавляет авторизацию, трансформацию запросов, агрегацию ответов и circuit breaker. Если нужна бизнес-логика на уровне API — используйте оба.
REST (через OpenFeign) проще в отладке, имеет широкую поддержку инструментов и подходит для 80% случаев. gRPC даёт в 3-5 раз меньший overhead на сериализацию и поддерживает streaming, но усложняет отладку. Рекомендуем начать с REST, а перейти на gRPC только для высоконагруженных внутренних вызовов.

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

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

📞 Связаться с нами
#spring boot#docker#kubernetes#helm#prometheus#grafana#jaeger#microservices
Комментарии 0

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

загрузка...