CI/CD, инфраструктура и облака Kubernetes: архитектура, объекты, сеть, хранилища, автоскейлинг и эксплуатация
0%

Kubernetes: архитектура, объекты, сеть, хранилища, автоскейлинг и эксплуатация

Kubernetes: архитектура, объекты, сеть, хранилища, автоскейлинг и эксплуатация

В предыдущей статье — https://courses.digitable.life/post/devops/05-containers-and-registries/ — мы собрали образ, уменьшили его, подписали и положили в реестр. Теперь его нужно запустить: на десятке машин, с автоматической заменой упавших экземпляров, с балансировкой, с дисками, с плавным обновлением и с возможностью пережить смерть половины нод. Именно эту задачу решает Kubernetes.

Статья устроена как маршрут через реальную эксплуатацию: сначала — что такое Kubernetes на уровне идеи (это важнее, чем кажется: почти все ошибки новичков растут из непонимания одной концепции), потом архитектура, объекты, сеть, хранилища, автоскейлинг, деньги и отладка.

Сразу оговорка, которую редко делают в начале: Kubernetes — дорогой инструмент. Не столько в деньгах за control plane, сколько в постоянной эксплуатационной нагрузке. Если у вас три сервиса и две ноды, честный ответ — вам он, скорее всего, не нужен; читайте https://courses.digitable.life/post/devops/07-k3s-and-lightweight/ и https://courses.digitable.life/post/devops/14-vps-vds-and-bare-metal/. Эта статья — про то, как он работает, чтобы вы могли принять решение осознанно, а не по моде.

Главная идея: цикл согласования, а не «система команд»

Большинство инструментов до Kubernetes были императивными: «запусти контейнер», «останови контейнер», «перенеси на другую машину». Kubernetes работает иначе. Вы описываете желаемое состояние (spec), система непрерывно наблюдает фактическое (status) и предпринимает действия, чтобы свести разницу к нулю.

Аналогия — термостат. Вы не говорите ему «включи нагрев на 12 минут». Вы говорите «хочу 22 °C», а он сам решает, когда греть, и продолжает решать это вечно.

Ключевое техническое следствие: контроллеры level-triggered, а не edge-triggered. Они реагируют не на событие «под умер», а на факт «сейчас реплик 2, а надо 3». Поэтому потерянное событие не ломает систему: следующий проход цикла всё равно увидит расхождение. Это же объясняет, почему kubectl delete pod у Deployment «не работает» — под пересоздаётся: вы изменили факт, но не желание.

Из этого же вытекает стиль отладки: не «какая команда не сработала», а «какой контроллер видит расхождение и почему не может его устранить». Ответ почти всегда лежит в kubectl describe и в событиях.

Архитектура: что из чего состоит

Кластер — это control plane (мозг) и набор нод (мышцы). Все компоненты общаются только через API-сервер; прямых связей между ними нет. Это архитектурное решение стоит запомнить: оно объясняет и расширяемость, и то, почему API-сервер — единственная настоящая точка отказа.

Что за этим стоит на практике:

  • etcd — самое хрупкое место кластера. Это Raft-хранилище, чувствительное к задержке fsync. Целевой ориентир: etcd_disk_wal_fsync_duration_seconds p99 < 10 мс, etcd_disk_backend_commit_duration_seconds p99 < 25 мс. На сетевом диске без гарантированных IOPS кластер начинает «мигать»: лидер переизбирается, API-сервер таймаутит, kubelet теряет ноды. Держите etcd на локальном NVMe. Размер БД по умолчанию ограничен 2 ГиБ (--quota-backend-bytes, часто поднимают до 8 ГиБ); при превышении etcd переходит в read-only alarm — кластер жив, но ничего не меняется.
  • Кворум требует нечётного числа членов. 3 члена переживают отказ одного, 5 — двух. Четыре члена хуже трёх: тот же порог отказов, но больше латентность записи.
  • API-сервер горизонтально масштабируется (он stateless), а etcd — нет.
  • Планировщик не «переносит» поды. Он один раз выбирает ноду для пода без nodeName и делает bind. Дальше под живёт на этой ноде до смерти. Балансировку постфактум делает отдельный инструмент — descheduler.

Что происходит после kubectl apply

Полезно один раз проследить путь целиком — половина вопросов «почему под висит в Pending» отвечается этой схемой.

Каждая стрелка — место, где всё может застрять, и у каждого места свой диагноз:

Симптом Где застряло Первая команда
Pending, нет события Scheduled планировщик не нашёл ноду kubectl describe po → секция Events
Pending + unbound PersistentVolumeClaim PVC не привязан kubectl get pvc,pv
ContainerCreating долго CNI или монтирование тома kubectl describe po, логи kubelet
ImagePullBackOff реестр, права, тег kubectl describe po → Events
CrashLoopBackOff приложение падает kubectl logs --previous
Running 0/1 readiness probe красный kubectl describe po → Conditions
Terminating навсегда finalizer или зависший том kubectl get po -o yaml → metadata.finalizers

Объекты: карта API

Kubernetes — это набор типов ресурсов и контроллеров к ним. Ниже — те, без которых не обходится ни один прод.

Pod — и почему он не «контейнер»

Под — это группа контейнеров с общим сетевым namespace (один IP, общий localhost), общими томами и общей судьбой. Внутри пода контейнеры не изолированы по сети: sidecar видит приложение на 127.0.0.1:8080.

Три роли контейнеров в поде:

  • init-контейнеры выполняются последовательно до старта основных; используются для миграций схемы, ожидания зависимостей, подготовки томов;
  • основные контейнеры — приложение;
  • sidecar-контейнеры — начиная с Kubernetes 1.29 это официально init-контейнер с restartPolicy: Always: он стартует до основных, живёт параллельно и корректно завершается после них. До этого sidecar’ы ломали Job’ы (под никогда не завершался) — теперь нет.

Deployment vs StatefulSet vs DaemonSet

Deployment StatefulSet DaemonSet
Имена подов случайный суффикс app-0, app-1, … стабильные по одному на ноду
Порядок запуска параллельно последовательно (по умолчанию) параллельно
Диски общий PVC или без них свой PVC на реплику (volumeClaimTemplates) обычно hostPath
Обновление RollingUpdate / Recreate RollingUpdate от старшего к младшему, partition для канареек RollingUpdate
DNS каждого пода нет есть, через headless Service нет
Типично для API, воркеры, фронтенды БД, Kafka, ZooKeeper, Elasticsearch fluent-bit, node-exporter, CNI

Практическое правило: StatefulSet не делает приложение stateful-совместимым. Он даёт стабильную идентичность и диски, но не решает репликацию, выбор лидера и восстановление. Кластер PostgreSQL «на StatefulSet руками» — это подписка на инциденты; берите оператор (CloudNativePG, Zalando) или managed-БД. Про сам выбор — https://courses.digitable.life/post/databases/00-overview/.

Жизненный цикл пода и корректное завершение

Здесь живёт самая распространённая причина 502 при деплое.

Гонка, которую нужно понимать дословно: при удалении пода API-сервер параллельно делает две вещи — говорит kubelet начинать завершение и говорит endpoint-контроллеру убрать под из EndpointSlice. Второе распространяется дальше через kube-proxy на каждой ноде, через ingress-контроллер, через клиентские балансировщики. Это занимает от сотен миллисекунд до нескольких секунд. Всё это время трафик продолжает идти в под, который уже получил SIGTERM.

Лечение — preStop-хук, который просто ждёт, ничего не делая:

lifecycle:
  preStop:
    exec:
      # Не «graceful shutdown приложения», а пауза, чтобы удаление из
      # EndpointSlice успело разойтись по всем балансировщикам.
      # Приложение в это время продолжает нормально обслуживать трафик.
      command: ["/bin/sh", "-c", "sleep 15"]
terminationGracePeriodSeconds: 45   # 15 (preStop) + запас на долгие запросы

Второе обязательное условие — приложение должно обрабатывать SIGTERM: перестать принимать новые соединения, дождаться текущих, закрыть пул БД, выйти. В контейнере это работает только если приложение — PID 1 или под ним есть init (shareProcessNamespace, tini). Классическая ловушка: CMD ["sh", "-c", "python app.py"] — сигнал получает sh, который его не передаёт; после terminationGracePeriodSeconds приходит SIGKILL, соединения рвутся.

Probes: три разные вещи, которые постоянно путают

# Стартовая проба: защищает медленный старт от liveness-рестартов.
# Даёт до 30 × 10 = 300 секунд на инициализацию.
startupProbe:
  httpGet: { path: /healthz, port: 8080 }
  periodSeconds: 10
  failureThreshold: 30

# Liveness: «процесс завис, помоги только рестарт».
# НИКОГДА не проверяет БД, Redis или соседние сервисы — иначе одна
# упавшая зависимость каскадно перезапустит весь кластер приложений.
livenessProbe:
  httpGet: { path: /healthz, port: 8080 }
  periodSeconds: 10
  timeoutSeconds: 2
  failureThreshold: 3

# Readiness: «готов ли принимать трафик прямо сейчас».
# ВОТ здесь проверяются зависимости: нет коннекта к БД — убираем из балансировки,
# но под не убиваем, он восстановится сам.
readinessProbe:
  httpGet: { path: /readyz, port: 8080 }
  periodSeconds: 5
  timeoutSeconds: 2
  failureThreshold: 2

Три ошибки, встречающиеся в каждом втором кластере:

  1. /healthz и /readyz — один и тот же обработчик, проверяющий БД. При кратковременной недоступности БД liveness убивает все поды одновременно; после рестарта они дружно бьют по восстановившейся БД и роняют её снова.
  2. Нет startupProbe у JVM/Rails-приложения. Старт 90 секунд, initialDelaySeconds: 30, liveness убивает контейнер на 60-й секунде — CrashLoopBackOff навсегда, при полностью исправном коде.
  3. timeoutSeconds: 1 у приложения под нагрузкой. Проба таймаутит из-за GC-паузы и превращает перегрузку в отказ.

Ресурсы, QoS и деньги

Каждый контейнер объявляет requests (гарантия, по ней планирует scheduler) и limits (потолок, его обеспечивает cgroup). Из их соотношения выводится класс QoS — задать его напрямую нельзя.

Классы QoS в Kubernetes и порядок вытеснения подов

Механика различается для двух ресурсов принципиально:

  • Память несжимаема. Превысил limit — контейнер получает OOMKill мгновенно, exit code 137. Нет никакого «немного подождать».
  • CPU сжимаем. Превысил limit — CFS-квота начинает throttling: процесс просто не получает слотов до конца 100-мс периода. Приложение не падает, оно тормозит, и это видно только в метрике container_cpu_cfs_throttled_seconds_total.

Отсюда практические рекомендации, которые я бы назвал консенсусом индустрии:

  • memory: requests == limits всегда. Память нельзя «одолжить» безопасно: если под превысит request, при давлении на ноду его вытеснят, и вы получите непредсказуемые падения.
  • CPU limit — по умолчанию не ставить для латентно-чувствительных сервисов. Ставьте requests, чтобы планировщик считал честно, но не ограничивайте пики: throttling на 30 мс во время всплеска даёт хвост p99, который потом ищут неделями. Контраргумент: без limits «шумный сосед» может съесть простаивающий CPU ноды — но requests других подов при этом всё равно соблюдаются ядром через веса cgroup. Ставьте CPU limits там, где важна предсказуемость и защита от багов (batch, недоверенные нагрузки, мультитенантность).
  • ResourceQuota на namespace и LimitRange с дефолтами — чтобы BestEffort-поды не появлялись случайно.

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

Соотношение capacity, allocatable, суммы requests и реального потребления на ноде

Планировщик оперирует суммой requests, а не фактическим потреблением. Если команды по привычке ставят «2 CPU / 4 Gi на всякий случай», кластер физически заполнен на 27 %, а по мнению scheduler’а — на 80 %, и Cluster Autoscaler исправно докупает ноды. Это единственная ошибка, которая одновременно невидима (ничего не падает) и стоит десятки процентов счёта.

Что делать конкретно:

# Кто сколько просит и сколько реально ест — на одной ноде
kubectl describe node ip-10-0-3-17 | grep -A 12 "Allocated resources"
Allocated resources:
  Resource           Requests      Limits
  --------           --------      ------
  cpu                5600m (78%)   9200m (129%)
  memory             21Gi (73%)    34Gi (119%)
# Фактическое потребление
kubectl top pods -A --sort-by=cpu | head -5
NAMESPACE   NAME                        CPU(cores)   MEMORY(bytes)
payments    api-7d9f4c8b6-x2kqp         180m         612Mi
search      indexer-5c7b9d4f8-mn4rt     140m         1840Mi

api просит 2000m, ест 180m — коэффициент 11×. Инструменты, которые считают это автоматически и дают рекомендации: Goldilocks (поверх VPA в режиме Off), KRR (по данным Prometheus), OpenCost. Подробнее про FinOps — https://courses.digitable.life/post/devops/15-cloud-cost-and-tradeoffs/.

Рабочий Deployment целиком

Ниже манифест, который можно брать за шаблон: в нём собрано всё, о чём шла речь.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: payments-api
  namespace: payments
spec:
  replicas: 3
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0      # не снижаем ёмкость во время выката
      maxSurge: 1            # добавляем по одному поду
  selector:
    matchLabels: { app: payments-api }
  template:
    metadata:
      labels: { app: payments-api, version: "1.4.2" }
    spec:
      # Разносим реплики по зонам: падение AZ не уносит сервис целиком
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels: { app: payments-api }
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway   # мягкое пожелание
          labelSelector:
            matchLabels: { app: payments-api }
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        fsGroup: 10001
        seccompProfile: { type: RuntimeDefault }
      serviceAccountName: payments-api      # НЕ default
      automountServiceAccountToken: false   # токен не нужен — не монтируем
      terminationGracePeriodSeconds: 45
      containers:
        - name: app
          # деплой по дайджесту, а не по тегу — см. статью про образы
          image: ghcr.io/acme/payments-api@sha256:9f3c1a...
          ports:
            - { name: http, containerPort: 8080 }
          resources:
            requests: { cpu: "250m", memory: "512Mi" }
            limits:   { memory: "512Mi" }   # CPU limit намеренно отсутствует
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }
          envFrom:
            - configMapRef: { name: payments-config }
          env:
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef: { name: payments-db, key: password }
          volumeMounts:
            - { name: tmp, mountPath: /tmp }   # нужен из-за readOnlyRootFilesystem
          startupProbe:
            httpGet: { path: /healthz, port: http }
            periodSeconds: 5
            failureThreshold: 30
          livenessProbe:
            httpGet: { path: /healthz, port: http }
            periodSeconds: 10
            failureThreshold: 3
          readinessProbe:
            httpGet: { path: /readyz, port: http }
            periodSeconds: 5
            failureThreshold: 2
          lifecycle:
            preStop:
              exec: { command: ["/bin/sh", "-c", "sleep 15"] }
      volumes:
        - name: tmp
          emptyDir: { sizeLimit: 256Mi }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: payments-api, namespace: payments }
spec:
  minAvailable: 2       # при drain ноды не дать упасть ниже двух реплик
  selector:
    matchLabels: { app: payments-api }

PodDisruptionBudget — объект, о котором вспоминают после первого ночного инцидента. Без него kubectl drain (или апгрейд нод облаком, или Cluster Autoscaler) может одновременно выселить все реплики сервиса. Осторожно с обратной крайностью: minAvailable, равный числу реплик, делает drain невозможным — нода зависает в состоянии SchedulingDisabled навсегда, а апгрейд кластера встаёт.

Сеть

Модель

Kubernetes задаёт не реализацию, а требования, и они на удивление строгие:

  1. Каждый под получает свой IP, маршрутизируемый внутри кластера.
  2. Поды общаются друг с другом без NAT — под видит тот же IP собеседника, который тот видит у себя.
  3. Агенты на ноде (kubelet) могут достучаться до любого пода на этой ноде.

Реализация — дело CNI-плагина. Отсюда «плоская» сеть: никаких проброшенных портов, никакого -p 8080:8080.

CNI Как устроен NetworkPolicy Порог входа Когда брать
Flannel VXLAN-оверлей нет (только с Calico policy) минимальный тестовые кластеры, простые on-prem
Calico BGP без оверлея или IPIP/VXLAN; есть eBPF-режим да, богатые (GlobalNetworkPolicy) средний on-prem, где нужны политики и производительность
Cilium eBPF, может заменить kube-proxy да + L7 (HTTP-методы, gRPC) высокий крупные кластеры, mTLS, наблюдаемость (Hubble)
AWS VPC CNI реальные IP из VPC через ENI да (свежие версии) низкий на EKS EKS по умолчанию; см. подвох ниже
Cloud-native (GKE/AKS) нативная интеграция с VPC да нулевой managed-кластеры

Подвох AWS VPC CNI: поды получают IP прямо из подсети VPC. Это отлично для интеграции с остальной инфраструктурой (security groups, VPC Flow Logs работают как для обычных инстансов) и ужасно для плотности — число подов на ноду жёстко ограничено количеством ENI и IP на них: m5.large — 29 подов, t3.medium — 17. А подсеть /24 на 254 адреса исчерпывается на ~8 нодах. Планируйте адресное пространство заранее или включайте prefix delegation. Подробнее про сеть AWS — https://courses.digitable.life/post/devops/11-aws/.

Service: виртуальный IP, которого не существует

ClusterIP — не адрес какой-то машины. Это запись в правилах DNAT на каждой ноде.

Пакет с адресом 10.96.44.12 не покидает ноду с этим адресом — он переписывается ядром ещё до выхода. Балансировка происходит на стороне клиента, на его ноде. Практические следствия:

  • Это L4-балансировка по соединениям, а не по запросам. Долгоживущее HTTP/2 или gRPC-соединение прилипает к одному поду навсегда — новые реплики не получат трафика. Лечение: headless Service + клиентская балансировка, service mesh или принудительный MAX_CONNECTION_AGE на сервере.
  • iptables-режим строит цепочку правил на каждый Service: при тысячах сервисов обновление правил занимает секунды и грузит CPU. IPVS использует хеш-таблицу и масштабируется лучше. Cilium в режиме kube-proxy replacement делает то же через eBPF-мапы — это сейчас самый быстрый вариант.
  • externalTrafficPolicy: Local сохраняет исходный IP клиента и убирает лишний хоп, но балансирует хуже: трафик идёт только в поды на ноде, куда пришёл пакет.

Типы Service:

# ClusterIP — только внутри кластера (по умолчанию)
# NodePort — открывает порт 30000-32767 на КАЖДОЙ ноде
# LoadBalancer — NodePort + внешний балансировщик от облака
# ExternalName — CNAME на внешний адрес, без прокси

apiVersion: v1
kind: Service
metadata: { name: payments-api, namespace: payments }
spec:
  selector: { app: payments-api }
  ports:
    - { name: http, port: 8080, targetPort: http }
---
# Headless: DNS отдаёт IP всех подов, VIP не создаётся.
# Обязателен для StatefulSet: даёт payments-db-0.payments-db.payments.svc
apiVersion: v1
kind: Service
metadata: { name: payments-db, namespace: payments }
spec:
  clusterIP: None
  selector: { app: payments-db }
  ports: [{ name: pg, port: 5432 }]

Важное про стоимость: каждый type: LoadBalancer — это отдельный балансировщик у облака, примерно 18 $–25/мес плюс трафик. Двадцать сервисов, выставленных наружу «по-простому», дают 400 $+/мес на пустом месте. Правильный путь — один Ingress/Gateway-контроллер за одним LB, а дальше маршрутизация по Host/Path внутри кластера.

DNS и ловушка ndots

CoreDNS резолвит <service>.<namespace>.svc.cluster.local. В /etc/resolv.conf пода стоит options ndots:5 — это значит, что любое имя с менее чем пятью точками сначала пробуется со всеми search-доменами.

Запрос к api.external.com (2 точки) порождает: api.external.com.payments.svc.cluster.local (NXDOMAIN), api.external.com.svc.cluster.local (NXDOMAIN), api.external.com.cluster.local (NXDOMAIN), затем сам домен — и всё это по A и AAAA, то есть 8 DNS-запросов вместо одного. На нагруженном кластере CoreDNS становится узким местом и начинает таймаутить.

Три лечения по возрастанию усилий: ставить точку в конце (api.external.com.), задать dnsConfig: {options: [{name: ndots, value: "2"}]} в поде, развернуть NodeLocal DNSCache как DaemonSet.

Ingress и Gateway API

Ingress — стандарт для HTTP-маршрутизации, но он заморожен в развитии: всё сверх «host + path» делается вендорными аннотациями, из-за чего манифест nginx-ingress непереносим на traefik. Преемник — Gateway API (v1.0 GA с октября 2023), где роли разделены: инфраструктурная команда владеет Gateway, продуктовая — HTTPRoute в своём namespace.

# Gateway API: маршрут в своём namespace, привязанный к общему шлюзу
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: payments, namespace: payments }
spec:
  parentRefs:
    - { name: public-gateway, namespace: infra }
  hostnames: ["api.acme.io"]
  rules:
    - matches:
        - path: { type: PathPrefix, value: /v1/payments }
      backendRefs:
        - { name: payments-api, port: 8080, weight: 90 }
        - { name: payments-api-canary, port: 8080, weight: 10 }

Весовое распределение здесь — часть стандарта, а не аннотация: это то, ради чего Gateway API и делали. Стратегии выката поверх этого разобраны в https://courses.digitable.life/post/devops/04-cd-and-release-strategies/.

NetworkPolicy: по умолчанию всё открыто

В свежем кластере любой под может обратиться к любому. Это регулярно всплывает на аудитах. Базовый шаблон — deny-all в namespace плюс точечные разрешения:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny-all, namespace: payments }
spec:
  podSelector: {}                 # все поды namespace
  policyTypes: [Ingress, Egress]  # egress тоже — иначе скомпрометированный под
                                  # спокойно выкачает данные наружу
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-api-to-db, namespace: payments }
spec:
  podSelector:
    matchLabels: { app: payments-db }
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector:
            matchLabels: { app: payments-api }
      ports: [{ protocol: TCP, port: 5432 }]
---
# После default-deny обязательно вернуть DNS, иначе сломается всё сразу
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-dns-egress, namespace: payments }
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: kube-system }
          podSelector:
            matchLabels: { k8s-app: kube-dns }
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }

Забытый DNS-egress — ошибка номер один при внедрении политик: всё «внезапно» ломается, а в логах непонятные таймауты соединений.

Хранилища

Модель разделена на три уровня: StorageClass (какой тип дисков и как их создавать) → PersistentVolumeClaim (заявка приложения) → PersistentVolume (конкретный том). Драйвер — CSI-плагин от облака или СХД.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata: { name: fast-ssd }
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
  iops: "6000"        # gp3 позволяет платить за IOPS отдельно от объёма
  throughput: "250"
  encrypted: "true"
reclaimPolicy: Delete           # Retain для продовых БД — защита от опечатки
allowVolumeExpansion: true      # без этого расширить диск не получится вообще
volumeBindingMode: WaitForFirstConsumer   # критично: см. ниже

volumeBindingMode: WaitForFirstConsumer — параметр, который ломает кластеры чаще остальных. При Immediate диск создаётся сразу, в произвольной зоне; если планировщик потом решит поставить под в другую зону, PVC никогда не привяжется и под навсегда останется в Pending. WaitForFirstConsumer откладывает создание диска до выбора ноды. Для любого зонального хранилища (EBS, GCE PD, Azure Disk) это единственный правильный режим.

Режимы доступа:

Режим Смысл Реально поддерживают
ReadWriteOnce (RWO) одна нода на чтение-запись блочные: EBS, PD, Azure Disk, local
ReadOnlyMany (ROX) много нод, только чтение NFS, CephFS
ReadWriteMany (RWX) много нод на запись файловые: EFS, Azure Files, CephFS, Longhorn
ReadWriteOncePod (RWOP) ровно один под CSI с поддержкой; защита от split-brain

Главное недоразумение: RWO означает «одна нода», а не «один под» — несколько подов на одной ноде спокойно смонтируют один том, что для БД означает повреждение данных. Для этого и ввели RWOP.

StatefulSet с дисками на реплику:

apiVersion: apps/v1
kind: StatefulSet
metadata: { name: payments-db, namespace: payments }
spec:
  serviceName: payments-db     # тот самый headless Service
  replicas: 3
  podManagementPolicy: OrderedReady
  selector:
    matchLabels: { app: payments-db }
  template:
    metadata:
      labels: { app: payments-db }
    spec:
      terminationGracePeriodSeconds: 120   # БД нужно время на checkpoint
      containers:
        - name: postgres
          image: postgres:16.4
          resources:
            requests: { cpu: "2", memory: "8Gi" }
            limits:   { memory: "8Gi" }
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
  volumeClaimTemplates:        # PVC создаётся ДЛЯ КАЖДОЙ реплики
    - metadata: { name: data }
      spec:
        accessModes: [ReadWriteOnce]
        storageClassName: fast-ssd
        resources:
          requests: { storage: 200Gi }
  # Что делать с дисками при масштабировании вниз и удалении
  persistentVolumeClaimRetentionPolicy:
    whenScaled: Retain         # уменьшили реплики — диски сохранить
    whenDeleted: Retain        # удалили StatefulSet — диски сохранить

По умолчанию PVC от StatefulSet не удаляются ни при scale down, ни при удалении объекта. Это защита от потери данных, но и источник тихой утечки денег: удалили StatefulSet полгода назад, а десять дисков по 200 Gi продолжают тарифицироваться. Проверяйте kubectl get pvc -A при аудите расходов.

Честная рекомендация по stateful-нагрузкам: если у вас нет выделенного человека на эксплуатацию хранилищ, держите базы вне Kubernetes — в managed-сервисе или на отдельных серверах. Отказ CSI-драйвера, зависший VolumeAttachment, невозможность отмонтировать том с мёртвой ноды — это отдельный класс инцидентов, где на каждый шаг нужно понимать всю цепочку от kubelet до API облака.

Автоскейлинг: три независимых уровня

HPA считает желаемое число реплик по простой формуле:

desiredReplicas = ceil( currentReplicas × currentMetricValue / desiredMetricValue )

С двумя нюансами, о которых нужно знать. Первый: изменение игнорируется, если отношение метрик отличается от единицы менее чем на 10 % (--horizontal-pod-autoscaler-tolerance) — это гасит дрожание. Второй: currentMetricValue для Utilization считается от requests, а не от limits. Занизили request — HPA сойдёт с ума при нормальной нагрузке; завысили — не сработает никогда.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: payments-api, namespace: payments }
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: payments-api
  minReplicas: 3
  maxReplicas: 40
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 70 }
    # Кастомная метрика через Prometheus Adapter — обычно куда осмысленнее CPU
    - type: Pods
      pods:
        metric: { name: http_requests_per_second }
        target: { type: AverageValue, averageValue: "200" }
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0      # вверх — сразу
      policies:
        - { type: Percent, value: 100, periodSeconds: 30 }  # максимум ×2 за 30 с
        - { type: Pods, value: 8, periodSeconds: 30 }
      selectPolicy: Max
    scaleDown:
      stabilizationWindowSeconds: 600    # вниз — осторожно, окно 10 минут
      policies:
        - { type: Percent, value: 20, periodSeconds: 60 }

Асимметрия scaleUp/scaleDown — не украшение. Слишком быстрый scale down даёт «пилу»: нагрузка спала на минуту, поды убрали, нагрузка вернулась, поды поднимают заново, и каждый цикл — это холодные старты, прогрев кэшей и всплеск латентности.

Сравнение уровней:

Инструмент Что меняет Реакция Порог входа Ограничение
HPA число реплик 15–60 с низкий плохо работает по CPU для IO-bound сервисов
VPA requests/limits минуты средний пересоздаёт под; конфликтует с HPA по той же метрике
Cluster Autoscaler число нод в node group 1–4 мин (провижининг ноды) средний работает в рамках заранее заданных групп
Karpenter ноды напрямую, подбирая тип инстанса 30–90 с средний, только AWS/Azure другая ментальная модель, свои CRD
KEDA replicas по внешним событиям секунды низкий нужен доступ к источнику метрик

Ключевой практический момент: HPA бесполезен, если ноды добавляются 4 минуты. Автоскейлинг подов упирается в скорость появления ёмкости. Отсюда приём с «шариками» (overprovisioning): держите Deployment из подов-пустышек с отрицательным PriorityClass, которые занимают место и мгновенно вытесняются реальной нагрузкой — так вы платите за небольшой запас ёмкости, но получаете отклик за секунды.

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata: { name: overprovisioning }
value: -10                  # ниже нуля: вытесняется любым обычным подом
globalDefault: false
description: "Держит свободную ёмкость для мгновенного scale up"

Сколько это стоит и что выбрать под свой масштаб

Control plane у managed-провайдеров стоит примерно одинаково — порядка 0.10 $/час, то есть ~73 $/месяц за кластер (EKS; GKE Standard/Autopilot с бесплатным зональным кластером в free tier; AKS — бесплатный Free-тир без SLA и ~0.10 $/час за Standard c SLA 99.95 %). На фоне нод это копейки, но помните про множитель: dev + staging + prod + песочницы = четыре кластера = ~300 $/мес только за управление.

Вариант Стоимость управления Порог входа Эксплуатационная нагрузка Кому
Managed (EKS/GKE/AKS) ~73 $/мес за кластер + ноды средний апгрейды нод, аддоны, IAM почти всем, кто уже в облаке
GKE Autopilot / EKS Fargate наценка ~20–30 % к ресурсам пода низкий нод нет вообще малые команды, нестабильная нагрузка
Self-hosted (kubeadm) 0 за софт, 3 ноды control plane высокий etcd, сертификаты, апгрейды, backup on-prem, регуляторика, большой масштаб
k3s / k0s 0, control plane на 512 МБ низкий минимальная, но всё на вас edge, малые кластеры, dev — см. https://courses.digitable.life/post/devops/07-k3s-and-lightweight/
Без Kubernetes 0 нулевой деплой скриптом/compose до ~5 сервисов, см. https://courses.digitable.life/post/devops/14-vps-vds-and-bare-metal/

Скрытые расходы, которые не видны в калькуляторе провайдера:

  • Балансировщики: каждый type: LoadBalancer — 18 $–25/мес. Один Gateway вместо двадцати LB экономит 400 $+/мес.
  • Межзональный трафик: разнесли поды по трём AZ ради отказоустойчивости — и получили плату за каждый мегабайт между ними (0.01 $–0.02/ГБ в обе стороны в AWS). На chatty-микросервисах это ощутимая строка. Смягчается topologyAware маршрутизацией (trafficDistribution: PreferClose).
  • Простаивающие PVC и снапшоты после удалённых StatefulSet.
  • Логи: DaemonSet-агент шлёт всё подряд в managed-хранилище — счёт за логи регулярно обгоняет счёт за compute. См. https://courses.digitable.life/post/devops/16-observability-and-oncall/.
  • Люди. Полноценная эксплуатация self-hosted кластера — это как минимум 0.5–1 FTE, что дороже любого варианта из таблицы выше.

Эксплуатация: апгрейды, бэкапы, отладка

Версии и апгрейды

Kubernetes выпускает три минорные версии в год, каждая поддерживается около 14 месяцев. Правила, которые нельзя нарушать:

  • Только на одну минорную версию за раз, control plane первым. Прыжок 1.28 → 1.31 напрямую не поддерживается.
  • Skew: kubelet может отставать от API-сервера максимум на 3 минорные версии, kubectl — на ±1.
  • Перед апгрейдом прогоните детектор устаревших API (pluto, kubent). Удалённый API — это молча сломавшийся деплой, а не понятная ошибка.

Порядок безопасного вывода ноды:

kubectl cordon ip-10-0-3-17                  # не планировать новое
kubectl drain ip-10-0-3-17 \
  --ignore-daemonsets \                      # DaemonSet выселить нельзя
  --delete-emptydir-data \                   # согласиться на потерю emptyDir
  --grace-period=120 \
  --timeout=15m                              # PDB может заблокировать надолго
# ... замена/обновление ноды ...
kubectl uncordon ip-10-0-3-17

Бэкап: что именно нужно спасать

Три разные вещи, и путать их дорого:

  1. Состояние etcd — все объекты кластера. etcdctl snapshot save по расписанию (или снапшоты от провайдера в managed).
  2. Манифесты — они должны жить в Git, а не только в кластере. Это и есть аргумент за GitOps: восстановление сводится к «подключить ArgoCD к репозиторию». См. https://courses.digitable.life/post/devops/08-helm-and-gitops/.
  3. Данные в PV — снапшоты дисков или Velero с CSI-снапшотами. etcd-бэкап их не содержит.
# Снапшот etcd (self-hosted)
ETCDCTL_API=3 etcdctl \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  snapshot save /backup/etcd-$(date +%F-%H%M).db

# Velero: объекты + тома одного namespace
velero backup create payments-$(date +%F) \
  --include-namespaces payments \
  --snapshot-volumes \
  --ttl 720h

Бэкап, который ни разу не восстанавливали, бэкапом не является. Раз в квартал — учебное восстановление в отдельный кластер, с замером времени.

Отладка: команды, которые реально нужны

# 1. События кластера в хронологии — первое, что стоит смотреть всегда
kubectl get events -A --sort-by=.lastTimestamp | tail -20
NAMESPACE  LAST SEEN  TYPE      REASON              OBJECT                     MESSAGE
payments   30s        Warning   FailedScheduling    pod/payments-api-7d9-hk2   0/6 nodes are available:
                                                                               3 Insufficient cpu,
                                                                               2 node(s) had untolerated taint,
                                                                               1 node(s) didn't match
                                                                               topology spread constraints.

Это сообщение — готовый диагноз: планировщик перечисляет, сколько нод отсеял каждый фильтр.

# 2. Почему под не стартует
kubectl describe pod payments-api-7d9f4c8b6-hk2xq -n payments

# 3. Логи упавшего экземпляра, а не текущего
kubectl logs payments-api-7d9f4c8b6-hk2xq -n payments --previous

# 4. Debug-контейнер в работающий под (образ без shell — не помеха)
kubectl debug -it payments-api-7d9f4c8b6-hk2xq -n payments \
  --image=nicolaka/netshoot --target=app

# 5. Копия пода с изменённой командой — когда падает на старте
kubectl debug payments-api-7d9f4c8b6-hk2xq -n payments \
  --copy-to=debug-pod --container=app -- sleep 3600

# 6. Проверить права, не гадая по RBAC-манифестам
kubectl auth can-i create deployments -n payments \
  --as=system:serviceaccount:ci:deployer

# 7. Что вообще есть в кластере и какие версии API
kubectl api-resources --verbs=list -o wide | head

# 8. Кто съел ноду
kubectl top node
kubectl describe node ip-10-0-3-17 | grep -A 20 "Non-terminated Pods"

kubectl debug с эфемерными контейнерами (стабильно с 1.25) — главный аргумент против «положим в образ curl и bash на всякий случай». Отладочный инструментарий подключается снаружи, а продовый образ остаётся distroless.

Типичные ошибки, отсортированные по цене

  1. Завышенные requests без ревизии. Кластер загружен на четверть, счёт — полный. Дороже всего и заметно меньше всего.
  2. Нет PodDisruptionBudget. Первый же drain или апгрейд нод роняет сервис целиком, обычно ночью.
  3. Liveness-проба, проверяющая внешние зависимости. Превращает деградацию в полный отказ с каскадным рестартом.
  4. Один под без topologySpreadConstraints. Все три реплики оказались на одной ноде — падение ноды уносит сервис.
  5. image: myapp:latest. Рестарт пода через месяц подтягивает другой образ. Диагностика такого инцидента занимает часы.
  6. default ServiceAccount с примонтированным токеном. Компрометация любого пода даёт доступ к API кластера в правах namespace.
  7. cluster-admin для CI. Пайплайн, который может всё, — это пайплайн, из которого можно всё. См. https://courses.digitable.life/post/devops/17-security-in-pipeline/.
  8. Секреты в etcd без шифрования. Объект Secret — это base64, а не шифрование. Включайте EncryptionConfiguration с KMS или используйте внешнее хранилище (External Secrets Operator, Vault).
  9. kubectl apply руками в прод. Кластер расходится с Git, и никто не знает, что реально задеплоено. Лечится GitOps.
  10. Нет terminationGracePeriodSeconds и preStop. Каждый деплой даёт всплеск 502, который списывают на «ну это же выкат».
  11. Единственный namespace на всё. Нет квот, нет изоляции политиками, нет разграничения прав.
  12. Кластер как питомец. Если у вас нет воспроизводимой процедуры создания кластера с нуля (Terraform + GitOps — https://courses.digitable.life/post/devops/09-terraform-and-iac/), у вас нет DR-плана, что бы ни было написано в документе.

Мини-итог

  • Kubernetes — не «система запуска контейнеров», а набор контроллеров, непрерывно сводящих факт к желанию. Понимание цикла согласования объясняет 80 % поведения системы.
  • Всё общение идёт через API-сервер; всё состояние живёт в etcd. Латентность диска под etcd — здоровье кластера.
  • Планировщик оперирует requests, а не реальным потреблением: именно поэтому завышенные requests — самая дорогая и самая незаметная ошибка.
  • Память ставьте requests == limits; CPU limit для латентно-чувствительных сервисов чаще вредит, чем помогает.
  • Service — это правила DNAT на каждой ноде и балансировка по соединениям, а не по запросам. Долгоживущие gRPC-соединения требуют отдельного решения.
  • Корректное завершение — preStop sleep плюс обработка SIGTERM. Без обоих деплой всегда даёт ошибки.
  • Автоскейлинг работает на трёх независимых уровнях, и HPA бесполезен, если ноды приходят четыре минуты.
  • Основные расходы прячутся не в control plane, а в простое ресурсов, балансировщиках, межзональном трафике и логах.

Источники

Что дальше

Мы разобрали «большой» Kubernetes: со всей его мощью и всей его ценой. Но огромной части задач — edge-устройства, кластер на трёх VPS, dev-окружение на ноутбуке, CI-раннеры — весь этот control plane не нужен. Об этом — следующая статья: k3s, k0s, MicroK8s и лёгкие дистрибутивы Kubernetes: где они выигрывают.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов