Проверка готовности приложения к работе в реальном ненадежном мире. Часть 4
- четверг, 14 ноября 2024 г. в 00:00:12
Четвертая часть статьи, в которой Виталий Лихачёв, SRE в booking.com и спикер курса Слёрма «Golang-разработчик» рассказывает, о чём стоит подумать перед выкаткой сервиса в жестокий прод, где он может не справиться с нагрузкой или деградировать из-за резких всплесков при наплыве пользователей и по вечерам.
Статья состоит из 5 частей, которые выходят по очереди:
Наблюдаемость. Архитектура. Антипаттерны.
Стоит уточнить, что ошибиться можно в самых неожиданных местах. Не пытайтесь придумывать свои алгоритмы, свою логику и т.д. — используйте проверенные решения.
Например, https://blog.deteact.com/ru/common-flaws-of-sms-auth/ — посмотрите на типичные ошибки, которые можно реализовать своими руками в ваших сервисах, когда вы подключаете верификацию номера пользователя через смс.
Про https слышали все и только в очень маленьких проектах вы будете сами и разработчиком, и админом и будете настраивать сертификаты для https, но мы немного подробнее упомянем mTLS.
mTLS (Mutual TLS) — это расширение TLS, которое предоставляет двустороннюю аутентификацию. В отличие от обычного TLS, где аутентифицируется только сервер, mTLS требует аутентификации как сервера, так и клиента.
Применение:
Двусторонняя аутентификация: Обе стороны (клиент и сервер) подтверждают свои идентичности, используя сертификаты.
Защита микросервисов: mTLS помогает предотвратить несанкционированный доступ к микросервисам, поскольку только аутентифицированные сервисы могут взаимодействовать друг с другом.
Безопасный обмен данными: Все данные передаются по зашифрованному каналу, что обеспечивает защиту от подслушивания и атак.
Казалось бы, зачем защищать сервисы внутри кластера? mTLS обеспечивает двустороннюю аутентификацию, позволяя каждому сервису удостовериться в подлинности другого. Это помогает предотвратить атаки "человек посередине" (Man-in-the-Middle) и гарантирует, что данные передаются только между доверенными сервисами.
Закрытые сети не защищены от внутренних угроз. Вредоносные действия, совершаемые пользователями или сервисами внутри сети, могут привести к утечкам данных или атакам на другие сервисы. Хотя закрытые сети могут быть безопасными, шифрование данных, передаваемых между сервисами, всегда рекомендуется. mTLS обеспечивает шифрование на уровне транспортного протокола, что предотвращает перехват и подмену данных. Это особенно важно для конфиденциальной информации или персональных данных.
Использование mTLS позволяет более точно отслеживать и аудировать трафик между сервисами. Сертификаты могут быть использованы для логирования и мониторинга взаимодействий, что помогает выявлять подозрительные действия и анализировать проблемы безопасности.
Но цена этому всему — более сложная инфраструктура и накладные расходы на шифрование.
Подробнее про правильные “безопасные” контейнеры https://habr.com/en/companies/oleg-bunin/articles/799773/ и https://habr.com/en/companies/slurm/articles/514528/
Идея простая: сокращаем вектор атаки, насколько это возможно.
PII (Personally Identifiable Information) — это любая информация, которая может быть использована для идентификации конкретного человека. Это может включать как напрямую идентифицирующие данные (например, имя или номер паспорта), так и косвенно идентифицирующие данные, которые в сочетании с другой информацией могут помочь в идентификации личности.
Работа с такими данными требует внимательных подходов.
Данные не логируются, не отдаются наружу в API без надобности. Лучше сделать несколько разных API для отдачи разных частей данных о пользователе, если это требуется с точки зрения безопасности и с точки зрения разного уровня доступа к этим данным от разных клиентов.
Также важно выстроить процесс работы с PII data с точки зрения законов: GDPR, HIPPA, ФЗ 152.
Процесс сканирования уязвимостей:
Статический анализ кода (SAST): Инструменты для анализа исходного кода на наличие уязвимостей, таких как OWASP Top Ten (например, SQL Injection, Cross-Site Scripting).
Примеры инструментов: SonarQube. Динамический анализ (DAST): Инструменты для тестирования приложения в работающем состоянии, чтобы выявить уязвимости. Это может включать сканирование HTTP-запросов, тестирование API и пр.
Примеры инструментов: OWASP ZAP, Burp Suite. Сканирование зависимостей
Микросервисы часто используют сторонние библиотеки и фреймворки. Используйте инструменты, такие как OWASP Dependency-Check или Snyk, для анализа зависимостей на наличие известных уязвимостей.
Для микросервисов, работающих в контейнерах, используйте инструменты, такие как Clair или Trivy, для анализа образов контейнеров на наличие уязвимостей в операционных системах и установленных пакетах.
Уровни, на которых находятся уязвимости
Уровень приложения
Уязвимости в коде: ошибки в логике приложения, неверная обработка данных, недостаточная аутентификация и авторизация. Уязвимости API: неправильно настроенные точки доступа, недостаточная защита данных.
Уровень зависимостей
Использование устаревших или уязвимых библиотек и фреймворков, которые могут быть легко скомпрометированы.
Уровень инфраструктуры
Неправильные настройки серверов, сетей, баз данных и контейнеров. Например, открытые порты, ненадежные конфигурации сетевых политик.
Уровень контейнеров
Уязвимости в образах контейнеров: использование образов, содержащих уязвимые устаревшие пакеты. Неправильные настройки безопасности в контейнерах, такие как недостаточные ограничения ресурсов или привилегированные процессы.
Уровень сети
Уязвимости, связанные с сетевым трафиком между микросервисами, такие как недостаточная защита при передаче данных и уязвимости на уровне межсервисного взаимодействия.
Уровень конфигураций
Ошибки в конфигурации, такие как открытые настройки доступа к базам данных, неправильные права доступа к ресурсам, неиспользуемые учетные записи.
Даже когда данные хранятся в облаках, не стоит пренебрегать возможностью шифрования дисков, потому что диски имеют срок жизни и никто не гарантирует, что из-за человеческой или автоматической ошибки при уничтожении диска всё пойдет как надо. Проще говоря, ДЦ облачных провайдеров, в целом, защищены, но никогда не стоит полагаться на то, что диски с вашими данными не попадут в чужие руки, даже в случае с крупными ДЦ
Если вы пишете сервис, работающий с критичными данными (пароли, управление номерами телефонов пользователя, вывод денег на карту и т.д.) пишите аудит лог максимально подробно. И под логом имеется в виду не строка в логе, которая через месяц будет удалена, а отдельное хранилище с данными аудита операций.
Для соответствия законам обязательно нужно реализовывать процессы управления данными пользователей и возможность удалить эти данные.
Процесс добавления новых функций в сервисы должен быть чётко определён и стандартизирован для обеспечения качества. Это включает в себя следующие этапы:
Обсуждение и планирование:
Команда обсуждает идеи и предложения по новым функциям, определяет их приоритеты и составляет план реализации.
Создание задачи: Задача описывается в системе управления проектами (например, Jira, Trello), где определяется цель, ожидаемые результаты и критерии приемки.
Разработка: Программист реализует функциональность, придерживаясь общих стандартов кодирования и архитектуры проекта.
Код-ревью: Код проходит через процесс ревью, чтобы обеспечить его качество, читаемость и соответствие стандартам.
Тестирование: Перед слиянием в основную ветку выполняются юнит-тесты и интеграционные тесты для проверки работоспособности новой функции. Документация: Вся новая функциональность должна быть документирована, включая изменения в API и инструкции для пользователей.
Линтинг — это процесс анализа кода на наличие ошибок, потенциальных проблем и несоответствий стандартам стиля.
В Golang для этой цели можно использовать инструменты, такие как golint, staticcheck и golangci-lint. Эти инструменты помогают:
- обнаруживать синтаксические ошибки и несоответствия стилю кодирования;
- указывать на потенциальные проблемы;
- улучшать читаемость кода и соответствие лучшим практикам, что снижает вероятность возникновения ошибок.
Форматирование кода в Golang достигается с помощью утилиты gofmt, которая автоматически форматирует код в соответствии с общепринятыми стандартами Go.
Юнит-тестирование — это метод тестирования отдельных компонентов или функций программы для проверки корректности их работы.
В Golang для юнит-тестирования используются встроенные пакеты, такие как testing.
Ключевые моменты:
Изоляция: Каждый тест проверяет только одну функцию или метод, что упрощает отладку.
Автоматизация: Юнит-тесты могут быть автоматически запущены при каждом изменении кода, что помогает обеспечить стабильность системы.
Документация: Тесты служат хорошей документацией кода, показывая, как функции должны работать и какие результаты ожидать.
Интеграционное тестирование — это метод тестирования, который проверяет взаимодействие между независимыми сервисами.В Golang интеграционное тестирование может включать:
- тестирование взаимодействий: проверка того, как различные части системы работают вместе, включая взаимодействие с базами данных, внешними API и другими сервисами;
- среды тестирования: использование тестовых или контейнерных окружений для имитации реальных условий работы.
Контрактное тестирование — это метод, который проверяет, что два или более сервисов (или модулей) могут взаимодействовать друг с другом, соблюдая заранее согласованные "контракты".
В контексте Golang это может включать:
- определение контрактов: описание ожидаемых входных и выходных данных между сервисами, что позволяет обеим сторонам работать независимо.
- тестирование контрактов: автоматизированное тестирование на соответствие контрактам, что помогает обнаруживать ошибки в API и предотвращать проблемы при развёртывании новых версий сервисов.
- поддержка совместимости: помогает обеспечить, что изменения в одном сервисе не сломают функциональность другого сервиса, что особенно важно в микросервисной архитектуре.
Инструмент для контрактного тестирования, который вы можете использовать - pact.
Тестовые окружения — это изолированные системы, в которых можно развёртывать приложения для тестирования перед запуском в продакшн. Эти окружения могут включать различные среды, такие как dev и staging.
Пример, как это делается https://habr.com/en/articles/717392/
Это метод развёртывания, при котором новая версия приложения сначала запускается для небольшой части пользователей, чтобы проверить её работоспособность и стабильность, прежде чем развернуть её для всех.
Это процесс, при котором система автоматически возвращается к предыдущей стабильной версии приложения, если новая версия вызывает проблемы или сбои.
Как это делается? Например, у каждого сервиса есть обязательная метрика вида http_5xx_count, которая показывает количество ошибок. Система выкатки (вероятно кастомизированная, свой велосипед в рамках компании) знает какой сервис выкатывается, знает какую метрику проверять и в случае, когда фон ошибок после начала переключения части трафика на новую версию сервиса превышает некий предел, автоматически отменяет выкатку.
Важно сохранять обратную совместимость в схемах данных между версиями сервиса. Например, добавили новую колонку в таблицу, но версия X сервиса не знает про неё, а версия X+1 знает. Логика работы сервиса не должна из-за этого ломаться.
Также сложные создания индексов важно делать НЕ через выкатку сервиса, а отдельным процессом. К сожалению, миграции вида create index concurrently в PostgreSQL могут занимать часы на больших базах и важно сначала создать индекс, а потом выкатить версию сервиса с миграцией, в которой сказано буквально create index if not exists (обратите внимание, без concurrently), потому что миграции должны отображать развитие схемы БД в любом случае. На заметку: create index без concurrently заблокирует вам БД, пока не создаст индекс.
Подробнее https://habr.com/en/articles/540500/ и https://habr.com/en/articles/736458/ — как работать с большими БД без даунтайма.
Это процесс, при котором входящий трафик копируется и отправляется на новую версию приложения без вмешательства в обработку трафика пользователями.
Базово, например, в nginx, реализуется так. В реальных системах всё сложнее, но надеюсь, вы уловили суть. Главное чтобы зеркалирование трафика не влияло на основной трафик.
location / {
mirror /mirror;
proxy_pass http://backend;
}
location = /mirror {
internal;
proxy_pass http://test_backend$request_uri;
}
Даже в случае полной недоступности test_backend в примере основной трафик не должен испытывать проблем.
А если у вас нет инфраструктуры под это и есть задача потестировать реальный трафик на копии сервиса с изменениями (надеемся, что вы и БД отдельную поставите под копию сервиса), то можно и сделать велосипед
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Forward the request to the original service
resp, err := http.Get(originalServiceURL + r.URL.String())
if err != nil {
http.Error(w, "Error reaching original service", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// Copy the original response back to the client
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
http.Error(w, "Error reading response", http.StatusInternalServerError)
return
}
w.WriteHeader(resp.StatusCode)
w.Write(body)
// Mirror the request to the new service in a separate goroutine
go mirrorTraffic(r, newServiceURL)
})
func mirrorTraffic(r *http.Request, newServiceURL string) {
// Create a new request to mirror the traffic
newReq, err := http.NewRequest(r.Method, newServiceURL+r.URL.String(), r.Body)
if err != nil {
log.Printf("Failed to create new request: %v", err)
return
}
// Copy headers
newReq.Header = r.Header
// Send the mirrored request
client := &http.Client{}
resp, err := client.Do(newReq)
if err != nil {
log.Printf("Failed to mirror request: %v", err)
return
}
defer resp.Body.Close()
// Optionally, you can log the response from the new service
log.Printf("Mirrored request to %s, response status: %s", newServiceURL, resp.Status)
}
Пример упрощен. Здесь нет таймаутов для отправки запросов в mirror, но основная суть должна быть понятна — создаем горутину, которая в идеальном мире никак не влияет на основной запрос, но позволяет отправить копию запроса на зеркало для теста нагрузки или теста новой логики.
Позволяют включать или отключать определенные функции приложения без необходимости деплоя. Это помогает в управлении функциональностью и тестировании новых функций на живых пользователях.
Как это может выглядеть? Просто запускается фоновая горутина, которая периодически ходит во внешнюю систему, где хранятся текущие значения флагов. Таким образом можно на лету менять их значения, не перегружая запросами систему хранения флагов, как если бы мы ходили в неё на каждый запрос в наш сервис.
type FeatureFlags struct {
Flags map[string]bool `json:"flags"`
}
type Config struct {
FeatureFlags *FeatureFlags
mu sync.RWMutex
}
func (c *Config) UpdateFeatureFlags(url string) {
for {
flags, err := fetch(url)
if err != nil {
time.Sleep(10 * time.Second)
continue
}
c.mu.Lock()
c.FeatureFlags = flags
c.mu.Unlock()
time.Sleep(1 * time.Minute)
}
}
func (c *Config) IsFeatureEnabled(feature string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
if c.FeatureFlags == nil {
return false // or handle default case
}
return c.FeatureFlags.Flags[feature]
}
func main() {
config := &Config{
FeatureFlags: &FeatureFlags{
Flags: make(map[string]bool)
},
}
go config.UpdateFeatureFlags(url)
}
Это принцип проектирования, позволяющий новым версиям программного обеспечения работать с данными и интерфейсами старых версий. Это важно для минимизации сбоев при обновлении.
В следующей и заключительной части разбираем наблюдаемость, архитектуру и антипаттерны.
Повысить навыки разработки на Go и собрать полноценный сервис для портфолио можно на курсе «Golang-разработчик».