Привет, Хабр!

Релиз — это всегда как шаг по канату над пропастью: с одной стороны, нас ждут крутые новые фичи, с другой — опасность сломать то, что уже работает. И все мы знаем, как больно бывает, когда что-то идёт не так.

Сегодня поговорим о том, как выпускать обновления так, чтобы ни одна строчка старого кода не упала жертвой прогресса.

Принципы предотвращающие поломку старого кода

Не изменяйте существующие интерфейсы, если это возможно

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

Пример на Go:

// старый метод:
func GetUser(id int) (User, error) {
    // получение пользователя
}

// новый метод, расширяющий старую функциональность:
func GetUserV2(id int, includeInactive bool) (User, error) {
    // получение пользователя с дополнительным параметром
}

Это позволяет старым вызовам продолжать работать, а новые пользователи могут воспользоваться доп. фичами.

Используйте семантическое версионирование

Семантическое версионирование помогает обозначить изменения в коде. Основное правило: изменения, которые ломают обратную совместимость, требуют увеличения первой цифры версии. Минорные изменения и исправления ошибок не должны нарушать совместимость с предыдущими версиями.

1.0.0 → 1.1.0 → 1.1.1 (совместимы)
2.0.0 (несовместимая версия)

Версионирование API и библиотек

Чтобы избежать поломки у клиентов, используйте подход с несколькими версиями API. При выпуске новой версии оставляйте старую доступной для тех, кто ещё не готов к миграции.

Пример REST API:

GET /api/v1/users
GET /api/v2/users

Не спешите удалять устаревшие функции. Вместо этого объявите их deprecated, предоставив пользователям возможность мигрировать на новые версии с заранее обозначенным сроком поддержки старой версии.

Стратегии постепенного внедрения изменений

Feature toggles

Feature toggles — это техника управления функциональностью, при которой новые фичи могут быть включены или отключены через конфигурацию, без необходимости изменения кода или повторного развертывания приложения.

Переключатели функций реализуются как логические условия в коде, определяющие, должна ли быть активирована новая функция для конкретного пользователя или группы пользователей. Например, на уровне приложения это может выглядеть как простой условный оператор:

Пример на Java:

if (FeatureToggle.isEnabled("new_feature")) {
    // включение новой функциональности
} else {
    // выполнение старого кода
}

Feature toggles позволяют:

  • Включать/выключать фичи в реальном времени без развертывания новой версии.

  • Ограничивать доступ к новым функциям для конкретных групп пользователей, к слову хорошо для того же A/B теста.

  • Избегать больших релизов с риском поломки, разделяя работу на более мелкие итерации.

Виды Feature toggles:

  • Release toggles: используются для выпуска функций, которые ещё не готовы к массовому использованию, но находятся в стадии разработки. Функция может быть включена позже.

  • Ops toggles: используются для оперативного управления фичами в продакшене, например, для быстрого выключения функционала в случае проблем.

  • Experiment toggles: применяются для тестирования функций на отдельных группах пользователей.

Динамическое управление Feature Toggles через конфигурацию позволяет переключать функциональность без внесения изменений в кодовую базу или повторного развертывания приложения.

Пример реализации:

Создадим файл конфигурации config.yaml, где будем управлять состоянием фичи:

feature_toggles:
  new_feature: true
  experimental_feature: false

Напишем код для динамической загрузки конфигурации и использования Feature Toggles:

package main

import (
    "fmt"
    "gopkg.in/yaml.v2"
    "io/ioutil"
)

type Config struct {
    FeatureToggles map[string]bool `yaml:"feature_toggles"`
}

func loadConfig(filename string) (*Config, error) {
    data, err := ioutil.ReadFile(filename)
    if err != nil {
        return nil, err
    }

    var config Config
    err = yaml.Unmarshal(data, &config)
    if err != nil {
        return nil, err
    }

    return &config, nil
}

func main() {
    config, err := loadConfig("config.yaml")
    if err != nil {
        panic(err)
    }

    if config.FeatureToggles["new_feature"] {
        fmt.Println("New feature is enabled")
        // Активируем новую функциональность
    } else {
        fmt.Println("Old feature is running")
        // Оставляем старую функциональность
    }
}

Теперь, изменяя состояние фичи в файле config.yaml, можно включать или выключать её без необходимости изменения кода или перезапуска приложения.

Blue-green deployment

Blue-green deployment — это стратегия развертывания, при которой есть две версии приложения: одна синяя, blue — работающая в продакшене, и вторая зелёная, green — новая версия, которая готовится к релизу. На момент развертывания трафик переключается с текущей синей версии на новую зелёную.

Процесс развертывания:

  1. Синяя среда (blue) — текущая версия приложения, работающая в продакшене.

  2. Зелёная среда (green) — новая версия, которая развёрнута параллельно и готовится к тестированию.

  3. После успешного тестирования на зелёной среде, трафик пользователей перенаправляется на неё. Если с новой версией всё в порядке, зелёная среда становится основной.

  4. Rollback: если обнаруживаются проблемы, трафик можно быстро вернуть на старую версию blue.

Допустим, если приложение работает на сервере с версией 1.0 (синяя среда). Можно развернуть версию 2.0 на новом сервере и проверить её работоспособность. Если всё работает корректно, то перенаправляем весь трафик с синей на зелёную среду, без какого-либо простоя.

Для реализации этой стратегии используют Kubernetes, Spinnaker, или Terraform.

Canary releases

Canary release — это стратегия, при которой новая версия приложения выкатывается только для небольшой части пользователей. Если с новой версией всё в порядке, процент пользователей постепенно увеличивается, и она становится доступна всем.

Название «канареечные релизы» пришло от практики использования канареек в шахтах для предупреждения о вредных газах — если канарейка умирает, шахтёры знают о наличии опасности (бедные канарейки).

Шахтер с канарейкой
Шахтер с канарейкой

Аналогично, у нас канареечные релизы помогают выявить проблемы на небольшом количестве пользователей до того, как они затронут всех.

Процесс развертывания:

  1. Первая фаза: новая версия выкатывается для небольшой части аудитории, например, 5%. В это время команда наблюдает за метриками, производительностью и отзывами пользователей.

  2. Увеличение трафика: если не возникает серьёзных проблем, новая версия постепенно распространяется на большую часть пользователей, например, 25%, 50%, и так далее.

  3. Полный релиз: если на всех этапах всё проходит без сбоев, новая версия становится основной для всех пользователей.

Пример на Kubernetes с Istio:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
spec:
  hosts:
  - my-app
  http:
  - route:
    - destination:
        host: my-app
        subset: v2
      weight: 5   # 5% пользователей получают новую версию
    - destination:
        host: my-app
        subset: v1
      weight: 95  # 95% пользователей остаются на старой версии

Главный риск таких релизов — если ошибка возникнет в новой версии, она может затронуть даже небольшую аудиторию. Поэтому важно мониторить ключевые метрики и автоматически сворачивать релиз, если что-то идёт не так.

Типы тестов для проверки обратной совместимости

Регрессионные тесты

Регрессионное тестирование — это процесс повторного запуска старых тестов на изменённой системе для проверки того, что недавние изменения не нарушили работоспособность уже существующего функционала.

Основные цели регрессионных тестов:

  • Проверка того, что новые фичи не затронули старый функционал.

  • Убедиться, что исправление одного бага не привело к появлению другого.

Пример теста на JUnit (Java):

@Test
public void testOldFunctionality() {
    User user = userService.getUserById(1);
    assertNotNull(user);
    assertEquals("John", user.getName());
}

Так можно удостовериться, что старые функции, такие как getUserById, продолжают корректно работать после внесения изменений в код.

Когда использовать:

  • После каждого изменения в коде.

  • Когда добавляются новые фичи или исправляются баги.

  • Перед каждым релизом.

Регрессионные тесты подходят для автоматизации, т.к они проверяют существующий функционал, и каждый раз должны проходить без изменений. Используйте их как часть CI/CD пайплайна.

Контрактное тестирование

Контрактное тестирование проверяет, что изменения в одном сервисе не нарушают соглашения между сервисами.

Пример применения Pact для контрактного тестирования:

@Pact(provider = "UserProvider", consumer = "UserConsumer")
public RequestResponsePact createPact(PactDslWithProvider builder) {
    return builder
        .given("User with ID 1 exists")
        .uponReceiving("A request for User ID 1")
        .path("/users/1")
        .willRespondWith()
        .status(200)
        .body(new PactDslJsonBody()
              .integerType("id", 1)
              .stringType("name", "John"))
        .toPact();
}

Этот тест проверяет, что контракт между клиентом и сервером для получения пользователя по ID остаётся действительным после изменений.

Когда использовать:

  • В микросервисных архитектурах, где нужна совместимость API.

  • При изменении API или микросервисов.

  • При изменении контрактов между сервисами.

Контрактные тесты можно запускать в рамках CI/CD пайплайнов.


Как создать свой CI/CD конвейер с использованием Tekton + Kubernetes? Узнаете 19 сентября на открытом уроке, который пройдет в рамках курса «DevOps практики и инструменты». Если тема для вас актуальна — записывайтесь на урок по ссылке.

Комментарии (0)