Миграция в Kubernetes: 8 ловушек, которые мы обошли для ТрейдБот

Клиент и контекст проекта

«ТрейдБот» — торговая платформа для автоматизированной торговли на Московской бирже. 14 микросервисов, 3 разработчика в штате, инфраструктура на 6 выделенных серверах с ручным деплоем через ansible-плейбуки. Пиковая нагрузка: 2000 заявок в секунду в период открытия торгов.

Клиент обратился к нам в феврале 2026 года с задачей: перенести платформу в Kubernetes для автоматического масштабирования (нагрузка различается в 50 раз между торговыми сессиями и ночью) и ускорения деплоя (текущий процесс занимал 2 часа с даунтаймом 10-15 минут).

Мы сформировали команду из 2 DevOps-инженеров и одного архитектора. Срок проекта — 8 недель. В процессе миграции мы столкнулись с 8 типичными проблемами, о которых многие команды забывают при переносе приложений в Kubernetes. Ниже — подробный разбор каждой.

Ловушка 1: Stateful-компоненты в Kubernetes

Kubernetes спроектирован для stateless-приложений. Поды динамически создаются, убиваются и перемещаются между нодами. Если приложение хранит данные на локальном диске контейнера — они исчезнут при рестарте.

У «ТрейдБот» три компонента хранили состояние локально:

  • Кеш котировок — в памяти, Redis-подобная структура
  • Журнал транзакций — файлы на диске для аудита
  • Сессии пользователей — в локальном хранилище процесса

Решение для каждого:

КомпонентБылоСтало
Кеш котировокIn-memory словарьRedis Cluster (managed)
Журнал транзакцийФайлы на дискеS3-совместимое хранилище (MinIO)
Сессии пользователейЛокальная памятьRedis с TTL 30 мин

Для PostgreSQL мы использовали оператор postgres-operator от Zalando, который автоматизирует failover, бэкапы и масштабирование. Бойтесь запускать базы данных без операторов — ручное управление StatefulSet в production приводит к потерям данных.

Ловушка 2: отсутствие Liveness и Readiness Probes

Без probes Kubernetes не знает, жив ли ваш контейнер и готов ли принимать трафик. Результат — пользователи попадают на «мёртвый» под, а Kubernetes не перезапускает зависший процесс.

Мы реализовали три типа проверок для каждого сервиса «ТрейдБот»:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  template:
    spec:
      containers:
        - name: order-service
          image: tradebot/order-service:1.4.2
          ports:
            - containerPort: 8080
          # Startup probe — даём время на инициализацию
          startupProbe:
            httpGet:
              path: /health/startup
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 30  # До 150 сек на старт
          # Liveness — перезапуск при зависании
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            periodSeconds: 15
            timeoutSeconds: 5
            failureThreshold: 3
          # Readiness — исключение из балансировки
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 2

Критически важно: liveness probe не должен проверять зависимости (БД, Redis). Если БД недоступна — все поды будут убиты и перезапущены одновременно, создав каскадный сбой. Liveness проверяет только «процесс жив», readiness — «могу обслуживать запросы».

У «ТрейдБот» именно этот сценарий произошёл на стейджинге: liveness probe проверял подключение к PostgreSQL, БД ушла на failover на 8 секунд, и все 6 реплик order-service были убиты одновременно.

Ловушка 3: неправильные лимиты CPU и Memory

Kubernetes ограничивает ресурсы контейнеров через requests (гарантированный минимум) и limits (потолок). Без них pod может занять все ресурсы ноды или, наоборот, быть убит OOM Killer.

Мы профилировали каждый сервис «ТрейдБот» в течение недели на стейджинге с реалистичной нагрузкой и получили базовые метрики:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  template:
    spec:
      containers:
        - name: order-service
          resources:
            requests:
              cpu: "250m"      # 0.25 ядра — гарантированный минимум
              memory: "256Mi"  # 256 МБ — гарантированный минимум
            limits:
              cpu: "1000m"     # 1 ядро — потолок
              memory: "512Mi"  # 512 МБ — OOM Kill при превышении

Ловушка с CPU throttling: Kubernetes делит процессорное время квантами по 100 мс. Если приложение использует несколько потоков и выбирает весь лимит CPU за первые 30 мс кванта — оставшиеся 70 мс контейнер будет заморожен. Для торгового сервиса с требованием латентности <10 мс это критично.

Решение: мы установили CPU limits в 2x от requests для латентно-чувствительных сервисов, а для batch-обработки (отчёты, аналитика) — оставили жёсткие лимиты.

Для мониторинга мы развернули VictoriaMetrics с дашбордами по каждому сервису:

# PromQL запрос для отслеживания CPU throttling
rate(container_cpu_cfs_throttled_seconds_total{namespace="tradebot"}[5m])
/
rate(container_cpu_usage_seconds_total{namespace="tradebot"}[5m])
* 100

Ловушка 4: секреты и конфигурация внутри образов

Разработчики «ТрейдБот» хранили конфигурацию в .env файлах внутри Docker-образов. Одни и те же образы для dev, staging и production — каждый со своим набором переменных окружения, зашитых при сборке. Это означало:

  • Отдельный образ для каждого окружения (3 сборки вместо одной)
  • Пароли от production БД попадали в Docker registry
  • Изменение конфигурации требовало пересборки образа

Мы перевели всё на нативные механизмы Kubernetes:

# ConfigMap — для неконфиденциальных параметров
apiVersion: v1
kind: ConfigMap
metadata:
  name: order-service-config
  namespace: tradebot
data:
  LOG_LEVEL: "info"
  MAX_ORDERS_PER_SECOND: "500"
  CACHE_TTL_SECONDS: "60"
  EXCHANGE_API_URL: "https://api.moex.com/v1"
---
# Secret — для паролей и ключей
apiVersion: v1
kind: Secret
metadata:
  name: order-service-secrets
  namespace: tradebot
type: Opaque
stringData:
  DB_PASSWORD: "${SEALED_SECRET}"
  EXCHANGE_API_KEY: "${SEALED_SECRET}"
  JWT_SECRET: "${SEALED_SECRET}"

Для production-секретов мы использовали Sealed Secrets от Bitnami — секреты шифруются публичным ключом кластера и безопасно хранятся в Git-репозитории.

Подключение в deployment:

spec:
  containers:
    - name: order-service
      envFrom:
        - configMapRef:
            name: order-service-config
        - secretRef:
            name: order-service-secrets

Теперь один Docker-образ работает в любом окружении — меняется только ConfigMap и Secret.

Ловушка 5: Graceful Shutdown и обработка SIGTERM

При обновлении или масштабировании Kubernetes отправляет SIGTERM в PID 1 контейнера. У приложения есть terminationGracePeriodSeconds (по умолчанию 30 секунд) на корректное завершение. Затем — SIGKILL.

Для торговой платформы это критически важно: незавершённая заявка на бирже может привести к финансовым потерям. Мы реализовали graceful shutdown для каждого сервиса:

# Python (FastAPI) — обработка SIGTERM
import signal
import asyncio
from contextlib import asynccontextmanager

shutdown_event = asyncio.Event()

def handle_sigterm(signum, frame):
    shutdown_event.set()

signal.signal(signal.SIGTERM, handle_sigterm)

@asynccontextmanager
async def lifespan(app):
    # Startup
    await init_db_pool()
    await connect_exchange()
    yield
    # Shutdown — graceful
    logger.info("SIGTERM received, finishing pending orders...")
    await finish_pending_orders(timeout=25)  # 25 сек из 30
    await close_exchange_connection()
    await close_db_pool()
    logger.info("Graceful shutdown complete")

Мы увеличили terminationGracePeriodSeconds до 60 секунд для order-service, так как завершение ордера на бирже может занять до 45 секунд:

spec:
  terminationGracePeriodSeconds: 60

Важный нюанс: многие приложения запускают shell (bash) как PID 1 через ENTRYPOINT. Shell не пробрасывает SIGTERM дочернему процессу. Решение — использовать exec-форму CMD/ENTRYPOINT или tini как init-процесс.

Ловушка 6-7: балансировка между репликами и HTTPS через Ingress

Ловушка 6: При горизонтальном масштабировании (HPA) количество реплик меняется динамически. Приложение не должно полагаться на конкретный под — любой запрос может попасть на любую реплику.

У «ТрейдБот» сервис аналитики хранил промежуточные вычисления в памяти процесса. При масштабировании с 2 до 5 реплик пользователи получали разные данные в зависимости от того, на какой под попал запрос. Мы вынесли все промежуточные результаты в Redis и сконфигурировали HPA:

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

Ловушка 7: Приложение за Ingress (nginx) работает по HTTP внутри кластера, но снаружи — HTTPS. Если приложение генерирует абсолютные URL с http://, браузер получит mixed content или бесконечный редирект.

Для «ТрейдБот» мы настроили доверенные прокси в каждом фреймворке:

# Python FastAPI — корректная работа за reverse proxy
from fastapi import FastAPI
from fastapi.middleware.trustedhost import TrustedHostMiddleware

app = FastAPI(root_path="/api")
app.add_middleware(
    TrustedHostMiddleware,
    allowed_hosts=["tradebot.ru", "*.tradebot.ru"]
)

# uvicorn запуск с proxy headers
# CMD ["uvicorn", "app:app", "--proxy-headers", "--forwarded-allow-ips", "*"]

Ingress конфигурация:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: tradebot-ingress
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
  tls:
    - hosts:
        - tradebot.ru
        - api.tradebot.ru
      secretName: tradebot-tls
  rules:
    - host: api.tradebot.ru
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-gateway
                port:
                  number: 8080

Ловушка 8: SSL-сертификаты и cert-manager

Последняя ловушка — ручное управление SSL-сертификатами. Разработчики «ТрейдБот» обновляли Let's Encrypt сертификаты вручную через certbot на каждом из 6 серверов. Дважды за 2025 год сертификат истекал незамеченным, приводя к недоступности платформы.

В Kubernetes мы развернули cert-manager — контроллер, который автоматически выпускает и обновляет сертификаты:

# Установка cert-manager
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set installCRDs=true

# ClusterIssuer для Let's Encrypt
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: devops@tradebot.ru
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
      - http01:
          ingress:
            class: nginx

Теперь в Ingress достаточно указать аннотацию — cert-manager сам выпустит и обновит сертификат:

metadata:
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
  tls:
    - hosts:
        - tradebot.ru
      secretName: tradebot-tls  # cert-manager создаст автоматически

Сертификаты обновляются за 30 дней до истечения. За 4 месяца после внедрения — ноль инцидентов, связанных с SSL. Подробнее о Kubernetes best practices — на itfresh.ru.

Результаты миграции

Миграция «ТрейдБот» в Kubernetes заняла 7 недель (на неделю меньше плана). Все 14 сервисов работают в managed Kubernetes-кластере с 3 нодами (масштабируется до 8 в пиковые часы).

МетрикаДо миграцииПосле миграции
Время деплоя2 часа8 минут
Даунтайм при деплое10-15 мин0 (rolling update)
МасштабированиеРучное (часы)Автоматическое (минуты)
Инциденты SSL2 за год0
Стоимость инфраструктуры186K ₽/мес (6 серверов)94K ₽/мес (avg 3 ноды)
Восстановление после сбоя30-60 мин2-3 мин (self-healing)

Экономия на инфраструктуре — 50%, за счёт автомасштабирования: ночью работает 2 ноды, в торговые часы — до 8. Ранее 6 серверов работали 24/7 на полную мощность.

Рекомендуем изучить методологию The Twelve-Factor App перед миграцией — она закрывает 80% типичных проблем при переходе на контейнерную инфраструктуру.

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

Можно, но только с операторами (postgres-operator, mysql-operator, redis-operator). Операторы автоматизируют failover, бэкапы, масштабирование. Без оператора StatefulSet требует ручного управления, что неприемлемо для production. Альтернатива — managed базы данных (RDS, Cloud SQL) за пределами кластера.
Liveness probe определяет, жив ли контейнер. При неудаче — контейнер убивается и перезапускается. Readiness probe проверяет, готов ли контейнер принимать трафик. При неудаче — под исключается из балансировки Service, но не перезапускается. Liveness должен проверять только процесс, readiness — доступность зависимостей.
Установите requests на уровне среднего потребления (по данным мониторинга за неделю), а limits — в 2-4 раза выше для обработки пиков. Для латентно-чувствительных сервисов можно убрать CPU limits совсем (только requests), чтобы избежать throttling. Обязательно мониторьте метрику container_cpu_cfs_throttled_seconds_total.
Kubernetes Secrets по умолчанию хранятся в etcd в base64 (не шифрованные). Для production рекомендуем: Sealed Secrets от Bitnami для хранения в Git, или внешние хранилища (HashiCorp Vault, AWS Secrets Manager) через External Secrets Operator. Также включите encryption at rest для etcd.
Используйте tini (--init флаг в Docker) как PID 1 — он корректно пробрасывает сигналы дочернему процессу. Или оберните приложение в скрипт с trap: trap 'kill -TERM $PID' TERM. В Kubernetes добавьте preStop хук с командой sleep 5 для гарантии, что Endpoints обновятся до начала завершения.

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

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

📞 Связаться с нами
#миграция Kubernetes#Kubernetes ошибки#liveness readiness probes#Kubernetes ресурсы#ConfigMap Secrets#graceful shutdown#Ingress SSL#cert-manager
Комментарии 0

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

загрузка...