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:

YAML
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!
  

Разберем эту анатомию детально:

  1. Сетевой путь: Контроллер EndpointSlice исключает IP-адрес пода из списка активных эндпоинтов сервиса. Демон kube-proxy на каждой ноде кластера считывает это изменение и перестраивает цепочки iptables (или таблицы хэшей IPVS). В крупных кластерах с сотнями нод распространение этой информации по сети занимает от 1 до 5 секунд.
  2. Путь исполнения (kubelet): Локальный агент kubelet получает команду на уничтожение пода и немедленно посылает корневому процессу контейнера сигнал ядра SIGTERM.

Решение 1: preStop Hook — исцеляющая задержка

Для компенсации сетевого лага в спецификацию контейнера добавляют хук жизненного цикла preStop:

YAML
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

После того как сетевой трафик отведен, приложение должно корректно завершить работу:

  1. Прекратить прием новых TCP-соединений (закрыть сокет net.Listener).
  2. Дождаться завершения всех уже выполняющихся HTTP/gRPC запросов.
  3. Корректно закрыть соединения с брокерами очередей (Kafka, RabbitMQ) и базой данных.
  4. Уложиться в жесткий лимит времени, выделенный Kubernetes (terminationGracePeriodSeconds).

Для этого в стандартной библиотеке Go используется метод server.Shutdown():

Go
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: его невозможно перехватить в коде или проигнорировать — процесс будет мгновенно стерт из памяти с риском повреждения данных.

Решение: Если вашим горутинам требуется больше времени на завершение, обязательно увеличьте лимит в манифесте:

YAML
spec:
  terminationGracePeriodSeconds: 120

Вопросы для собеседования


Резюме

  1. Математика релиза: Параметры maxSurge и maxUnavailable задают баланс между скоростью обновления и запасом отказоустойчивости кластера.
  2. Параллельная асинхронность: Маршрутизация трафика отключается независимо от жизненного цикла контейнера. Используйте preStop hook (или таймер в коде) для компенсации сетевого лага iptables.
  3. Graceful Shutdown на Go: Использование http.Server.Shutdown(ctx) в связке с перехватом сигналов SIGTERM и SIGINT — закон для production-сервисов.
  4. Readiness Probes критичны: Без настроенной проверки готовности плавное обновление превращается в лотерею с гарантированным выпадением 503 Service Unavailable.
  5. Expand-Contract для БД: Помните, что во время Rolling Update старый и новый код работают одновременно. Миграции базы данных обязаны сохранять обратную совместимость.

Мы научились разворачивать, масштабировать и бесшовно обновлять наш микросервис. Но теперь кластер превратился в сложнейший распределенный механизм: десятки взаимосвязанных сервисов, тысячи подов, динамические IP-адреса и непредсказуемая виртуальная сеть. Если где-то начнет деградировать база данных, расти задержка ответов или течь память в одном из 100 подов — как мы узнаем об этом до того, как упадет вся система? В следующей лекции мы откроем глаза нашей инфраструктуре: 7. Observability в Kubernetes.