Kubernetes в продакшене: 10 уроков первого года

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

«СмартЛогистик» — это платформа для управления доставками, которая обрабатывает маршруты, отслеживает грузы в реальном времени и оптимизирует логистические цепочки. Инфраструктура: managed Kubernetes кластер на 28 нод (Yandex Cloud), 15 микросервисов на Go и Python, PostgreSQL, Redis, Kafka. Среднесуточный трафик — 3000 RPS с пиками до 8000 RPS в утренние часы.

Урок 1: Resource Limits — не роскошь, а страховка

В первый месяц мы задеплоили сервис обработки маршрутов без resource limits. Он работал нормально при обычной нагрузке, но при пиковой начал потреблять всю доступную память ноды. Kubelet убил его через OOMKill, но прежде чем это случилось, сервис утащил за собой три других пода на той же ноде.

Теперь у нас жёсткое правило: ни один под не попадает в продакшен без limits.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: route-optimizer
  namespace: logistics
spec:
  replicas: 3
  selector:
    matchLabels:
      app: route-optimizer
  template:
    metadata:
      labels:
        app: route-optimizer
    spec:
      containers:
        - name: route-optimizer
          image: registry.smartlogistic.ru/route-optimizer:v2.4.1
          resources:
            requests:
              cpu: 250m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 512Mi

Практические правила, которые мы выработали: requests = типичное потребление (P50), limits = пиковое потребление (P99) с запасом 20%. CPU limits лучше ставить с осторожностью — throttling может быть хуже, чем отсутствие лимита. Для memory limits всё наоборот — ставьте всегда, OOMKill непредсказуем.

Для контроля мы добавили LimitRange на уровне namespace:

apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: logistics
spec:
  limits:
    - type: Container
      default:
        cpu: 500m
        memory: 256Mi
      defaultRequest:
        cpu: 100m
        memory: 128Mi
      max:
        cpu: "4"
        memory: 4Gi

Урок 2: HPA требует тонкой настройки

Horizontal Pod Autoscaler — мощный инструмент, но дефолтная настройка по CPU редко работает хорошо. Наш сервис отслеживания грузов был IO-bound: CPU оставался на 20%, а запросы копились в очереди. HPA не масштабировал, потому что смотрел только на CPU.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: tracking-service-hpa
  namespace: logistics
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: tracking-service
  minReplicas: 3
  maxReplicas: 20
  metrics:
    # Кастомная метрика — количество запросов в секунду на под
    - type: Pods
      pods:
        metric:
          name: http_requests_per_second
        target:
          type: AverageValue
          averageValue: "100"
    # CPU как дополнительный сигнал
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30
      policies:
        - type: Pods
          value: 4
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300  # 5 минут стабилизации перед scale down
      policies:
        - type: Percent
          value: 25
          periodSeconds: 120

Ключевой момент — behavior. Без stabilizationWindow HPA начинает «пилить»: масштабирует вверх, нагрузка падает, масштабирует вниз, нагрузка растёт. Scale down должен быть медленным и осторожным.

Урок 3: Pod Disruption Budgets спасают от простоя

Kubernetes регулярно перемещает поды: обновления нод, кластерный автоскейлинг, spot-инстансы. Без PDB кластер может одновременно убить все реплики сервиса при обновлении ноды.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: tracking-service-pdb
  namespace: logistics
spec:
  minAvailable: 2  # или maxUnavailable: 1
  selector:
    matchLabels:
      app: tracking-service

Мы усвоили это после инцидента: обновление Kubernetes-нод убило все 3 реплики API-шлюза одновременно, потому что все они оказались на двух нодах, попавших под обновление. 4 минуты полного простоя. С PDB drain ноды ждёт, пока новые поды поднимутся на других нодах.

Урок 4: Секреты через HashiCorp Vault, не через Kubernetes Secrets

Kubernetes Secrets хранятся в etcd в base64. Это не шифрование. Любой, кто имеет доступ к кластеру, может прочитать их. Для логистической платформы, работающей с данными клиентов и API-ключами перевозчиков, это неприемлемо.

Мы внедрили HashiCorp Vault с Vault Agent Injector:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-integration
spec:
  template:
    metadata:
      annotations:
        vault.hashicorp.com/agent-inject: "true"
        vault.hashicorp.com/role: "payment-integration"
        vault.hashicorp.com/agent-inject-secret-db-creds: "secret/data/logistics/db"
        vault.hashicorp.com/agent-inject-template-db-creds: |
          {{- with secret "secret/data/logistics/db" -}}
          DB_HOST={{ .Data.data.host }}
          DB_PORT={{ .Data.data.port }}
          DB_USER={{ .Data.data.username }}
          DB_PASSWORD={{ .Data.data.password }}
          {{- end -}}
    spec:
      serviceAccountName: payment-integration
      containers:
        - name: app
          image: registry.smartlogistic.ru/payment-integration:v1.8.0
          command: ["sh", "-c", "source /vault/secrets/db-creds && ./app"]

Vault автоматически ротирует credentials каждые 24 часа. Приложение получает новые секреты через перемонтированный файл без рестарта пода. Audit log показывает, кто и когда запрашивал секреты.

Урок 5: Network Policies — zero trust внутри кластера

По умолчанию любой под в Kubernetes может общаться с любым другим подом. Это значит, что компрометация одного сервиса открывает доступ ко всем остальным.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: tracking-service-netpol
  namespace: logistics
spec:
  podSelector:
    matchLabels:
      app: tracking-service
  policyTypes:
    - Ingress
    - Egress
  ingress:
    # Принимаем трафик только от API gateway и мониторинга
    - from:
        - podSelector:
            matchLabels:
              app: api-gateway
        - podSelector:
            matchLabels:
              app: prometheus
      ports:
        - port: 8080
          protocol: TCP
        - port: 9090  # метрики
          protocol: TCP
  egress:
    # Разрешаем только PostgreSQL, Redis и Kafka
    - to:
        - podSelector:
            matchLabels:
              app: postgresql
      ports:
        - port: 5432
    - to:
        - podSelector:
            matchLabels:
              app: redis
      ports:
        - port: 6379
    - to:
        - podSelector:
            matchLabels:
              app: kafka
      ports:
        - port: 9092
    # DNS
    - to:
        - namespaceSelector: {}
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - port: 53
          protocol: UDP

Не забывайте про DNS в egress. Мы потратили 2 часа на дебаг, пока не поняли, что заблокировали DNS-резолвинг.

Урок 6: Init Containers для управления зависимостями

Сервис стартует быстрее, чем его зависимости. При деплое кластера с нуля сервис падал, потому что PostgreSQL ещё не был готов.

spec:
  initContainers:
    # Ждём готовности PostgreSQL
    - name: wait-for-postgres
      image: busybox:1.36
      command:
        - sh
        - -c
        - |
          until nc -z postgresql.logistics.svc.cluster.local 5432; do
            echo "Waiting for PostgreSQL..."
            sleep 2
          done
    # Выполняем миграции БД
    - name: run-migrations
      image: registry.smartlogistic.ru/tracking-service:v3.1.0
      command: ["./migrate", "up"]
      envFrom:
        - secretRef:
            name: tracking-db-credentials
  containers:
    - name: tracking-service
      image: registry.smartlogistic.ru/tracking-service:v3.1.0

Init containers гарантируют, что основной контейнер стартует только после выполнения всех подготовительных шагов. Миграции БД в init container — особенно удобно: если миграция упала, под не стартует, rollout останавливается, и мы сразу видим проблему.

Урок 7: Liveness vs Readiness — не путайте их

Самая частая ошибка новичков — делать liveness и readiness probes одинаковыми. Это приводит к каскадным рестартам под нагрузкой.

spec:
  containers:
    - name: api-gateway
      ports:
        - containerPort: 8080
      # Readiness: «могу ли я принимать трафик?»
      # Если не ready — под исключается из Service,
      # но НЕ перезапускается
      readinessProbe:
        httpGet:
          path: /healthz/ready
          port: 8080
        initialDelaySeconds: 5
        periodSeconds: 10
        failureThreshold: 3
        successThreshold: 1
      # Liveness: «жив ли процесс вообще?»
      # Если не alive — под ПЕРЕЗАПУСКАЕТСЯ
      livenessProbe:
        httpGet:
          path: /healthz/alive
          port: 8080
        initialDelaySeconds: 15
        periodSeconds: 20
        failureThreshold: 5  # высокий порог — не перезапускаем при временных проблемах
      # Startup probe для медленного старта
      startupProbe:
        httpGet:
          path: /healthz/alive
          port: 8080
        failureThreshold: 30
        periodSeconds: 5  # 30 * 5 = 150 секунд на старт

Наш Go-хендлер для health checks:

func (s *Server) healthAlive(w http.ResponseWriter, r *http.Request) {
    // Liveness: проверяем только что процесс жив
    // НЕ проверяем зависимости — иначе падение БД
    // вызовет рестарт всех подов
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("alive"))
}

func (s *Server) healthReady(w http.ResponseWriter, r *http.Request) {
    // Readiness: проверяем зависимости
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    if err := s.db.PingContext(ctx); err != nil {
        w.WriteHeader(http.StatusServiceUnavailable)
        fmt.Fprintf(w, "db: %v", err)
        return
    }
    if err := s.redis.Ping(ctx).Err(); err != nil {
        w.WriteHeader(http.StatusServiceUnavailable)
        fmt.Fprintf(w, "redis: %v", err)
        return
    }
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("ready"))
}

Правило: liveness probe никогда не должен проверять внешние зависимости. Иначе падение базы данных убьёт все поды приложения, хотя сами процессы работают нормально.

Урок 8: Spot-инстансы экономят 60%, но требуют подготовки

Мы перевели stateless-сервисы на spot-ноды (preemptible instances в Yandex Cloud), сэкономив 62% на вычислительных ресурсах. Но для этого пришлось подготовиться:

  • Все сервисы обрабатывают graceful shutdown за 30 секунд (Kubernetes даёт SIGTERM перед SIGKILL).
  • PDB гарантирует, что минимум 50% реплик всегда доступны.
  • Pod Topology Spread распределяет поды по зонам доступности.
  • On-demand ноды для stateful workloads (PostgreSQL, Kafka, Redis).
spec:
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: topology.kubernetes.io/zone
      whenUnsatisfiable: DoNotSchedule
      labelSelector:
        matchLabels:
          app: tracking-service
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - key: node-type
                operator: In
                values:
                  - spot
  tolerations:
    - key: "cloud.google.com/gke-preemptible"
      operator: "Equal"
      value: "true"
      effect: "NoSchedule"

Урок 9: GitOps с ArgoCD — единственный путь в продакшен

Мы запретили kubectl apply руками в продакшене. Все изменения инфраструктуры идут через Git:

# Структура репозитория
infra/
  base/
    tracking-service/
      deployment.yaml
      service.yaml
      hpa.yaml
      netpol.yaml
      kustomization.yaml
  overlays/
    staging/
      kustomization.yaml
      patches/
    production/
      kustomization.yaml
      patches/
        resource-limits.yaml
        replicas.yaml

ArgoCD следит за Git-репозиторием и автоматически синхронизирует состояние кластера. Любое расхождение видно на дашборде. Если кто-то вручную изменит что-то в кластере — ArgoCD откатит назад.

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: tracking-service
  namespace: argocd
spec:
  project: logistics
  source:
    repoURL: https://git.smartlogistic.ru/infra.git
    path: infra/overlays/production
    targetRevision: main
  destination:
    server: https://kubernetes.default.svc
    namespace: logistics
  syncPolicy:
    automated:
      prune: true       # удалять ресурсы, которых нет в Git
      selfHeal: true     # откатывать ручные изменения
    syncOptions:
      - CreateNamespace=true
    retry:
      limit: 3
      backoff:
        duration: 5s
        factor: 2
        maxDuration: 3m0s

Результат: полная аудируемость (кто, когда, что изменил — видно в git log), мгновенный откат (git revert), одинаковый процесс для staging и production.

Урок 10: Disaster Recovery — проверяйте, а не планируйте

У нас был план аварийного восстановления. Красивый документ в Confluence. Когда кластер упал из-за проблемы с etcd, оказалось, что план устарел на 4 месяца и половина шагов не работает.

Теперь мы проводим DR-учения каждый месяц:

  • Velero для бэкапа ресурсов Kubernetes и persistent volumes. Бэкапы каждые 6 часов, хранение 30 дней.
  • Тест восстановления — раз в месяц поднимаем кластер из бэкапа в отдельном namespace и проверяем работоспособность.
  • Chaos Engineering — используем Litmus Chaos для имитации отказов: убийство подов, отключение нод, инъекция задержек сети.
# Velero: ежедневный бэкап namespace logistics
apiVersion: velero.io/v1
kind: Schedule
metadata:
  name: logistics-daily-backup
  namespace: velero
spec:
  schedule: "0 */6 * * *"
  template:
    includedNamespaces:
      - logistics
    includedResources:
      - deployments
      - services
      - configmaps
      - secrets
      - persistentvolumeclaims
    storageLocation: yandex-s3
    ttl: 720h  # 30 дней
    snapshotVolumes: true

После внедрения ежемесячных учений время восстановления кластера сократилось с «неизвестно» до 22 минут. И, что важнее, команда уверена в процессе и не паникует при реальных инцидентах.

Итоги первого года

Kubernetes — не серебряная пуля. Это мощный инструмент, который требует экспертизы и дисциплины. За год работы с кластером «СмартЛогистик» мы достигли:

  • 99.95% uptime — при SLA 99.9%. Три инцидента за год, максимальный простой — 8 минут.
  • Сокращение расходов на инфраструктуру на 45% — за счёт spot-инстансов, автоскейлинга и правильного ресурсного планирования.
  • Время деплоя 4 минуты — от коммита до продакшена через ArgoCD.
  • DR recovery за 22 минуты — проверенный и отработанный процесс.

Если вы только начинаете с Kubernetes — начните с managed-решения, не экономьте на мониторинге и помните: самые дорогие уроки — те, которые вы учите в продакшене.

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

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

📞 Связаться с нами
#argocd#budgets#chaos engineering#containers#devops#disaster#disruption#dr recovery за 22 минуты
Комментарии 0

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

загрузка...