Проверка готовности приложения к работе в реальном ненадежном мире. Часть 2

6c5fba59475a58b3e77653f39eacb4b5.png

Вторая часть статьи, в которой Виталий Лихачёв,  SRE в booking.com и спикер курса Слёрма «Golang-разработчик» рассказывает, о чём стоит подумать перед выкаткой сервиса в жестокий прод, где он может не справиться с нагрузкой или деградировать из-за резких всплесков при наплыве пользователей и по вечерам.

Статья состоит из 5 частей, которые будут выходить по очереди:

1. Надежность.

2. Масштабируемость/отказоустойчивость.

3. Resiliency/отказоустойчивость.

4. Безопасность. Процесс разработки. Процесс выкатки.

5. Наблюдаемость. Архитектура. Антипаттерны.

Масштабируемость/отказоустойчивость

Процедуры восстановления документированы

Здесь все довольно очевидно. Есть документация, как восстановить данные из бэкапов — отлично.

Ещё лучше, когда процедуры восстановления периодически (раз в квартал) проверяются.

Вообще отлично, если ещё и проверяется корректность работы пользовательских сценариев после восстановления их бэкапа.

Chaos testing

Это просто. Устраиваем локальный апокалипсис для инфраструктуры. Особенно удобно это делать в k8s, но и за пределами куба можно построить такой процесс.

Идея проста, как палка: случайным образом (настраиваем случайность сами, конечно же) начинаем вырубать отдельные реплики приложения и смотрим на поведение системы.

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

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

Тесты внешних зависимостей

Ни один большой продукт не пишется без интеграций.

Службы доставки.

Сервисы отправки смс.

CDN.

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

И так далее. Сценарии можно придумать самые невероятные.

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

Например, нам нужно отправлять много смс. В случае единственного внешнего провайдера смс, который решил обновить прод в пятницу, и у него отъехала доставка сообщений, было бы неплохо иметь резервного провайдера. А еще круче — уметь переключаться на него автоматически без участия человека. А вот уже тест такого переключения должен делать человек:).

Документы для on call дежурных

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

Конечно же, сценариев поломки может быть много, и поиск правильного документа не должен занимать много времени.

Обычно это решается тем, что есть actionable alerts (т.е. алерты, на которые должен реагировать человек, а не автоматика), и к ним прикрепляют ссылки на документы (потому что алерт создавал человек, и он должен понимать, что делать, если конкретный алерт триггернулся).

В примере с смс это будет ссылка на документ, объясняющий как переключить провайдера смс. В идеале, это должно быть действие в виде нажатия одной кнопки во внутренней админке компании.

Graceful shutdown

Идея «катимся быстро, релизим много раз в сутки» неплоха сама по себе, но каждый релиз — это влияние на пользовательские запросы, потому что при неправильной реализации приложение вырубится посреди выполнения запроса и в итоге пользователи будут получать ошибки в моменты выкаток.

Это касается не только синхронных нагрузок (http запросы), но и асинхронных (обработчики фоновых задач из очереди). Хотя пользователи сразу не увидят проблем с фоновыми задачами, если обработчики работают долго, стоит позволить им сохранять промежуточное состояние. Это поможет избежать повторной обработки сообщения. Кроме того, сообщение может задерживаться в очереди дольше, чем нужно. Брокер сообщений может не заметить сбой обработчика в течение заданного таймаута, из-за чего очередь будет накапливаться. Это может привести к увеличению очереди на обработку.

Для http реализуется просто — через обработку сигнала, обычно это SIGTERM.

package main

import (
    "context"
    "errors"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    server := &http.Server{
        Addr: ":8080",
    }

    http.Handle("/", http.FileServer(http.Dir("./public")))

    go func() {
        if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
            log.Fatalf("HTTP server error: %v", err)
        }
        log.Println("Stopped serving new connections.")
    }()

    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
    <-sigChan

    shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second)
    defer shutdownRelease()

    if err := server.Shutdown(shutdownCtx); err != nil {
        log.Fatalf("HTTP shutdown error: %v", err)
    }
    log.Println("Graceful shutdown complete.")
}

Для асинхронных обработчиков идея та же самая, только обработку отмены контекста мы уже реализуем сами в нужных местах логики обработки.

12 factor apps

Это уже ставшая классической история. Подробно на ней останавливаться не будем.

Однако отошлём читателя к интересной интерпретации этой идеи, с разбором, чего не хватает в 12 факторах https://habr.com/en/companies/flant/articles/460363/

Health checks

Кажется, что добавил http endpoint вида /api/health, пингуешь там БД и этого достаточно.

Но потом оказывается, что в случае добавления пинга до БД при кратковременном пропадании связности до БД все реплики приложения в k8s окажутся unhealthy и начнут перезапускаться, а при перезапуске создадут лишнюю нагрузку на БД. И таких нюансов, конечно же, больше одного.

Посмотрим подходы, как можно делать healthchecks, а вы уже по логике приложения выберите, как стоит их реализовать.

Синхронные/асинхронные.

Синхронные: каждый HTTP-запрос на /api/health проверяет состояние системы, а внутри ходим в какие-то внешние системы (следует избегать) либо проверяем внутреннее состояние (а нет ли у нас зависших потоков). Этот метод подходит для небольших приложений, но не масштабируется для сложных систем. Если проверка требует больших данных или может вызвать задержки, приложение будет медленно отвечать на запросы.

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

Кеширование: результаты проверок кешируются для снижения нагрузки на проверяемые сервисы и предотвращения атак.

Подписки: можно настроить реакцию на изменение состояние системы. Конкретно это может быть реализовано через channels.

Пример библиотеки, которая поддерживает такие возможности https://github.com/alexliesenfeld/health

package main

import (
	"context"
	"fmt"
	"github.com/alexliesenfeld/health"
	"log"
	"net/http"
	"sync/atomic"
	"time"
)

// This example shows how this library can be used to listen for health status changes.
func main() {
	// Create a new Checker
	checker := health.NewChecker(

		// Configure a global timeout that will be applied to all checks.
		health.WithTimeout(10*time.Second),

		// A simple successFunc to see if a fake database connection is up.
		health.WithCheck(health.Check{
			Name:           "database",
			Timeout:        2 * time.Second, // A successFunc specific timeout.
			StatusListener: onComponentStatusChanged,
			Check: func(ctx context.Context) error {
				return nil // no error
			},
		}),

		// A simple successFunc to see if a fake file system up.
		health.WithCheck(health.Check{
			Name:           "filesystem",
			Timeout:        2 * time.Second, // A successFunc specific timeout.
			StatusListener: onComponentStatusChanged,
			Check: func(ctx context.Context) error {
				return nil // example error
			},
		}),

		// The following check will be executed periodically every 10 seconds.
		health.WithPeriodicCheck(5*time.Second, 10*time.Second, health.Check{
			Name:           "search-engine",
			StatusListener: onComponentStatusChanged,
			Check:          volatileFunc(),
		}),

		// This listener will be called whenever system health status changes (e.g., from "up" to "down").
		health.WithStatusListener(onSystemStatusChanged),
	)

	// We Create a new http.Handler that provides health successFunc information
	// serialized as a JSON string via HTTP.
	http.Handle("/health", health.NewHandler(checker))
	http.ListenAndServe(":3000", nil)
}

func onComponentStatusChanged(_ context.Context, name string, state health.CheckState) {
	log.Println(fmt.Sprintf("component %s changed status to %s", name, state.Status))
}

func onSystemStatusChanged(_ context.Context, state health.CheckerState) {
	log.Println(fmt.Sprintf("system status changed to %s", state.Status))
}

func volatileFunc() func(ctx context.Context) error {
	var count uint32
	return func(ctx context.Context) error {
		defer atomic.AddUint32(&count, 1)
		if count%2 == 0 {
			return fmt.Errorf("this is a check error") // example error
		}
		return nil
	}
}

В следующей части рассмотрим resiliency и отказоустойчивость.

Повысить навыки разработки на Go и собрать полноценный сервис для портфолио можно на курсе «Golang-разработчик».

© Habrahabr.ru