← назад к разделу

Раньше сервис деплоили так: написали скрипт, зашли по ssh, остановили процесс, скопировали новый jar, запустили снова. Если нода упала — всё, сервис лежит, кто-то идёт чинить руками. Если нужно пять копий — пять раз делаешь одно и то же.

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

Как это работает вживую — на схеме ниже: один и тот же цикл сравнения отрабатывает и упавший под, и ручную команду, и правку манифеста.

Контроллер сравнивает декларацию с фактом и правит фактДекларация · истинаreplicas: 3selector: app=order-serviceФакт в кластереживые поды прямо сейчасIP меняется при пересозданиисоздаёт/удаляетчитает факт Под упал вместе с нодойвы не делали ничегофакт: 2 из 3создаёт подснова 3 из 3 Запустили 4-й под рукамиметка app=order-service+факт: 4 из 3удаляет лишнийснова 3 из 3 Правим replicas: 3 → 5меняем саму декларациюбыло 3 из 3создаёт ещё 2стало 5 из 5 Новый под попадёт в Service не сразу: пока readiness probe красная,после замены упавшего пода трафик идёт на два готовых из трёх. Руками меняется только факт — его вернут к декларацииДержится только то, что записано в манифесте

Один и тот же цикл сравнения отрабатывает все три случая: пода не хватает — создаст, лишний — удалит, поменяли манифест — подтянет факт к нему.

Обязательно

Главная идея: желаемое состояние

Kubernetes работает как постоянный наблюдатель. Вы говорите ему: «хочу три копии этого сервиса, каждой нужно 512 МБ памяти, наружу смотрит порт 8080». Kubernetes сравнивает это с тем, что есть на самом деле, и устраняет расхождения.

Упала одна копия — Kubernetes создаёт новую. Умерла целая нода — поды переезжают на живые машины. Вы ничего не делаете руками: кластер сам поддерживает нужную картину.

Из этого следует важное правило: состояние кластера меняют правкой деклараций, не ручными командами. Зайти и что-то поправить «по-быстрому» — это то же самое, что менять файл на работающем сервере в обход системы контроля версий.

Кто именно исполняет ваши намерения

«Кластер сам решает как» — удобное упрощение, но дальше по фазе придётся читать события и разбирать инциденты, а для этого надо знать, кто их пишет. Внутри кластера четыре роли, и все они обычные программы.

api-server — единственная дверь. Всё, что делает kubectl, всё, что делают контроллеры и все инструменты, идёт через его HTTP API. Он проверяет права, проверяет корректность объекта и записывает его. Практическое следствие: если kubectl отвечает ошибкой доступа или таймаутом, дело не в поде, а в этой двери.

etcd — база, в которой лежат все объекты кластера. Ничего кроме api-server с ней не разговаривает. Знать про неё нужно ради одного: состояние кластера — это записи в базе, и резервная копия кластера — это копия etcd.

scheduler — выбирает, на какой узел поставить новый под. Он смотрит на запрошенные ресурсы (о них ниже), на свободную ёмкость узлов и на ограничения размещения. Именно он оставляет под в состоянии Pending, когда подходящего узла нет — и пишет об этом событие с причиной.

controller-manager — набор контроллеров, каждый со своим циклом «сравнил желаемое с текущим — сделал шаг». Контроллер Deployment создаёт и удаляет поды, контроллер узлов помечает узел недоступным, и так далее. Отсюда важное свойство: изменения происходят не мгновенно, а за несколько проходов цикла, поэтому между kubectl apply и появлением пода проходят секунды.

kubelet — агент на каждом узле. Он получает от api-server список подов, которые должны работать на его узле, запускает контейнеры через среду выполнения (containerd), следит за пробами, перезапускает упавшее и сообщает состояние обратно. Всё, что происходит с контейнером на узле, — его работа, и логи контейнера тоже приходят через него.

Как это складывается в один рассказ: kubectl apply отправляет объект в api-server, тот пишет его в etcd, контроллер Deployment замечает новый объект и создаёт поды, scheduler назначает им узлы, kubelet на этих узлах запускает контейнеры и докладывает статус. Каждый шаг оставляет событие (kubectl get events, kubectl describe) — и именно поэтому события читаются как хронология: их пишут разные участники этой цепочки.

Pod: один процесс (или несколько рядом)

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

Два свойства пода важно понять с самого начала:

Под одноразовый. Его не лечат — его убивают и создают новый. Если приложение упало, Kubernetes не пытается его починить: он просто запускает свежий контейнер из образа. Из этого следует: никакого важного состояния внутри пода быть не должно. Файлы на диске исчезнут. IP-адрес сменится.

IP пода непостоянен. Пока под жив — у него есть адрес. Когда под пересоздаётся — адрес другой. Полагаться на него нельзя. Для постоянного адреса существует Service — об этом ниже.

Иногда в поде несколько контейнеров: например, ваше приложение плюс агент сбора логов. Они делят одну сеть и один диск. Это называется sidecar-паттерн, но новичку это редко нужно — почти всегда под = один контейнер.

Deployment: кто управляет подами

Поды вручную почти никогда не создают. Deployment — это декларация: «запусти N копий вот такого пода и поддерживай их в рабочем состоянии».

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  selector:
    matchLabels: { app: order-service }
  template:
    metadata:
      labels: { app: order-service }
    spec:
      containers:
        - name: app
          image: registry.local/order-service:1.42.0
          ports:
            - containerPort: 8080
          resources:
            requests: { cpu: "500m", memory: "768Mi" }
            limits: { memory: "768Mi" }

Deployment даёт три вещи:

  • Самовосстановление — упала реплика, Kubernetes создаёт новую.
  • Масштабирование — поменяли replicas: 3 на replicas: 5, через несколько секунд их пять.
  • Обновление без остановки — при смене образа Deployment заменяет поды порциями, не роняя сервис целиком.

Как Deployment находит свои поды? Не по именам — по меткам (labels). В примере выше все поды имеют метку app: order-service, а Deployment ищет именно их через selector.matchLabels. Этот же механизм используется в Service.

совпало Deployment поды app=order Service app=order endpoints 3 из 3 разошлось Deployment поды app=order Service app=orders endpoints пуст

Deployment держит поды по своей метке, Service ищет по своей: разойдётся одна буква - endpoints пуст, а поды при этом живы.

Одна ловушка, о которой лучше узнать заранее: селектор у Deployment менять нельзя. Захотели переименовать метку в уже работающем Deployment — кластер откажет, придётся удалять объект и создавать заново. Метки в selector.matchLabels выбирают один раз и всерьёз.

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

requests и limits: сколько ресурсов просит под

В манифесте выше есть блок resources, и это не украшение: от этих двух чисел зависит, где под запустится и что с ним будет под нагрузкой.

        resources:
          requests:
            memory: "512Mi"
            cpu: "250m"
          limits:
            memory: "768Mi"
            cpu: "1"

requests — это то, что кластер обязуется дать. Планировщик складывает запросы всех подов на узле и ставит новый под только туда, где остался запас. Поэтому requests — про размещение: слишком большой запрос оставляет под в Pending навсегда, слишком маленький ставит его на переполненный узел, где он будет голодать.

limits — это потолок, дальше которого не дадут. По памяти потолок жёсткий: процесс, который его перешагнул, убивает ядро, и контейнер получает состояние OOMKilled (тот самый код 137 из статей про Docker). По процессору потолок мягкий: процесс не убивают, его притормаживают — он получает свою долю времени и ждёт остальное. Это неприятный эффект: приложение внезапно отвечает вдвое медленнее, а в логах ничего нет.

Единицы стоит запомнить, потому что они необычные. Память: Mi и Gi — это двоичные мегабайты и гигабайты (1024), а M и G — десятичные; разница в пять процентов, и в узких лимитах она заметна. Процессор: 1 означает одно ядро, 250m — четверть ядра («m» значит тысячные доли).

Как выбирать, коротко. Запрос памяти — примерно то, что процесс занимает под нагрузкой, с запасом; лимит памяти близко к запросу, чтобы под не рос неограниченно. Запрос процессора — обычное потребление, а лимит по процессору многие намеренно не ставят вовсе: он приносит притормаживание чаще, чем пользу. Про то, как это связано с настройками рантайма, — отдельные статьи: JVM считает кучу от лимита памяти, а число потоков — от лимита процессора; у Go то же делают GOMEMLIMIT и GOMAXPROCS, у Node — размер старого поколения V8, у Python — число воркеров.

Из пары requests и limits Kubernetes сам выводит класс обслуживания пода, и он решает, кого убьют первым, когда узлу не хватает памяти. Guaranteed: запросы равны лимитам по памяти и процессору у каждого контейнера; такой под вытесняют последним, и это выбор для баз и брокеров. Burstable: запросы есть, но меньше лимитов или заданы не всюду; обычный класс для сервисов, и среди них первым вытесняют того, кто сильнее превысил свой запрос. BestEffort: ни запросов, ни лимитов; первый кандидат на вытеснение и худшее размещение. Класс виден в kubectl describe pod строкой QoS Class. Поэтому запросы указывают всегда, даже приблизительные: под без них попадает в BestEffort.

Service: постоянный адрес над сменяющимися подами

Поды приходят и уходят, их адреса меняются. Service — это стабильное имя и виртуальный IP, который всегда указывает на поды, готовые принимать трафик:

apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector: { app: order-service }
  ports:
    - port: 80
      targetPort: 8080

Теперь любой другой сервис в кластере обращается по имени http://order-service — и Kubernetes направит запрос на один из готовых подов с меткой app: order-service.

Важная деталь: под попадает в список доступных адресов только тогда, когда его readiness probe возвращает успех. Если приложение ещё не прогрелось или временно недоступно — трафик на него не идёт. Как настроить probe — в следующей статье.

Как запрос из интернета добирается до пода

Полная цепочка выглядит так:

интернет LoadBalancer Ingress-контроллер Service Pod

Каждый шаг отвечает за своё: балансировщик даёт внешний адрес, Ingress разбирает домены и пути, Service находит живые поды. Под при этом может переезжать и менять адрес — снаружи этого не видно.

Ingress — это декларация маршрутизации: «запросы на api.example.com/orders отправляй в Service order-service». Плюс там же настраивается TLS. Выполняет эту декларацию Ingress-контроллер (например, nginx или traefik) — он уже реально слушает входящие запросы.

Когда что-то не работает, эту цепочку проверяют звено за звеном: LoadBalancer поднят? Ingress сконфигурирован правильно? Service видит поды? Поды отвечают на probe?

ConfigMap и Secret: настройки отдельно от образа

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

ConfigMap — набор пар «ключ — значение» или целых файлов с настройками:

apiVersion: v1
kind: ConfigMap
metadata:
  name: orders-config
data:
  SPRING_PROFILES_ACTIVE: prod
  LOG_LEVEL: INFO

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

Подключают их к поду двумя способами:

        envFrom:
          - configMapRef:
              name: orders-config      # все ключи станут переменными окружения
        env:
          - name: SPRING_DATASOURCE_PASSWORD
            valueFrom:
              secretKeyRef:            # одно значение из секрета
                name: orders-db
                key: password
        volumeMounts:
          - name: config               # или файлом на диске
            mountPath: /config

Что важно знать сразу. Переменные окружения читаются при старте: изменили ConfigMap — под продолжает работать со старыми значениями, пока его не перезапустят. Файлы, наоборот, обновляются на месте через некоторое время, и приложение может их перечитать. И base64 в секрете никого не защищает: настоящая защита — это права доступа к объектам и шифрование в самой базе кластера, а пароли в репозитории с манифестами — отдельная тема, о которой статья про доставку.

Namespace: разделяем окружения

Два окружения в одном кластере — и в обоих сервис называется order-service. Чтобы имена не сталкивались, а права и квоты раздавались по окружениям, объекты раскладывают по namespace — папкам кластера: payments-prod, payments-staging.

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

Минимальный набор команд

Большинство задач разработчика покрывают пять команд:

kubectl -n payments-prod apply -f deployment.yaml
kubectl -n payments-prod get pods
kubectl -n payments-prod describe pod order-service-7d4b9c-x2x4v
kubectl -n payments-prod logs deploy/order-service --tail=200
kubectl -n payments-prod port-forward svc/order-service 8080:80

Первая — отправить в кластер поправленный манифест; именно так меняют состояние, а не ручными командами. Вторая — посмотреть, что запущено. Третья — разобраться, почему под не стартует или лежит. Четвёртая — прочитать логи; учтите, что у деплоймента с тремя репликами kubectl возьмёт логи одного пода, а не всех сразу. Пятая — временно пробросить порт к себе, чтобы проверить работу сервиса локально.

Что Kubernetes не делает за вас

Kubernetes заменяет упавшие поды — но не чинит код внутри. Если приложение падает из-за бага, Kubernetes будет перезапускать под снова и снова (это называется CrashLoopBackOff) — причину придётся искать в логах и чинить самому.

Перезапуски при этом не идут подряд: после каждого падения кластер ждёт всё дольше — 10 секунд, 20, 40, 80 — и упирается в потолок 5 минут. Поэтому упавший под может минутами просто лежать и «ничего не делать»: это не зависание, а пауза перед следующей попыткой.

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

База данных в Kubernetes — отдельная, сложная тема (StatefulSet, операторы). Большинству команд проще держать управляемую БД вне кластера.

Когда Kubernetes не нужен

Статья до этого места объясняет, как он работает, и не отвечает на вопрос, стоит ли его брать. Ответ не всегда «да», и цена у него вполне измеримая.

Что придётся содержать. Управляемый кластер у облачного провайдера — это плата за узлы и за сам кластер, а ещё реестр образов, хранилище, балансировщик, система сбора логов и метрик. Свой кластер на своих машинах добавляет сюда обновления версий (несколько раз в год, с ломающими изменениями в API), сертификаты, резервные копии базы кластера и дежурство. Кто-то должен это уметь: не «прочитал руководство», а понимать пробы, ресурсы, сеть и права.

Когда не стоит.

  • Один сервис на паре машин. Контейнер плюс docker compose или systemd, перезапуск при падении, обновление по одному узлу за раз. Это закрывает те же задачи меньшими силами, и разбор такого отказа не требует знания десяти объектов.
  • Нет платформенной поддержки. Если в команде четыре разработчика и никто не хочет заниматься кластером, кластер будет заниматься вами. Признак: манифесты правит один человек, и когда он в отпуске, выкаты останавливаются.
  • Нагрузка ровная и небольшая. Автоматическое масштабирование, ради которого часто и берут кластер, имеет смысл при пиках в разы; при ровной нагрузке достаточно фиксированного числа экземпляров.
  • Всё живёт в одном приложении. Монолиту с базой Kubernetes не даёт почти ничего, кроме нового слоя, в котором надо разбираться при каждом сбое.

Что брать вместо. Контейнеры плюс docker compose на одной машине; готовая площадка для контейнеров у облака (запускает образ, сама масштабирует и обновляет, без кластера); или обычный выкат на виртуальные машины через systemd. Все три дают меньше возможностей и требуют меньше знаний — и это правильный размен, пока сервисов немного.

Когда стоит. Сервисов больше пяти и они выкатываются независимо; несколько команд, которым нужны свои пространства и квоты; нагрузка с пиками, которую надо масштабировать автоматически; требование к доступности, из которого следуют несколько узлов и автоматический перенос подов. Тогда те же задачи всё равно придётся решать — и решать их вручную выйдет дороже.

Шпаргалка по объектам

ОбъектЧто делает
PodЗапускает один или несколько контейнеров вместе
DeploymentСледит, чтобы нужное число подов всегда работало
ServiceПостоянное имя и адрес над меняющимися подами
IngressМаршрутизация внешнего трафика к нужному Service
ConfigMap / SecretКонфигурация и секреты отдельно от образа
NamespaceГруппа объектов с правами и квотами
Дополнительно: при первом чтении можно пропустить

Глубже: куда девать состояние: PersistentVolume, PVC, StorageClass и StatefulSetрасширенное

«Файлы на диске исчезнут» это правило для пода, а не приговор для данных. У Kubernetes есть свой способ дать поду диск, который переживёт пересоздание, и четыре объекта, которые для этого нужны.

PersistentVolume это сам диск: том в облаке, раздел на узле, сетевая папка. Объект кластерный, заводит его администратор или облако. PersistentVolumeClaim это заявка от приложения в своём namespace: «мне нужно 20 ГБ с таким-то режимом доступа», и Kubernetes связывает заявку с подходящим томом. Под ссылается на заявку, а не на том, поэтому манифест приложения не знает, где диск физически. StorageClass избавляет от ручного заведения томов: класс описывает, как их создавать (тип облачного диска, скорость), и заявка с именем класса получает свежий том автоматически. Режим доступа важнее, чем кажется: ReadWriteOnce означает, что том монтируется на один узел, и Deployment с двумя репликами на разных узлах со вторым подом не поднимется; ReadWriteMany дают только сетевые файловые системы. Политика Retain против Delete решает, что будет с диском при удалении заявки, и для баз ставят Retain.

StatefulSet это Deployment для тех, кому нужны и диск, и постоянное имя. Поды называются db-0, db-1, стартуют и останавливаются по порядку, каждый получает свою заявку из шаблона volumeClaimTemplates, и при пересоздании db-1 снова подключает свой диск, а не чужой. Headless Service даёт каждому поду постоянное DNS-имя. Так живут базы, брокеры, всё, где у реплик разные роли.

Для сервиса без состояния на любом стеке из этого следует простое правило: ему диск не нужен. Файлы пользователей в объектное хранилище, состояние в базу, кэш в Redis, временные файлы в emptyDir, который живёт ровно столько, сколько под. Заявка на диск у сервиса без состояния это либо ошибка проектирования, либо журнал, который должен идти в stdout. А базу данных в кластере запускают через оператор (CloudNativePG, оператор Kafka), который знает про резервные копии и переключение реплик, либо берут управляемую базу вне кластера; StatefulSet руками это последнее, что делают с базой.

Глубже: несколько контейнеров в поде: sidecar, init и порядок остановкирасширенное

«Новичку редко нужен» относится к тому, чтобы писать sidecar самому. Встречать их придётся сразу: сборщик журналов, прокси сервисной сетки (istio-proxy), агент секретов (vault-agent) приходят в под без вашего участия, и три вещи про них стоит знать заранее.

Что делят. Контейнеры одного пода видят друг друга по localhost и могут делить тома, но у каждого свой образ, свои ресурсы и свой журнал. kubectl logs order-service-7d9f -c app читает журнал одного контейнера, --all-containers всех; без -c при нескольких контейнерах команда откажет, и это первое, обо что спотыкаются. Готовность пода это готовность всех контейнеров: если sidecar не поднялся, под не получит трафик, хотя приложение живо. Запросы ресурсов складываются: под с прокси просит на пол-ядра и сто мегабайт больше, чем приложение, и это видно в квотах.

Init-контейнеры запускаются до основных, по порядку, и обязаны завершиться: ждать базу, скачать конфигурацию, проверить миграции. Их журнал читают тем же -c.

Порядок остановки. При завершении пода все контейнеры получают SIGTERM одновременно. Прокси сетки, который умирает раньше приложения, рвёт исходящие вызовы ровно в тот момент, когда приложение доделывает запросы; агент секретов, ушедший первым, оставляет приложение без обновления токена. Старый обходной путь это задержка в preStop у sidecar или его настройка «жить, пока есть соединения». Штатный ответ появился в Kubernetes 1.29: контейнер объявляют в initContainers с restartPolicy: Always, и он становится настоящим sidecar: стартует до приложения, живёт вместе с ним и останавливается после него. Сервисные сетки умеют работать в этом режиме, и включать его стоит там, где приложение при остановке ещё делает исходящие вызовы, о чём статья про корректное завершение в Kubernetes.

Коротко

  • Kubernetes поддерживает желаемое состояние: вы декларируете, он выполняет и восстанавливает при сбоях.
  • Pod — одноразовая единица. Его не лечат, а пересоздают. Никакого важного состояния внутри. Sidecar приходят без вас: журнал по -c, готовность пода это все контейнеры, ресурсы складываются; SIGTERM приходит всем сразу, и штатный порядок остановки дают sidecar через initContainers с restartPolicy: Always.
  • Deployment управляет N копиями пода, даёт самовосстановление, масштабирование и обновление без остановки. Labels — механизм связи: Deployment находит свои поды по меткам, Service — тоже.
  • Service — постоянный адрес над живыми подами. Трафик идёт только на поды с зелёной readiness probe. Ingress маршрутизирует внешний трафик к нужному Service.
  • Namespace разделяет окружения административно, не сетево. ConfigMap и Secret держат настройки вне образа: переменные читаются при старте и требуют перезапуска, файлы обновляются на месте, а base64 в секрете — не защита.
  • Kubernetes заменяет упавшие поды, но не чинит код и не делает сервис отказоустойчивым сам по себе.
  • Состояние живёт не в поде: PVC по StorageClass даёт диск, ReadWriteOnce это один узел, StatefulSet это постоянные имена и свой диск у каждой реплики; сервису без состояния диск не нужен, базу ставят оператором или берут снаружи.
  • Намерения исполняют четыре участника: api-server как единственная дверь, etcd как хранилище, scheduler для размещения, контроллеры и kubelet на узлах — и каждый пишет события, по которым потом читается хронология.
  • requests определяют размещение (маленькие — голодание, большие — Pending), limits — потолок: по памяти жёсткий (OOMKilled); класс обслуживания выводится из них — Guaranteed (равны) вытесняют последним, Burstable — обычный слой.
  • Kubernetes не нужен одному сервису на паре машин и команде без платформенной поддержки: там дешевле compose, готовая площадка для контейнеров или systemd.

Что почитать дальше