Как внедрение CI/CD превратилось в эпопею с рефакторингом

2c6d5abc0ae50a74cc40aae0925fb3ba.png

Всем привет меня зовут Роман. Я CTO компании LikeSoft и сегодня я хочу поделиться кейсом как я переводил LMS платформу на облако.

О проекте 

LMS платформа для обучения детей. Существующая архитектура была на базе PHP/ Laravel, MySQL, код крутиился на VPS сервере и все деплоилось по команде git pull. 

Основная задача

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

Всё началось с того, что мы просто хотели автоматизировать деплой нашего проекта. Поставить и GitLab CI, настроить пайплайн — пара недель работы, думал я. Но тут возникли следующие проблемы.

Проблемы с базой данных

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

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

Анализ и планирование

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

Поняв это, мы определили, что оптимальным решением будет внедрение архитектуры «мастер-слейв» (primary-replica). В такой конфигурации все запросы на запись отправляются в мастер-сервер (primary), а запросы на чтение — в реплики (слейвы), которые периодически синхронизируются с мастером.

Что сделали

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

  2. Чтение: Запросы на получение данных направлялись к репликам.

  3. Запись: Операции по обновлению и добавлению данных направлялись к мастеру.

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

  5. Балансировка нагрузки: Для распределения нагрузки между слейв-серверами мы подключили балансировщик запросов на чтение. Это позволило равномерно распределять запросы между репликами, что существенно снизило нагрузку на каждый отдельный сервер и повысило отказоустойчивость системы.

  6. Мониторинг репликации: Одним из рисков работы с репликами является отставание по времени синхронизации (replication lag). Мы внедрили мониторинг задержек репликации с помощью Prometheus и настроили алерты на превышение допустимого уровня задержек, что позволило нам оперативно реагировать на возможные сбои и дисбаланс в репликации.

Проблемы и решения

При внедрении разделения запросов на чтение и запись мы столкнулись с несколькими челенджами:

  1. Консистентность данных: Одной из основных проблем было то, что иногда реплики могли немного отставать от мастера по данным. Это могло приводить к тому, что пользователь мог увидеть старые данные сразу после внесения изменений. Для решения этой проблемы мы внедрили стратегию использования мастера для критических операций (например, когда нужно сразу же показать пользователю обновленные данные) и разработали систему проверки состояния репликации перед выполнением некоторых запросов.

  2. Нагрузка на мастер-сервер: Хотя большая часть запросов была перенесена на слейвы, на мастер всё равно поступали операции записи, особенно при активном обновлении данных пользователей. Для решения этой проблемы мы расширили мощность мастера, а также начали оптимизировать SQL-запросы для более эффективной работы с базой данных.

  3. Синхронизация данных: Мы столкнулись с ситуациями, когда синхронизация между мастером и слейвами могла замедляться из-за увеличения объема данных. Для минимизации этого эффекта мы добавили несколько механизмов оптимизации репликации, таких как сжатие данных при передаче и уменьшение количества временных таблиц.

Итоги

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

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

Проблемы с воркерами

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

Практическая проблема:

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

Решение:

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

class ReportGenerationJob implements ShouldQueue
{
    public function handle()
    {
        $dataChunks = $this->getDataInChunks();

        foreach ($dataChunks as $chunk) {
            // Обработка каждого куска данных
            $this->processChunk($chunk);
        }
    }

    private function getDataInChunks()
    {
        // Логика для разделения данных на части
        return ReportData::chunk(100);
    }

    private function processChunk($chunk)
    {
        // Обрабатываем каждый кусок данных
    }
}

Выводы:

Если воркеры потребляют слишком много ресурсов, подумайте о разделении задач на более мелкие подзадачи. Используйте механизмы очередей (например, Redis или RabbitMQ), чтобы отправлять мелкие задачи в очередь и обрабатывать их параллельно несколькими воркерами. Убедитесь, что ваши воркеры могут масштабироваться по горизонтали, чтобы обеспечить максимальное использование ресурсов кластера.

Разделение фронтенда и бэкенда

Изначально наш фронтенд был реализован с использованием шаблонов Blade с вкраплениями Vue.js. Это ограничивало возможности для параллельного развития фронтенда и бэкенда, усложняло поддержку кода и делало его менее гибким.

Фронтенд и бэкенд тесно связаны друг с другом, что замедляет процесс разработки. Каждое изменение в бизнес-логике требует правок как в фронтенде, так и в бэкенде. Это также затрудняет тестирование и деплой компонентов отдельно.

Решение:

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

Практическая рекомендация:

Если вы чувствуете, что тесная интеграция фронтенда и бэкенда замедляет разработку, рассмотрите возможность их разделения. Постройте API, который позволит фронтенду и бэкенду работать независимо. Это облегчит разработку, тестирование и масштабирование каждого компонента отдельно. Используйте стандарты REST или GraphQL в зависимости от требований к проекту.

Настройка kubernetes

Теперь мы подошли к самому «интересному» — настройке Kubernetes. 

Первым шагом была настройка манифестов для деплоя:

apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: app
image: my-app:latest
ports:
- containerPort: 80
resources:
limits:
memory: "512Mi"
cpu: "500m"
requests:
memory: "256Mi"
cpu: "250m"

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

Причина заключалась в том, что не были правильно настроены лимиты памяти и CPU. Наши воркеры для обработки отчётов, начали пожирать так много памяти, что поды просто не выдерживали и крашились. Это приводило к деградации подов, и Kubernetes, по своей доброте, бесконечно их перезапускал. А это, в свою очередь, порождало новые проблемы с потреблением ресурсов.

resources:
limits:
memory: "1024Mi"
cpu: "1"
requests:
memory: "512Mi"
cpu: "500m"

В итоге, мы перенастроили политики масштабирования 

apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
name: app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: my-app
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 80

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

Мониторинг и отладка

Так же критически важно настроить правильный мониторинг. Для этого использовали Prometheus, ELK и Sentry.

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

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

Решение:

Мы внедрили мониторинг на базе Prometheus для сбора метрик, ELK стека для централизованного логирования и Sentry для отслеживания ошибок в реальном времени. Эти инструменты позволили нам не только мониторить производительность системы, но и оперативно реагировать на любые отклонения от нормы.

  1. Prometheus собирал метрики с воркеров, нод Kubernetes и базы данных, помогая отслеживать загрузку ресурсов, задержки и репликацию БД.

  2. ELK стек позволил централизованно собирать логи с различных сервисов, что упростило анализ проблем.

  3. Sentry позволил отслеживать ошибки на уровне кода и сообщать разработчикам о них в режиме реального времени.

Выводы

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

Мораль этой истории? Если что-то кажется простым, готовьтесь к самому сложному. В программировании всё работает не так, как в книгах: один шаг вперёд может потянуть за собой кучу незапланированных действий. Но в конце концов, результат стоил всех этих мучений — теперь наш CI/CD работает как часы.

© Habrahabr.ru