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-сервер — единственная настоящая точка отказа.
REST, аутентификация,
authz, admission, валидация"] ETCD[("etcd
Raft-кворум,
единственное состояние")] SCH["kube-scheduler
filter → score → bind"] CM["kube-controller-manager
Deployment, ReplicaSet, Node,
Job, ServiceAccount, ~40 циклов"] CCM["cloud-controller-manager
LoadBalancer, Route, Node lifecycle"] end subgraph N1["Нода 1"] KL1["kubelet
приводит поды на ноде
к желаемому состоянию"] KP1["kube-proxy / eBPF
реализует Service"] CR1["containerd / CRI-O
+ CNI-плагин"] P1["Pod A"] P2["Pod B"] end subgraph N2["Нода 2"] KL2["kubelet"] KP2["kube-proxy"] CR2["containerd"] P3["Pod C"] end API <--> ETCD SCH -->|watch: поды без nodeName| API CM -->|watch + write| API CCM -->|watch + write| API KL1 -->|watch: поды моей ноды
+ отчёт о статусе| API KL2 --> API KP1 -->|watch: Service,
EndpointSlice| API KP2 --> API KL1 --> CR1 CR1 --> P1 CR1 --> P2 KL2 --> CR2 CR2 --> P3 style API fill:#3d8bcd,color:#fff style ETCD fill:#e76f51,color:#fff style SCH fill:#2a9d8f,color:#fff style CM fill:#2a9d8f,color:#fff
Что за этим стоит на практике:
- etcd — самое хрупкое место кластера. Это Raft-хранилище, чувствительное к задержке
fsync. Целевой ориентир:etcd_disk_wal_fsync_duration_secondsp99 < 10 мс,etcd_disk_backend_commit_duration_secondsp99 < 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» отвечается этой схемой.
→ валидация схемы → validating admission A->>E: запись объекта (revision N) A-->>U: 201 Created A-->>D: watch event: ADDED Deployment D->>A: создать ReplicaSet (owner ref) A-->>R: watch event: ADDED ReplicaSet R->>A: создать 3 Pod (spec.nodeName пуст) A-->>S: watch event: Pod без nodeName S->>S: filter (ресурсы, taints, affinity, тома)
→ score → выбор ноды S->>A: POST /pods/{name}/binding → nodeName=node-3 A-->>K: watch event: под назначен мне K->>C: pull образа (если нужен), создать sandbox C->>C: CNI ADD → IP пода C-->>K: контейнеры запущены K->>A: PATCH status: Ready=true A-->>U: kubectl get po → Running 1/1
Каждая стрелка — место, где всё может застрять, и у каждого места свой диагноз:
| Симптом | Где застряло | Первая команда |
|---|---|---|
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
Три ошибки, встречающиеся в каждом втором кластере:
/healthzи/readyz— один и тот же обработчик, проверяющий БД. При кратковременной недоступности БД liveness убивает все поды одновременно; после рестарта они дружно бьют по восстановившейся БД и роняют её снова.- Нет
startupProbeу JVM/Rails-приложения. Старт 90 секунд,initialDelaySeconds: 30, liveness убивает контейнер на 60-й секунде —CrashLoopBackOffнавсегда, при полностью исправном коде. timeoutSeconds: 1у приложения под нагрузкой. Проба таймаутит из-за GC-паузы и превращает перегрузку в отказ.
Ресурсы, QoS и деньги
Каждый контейнер объявляет requests (гарантия, по ней планирует scheduler) и limits (потолок, его обеспечивает cgroup). Из их соотношения выводится класс QoS — задать его напрямую нельзя.
Механика различается для двух ресурсов принципиально:
- Память несжимаема. Превысил
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 чаще всего дорожает без всякой пользы:
Планировщик оперирует суммой 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 задаёт не реализацию, а требования, и они на удивление строгие:
- Каждый под получает свой IP, маршрутизируемый внутри кластера.
- Поды общаются друг с другом без NAT — под видит тот же IP собеседника, который тот видит у себя.
- Агенты на ноде (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.244.1.5"] -->|"GET payments-api:8080"| DNS["CoreDNS
payments-api.payments.svc.cluster.local
→ 10.96.44.12"] C -->|"connect 10.96.44.12:8080"| KP{"kube-proxy на ноде клиента
iptables / IPVS / eBPF"} KP -->|"DNAT, случайный выбор"| E1["Pod 10.244.1.9:8080"] KP --> E2["Pod 10.244.2.4:8080"] KP --> E3["Pod 10.244.3.7:8080"] ES["EndpointSlice
только Ready-поды"] -.->|"watch"| KP style KP fill:#3d8bcd,color:#fff style ES fill:#2a9d8f,color:#fff
Пакет с адресом 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 облака.
Автоскейлинг: три независимых уровня
есть Pending-поды → добавить ноду"] end subgraph L2["Уровень 2: число подов"] HPA["HorizontalPodAutoscaler
CPU / память / кастомные метрики"] KEDA["KEDA
длина очереди, Kafka lag,
scale to zero"] end subgraph L1["Уровень 1: размер пода"] VPA["VerticalPodAutoscaler
подбирает requests"] end M["Метрики:
metrics-server → CPU/RAM
Prometheus Adapter → кастомные"] --> HPA Q["Внешние источники:
SQS, Kafka, RabbitMQ, cron"] --> KEDA HPA -->|"больше реплик"| P["Поды не влезают → Pending"] KEDA -->|"меняет replicas у HPA"| HPA P --> CA VPA -.->|"конфликт по CPU/памяти,
если HPA скейлит по тому же"| HPA style HPA fill:#3d8bcd,color:#fff style CA fill:#2a9d8f,color:#fff style VPA fill:#e9a23b,color:#3a3f4b
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
Бэкап: что именно нужно спасать
Три разные вещи, и путать их дорого:
- Состояние etcd — все объекты кластера.
etcdctl snapshot saveпо расписанию (или снапшоты от провайдера в managed). - Манифесты — они должны жить в Git, а не только в кластере. Это и есть аргумент за GitOps: восстановление сводится к «подключить ArgoCD к репозиторию». См. https://courses.digitable.life/post/devops/08-helm-and-gitops/.
- Данные в 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.
Типичные ошибки, отсортированные по цене
- Завышенные
requestsбез ревизии. Кластер загружен на четверть, счёт — полный. Дороже всего и заметно меньше всего. - Нет
PodDisruptionBudget. Первый же drain или апгрейд нод роняет сервис целиком, обычно ночью. - Liveness-проба, проверяющая внешние зависимости. Превращает деградацию в полный отказ с каскадным рестартом.
- Один под без
topologySpreadConstraints. Все три реплики оказались на одной ноде — падение ноды уносит сервис. image: myapp:latest. Рестарт пода через месяц подтягивает другой образ. Диагностика такого инцидента занимает часы.defaultServiceAccount с примонтированным токеном. Компрометация любого пода даёт доступ к API кластера в правах namespace.cluster-adminдля CI. Пайплайн, который может всё, — это пайплайн, из которого можно всё. См. https://courses.digitable.life/post/devops/17-security-in-pipeline/.- Секреты в etcd без шифрования. Объект
Secret— это base64, а не шифрование. Включайте EncryptionConfiguration с KMS или используйте внешнее хранилище (External Secrets Operator, Vault). kubectl applyруками в прод. Кластер расходится с Git, и никто не знает, что реально задеплоено. Лечится GitOps.- Нет
terminationGracePeriodSecondsиpreStop. Каждый деплой даёт всплеск 502, который списывают на «ну это же выкат». - Единственный namespace на всё. Нет квот, нет изоляции политиками, нет разграничения прав.
- Кластер как питомец. Если у вас нет воспроизводимой процедуры создания кластера с нуля (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 Documentation: Concepts — первоисточник, читается лучше большинства книг по теме.
- Kubernetes Enhancement Proposals (KEP) — почему API устроен именно так; лучший способ понять мотивацию.
- Brendan Burns, Joe Beda, Kelsey Hightower. Kubernetes: Up and Running, 3rd ed. — от авторов проекта.
- Bilgin Ibryam, Roland Huß. Kubernetes Patterns, 2nd ed. — бесплатная у Red Hat, каталог прикладных паттернов.
- Production Best Practices, learnk8s — практический чек-лист перед выходом в прод.
- Kubernetes Networking, официальный раздел и Gateway API.
- CSI Specification и Kubernetes Storage Concepts.
- etcd Operations Guide — требования к диску, кворум, восстановление.
- Kubernetes Version Skew Policy — что с чем совместимо при апгрейде.
- CIS Kubernetes Benchmark и Pod Security Standards.
- OpenCost — открытая модель расчёта стоимости нагрузок в кластере.
Что дальше
Мы разобрали «большой» Kubernetes: со всей его мощью и всей его ценой. Но огромной части задач — edge-устройства, кластер на трёх VPS, dev-окружение на ноутбуке, CI-раннеры — весь этот control plane не нужен. Об этом — следующая статья: k3s, k0s, MicroK8s и лёгкие дистрибутивы Kubernetes: где они выигрывают.