2. Rolling updates
Анатомия плавного обновления без даунтайма
В предыдущей статье (1. CI/CD и деплой) мы построили конвейер непрерывной интеграции, собирающий кристально чистые Docker-образы и доставляющий свежие манифесты в кластер Kubernetes. Но что физически происходит внутри серверов в момент, когда новый манифест применяется контроллером?
Наивный подход к развертыванию — стратегия Recreate: выключить старую версию сервиса, дождаться ее полного завершения и запустить новую. В эпоху монолитов на выделенных серверах это было привычным делом: пользователям показывали заглушку «Технические работы на 15 минут». потерей корзин покупателей и разорванными банковскими транзакциями.
Индустриальным стандартом для обновления stateless-приложений в Kubernetes является стратегия Rolling Update (Плавное скользящее обновление). На бумаге она выглядит элементарно: по очереди заменяем один старый под на один новый. Но за этой видимой простотой скрываются сложнейшие гонки сетевого стека ядра Linux, о которых обязан знать каждый бэкенд-инженер.
1. Как работает Rolling Update под капотом
Фундаментальный принцип Kubernetes гласит: Поды иммутабельны (неизменяемы). Вы не можете «на лету» заменить бинарный файл внутри работающего контейнера или подменить его образ.
Когда вы обновляете образ контейнера в объекте Deployment, контроллер kube-controller-manager не модифицирует существующие поды. Вместо этого он создает совершенно новый объект ReplicaSet, который начинает постепенно разворачивать поды версии V2, одновременно отдавая команды на плавное сворачивание старого ReplicaSet версии V1.
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 Deployment["Deployment: go-api (v1 -> v2)"]
RS1["ReplicaSet V1\n(Старая версия: 4 -> 3 -> 0)"]
RS2["ReplicaSet V2\n(Новая версия: 0 -> 1 -> 4)"]
end
RS1 --> PodOld1["Pod V1"]
RS1 --> PodOld2["Pod V1"]
RS2 --> PodNew1["Pod V2 (Ready)"]
RS2 --> PodNew2["Pod V2 (Creating)"]
Темпом и безопасностью этой рокировки управляют два критически важных параметра в блоке strategy.rollingUpdate:
maxSurge: Сколько подов разрешено создать сверх целевого количества реплик на время обновления (в абсолютных числах или процентах).maxUnavailable: Сколько старых подов разрешено вывести из строя (сделать недоступными) в процессе деплоя.
apiVersion: apps/v1
kind: Deployment
metadata:
name: go-api
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25% # Разрешено создать +1 под сверх лимита (25% от 4)
maxUnavailable: 0 # Строгий запрет на уменьшение доступных подов!
Математика Rolling Update
Представьте кластер под высокой нагрузкой: replicas: 4, maxSurge: 1 (25%), maxUnavailable: 0.
Параметр maxUnavailable: 0 дает железную гарантию: в любой момент времени кластер обязан иметь не менее 4 полностью работоспособных подов. Параметр maxSurge: 1 ограничивает максимальный размер пула не более чем 5 подов одновременно.
sequenceDiagram
autonumber
participant K8s as Deployment Controller
participant RS1 as ReplicaSet V1 (Old)
participant RS2 as ReplicaSet V2 (New)
participant Svc as Endpoints / Service
Note over RS1: Работают 4 пода V1 (нагрузка 100%)
K8s->>RS2: Создать 1 новый Pod V2 (всего 5 подов в кластере)
RS2-->>Svc: Pod V2 проходит Readiness Probe -> Добавлен в Endpoints!
K8s->>RS1: Удалить 1 старый Pod V1 (осталось 4 пода: 3 старых + 1 новый)
Note over K8s: Повторение цикла для оставшихся 3 подов...
Note over RS2: Работают 4 пода V2 (Деплой завершен без потери емкости!)
- Контроллер отдает команду
ReplicaSet V2создать 1 новый под. В кластере становится 5 подов (4 старых + 1 новый). - Новый под стартует, инициализирует Go-рантайм и успешно отвечает на
Readiness Probe. Только после этого контроллер включает его IP в списокEndpointsсервиса. - Контроллер отдает команду
ReplicaSet V1уничтожить 1 старый под. В кластере снова 4 пода (3 старых, 1 новый). - Шаги циклически повторяются до тех пор, пока
ReplicaSet V1не сожмется до 0, аReplicaSet V2не вырастет до 4.
2. Самая страшная сетевая гонка: Откуда берутся 502 ошибки?
Большинство разработчиков считают: «Если я выставил maxUnavailable: 0 и реализовал graceful shutdown в Go, потеря трафика математически невозможна». Это опаснейшая иллюзия.
Дело в том, что Kubernetes — это асинхронная распределенная система. В ней нет глобальной распределенной транзакции, которая могла бы одновременно сказать и приложению: «Завершайся», и сетевой подсистеме: «Перестань слать туда трафик».
Когда планировщик решает уничтожить старый под, запускаются два совершенно независимых параллельных процесса:
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
DeleteEvent["K8s решает удалить Pod"]:::process
subgraph AppTrack["Ветка приложения (Локальная нода)"]
Kubelet["Kubelet шлет SIGTERM Go-процессу"]:::process
GoProc["Go-сервер получает сигнал и закрывает сокет"]:::network
AppTrackEnd["💥 Сервер больше не слушает порт!"]:::network
Kubelet --> GoProc --> AppTrackEnd
end
subgraph NetTrack["Сетевая ветка (Все ноды кластера)"]
APIServer["API Server обновляет Endpoints"]:::network
KubeProxy["Kube-proxy на 50 нодах замечает событие"]:::network
IPTables["Пересчет и запись правил iptables / IPVS"]:::process
NetTrackEnd["Трафик прекращает поступать на старый IP"]:::process
APIServer --> KubeProxy --> IPTables --> NetTrackEnd
end
DeleteEvent --> AppTrack
DeleteEvent --> NetTrack
Спасительный рецепт: preStop Hook + Плавное завершение
Чтобы победить эту сетевую гонку, мы обязаны искусственно задержать смерть Go-процесса, дав сетевому стеку Linux фору на вычищение таблиц маршрутизации.
1. Спецификация пода в манифесте Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: go-api
spec:
template:
spec:
containers:
- name: api
image: registry.example.com/go-api:v2.0.0
lifecycle:
preStop:
exec:
# КРИТИЧЕСКИ ВАЖНО: спим 10 секунд, пока iptables обновляется на всех нодах!
command: ["/bin/sh", "-c", "sleep 10"]
# Даем достаточно времени на preStop (10с) + честный Go shutdown (15с)
terminationGracePeriodSeconds: 30
2. Промышленная реализация Graceful Shutdown в Go:
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/ping", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("pong"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Запускаем сервер в фоновой горутине
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen error: %v", err)
}
}()
// Подписываемся на сигналы завершения ОС
// ВАЖНО: SIGTERM придет в контейнер только ПОСЛЕ того, как отработает preStop hook (sleep 10)!
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
<-ctx.Done() // Блокируемся до получения сигнала от Kubelet
log.Println("Получен сигнал завершения. Начинаем плавную остановку...")
// Создаем контекст с таймаутом на доработку активных запросов
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Shutdown перестает принимать новые соединения и ждет завершения текущих горутин
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("Принудительное завершение сервера по таймауту: %v", err)
} else {
log.Println("Все активные запросы успешно завершены. Сервер чисто остановлен.")
}
}
Благодаря связке preStop: sleep 10 и srv.Shutdown() сетевые таблицы гарантированно обновятся до того, как Go-сервер закроет сокет. Ноль потерянных клиентских запросов!
3. Mechanical Sympathy: Ресурсный голод при обновлении
Параметр maxSurge требует свободных аппаратных ресурсов. Если у вас было 4 пода с запросом requests: cpu: "2", то в момент обновления maxSurge: 1 потребует от кластера суммарно не 8, а 10 ядер CPU!
Если все физические серверы в кластере уже утилизированы на 100%, планировщик Kubernetes (kube-scheduler) не сможет назначить новый под ни на один узел. Под застрянет в мучительном статусе Pending.
Что произойдет с деплоем? Он намертво зависнет!
- Старые поды не будут удаляться (ведь действует директива
maxUnavailable: 0). - Новый под не может запуститься (не хватает процессорных ресурсов).
- Инженеры смотрят на зависший CI/CD пайплайн.
4. Rollback: Мгновенный откат при сбоях
Даже самый качественный CI-пайплайн с тестами и линтерами не гарантирует 100% отсутствие багов. Что делать, если новая версия Go-бинарника падает с паникой из-за непредвиденного формата входящих данных?
Автоматическая защита Kubernetes
Если новые поды версии V2 падают при старте или не могут пройти Readiness Probe, Deployment Controller немедленно останавливает обновление! Он никогда не убьет оставшиеся здоровые поды версии V1. Кластер продолжит обслуживать трафик на старой стабильной версии.
Ручной откат в один клик
Kubernetes бережно сохраняет историю предыдущих объектов ReplicaSet (по умолчанию глубина истории revisionHistoryLimit: 10). Для мгновенного отката на предыдущую версию достаточно выполнить команду:
kubectl rollout undo deployment/go-api
Kubernetes мгновенно запустит обратный процесс Rolling Update. Поскольку образы старой стабильной версии уже закэшированы на дисках серверов, откат происходит за считанные секунды без необходимости повторной сборки и скачивания образов.
Резюме
- Иммутабельность подов: Rolling Update обновляет приложение не модификацией контейнеров, а плавным созданием нового ReplicaSet параллельно со сворачиванием старого.
- Параметры
maxSurgeиmaxUnavailable: Для критических публичных API всегда используйтеmaxUnavailable: 0, чтобы не допустить просадки пропускной способности кластера. - Победа над 502 ошибками: Сетевые таблицы
iptables/IPVSобновляются с задержкой в несколько секунд. Для ликвидации гонки соединений обязательно используйтеpreStop: sleep 10в связке сsrv.Shutdown()в Go. - Ресурсный баланс: Всегда учитывайте потребность
maxSurgeв свободных мощностях кластера или настраивайте Cluster Autoscaler для динамического заказа нод при зависании подов вPending. - Rollback: Защита от сбоев встроена в K8s по умолчанию, а команда
kubectl rollout undoобеспечивает мгновенный возврат к предыдущей рабочей ревизии.
Стратегия Rolling Update идеальна, когда новая версия обратно совместима со старой. Но что делать, если вы кардинально переписали API, изменили контракты данных или хотите проверить новую версию всего на 5% пользователей перед полным релизом? В следующей статье мы разберем продвинутые техники: 3. Canary и blue green deploy.