Local Kubernetes Dev — Part 7: Kubernetes manifests for your service
Три манифеста для вашего сервиса — Namespace, Deployment и Service — и как лейблы с селекторами склеивают их в минимальный, но рабочий каркас.
В прошлой главе мы упаковали 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. Посмотреть, что к какой категории относится, можно так:
1kubectl api-resources --namespaced=true
2kubectl api-resources --namespaced=falseСоздать namespace можно одной командой или манифестом. Раз мы условились хранить всё в Git (об этом ниже), сделаем манифестом. Имя должно быть валидным DNS-лейблом по RFC 1123 — строчные буквы, цифры и дефис. Наш namespace называется myapp:
1# namespace.yaml
2apiVersion: v1
3kind: Namespace
4metadata:
5 name: myappПрименяем:
1kubectl apply -f namespace.yamlЧтобы не дописывать -n myapp к каждой команде, удобно один раз переключить контекст на нужный namespace:
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 и главе про контейнеризацию):
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:
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:
- Deployment в
spec.template.metadata.labelsвешает на каждый создаваемый под лейблapp: myapp. - Тот же Deployment через
spec.selector.matchLabels: {app: myapp}опознаёт эти поды как «свои» — за этим стоит ReplicaSet, который по этому селектору считает поды и поддерживает их число. - 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>. Внешне сервис есть, а трафик уходит в никуда. Проверяется мгновенно:
1kubectl describe svc myapp -n myappЕсли в выводе Endpoints: <none> — значит, лейбл пода и селектор Service не совпали. Сверьте app: в обоих манифестах.
kubectl apply -f / get / describe
Манифесты написаны — пора применить их к кластеру и научиться смотреть, что происходит. Главный инструмент — kubectl apply. Это декларативная команда: «приведи кластер к тому, что в этом файле». Она идемпотентна — можно запускать сколько угодно раз: первый раз создаст ресурс, последующие применят только изменения (через так называемый three-way merge).
1# по одному файлу
2kubectl apply -f namespace.yaml
3kubectl apply -f deployment.yaml -f service.yaml
4
5# или сразу всю папку с манифестами
6kubectl apply -f ./k8s/Иногда Deployment и Service удобно держать в одном файле — Kubernetes понимает несколько объектов в одном 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.
Прежде чем применять изменения, полезно посмотреть, что именно поменяется:
1kubectl diff -f deployment.yamlДальше — команды для просмотра состояния. kubectl get показывает списки ресурсов:
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 делал с объектом (скачивал образ, не смог запустить, перезапускал):
1kubectl describe pod <NAME> -n myapp
2kubectl describe svc myapp -n myapp # тут смотрим поле Endpoints (см. выше)А чтобы увидеть, что пишет само приложение, есть kubectl logs:
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/ в корне репозитория, а в ней — манифесты, названные по приложению и типу ресурса:
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):
1kubectl apply -k overlays/devНо это уже задел на будущее — для локальной разработки myapp плоской папки k8s/ более чем достаточно. В следующей главе мы научим Tilt автоматически применять эти манифесты и пересобирать образ на каждое изменение кода, чтобы не гонять kubectl apply руками.