Чек-лист запуска микросервисов в продакшен: кейс на 8 сервисах

Ситуация: ДатаФлоу запускает платформу аналитики

Компания «ДатаФлоу» — разработчик платформы бизнес-аналитики для ритейла. Команда из 25 разработчиков за год написала 8 микросервисов на Go и Python: сервис приёма данных, ETL-пайплайн, API-шлюз, сервис отчётов, сервис уведомлений, биллинг, пользовательский сервис и сервис интеграций с внешними API.

Клиент обратился к нам за неделю до запланированного релиза. При первичном аудите наша команда ITFresh обнаружила, что 6 из 8 сервисов не готовы к продакшену: отсутствовал мониторинг, логи писались в файлы на диск, не было graceful shutdown, healthcheck-эндпоинтов и единого стандарта конфигурации.

Мы предложили сдвинуть релиз на 3 недели и провести каждый сервис через наш production-ready чек-лист из 20 пунктов. Подробнее о нашем подходе — на itfresh.ru.

Пункт 1: Стандартизация логирования

Первое, что мы обнаружили — хаос в логировании. Три сервиса писали логи в файлы на диск, два использовали plaintext-формат со своими уникальными шаблонами, остальные выводили в stdout, но без структуры. При инциденте инженеру приходилось подключаться к каждому серверу отдельно и парсить логи grep-ом.

Мы установили единый стандарт: все сервисы пишут структурированные JSON-логи в stdout/stderr. Никаких файлов на диске, никакой самодеятельности с ротацией.

Формат и обязательные поля

Каждая строка лога — валидный JSON со стандартизированным набором полей:

{
  "timestamp": "2026-03-15T14:22:03.451Z",
  "level": "error",
  "service": "billing-service",
  "version": "1.4.2",
  "trace_id": "abc123def456",
  "user_id": "usr_98712",
  "message": "Payment processing failed",
  "error": "connection refused: payment-provider:443",
  "duration_ms": 3021
}

Пример настройки структурированного логирования в Go-сервисе:

package main

import (
    "os"
    "log/slog"
)

func initLogger() *slog.Logger {
    level := slog.LevelInfo
    if os.Getenv("LOG_LEVEL") == "debug" {
        level = slog.LevelDebug
    }

    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: level,
    })

    logger := slog.New(handler)
    slog.SetDefault(logger)
    return logger
}

// Использование
func processOrder(ctx context.Context, orderID string) error {
    slog.InfoContext(ctx, "processing order",
        "order_id", orderID,
        "trace_id", traceIDFromCtx(ctx),
    )
    // ...
}

Для Python-сервисов мы использовали python-json-logger:

import logging
from pythonjsonlogger import jsonlogger

def setup_logging():
    handler = logging.StreamHandler()
    formatter = jsonlogger.JsonFormatter(
        '%(timestamp)s %(level)s %(name)s %(message)s',
        rename_fields={'levelname': 'level', 'asctime': 'timestamp'},
        datefmt='%Y-%m-%dT%H:%M:%S.%fZ'
    )
    handler.setFormatter(formatter)
    logging.root.addHandler(handler)
    logging.root.setLevel(
        getattr(logging, os.environ.get('LOG_LEVEL', 'INFO'))
    )

Это устранило необходимость писать Grok-паттерны для парсинга, что раньше было источником постоянных проблем при обновлении форматов.

Пункт 2: Health Check эндпоинты

Ни один из 8 сервисов не имел health check эндпоинтов. Kubernetes не мог определить, жив ли сервис, готов ли он принимать трафик, и при зависании контейнер продолжал получать запросы, возвращая 502-ошибки.

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

Liveness и Readiness Probes

Liveness Probe (/healthz) — проверяет, что процесс не завис. Kubernetes перезапускает контейнер, если проба не отвечает:

// Go: liveness probe
func livenessHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status":"alive"}`))
}

Readiness Probe (/ready) — проверяет, что сервис готов принимать запросы: база данных подключена, кэш прогрет, зависимые сервисы доступны:

// Go: readiness probe с проверкой зависимостей
func readinessHandler(w http.ResponseWriter, r *http.Request) {
    checks := map[string]error{
        "postgres": db.PingContext(r.Context()),
        "redis":    redisClient.Ping(r.Context()).Err(),
        "kafka":    kafkaProducer.Ping(),
    }

    failed := make(map[string]string)
    for name, err := range checks {
        if err != nil {
            failed[name] = err.Error()
        }
    }

    if len(failed) > 0 {
        w.WriteHeader(http.StatusServiceUnavailable)
        json.NewEncoder(w).Encode(map[string]interface{}{
            "status": "not_ready",
            "checks": failed,
        })
        return
    }

    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ready"})
}

Конфигурация в Kubernetes манифесте:

livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet:
    path: /ready
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5
  failureThreshold: 2

Пункт 3: Graceful Shutdown

Критическая проблема, которую мы нашли в 5 из 8 сервисов: при получении SIGTERM процесс немедленно завершался, обрывая текущие запросы и транзакции. В одном случае ETL-сервис прерывал обработку пакета из 10 000 записей на середине, оставляя данные в неконсистентном состоянии.

Корректное завершение работы

Мы внедрили стандартный паттерн graceful shutdown во все сервисы:

package main

import (
    "context"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    srv := &http.Server{Addr: ":8080", Handler: router}

    // Запускаем сервер в горутине
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("HTTP server error: %v", err)
        }
    }()

    // Ожидаем сигнал завершения
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
    <-quit

    log.Info("shutting down gracefully...")

    // Даём 30 секунд на завершение текущих запросов
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    // Останавливаем приём новых запросов
    if err := srv.Shutdown(ctx); err != nil {
        log.Error("forced shutdown", "error", err)
    }

    // Закрываем подключения к БД и очередям
    db.Close()
    kafkaConsumer.Close()
    redisClient.Close()

    log.Info("shutdown complete")
}

Для Python-сервисов с неуправляемыми зависимостями мы использовали dumb-init как PID 1 в контейнере, который корректно пробрасывает сигналы:

FROM python:3.11-slim
RUN apt-get update && apt-get install -y dumb-init
ENTRYPOINT ["dumb-init", "--"]
CMD ["python", "main.py"]

В Kubernetes мы настроили terminationGracePeriodSeconds: 45, давая сервису достаточно времени для корректного завершения перед принудительным SIGKILL.

Пункт 4: Мониторинг и алертинг

Без мониторинга микросервисы превращаются в чёрный ящик. Мы настроили трёхуровневую систему наблюдаемости:

Метрики (Prometheus + Grafana): Каждый сервис экспортирует метрики через /metrics эндпоинт в формате Prometheus. Стандартный набор метрик включает RED (Rate, Errors, Duration) для каждого API-эндпоинта и USE (Utilization, Saturation, Errors) для ресурсов.

Алертинг (Alertmanager → Telegram): Мы настроили обязательные алерты для каждого сервиса:

# alerting-rules.yml
groups:
  - name: microservices
    rules:
      - alert: HighErrorRate
        expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Error rate > 5% on {{ $labels.service }}"

      - alert: HighLatency
        expr: histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])) > 2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "P99 latency > 2s on {{ $labels.service }}"

      - alert: MemoryLeak
        expr: rate(process_resident_memory_bytes[30m]) > 1048576
        for: 30m
        labels:
          severity: warning
        annotations:
          summary: "Memory growing >1MB/30min on {{ $labels.service }}"

      - alert: ServiceDown
        expr: up == 0
        for: 1m
        labels:
          severity: critical

Пункт 5: Конфигурация через окружение и CI/CD

Четыре сервиса хранили конфигурацию в YAML-файлах внутри репозитория, включая пароли к базам данных. Мы перевели все на модель 12-factor app: конфигурация читается из переменных окружения, секреты — из Kubernetes Secrets.

Стандарт конфигурации и автоматизация деплоя

Приоритет чтения конфигурации:

  1. Аргументы командной строки (наивысший)
  2. Переменные окружения
  3. Конфигурационный файл (дефолтные значения)
# Пример .env для локальной разработки
DB_HOST=localhost
DB_PORT=5432
DB_NAME=analytics
DB_USER=app
DB_PASSWORD=dev_password
DB_MAX_CONNS=10
DB_IDLE_CONNS=5

REDIS_URL=redis://localhost:6379/0
KAFKA_BROKERS=localhost:9092

LOG_LEVEL=debug
HTTP_PORT=8080
METRICS_PORT=9090

CI/CD пайплайн мы построили на GitLab CI с принципом «ничего никогда не деплоится вручную»:

# .gitlab-ci.yml
stages:
  - test
  - build
  - deploy

test:
  stage: test
  script:
    - go test -race -coverprofile=coverage.out ./...
    - go vet ./...
    - golangci-lint run
  coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'

build:
  stage: build
  script:
    - docker build -t $REGISTRY/$CI_PROJECT_NAME:$CI_COMMIT_SHA .
    - docker push $REGISTRY/$CI_PROJECT_NAME:$CI_COMMIT_SHA
  only:
    - main

deploy:
  stage: deploy
  script:
    - kubectl set image deployment/$CI_PROJECT_NAME
        app=$REGISTRY/$CI_PROJECT_NAME:$CI_COMMIT_SHA
    - kubectl rollout status deployment/$CI_PROJECT_NAME --timeout=120s
  only:
    - main

Пункт 6: Dead Letter Queue и управление памятью

ETL-сервис и сервис интеграций обрабатывали сообщения из Kafka. При получении невалидного сообщения (например, изменился формат данных от внешнего API) сервис падал, сообщение возвращалось в очередь, и начинался бесконечный цикл «получил → упал → получил».

Обработка ошибок и защита от memory leak

Мы внедрили паттерн Dead Letter Queue (DLQ): после N неудачных попыток обработки сообщение отправляется в отдельную очередь для ручного анализа:

func processMessage(msg *kafka.Message) error {
    retryCount := getRetryCount(msg.Headers)

    if retryCount >= maxRetries {
        log.Warn("max retries exceeded, sending to DLQ",
            "topic", msg.Topic,
            "retry_count", retryCount,
        )
        return producer.Send(dlqTopic, msg.Key, msg.Value)
    }

    if err := handler.Process(msg); err != nil {
        log.Error("processing failed",
            "error", err,
            "retry_count", retryCount,
        )
        // Отправляем обратно в очередь с увеличенным счётчиком
        return producer.SendWithRetry(msg.Topic, msg, retryCount+1)
    }

    return nil
}

Для защиты от утечек памяти в долгоживущих сервисах мы добавили самоограничение: сервис обрабатывает максимум N сообщений, затем корректно завершается и перезапускается Kubernetes:

MAX_MESSAGES_BEFORE_RESTART=50000

# В коде сервиса
processed := 0
for msg := range consumer.Messages() {
    processMessage(msg)
    processed++
    if processed >= maxMessages {
        log.Info("message limit reached, shutting down for restart",
            "processed", processed,
        )
        shutdown()
    }
}

Это проще, чем искать утечки памяти в production, и надёжно предотвращает OOM-kill.

Пункт 7: Управление зависимостями и горизонтальное масштабирование

Наш аудит показал ещё две критические проблемы. Во-первых, зависимости не были зафиксированы — go.sum не коммитился, Python-сервисы использовали pip install без версий. Сборка одного и того же коммита в разные дни давала разные бинарники.

Во-вторых, ни один сервис не был готов к горизонтальному масштабированию: сервис отчётов хранил состояние в памяти, биллинг использовал локальный файловый кэш.

Фиксация версий и stateless-архитектура

Правила фиксации зависимостей:

  • Все версии фиксируются с точностью до патча: fastapi==0.109.2, не fastapi>=0.109
  • Каждое обновление зависимости — отдельный коммит для лёгкого отката
  • Lock-файлы (go.sum, poetry.lock) обязательно в репозитории
  • Dependabot проверяет обновления безопасности еженедельно

Для перехода к stateless-архитектуре:

# Вместо локального файлового кэша — Redis
import redis

cache = redis.Redis(
    host=os.environ['REDIS_HOST'],
    port=6379,
    decode_responses=True
)

def get_report(report_id: str) -> dict:
    cached = cache.get(f"report:{report_id}")
    if cached:
        return json.loads(cached)

    report = generate_report(report_id)
    cache.setex(f"report:{report_id}", 3600, json.dumps(report))
    return report

После этих изменений каждый сервис мог масштабироваться горизонтально через HPA в Kubernetes:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: etl-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: etl-service
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

Результаты и итоговый чек-лист

За 3 недели наша команда провела все 8 сервисов «ДатаФлоу» через полный production-ready чек-лист. Итоговый статус:

КритерийДо аудита (из 8)После (из 8)
Структурированные JSON-логи в stdout28
Health check эндпоинты08
Graceful shutdown38
Мониторинг и алерты18
CI/CD без ручных шагов58
Горизонтальное масштабирование07 (1 Kafka consumer)
DLQ для очередей03 (все consumer-сервисы)
Зафиксированные зависимости48

Платформа была запущена в production с задержкой всего в 3 недели от первоначального плана, но с уровнем надёжности, который без подготовки достигается обычно через полгода боевой эксплуатации. За первые 2 месяца работы — ноль критических инцидентов.

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

При наличии готового чек-листа и опыта — 2-3 дня на один сервис. Основное время уходит на внедрение graceful shutdown и health check, так как они требуют изменений в коде самого приложения.
В контейнерной среде сервис не должен знать, куда идут его логи. Запись в stdout позволяет оркестратору (Docker, Kubernetes) собирать логи централизованно. Файловые логи создают проблемы с ротацией, дисковым пространством и невозможностью агрегации.
Liveness проверяет, жив ли процесс. Если нет — Kubernetes перезапускает контейнер. Readiness проверяет, готов ли сервис принимать трафик. Если нет — Kubernetes убирает pod из балансировки, но не перезапускает его. Это разные механизмы с разными целями.
Используйте паттерн Dead Letter Queue: после N неудачных попыток обработки сообщение перемещается в отдельную очередь для ручного анализа. Это предотвращает зацикливание и позволяет разобраться с проблемой без потери данных.
Да, обязательно. Фиксация только major-версий приводит к тому, что сборка одного и того же коммита в разные дни может дать разный результат. Каждое обновление зависимости должно быть осознанным решением в отдельном коммите.

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

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

📞 Связаться с нами
#микросервисы production#чек-лист микросервисов#мониторинг сервисов#graceful shutdown#healthcheck kubernetes#логирование json#ci cd пайплайн#dead letter queue
Комментарии 0

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

загрузка...