Разворачиваем веб-приложение в Kubernetes с нуля
Современные веб-приложения, даже простые на вид, часто подразумевают нетривиальную архитектуру, состоящую из многих компонент. В статье «Делаем современное веб-приложение с нуля» я рассказал, как она может выглядеть, и собрал для демонстрации простейшую реализацию на стеке из нескольких популярных технологий. В неё вошёл бэкенд, фронтенд, воркер для асинхронных задач и аж два хранилища данных — MongoDB как основная база и Redis как очередь задач. В «Делаем поиск в веб-приложении с нуля» я показал, как можно добавить полнотекстовый поиск, и подключил третье хранилище — Elasticsearch.
Всё это время для простоты разработки и отладки компоненты приложения запускались локально через Docker Compose. Но как развернуть такое приложение в настоящем продакшн-окружении? Как обеспечить горизонтальное масштабирование? Как раскатывать новые релизы без простоя?
В этой статье мы разберёмся, как разворачивать многокомпонентное веб-приложение в кластере Kubernetes на примере его локальной реализации — minikube. Мы поднимем виртуальный кластер прямо на рабочем ноутбуке, разберёмся с основными сущностями Kubernetes, запустим и соединим между собой компоненты демо-приложения и обсудим, какие ещё возможности Kubernetes пригодятся нам в суровом энтерпрайзе. Если вы занимаетесь разработкой и слышали о Kubernetes, но ещё не имели возможности пощупать его руками — добро пожаловать!
Зачем нужны Docker и Kubernetes
Docker-контейнеры, с которыми так или иначе сталкивается, наверное, большинство разработчиков в вебе, решают множество задач.
Изоляция: контейнеры позволяют нам запускать приложения, не переживая о возможных конфликтах между ними. Сервис занимает какой-то порт? Программа патчит системный файл? Два исполняемых файла требуют разные версии библиотек? Больше не проблема.
Воспроизводимость: контейнеры позволяют нам запустить код на любой системе с установленным Docker daemon и не переживать, что результат работы будет зависеть от окружения. В системе установлена другая версия интерпретатора и у функции отличающееся поведение? Программа падает из-за отсутствующей переменной окружения? Больше не может такого произойти.
Переносимость: Docker даёт нам единый механизм сборки и передачи образов между окружениями. Собираем образ командой
docker build
на любой машине, пушим в хранилище образов, пуллим на любой другой машине и запускаем — схема предельно проста и не зависит ни от используемого языка программирования, ни от типа задачи, которую решает образ, будь то сервис, слушающий порт пока не остановят, или джоба, однократно выполняющаяся и умирающая.
Заметьте, что все эти преимущества подразумевают наличие нескольких Docker-контейнеров. Один контейнер в поле не воин, и для того, чтобы построить что-то стоящее, нам потребуется способ управлять множеством контейнеров. В предыдущих статьях для разработки на локальной машине мы использовали Docker Compose; для этой задачи он действительно хорош, и в случае разворачивания достаточно простых и слабо нагруженных проектов в целом можно просто делать docker compose up -d
на продакшн-сервере и это будет работать. Но как только продакшн-серверов становится больше одного, это, конечно, резко перестаёт быть удобно.
Kubernetes — это система оркестрации контейнеров промышленного уровня, позволяющая разворачивать множество контейнеров на больших кластерах машин и предоставляющая из коробки фичи, нужные для больших высоконагруженных проектов — как, например, поэтапная выкатка новых релизов и автоматическое горизонтальное масштабирование под нагрузкой. Kubernetes вводит ряд абстракций, таких, как под и сервис, и даёт инструменты для построения сложных систем на основе этих абстракций.
Поднятие кластера Kubernetes с нуля — нетривиальная задача и, как правило, удел команды эксплуатации/DevOps. Но существует множество managed решений, позволяющих поднять кластер за один клик; например, кластер на несколько нод легко поднять в DigitalOcean за несколько десятков долларов в месяц. Соответственно, создание кластеров мы оставим профессионалам, а сами сфокусируемся на работе с уже поднятым кластером, характерной для жсоноукладчика прикладного разработчика. Чтобы это было бесплатно, я буду рассказывать и показывать на локальном кластере minikube, но вы вольны экспериментировать с любым кластером, доступным под рукой.
Настраиваем окружение
Прежде, чем перейти к делу, давайте быстро соберём всё необходимое для работы — благо, там немного.
В первую очередь нам потребуется установить kubectl — command line interface для управления кластером Kubernetes. Следующим будет minikube — локальная реализация Kubernetes. Я не буду копировать сюда документацию, но скажу, что если вы работаете на Mac, должно хватить
brew install kubectl
brew install minikube
minikube start
Разворачивать мы будем демо-приложение, которое я описывал в предыдущих статьях; достаточно счекаутить его из GitHub и выбрать ветку:
git clone git@github.com:Saluev/habr-app-demo.git
cd habr-app-demo
git checkout feature/k8s
В этом репозитории лежит код бэкенда, фронтенда и воркера для нашей демо-аппки, в деталях описанный в первой статье цикла, и докерфайлы для его сборки. Мы не будем особо вглядываться в код демо-приложения или как-либо его модифицировать, поэтому разбираться в нём не нужно;, но можно зайти в репозиторий и поставить звёздочку.
Также в репозитории лежат все YAML-файлы, приведённые в статье, а в скрипте k8s/log.sh
— все запускаемые в терминале команды.
Разбираемся с объектной моделью
Всё, что происходит в кластере Kubernetes, описывается через создание и изменение энного количества ресурсов разных типов. Собственно, бóльшая часть операций, которые мы будем производить посредством утилиты kubectl — это CRUD-операции над ресурсами (Create, Read, Update, Delete).
Для удобства ресурсы рассортированы по пространствам имён (namespace). В лучших традициях ООП пространство имён — тоже ресурс. Мы можем запросить список ресурсов интересующего нас типа командой kubectl get
:
$ kubectl get namespaces
NAME STATUS AGE
default Active 1d
kube-node-lease Active 1d
kube-public Active 1d
kube-system Active 1d
С точки зрения пользователя кластера ресурсы — это просто документы YAML/JSON. Командой kubectl get
мы можем получить YAML для конкретного ресурса из списка (хоть в случае неймспейсов он и не очень содержательный) — например, для default:
$ kubectl get namespace -o yaml default
apiVersion: v1
kind: Namespace
metadata:
creationTimestamp: "2022-07-25T18:00:57Z"
labels:
kubernetes.io/metadata.name: default
name: default
resourceVersion: "200"
uid: 492e34b7-82e0-472b-9fb9-3ed7005a83eb
spec:
finalizers:
- kubernetes
status:
phase: Active
Поле kind
содержит тип ресурса; остальные данные нам сейчас не особо интересны. Флаг -o yaml
указывает формат вывода; для написания скриптов также может быть полезен -o json
:
$ kubectl get namespace -o json default | jq .status.phase
"Active"
Ещё мы можем получить человекочитаемое описание ресурса командой kubectl describe
:
$ kubectl describe namespace default
Name: default
Labels: kubernetes.io/metadata.name=default
Annotations:
Status: Active
No resource quota.
No LimitRange resource.
В отличие от сухого вывода kubectl get
, здесь, помимо характеристик самого запрошенного ресурса, могут быть перечислены связанные с ним другие ресурсы, представляющие интерес — например, события (о них ниже) или, как в данном случае, LimitRange
(что бы это ни было).
Изменять и удалять неймспейсы обычно не нужно, поэтому с этой частью CRUD-интерфейса мы разберёмся в следующем разделе.
Разных типов ресурсов очень много, и новые могут добавляться за счёт подключения плагинов; можно запустить команду kubectl api-resources
, чтобы получить представление обо всех поддерживаемых ресурсах:
$ kubectl api-resources
NAME SHORTNAMES APIVERSION NAMESPACED KIND
bindings v1 true Binding
configmaps cm v1 true ConfigMap
endpoints ep v1 true Endpoints
events ev v1 true Event
namespaces ns v1 false Namespace
...
Из полезного в этой табличке — колонка SHORTNAMES
, указывающая, как можно сокращать команды. Например, kubectl get namespaces
и kubectl get ns
— это одна команда.
Создаём первый под
Понятно, что на неймспейсах далеко не уедешь. Я представил вам Kubernetes как систему управления контейнерами — время запустить контейнер!
Минимальной единицей выполнения в Kubernetes является под (Pod). Под — это несколько Docker-контейнеров, гарантированно запускаемых на одной виртуальной машине (ноде), коих в кластере может быть множество. В простейшем случае это просто один контейнер, в котором крутится сервис; другой типичный сценарий — один контейнер с сервисом и второй, например, с обработчиком логов вроде Filebeat.
Для пробы пера давайте создадим простой под со встроенным в python HTTP-сервером, запустив команду kubectl apply
:
kubectl apply -f - <
В качестве альтернативы этому громоздкому синтаксису можно сохранить YAML в файл и вызвать kubectl apply -f path/to/filename.yaml
. После вызова мы получим ответ pod/test-pod created
и сможем увидеть его в списке подов:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
test-pod 0/1 ContainerCreating 0 69s
Через пару минут, за которые minikube скачает Docker-образ python:bullseye
, контейнер перейдёт в статус Running
:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
test-pod 1/1 Running 0 4m20s
Итак, объект в Kubernetes создался. Но что произошло при этом физически? Давайте посмотрим на вывод kubectl describe
:
$ kubectl describe pod test-pod
...
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 2m27s default-scheduler Successfully assigned default/test-pod to minikube
Normal Pulling 2m26s kubelet Pulling image "python:bullseye"
Normal Pulled 6s kubelet Successfully pulled image "python:bullseye" in 2m20.024146s (2m20.024218s including waiting)
Normal Created 5s kubelet Created container python-container
Normal Started 5s kubelet Started container python-container
В конце вывода мы видим список внутренних событий Kubernetes, из которого можем узнать, что происходило с ресурсом. В первом событии Kubernetes выбрал, на какой ноде разместить под. minikube
— это название нашей единственной ноды; в настоящем кластере там будет идентификатор одной из машин. Следующим событием Kubernetes начал тянуть на эту ноду нужный Docker-образ. Дальше он создал контейнер в соответствии с нашей спецификацией и, наконец, запустил его.
По аналогии с docker exec
мы можем запускать команды в контейнерах через kubectl exec
, в том числе в интерактивном режиме:
$ kubectl exec test-pod -- whoami
root
$ kubectl exec -it test-pod -- /bin/bash
root@test-pod:/# ls
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
root@test-pod:/#
Давайте проверим, что HTTP-сервер в нашем поде действительно работает, получив к нему локальный доступ командой kubectl port-forward
:
$ kubectl port-forward pod/test-pod 7080:8080
Forwarding from 127.0.0.1:7080 -> 8080
Forwarding from [::1]:7080 -> 8080
Теперь мы можем открыть http://localhost:7080/ и увидеть стандартный ответ http.server
:
Это — файлы, хранящиеся в корне файловой системы внутри Docker-контейнера с python.
Также мы можем посмотреть логи контейнера командой kubectl logs
:
$ kubectl logs test-pod
127.0.0.1 - - [03/Jul/2023 14:24:19] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [03/Jul/2023 14:24:19] code 404, message File not found
127.0.0.1 - - [03/Jul/2023 14:24:19] "GET /favicon.ico HTTP/1.1" 404 -
Виден наш запрос в корень и автоматический запрос favicon, вернувший 404. Вроде всё работает! Теперь можем перейти к разворачиванию сервисов, делающих что-то осмысленное —, а именно нашего бэкенда и фронтенда.
Собираем Docker-образы
Чтобы сэкономить время и сохранить фокус статьи, я не буду углубляться в детали того, как строятся образы бэкенда и фронтенда — это можно посмотреть в предыдущей статье. Если вы счекаутили репозиторий и переключились на ветку feature/k8s
, для сборки образов всё должно быть готово, и образы собираются через обычный docker build
:
docker build --target backend -t habr-app-demo/backend:latest backend
docker build --target worker -t habr-app-demo/worker:latest backend
docker build -t habr-app-demo/frontend:latest frontend
Если бы у нас был настоящий продакшн-кластер Kubernetes, где-то рядом с ним у нас бы было корпоративное хранилище Docker-образов, в которое достаточно было бы их запушить. Например, если бы мы завели хранилище под названием habr-app-demo-registry
в DigitalOcean, это выглядело бы так:
docker tag \
habr-app-demo/backend:latest \
registry.digitalocean.com/habr-app-demo-registry/backend
docker push \
registry.digitalocean.com/habr-app-demo-registry/backend
# Теперь можно использовать
# registry.digitalocean.com/habr-app-demo-registry/backend:latest
# как название образа в описании пода.
# Аналогично для двух других образов.
Если мы хотим продолжить работать с minikube, не подключая платные облачные решения, там всё чуть менее очевидно. minikube поднимает свой собственный Docker daemon, и собирать образы надо в нём, чтобы Kubernetes-движок мог найти их локально. Для этого есть команда minikube docker-env
, которую можно использовать так:
eval $(minikube docker-env)
docker build --target backend -t habr-app-demo/backend:latest backend
docker build --target worker -t habr-app-demo/worker:latest backend
docker build -t habr-app-demo/frontend:latest frontend
Теперь образы собрал сразу нужный инстанс Docker daemon. Также есть возможность импортировать ранее собранный образ в него снаружи, но это работает намного медленнее.
Если всё сделано правильно, мы увидим наши образы в выводе команды docker images
с правильным env:
$ (eval $(minikube docker-env) && docker images)
REPOSITORY TAG IMAGE ID CREATED SIZE
habr-app-demo/backend latest e6fe8b126165 4 minutes ago 126MB
habr-app-demo/frontend latest eb41101843f2 3 minutes ago 126MB
habr-app-demo/worker latest 629e4c4dc21e 2 minutes ago 126MB
...
Деплоим деплоймент
Хоть мы и можем сразу перейти к созданию подов с нашими свежесобранными Docker-образами, это будет не вполне корректно с точки зрения использования Kubernetes. Дело в том, что поды задуманы как одноразовые, неустойчивые сущности: кластер Kubernetes может удалить любой под в любой момент. Ушла в оффлайн нода, на которой был развёрнут под — всё, этого пода больше нет. Кроме того, если мы разворачиваем сервис для хоть сколько-то серьёзной нагрузки, одним подом мы вряд ли обойдёмся, а создавать множество подов руками неудобно.
Для создания стабильного, персистентного набора подов Kubernetes предоставляет другой низкоуровневый тип ресурса — ReplicaSet. Репликасет позволяет указать а) количество желаемых подов и б) шаблон, по которому клепать эти поды. Так будет выглядеть описание репликасета, поднимающего два пода с нашим бэкендом:
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: backend-replicaset
spec:
replicas: 2 # сколько подов нужно держать поднятыми
# template — шаблон для создания подов. Содержимое по синтаксису
# аналогично описанию пода (см. пример выше), с небольшими различиями:
# • apiVersion и kind не нужно указывать — и так понятно, что
# это под в той же версии API, что и репликасет;
# • также не нужно указывать name — он будет генерироваться
# движком Kubernetes исходя из названия репликасета.
template:
metadata:
# labels — это произвольные key-value пары, хранящие
# метаинформацию о поде. Выбор ключа app и значения
# backend-app произволен. Но этот лейбл потребуется нам ниже!
labels:
app: backend-app
spec:
containers:
- name: backend-container
# Способ указать minikube использовать ранее собранные
# нами локально образы. Без настройки imagePullPolicy
# Kubernetes будет пытаться тянуть образ из интернета
# (и, конечно, не сможет его найти и сфейлится).
image: docker.io/habr-app-demo/backend:latest
imagePullPolicy: Never
ports:
- containerPort: 40001
# selector — это способ для репликасета понять, какие поды
# из числа уже существующих в кластере относятся к нему. Поскольку
# мы прописали в шаблоне выше лейбл app: backend-app, мы точно
# знаем, что все поды с таким лейблом порождены этим репликасетом.
# Репликасет будет пользоваться этим селектором, чтобы понять,
# сколько он уже насоздавал подов и сколько ещё нужно, чтобы
# добиться количества реплик, указанного выше в поле replicas.
selector:
matchLabels:
app: backend-app
Занимается репликасет исключительно тем, что создаёт и поддерживает нужное количество активных подов — рестартует поды при наличии ошибок, создаёт новые в случае удаления (например, в сценарии с падением ноды или если вы решите удалить один из подов руками) и так далее.
Мы могли бы создать репликасет по YAML выше и увидеть, как он создаст два пода. Но у репликасета есть минус — неудобно обновлять поды: нам бы хотелось, чтобы если мы обновим версию бэкенда, она выкатывалась постепенно, под за подом; с репликасетами же нам придётся вручную создавать новый репликасет с актуальной версией и либо разом удалять старый (что под нагрузкой довольно опасно), либо долгой многоходовочкой менять replicas
в обоих репликасетах, сдувая старый и надувая новый. К счастью, есть более высокоуровневый компонент, умеющий заниматься ровно этим.
Для удобного развёртывания сервисов в Kubernetes есть ресурс Deployment, для создания которого нужно указать все те же параметры, что и для репликасета, плюс (опционально) настройки той самой плавной выкатки — например, какое максимальное количество подов может быть в переходном состоянии в каждый момент времени (об этом ниже, в разделе «Плавная выкатка»).
Поведение деплоймента в общих чертах выглядит так. При создании деплоймент создаёт репликасет, который порождает нужное количество подов и берётся следить, чтобы их количество не менялось. Это распространённый шаблон в Kubernetes — высокоуровневые абстракции создают низкоуровневые, чтобы реализовать поверх них более сложное поведение. Далее, при необходимости перевыкатки деплоймент возьмёт на себя создание второго репликасета и постепенное масштабирование их обоих до тех пор, пока в старом не останется подов.
Описание простейшего деплоймента с бэкендом практически не отличается от репликасета:
# k8s/backend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend-deployment
spec:
replicas: 2
template:
metadata:
labels:
app: backend-app
spec:
containers:
- name: backend-container
image: docker.io/habr-app-demo/backend:latest
imagePullPolicy: Never
ports:
- containerPort: 40001
selector:
matchLabels:
app: backend-app
# Без полотна комментариев этот YAML
# гораздо менее ужасающий, не правда ли
# (надеюсь, вы их читаете)
Давайте создадим деплоймент командой kubectl apply
и посмотрим, что случится со списком ресурсов, командой kubectl get all
:
$ kubectl apply -f k8s/backend-deployment.yaml
deployment.apps/backend-deployment created
$ kubectl get all
NAME READY STATUS RESTARTS AGE
pod/backend-deployment-77cc555f4b-2wv24 0/1 CrashLoopBackOff 2 (19s ago) 45s
pod/backend-deployment-77cc555f4b-kd4k9 0/1 CrashLoopBackOff 2 (18s ago) 44s
pod/test-pod 1/1 Running 0 1h
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/backend-deployment 0/2 2 0 50s
NAME DESIRED CURRENT READY AGE
replicaset.apps/backend-deployment-77cc555f4b 2 2 0 47s
Случилось ровно то, что и ожидалось — создался деплоймент, он породил репликасет с названием, сгенерированным по названию деплоймента, а уже в рамках этого репликасета создались два пода с названиями, сгенерированными по названию репликасета. Но что-то с этими подами явно не так! Что такое CrashLoopBackOff
?
О, если вы будете работать с Kubernetes, вы будете видеть это проклятое слово (фразу?) очень часто.
Если коротко, это значит, что Kubernetes создал поды, но контейнеры в них падают с ошибкой, и делают это раз за разом — поэтому crash loop. Если бы контейнер по стечению обстоятельств упал один раз, у него был бы статус Error
, быстро сменяющийся рестартом — этот статус вы увидите, если сделаете kubectl get all
достаточно быстро после создания деплоймента, пока Kubernetes ещё не успел порестартить поды хотя бы дважды.
Чтобы понять, в чём дело, давайте посмотрим логи произвольного пода из деплоймента — это удобнее, чем копировать название пода:
$ kubectl logs deployment/backend-deployment
Found 2 pods, using pod/backend-deployment-77cc555f4b-2wv24
...
pymongo.errors.ServerSelectionTimeoutError: mongo:27017: [Errno -2] Name does not resolve, Timeout: 1.0s, Topology Description: ]>
...
Ах да. Мы же не подняли базу данных!
Поднимаем базу данных
Дисклеймер. Грамотно развернуть базу данных в продакшне — весьма нетривиальная задача, и нет общего мнения, что делать это в кластере Kubernetes — хорошая идея. Как правило, облачные провайдеры предоставляют свои SaaS-решения для разворачивания самых популярных БД и они будут куда более надёжными, чем результаты самодеятельности. Но поскольку это вводная статья про Kubernetes, я воспользуюсь этой возможностью, чтобы представить читателям ещё несколько полезных типов ресурсов и операций над ними. Выбор же самой БД обусловлен наследием предыдущей статьи и моим опытом работы с ней.
Как я упоминал выше, поды — не очень стабильные объекты, создаваемые и удаляемые по прихоти Kubernetes-кластера. При необходимости создать под Kubernetes может разместить его на любой из подходящих нод (хотя, конечно, есть способы управлять этим процессом). При этом у пода генерируется непредсказуемый ID, и обратиться к нему извне становится сложно, как и, например, понять внутри самого пода, кто он: главная реплика базы данных, secondary реплика или что-то ещё.
Поэтому для stateful-сервисов Kubernetes предоставляет отдельную абстракцию — StatefulSet, позволяющую создать относительно стабильный упорядоченный набор подов с фиксированными именами (и, соответственно, адресами, по которому можно обращаться к ним внутри кластера) и доступом к персистентным томам.
Давайте посмотрим на простейший StatefulSet, поднимающий одну реплику MongoDB:
# k8s/mongodb-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mongodb-statefulset
spec:
serviceName: mongodb-service
replicas: 1 # сколько подов требуется в стейтфулсете
template:
metadata:
labels:
# label нужен из тех же соображений, что и в деплойменте.
app: mongodb-app
spec:
containers:
- name: mongodb-container
image: mongo:6.0.6
# Выставляем наружу дефолтный порт монги.
ports:
- containerPort: 27017
name: mongodb-cli
# selector нужен из тех же соображений, что и в деплойменте.
selector:
matchLabels:
app: mongodb-app
Конечно, чего-то не хватает. Set-то у нас stateful, но где этот state? Хорошо бы примонтировать какое-то персистентное хранилище, чтобы файлы базы данных располагались в нём и сохранялись при пересоздании подов.
Персистентные тома в Kubernetes создаются посредством ресурса PersistentVolume, в котором можно указать размер, тип хранилища (в разных облаках доступны разные типы), политику монтирования (можно ли монтировать том строго на одной ноде или на нескольких) и прочие настройки. Тома привязываются к подам посредством промежуточного ресурса PersistentVolumeClaim (PVC), в котором можно указать требования к тому; Kubernetes умеет создавать том по PVC, если подходящего ещё не существует.
Поскольку в стейтфулсете в общем случае больше одного пода и чаще всего разным подам нужны отдельные тома, стейтфулсет предоставляет возможность задать шаблон PVC, по которому на каждый созданный под будет создан свой том. YAML нашего стейтфулсета усложняется следующим образом:
# k8s/mongodb-statefulset.yaml
...
containers:
- name: mongodb-container
...
# Монтируем том с данными.
volumeMounts:
- mountPath: "/data/db"
name: mongodb-pvc
...
volumeClaimTemplates:
- metadata:
name: mongodb-pvc
spec:
accessModes:
- ReadWriteOnce # можно читать/писать только на одной ноде
resources:
requests:
storage: 100Mi
Создав стейтфулсет через kubectl apply
и запросив список подов, видим новый под:
$ kubectl apply -f k8s/mongodb-statefulset.yaml
statefulset.apps/mongodb-statefulset created
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
backend-deployment-77cc555f4b-2wv24 0/1 CrashLoopBackOff 42 (2m9s ago) 4h20m
backend-deployment-77cc555f4b-kd4k9 0/1 CrashLoopBackOff 42 (2m11s ago) 4h20m
mongodb-statefulset-0 1/1 Running 0 5s
test-pod 1/1 Running 0 4h20m
Обратите внимание, что вместо случайного суффикса название пода оканчивается на порядковый номер пода в стейтфулсете, то есть при пересоздании пода гарантированно останется таким же.
В списке персистентных томов также видим автоматически созданный том:
$ kubectl get persistentvolumes
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
pvc-88da024c-31ef-49f8-9b0b-3403bc3795d2 100Mi RWO Delete Bound default/mongodb-pvc-mongodb-statefulset-0 standard 13d
Теперь хорошо бы как-то проверить, что MongoDB работает — что можно к ней подключиться изнутри кластера, поделать запросы и всё такое. Но какой адрес у пода внутри кластера?
Для сетевого доступа к подам нам потребуется ещё одна абстракция — Service. Описание сервиса состоит из селектора подов, к которым нужно обеспечить доступ, и типа доступа, которых есть несколько. Поскольку нам нужен внутренний доступ к одному конкретному поду в StatefulSet, нас устроит самый простой тип сервиса, так называемый headless service — без балансировки нагрузки и выделения статического IP для доступа извне:
# k8s/mongodb-service.yaml
apiVersion: v1
kind: Service
metadata:
name: mongodb-service
spec:
# ClusterIP — самый простой тип сервиса, который
# позволяет подам связываться друг с другом в рамках
# кластера, но абсолютно никак не виден снаружи:
type: ClusterIP
clusterIP: None
selector:
app: mongodb-app
kubectl apply -f k8s/mongodb-service.yaml
Теперь для доступа к поду с БД внутри кластера должен заработать URL
, то есть в нашем случае mongodb-statefulset-0.mongodb-service.default.svc.cluster.local
. Давайте проверим это, зайдя в тестовый под и попытавшись подключиться через pymongo:
$ kubectl exec -it test-pod -- /bin/bash
root@test-pod:/# python -m pip install pymongo
Collecting pymongo
...
root@test-pod:/# python -q
>>> import pymongo
>>> uri = "mongodb://mongodb-statefulset-0.mongodb-service.default.svc.cluster.local"
>>> c = pymongo.MongoClient(uri)
>>> c.some_database.some_collection.insert_one({"foo": "bar"})
>>> list(c.some_database.some_collection.find({}))
[{'_id': ObjectId('3b9d88f84242424242424242'), 'foo': 'bar'}]
Работает!
Проверив поды бэкенда, мы заметим, что они всё ещё в крашлупе, поскольку ожидают, что хостнейм монги — mongo
, как это было раньше, в решении с Docker Compose. Я закоммитил заранее возможность переключить окружение; для этого нужно поменять переменную окружения APP_ENV
. Менять файл k8s/backend-deployment.yaml
и пересоздавать деплоймент целиком не очень удобно; давайте внесём точечное изменение командой kubectl patch
:
# k8s/backend-deployment-patch.yaml
spec:
template:
spec:
containers:
- name: backend-container
env:
- name: APP_ENV
value: k8s
kubectl patch deployment backend-deployment \
--patch-file k8s/backend-deployment-patch.yaml
Удобной альтернативой kubectl patch
может быть kubectl edit
, позволяющая отредактировать YAML прямо в консольном редакторе типа vim, а также — в этом конкретном случае настройки переменной окружения — kubectl set env
:
kubectl set env deployment/backend-deployment APP_ENV=k8s
Проверив список подов снова (возможно, через несколько секунд — Kubernetes может потребоваться время, чтобы заметить новый Service), увидим, что Kubernetes пересоздал поды бэкенда, у них сменились имена и пропали ошибки:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
backend-deployment-7547fb8b7c-4k2x7 1/1 Running 0 11s
backend-deployment-7547fb8b7c-hhvhd 1/1 Running 0 8s
...
Идём дальше!
Прячем секреты
Хоть мы и тренируемся на локальном кластере и балуемся разворачиванием базы данных вручную, хочется получить настолько production-ready решение, насколько возможно в рамках вводной статьи. А что отличает подключение к базе в локальном окружении от продакшна? Конечно, безопасная аутентификация!
Для хранения паролей и прочей приватной информации Kubernetes предоставляет ещё один тип ресурса — Secret. Давайте сгенерируем пароль и создадим секрет:
# Генерируем пароль и пишем в файл без переноса строки
echo -n "$(openssl rand -hex 14)" > password.txt
kubectl create secret generic mongodb-secret \
--from-file password.txt
В данном случае мы создали секрет-совокупность файлов, который можно будет подключать к контейнерам как раздел (аналогично тому, как разделы подключаются в Docker Compose), в который будут подтягиваться исходные файлы.
Поправим конфигурацию MongoDB, чтобы у пользователя root был сгенерированный нами пароль. Конкретно в случае MongoDB это можно сделать, поправив переменную окружения MONGO_INITDB_ROOT_PASSWORD_FILE
. Также нам нужно будет примонтировать раздел с файлом password.txt
:
# k8s/mongodb-statefulset-v2.yaml
...
- env:
...
- name: MONGO_INITDB_ROOT_PASSWORD_FILE
value: "/run/secrets/mongodb/password.txt"
...
# В разделе volumeMounts мы описываем, какие тома
# по каким путям нужно примонтировать. Тома могут
# иметь разное происхождение (например, сейчас у нас
# один персистентный том и один томик с секретами).
# Описание того, какого типа какой том, вынесено в
# отдельное поле — volumes (ниже).
volumeMounts:
- mountPath: "/data/db"
name: mongodb-pvc
- mountPath: "/run/secrets/mongodb"
name: mongodb-secret-volume
readOnly: true
...
# В разделе volumes мы описываем тома, которые нужно
# примонтировать в соответствии с инструкциями в поле
# volumeMounts.
volumes:
- name: mongodb-secret-volume
# Указываем, что данные для этого тома надо взять
# из ресурса Secret с названием mongodb-secret.
secret:
secretName: mongodb-secret
optional: false
# А персистентный том не надо описывать — Kubernetes
# сделает это за нас для всех подов в стейтфулсете.
...
Поскольку пароль берётся во внимание только при инициализации базы данных с нуля, нам проще всего полностью удалить стейтфулсет и все данные и создать заново с новыми настройками (не повторять на продакшне!):
# Удаляем стейтфулсет (а с ним и под)
$ kubectl delete statefulset mongodb-statefulset
statefulset.apps "mongodb-statefulset" deleted
# Удаляем PersistentVolumeClaim, чтобы Kubernetes разрешил удалить сам том
$ kubectl delete pvc mongodb-pvc-mongodb-statefulset-0
persistentvolumeclaim "mongodb-pvc-mongodb-statefulset-0" deleted
$ kubectl get persistentvolumes
No resources found
# Пересоздаём всё созданием нового стейтфулсета
$ kubectl apply -f k8s/mongodb-statefulset-v2.yaml
statefulset.apps/mongodb-statefulset created
Если сейчас перезапустить поды бэкенда (например, командой kubectl rollout restart deployment
— о ней ниже), увидим, что они снова попали в CrashLoopBackOff
— теперь к базе нужен пароль. Я подготовил ещё один APP_ENV=k8s_secrets
, при котором бэкенд берёт пароль из файла. Нужно обновить APP_ENV
и примонтировать секрет:
# k8s/backend-deployment-patch-2.yaml
spec:
template:
spec:
containers:
- name: backend-container
env:
- name: APP_ENV
value: k8s_secrets
volumeMounts:
- mountPath: "/run/secrets/mongodb"
name: mongodb-secret-volume
readOnly: true
volumes:
- name: mongodb-secret-volume
secret:
secretName: mongodb-secret
optional: false
Применяем патч и видим, что вновь созданные поды работают без проблем!
$ kubectl patch deployment backend-deployment \
--patch-file k8s/backend-deployment-patch-2.yaml
deployment.apps/backend-deployment patched
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
backend-deployment-775d8c8b8f-m686f 1/1 Running 0 9s
backend-deployment-775d8c8b8f-zghvf 1/1 Running 0 6s
...
Настраиваем роутинг
Бэкенд и база заработали — давайте вспомним про остальные сервисы (фронтенд, воркер и очередь задач для него) и быстренько поднимем их:
kubectl apply -f k8s/frontend-deployment.yaml
kubectl apply -f k8s/worker-deployment.yaml
kubectl apply -f k8s/redis.yaml
(Попробуйте посмотреть, какие появились новые поды и что в них происходит.)
Теперь время настроить внешний доступ к нашим двум сервисам — фронтенду, отдающему пререндеренные страницы и статические файлы, и бэкенду, предоставляющему JSON API.
Kubernetes предоставляет много способов выставить наружу доступ к крутящимся в кластере сервисам, и я не буду подробно рассматривать их все. Простейшая схема, подходящая для сурового продакшна, выглядит так.
Доступ в кластер извне осуществляется созданием ресурса Service типа LoadBalancer:
apiVersion: v1
kind: Service
metadata:
name: ...
spec:
type: LoadBalancer
selector:
...
Если сделать это в настоящем Kubernetes-кластере (развёрнутом, например, в AWS или DigitalOcean), облачный провайдер выделит статический IP и создаст некий (имплементация зависит от провайдера) балансировщик нагрузки, который будет обслуживать этот IP и распределять трафик между подами, подходящими под указанный селектор.
Статические IP стоят денег, а функциональности непрозрачного проприетарного балансировщика может не хватать для многих задач, поэтому при наличии множества сервисов (в нашем случае аж двух!) обычно поднимается один сервис типа LoadBalancer, который уже роутит трафик между сервисами. Для этого можно использовать готовые балансировщики нагрузки — например, nginx или traefik.
Роутинг трафика внутри кластера осуществляется созданием ресурсов Service типов ClusterIP или NodePort, ресурсов Ingress и установкой в кластер ингресс-контроллера.
Service, уже упоминавшийся выше — это ресурс, инкапсулирующий, собственно, сервис — совокупность подов, реализующих какой-то один сетевой интерфейс; например, HTTP или gRPC API. В правилах роутинга Service выступает в роли цели, куда роутить запросы.
Ингресс — ресурс, инкапсулирующий правило роутинга. Если вы работали с nginx, неплохой аналог ингресса — сайт в папке /etc/nginx/conf.d/sites-enabled
. Проще один раз увидеть — так будет выглядеть ингресс для доступа к сервису frontend-service
:
# k8s/frontend-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: frontend-ingress
annotations:
# Аннотации позволяют настроить поведение
# ингресс-контроллера и, конечно, зависят от
# того, какой именно мы взяли — сейчас это nginx:
nginx.ingress.kubernetes.io/from-to-www-redirect: "true"
spec:
rules:
# Правила роутинга представляют собой ровно то,
# что можно ожидать — хост, протокол, пути, на какой
# сервис (и какой порт) перенаправить трафик:
- host: frontend.localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: frontend-service
port:
number: 40002
Ингресс-контроллер — это компонент, следящий за существующими в кластере ингрессами и, собственно, реализующий роутинг. Процесс установки и настройки ингресс-контроллера различается в зависимости от его выбора, и чтобы не вдаваться в лишние для вводной статьи детали, давайте остановимся на самом простом варианте, для установки которого в minikube есть готовая документация — nginx. Устанавливаем контроллер:
$ minikube addons enable ingress
...