Проверка готовности приложения к работе в реальном ненадежном мире. Часть 1
- четверг, 7 ноября 2024 г. в 00:00:09
Опытом делится Виталий Лихачёв, SRE в booking.com и спикер курса Слёрма «Golang-разработчик». Он рассказывает, о чём стоит подумать перед выкаткой сервиса в жестокий прод, где он может не справиться с нагрузкой или деградировать из-за резких всплесков при наплыве пользователей и по вечерам.
Считайте это некоторым чек-листом, но не применяйте все пункты as is, потому что каждая система уникальна и иногда вполне допустимо построить менее надежную систему с целью значительного сокращения затрат на разработку, поддержку и эксплуатацию (например, отсутствие резервирования). Однако бэкапы обязательно должны быть 🙂
Некоторые термины не будем переводить не в силу лени автора, а в силу устойчивости терминов в литературе.
Отдельные пункты внимательный читатель может отнести сразу к нескольким разделам верхнего уровня. Поэтому деление на подгруппы довольно условное.
Если возникнет вопрос, а почему не описан некоторый X в чек-листе, то ответ простой: статья и так получилась огромной, и вы вероятно сможете найти что-то полезное для себя.
Статья состоит из 5 частей, которые будут выходить по очереди:
1. Надежность.
2. Масштабируемость/отказоустойчивость.
3. Resiliency/отказоустойчивость.
4. Безопасность. Процесс разработки. Процесс выкатки.
5. Наблюдаемость. Архитектура. Антипаттерны.
Это процесс планирования ресурсов, которые потребуются для стабильной работы приложения в будущем. Автоматизация этого процесса может включать в себя использование скейлинга приложений в k8s.
Как это работает в дикой природе?
Например, в вашем приложении есть 10 реплик, на которые по алгоритму random/round robin/etc. распределяется трафик, при этом 5 реплик приложения находятся в одной availability zone, 5 реплик — в другой. Что произойдет, если одна AZ становится недоступной? Как раз на этот вопрос может ответить capacity planning. Не выключая реплики сервиса, при помощи автоматизированного инструмента управления трафиком (почти наверняка это какой-то service mesh с envoy proxy) начинаем менять вес одной из реплик сервиса на лету таким образом, что постепенно вместо 10% общего трафика реплика начинает обрабатывать 12-15-20-…-N% трафика.
При этом инструмент автоматизации обязательно отслеживает процент ошибок и перестает менять вес реплики, когда количество ошибок превышает некий порог, обычно довольно низкий (1%, 0.1%). Как только процент ошибок превышен, происходит снижение веса реплики обратно до 10%, и формируется отчет (либо проверяются метрики в Grafana, например), и таким образом мы понимаем, сколько трафика держит одна реплика сервиса при заданных ресурсах. Тут мы не говорим про БД/кеши и т.д., которые находятся за сервисом, потому что их выживание в случае отказа AZ — отдельная обширная тема.
Определение и устранение узких мест — важный шаг для обеспечения надёжности. Узкие места могут возникать на уровне процессоров, отдельных ядер процессора, памяти, сети или ввода-вывода. Для их выявления обычно используют профилирование производительности и мониторинг с помощью инструментов, таких как Prometheus или Grafana. Это очень многогранная тема, которая может сильно зависеть от используемых инструментов.
Конкретно для Golang можно предложить следующие варианты:
Профилирование с помощью встроенного инструмента pprof. Он позволяет собирать данные о производительности приложения: загрузке процессора, потреблении памяти, времени выполнения горутин и другие показатели. С его помощью можно выявить узкие места на уровне процессора (CPU) или неэффективного использования памяти.
Как это работает: в код добавляются обработчики для профилирования, которые можно вызвать для создания отчетов. Эти данные анализируются для поиска горутин или операций, потребляющих чрезмерное количество ресурсов.
Например
package main
import (
"fmt"
"runtime"
)
import "runtime/pprof"
import "os"
import "time"
func main() {
go leakyFunction()
time.Sleep(time.Millisecond * 100)
f, _ := os.Create("/tmp/profile.pb.gz")
defer f.Close()
runtime.GC()
fmt.Println("write heap profile")
pprof.WriteHeapProfile(f)
}
func leakyFunction() {
s := make([]string, 3)
for i := 0; i < 1000000000; i++ {
s = append(s, "magical pprof time")
}
}
И проверим профиль
go tool pprof /tmp/profile.pb.gz
File: main
Type: inuse_space
Time: Oct 23, 2024 at 3:45pm (CEST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 74.47MB, 100% of 74.47MB total
flat flat% sum% cum cum%
74.47MB 100% 100% 74.47MB 100% main.leakyFunction
Видим виновника использования памяти — leakyFunction. Пример упрощенный, но в реальных больших программах вы все равно можете предполагать, где течет память, где высокое потребление CPU и так далее. Это поможет в дальнейшем идентифицировать места, которые нужно профилировать.
Что если ваше приложение нежизнеспособно без коннекта к БД? А можно ли сделать его более надежным?
Просто пример — вы заранее знаете, что небольшое подмножество данных достается из БД чаще всего, и эти данные можно кешировать in-memory на уровне каждой реплики сервиса. Делать это, конечно же, нужно аккуратно, со знанием специфики данных и точным определением, что именно эти данные являются высокочастотными в плане частоты обращения за ними. Так же нужно думать про инвалидацию кеша, про недопущение OOM, т.е. ограничивать размер кеша. Но в целом, это реально для определенных типов данных.
Представьте, что у вас есть приложение, которое отдает текущие цены товаров для листинга каталога большого интернет-магазина. Конечно же, там будут текущие акции, топ товаров и прочие подобные высокочастотные штуки. Чтобы превратить hard зависимость в частично soft зависимость, важно понимать, как определить высокочастотные данные и как инвалидировать кеш. Это поможет ослабить зависимость от БД и бонусом снизить нагрузку на неё.
На этапе покупки/оплаты всё равно придётся ходить мимо кеша, чтобы не обмануть случайно пользователя. И на этой скользкой дорожке надо хорошо подумать, как кешировать разные данные и как инвалидировать кеш.
Здесь по отношению к хард зависимостям все проще.
Зависимость является soft, если без нее можно выполнить некий бизнес процесс без сильного влияния на пользовательский опыт.
Например, что если у нас есть карточка товара и мы не смогли из сервиса рейтингов подгрузить оценку товара? Мы можем нарисовать карточку, просто скрыв рейтинг или сделав некие fallback вместо показа рейтинга.
Тут внимательный читатель может спросить, почему рейтинг хранится отдельно от товара? Валидный вопрос, но в больших системах часто разделяются БД в зависимости от паттернов нагрузки, а также рейтинг может быть большой отдельной подсистемой с аналитикой, рекомендациями и т.д. — целый отдельный мир, поэтому даже такая, казалось бы, небольшая часть системы выносится в отдельные сервисы и отдельные БД
Понимание паттернов трафика позволяет лучше подготовить приложение к пиковым нагрузкам. Например, если логика бизнеса предполагает неожиданные росты нагрузки в 10 раз по отношению к среднему (типичная история для распродаж), то используем нагрузочные тесты (см. дальше) для получения метрик работы приложения и для идентификации bottlenecks.
Простыми словами:
SLI — конкретные метрики, отображающие соотношение успешные/все запросы (например, http_200_count/(http_all_count)).
SLO — внутреннее соглашение по лимитам ошибок. Обычно более жёсткое, чем SLA. Например, 99.9% всех запросов должны выполняться правильно.
SLA — внешнее соглашение — часто с юридической составляющей, которая закрепляет санкции в отношении компании, если SLA нарушен. Обычно это какой-то возврат средств, оплаченных за время простоя, либо скидки на ресурсы пропорционально времени простоя. Тема сложная и нетривиальная, вплоть до сложных принципов подсчета времени простоя, которые могут не совпадать с ощущениями.
Обязательное требование для medium/high critical сервисов в большинстве (а может уже и во всех) бигтехах. Если при деградации сервиса (повысилось количество ошибок, выросли latency) никакие важные пользовательские сценарии не страдают так, что это влияет на общее восприятие приложения, то скорее всего такой сервис является low critical, и для него не так важно определить SLI/SLO, либо определить довольно расслабленный SLO вида 95%.
Однако для важных сервисов фон ошибок/недоступности обычно допустим с довольно маленьким интервалом. Классические значения для high critical сервисов ставят на уровне минимум 99.9% либо 99.95%.
И тут важно разделять фон ошибок и недоступность.
Если сервис недоступен 1 минуту и это укладывается в SLO в пределах заданного интервала (неделя, месяц), то с этим ничего не делается. Однако если сервис доступен почти всегда, но при этом фон ошибок создает проблемы пользователям, то вводится понятие error budget, когда считается не недоступность как таковая, а сколько ошибок по отношению к общему числу запросов мы получили на согласованном временном интервале. Таким образом даже для “всегда” доступного приложения в случае слишком большого сгорания error budget принимаются меры к уменьшению ошибок.
В следующей части рассмотрим масштабируемость и отказоустойчивость.
Повысить навыки разработки на Go и собрать полноценный сервис для портфолио можно на курсе «Golang-разработчик».