Проверка готовности приложения к работе в реальном ненадежном мире. Часть 3
Третья часть статьи, в которой Виталий Лихачёв, SRE в booking.com и спикер курса Слёрма «Golang-разработчик» рассказывает, о чём стоит подумать перед выкаткой сервиса в жестокий прод, где он может не справиться с нагрузкой или деградировать из-за резких всплесков при наплыве пользователей и по вечерам.
Статья состоит из 5 частей, которые выходят по очереди:
Надежность.
Масштабируемость/отказоустойчивость.
Resiliency/отказоустойчивость.
Безопасность. Процесс разработки. Процесс выкатки.
Наблюдаемость. Архитектура. Антипаттерны.
Resiliency/отказоустойчивость
Тесты деградации зависимостей
Одно дело, когда зависимость полностью отказывает. А другое — когда работает частично (много ошибок), либо слишком медленно.
Такими тестами, например, проверяется корректность работы таймаутов/ретраев/настроек circuit breaker и т.д.
Connection/socket timeouts
Здесь все просто. Ни один запрос не должен выполняться бесконечно, но и ставить лимиты вида «давайте ждать получения body для POST запроса» в 60 секунд тоже не стоит (см. атаку slowloris https://www.cloudflare.com/en-gb/learning/ddos/ddos-attack-tools/slowloris/). Если это сервис, обычно обрабатывающий запросы за 1–2 секунды, начиная от установления соединения и заканчивая отправкой последнего байта ответа клиенту, то ему вполне можно поставить значения таймаутов вида 5–10 секунд.
И важно: при таймауте нужно правильно отменять обработку.
Например, у нас есть http handler и мы пробрасываем контекст на все уровни дальше при обработке. Если context отменен (клиент отменил запрос или отвалился, закрыв соединение с сервером), то продолжать выполнять запрос не имеет смысл. Писать его уже некуда :)
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
)
func main() {
http.HandleFunc("/hello", HelloHandler)
log.Println("Starting server on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("Server failed: %s", err)
}
}
func HelloHandler(w http.ResponseWriter, r *http.Request) {
// Создаём контекст из запроса
ctx := r.Context()
log.Println("Handler started")
defer log.Println("Handler finished")
// Выполняем длительную операцию с проверкой контекста
if err := doWork(ctx); err != nil {
log.Printf("Error: %v", err)
http.Error(w, "Request was cancelled", http.StatusRequestTimeout)
return
}
fmt.Fprintln(w, "Hello, World!")
}
// doWork - симуляция длительной работы
func doWork(ctx context.Context) error {
select {
case <-time.After(5 * time.Second):
// Имитация успешного выполнения задачи
log.Println("Work completed")
return nil
case <-ctx.Done():
// Контекст был отменён (например, клиент закрыл соединение)
log.Println("Work cancelled")
return ctx.Err() // Возвращаем ошибку контекста
}
}
Проверить такой пример легко, запустив сервер и отправив запрос
curl localhost:8080/hello
Как только запрос начал выполняться, можно нажать ctrl-c, завершив выполнение curl, и сервер заметит разрыв соединения и прокинет отмену контекста в метод doWork
Retry policies
Сеть ненадежна, да и сервисы могут сбоить, потому что им самим нужно сходить в БД/кеш/etc. для формирования ответа.
В этом случае настраивается политика ретраев (в идеале на основе общей библиотеки, скрывающей всё под капотом), чтобы пользователь меньше видел ошибок, потому что часто такие ошибки решаются после первой же попытки.
Тут важно не перестараться и не создать в системе retry storm.
Когда есть цепочка api gateway → service A → service B, то в случае ошибки запроса из A в B, A выполнит ретрай. Потом, не дождавшись вовремя ответа от A так же api gateway выполнит ретрай. Таким образом вместо 2 запросов получим 4 (как минимум). Реальных связей между системами, конечно, больше, и может быть 3–5 сетевых походов. Если делаем хотя бы 2 ретрая, можем получить деградацию на ровном месте, когда пара секунд проблем в сети перегрузит наши сервисы повторными попытками запросов.
В таких случаях обычно делают такую логику, что только api gateway имеет право выполнять ретрай.
Circuit breakers
Используется для защиты системы от постоянных или временных отказов в зависимых сервисах или компонентах. Он помогает предотвратить перегрузку системы в условиях, когда внешние сервисы или компоненты работают нестабильно, вызывая многочисленные ошибки или слишком долгие задержки.
Пример использования библиотеки https://github.com/eapache/go-resiliency/
package main
import (
"errors"
"fmt"
"time"
"github.com/eapache/go-resiliency/breaker"
)
func unreliableOperation() error {
// Симуляция временной ошибки
return errors.New("external service error")
}
func main() {
// Создаем circuit breaker с порогом 3 ошибок и таймаутом 5 секунд
cb := breaker.New(3, 1, 5*time.Second)
for i := 0; i < 10; i++ {
err := cb.Run(func() error {
// Симулируем вызов ненадежного сервиса
err := unreliableOperation()
if err != nil {
return err
}
return nil
})
if err != nil {
if err == breaker.ErrBreakerOpen {
fmt.Println("Circuit breaker open, skipping request")
} else {
fmt.Printf("Request failed: %v\n", err)
}
} else {
fmt.Println("Request succeeded")
}
time.Sleep(1 * time.Second) // Задержка между запросами
}
}
Конечно, у всего есть недостатки.
Слишком раннее или позднее открытие Circuit Breaker
Если порог срабатывания Circuit Breaker установлен слишком низким, он может открыться даже при незначительных и кратковременных сбоях. Это может привести к излишнему отказу от работы с зависимым сервисом, хотя он мог бы быстро восстановиться. С другой стороны, если порог слишком высок, система будет продолжать посылать запросы к неработающему сервису, вызывая задержки и снижение производительности.
Решение: правильно настраивайте порог ошибок и частоту запросов на основе реальных данных о производительности и типе сервисов (делайте хорошо, а плохо не делайте — ваш кэп).
Таймаут
Если время ожидания до перехода в полуоткрытое состояние слишком большое, система может слишком долго игнорировать восстановившийся сервис, снижая общую доступность системы. Если время слишком короткое, система будет слишком часто «тестировать» сервис, что может привести к неэффективному использованию ресурсов.
Решение: баланс между временем ожидания перед повторной проверкой и частотой тестирования сервиса должен быть основан на данных о его типичных временах восстановления.
Игнорирование ошибок при открытом Circuit Breaker
В некоторых случаях, особенно при использовании открытого Circuit Breaker, ошибки могут не логироваться или не отслеживаться должным образом, и администраторам будет сложно диагностировать, почему сервис не работает.
Решение: важно вести аудит состояний Circuit Breaker, чтобы понимать, когда и почему он открывается, и какие ошибки вызывают это состояние.
Потеря критических запросов
Если Circuit Breaker неправильно настроен, он может блокировать критически важные запросы, которые могли бы пройти, если бы сервис был проверен ещё раз. Например, система может блокировать запросы пользователей в периоды, когда сервис временно нестабилен, но всё ещё способен обрабатывать запросы.
Решение: некоторые запросы можно помечать как высокоприоритетные и разрешать их даже в состоянии открытого Circuit Breaker (или реализовать fallback-логику для критических операций).
Влияние на пользовательский опыт
Circuit Breaker может привести к ухудшению пользовательского опыта, если неправильно настроен. Например, при длительных периодах открытого состояния пользователи могут получать мгновенные ошибки без объяснений, почему запрос не выполнен.
Решение: используйте корректные сообщения об ошибках и механизмы кеширования или fallback для отображения пользователю, что система временно недоступна, но запрос можно повторить позже.
Rate limiters
Rate Limiter применяется для предотвращения перегрузки систем или злоупотребления ресурсами.
При этом rate limiter может (и должен) быть многоуровневым.
Например, на уровне API gateway — одни лимиты, общие на всю систему и кастомизируемые в зависимости от конкретного API endpoint.
На уровне service mesh внутри k8s — другие лимиты, чтобы в каждое приложение не добавлять логику лимитирования запросов.
На уровне приложения так же могут быть отдельные rate limiters, зависящие от логики приложения и его бизнес-домена.
Например, если авторизованный пользователь на платформе может запрашивать номер телефона другого пользователя для связи с ним по поводу выложенного объявления, то мы не хотим давать смотреть все возможные номера без лимитов, чтобы защититься от мошенников или парсинга. Как это решается на самом деле — вводится концепция подменных номеров для нераскрытия реальных номеров, но сейчас не об этом. На уровне API gateway или на уровне envoy такое сделать может быть очень нетривиально, а на уровне нашего приложения мы храним некий счетчик, к примеру, в Redis, и даём смотреть только 30 номеров в час.
Проблемы при неправильном использовании Rate Limiter:
Чрезмерное ограничение (over-restriction).
Если лимиты настроены слишком жёстко, это может вызвать негативный пользовательский опыт. Например, пользователи могут часто сталкиваться с ошибками »429 Too Many Requests», даже если их активность легитимна. Это может привести к снижению удовлетворённости клиентов или потере пользователей.
Нарушение функционирования системы.
Если Rate Limiter настроен неправильно, он может блокировать критически важные запросы, что приведёт к снижению доступности системы. Например, при интеграции с внешними системами или обработке платежей блокировка запросов может вызвать серьёзные сбои. Для критических запросов нужно формировать отдельные rate limiters.
Неправильная работа с распределёнными системами.
В распределённых системах (например, когда API развёрнуто на нескольких серверах) может возникнуть проблема координации лимитов между различными инстансами. Если Rate Limiter применяется локально на каждом сервере, лимит может не соблюдаться глобально. Решается такое внешними быстрыми хранилищами типа redis cluster, где хранятся счетчики.
Проблемы с пользователями за общим IP.
В некоторых системах лимиты накладываются на уровень IP-адресов. Это может создать проблемы для пользователей, которые используют общий IP-адрес (например, сотрудники одной компании или клиенты через NAT), что приведёт к блокировке всех запросов с этого IP, даже если каждый пользователь делает допустимое количество запросов.
Пограничные эффекты (bursting).
Если используется алгоритм с фиксированными окнами (например, Fixed Window Counter), пользователи могут сделать множество запросов в самом конце одного окна и в начале следующего окна, что создаёт «всплески» нагрузки. Для решения нужно использовать более гибкие алгоритмы типа sliding window.
Как выглядит sliding window (базово, без углубления в детали). Конечно, вы будете использовать готовые библиотеки в 99.9% случаев, причем реализованные более оптимально, чем представленный пример. Но в этом примере очень удобно использовать каналы для демонстрации sliding window, несмотря на то, что это не самый быстрый вариант.
package main
import (
"fmt"
"sync"
"time"
)
type SlidingWindowRateLimiter struct {
requests chan time.Time // Канал для хранения времени каждого запроса
limit int // Лимит запросов в окне
windowSize time.Duration // Длительность окна
mu sync.Mutex // Мьютекс для защиты канала от гонок данных
}
func NewSlidingWindowRateLimiter(limit int, windowSize time.Duration) *SlidingWindowRateLimiter {
return &SlidingWindowRateLimiter{
requests: make(chan time.Time, limit), // Создаем канал с буфером на размер лимита
limit: limit,
windowSize: windowSize,
}
}
// Метод для проверки возможности выполнить запрос
func (r *SlidingWindowRateLimiter) Allow() bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
// Удаляем старые запросы, которые не попадают в текущее окно
for len(r.requests) > 0 {
earliest := <-r.requests
if now.Sub(earliest) <= r.windowSize {
// Если старый запрос всё ещё в пределах окна, возвращаем его обратно
r.requests <- earliest
break
}
}
// Если в окне ещё есть место для нового запроса
if len(r.requests) < r.limit {
r.requests <- now // Запоминаем время нового запроса
return true
}
// Лимит достигнут
return false
}
func main() {
limiter := NewSlidingWindowRateLimiter(3, 10*time.Second) // Лимит 3 запросов в 10 секунд
for i := 1; i <= 10; i++ {
if limiter.Allow() {
fmt.Printf("Request #%d allowed\n", i)
} else {
fmt.Printf("Request #%d blocked\n", i)
}
time.Sleep(2 * time.Second) // Эмулируем паузу между запросами
}
}
Back pressure
Это механизм управления нагрузкой в асинхронных или распределённых системах, который предотвращает перегрузку, когда потребитель (consumer) не успевает обрабатывать данные с той скоростью, с которой их предоставляет производитель (producer).
Ниже приведён пример использования back pressure, где производитель генерирует данные быстрее, чем потребитель может их обрабатывать. Для предотвращения переполнения системы вводится back pressure через канал с ограниченным буфером.
package main
import (
"fmt"
"math/rand"
"time"
)
// Производитель, который быстро генерирует данные
func producer(dataCh chan<- int) {
for {
num := rand.Intn(100) // Генерируем случайные данные
fmt.Printf("Produced: %d\n", num)
dataCh <- num // Отправляем данные в канал
time.Sleep(100 * time.Millisecond) // Производитель работает быстро
}
}
// Потребитель, который обрабатывает данные медленнее
func consumer(dataCh <-chan int) {
for data := range dataCh {
fmt.Printf("Consumed: %d\n", data)
time.Sleep(500 * time.Millisecond) // Потребитель работает медленно
}
}
func main() {
// Создаем канал с буфером на 5 элементов
dataCh := make(chan int, 5)
// Запускаем производителя
go producer(dataCh)
// Запускаем потребителя
go consumer(dataCh)
// Работаем бесконечно
select {}
}
Проблемы, которые могут возникнуть при неправильном использовании back pressure:
Блокировки и задержки
Если канал с буфером слишком маленький, producer будет часто блокироваться, ожидая освобождения места в канале. Это может привести к задержкам или полной остановке работы producer. В реальных системах это может вызывать неэффективное использование ресурсов и увеличение времени отклика.
Потеря данных
Если буфер канала слишком мал, и producer не может ждать, данные могут быть потеряны. В некоторых сценариях пропуск данных может быть допустимым: например, при отправке метрик в больших объемах, где прореживание метрик (потеря значений в определенные моменты времени) вполне допустима.
И если мы говорим про синхронные запросы в http, то краткий ответ — код 429.
package main
import (
"fmt"
"net/http"
"time"
)
const (
maxConcurrentRequests = 5 // Максимальное количество одновременных запросов
requestProcessingTime = 2 * time.Second // Время обработки каждого запроса
)
// Semaphore (канал), который будет использоваться для ограничения количества параллельных запросов
var semaphore = make(chan struct{}, maxConcurrentRequests)
// HTTP handler, который ограничивает количество одновременно обрабатываемых запросов
func limitedHandler(w http.ResponseWriter, r *http.Request) {
// Попытка захватить семафор для обработки запроса
select {
case semaphore <- struct{}{}:
// Обязательно освобождаем семафор по завершении
defer func() { <-semaphore }()
// Симулируем долгую обработку запроса
fmt.Fprintf(w, "Processing request: %s\n", r.URL.Path)
time.Sleep(requestProcessingTime) // Эмулируем сложную работу
fmt.Fprintf(w, "Request processed: %s\n", r.URL.Path)
default:
// Если не удалось захватить семафор, возвращаем ошибку
http.Error(w, "429 - Too many requests", http.StatusTooManyRequests)
}
}
func main() {
http.HandleFunc("/", limitedHandler)
fmt.Println("Starting server on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Println("Server failed:", err)
}
}
Load shedding
Это механизм, который применяется в системах, работающих с высокой нагрузкой, для предотвращения перегрузки.
В контексте серверов или распределённых систем load shedding заключается в отказе от обработки части запросов или задач, когда система близка к перегрузке, с целью сохранить доступность и производительность для оставшихся запросов.
package main
import (
"fmt"
"net/http"
"sync/atomic"
"time"
)
const (
maxConcurrentRequests = 10 // Максимальное количество одновременных запросов
maxRequestProcessingTime = 2 * time.Second // Время обработки запроса
)
// Переменная для отслеживания текущего количества активных запросов
var activeRequests int32
// Load shedding handler
func loadSheddingHandler(w http.ResponseWriter, r *http.Request) {
// Проверяем, превышает ли количество активных запросов допустимый порог
if atomic.LoadInt32(&activeRequests) >= maxConcurrentRequests {
http.Error(w, "503 Service Unavailable - Too many requests", http.StatusServiceUnavailable)
return
}
// Увеличиваем количество активных запросов
atomic.AddInt32(&activeRequests, 1)
defer atomic.AddInt32(&activeRequests, -1)
// Симулируем обработку запроса
time.Sleep(maxRequestProcessingTime)
fmt.Fprintf(w, "Request processed successfully: %s\n", r.URL.Path)
}
func main() {
http.HandleFunc("/", loadSheddingHandler)
fmt.Println("Starting server on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Println("Server failed:", err)
}
}
Тут внимательный читатель может заметить, что концептуально это не отличается от back pressure, да и от rate limiter тоже. Да, это довольно похожие концепции, но есть важное отличие.
Load Shedding предполагает отказ от части запросов при перегрузке. Когда система получает слишком много запросов, она начинает отклонять их, чтобы сохранить стабильность для оставшихся. Это механизм защиты системы от перегрузки путём отказа в обслуживании.
Back Pressure же ориентирован на регулирование скорости обработки запросов. Если потребитель не может справиться с объёмом запросов от клиента, он замедляет или ограничивает поток запросов, чтобы поддерживать стабильную работу. Это механизм снижения интенсивности нагрузки на систему. Конкретно в примере с back pressure ранее мы должны отдавать не только код 429, но и еще заголовок Retry-After, чтобы клиент знал когда можно приходить обратно. В этом плане Back Pressure и Rate Limiter, по сути, реализуют одинаковую логику.
А теперь посмотрим на load shedding в контексте сервиса, управляющего пользовательскими сессиями, а конкретно: сервис обрабатывает рефреш токены и выдаёт пользователям новые сессии.
Когда количество одновременных запросов на обновление токенов достигает максимального значения (мы это можем задать сами по результатам нагрузочных тестов), система может начать отклонять новые запросы. Это позволяет сохранить стабильность системы и обрабатывать хотя бы часть запросов, но некоторые пользователи могут получить ошибку 503 Service Unavailable. В противном случае можем получить деградацию БД и вместо условных 5% ошибок получим 50% ошибок из-за тормозов БД.
Сравнение и выбор механизма:
Характеристика | Load Shedding | Back Pressure |
---|---|---|
Принцип действия | Отклонение части запросов для защиты системы | Замедление или регулировка потока запросов |
Пример поведения | Сервер возвращает ошибку | Сервер сигнализирует клиентам уменьшить нагрузку (например, через |
Реакция клиента | Клиенты вынуждены повторить запрос позже | Клиенты уменьшают частоту запросов или ожидают |
Подход к перегрузке | Резкое ограничение — часть запросов не обслуживается | Плавное ограничение — управление интенсивностью запросов |
Риски | Отказы в обслуживании могут повредить UX | Может привести к задержкам в обслуживании |
Когда использовать | При критических перегрузках, когда надо быстро сократить нагрузку | Когда нужна динамическая регулировка потока запросов |
Idempotent requests
Это такие запросы, которые при многократном выполнении дают один и тот же результат, независимо от того, сколько раз они были отправлены. В контексте API это означает, что если клиент отправит один и тот же запрос несколько раз, сервер выполнит его лишь однажды или обеспечит такой же результат без побочных эффектов.
Пример — платежи. В системе обработки платежей важно, чтобы многократное отправление одного и того же запроса не приводило к повторным платежам.
Рассмотрим сценарий.
Ситуация: пользователь оплачивает товар на сайте и по какой-то причине отправляет запрос на оплату дважды (например, из-за проблемы с сетью, ожидания или повторной отправки формы). Идемпотентный запрос: клиент отправляет запрос на оплату (например, POST /payment), указывая уникальный идентификатор транзакции (transaction_id). Сервер проверяет, была ли транзакция с таким transaction_id уже обработана. Если не была — выполняется операция списания денег. Если была — сервер возвращает тот же результат, что и при первом запросе, и не выполняет повторную операцию списания. Тут важно уточнить, если обработка запроса занимает значительное время (сотни миллисекунд), то есть риск получить повторные запросы ещё до того, как выполнился первый и записал данные в БД. Конечно, реализация должна учитывать такие случаи.
Как это сделать, например, в PostgreSQL? Сделать таблицу с уникальным индексом по transaction_id, которая будет показывать, что платеж в обработке. При попытке выполнить второй запрос запись в такую таблицу не произойдет из-за ограничения уникальности.
Структура таблицы (довольно примитивная, реальные могут быть сложнее)
CREATE TABLE payments (
id SERIAL PRIMARY KEY,
transaction_id VARCHAR(255) UNIQUE NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(50) NOT NULL, -- pending, success, failed
created_at TIMESTAMPTZ DEFAULT NOW()
);
И кусок реализации для демонстрации идеи. Конечно, клиент должен правильно обработать ответ, что транзакция уже в обработке, но это уже совсем другая история.
// Попробуем вставить новую транзакцию. Если транзакция уже существует, возникнет ошибка.
_, err = tx.Exec(context.Background(),
"INSERT INTO payments (transaction_id, amount, status) VALUES ($1, $2, $3)", paymentReq.TransactionID, paymentReq.Amount, "pending")
if err != nil {
if pgxErr, ok := err.(*pgx.PgError); ok && pgxErr.Code == "23505" { // Код ошибки уникальности
// Транзакция уже существует, возвращаем результат без повторной обработки
var status string
err = db.QueryRow(context.Background(), "SELECT status FROM payments WHERE transaction_id=$1", paymentReq.TransactionID).Scan(&status)
if err != nil {
http.Error(w, "Error fetching existing payment", http.StatusInternalServerError)
return
}
// Возвращаем существующий результат
response := PaymentResponse{
Status: status,
TransactionID: paymentReq.TransactionID,
Amount: paymentReq.Amount,
}
json.NewEncoder(w).Encode(response)
return
}
// Если ошибка другая — возвращаем ошибку
http.Error(w, "Error creating payment", http.StatusInternalServerError)
return
}
DLQ
Это концепция при работе в асинхронных системах, которая используется для обработки сообщений, которые невозможно обработать даже в случае повторных попыток. Например: формат сообщения не соответствует ожидаемому.
Когда система не может успешно обработать сообщение после нескольких попыток (из-за ошибок или некорректных данных), это сообщение отправляется в специальную очередь — Dead Letter Queue, вместо того чтобы быть повторно обработанным.
Когда применяется DLQ?
Некорректные данные: сообщение содержит ошибочные или неполные данные, из-за чего его невозможно обработать.
Ограничение на количество попыток: После нескольких неудачных попыток обработать сообщение, оно отправляется в DLQ для дальнейшего анализа.
Необходимость диагностики: DLQ позволяет сохранить сообщения, которые не удалось обработать, и позже проанализировать их причины.
Важно уточнить, что на длину DLQ должны быть обязательно настроены метрики и алерты, потому что при нормальной работе системы попадание сообщения в DLQ должно быть исключением, а не правилом.
Так же если есть DLQ, должен быть механизм, который позволяет переотправить сообщения из DLQ обратно в основную очередь для повторной обработки либо для удаления из DLQ, если мы понимаем, что, к примеру, из-за ошибки в producer было сформировано некорректное сообщение.
Мультирегиональность/network split testing
Мультирегиональность — это стратегия развертывания сервисов в нескольких регионах для повышения доступности, отказоустойчивости и минимизации задержек. Это позволяет сервисам обеспечивать более высокую производительность для пользователей, находящихся в разных частях мира.
В свою очередь, network split testing — это метод, который позволяет тестировать поведение системы в случае, как неожиданно, разделения сети на несвязанные участки. Такие ситуации могут возникнуть и в пределах одного региона. Например, случайно неправильно настроили правила в firewall и разделили кластер на две части. Вдруг оказалось, что сервис не может достучаться до зависимостей. Тут можно выявить и проблемы в hard/soft зависимостях, и определить стратегии для graceful degradation, и неправильно настроенные таймауты и ретраи и так далее.
Failover тестирование
Процесс проверки способности системы автоматически переключаться на резервные ресурсы в случае сбоя основного компонента. Цель этого тестирования — убедиться, что система может продолжать функционировать без значительных деградаций.
Рассмотрим failover БД. Предположим, у вас есть приложение, использующее базу данных PostgreSQL. Вы хотите протестировать переключение на резервную базу данных в случае сбоя основной.
Настройка окружения:
У вас есть основная база данных db_primary и резервная база данных db_secondary.
Создание тестового приложения:
Приложение подключается к основной базе данных, но имеет возможность переключаться на резервную (master/slave конфигурация, где slave автоматически становится master — это уже отдельная история, как настроить автопереключение для PostgreSQL. Один из инструментов для этого — patroni).
Имитирование сбоя:
Вы можете использовать команду pg_ctl для остановки основной базы данных: pg_ctl -D /path/to/db_primary stop.
Проверка переключения:
После остановки основной базы данных проверьте, происходит ли переключение на резервную базу данных, конечно же, без ручного вмешательства со стороны разработчиков/админов/девопсов и т.д.
Нагрузочное тестирование
Это процесс оценки производительности системы, который помогает выявить узкие места (bottlenecks) и гарантировать, что сервисы могут обрабатывать ожидаемые нагрузки.
CPU Bottlenecks
Описание: процессор может стать узким местом, если приложение не может эффективно использовать ресурсы CPU, например, из-за блокировок или неэффективных алгоритмов.
Как выявить: используйте профилирование CPU с помощью инструментов, таких как pprof. Анализируйте, какие функции потребляют наибольшее время CPU.
Примеры: долгие циклы обработки, неэффективные алгоритмы.Memory Bottlenecks
Описание: потребление памяти может стать узким местом, если приложение выделяет слишком много памяти или не освобождает её правильно.
Как выявить: используйте инструменты профилирования памяти, такие как pprof, чтобы обнаружить утечки памяти или чрезмерное выделение памяти. Примеры: утечки памяти из-за неправильного использования указателей, слишком большие структуры данных, неэффективное использование кеша процессора.I/O Bottlenecks
Описание: невозможность быстро обрабатывать операции ввода-вывода может замедлить приложение. Это может происходить как из-за медленных дисков, так и из-за блокирующих операций.
Как выявить: проверьте время выполнения операций чтения и записи, используйте профилирование для выявления задержек.
Примеры: долгое чтение из базы данных, медленные сетевые запросы, блокировки на файловой системе.Network Bottlenecks
Описание: ограниченная пропускная способность сети может замедлить обмен данными между сервисами, особенно в микросервисной архитектуре.
Как выявить: используйте инструменты мониторинга сети, чтобы проверить задержки и пропускную способность.
Примеры: задержки при запросах к API, узкие места в маршрутизации.Database Bottlenecks
Описание: неправильная настройка базы данных, отсутствующие индексы или медленные запросы могут значительно замедлить выполнение операций.
Как выявить: используйте инструменты мониторинга баз данных, такие как pg_stat_activity для PostgreSQL, чтобы выявить медленные запросы.
Примеры: долгие запросы к базе данных, блокировки транзакций.Goroutine Bottlenecks
Описание: приложение может столкнуться с ограничениями при создании и управлении большим количеством горутин, если они не используются эффективно.
Как выявить: проверьте, сколько горутин создается и сколько из них активно выполняется в каждый момент времени.
Примеры: применение неэффективных паттернов управления горутинами, например, неправильно реализованный паттерн worker pool, где горутины не переиспользуются и остаются в зависшем состоянии.Garbage Collection (GC) Bottlenecks
Описание: частые сборки мусора могут вызывать задержки в работе приложения, особенно если выделение и освобождение памяти не оптимизировано.
Как выявить: мониторьте время, затрачиваемое на сборку мусора, с помощью профилирования.
Примеры: высокое потребление памяти и частые сборки мусора из-за временных объектов. Решается, например, через sync.Pool, если от создания большого количества мелких объектов нельзя уйти.Lock Contention
Описание: если несколько горутин пытаются получить доступ к одному и тому же ресурсу, это может привести к блокировкам и задержкам.
Как выявить: используйте pprof для анализа блокировок и выявления узких мест.
Примеры: частое использование мьютексов или других средств синхронизации без необходимости.и
Описание: если ваше приложение зависит от внешних API или сервисов, их производительность также может стать узким местом.
Как выявить: анализируйте время ожидания ответов от внешних сервисов. Примеры: внешние API с высокой задержкой, нестабильные сетевые соединения.
Автоматическое масштабирование
Обычно про него говорят в контексте k8s.
Это процесс, который позволяет автоматически увеличивать или уменьшать количество подов в зависимости от нагрузки на приложение. Horizontal Pod Autoscaler (HPA) автоматически изменяет количество реплик подов в зависимости от загрузки, таких как использование CPU или другие метрики, которые вы настроите. Это наиболее распространенный метод масштабирования в Kubernetes.
Процедуры бэкапа и восстановления
Идея простая: бэкапы надо делать правильно (а неправильно не делать), проверять восстановление из бэкапов и так далее.
Можно почитать отдельный чеклист для бэкапов https://habr.com/en/companies/selectel/articles/658297/
И, конечно же, изучить как правильно бэкапить ваши БД, кеши (да, их тоже может быть нужно бэкапить, чтобы не поднимать нагруженный сервис без заполненного кеша, потому что без кеша он может не подняться под нагрузкой).
Продолжение в следующей части. Разберем безопасность, процесс разработки и выкатки.
Повысить навыки разработки на Go и собрать полноценный сервис для портфолио можно на курсе «Golang-разработчик».