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).
Все контейнеры внутри одного Пода:
- Разделяют общий Network Namespace: У них один общий IP-адрес, единый диапазон сетевых портов и общий сетевой стек. Контейнеры внутри пода обращаются друг к другу через интерфейс локальной петли
localhost. - Разделяют общий IPC Namespace: Могут взаимодействовать через высокоскоростную разделяемую память (POSIX Shared Memory, семафоры).
- Могут монтировать общие тома (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:
- Deployment описывает желаемое состояние: версию образа, стратегию выкатки и количество реплик.
- Deployment создает и контролирует дочерний объект —
ReplicaSet. - ReplicaSet гарантирует, что в кластере в любой момент времени запущено ровно
replicasодинаковых подов. - При обновлении тега образа 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):
- Выделяется стабильный виртуальный IP из специального диапазона кластера (ClusterIP).
-
Внутренний DNS-сервер CoreDNS создает запись:
Text my-go-app.default.svc.cluster.local -
Демоны
kube-proxyнастраивают правила ядра (iptablesилиIPVS), перехватывающие запросы на ClusterIP и транслирующие их через DNAT на реальные IP-адреса здоровых подов.
В 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, для старого пода происходят два независимых асинхронных процесса:
- Процесс остановки пода: Kubelet отправляет главному процессу контейнера сигнал
SIGTERM. - Процесс обновления сети: 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-разработчик наивно напишет:
<-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) перед закрытием слушающего сокета:
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")
}
Итог
- Под как песочница: Контейнеры внутри Пода разделяют сеть через спящий процесс
pauseи общаются поlocalhost. - Иерархия Deployment -> ReplicaSet -> Pod: Гарантирует поддержание желаемого числа реплик и плавный Rolling Update.
- Строгое разделение проб:
startupProbeзащищает медленный старт,readinessProbeуправляет балансировкой трафика, аlivenessProbeдолжна проверять только локальные дедлоки без внешних зависимостей. - Service и CoreDNS: Создают стабильный ClusterIP и имя хоста для пула подов, балансируя трафик через таблицы ядра
iptables/IPVS. - Draining Delay при остановке: Асинхронное удаление IP из сети требует обязательной паузы (sleep 5–10с) в Go перед вызовом
srv.Shutdown(), исключая ошибки 502 при обновлении кластера.
Наши сервисы запущены, защищены от сбоев и корректно завершают работу. Однако хардкодить параметры конфигурации и секретные ключи в образы недопустимо. В следующей статье мы разберем декларативное управление конфигурациями: 3. ConfigMap и Secret.