Современные веб-приложения, даже простые на вид, часто подразумевают нетривиальную архитектуру, состоящую из многих компонент. В статье «Делаем современное веб-приложение с нуля» я рассказал, как она может выглядеть, и собрал для демонстрации простейшую реализацию на стеке из нескольких популярных технологий. В неё вошёл бэкенд, фронтенд, воркер для асинхронных задач и аж два хранилища данных — MongoDB как основная база и Redis как очередь задач. В «Делаем поиск в веб-приложении с нуля» я показал, как можно добавить полнотекстовый поиск, и подключил третье хранилище — Elasticsearch.

Всё это время для простоты разработки и отладки компоненты приложения запускались локально через Docker Compose. Но как развернуть такое приложение в настоящем продакшн-окружении? Как обеспечить горизонтальное масштабирование? Как раскатывать новые релизы без простоя?

В этой статье мы разберёмся, как разворачивать многокомпонентное веб-приложение в кластере Kubernetes на примере его локальной реализации — minikube. Мы поднимем виртуальный кластер прямо на рабочем ноутбуке, разберёмся с основными сущностями Kubernetes, запустим и соединим между собой компоненты демо-приложения и обсудим, какие ещё возможности Kubernetes пригодятся нам в суровом энтерпрайзе. Если вы занимаетесь разработкой и слышали о Kubernetes, но ещё не имели возможности пощупать его руками — добро пожаловать!

Зачем нужны Docker и Kubernetes

Docker-контейнеры, с которыми так или иначе сталкивается, наверное, большинство разработчиков в вебе, решают множество задач. 

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

  2. Воспроизводимость: контейнеры позволяют нам запустить код на любой системе с установленным Docker daemon и не переживать, что результат работы будет зависеть от окружения. В системе установлена другая версия интерпретатора и у функции отличающееся поведение? Программа падает из-за отсутствующей переменной окружения? Больше не может такого произойти.

  3. Переносимость: 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:  <none>
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 - <<EOF
apiVersion: v1
kind: Pod
metadata:
  name: test-pod
spec:
  containers:
    - name: python-container
      image: python:bullseye
      command: ["python3", "-m", "http.server", "8080"]
EOF

В качестве альтернативы этому громоздкому синтаксису можно сохранить 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: <TopologyDescription id: 64a319b1d732cacfbcdd43f1, topology_type: Unknown, servers: [<ServerDescription ('mongo', 27017) server_type: Unknown, rtt: None, error=AutoReconnect('mongo:27017: [Errno -2] Name does not resolve')>]>
...

Ах да. Мы же не подняли базу данных!

Поднимаем базу данных

Дисклеймер. Грамотно развернуть базу данных в продакшне — весьма нетривиальная задача, и нет общего мнения, что делать это в кластере 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 <pod name>.<service name>.<namespace>.svc.cluster.local, то есть в нашем случае 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"})
<pymongo.results.InsertOneResult object at 0x7f8906b0b2b0>
>>> 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
...
????  The 'ingress' addon is enabled

(У меня установка сработала только со второго раза, так что будьте настойчивы…)

Логика выбора подов для сетевого взаимодействия, как мы уже обсудили выше, инкапсулирована в ресурсах типа Service, поэтому следующим шагом создаём сервисы:

# k8s/frontend-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: frontend-service
spec:
  # NodePort — один из встроенных типов сервисов
  # в Kubernetes. Его выбор сейчас обусловлен особенностями
  # реализации minikube, в нормальном кластере можно
  # было бы использовать более простой type: ClusterIP.
  type: NodePort
  selector:
    app: frontend-app
  ports:
    - protocol: TCP
      port: 40002
kubectl apply -f k8s/frontend-service.yaml
kubectl apply -f k8s/backend-service.yaml

Теперь создаём ингрессы:

kubectl apply -f "k8s/*-ingress.yaml"

Поскольку ингресс опирается на имя хоста для роутинга между двумя сервисами (я использовал хосты frontend.localhost и backend.localhost), нам также придётся прописать эти хосты в /etc/hosts, чтобы всё заработало:

echo "127.0.0.1 frontend.localhost\n127.0.0.1 backend.localhost" | sudo tee -a /etc/hosts

Теперь наши отладочные хосты указывают на 127.0.0.1. Осталось запустить minikube tunnel, чтобы прорезать доступ:

$ minikube tunnel
✅  Tunnel successfully started

????  NOTE: Please do not close this terminal as this process must stay alive for the tunnel to be accessible ...

❗  The service/ingress backend-ingress requires privileged ports to be exposed: [80 443]
????  sudo permission will be asked for it.
❗  The service/ingress frontend-ingress requires privileged ports to be exposed: [80 443]
????  Starting tunnel for service backend-ingress.
????  sudo permission will be asked for it.
????  Starting tunnel for service frontend-ingress.

(Нужно будет ввести пароль, чтобы разрешить minikube занять 80-й порт.)

Обратите внимание, что нам не пришлось создавать LoadBalancer, как я описал в начале раздела — это особенность использования minikube + nginx.

Чтобы увидеть содержательный результат, осталось создать тестовый контент:

kubectl exec deploy/backend-deployment -- \
    python -m tools.add_test_content

И, если всё было сделано правильно, можно зайти на http://frontend.localhost/card/helloworld и увидеть, что всё работает!

Немного об отладке

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

Начинать всегда стоит с ознакомления со списком ресурсов. kubectl get pods покажет, какие поды запущены, в каком они статусе и, что немаловажно, сколько было рестартов каждого пода; частые рестарты могут указывать на систематические проблемы, которые стоит копнуть глубже.

$ kubectl get pods
NAME                                 READY STATUS  RESTARTS     AGE
backend-deployment-775d8c8b8f-m686f  1/1   Running 8 (5h2m ago) 8d
backend-deployment-775d8c8b8f-zghvf  1/1   Running 9 (5h2m ago) 8d
frontend-deployment-8579847d7f-dxwst 1/1   Running 0            66m
frontend-deployment-8579847d7f-nbxrx 1/1   Running 0            66m
mongodb-statefulset-0                1/1   Running 2 (2d4h ago) 8d
redis-statefulset-0                  1/1   Running 0            2d
test-pod                             1/1   Running 5 (2d ago)   28d
worker-deployment-6cb87545d6-r2c2s   1/1   Running 489 (2d ago) 54d
worker-deployment-6cb87545d6-xtchk   1/1   Running 489 (2d ago) 54d

(В моём случае большинство рестартов — это рестарты всего minikube: я останавливаю его вне рабочего времени, потому что поды могут создавать приличную нагрузку на CPU.)

Самый очевидный инструмент отладки — логи. Можно смотреть логи произвольного пода как в деплойменте, так и в стейтфулсете:

kubectl logs deployment/backend-deployment
kubectl logs statefulset/mongodb-statefulset

Также можно смотреть логи конкретных подов (откроется лог первого контейнера), логи конкретного контейнера и логи всех контейнеров:

kubectl logs backend-deployment-775d8c8b8f-m686f
kubectl logs -c backend-container backend-deployment-775d8c8b8f-m686f
kubectl logs --all-containers backend-deployment-775d8c8b8f-m686f

Если под упал и уже пытается перезапуститься, логов падения вы не увидите. Для просмотра логов предыдущего запуска можно добавить флаг --previous:

kubectl logs --previous backend-deployment-775d8c8b8f-m686f

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

Распространённая проблема — падение пода по ошибке out of memory (OOM). Тут в логах ничего не будет видно и стоит смотреть события через kubectl describe:

$ kubectl describe pod oom-pod
...
Events:
  Type     Reason     Age                   From               Message
  ----     ------     ----                  ----               -------
  Normal   Scheduled  3m1s                  default-scheduler  Successfully assigned default/oom-pod to minikube
  Normal   Pulled     81s (x5 over 3m1s)    kubelet            Container image "python:bullseye" already present on machine
  Normal   Created    81s (x5 over 3m1s)    kubelet            Created container python-container
  Normal   Started    81s (x5 over 3m)      kubelet            Started container python-container
  Warning  BackOff    80s (x10 over 2m58s)  kubelet            Back-off restarting failed container python-container in pod oom-pod_default(3251c975-d536-4d39-8754-8651e8bbde0a)

По идее, в Kubernetes у пода есть состояние OOMKilled, по которому можно понять, что произошёл OOM, и соответствующий ивент. Но, как видно на этом примере (я запустил в контейнере python-скрипт, создающий список из 2^40 объектов) и моём трудовом опыте, не всегда это работает. В настоящем продакшне стоит иметь метрики количества памяти, занимаемой контейнерами, чтобы отлавливать OOM-ы. На практике если Kubernetes рестартит контейнер, а в логах нет никаких ошибок, я в первую очередь подозреваю OOM.

Иногда бывает полезно посмотреть все события кластера в хронологическом порядке:

kubectl get events --sort-by='.metadata.creationTimestamp' -A

В случае проблем с роутингом имеет смысл посмотреть логи ингресс-контроллера. У нас он живёт в неймспейсе ingress-nginx:

$ kubectl -n ingress-nginx get pods
NAME                                      READY STATUS    RESTARTS   AGE
ingress-nginx-admission-create-wd4j2      0/1   Completed 0          3d
ingress-nginx-admission-patch-jfjrg       0/1   Completed 0          3d
ingress-nginx-controller-6cc5ccb977-s7659 1/1   Running   1 (2d ago) 3d

$ kubectl -n ingress-nginx logs deployment/ingress-nginx-controller
...

В целом при отладке стоит помнить, что ресурсы могут существовать в разных неймспейсах; особенно это касается системных компонент, запущенных самим кластером, и всяких инфраструктурных обвязок, поддерживаемых командой эксплуатации. Заходить в хату в новый кластер всегда стоит с запуска kubectl get namespaces. Для работы с ресурсами вне дефолтного неймспейса обязательно надо указывать флаг -n <имя неймспейса>, иначе kubectl get будет говорить, что такого ресурса не существует.

О чём ещё стоит знать

Раз уж я презентовал Kubernetes как систему оркестрации контейнеров промышленного уровня, расскажу здесь о фичах, с которыми вы с большой вероятностью столкнётесь в настоящих высоконагруженных проектах и ради которых в значительной мере и затевался сам Kubernetes.

Реквесты и лимиты

Выше я писал, что распространённая проблема в продакшне — падение контейнеров по OOM. Но чтобы контейнер упал по OOM, его память должна быть как-то ограничена. Конечно, она жёстко ограничена памятью ноды, на которой он запущен; но как-то неправильно, если контейнер будет умирать, только выжрав всю ноду. Поэтому в Kubernetes есть возможность установить лимит памяти и загрузки CPU на каждый контейнер:

apiVersion: v1
kind: Pod
metadata:
  name: test-pod
spec:
  containers:
    - name: python-container
      image: python:bullseye
      command: ["python3", "-m", "http.server", "8080"]

      resources:
        requests:
          memory: "128Mi"
          cpu: "250m"
        limits:
          memory: "256Mi"
          cpu: "500m"

В разделе resources можно указать два типа ограничений: requests и limits. 

Requests — это количество памяти и CPU, которые будут гарантированы контейнеру. Контейнер будет запущен на ноде только если там достаточно незанятой другими Kubernetes-контейнерами памяти и CPU.

Limits — это максимальное количество памят и CPU, которое может занять контейнер. Контейнер может получить больше памяти, чем прописано в requests, если на ноде ещё есть память, не занятая другими контейнерами, но при попытке занять памяти больше, чем в limits, контейнер будет убит. Он может быть убит и раньше, если незанятая память на ноде закончится — гарантируется только столько памяти, сколько прописано в requests.

Если же контейнер превышает requests по CPU и ресурс CPU на ноде закончился (или упёрся в limits), контейнер начнёт тротлиться, то есть Kubernetes будет искусственно держать процесс на паузе какой-то процент времени. Это крайне нежелательно для веб-сервисов, потому что кванты времени в Kubernetes больше, чем на привычных операционных системах, и тротлинг может приводить к значительному ухудшению latency — например, Kubernetes может поставить ваш процесс на паузу на добрые 100 мс, что зачастую в разы выше времени обработки запроса.

Память обычно задают в Mi и Gi — это мегабайты и гигабайты в двоичной системе (2^30 и 2^40 байт). CPU обычно задают в m — это одна тысячная одного виртуального ядра, то есть 1000m гарантирует процессу одно целое ядро.

Плавная выкатка

Если мы меняем что-то в деплойменте — например, версию Docker-образа при выкатке нового релиза, — Kubernetes должен создать новые поды по новому шаблону и удалить старые. По умолчанию это будет делаться постепенно — Kubernetes начнёт гасить 25% старых подов и создавать соответствующее количество новых, по мере успешного запуска новых продолжая гасить старые. Число в 25%, конечно, настраивается, причём с обеих сторон — какой процент подов от желаемого числа может быть в переходном состоянии (maxUnavailable) и какое количество лишних подов может существовать в моменте (maxSurge): 

apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend-deployment
spec:
  replicas: 10
  strategy:
    rollingUpdate:
      maxUnavailable: "10%"
      maxSurge: "50%"
  ...

Мы можем уменьшить maxUnavailable, чтобы обезопасить себя от отключения слишком большого числа подов разом (например, если по какой-то причине мы любим, чтобы все поды работали на пределе своих возможностей); можем уменьшить maxSurge, чтобы во время выкатки система не была перегружена подами (если у нас 100 подов, лишние 25 могут быть проблемой); или можем увеличить maxUnavailable и/или maxSurge, чтобы ускорить выкатку.

Помимо значений в процентах можно указывать абсолютное количество подов. Например, самая медленная и безопасная выкатка будет настроена как-то так:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend-deployment
spec:
  replicas: 10
  strategy:
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  ...

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

Помимо изменений в деплойменте, плавную выкатку можно стриггерить вручную командой kubectl rollout restart:

kubectl rollout restart deployment backend-deployment

Можете сделать в backend-deployment побольше подов через поле replicas, запустить эту команду и сделать подряд несколько вызовов kubectl get pods и kubectl get replicas, чтобы увидеть процесс плавной выкатки своими глазами.

Liveness-пробы

При плавной выкатке Kubernetes должен понимать, в какой момент только что запущенный под уже готов принимать на себя нагрузку, чтобы сменить его статус с ContainerCreating на Running и продолжить процесс выкатки. Как это устроено?

Для определения, запустился ли успешно контейнер, используется так называемая liveness-проба. Это некоторое действие, которое Kubernetes-движок может совершать с контейнером — например, запуск команды, HTTP- или gRPC-запрос, — и проверять, что получается ожидаемый результат. Если мы делаем обычный HTTP-сервис, нам идеально подходит HTTP-запрос. Конфигурируется он так:

apiVersion: v1
kind: Pod
metadata:
  name: test-pod
spec:
  containers:
    - name: python-container
      image: python:bullseye
      command: ["python3", "-m", "http.server", "8080"]

      livenessProbe:
        httpGet:
          path: /ping
          port: 8080
          httpHeaders:
          - name: X-Request-Reason
            value: liveness-probe
        initialDelaySeconds: 5
        periodSeconds: 3
        failureThreshold: 2

Всё предельно просто: мы указываем порт, путь, дополнительные HTTP-заголовки (если они нужны) и три параметра: сколько ждать после запуска контейнера перед первой попыткой сделать пробу (initialDelaySeconds), как часто делать пробу, убеждаясь, что под ещё работает (periodSeconds), и сколько раз подряд проба должна завершиться неуспехом, чтобы под был убит (failureThreshold). С настройками выше Kubernetes подождёт пять секунд после создания контейнера, после чего начнёт каждые три секунды делать GET-запрос пути /ping. Если GET-запрос два раза подряд вернёт статус, отличный от 200 (или стаймаутится), под будет убит и пересоздан.

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

Для отдельных экзотических сценариев (очень медленно стартующий контейнер, контейнер с периодами недоступности без необходимости рестарта) есть ещё два вида проб — startupProbe и readinessProbe, но в рамках этой статьи я вас пощажу. 

Горизонтальное масштабирование

Если в какой-то момент мы видим, что наш бэкенд не справляется с нагрузкой, мы можем быстро изменить количество подов командой kubectl scale:

kubectl scale deployment backend-deployment --replicas=6

Но, конечно, 24/7 следить за продакшном и настраивать количество реплик на глаз мы не хотим. Благо, в Kubernetes встроено автоматическое горизонтальное масштабирование. Для него нам необходимо создать ресурс HorizontalPodAutoscaler (HPA):

apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
  name: backend-hpa
spec:

  # Указываем, какую совокупность подов надо скейлить:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: backend-deployment

  # Нижний и верхний предел количества реплик,
  # чтобы случайно не сожрать весь кластер:
  minReplicas: 2
  maxReplicas: 20

  # Метрики, по значениям которых будем определять
  # потребность в масштабировании:
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 75
  - type: Resource
    resource:
      name: memory
      target:
        type: AverageValue
        averageValue: 400Mi

В этом примере автоскейлер сконфигурирован так, чтобы средняя загрузка CPU по всем работающим подам не превышала 75% (процент считается относительно requests), а среднее потребление памяти — 400 МБ. Автоскейлер будет с какой-то периодичностью проверять метрики подов и при необходимости скейлить деплоймент без ручного вмешательства.

HPA — сложный объект в фазе активной разработки, так что этот пример может быстро устареть. За актуальными деталями отсылаю к документации и отдельным статьям по теме.

Джобы

Для выполнения асинхронных задач в нашем демо-приложении есть воркер, читающий очередь задач из Redis. И хотя этот стек может выглядеть сомнительно, идейно такой компонент совершенно адекватен и существует, наверное, в том или ином виде в большинстве сложных веб-проектов. 

Но иногда возникает потребность выполнять особенно тяжёлые задачи, под которые хорошо бы запустить отдельный контейнер со своей памятью/CPU, чтобы не мешать выполнять остальные, мелкие задачи почти-в-режиме-реального-времени. Примерами могут служить тяжёлые миграции, дампы базы и так далее.

Для такого одноразового запуска контейнеров есть отдельный тип ресурса Job. Спецификация выглядит похоже на деплоймент, потому что джобе тоже нужно многократно плодить поды — но не потому, что их нужно много, а потому, что может возникнуть потребность в ретраях.

# k8s/test-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: test-job
spec:
  template:
    spec:
      containers:
        - name: python-container
          image: python:bullseye
          command: ["python3", "-c", "import math; print(math.pi)"]

      # restartPolicy определяет, что будет происходить в 
      # случае ошибок. Never значит, что в случае ошибки
      # под будет пересоздан; OnFailure же оставляет 
      # Kubernetes возможность перезапустить контейнер 
      # без пересоздания, с возможными артефактами предыдущих
      # неудачных запусков.
      restartPolicy: Never

  backoffLimit: 3    # максимальное количество попыток

Запустив джобу, увидим порождённый ею под в новом для нас статусе Completed:

$ kubectl apply -f k8s/test-job.yaml
job.batch/test-job created

$ kubectl get pods
NAME             READY   STATUS      RESTARTS   AGE
...
test-job-rhddf   0/1     Completed   0          60s
...

$ kubectl logs job/test-job   
3.141592653589793

Заменив в python-контейнере печать числа пи на бросок эксепшна и подождав немного, увидим другую картину — Kubernetes по очереди создаст три пода (в соответствии с выставленным в джобе backoffLimit) и на этом остановится.

$ kubectl apply -f k8s/fail-job.yaml
job.batch/fail-job created

$ kubectl get pods
NAME             READY   STATUS      RESTARTS   AGE
...
fail-job-fl7dg   0/1     Error       0          18s
fail-job-gz4nq   0/1     Error       0          23s
fail-job-shzs6   0/1     Error       0          4s
...

$ kubectl get logs job/fail-job
Found 4 pods, using pod/fail-job-gz4nq
Traceback (most recent call last):
  File "<string>", line 1, in <module>
RuntimeError

Заключение

Хотя Kubernetes нельзя назвать таким уж простым инструментом, освоить его на достаточном для прикладного разработчика уровне несложно, и даже вполне возможно в домашних условиях. Надеюсь, эта статья приспустит завесу тайны для тех, кто об оркестрации контейнеров только слышал, и будет полезна тем, кому предстоит работать с Kubernetes в ближайшем будущем. Спасибо за внимание!

P. S. Большое спасибо @hexyo, @yesview и @dmtrskv за вычитку и комментарии к статье!

Комментарии (5)


  1. anonymous
    10.08.2023 11:19

    НЛО прилетело и опубликовало эту надпись здесь


  1. alpush
    10.08.2023 11:19

    Супер подробно. Спасибо!


  1. mrxanru
    10.08.2023 11:19
    +1

    Тот самый необходимый минимум, спасибо!


  1. olku
    10.08.2023 11:19
    +1

    Отличный баланс между широтой и глубиной. Я бы поды выкинул как несущественные для разработчика, но тогда не понять где же оно в конце концов работает. А декларацию сетевых политик между сервисами бы добавил. Всего пара десятков строк, но заставляет думать в парадигме МСА не создавать распределённый монолит.


  1. mikegordan
    10.08.2023 11:19

    1)

    "Ингресс-контроллер — это компонент, следящий за существующими в кластере ингрессами и, собственно, реализующий роутинг. "

    "обычно поднимается один сервис типа LoadBalancer, который уже роутит трафик между сервисами."

    Так LoadBalancer или Ингресс организует роутинг? Не очень понимаю полный путь запроса через чего он проходит.

    2) Не очень понял про mongodb если он будет работать допустим с 100 шардами. Как Игресс\ЛоудБалансер поймет на какой "контейнер" нужно передать запрос?