6. Stateful приложения
StatefulSet, Headless Service и распределенный консенсус в Go
В предыдущих главах (2. Pod, Deployment, Service и 5. Scaling и autoscaling) мы рассматривали мир микросервисов глазами разработчика stateless-приложений. В этой идеализированной модели поды эфемерны, легковесны и взаимозаменяемы: их можно уничтожать, перезапускать на других серверах и масштабировать сотнями за считанные секунды. В системном администрировании этот подход называют принципом «Cattle, not Pets» (скот, а не домашние любимцы) — если одна овца в стаде заболела, пастух берет другую без малейших сожалений.
Но бэкенд не состоит из одного лишь stateless API. Рано или поздно любой прикладной код упирается в хранилище данных: реляционные СУБД (PostgreSQL, MySQL), распределенные документные базы (MongoDB), брокеры сообщений (Apache Kafka, RabbitMQ) или кэширующие кластеры (Redis Cluster).
Каждая нода в таком кластере — это именно «Pet» (персональный питомец). У нее есть уникальное имя, своя выделенная порция данных на диске, своя роль в репликации (Master или Replica, Leader или Follower). Попытка развернуть такую систему с помощью стандартного Deployment приведет к немедленной катастрофе и порче данных.
Для управления приложениями, обладающими состоянием, в Kubernetes существует специализированный контроллер — StatefulSet.
1. Почему Deployment не подходит для State?
Представьте, что наивный инженер попытался запустить отказоустойчивый кластер PostgreSQL из 3 узлов обычным манифестом Deployment:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph BadDeployment["Deployment: Катастрофа со State"]
Dep["Deployment: postgres (replicas: 3)"]:::data --> P1["Pod: postgres-9f8b2"]
Dep --> P2["Pod: postgres-k3x1a"]
Dep --> P3["Pod: postgres-7d2q0"]
P1 --> PVC["Единый PVC (data-disk)"]
P2 --> PVC
P3 --> PVC
PVC -.-> Corrupt["💥 Data Corruption!<br>3 процесса пишут в одну ФС!"]
end
Что пойдет не так?
- Эфемерные имена подов: Контроллер Deployment генерирует случайные хэш-суффиксы: поды называются
postgres-a1b2,postgres-c3d4. При любом рестарте или падении ноды под пересоздается с совершенно новым случайным именем. Кластер СУБД не способен понять, кто из них Master, а кто Replica, если идентификаторы непрерывно меняются. - Эфемерные сетевые IP-адреса: Поды получают новые динамические IP-адреса. Реплика не знает, к кому подключаться для потоковой репликации WAL.
- Разделяемое дисковое хранилище (Катастрофа ФС): В спецификации Deployment все три реплики ссылаются на один и тот же
PersistentVolumeClaim(PVC). Если три независимых процесса PostgreSQL одновременно примонтируют один и тот же блочный том и начнут параллельно модифицировать системные файлы каталога$PGDATA, файловая система разрушится (Data Corruption) за секунды.
2. StatefulSet: Идентичность в мире хаоса
Контроллер StatefulSet радикально меняет правила игры, гарантируя строгую идентичность (Sticky Identity) для каждого пода на протяжении всей его жизни.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph SS["StatefulSet: postgres"]
SS_Ctrl["StatefulSet Controller"]:::data --> P0["Pod: postgres-0"]
SS_Ctrl --> P1["Pod: postgres-1"]
SS_Ctrl --> P2["Pod: postgres-2"]
P0 --> PVC0["PVC: data-postgres-0"]
P1 --> PVC1["PVC: data-postgres-1"]
P2 --> PVC2["PVC: data-postgres-2"]
PVC0 --> D0["PV 0 (Dedicated EBS/NVMe)"]
PVC1 --> D1["PV 1 (Dedicated EBS/NVMe)"]
PVC2 --> D2["PV 2 (Dedicated EBS/NVMe)"]
end
Ключевые гарантии StatefulSet:
- Стабильные детерминированные имена: Поды получают строго фиксированные имена с порядковым нулевым индексом:
postgres-0,postgres-1,postgres-2. Если подpostgres-1падает или нода сгорает, Kubernetes пересоздаст под именно с именемpostgres-1. - Стабильное хранилище (
volumeClaimTemplates): В отличие от Deployment, в StatefulSet описывается не статическая ссылка на диск, а шаблон клеймаvolumeClaimTemplates. Для каждого подаpostgres-Nсоздается свой персональный PVCdata-postgres-N. При рестарте пода на любой другой ноде кластера Kubernetes гарантированно примонтирует к нему ровно его собственный старый диск. - Строгая очередность запуска и удаления:
Поды разворачиваются последовательно:0->1->2. Подpostgres-1не начнет создаваться до тех пор, покаpostgres-0не перейдет в статусRunningи успешно не пройдетReadiness Probe. Удаление при масштабировании вниз (Scale Down) происходит в строго обратном порядке:2->1->0.
3. Headless Service: Сетевой якорь и прямой доступ к нодам
Обычный Kubernetes Service агрегирует поды за единым виртуальным IP-адресом (ClusterIP) и случайным образом раскидывает трафик. Но как нашему Go-приложению отправить запрос на запись строго в Master-ноду postgres-0, а тяжелые аналитические SELECT запросы распределить по репликам postgres-1 и postgres-2?
Для этого создается специальный сервис без виртуального IP — Headless Service (задается директивой clusterIP: None):
apiVersion: v1
kind: Service
metadata:
name: postgres-hl
spec:
clusterIP: None # Ключевой признак Headless Service
selector:
app: postgres
ports:
- port: 5432
name: postgresql
В связке со StatefulSet внутренний DNS-сервер Kubernetes (CoreDNS) начинает генерировать индивидуальные A/AAAA-записи для каждого пода по предсказуемому шаблону:
<pod-name>.<service-name>.<namespace>.svc.cluster.local
Например:
postgres-0.postgres-hl.default.svc.cluster.local-> отдает прямой IP-адрес подаpostgres-0.postgres-1.postgres-hl.default.svc.cluster.local-> отдает прямой IP-адрес подаpostgres-1.postgres-2.postgres-hl.default.svc.cluster.local-> отдает прямой IP-адрес подаpostgres-2.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
GoClient["Go Backend Client"]:::entry -->|"DNS query: postgres-0.postgres-hl"| CoreDNS["CoreDNS"]
CoreDNS -->|"A-record: 10.244.1.45 (Pod Direct IP)"| GoClient
GoClient -->|"Прямой коннект: порт 5432"| Pod0["Pod: postgres-0 (Master)"]
Теперь в коде на Go мы можем явно настроить пулы соединений для записи и чтения:
package main
import (
"database/sql"
"log"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
// Прямое подключение к мастер-узлу через DNS-имя Headless Service
masterDSN := "postgres://user:pass@postgres-0.postgres-hl:5432/mydb?sslmode=disable"
dbMaster, err := sql.Open("pgx", masterDSN)
if err != nil {
log.Fatalf("failed to connect to master: %v", err)
}
defer dbMaster.Close()
// Реплика для чтения отчетов
replicaDSN := "postgres://user:pass@postgres-1.postgres-hl:5432/mydb?sslmode=disable"
dbReplica, err := sql.Open("pgx", replicaDSN)
if err != nil {
log.Fatalf("failed to connect to replica: %v", err)
}
defer dbReplica.Close()
log.Println("Успешное разделение потоков чтения и записи!")
}
4. Go и Raft: Пишем свой собственный распределенный сервис
Язык Go стал признанным стандартом для создания распределенных систем высокой надежности: Kubernetes, etcd, CockroachDB, Consul, HashiCorp Vault — все они написаны на Go.
Сердцем большинства этих систем является алгоритм распределенного консенсуса Raft (реализованный в виде библиотек hashicorp/raft или etcd-io/raft). Если вы разрабатываете отказоустойчивое хранилище на Go, вам неизбежно потребуется запускать его в Kubernetes как StatefulSet.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph RaftCluster["Raft Cluster (3 узла)"]
R0["raft-0\n(Leader)"]:::process <-->|"AppendEntries / Heartbeat"| R1["raft-1\n(Follower)"]
R0 <-->|"AppendEntries / Heartbeat"| R2["raft-2\n(Follower)"]
R0 --> WAL0[("Write-Ahead Log\n(data-raft-0)")]
R1 --> WAL1[("Write-Ahead Log\n(data-raft-1)")]
R2 --> WAL2[("Write-Ahead Log\n(data-raft-2)")]
end
Почему именно StatefulSet?
- Жесткий список пиров при инициализации: Библиотекам Raft требуется статический список адресов участников для формирования первоначального кворума. Мы передаем его через переменные окружения:
RAFT_PEERS=raft-0.raft-hl,raft-1.raft-hl,raft-2.raft-hl. - Персистентность Write-Ahead Log (WAL): Все изменения состояния сначала сериализуются на диск в журнал упреждающей записи (Write-Ahead Log). Потеря диска при перезапуске пода означает потерю локальной истории коммитов и необходимость долгой передачи снапшота от лидера по сети.
volumeClaimTemplatesгарантирует, что том с WAL останется привязанным к тому же пиру.
5. Ловушки StatefulSet в Production: О чем молчит документация
Эксплуатация StatefulSet в боевых условиях требует железной дисциплины: здесь ошибки конфигурации не прощаются планировщиком.
5.1. Rolling Updates и потеря Кворума
По умолчанию стратегия обновления StatefulSet — RollingUpdate. Контроллер начинает последовательное обновление с пода с наибольшим индексом: обновляет postgres-2, дожидается готовности, затем переходит к postgres-1, и наконец к postgres-0.
Представьте, что в новую версию бинарника закралась ошибка (паника в main.go).
- Под
postgres-2обновляется и падает вCrashLoopBackOff. - Если автоматика продолжит обновление и уронит
postgres-1, в кластере из 3 нод живой останется лишь одна нода (postgres-0). - В распределенных системах большинство (кворум) вычисляется по формуле $\lfloor N/2 \rfloor + 1$. Для 3 нод кворум равен 2. Потеря двух нод означает полную потерю кворума: кластер мгновенно блокирует все операции записи!
5.2. PersistentVolume Reclaim Policy: Куда исчезают диски?
Когда вы удаляете объект StatefulSet командой kubectl delete statefulset postgres, Kubernetes удаляет поды, но намеренно оставляет нетронутыми PVC (PersistentVolumeClaim). Это сделано для предотвращения случайного уничтожения данных.
Однако если администратор удаляет весь namespace, или в StorageClass установлена политика Reclaim Policy: Delete, облачный провайдер немедленно форматирует и удаляет блочные диски (EBS тома в AWS). Для баз данных всегда настраивайте Reclaim Policy: Retain, чтобы физические диски оставались в облаке даже при ошибочном удалении манифестов.
3. Удаление Пода со "сломанной" нодой
Если физический сервер внезапно обесточился, kubelet на этой ноде умирает. Kubernetes видит потерю связи, но не имеет права автоматически пересоздать под StatefulSet на другом сервере.
Почему? Потому что K8s не знает, действительно ли сервер мертв, или произошла изоляция сети (сетевой раскол — network partition). Если под с тем же именем и тем же диском запустится на другой ноде, пока старый под все еще пишет на диск, произойдет непоправимый раскол мозга (split-brain).
Под зависает в статусе Terminating. Чтобы вернуть кластер к жизни, инженеру приходится:
- Физически удостовериться, что старый сервер выключен.
- Принудительно удалить под командой:
kubectl delete pod postgres-1 --force --grace-period=0. - В облачной панели принудительно отсоединить том от зависшей виртуальной машины.
Резюме
- Deployment vs StatefulSet: Deployment предназначен для взаимозаменяемых stateless-подов. StatefulSet необходим для сервисов с состоянием, требующих стабильных сетевых имен и персональных томов.
- Гарантия идентичности: Имена подов индексируются (
0, 1, 2...), поды запускаются и удаляются в строгом порядке, аvolumeClaimTemplatesпривязывает выделенный PVC к конкретному индексу. - Headless Service (
clusterIP: None): Дает Go-приложениям возможность получать прямые IP-адреса конкретных нод кластера через CoreDNS для разделения операций чтения и записи. - Go и алгоритмы консенсуса: Разработка отказоустойчивых сервисов на базе Raft (
hashicorp/raft,etcd-io/raft) опирается на стабильные DNS-имена и персистентность WAL в StatefulSet. - Осторожность с обновлениями: Обновления могут нарушить кворум. Используйте стратегию
partitionилиOnDeleteдля безопасного развертывания.
Stateful-приложения невероятно чувствительны к сетевым задержкам и разрывам связи. Если межподовая сеть начнет терять UDP/TCP пакеты, Raft-кворум расколется. В следующей главе мы досконально разберем сетевую архитектуру Kubernetes: оверлейные сети, CNI-плагины и eBPF: 7. Networking в Kubernetes.