6. Rolling updates
Пересборка двигателя в полете: Релиз без права на ошибку
«Если вы не можете обновить систему без остановки обслуживания пользователей, значит, в вашей архитектуре заложена скрытая зависимость от времени простоя».
— Принцип непрерывной доступности
В эпоху монолитных систем релиз новой версии программного обеспечения был драматическим событием, к которому инженерные отделы готовились неделями. Назначались ночные «технические окна» по выходным, серверы вручную выводились из балансировки, демоны останавливались, администраторы копировали новые бинарники по SSH, накатывали скрипты миграций и с замиранием сердца перезапускали сервисы. Даунтайм (Downtime) считался неизбежным злом.
В мире микросервисов, высокой конкуренции и непрерывной доставки (CI/CD) распределенные системы развертывают новые версии кода десятки раз в сутки прямо посреди рабочего дня. Делать это с прерыванием обслуживания — непозволительная роскошь: даже секундная пауза под нагрузкой в 20 000 RPS обернется десятками тысяч оборванных клиентских корзин, финансовыми потерями и падением репутации сервиса.
В платформе Kubernetes стандартным механизмом обновления без простоя является Rolling Update (Постепенное обновление) — стратегия по умолчанию для контроллера Deployment.
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
subgraph Cluster["Kubernetes Deployment: Rolling Update в динамике"]
direction TB
subgraph Step1["1. Старт релиза"]
V1_1["Pod v1 (Active)"]
V1_2["Pod v1 (Active)"]
V1_3["Pod v1 (Active)"]
end
subgraph Step2["2. Выкатка v2"]
V2_1["Pod v2 (Starting...)"]
V1_2b["Pod v1 (Active)"]
V1_3b["Pod v1 (Active)"]
end
subgraph Step3["3. Завершение"]
V2_1c["Pod v2 (Active)"]
V2_2c["Pod v2 (Active)"]
V2_3c["Pod v2 (Active)"]
end
end
Step1 ==> Step2 ==> Step3
В этой лекции мы разберем математику безопасного релиза, исследуем скрытое состояние гонки между ядром Linux (iptables), сетевым стеком и планировщиком при удалении подов, а также научимся реализовывать образцовый Graceful Shutdown на Go, гарантирующий, что ни один пользовательский запрос не будет сброшен по таймауту или Connection refused.
Анатомия Rolling Update
Когда вы обновляете тег Docker-образа в манифесте Deployment (например, переходите с myapp:v1 на myapp:v2), Kubernetes не уничтожает старые инстансы разом. Контроллер разворачивает изящный алгоритмический танец между двумя объектами ReplicaSet:
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 Controller\n(kube-controller-manager)"]:::process -->|"Управляет старой версией"| RS1["ReplicaSet V1\nmyapp:v1"]
D -->|"Управляет новой версией"| RS2["ReplicaSet V2\nmyapp:v2"]
RS1 -.->|"Scale Down: -1 Pod"| P1["Pod V1 (myapp:v1)"]
RS2 -.->|"Scale Up: +1 Pod"| P2["Pod V2 (myapp:v2)"]
P2 -->|"Проверка готовности"| Ready{"Readiness Probe\nУспешна?"}
Ready -->|"Да (статус Ready)"| Drain["Начать вывод из эксплуатации Pod V1"]
Ready -->|"Нет / Сбой"| Halt["Приостановить выкатку\nЗащита от сбоя"]
Процесс находится под непрерывным надзором контроллера Deployment в составе kube-controller-manager. Он синхронно увеличивает число желаемых реплик в ReplicaSet V2 и уменьшает их в ReplicaSet V1, контролируя, чтобы суммарное число доступных инстансов всегда оставалось в безопасных границах.
Математика безопасности: maxSurge и maxUnavailable
Скорость, агрессивность и ресурсоемкость процесса обновления регулируются двумя взаимосвязанными параметрами в спецификации Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-deployment
spec:
replicas: 100
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
Оба параметра могут задаваться как в абсолютных целых числах (подах), так и в процентах от желаемого числа replicas:
maxUnavailable(Максимальная недоступность): Какое количество подов от целевого значения может быть недоступно во время обновления.
Приreplicas: 100иmaxUnavailable: 25%контроллер имеет право немедленно остановить до 25 старых подов, оставив 75 подов для обслуживания входящего трафика. Это снижает вычислительную мощность пула на 25%, но экономит ресурсы нод.maxSurge(Максимальное превышение): На сколько подов общее количество запущенных контейнеров может временно превысить базовое значениеreplicas.
Приreplicas: 100иmaxSurge: 25%кластер может создать до 25 новых подов сверх нормы, доводя общее число активных инстансов в пике до 125 подов.
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 Calc["Математика Rolling Update (100 реплик, Surge 25%, Unavailable 25%)"]
R["Желаемые реплики: 100"]:::process
Min["Минимум доступных: 100 - 25 = 75 подов"]:::process
Max["Максимум в кластере: 100 + 25 = 125 подов"]:::process
R --> Min
R --> Max
end
Mechanical Sympathy: Смерть пода и сетевой лаг
Самая коварная ловушка Rolling Update кроется в асинхронной распределенной природе Kubernetes.
Когда kube-controller-manager принимает решение вывести старый Pod из эксплуатации, в системе стартуют два параллельных и совершенно независимых процесса:
sequenceDiagram
autonumber
actor Client as Входящий клиент
participant API as kube-apiserver
participant EPC as EndpointSlice Controller
participant KP as kube-proxy (iptables)
participant KL as kubelet
participant App as Go Приложение
Note over API: Начало удаления Pod (DeletionTimestamp)
par Сетевая подсистема (Асинхронно!)
API->>EPC: Pod удаляется
EPC->>KP: Обновить Endpoints / EndpointSlice
KP->>KP: Пересборка правил iptables / IPVS
Note over KP: Лаг: 1-5 секунд на всех нодах!
and Жизненный цикл контейнера (Асинхронно!)
API->>KL: Остановить контейнер
KL->>App: Отправка сигнала SIGTERM!
end
App->>App: Если выйти сразу: Socket Closed!
Client->>KP: HTTP запрос (правило iptables еще указывает на старый Pod)
KP->>App: Пакет TCP SYN -> В закрытый сокет!
App-->>Client: 💥 TCP RST: Connection refused!
Разберем эту анатомию детально:
- Сетевой путь: Контроллер
EndpointSliceисключает IP-адрес пода из списка активных эндпоинтов сервиса. Демонkube-proxyна каждой ноде кластера считывает это изменение и перестраивает цепочкиiptables(или таблицы хэшей IPVS). В крупных кластерах с сотнями нод распространение этой информации по сети занимает от 1 до 5 секунд. - Путь исполнения (kubelet): Локальный агент
kubeletполучает команду на уничтожение пода и немедленно посылает корневому процессу контейнера сигнал ядраSIGTERM.
Решение 1: preStop Hook — исцеляющая задержка
Для компенсации сетевого лага в спецификацию контейнера добавляют хук жизненного цикла preStop:
spec:
containers:
- name: go-app
image: myapp:v2
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
(Для минималистичных Docker-образов на базе scratch, где отсутствует утилита /bin/sh, задержку можно организовать непосредственно в коде Go: перехватить SIGTERM, подождать 5 секунд и лишь затем закрывать сетевой listener).
Решение 2: Идиоматичный Graceful Shutdown на Go
После того как сетевой трафик отведен, приложение должно корректно завершить работу:
- Прекратить прием новых TCP-соединений (закрыть сокет
net.Listener). - Дождаться завершения всех уже выполняющихся HTTP/gRPC запросов.
- Корректно закрыть соединения с брокерами очередей (Kafka, RabbitMQ) и базой данных.
- Уложиться в жесткий лимит времени, выделенный Kubernetes (
terminationGracePeriodSeconds).
Для этого в стандартной библиотеке Go используется метод server.Shutdown():
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/checkout", func(w http.ResponseWriter, r *http.Request) {
// Имитация длительной бизнес-транзакции
time.Sleep(2 * time.Second)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"order_processed"}`))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
// 1. Канал для перехвата сигналов операционной системы
quit := make(chan os.Signal, 1)
// Подписываемся на SIGINT (Ctrl+C локально) и SIGTERM (от Kubelet в кластере)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// 2. Запуск сервера в отдельной неблокирующей горутине
go func() {
slog.Info("Server is listening for requests", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("ListenAndServe encountered a fatal error", "err", err)
os.Exit(1)
}
}()
// 3. Блокируемся и ждем сигнала завершения от ОС
sig := <-quit
slog.Info("Signal received, initiating graceful shutdown", "signal", sig.String())
// 4. Опционально (если образ scratch и нет preStop):
// time.Sleep(5 * time.Second) // Даем kube-proxy время обновить iptables
// 5. Создаем контекст с жестким таймаутом на доработку текущих соединений.
// Этот таймаут должен быть меньше, чем terminationGracePeriodSeconds в K8s!
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
// 6. Вызов server.Shutdown():
// - Немедленно закрывает все открытые net.Listener (отклоняет новые TCP SYN)
// - Закрывает все простаивающие (idle) Keep-Alive соединения
// - Блокирует поток исполнения, ожидая, пока активные горутины запросов завершатся
if err := srv.Shutdown(ctx); err != nil {
slog.Error("Server forced to shutdown by timeout", "err", err)
os.Exit(1)
}
slog.Info("All active HTTP requests processed. Server successfully stopped.")
}
Архитектурные ловушки
1. Отсутствие Readiness Probes
Контроллер Rolling Update полностью опирается на статусы подов. Если в манифесте отсутствует Readiness Probe (см. 6. Health checks), Kubernetes посчитает Pod готовым (Ready) ровно в ту миллисекунду, когда процесс запустится в контейнере.
Если вашему Go-приложению требуется 5–10 секунд на прогрев кэша, подключение к базам данных и подгрузку конфигурации, кластер не будет ждать. Он посчитает новый Pod работоспособным, мгновенно убьет старые рабочие поды и направит весь пользовательский поток на неподготовленные инстансы. Результат — массовый обвал в 503 Service Unavailable.
2. Слом контрактов БД (Backward Incompatibility)
Вы провели идеальный релиз без потери сетевых пакетов. Но ваш сервис работает с общей базой данных.
Представьте сценарий:
- Версия 1 (
myapp:v1) ожидает, что данные пользователя лежат в одной колонкеfull_name. - Версия 2 (
myapp:v2) разбивает имя на две колонкиfirst_nameиlast_name.
В процессе Rolling Update (который под нагрузкой может длиться от нескольких минут до часа) myapp:v1 и myapp:v2 работают бок о бок одновременно!
Если myapp:v2 выполнит деструктивную миграцию (DROP COLUMN full_name), работающие инстансы myapp:v1 моментально упадут с ошибками выполнения SQL-запросов.
Инженерное правило: Всегда строго соблюдайте обратную совместимость (см. 7. Backward compatibility). Применяйте паттерн Expand-Contract (Расширение — Сжатие): сначала добавляйте новые колонки (first_name, last_name), поддерживая запись в обе структуры, и лишь через несколько релизов удаляйте устаревшую колонку full_name.
3. Слишком короткий terminationGracePeriodSeconds
По умолчанию Kubernetes выделяет контейнеру ровно 30 секунд на всю процедуру остановки (включая работу хука preStop и вызов http.Server.Shutdown(ctx)).
Если у вас запущен фоновый воркер, выполняющий тяжелую транзакцию обработки видео или генерации отчета длительностью 2 минуты, то ровно через 30 секунд kubelet потеряет терпение и пошлет процесс-убийцу SIGKILL. Сигнал SIGKILL обрабатывается исключительно ядром Linux: его невозможно перехватить в коде или проигнорировать — процесс будет мгновенно стерт из памяти с риском повреждения данных.
Решение: Если вашим горутинам требуется больше времени на завершение, обязательно увеличьте лимит в манифесте:
spec:
terminationGracePeriodSeconds: 120
Вопросы для собеседования
Резюме
- Математика релиза: Параметры
maxSurgeиmaxUnavailableзадают баланс между скоростью обновления и запасом отказоустойчивости кластера. - Параллельная асинхронность: Маршрутизация трафика отключается независимо от жизненного цикла контейнера. Используйте
preStophook (или таймер в коде) для компенсации сетевого лагаiptables. - Graceful Shutdown на Go: Использование
http.Server.Shutdown(ctx)в связке с перехватом сигналовSIGTERMиSIGINT— закон для production-сервисов. - Readiness Probes критичны: Без настроенной проверки готовности плавное обновление превращается в лотерею с гарантированным выпадением
503 Service Unavailable. - Expand-Contract для БД: Помните, что во время Rolling Update старый и новый код работают одновременно. Миграции базы данных обязаны сохранять обратную совместимость.
Мы научились разворачивать, масштабировать и бесшовно обновлять наш микросервис. Но теперь кластер превратился в сложнейший распределенный механизм: десятки взаимосвязанных сервисов, тысячи подов, динамические IP-адреса и непредсказуемая виртуальная сеть. Если где-то начнет деградировать база данных, расти задержка ответов или течь память в одном из 100 подов — как мы узнаем об этом до того, как упадет вся система? В следующей лекции мы откроем глаза нашей инфраструктуре: 7. Observability в Kubernetes.