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

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-ноды, которые в Yandex Cloud называются preemptible-инстансами. И знаете что? Мы смогли сократить наши затраты на вычисления аж на 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, тогда-то и выяснилось: наш план протух аж на четыре месяца, и, к сожалению, добрая половина шагов, описанных в нём, уже просто не работала.

Теперь 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

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

загрузка...
📄
Скачайте подробный разбор в PDF Кейсы, статистика, типовые ошибки и чек-лист самопроверки — 12 страниц
Скачать PDF

Подпишитесь на рассылку ITfresh

Раз в неделю — практические гайды для руководителя IT и сисадмина: безопасность, 1С, миграции, резервные копии, лайфхаки из реальных проектов.

Реквизиты оператора персональных данных

ООО «АЙТИ-ФРЕШ», ИНН 7719418495, КПП 771901001. Юридический адрес: 105523, г. Москва, Щёлковское шоссе, д. 92, корп. 7. Контакт: info@itfresh.ru, +7 903 729-62-41. Оператор обрабатывает e-mail подписчика в целях рассылки информационных и рекламных материалов до момента отзыва согласия.