В прошлой главе мы упаковали myapp в Docker-образ. Теперь нужно объяснить кластеру, что с этим образом делать: сколько копий запустить, на каком порту он слушает и как другие части системы будут его находить. Делается это через манифесты — текстовые YAML-файлы, в которых вы декларативно описываете желаемое состояние. Вы не командуете «запусти контейнер», вы говорите «я хочу, чтобы в кластере было вот это», а Kubernetes сам приводит реальность к описанию и удерживает её в этом состоянии.

В этой главе мы напишем три манифеста для нашего сквозного примера — Namespace, Deployment и Service — и разберём, как они склеиваются между собой. PostgreSQL и прочие зависимости оставим на главу про зависимости, конфигурацию и секреты — на главу про конфигурацию и секреты, а внешний доступ через Ingress — на главу про сеть. Здесь — минимальный, но рабочий каркас.

Namespace — изолируем приложение

Namespace (пространство имён) — это механизм изоляции и разграничения имён внутри одного кластера. Грубо говоря, это папка для ваших ресурсов. Имена объектов уникальны внутри одного namespace, но не между разными: вы можете иметь Deployment с именем myapp и в namespace dev, и в namespace staging одновременно — конфликта не будет.

Из коробки в кластере есть четыре системных namespace: default (куда всё попадает, если не указать другой), kube-system (системные компоненты самого Kubernetes), kube-public и kube-node-lease. Хорошая привычка — не складывать своё приложение в default, а завести отдельный namespace. Так проще удалить всё разом, навесить лимиты ресурсов и не перепутать своё с чужим. Префикс kube- зарезервирован под системные нужды, поэтому свои namespace так называть нельзя. Вкладывать namespace друг в друга, кстати, тоже нельзя — иерархии тут нет.

Важно понимать, что namespace распространяется только на namespaced-объекты: Pod, Deployment, Service, ConfigMap, Secret и т. п. Есть и cluster-scoped объекты, которые живут на уровне всего кластера и в namespace не лежат: Node, PersistentVolume, StorageClass. Посмотреть, что к какой категории относится, можно так:

bash
1kubectl api-resources --namespaced=true
2kubectl api-resources --namespaced=false

Создать namespace можно одной командой или манифестом. Раз мы условились хранить всё в Git (об этом ниже), сделаем манифестом. Имя должно быть валидным DNS-лейблом по RFC 1123 — строчные буквы, цифры и дефис. Наш namespace называется myapp:

yaml
1# namespace.yaml
2apiVersion: v1
3kind: Namespace
4metadata:
5  name: myapp

Применяем:

bash
1kubectl apply -f namespace.yaml

Чтобы не дописывать -n myapp к каждой команде, удобно один раз переключить контекст на нужный namespace:

bash
1kubectl config set-context --current --namespace=myapp

Ещё про namespace полезно знать в контексте DNS. Сервисы внутри кластера получают доменное имя вида <service>.<namespace>.svc.cluster.local. Если вы обращаетесь к сервису из того же namespace, достаточно короткого имени (myapp). Если из другого — нужно полное имя (FQDN), например myapp.myapp.svc.cluster.local. Это типичные грабли: «из соседнего namespace не достучаться по короткому имени». Подробнее про внутрикластерный DNS — в главе про сеть.

Deployment: pod, контейнер, образ, реплики

Прежде чем писать Deployment, разберёмся с терминами снизу вверх.

  • Контейнер — это запущенный экземпляр вашего образа (того самого, что мы собрали в главе про контейнеризацию).
  • Pod (под) — наименьшая единица, которой оперирует Kubernetes. Это «обёртка» вокруг одного или нескольких контейнеров, делящих сеть и хранилище. В нашем случае под = один контейнер с myapp. Поды эфемерны: упал — Kubernetes выкинет старый и создаст новый с новым IP. Поэтому поды напрямую почти никогда не создают руками.
  • ReplicaSet — контроллер, который следит, чтобы в кластере всегда крутилось заданное число одинаковых подов.
  • Deployment — то, чем вы пользуетесь на практике. Он управляет ReplicaSet'ами и даёт декларативные обновления: меняете образ — Deployment плавно выкатывает новую версию, при проблеме откатывает.

Иерархия получается такая: Deployment → ReplicaSet → Pods. Вы описываете только верхний уровень, остальное Kubernetes создаёт сам (имя ReplicaSet он формирует как имя Deployment плюс хеш).

Вот манифест для myapp. Сервис слушает порт 8080 (HTTP API на FastAPI/uvicorn), образ берём из встроенного registry k3d — k3d-registry.localhost:5000/myapp:dev (как настраивали в главе про k3d и главе про контейнеризацию):

yaml
1# deployment.yaml
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5  name: myapp
6  namespace: myapp
7  labels:
8    app: myapp
9spec:
10  replicas: 1
11  selector:
12    matchLabels:
13      app: myapp
14  template:
15    metadata:
16      labels:
17        app: myapp
18    spec:
19      containers:
20        - name: myapp
21          image: k3d-registry.localhost:5000/myapp:dev
22          ports:
23            - containerPort: 8080

Разберём ключевые поля:

  • apiVersion: apps/v1 и kind: Deployment — какой тип объекта мы описываем.
  • spec.replicas: 1 — сколько копий пода держать. По умолчанию 1, что для локальной разработки обычно и нужно. В проде ставят больше для отказоустойчивости.
  • spec.selector.matchLabels — по каким лейблам Deployment опознаёт «свои» поды.
  • spec.template — шаблон, по которому штампуются поды: их лейблы (metadata.labels) и контейнеры (name, image, ports.containerPort).

Самое важное правило, на котором спотыкаются все новички: spec.selector.matchLabels обязан совпадать с spec.template.metadata.labels. Если они разойдутся, Kubernetes отклонит манифест прямо при apply. Логика простая: Deployment создаёт поды с лейблами из шаблона, а потом по селектору ищет, что он создал. Если искать он будет не то, что создаёт, — система не сойдётся. У нас везде app: myapp, поэтому всё в порядке.

Лейбл app: myapp мы придумали сами — это произвольная пара ключ/значение. В реальных проектах часто добавляют ещё и рекомендованные лейблы Kubernetes, чтобы инструменты (Helm, дашборды, мониторинг) понимали ваши объекты единообразно: app.kubernetes.io/name, app.kubernetes.io/instance, app.kubernetes.io/version, app.kubernetes.io/component, app.kubernetes.io/part-of, app.kubernetes.io/managed-by (Recommended Labels). Они не обязательны, и для первого знакомства простого app: myapp достаточно — но знать про них стоит.

Здесь мы намеренно дали минимальный Deployment. В реальной локалке к нему добавляют переменные окружения, проверки готовности (readinessProbe/livenessProbe) и лимиты ресурсов — это мы разберём в главе про приближение к проду, когда будем приближать сервис к проду.

Service (ClusterIP): стабильный адрес внутри кластера

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

Тип Service по умолчанию — ClusterIP. Это виртуальный IP-адрес, доступный только внутри кластера. Service получает не только этот стабильный IP, но и DNS-имя, так что клиенты вообще не хардкодят адреса — они просто ходят по имени myapp. Контроллер постоянно следит, какие поды подходят под селектор, и обновляет список их адресов (внутри это называется EndpointSlices).

Манифест Service для myapp:

yaml
1# service.yaml
2apiVersion: v1
3kind: Service
4metadata:
5  name: myapp
6  namespace: myapp
7spec:
8  selector:
9    app: myapp
10  ports:
11    - protocol: TCP
12      port: 80
13      targetPort: 8080

Два поля про порты часто путают — запомните разницу:

  • port — порт, на котором слушает сам Service. Сюда стучатся другие клиенты в кластере. Здесь мы выставили 80.
  • targetPort — порт на поде, куда Service переправляет трафик. У myapp приложение слушает 8080, поэтому targetPort: 8080.

Если targetPort не указать, он по умолчанию равен port. Мы указали явно, потому что у нас они разные. Теперь любой под в кластере может сходить на http://myapp.myapp.svc.cluster.local:80 (или просто http://myapp из того же namespace) и попасть в myapp на порт 8080.

Запомните: «снаружи» (другие сервисы в кластере, Ingress) к myapp обращаются на порт 80 Service, а не на 8080 — приложение слушает 8080 только внутри пода. Service переводит одно в другое. Когда дальше встретите kubectl port-forward, обратите внимание на запись вида 8080:80 — слева ваш локальный порт, справа порт Service (80); пробрасывать можно и прямо на порт пода (8080:8080), минуя Service. Оба варианта законны, просто целятся в разные порты, поэтому в главе про Tilt и главе про наблюдаемость числа выглядят по-разному.

ClusterIP делает сервис доступным только изнутри. Чтобы достучаться до myapp из браузера на вашей машине, нужен kubectl port-forward либо Ingress — это тема главы про сеть.

Связь Deployment и Service через labels/selectors «на пальцах»

Теперь — главное, что нужно прочувствовать. У нас три объекта (Deployment, поды, Service), и ничто не связывает их жёсткими ссылками вроде «Service, вот ID этих подов». Вместо этого всё держится на лейблах — произвольных метках на объектах. Лейблы здесь работают как клей.

Проследим цепочку на нашем примере с лейблом app: myapp:

  1. Deployment в spec.template.metadata.labels вешает на каждый создаваемый под лейбл app: myapp.
  2. Тот же Deployment через spec.selector.matchLabels: {app: myapp} опознаёт эти поды как «свои» — за этим стоит ReplicaSet, который по этому селектору считает поды и поддерживает их число.
  3. Service через свой spec.selector: {app: myapp} ищет поды с тем же лейблом, складывает их адреса в свой EndpointSlice и балансирует трафик между ними.

То есть два разных селектора — у Deployment и у Service — смотрят на одни и те же лейблы подов, но решают разные задачи: Deployment отвечает «кто мои поды для подсчёта реплик», Service — «куда слать трафик».

Маленький нюанс синтаксиса, который сбивает с толку: у Service селектор пишется напрямую, плоско — selector: {app: myapp} (это так называемый equality-based селектор). А у Deployment/ReplicaSet — через selector.matchLabels (новый формат, который умеет ещё и matchExpressions для более хитрых условий по множествам). Не пугайтесь: это просто разные поколения синтаксиса, оба сравнивают лейблы.

Самые частые грабли именно тут: селектор Service не совпал с лейблами подов (опечатка, забыли поменять). Тогда Service ни к чему не привязывается, и в его описании поле Endpoints будет пустым — <none>. Внешне сервис есть, а трафик уходит в никуда. Проверяется мгновенно:

bash
1kubectl describe svc myapp -n myapp

Если в выводе Endpoints: <none> — значит, лейбл пода и селектор Service не совпали. Сверьте app: в обоих манифестах.

kubectl apply -f / get / describe

Манифесты написаны — пора применить их к кластеру и научиться смотреть, что происходит. Главный инструмент — kubectl apply. Это декларативная команда: «приведи кластер к тому, что в этом файле». Она идемпотентна — можно запускать сколько угодно раз: первый раз создаст ресурс, последующие применят только изменения (через так называемый three-way merge).

bash
1# по одному файлу
2kubectl apply -f namespace.yaml
3kubectl apply -f deployment.yaml -f service.yaml
4
5# или сразу всю папку с манифестами
6kubectl apply -f ./k8s/

Иногда Deployment и Service удобно держать в одном файле — Kubernetes понимает несколько объектов в одном YAML, если разделить их строкой ---:

yaml
1# myapp.yaml
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5  name: myapp
6  namespace: myapp
7# ... spec Deployment ...
8---
9apiVersion: v1
10kind: Service
11metadata:
12  name: myapp
13  namespace: myapp
14# ... spec Service ...

Возможно, вы где-то видели kubectl create. Запомните разницу: createимперативная команда, она падает с ошибкой, если ресурс уже существует, и не умеет обновлять. Для повторяемых, хранящихся в Git манифестов всегда используйте apply.

Прежде чем применять изменения, полезно посмотреть, что именно поменяется:

bash
1kubectl diff -f deployment.yaml

Дальше — команды для просмотра состояния. kubectl get показывает списки ресурсов:

bash
1kubectl get pods -n myapp                 # поды в нашем namespace
2kubectl get all -n myapp                  # всё разом: поды, деплои, сервисы, replicaset'ы
3kubectl get pods -o wide                  # + IP подов и нода, на которой они крутятся
4kubectl get pod <NAME> -o yaml            # полный YAML конкретного объекта
5kubectl get pods -w                       # следить за изменениями в реальном времени
6kubectl get pods --show-labels            # показать лейблы
7kubectl get pods -l app=myapp             # отфильтровать по лейблу
8kubectl get pods -A                       # во всех namespace сразу

Кстати, про -n myapp: если забыть этот флаг (и не переключить контекст, как выше), kubectl смотрит в default, и будет казаться, что ресурсов нет. Очень частая причина паники «куда всё делось».

Когда что-то пошло не так, главный диагностический инструмент — kubectl describe. Он показывает детали объекта и, что важнее всего, раздел Events — хронику того, что Kubernetes делал с объектом (скачивал образ, не смог запустить, перезапускал):

bash
1kubectl describe pod <NAME> -n myapp
2kubectl describe svc myapp -n myapp       # тут смотрим поле Endpoints (см. выше)

А чтобы увидеть, что пишет само приложение, есть kubectl logs:

bash
1kubectl logs <POD> -f                     # -f = следить за логами вживую
2kubectl logs <POD> -c myapp               # -c = конкретный контейнер (если их несколько)
3kubectl logs <POD> --previous             # логи предыдущего, упавшего контейнера

Подробно про отладку, события и логи — в главе про наблюдаемость.

Где хранить манифесты в репозитории

Последний, но важный вопрос: где этим файлам жить. Короткий ответ — в Git, рядом с кодом сервиса. Манифесты, которые лежат только на ноутбуке одного разработчика и применяются «с десктопа», — это путь к боли: нет истории изменений, нельзя сделать diff, нельзя откатиться, у каждого в команде свой вариант кластера. Храня манифесты в репозитории, вы получаете версионирование, ревью через pull request, воспроизводимость и паритет окружений — те самые вещи, ради которых мы вообще затеяли локальный кластер (см. главу про production-like окружения).

Официальные рекомендации по конфигурации сводятся к нескольким простым правилам:

  • держать манифесты под version control;
  • связанные объекты одного приложения группировать в один файл через ---;
  • применять директорию целиком (kubectl apply -f ./k8s/);
  • предпочитать YAML, а не JSON — он читабельнее;
  • не дублировать значения по умолчанию (меньше конфигурации — меньше ошибок);
  • указывать последние стабильные apiVersion.

Как именно раскладывать файлы по папкам — это уже не часть официальной документации, а сложившаяся практика. Для небольшого сервиса вроде myapp достаточно простой структуры: папка k8s/ в корне репозитория, а в ней — манифесты, названные по приложению и типу ресурса:

text
1myapp/
2├── app/                      # код сервиса (FastAPI)
3├── Dockerfile
4├── k8s/
5│   ├── namespace.yaml
6│   ├── deployment.yaml
7│   └── service.yaml
8└── Tiltfile                  # появится в главе про Tilt

Когда приложение разрастётся и появятся разные окружения (dev/prod с разными настройками), есть смысл присмотреться к Kustomize — встроенному в kubectl механизму, который позволяет держать общую базу (base/) и накладывать на неё различия для окружений (overlays/dev, overlays/prod):

bash
1kubectl apply -k overlays/dev

Но это уже задел на будущее — для локальной разработки myapp плоской папки k8s/ более чем достаточно. В следующей главе мы научим Tilt автоматически применять эти манифесты и пересобирать образ на каждое изменение кода, чтобы не гонять kubectl apply руками.

Источники

Хотите чистые, поддерживаемые манифесты Kubernetes?
Нужна помощь с написанием чистых манифестов Kubernetes или структурой папки k8s/ для workflow от локалки до прода? Помогу спроектировать поддерживаемый набор манифестов.