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
  

Что пойдет не так?

  1. Эфемерные имена подов: Контроллер Deployment генерирует случайные хэш-суффиксы: поды называются postgres-a1b2, postgres-c3d4. При любом рестарте или падении ноды под пересоздается с совершенно новым случайным именем. Кластер СУБД не способен понять, кто из них Master, а кто Replica, если идентификаторы непрерывно меняются.
  2. Эфемерные сетевые IP-адреса: Поды получают новые динамические IP-адреса. Реплика не знает, к кому подключаться для потоковой репликации WAL.
  3. Разделяемое дисковое хранилище (Катастрофа ФС): В спецификации 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:

  1. Стабильные детерминированные имена: Поды получают строго фиксированные имена с порядковым нулевым индексом: postgres-0, postgres-1, postgres-2. Если под postgres-1 падает или нода сгорает, Kubernetes пересоздаст под именно с именем postgres-1.
  2. Стабильное хранилище (volumeClaimTemplates): В отличие от Deployment, в StatefulSet описывается не статическая ссылка на диск, а шаблон клейма volumeClaimTemplates. Для каждого пода postgres-N создается свой персональный PVC data-postgres-N. При рестарте пода на любой другой ноде кластера Kubernetes гарантированно примонтирует к нему ровно его собственный старый диск.
  3. Строгая очередность запуска и удаления:
    Поды разворачиваются последовательно: 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):

YAML
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 мы можем явно настроить пулы соединений для записи и чтения:

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?

  1. Жесткий список пиров при инициализации: Библиотекам Raft требуется статический список адресов участников для формирования первоначального кворума. Мы передаем его через переменные окружения:
    RAFT_PEERS=raft-0.raft-hl,raft-1.raft-hl,raft-2.raft-hl.
  2. Персистентность 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. Чтобы вернуть кластер к жизни, инженеру приходится:

  1. Физически удостовериться, что старый сервер выключен.
  2. Принудительно удалить под командой:
    kubectl delete pod postgres-1 --force --grace-period=0.
  3. В облачной панели принудительно отсоединить том от зависшей виртуальной машины.

Резюме

  1. Deployment vs StatefulSet: Deployment предназначен для взаимозаменяемых stateless-подов. StatefulSet необходим для сервисов с состоянием, требующих стабильных сетевых имен и персональных томов.
  2. Гарантия идентичности: Имена подов индексируются (0, 1, 2...), поды запускаются и удаляются в строгом порядке, а volumeClaimTemplates привязывает выделенный PVC к конкретному индексу.
  3. Headless Service (clusterIP: None): Дает Go-приложениям возможность получать прямые IP-адреса конкретных нод кластера через CoreDNS для разделения операций чтения и записи.
  4. Go и алгоритмы консенсуса: Разработка отказоустойчивых сервисов на базе Raft (hashicorp/raft, etcd-io/raft) опирается на стабильные DNS-имена и персистентность WAL в StatefulSet.
  5. Осторожность с обновлениями: Обновления могут нарушить кворум. Используйте стратегию partition или OnDelete для безопасного развертывания.

Stateful-приложения невероятно чувствительны к сетевым задержкам и разрывам связи. Если межподовая сеть начнет терять UDP/TCP пакеты, Raft-кворум расколется. В следующей главе мы досконально разберем сетевую архитектуру Kubernetes: оверлейные сети, CNI-плагины и eBPF: 7. Networking в Kubernetes.