Перейти к основному содержимому

Развертывание БД в K8s

Презентация​

Ссылка на презентацию
Последняя редакция: 18.09.26

Теория​

Деплой БД: с чего начать​

Развернуть БД в кластере - значит пройти четыре шага

  • Выделить место в кластере
  • Получить доступ к этому месту
  • Пробросить креды БД в поды
  • Написать манифесты для самой БД

Первые два шага - про PV и PVC

PV и PVC​

PV (PersistentVolume) - сегмент памяти, выделенный кластером
PVC (PersistentVolumeClaim) - запрос пода на использование памяти кластера, использует PV
Control Plane ищет PV, который удовлетворяет PVC, и отдаёт место

StorageClass​

StorageClass описывает конкретный класс/тип хранилища, доступный в кластере - например, в Kubernetes Dashboard это отдельная вкладка со списком классов (по умолчанию обычно один - standard)
У облачных провайдеров свой набор классов с разными характеристиками - например, у Yandex Cloud это yc-network-hdd, yc-network-ssd, yc-network-nvme и другие, отличающиеся производительностью и политикой восстановления

PersistentVolume​

Поле accessModes определяет, кто и как может обращаться к тому

  • ReadWriteOnce - чтение/запись для одной ноды
  • ReadWriteOncePod - чтение/запись для отдельного пода
  • ReadOnlyMany - чтение для многих
  • ReadWriteMany - чтение/запись для многих

Настройки самого PV (PV Settings)

  • local - настройки хранения на нодах
  • hostPath - аналогично, но для single-node кластеров
  • claimRef - ссылка на PVC, которые могут использовать этот PV
apiVersion: v1
kind: PersistentVolume
metadata:
{{META}}
spec:
storageClassName: {{STORAGE_CLASS}}
capacity:
storage: {{CAPACITY}}
accessModes:
- {{ACCESS_MODE}}
{{PV_SETTINGS}}

Здесь и в манифестах ниже {{ЗНАЧЕНИЕ}} - место, куда нужно подставить своё (это обозначение самого манифеста, а не часть синтаксиса Kubernetes)

Пример заполненного PV с hostPath - подходит для локального single-node кластера (minikube, kind)

apiVersion: v1
kind: PersistentVolume
metadata:
name: postgres-pv
spec:
storageClassName: standard
capacity:
storage: 5Gi
accessModes:
- ReadWriteOnce
hostPath:
path: /data/postgres-pv
type: DirectoryOrCreate
claimRef:
name: postgres-pvc
namespace: app

Несколько важных деталей, которые легко упустить

  • PV_SETTINGS не может быть пустым - нужно явно указать тип тома (hostPath, local, nfs, csi и т.д.), иначе Kubernetes откажет с ошибкой must specify a volume type
    Одних только capacity/accessModes/storageClassName/claimRef недостаточно
  • PV - ресурс на уровне всего кластера, а не namespace, поэтому в его metadata не может быть поля namespace (в отличие от PVC, который namespace-scoped)
    claimRef.namespace - это другое: он просто указывает, в каком namespace искать сам PVC
  • hostPath привязывает данные к конкретному узлу физически, поэтому имеет смысл только с ReadWriteOnce (одна нода) - ReadWriteMany для него не подходит, даже если формально Kubernetes это не запрещает

PersistentVolumeClaim​

PVC запрашивает конкретный PV - либо по имени (volumeName), либо через selector.matchLabel

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
{{META}}
spec:
accessModes:
- {{ACCESS_MODE}}
resources:
requests:
storage: {{CAPACITY_REQUEST}}
storageClassName: {{STORAGE_CLASS}}
selector:
{{PV_SELECTOR}}
{{PVC_SETTINGS}}

Как отдать PV поду​

Чтобы под реально использовал PV, в его манифесте нужны volumes (какие тома подключить) и volumeMounts (куда именно примонтировать том внутри контейнера - mountPath)

В volumes можно подключить не только PVC

  • persistentVolumeClaim
  • local
  • hostPath
  • secret
  • configMap
apiVersion: v1
kind: Pod
metadata:
{{META}}
spec:
containers:
- name: {{NAME}}
image: {{IMAGE}}
volumeMounts:
- mountPath: {{MOUNT_PATH}}
name: {{MOUNT_NAME}}
volumes:
- name: {{MOUNT_NAME}}
persistentVolumeClaim:
claimName: {{PVC_NAME}}

Секретные и открытые данные​

Третий шаг деплоя БД - пробросить в поды креды и прочие данные
Если данные чувствительные (пароли, ключи, токены) - используется Secret
Если нет (конфиги, адреса сервисов, прочие настройки) - ConfigMap

Secret​

Secret используется для передачи конфиденциальных данных

  • Креды (логин/пароль)
  • Приватные ключи шифрования
  • Сертификаты безопасности
  • Токены доступа

Для каждого типа данных есть свой тип секрета
Тип по умолчанию - Opaque

apiVersion: v1
kind: Secret
metadata:
{{META}}
type: {{SECRET_TYPE}}
{{SECRET_PAYLOAD}}

Payload секрета​

Данные в Secret задаются одним из двух полей

  • data - значения в base64
  • stringData - обычный текст, Kubernetes сам закодирует его при сохранении
apiVersion: v1
kind: Secret
metadata:
name: secret-basic-auth
type: kubernetes.io/basic-auth
# не закодированные данные
stringData:
username: admin
password: t0p-Secret
apiVersion: v1
kind: Secret
metadata:
name: dotfile-secret
# данные уже в base64
data:
.secret-file: dmFsdWUtMg9KDQo=

Передача данных из Secret в под​

Секрет можно передать поду тремя способами

Через том - файлом в файловой системе контейнера

containers:
- name: test-container
image: nginx
volumeMounts:
- name: secret-volume
mountPath: /etc/secret-volume
readOnly: true
volumes:
- name: secret-volume
secret:
secretName: test-secret

Через переменную окружения, по одному ключу (secretKeyRef)

env:
- name: BACKEND_USERNAME
valueFrom:
secretKeyRef:
name: backend-user
key: backend-username

Через envFrom - сразу весь секрет как набор переменных окружения

envFrom:
- secretRef:
name: test-secret

ConfigMap​

ConfigMap используется для передачи открытых данных

  • Конфиги
  • Хосты/адреса сервисов
  • Прочие настройки

В data данные указываются как есть, без base64-кодировки

apiVersion: v1
kind: ConfigMap
metadata:
{{META}}
data:
{{PLAIN_PAYLOAD}}

Передача данных из ConfigMap в под​

Те же три способа, что и для Secret

Через том, с выбором конкретных ключей (items)

volumes:
- name: foo
configMap:
name: myconfigmap
# Optional - можно взять не все ключи, а только нужные
items:
- key: "game.properties"
path: "game.props"
- key: "user-interface.properties"
path: "user-interface.props"

Через envFrom - весь ConfigMap как переменные окружения

envFrom:
- configMapRef:
name: myconfigmap

Через переменную окружения, по одному ключу (configMapKeyRef)

env:
- name: CONFIGMAP_USERNAME
valueFrom:
configMapKeyRef:
name: myconfigmap
key: username

StatefulSet​

StatefulSet - аналог Deployment для stateful-приложений

  • БД
  • Очереди/брокеры

Состоит из 3 обязательных частей

  • StatefulSet - похож на Deployment, но управляет подами иначе (см. ниже)
  • PVC - через volumeClaimTemplates, у каждой реплики появляется свой PVC
  • Headless Service (clusterIP: None) - без него у StatefulSet не будет стабильных сетевых имён

Именно headless-сервис даёт каждой реплике персональный, стабильный адрес вида {{под}}.{{service_name}}.{{namespace}}.svc.cluster.local (например, postgres-0.postgres.default.svc.cluster.local)
Это не то же самое, что DNS-имя обычного Service - там по одному и тому же имени каждый раз может ответить любой из подов, а тут - конкретный

apiVersion: apps/v1
kind: StatefulSet
metadata:
{{METADATA}}
spec:
selector:
{{SELECTOR}}
serviceName: {{SERVICE_NAME}}
replicas: {{REPLICAS}}
template:
{{POD_TEMPLATE}}
volumeClaimTemplates:
- metadata:
{{PVC_METADATA}}
spec:
accessModes: [ "{{ACCESS_MODE}}" ]
storageClassName: {{STORAGE_CLASS}}
resources:
requests:
storage: {{CAPACITY}}

Манифест самого Headless Service - без него у StatefulSet не будет per-pod DNS-имён

apiVersion: v1
kind: Service
metadata:
name: {{SERVICE_NAME}}
spec:
clusterIP: None
selector:
{{SELECTOR}}
ports:
- port: {{PORT}}

clusterIP: None - это и есть headless: сервис не получает свой виртуальный IP, а DNS сразу отдаёт адреса подов

StatefulSet vs Deployment​

КритерийDeploymentStatefulSet
ПриложенияДля stateless-приложенийДля stateful-приложений (БД, очереди/брокеры)
ПодыВзаимозаменяемыеНе взаимозаменяемые - у каждого свой постоянный идентификатор, сохраняющийся при пересоздании
Имена подовУникальные, без гарантированного порядкаИдут по порядку (pod-0, pod-1, ...)
Доступ к подамЧерез обычный ServiceЧерез Headless Service - он отвечает за сетевую идентичность подов
PersistentVolumeClaimОдин PVC общий для всех реплик (shared volume)volumeClaimTemplates - у каждой реплики свой PVC (no shared volume)

Job​

Job - контроллер для одноразовой задачи, а не долгоживущего сервиса
Для БД типичный пример - миграция схемы перед первым запуском или после деплоя новой версии приложения: команда должна отработать один раз и завершиться, а не работать вечно, как под за Deployment

В отличие от Deployment, Job не пересоздаёт под просто потому что тот завершился - под Deployment должен постоянно быть в статусе Running, а под Job наоборот должен дойти до Completed
Job пересоздаёт под только при ошибке (если не указано иное) и следит, чтобы нужное количество успешных запусков было выполнено

apiVersion: batch/v1
kind: Job
metadata:
name: {{JOB_NAME}}
spec:
backoffLimit: {{RETRY_LIMIT}}
template:
spec:
containers:
- name: {{CONTAINER_NAME}}
image: {{IMAGE}}
command: [{{COMMAND}}]
restartPolicy: {{RESTART_POLICY}}
  • backoffLimit - сколько раз пересоздать под при ошибке, прежде чем считать Job проваленным
  • restartPolicy - у пода в Job не может быть Always (это не сервис), только OnFailure или Never
  • command - сюда обычно кладут саму команду миграции (python manage.py migrate, alembic upgrade head и подобное)

Init containers​

Init containers - контейнеры в том же поде, что и основной (main) контейнер, но которые запускаются и обязательно завершаются до его старта
Это ещё один способ прогнать миграцию - не отдельным ресурсом, как Job выше, а прямо внутри Deployment самого приложения

Каждый init-контейнер должен успешно завершиться (Completed), прежде чем стартует следующий по списку, а основной контейнер не запустится, пока не отработают все init-контейнеры
Если init-контейнер падает, Kubernetes пересоздаёт под и пробует заново

apiVersion: apps/v1
kind: Deployment
metadata:
name: {{APP_NAME}}
spec:
template:
spec:
initContainers:
- name: {{INIT_NAME}}
image: {{IMAGE}}
command: [{{COMMAND}}]
containers:
- name: {{CONTAINER_NAME}}
image: {{IMAGE}}
  • initContainers - отдельный список на уровне пода (spec.template.spec), рядом с containers, а не внутри него
  • Контейнеры из списка выполняются по очереди, один за другим, а не параллельно

Job или init-контейнер: что выбрать для миграции​

Оба варианта решают одну и ту же задачу, но по-разному встроены в жизненный цикл приложения

КритерийJobInit-контейнер
Когда запускаетсяОтдельным шагом - kubectl apply/шаг CIАвтоматически при каждом создании пода Deployment
Сколько раз выполнитсяОдин раз (пока Job не пересоздан)При каждом рестарте/пересоздании пода, в том числе при масштабировании
РискЛегко забыть выполнить перед раскаткойМиграция должна быть безопасна для повторного/параллельного запуска (idempotent)
Видимость статусаОтдельный объект (kubectl get jobs)Скрыт внутри Deployment, смотрится через сам под

Главная ловушка init-контейнера с миграцией - при масштабировании Deployment до нескольких реплик init-контейнер отработает у каждой новой реплики отдельно, то есть миграция попробует выполниться параллельно несколько раз
Если миграция не идемпотентна (например, CREATE TABLE без IF NOT EXISTS), это ломается - поэтому для самих миграций часто безопаснее пользоваться отдельным Job, а init-контейнеры оставлять для более безобидных вещей вроде "подождать, пока БД начнёт принимать соединения"

CronJob​

CronJob - Job, который запускается по расписанию, а не один раз
Для БД типичный пример - регулярный бэкап: снять дамп и сохранить его, и делать это каждую ночь автоматически, а не руками

apiVersion: batch/v1
kind: CronJob
metadata:
name: {{CRONJOB_NAME}}
spec:
schedule: "{{CRON_EXPRESSION}}"
jobTemplate:
spec:
template:
spec:
containers:
- name: {{CONTAINER_NAME}}
image: {{IMAGE}}
command: [{{COMMAND}}]
restartPolicy: {{RESTART_POLICY}}
  • schedule - обычное cron-выражение ("0 3 * * *" - каждую ночь в 3:00)
  • jobTemplate - шаблон того самого Job, который CronJob создаёт на каждый запуск по расписанию - структура внутри такая же, как у обычного Job из раздела выше

CronJob не выполняет команду сам - в момент по расписанию он создаёт новый объект Job, а уже этот Job создаёт под, точно так же, как в разделе про Job выше

Примеры​

Собираем ConfigMap и Secret для nginx - конфиг сервера со страничкой "Hello World" и basic-auth на вход, всё через тома, теми же приёмами, что и в теории выше

ConfigMap с конфигом nginx и самой страничкой (два ключа в data)

apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
data:
default.conf: |
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html;
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/secrets/.htpasswd;
}
}
index.html: |
<html><body><h1>Hello World</h1></body></html>

Secret с файлом .htpasswd для basic-auth (сам файл получаем локально командой htpasswd -c .htpasswd admin, а его содержимое кодируем в base64 - base64 .htpasswd - и кладём в data)

apiVersion: v1
kind: Secret
metadata:
name: nginx-auth
type: Opaque
data:
.htpasswd: {{BASE64_HTPASSWD}}

Под, который монтирует оба - ConfigMap в каталог конфигов и статики, Secret в каталог, на который ссылается auth_basic_user_file

apiVersion: v1
kind: Pod
metadata:
name: nginx
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:latest
ports:
- containerPort: 80
volumeMounts:
- name: nginx-conf
mountPath: /etc/nginx/conf.d
- name: nginx-html
mountPath: /usr/share/nginx/html
- name: nginx-secrets
mountPath: /etc/nginx/secrets
readOnly: true
volumes:
- name: nginx-conf
configMap:
name: nginx-config
items:
- key: default.conf
path: default.conf
- name: nginx-html
configMap:
name: nginx-config
items:
- key: index.html
path: index.html
- name: nginx-secrets
secret:
secretName: nginx-auth

И Service, чтобы достучаться до пода снаружи

apiVersion: v1
kind: Service
metadata:
name: nginx-service
spec:
type: NodePort
selector:
app: nginx
ports:
- port: 80
targetPort: 80
kubectl apply -f configmap.yaml
kubectl apply -f secret.yaml
kubectl apply -f pod.yaml
kubectl apply -f service.yaml
minikube service nginx-service --url

Второй пример - тот же стек, что нужен в практике ниже, но уже для настоящей БД: StatefulSet с PostgreSQL, Headless Service и Secret с кредами

Secret с кредами БД

apiVersion: v1
kind: Secret
metadata:
name: postgres-secret
type: Opaque
stringData:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: t0p-Secret
POSTGRES_DB: appdb

Headless Service - без виртуального IP, только для DNS-имён подов

apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
clusterIP: None
selector:
app: postgres
ports:
- port: 5432

StatefulSet, который берёт креды из Secret через envFrom и получает по PVC свой том на каждую реплику

apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16
ports:
- containerPort: 5432
envFrom:
- secretRef:
name: postgres-secret
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: postgres-data
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi

Service и StatefulSet здесь специально называются одинаково (postgres/postgres) для простоты примера - это не обязательное правило
DNS-имя каждой реплики строится как <StatefulSet>-<N>.<serviceName>.<namespace>.svc.cluster.local, где <StatefulSet> - это имя самого StatefulSet, а <serviceName> - значение его spec.serviceName (то есть имя headless Service)
Если бы они назывались по-разному (например, StatefulSet - postgres-sts, а Service - postgres-svc), первая часть имени всё равно шла бы от StatefulSet - получилось бы postgres-sts-0.postgres-svc..., а не postgres-svc-0...
Подробнее про эту логику и частую ошибку с ней - в 05-K8s-networking

kubectl apply -f secret.yaml
kubectl apply -f service.yaml
kubectl apply -f statefulset.yaml
kubectl get pods -w

Из другого пода (например, Django-приложения) эта БД доступна по адресу postgres-0.postgres.<namespace>.svc.cluster.local:5432 - именно этот хост и креды из postgres-secret идут в DATABASES в settings.py

Третий пример - Job и CronJob для той же самой БД postgres/postgres-secret из примера выше

Job для разовой миграции схемы перед раскаткой новой версии приложения

apiVersion: batch/v1
kind: Job
metadata:
name: django-migrate
spec:
backoffLimit: 2
template:
spec:
containers:
- name: migrate
image: myapp:1.4.0
command: ["python", "manage.py", "migrate"]
envFrom:
- secretRef:
name: postgres-secret
restartPolicy: Never

Та же самая миграция, но как init-контейнер прямо в Deployment приложения - выполнится автоматически при каждом создании пода, без отдельного kubectl apply

apiVersion: apps/v1
kind: Deployment
metadata:
name: django-app
spec:
replicas: 1
selector:
matchLabels:
app: django-app
template:
metadata:
labels:
app: django-app
spec:
initContainers:
- name: migrate
image: myapp:1.4.0
command: ["python", "manage.py", "migrate"]
envFrom:
- secretRef:
name: postgres-secret
containers:
- name: django-app
image: myapp:1.4.0
envFrom:
- secretRef:
name: postgres-secret

С одной репликой (replicas: 1) это безопасно - миграция выполнится один раз перед стартом единственного пода
Если увеличить replicas до нескольких, миграция запустится у каждой новой реплики отдельно - для неидемпотентной миграции это и есть та самая ловушка из теории выше

CronJob для ночного дампа этой же БД - сам дамп кладётся на PVC, реальная выгрузка во внешнее хранилище уже за рамками этого примера

apiVersion: batch/v1
kind: CronJob
metadata:
name: postgres-backup
spec:
schedule: "0 3 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: pg-dump
image: postgres:16
command: ["sh", "-c", "pg_dump -h postgres -U $POSTGRES_USER $POSTGRES_DB > /backup/dump.sql"]
envFrom:
- secretRef:
name: postgres-secret
volumeMounts:
- name: backup-volume
mountPath: /backup
restartPolicy: OnFailure
volumes:
- name: backup-volume
persistentVolumeClaim:
claimName: postgres-backup-pvc
kubectl apply -f migrate-job.yaml
kubectl get jobs -w
kubectl apply -f backup-cronjob.yaml
kubectl get cronjobs

Проверь себя​

1. Чем PV отличается от PVC?

PV (PersistentVolume) - это реально выделенный кластером сегмент памяти
PVC (PersistentVolumeClaim) - это запрос пода на использование памяти, который Control Plane сопоставляет с подходящим PV

2. Что описывает StorageClass?

Конкретный класс/тип хранилища, доступный в кластере - у разных классов разная производительность и политика восстановления (у облачных провайдеров таких классов обычно несколько)

3. Чем ReadWriteOnce отличается от ReadWriteOncePod?

ReadWriteOnce даёт чтение/запись одной ноде (и всем подам на ней)
ReadWriteOncePod - более строгий вариант, доступ есть только у одного конкретного пода

4. Когда использовать Secret, а когда ConfigMap?

Secret - для чувствительных данных (пароли, ключи, токены)
ConfigMap - для всего остального (конфиги, адреса сервисов, прочие настройки)

5. Чем отличаются поля data и stringData в Secret?

data хранит значения в base64
stringData принимает обычный текст - Kubernetes сам закодирует его при сохранении

6. Какими способами можно передать Secret или ConfigMap в под?

Три способа: смонтировать как том (volumes/volumeMounts), передать один ключ как переменную окружения (secretKeyRef/configMapKeyRef) или передать сразу весь объект как набор переменных окружения (envFrom)

7. Из каких трёх частей состоит StatefulSet?

Сам StatefulSet, PVC через volumeClaimTemplates (свой PVC на каждую реплику) и Headless Service (даёт каждому поду собственное DNS-имя)

8. В чём главное отличие StatefulSet от Deployment по PVC?

У Deployment все реплики обычно делят один общий PVC (shared volume)
У StatefulSet через volumeClaimTemplates каждая реплика получает свой собственный PVC (no shared volume)

9. Чем Job отличается от Deployment с точки зрения жизненного цикла пода?

Под Deployment должен работать постоянно (Running), и контроллер пересоздаёт его, если он завершился
Под Job наоборот должен дойти до Completed и на этом закончить - Job пересоздаёт под только при ошибке, а не при успешном завершении

10. Почему миграция через init-контейнер опаснее миграции через Job при масштабировании?

Init-контейнер выполняется у каждой новой реплики пода отдельно
Если Deployment масштабируют до нескольких реплик, миграция запустится параллельно несколько раз - для неидемпотентной миграции (например, CREATE TABLE без IF NOT EXISTS) это приводит к ошибкам, тогда как Job выполняется один раз независимо от количества реплик приложения

11. Как CronJob запускает задачу по расписанию?

Сам CronJob ничего не выполняет напрямую - в момент по расписанию (schedule) он создаёт новый объект Job по своему jobTemplate, а уже этот Job создаёт под с нужной командой

Практика​

  1. Разверни PostgreSQL в кластере как StatefulSet - креды (логин/пароль/имя БД) вынеси в Secret, сам StatefulSet укажи с volumeClaimTemplates и заведи для него Headless Service
  2. Подключи эту БД к своему Django-проекту - в settings.py бери креды из переменных окружения (проброшенных из Secret), а не хардкодом
  3. Проверь, что данные переживают пересоздание пода - удали под StatefulSet'а (kubectl delete pod ...) и убедись, что после пересоздания БД видит ранее записанные данные
  4. (опционально) Разверни вместо самостоятельного StatefulSet managed-инстанс PostgreSQL в YCloud и подключи проект к нему вместо локального пода

Итог / чек-лист​

  • PV - реально выделенный кластером кусок памяти, PVC - запрос пода на этот кусок, Control Plane сопоставляет их между собой
  • StorageClass описывает тип хранилища (у облаков - несколько классов с разной производительностью)
  • Чтобы под получил PV, в манифесте пода нужны volumes и volumeMounts
  • Secret - для чувствительных данных, ConfigMap - для всего остального; данные в Secret можно закодировать (data) или оставить как текст (stringData, кодируется сам)
  • Оба передаются поду тремя способами: том, одна переменная окружения по ключу (secretKeyRef/configMapKeyRef) или все сразу через envFrom
  • StatefulSet - аналог Deployment для stateful-приложений (БД, очереди), обязательно вместе с volumeClaimTemplates и Headless Service
  • Главное отличие от Deployment - у каждой реплики StatefulSet свой собственный PVC и стабильное сетевое имя, а не общий volume и взаимозаменяемые поды
  • Job - для одноразовых задач (например, миграция схемы БД перед раскаткой) - под должен дойти до Completed, а не работать вечно, как в Deployment
  • Init containers - контейнеры пода, которые обязаны отработать и завершиться до старта основного контейнера; тоже годятся для миграции, но выполняются у каждой реплики отдельно, поэтому безопасны только для идемпотентных операций
  • CronJob - Job по расписанию (schedule), типичный пример - ночной бэкап; сам CronJob не выполняет команду, а создаёт новый Job на каждый запуск

Дополнительно​