2. Pod, Deployment, Service

Анатомия примитивов Kubernetes и Graceful Shutdown в Go

Разобрав архитектурный фундамент Kubernetes в лекции 1. Kubernetes. Архитектура, мы готовы спуститься на уровень базовых абстракций рабочей нагрузки (Workload API), с которыми бэкенд-инженер взаимодействует ежедневно.

Триада Pod, Deployment и Service — это краеугольный камень оркестрации в K8s. Понимание их взаимодействия, скрытой роли служебного контейнера pause, механики зондирования (Probes) и асинхронного выбывания из сетевой балансировки критически важно для развертывания высоконагруженных сервисов на Go с истинным нулевым временем простоя (Zero-Downtime Deployment).


1. Pod: Атомарная частица Kubernetes и контейнер Pause

В Docker базовой единицей изоляции выступает отдельный контейнер. В Kubernetes минимальной неделимой единицей планирования и развертывания является Pod (Под).

Под — это не контейнер, а группа из одного или нескольких тесно связанных контейнеров, помещенных в единую изолированную среду (Sandbox).

Все контейнеры внутри одного Пода:

  1. Разделяют общий Network Namespace: У них один общий IP-адрес, единый диапазон сетевых портов и общий сетевой стек. Контейнеры внутри пода обращаются друг к другу через интерфейс локальной петли localhost.
  2. Разделяют общий IPC Namespace: Могут взаимодействовать через высокоскоростную разделяемую память (POSIX Shared Memory, семафоры).
  3. Могут монтировать общие тома (Volumes): Например, один контейнер генерирует файлы, а sidecar-контейнер передает их в S3.

Под капотом: Роль служебного контейнера Pause

Каким образом Kubernetes гарантирует, что при аварийном падении вашего Go-бинарника сетевой IP-адрес пода не исчезнет, а открытые сетевые сокеты и правила маршрутизации останутся в сохранности?

Когда Kubelet создает Pod, он первым делом запускает скрытый инфраструктурный контейнер — pause (sandbox container):

  • Бинарник pause написан на Си и скомпилирован статически; он весит считанные килобайты.
  • Вся его задача — вызвать системный вызов pause(), который навсегда усыпляет процесс в ожидании сигналов ОС.
  • Контейнер pause создает и удерживает пространства имен Network и IPC.

Когда стартует ваш основной Go-контейнер и вспомогательные контейнеры (Sidecars, например Envoy-прокси в Service Mesh или сборщик логов Fluentbit), они запускаются с директивой объединения пространств имен (--net=container:pause). Если ваш Go-сервер рухнет с паникой и Kubelet начнет перезапуск, сетевой интерфейс пода ни на микросекунду не отключится, потому что его надежно удерживает спящий процесс pause!

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
    A["Pod - Network and IPC Namespace"]:::network --> B["Pause Container - Удерживает Netns"]
    A --> C["Go App Container - Присоединяется к Netns Pause"]
    A --> D["Sidecar Container - Логирование или Прокси"]
    C -->|"localhost:8080"| D
  

2. Deployment: Управление жизненным циклом и репликацией

В продакшен-средах «голые» поды напрямую практически никогда не создаются вручную. Если физический сервер перезагрузится, одиночный Pod погибнет навсегда.

Управление жизненным циклом делегируется контроллеру Deployment:

  1. Deployment описывает желаемое состояние: версию образа, стратегию выкатки и количество реплик.
  2. Deployment создает и контролирует дочерний объект — ReplicaSet.
  3. ReplicaSet гарантирует, что в кластере в любой момент времени запущено ровно replicas одинаковых подов.
  4. При обновлении тега образа Deployment создает второй ReplicaSet, постепенно наращивая в нем число подов и синхронно уменьшая реплики в старом ReplicaSet (Rolling Update).
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
    D["Deployment (v2)"]:::process --> RS1["ReplicaSet v1 (Уменьшение: 3 -> 0)"]
    D --> RS2["ReplicaSet v2 (Увеличение: 0 -> 3)"]
    RS1 -.-> P1["Pod v1 (Terminating)"]
    RS2 --> P2["Pod v2 (Running)"]
  

3. Пробы жизнеспособности (Probes): Симбиоз Kubelet и рантайма Go

Kubernetes не может знать внутреннее состояние вашего приложения, просто глядя на статус процесса в ОС. Процесс Go может не падать, но намертво зависнуть в бесконечном цикле или дедлоке мьютексов.

Для проверки реального здоровья Kubelet использует три типа проб:

1. startupProbe (Проба запуска)

Предназначена для медленно инициализирующихся сервисов. Если ваш Go-бэкенд при холодном старте выполняет миграции БД, прогревает тяжелые кэши или вычитывает гигабайтные справочники в память, startupProbe дает ему запас времени (например, до 2 минут). Пока startup-проба не вернет статус Success, остальные пробы даже не запускаются.

2. livenessProbe (Проба живучести)

Проверяет, способен ли процесс выполнять работу в принципе. Если liveness-проба падает (например, 3 раза подряд вернула HTTP-код 500 или таймаут), Kubelet безжалостно уничтожает Pod сигналом SIGTERM, а затем SIGKILL, и перезапускает контейнер.

3. readinessProbe (Проба готовности)

Проверяет, готов ли сервис принимать входящий пользовательский трафик в данный момент. Если readiness-проба возвращает ошибку, Kubernetes не убивает контейнер! Он просто временно исключает IP-адрес этого пода из балансировщика Service. Это идеальное место для проверки доступности БД и прогрева кэшей.


4. Service: Стабильная точка входа в мире эфемерных подов

Поскольку поды постоянно пересоздаются, их IP-адреса непрерывно меняются. Чтобы клиенты могли стабильно обращаться к пулу бэкендов, Kubernetes предоставляет абстракцию Service.

Service объединяет поды с помощью строкового селектора меток (например, selector: app=my-go-app) и выделяет виртуальный неизменяемый адрес ClusterIP (VIP):

  1. Выделяется стабильный виртуальный IP из специального диапазона кластера (ClusterIP).
  2. Внутренний DNS-сервер CoreDNS создает запись:

    Text
    my-go-app.default.svc.cluster.local
    
  3. Демоны kube-proxy настраивают правила ядра (iptables или IPVS), перехватывающие запросы на ClusterIP и транслирующие их через DNAT на реальные IP-адреса здоровых подов.

В Go-коде межсервисное взаимодействие выглядит прямолинейно:

Go
// Обращение по символическому DNS-имени Сервиса
resp, err := http.Get("http://billing-service:8080/api/charge")

Headless Service (clusterIP: None)

Если указать в манифесте clusterIP: None, сервис переходит в режим Headless (Безголовый):

  • K8s не выделяет виртуальный ClusterIP и не создает правила проксирования.
  • DNS-сервер CoreDNS при запросе к такому сервису возвращает список реальных IP-адресов всех запущенных подов (A-записи).

Это критически важно для развертывания распределенных Stateful-систем на Go (кластеры Raft, CockroachDB, Kafka, Cassandra), где узлам необходимо напрямую общаться с конкретными пирами, а не балансироваться случайным образом.


5. Самая страшная гонка: Анатомия Zero-Downtime Graceful Shutdown

Пожалуй, самая распространенная эксплуатационная ошибка, приводящая к появлению ошибок 502 Bad Gateway и Connection Refused во время деплоя, — это неправильно реализованный Graceful Shutdown в Go.

В чем суть распределенной гонки (Race Condition)?

Когда Kubernetes обновляет Deployment, для старого пода происходят два независимых асинхронных процесса:

  1. Процесс остановки пода: Kubelet отправляет главному процессу контейнера сигнал SIGTERM.
  2. Процесс обновления сети: API Server регистрирует удаление пода и рассылает события всем kube-proxy на всех воркер-нодах кластера для удаления IP-адреса пода из цепочек iptables/IPVS.
sequenceDiagram
    autonumber
    participant K8s as Control Plane (API Server)
    participant Pod as Go App Pod (Terminating)
    participant Proxy as Kube-proxy (Все ноды)
    participant Client as Другие микросервисы

    par Асинхронные потоки событий
        K8s->>Pod: 1. Отправка сигнала SIGTERM
        K8s->>Proxy: 2. Удаление Pod IP из Endpoints
    end
    Note over Pod: НАИВНЫЙ ПОДХОД: немедленный srv.Shutdown()!
    Note over Pod: Сервер закрывает слушающий порт!
    Client->>Pod: 3. Запрос по старому правилу iptables
    Pod-->>Client: ОШИБКА: Connection Refused (502 Bad Gateway)!
    Note over Proxy: Спустя 3-5 секунд: правила iptables обновлены
  

Если Go-разработчик наивно напишет:

Go
<-sigChan
srv.Shutdown(ctx) // Закрываем сокет НЕМЕДЛЕННО

то сервер мгновенно закроет слушающий порт :8080. Но в течение следующих 2–5 секунд kube-proxy на соседних серверах еще не успеет обновить таблицы iptables ядра!

Внешние балансировщики и соседние микросервисы продолжат слать запросы на старый IP-адрес, получая в ответ немедленный сброс соединения (TCP RST / Connection Refused) и выбивая ошибки пользователям.

Канонический паттерн Graceful Shutdown для Go в Kubernetes

Чтобы гарантировать 100% Zero-Downtime, Go-сервис обязан выдержать паузу тишины (Draining Delay) перед закрытием слушающего сокета:

Go
package main

import (
    "context"
    "errors"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("OK"))
    })
    mux.HandleFunc("/api/work", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(100 * time.Millisecond) // полезная работа
        w.Write([]byte("Done"))
    })

    srv := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }

    // 1. Контекст перехвата системных сигналов завершения
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
    defer stop()

    go func() {
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Fatalf("HTTP server failed: %v", err)
        }
    }()

    log.Println("Service started on :8080")
    <-ctx.Done() // Ожидаем SIGTERM от Kubernetes

    log.Println("SIGTERM received, entering network drain pause...")

    // 2. КРИТИЧЕСКИ ВАЖНО: Спим 5-10 секунд, пока Kube-proxy вычищает наш IP из всех iptables!
    // Сервер ПРОДОЛЖАЕТ принимать и обрабатывать запросы в этот период.
    time.Sleep(10 * time.Second)

    log.Println("Shutting down HTTP server gracefully...")

    // 3. Штатный Shutdown: закрываем сокет на прием новых подключений,
    // но даем активным HTTP-обработчикам 15 секунд на завершение
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Printf("Server forced to shutdown: %v", err)
    }

    log.Println("Service exited cleanly")
}

Итог

  1. Под как песочница: Контейнеры внутри Пода разделяют сеть через спящий процесс pause и общаются по localhost.
  2. Иерархия Deployment -> ReplicaSet -> Pod: Гарантирует поддержание желаемого числа реплик и плавный Rolling Update.
  3. Строгое разделение проб: startupProbe защищает медленный старт, readinessProbe управляет балансировкой трафика, а livenessProbe должна проверять только локальные дедлоки без внешних зависимостей.
  4. Service и CoreDNS: Создают стабильный ClusterIP и имя хоста для пула подов, балансируя трафик через таблицы ядра iptables/IPVS.
  5. Draining Delay при остановке: Асинхронное удаление IP из сети требует обязательной паузы (sleep 5–10с) в Go перед вызовом srv.Shutdown(), исключая ошибки 502 при обновлении кластера.

Наши сервисы запущены, защищены от сбоев и корректно завершают работу. Однако хардкодить параметры конфигурации и секретные ключи в образы недопустимо. В следующей статье мы разберем декларативное управление конфигурациями: 3. ConfigMap и Secret.