Kubernetes: фундамент
Kubernetes часто объясняют через сравнение с Docker: «Docker запускает контейнеры, а Kubernetes — управляет ими». Это правда, но слишком мягкая. Точнее так: Kubernetes — это операционная система для дата-центра. Как Linux управляет процессами, памятью и файлами одной машины, так Kubernetes управляет контейнерами, сетью и дисками флота машин. И как в Linux ты не создаёшь процесс, дергая напрямую scheduler ядра, а пишешь systemd-юнит, так и в Kubernetes ты не запускаешь контейнер — ты описываешь желаемое состояние, а control plane сам находит, где и как его достичь.
Краткая версия этой темы показала Deployment и Service за пять минут. Здесь мы разберём механику до уровня, на котором ты сможешь объяснить, почему под не стартует, глядя на события ноды, и почему «просто добавь памяти» иногда делает хуже. Всё в главе — исполняемо на kind или k3d за вечер.
Архитектура: кто принимает решения, кто их исполняет
Заголовок раздела «Архитектура: кто принимает решения, кто их исполняет»Кластер делится на две роли: control plane (мозг) и ноды (руки).
┌──────────── CONTROL PLANE ────────────┐ │ kube-apiserver ← единственная │ kubectl ────► │ точка входа, │ CI, всё ────► │ REST + etcd │ │ etcd ← база состояния │ │ scheduler ← выбирает ноду │ │ controller-mgr ← гоняет реальность │ └───────────────────────────────────────┘ │ watch / report │ ┌───────────┴───────────┐ ┌──────────┴──────────┐ │ NODE 1 │ │ NODE 2 │ │ kubelet ─ исполняет │ │ kubelet │ │ поды по │ │ │ │ спецификации │ │ │ │ kube-proxy ─ сеть, │ │ kube-proxy │ │ iptables/ │ │ │ │ IPVS │ │ │ │ containerd ─ рантайм │ │ containerd │ └───────────────────────┘ └─────────────────────┘Ключевой принцип: kube-apiserver — единственная точка входа. Когда ты пишешь kubectl apply, CLI шлёт манифест в API-сервер, тот валидирует его, сохраняет в etcd (распределённое хранилище всего состояния кластера) и возвращает ответ. Дальше за дело берутся controller’ы: Deployment controller замечает «хочется 3 реплики, живёт 0» и создаёт ReplicaSet; scheduler видит поды без ноды и назначает их, учитывая requests, аффинити и ограничения; kubelet на выбранной ноде скачивает образ и запускает контейнеры через containerd; kube-proxy прописывает правила, чтобы Service резолвился в живые поды. Ни один компонент не «знает всё» — каждый следит за своей частью состояния и двигает реальность к ней. Это и есть декларативная модель. Архитектура control plane подробно разобрана в официальной документации Kubernetes.
Pod: атомарная единица
Заголовок раздела «Pod: атомарная единица»Pod — минимальная единица планирования. Не контейнер: под. Почему? Потому что некоторым приложениям нужно несколько процессов рядом — классика: основной контейнер + sidecar-логгер, или приложение + nginx-прокси. Контейнеры одного пода делят сетевой namespace (общий IP, localhost между ними) и тома (можно монтировать общий emptyDir).
Но под смертен: у него нет гарантий перезапуска, имя меняется при пересоздании, IP меняется всегда. Поэтому поды напрямую создают только для отладки (kubectl run запускает именно под), а всё прочее — через контроллеры: Deployment для stateless, StatefulSet для stateful, Job/CronJob для задач, DaemonSet для агентов на каждой ноде. Семантика всех ворклоадов — в документации по workloads.
Deployment: полный манифест
Заголовок раздела «Deployment: полный манифест»Deployment — самый частый объект в кластере. Вот рабочий манифест с полным набором механизмов самозаживления:
apiVersion: apps/v1kind: Deploymentmetadata: name: api namespace: pet labels: { app: api }spec: replicas: 3 revisionHistoryLimit: 5 # сколько старых ReplicaSet хранить для отката strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 # старые поды не убиваем, пока не поднялись новые maxSurge: 1 # разрешаем +1 под на время раскатки selector: matchLabels: { app: api } template: metadata: labels: { app: api } spec: terminationGracePeriodSeconds: 30 # время на SIGTERM до SIGKILL containers: - name: api image: ghcr.io/darkpix/pet-api:1.4.2 ports: [{ containerPort: 3000 }] envFrom: - configMapRef: { name: api-config } - secretRef: { name: api-secrets }
# Гарантия планировщику: на ноде должно быть свободно столько requests: { cpu: 100m, memory: 128Mi } # Потолок через cgroup: CPU — throttling, память — OOM-kill limits: { cpu: 500m, memory: 256Mi }
# Startup: для приложений с долгой инициализацией (миграции, прогрев кэша). # Пока startup не прошла, liveness не проверяется — под не убьют раньше времени. startupProbe: httpGet: { path: /health, port: 3000 } failureThreshold: 30 # до 30 × 10s = 5 минут на старт periodSeconds: 10
# Readiness: под НЕ получает трафик, пока проба не зелёная. # Отвечает на вопрос «могу ли я обрабатывать запросы?» readinessProbe: httpGet: { path: /ready, port: 3000 } initialDelaySeconds: 2 periodSeconds: 5 timeoutSeconds: 2 failureThreshold: 3
# Liveness: проба красная N раз — kubelet перезапускает контейнер. # Отвечает на вопрос «не завис ли процесс?» — НЕ на «жив ли сервис» livenessProbe: httpGet: { path: /health, port: 3000 } periodSeconds: 15 timeoutSeconds: 2 failureThreshold: 3Три пробы — три разных вопроса, и смешивать их — классическая ошибка. Liveness-проба на /health, который проверяет связность с базой, убьёт все поды одновременно при падении БД — правильно: readiness должен «отключать» под от балансировки, а liveness должен смотреть только на сам процесс (event loop отвечает, память в норме). Полный набор параметров каждой пробы — в официальной документации по пробам.
Requests, limits и QoS
Заголовок раздела «Requests, limits и QoS»Планировщик укладывает поды на ноды строго по requests: сумма requests всех подов на ноде не превысит её ёмкость. Limits — это потолок внутри пода: превышение CPU обрезается throttling’ом (процесс просто получает меньше времени), превышение памяти — OOM-kill от cgroup.
Отношение requests к limits даёт QoS-класс, который определяет очерёдность выселения при нехватке ресурсов на ноде:
| QoS | Условие | Кого убивают первым |
|---|---|---|
Guaranteed |
requests == limits у всех контейнеров, заданы оба | Последнего |
Burstable |
requests < limits (обычный случай) | Вторым, по превышению requests |
BestEffort |
requests не заданы вообще | Первым |
Реальная история из практики: кластер с BestEffort-подами, на ноде кончилась память — OOM-killer начал с нодовых агентов и побил kubelet. Нода ушла в NotReady вместе со всеми подами. Правило: requests всегда, limits — для всего, что может съесть память (БД, Node.js, всё с кэшем).
Service: стабильная точка входа
Заголовок раздела «Service: стабильная точка входа»П IP у пода меняется при каждом пересоздании, поэтому поды находят друг друга через Service — стабильное DNS-имя + виртуальный IP + балансировка:
apiVersion: v1kind: Servicemetadata: { name: api, namespace: pet }spec: selector: { app: api } # все поды с этим лейблом — эндпоинты ports: - port: 80 targetPort: 3000---# Доступ снаружи к нодам: NodePort открывает порт 30000-32767 на каждой нодеapiVersion: v1kind: Servicemetadata: { name: api-nodeport, namespace: pet }spec: type: NodePort selector: { app: api } ports: [{ port: 80, targetPort: 3000, nodePort: 30080 }]---# Облачный балансировщик: в AWS/GCP создаётся реальный LBapiVersion: v1kind: Servicemetadata: { name: api-lb, namespace: pet }spec: type: LoadBalancer selector: { app: api } ports: [{ port: 80, targetPort: 3000 }]- ClusterIP — дефолт, доступ только внутри кластера. Так общаются сервисы между собой.
- NodePort — для bare-metal и отладки; в проде обычно скрыт за Ingress.
- LoadBalancer — в облаке создаёт внешний LB; на bare-metal нужен MetalLB.
Балансировка реализована через iptables/IPVS-правила kube-proxy: DNAT на случайный живой под. Поэтому Service не «владеет» соединениями и не терминирует TLS. Типы сервисов и механика service discovery разобраны в документации по Services.
ConfigMap и Secret: конфигурация вне образа
Заголовок раздела «ConfigMap и Secret: конфигурация вне образа»apiVersion: v1kind: ConfigMapmetadata: { name: api-config, namespace: pet }data: NODE_ENV: "production" LOG_LEVEL: "info" REDIS_URL: "redis://redis:6379"---apiVersion: v1kind: Secretmetadata: { name: api-secrets, namespace: pet }type: OpaquestringData: # K8s сам закодирует в base64 при записи DATABASE_URL: postgres://app:changeme@db:5432/appdbПодключение двумя способами. env — переменные в процессе (просто, но изменение ConfigMap не перезапустит под):
envFrom: - configMapRef: { name: api-config } - secretRef: { name: api-secrets }volume — файлы в директории (изменения подхватываются без рестарта, kubelet перезаписывает файлы):
volumeMounts: - { name: config, mountPath: /etc/api, readOnly: true } volumes: - name: config configMap: { name: api-config } # каждый ключ — файлPV, PVC и StorageClass
Заголовок раздела «PV, PVC и StorageClass»Диски в Kubernetes двухуровневые. PersistentVolume — физический диск (диск ноды, NFS-том, облачный диск), созданный админом или provisioner’ом. PersistentVolumeClaim — заявка приложения «мне нужно 10 Gi с таким-то доступом». Планировщик пода учитывает, где лежит PVC с режимом ReadWriteOnce.
apiVersion: v1kind: PersistentVolumeClaimmetadata: { name: db-data, namespace: pet }spec: accessModes: [ReadWriteOnce] storageClassName: local-path # provisioner из k3s; в kind — standard resources: { requests: { storage: 10Gi } }StorageClass — шаблон создания дисков: какой provisioner вызывать, какой тип диска, политику удаления (Retain — диск переживает PVC, Delete — удаляется вместе). Важное правило: PVC существует независимо от пода и переживает его — удаление Deployment данные не трогает, удаление PVC с Delete-классом — трогает.
CronJob: бэкапы по расписанию
Заголовок раздела «CronJob: бэкапы по расписанию»apiVersion: batch/v1kind: CronJobmetadata: { name: db-backup, namespace: pet }spec: schedule: "15 3 * * *" # каждую ночь в 03:15 concurrencyPolicy: Forbid # второй запуск, пока идёт первый, запрещён successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 3 startingDeadlineSeconds: 600 # окно наверстывания пропущенного запуска jobTemplate: spec: backoffLimit: 2 # перезапуски пода при падении template: spec: restartPolicy: OnFailure containers: - name: backup image: postgres:16-alpine envFrom: [{ secretRef: { name: api-secrets } }] command: ["/bin/sh", "-c"] args: - | set -e pg_dump "$DATABASE_URL" | gzip > /backup/dump-$(date +%F).sql.gz # ротация: храним 14 последних ls -1 /backup/dump-*.sql.gz | head -n -14 | xargs -r rm volumeMounts: - { name: backup, mountPath: /backup } volumes: - name: backup persistentVolumeClaim: { claimName: backup-pvc }Job доводит под до Completed и останавливается. Логи — обычные kubectl logs job/db-backup-28510347, поэтому пиши всё в stdout, не в файлы. Для настоящего продакшена дамп ещё выгружается наружу (S3 через rclone/aws s3 cp) — локальный PVC сгорит вместе с нодой.
Рабочий набор kubectl
Заголовок раздела «Рабочий набор kubectl»# Деплой и статусkubectl apply -f k8s/ # идемпотентно применить директориюkubectl apply -f deployment.yaml --dry-run=client -o yaml # проверить манифестkubectl get deploy,po,svc -n pet -o widekubectl get pods -n pet -w # watch в реальном времени
# Диагностикаkubectl describe pod api-7d9f4 -n pet # Events — журнал решений кластераkubectl logs deploy/api -n pet --previous # логи прошлого контейнера (после краша)kubectl logs -n pet -l app=api --tail=100 --since=10mkubectl exec -it deploy/api -n pet -- sh # зайти в контейнерkubectl port-forward svc/api 8080:80 -n pet # локальный доступ к сервисуkubectl debug -it pod/api-7d9f4 -n pet --image=busybox --target=api # ephemeral-контейнер
# Раскатка и откатkubectl rollout status deploy/api -n petkubectl rollout history deploy/api -n pet # ревизии с ревизион-annotationkubectl rollout undo deploy/api -n pet --to-revision=3kubectl set image deploy/api api=ghcr.io/darkpix/pet-api:1.4.3 -n pet # быстрый бамп тега
# События и ресурсы кластераkubectl get events -n pet --sort-by=.lastTimestampkubectl top pods -n pet # требует metrics-serverkubectl api-resources | grep -i cron # найти ресурс, если забыл названиеkubectl explain deployment.spec.strategy # справка по полям манифестаТри команды экономят часы: describe (почему), logs --previous (что случилось до рестарта) и explain (как называется поле, которое ты забыл). Полный список команд и глобальных флагов — в референсе kubectl.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Liveness-проба проверяет внешние зависимости. Проба на
/health, который пингует базу: база легла → все поды уходят в перезапуск → ещё больше нагрузки на базу →CrashLoopBackOffпо кластеру. Liveness — только про сам процесс, внешние зависимости — в readiness. - Нет requests. Под планируется куда попало, при нехватке ресурсов его убивают первым (BestEffort). «Работало вчера» — потому что вчера на ноде было свободнее. Задавай requests по замерам (
kubectl top podsпосле недели работы). - Requests без limits для memory-ёмких сервисов. Node.js с разрастающимся кэшем съедает память ноды и получает OOM — лучше раньше (limit на поде) и с restart, чем позже (OOM-killer всей ноды, где чужие поды).
- ConfigMap изменён, поды не перечитали.
envFromзашивает значения на старте; изменение ConfigMap не рестартит поды. Либо volume-монтирование, либоkubectl rollout restart, либо checksum-аннотация в шаблоне пода. - Удалён PVC с
Delete-политикой — удалились и данные. Для базы — потеря продакшена. StorageClass сRetainдля всего важного, бэкапы — отдельно (CronJob выше). - Деплой без проб вообще. Под стартует, получает трафик, пока ещё прогревает кэш — первые сотни запросов падают. Минимум — readinessProbe, иначе rolling update не лучше
docker restart.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Что происходит после
kubectl apply? CLI валидирует манифест и шлёт его в kube-apiserver → запись в etcd → controller’ы видят расхождение желаемого и текущего состояния → scheduler назначает поды на ноды → kubelet запускает контейнеры через рантайм → kube-proxy настраивает сеть. - Отличие readiness от liveness? Readiness: под не готов — он исключается из эндпоинтов Service (без перезапуска). Liveness: процесс завис — kubelet перезапускает контейнер. Startup защищает долгий старт от преждевременного срабатывания liveness.
- Что произойдёт, если контейнер превысит memory limit? cgroup OOM-kill: процесс убивается, kubelet перезапускает контейнер по restartPolicy. Для CPU превышение — мягкое: throttling, процесс замедляется, но живёт.
- Почему не создавать Pod напрямую? Под не переживает ноду, не перезапускается сам, теряет IP. Deployment обеспечивает реплики, self-healing, rolling update и rollback.
- ClusterIP, NodePort, LoadBalancer — разница? ClusterIP — внутренний виртуальный IP. NodePort — ClusterIP + порт на каждой ноде (30000-32767). LoadBalancer — NodePort + внешний балансировщик (в облаке — автоматически).
- QoS-классы и их влияние? Guaranteed (requests==limits) — последний кандидат на выселение; Burstable — по превышению requests; BestEffort — первый. Влияет на поведение при нехватке ресурсов ноды.
kubectl rollout undo— что под капотом? Deployment хранит историю ReplicaSet (ограниченаrevisionHistoryLimit). Откат — переключение на предыдущий ReplicaSet с масштабированием старого вверх и нового вниз.
Практика
Заголовок раздела «Практика»- Подними kind-кластер (
kind create cluster --name lab). Задеплой Deployment из главы (3 реплики) + Service ClusterIP. Убей один под (kubectl delete pod) и замерь время восстановления поkubectl get pods -w. - Добавь endpoint
/health(всегда 200) и/ready(первые 10 секунд после старта — 503). Задеплой с readinessProbe и наблюдай черезkubectl get endpoints: под появится в эндпоинтах только после прогрева. - Сломай liveness намеренно: пусть
/healthпосле 60 секунд работы начнёт возвращать 500. Наблюдай перезапуски:kubectl get pods,kubectl describe pod(события Unhealthy),kubectl logs --previous. - Создай PVC (10 Gi), подключи к поду Postgres, запиши данные. Удали Deployment, пересоздай — данные на месте. Удали PVC — проверь, что произошло с PV (зависит от reclaim policy).
- Настрой CronJob бэкапа: ежедневный дамп в PVC с ротацией на 14 файлов. Сымитируй падение (
exit 1в команде) и посмотри, как Job делает retry поbackoffLimit.
Что почитать
Заголовок раздела «Что почитать»- Kubernetes Documentation — Concepts — поды, ворклоады, сеть, хранилище
- Configure Liveness, Readiness and Startup Probes — первоисточник по пробам
- kubectl Cheat Sheet
- Assign Memory Resources to Containers и CPU — практика requests/limits
- Configure a Pod to Use a PersistentVolume